diff --git a/package-lock.json b/package-lock.json
index 63b4dab..0a32cc9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1636,6 +1636,14 @@
"universal-cookie": "4.0.4"
},
"dependencies": {
+ "axios": {
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
+ "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+ "requires": {
+ "follow-redirects": "^1.10.0"
+ }
+ },
"history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
@@ -5053,11 +5061,11 @@
"integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA=="
},
"axios": {
- "version": "0.21.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
- "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+ "version": "0.21.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
+ "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
- "follow-redirects": "^1.10.0"
+ "follow-redirects": "^1.14.0"
}
},
"axios-cache-adapter": {
diff --git a/package.json b/package.json
index d934f62..d3c5908 100755
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"@redux-beacon/segment": "^1.1.0",
"@reduxjs/toolkit": "^1.6.1",
"@testing-library/user-event": "^13.5.0",
+ "axios": "^0.21.4",
"classnames": "^2.3.1",
"core-js": "3.16.2",
"dompurify": "^2.3.1",
@@ -75,7 +76,6 @@
"@edx/frontend-build": "8.0.4",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0",
- "axios": "0.21.1",
"axios-mock-adapter": "^1.20.0",
"codecov": "^3.8.3",
"enzyme-adapter-react-16": "^1.15.6",
diff --git a/src/components/FilePreview/Banners/ErrorBanner.jsx b/src/components/FilePreview/Banners/ErrorBanner.jsx
new file mode 100644
index 0000000..4be244c
--- /dev/null
+++ b/src/components/FilePreview/Banners/ErrorBanner.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Alert, Button } from '@edx/paragon';
+import { Info } from '@edx/paragon/icons';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+const messageShape = PropTypes.shape({
+ id: PropTypes.string,
+ defaultMessage: PropTypes.string,
+});
+
+export const ErrorBanner = ({ actions, headingMessage, children }) => {
+ const actionButtons = actions.map(action => (
+
+ ));
+ return (
+
Abitary Child
; + + const props = { + actions: [ + { + id: 'action1', + onClick: jest.fn().mockName('action1.onClick'), + message: messages.retryButton, + }, + { + id: 'action2', + onClick: jest.fn().mockName('action2.onClick'), + message: messages.retryButton, + }, + ], + headingMessage: messages.unknownError, + children, + }; + + let el; + beforeEach(() => { + el = shallow(+ Abitary Child +
+
`;
diff --git a/src/components/FilePreview/__snapshots__/PDFRenderer.test.jsx.snap b/src/components/FilePreview/BaseRenderers/__snapshots__/PDFRenderer.test.jsx.snap
similarity index 100%
rename from src/components/FilePreview/__snapshots__/PDFRenderer.test.jsx.snap
rename to src/components/FilePreview/BaseRenderers/__snapshots__/PDFRenderer.test.jsx.snap
diff --git a/src/components/FilePreview/__snapshots__/TXTRenderer.test.jsx.snap b/src/components/FilePreview/BaseRenderers/__snapshots__/TXTRenderer.test.jsx.snap
similarity index 69%
rename from src/components/FilePreview/__snapshots__/TXTRenderer.test.jsx.snap
rename to src/components/FilePreview/BaseRenderers/__snapshots__/TXTRenderer.test.jsx.snap
index 91d905a..330a63c 100644
--- a/src/components/FilePreview/__snapshots__/TXTRenderer.test.jsx.snap
+++ b/src/components/FilePreview/BaseRenderers/__snapshots__/TXTRenderer.test.jsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Image Renderer Component snapshot 1`] = `
+exports[`TXT Renderer Component snapshot 1`] = `
diff --git a/src/components/FilePreview/BaseRenderers/index.jsx b/src/components/FilePreview/BaseRenderers/index.jsx
new file mode 100644
index 0000000..e390639
--- /dev/null
+++ b/src/components/FilePreview/BaseRenderers/index.jsx
@@ -0,0 +1,3 @@
+export { default as ImageRenderer } from './ImageRenderer';
+export { default as PDFRenderer } from './PDFRenderer';
+export { default as TXTRenderer } from './TXTRenderer';
diff --git a/src/components/FilePreview/FileRenderer.jsx b/src/components/FilePreview/FileRenderer.jsx
new file mode 100644
index 0000000..fde6592
--- /dev/null
+++ b/src/components/FilePreview/FileRenderer.jsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { StrictDict } from 'utils';
+import { FileTypes } from 'data/constants/files';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import {
+ PDFRenderer,
+ ImageRenderer,
+ TXTRenderer,
+} from 'components/FilePreview/BaseRenderers';
+import FileCard from './FileCard';
+
+import { ErrorBanner, LoadingBanner } from './Banners';
+
+import messages from './messages';
+
+export const RENDERERS = StrictDict({
+ [FileTypes.pdf]: PDFRenderer,
+ [FileTypes.jpg]: ImageRenderer,
+ [FileTypes.jpeg]: ImageRenderer,
+ [FileTypes.bmp]: ImageRenderer,
+ [FileTypes.png]: ImageRenderer,
+ [FileTypes.txt]: TXTRenderer,
+});
+
+export const ERROR_STATUSES = {
+ 404: {
+ headingMessage: messages.fileNotFoundError,
+ children: ,
+ },
+ 500: {
+ headingMessage: messages.unknownError,
+ children: ,
+ },
+};
+
+export const SUPPORTED_TYPES = Object.keys(RENDERERS);
+
+export const getFileType = (fileName) => fileName.split('.').pop()?.toLowerCase();
+export const isSupported = (file) => SUPPORTED_TYPES.includes(getFileType(file.name));
+
+/**
+ *
+ */
+export class FileRenderer extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ errorStatus: null,
+ isLoading: true,
+ };
+
+ this.onError = this.onError.bind(this);
+ this.onSuccess = this.onSuccess.bind(this);
+ this.resetState = this.resetState.bind(this);
+ }
+
+ onError(status) {
+ this.setState({
+ errorStatus: status,
+ isLoading: false,
+ });
+ }
+
+ onSuccess() {
+ this.setState({
+ errorStatus: null,
+ isLoading: false,
+ });
+ }
+
+ get error() {
+ const status = this.state.errorStatus;
+ return {
+ ...ERROR_STATUSES[status] || ERROR_STATUSES[500],
+ actions: [
+ {
+ id: 'retry',
+ onClick: this.resetState,
+ message: messages.retryButton,
+ },
+ ],
+ };
+ }
+
+ resetState = () => {
+ this.setState({
+ errorStatus: null,
+ isLoading: true,
+ });
+ };
+
+ render() {
+ const { file } = this.props;
+ const Renderer = RENDERERS[getFileType(file.name)];
+ return (
+
+ {this.state.isLoading && }
+ {this.state.errorStatus ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+}
+
+FileRenderer.defaultProps = {};
+FileRenderer.propTypes = {
+ file: PropTypes.shape({
+ name: PropTypes.string,
+ downloadUrl: PropTypes.string,
+ }).isRequired,
+};
+
+export default FileRenderer;
diff --git a/src/components/FilePreview/FileRenderer.test.jsx b/src/components/FilePreview/FileRenderer.test.jsx
new file mode 100644
index 0000000..d240d0c
--- /dev/null
+++ b/src/components/FilePreview/FileRenderer.test.jsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { FileTypes } from 'data/constants/files';
+import {
+ ImageRenderer,
+ PDFRenderer,
+ TXTRenderer,
+} from 'components/FilePreview/BaseRenderers';
+import {
+ FileRenderer,
+ getFileType,
+ ERROR_STATUSES,
+ RENDERERS,
+} from './FileRenderer';
+
+jest.mock('./FileCard', () => 'FileCard');
+
+jest.mock('components/FilePreview/BaseRenderers', () => ({
+ PDFRenderer: () => 'PDFRenderer',
+ ImageRenderer: () => 'ImageRenderer',
+ TXTRenderer: () => 'TXTRenderer',
+}));
+
+jest.mock('./Banners', () => ({
+ ErrorBanner: () => 'ErrorBanner',
+ LoadingBanner: () => 'LoadingBanner',
+}));
+
+describe('FileRenderer', () => {
+ describe('component', () => {
+ const supportedTypes = Object.keys(RENDERERS);
+ const files = [
+ ...supportedTypes.map((fileType, index) => ({
+ name: `fake_file_${index}.${fileType}`,
+ description: `file description ${index}`,
+ downloadUrl: `/url-path/fake_file_${index}.${fileType}`,
+ })),
+ ];
+
+ const els = files.map((file) => {
+ const el = shallow( );
+ el.instance().onError = jest.fn().mockName('this.props.onError');
+ el.instance().onSuccess = jest.fn().mockName('this.props.onSuccess');
+ return el;
+ });
+
+ describe('snapshot', () => {
+ els.forEach((el) => {
+ const file = el.prop('file');
+ const fileType = getFileType(file.name);
+
+ test(`successful rendering ${fileType}`, () => {
+ el.setState({ isLoading: false });
+ expect(el.instance().render()).toMatchSnapshot();
+ });
+ });
+
+ Object.keys(ERROR_STATUSES).forEach((status) => {
+ test(`has error ${status}`, () => {
+ const el = shallow( );
+ el.instance().setState({
+ errorStatus: status,
+ isLoading: false,
+ });
+ el.instance().resetState = jest.fn().mockName('this.resetState');
+ expect(el.instance().render()).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('component', () => {
+ describe('uses the correct renderers', () => {
+ const checkFile = (index, expectedRenderer) => {
+ const file = files[index];
+ const el = shallow( );
+ const renderer = el.find(expectedRenderer);
+ const { url, fileName } = renderer.props();
+
+ expect(renderer).toBeDefined();
+ expect(url).toEqual(file.downloadUrl);
+ expect(fileName).toEqual(file.name);
+ };
+ /**
+ * The manual process for this is prefer. I want to be more explicit
+ * of which file correspond to which renderer. If I use RENDERERS dicts,
+ * this wouldn't be a test.
+ */
+
+ test(FileTypes.pdf, () => checkFile(0, PDFRenderer));
+ test(FileTypes.jpg, () => checkFile(1, ImageRenderer));
+ test(FileTypes.jpeg, () => checkFile(2, ImageRenderer));
+ test(FileTypes.bmp, () => checkFile(3, ImageRenderer));
+ test(FileTypes.png, () => checkFile(4, ImageRenderer));
+ test(FileTypes.txt, () => checkFile(5, TXTRenderer));
+ });
+
+ test('getter for error', () => {
+ const el = els[0];
+ Object.keys(ERROR_STATUSES).forEach((status) => {
+ el.setState({
+ isLoading: false,
+ errorStatus: status,
+ });
+ const { actions, ...expectedError } = el.instance().error;
+ expect(ERROR_STATUSES[status]).toEqual(expectedError);
+ });
+ });
+ });
+
+ describe('renderer constraints', () => {
+ els.forEach((el) => {
+ const file = el.prop('file');
+ const fileType = getFileType(file.name);
+ const RendererComponent = RENDERERS[fileType];
+ const ActualRendererComponent = jest.requireActual(
+ 'components/FilePreview/BaseRenderers',
+ )[RendererComponent.name];
+
+ test(`${fileType} renderer must have onError and onSuccess props`, () => {
+ /* eslint-disable react/forbid-foreign-prop-types */
+ expect(ActualRendererComponent.propTypes.onError).toBeDefined();
+ expect(ActualRendererComponent.propTypes.onSuccess).toBeDefined();
+ });
+ });
+ });
+ });
+});
diff --git a/src/components/FilePreview/ImageRenderer.jsx b/src/components/FilePreview/ImageRenderer.jsx
deleted file mode 100644
index 75c5639..0000000
--- a/src/components/FilePreview/ImageRenderer.jsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const ImageRenderer = ({ url, fileName }) => (
);
-
-ImageRenderer.defaultProps = {
- fileName: '',
-};
-
-ImageRenderer.propTypes = {
- url: PropTypes.string.isRequired,
- fileName: PropTypes.string,
-};
-
-export default ImageRenderer;
diff --git a/src/components/FilePreview/__snapshots__/FileRenderer.test.jsx.snap b/src/components/FilePreview/__snapshots__/FileRenderer.test.jsx.snap
new file mode 100644
index 0000000..2d2659a
--- /dev/null
+++ b/src/components/FilePreview/__snapshots__/FileRenderer.test.jsx.snap
@@ -0,0 +1,197 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FileRenderer component snapshot has error 404 1`] = `
+
+
+
+
+
+`;
+
+exports[`FileRenderer component snapshot has error 500 1`] = `
+
+
+
+
+
+`;
+
+exports[`FileRenderer component snapshot successful rendering bmp 1`] = `
+
+
+
+`;
+
+exports[`FileRenderer component snapshot successful rendering jpeg 1`] = `
+
+
+
+`;
+
+exports[`FileRenderer component snapshot successful rendering jpg 1`] = `
+
+
+
+`;
+
+exports[`FileRenderer component snapshot successful rendering pdf 1`] = `
+
+
+
+`;
+
+exports[`FileRenderer component snapshot successful rendering png 1`] = `
+
+
+
+`;
+
+exports[`FileRenderer component snapshot successful rendering txt 1`] = `
+
+
+
+`;
diff --git a/src/components/FilePreview/index.jsx b/src/components/FilePreview/index.jsx
index 44dbaba..2e03acc 100644
--- a/src/components/FilePreview/index.jsx
+++ b/src/components/FilePreview/index.jsx
@@ -1,4 +1 @@
-export { default as FileCard } from './FileCard';
-export { default as ImageRenderer } from './ImageRenderer';
-export { default as PDFRenderer } from './PDFRenderer';
-export { default as TXTRenderer } from './TXTRenderer';
+export { default as FileRenderer, isSupported } from './FileRenderer';
diff --git a/src/components/FilePreview/messages.js b/src/components/FilePreview/messages.js
index 86e5faf..573a546 100644
--- a/src/components/FilePreview/messages.js
+++ b/src/components/FilePreview/messages.js
@@ -6,6 +6,21 @@ const messages = defineMessages({
defaultMessage: 'File info',
description: 'Popover trigger button text for file preview card',
},
+ retryButton: {
+ id: 'ora-grading.ResponseDisplay.FileRenderer.retryButton',
+ defaultMessage: 'Retry',
+ description: 'Retry button for error in file renderer',
+ },
+ fileNotFoundError: {
+ id: 'ora-grading.ResponseDisplay.FileRenderer.fileNotFound',
+ defaultMessage: 'File not found',
+ description: 'File not found error message',
+ },
+ unknownError: {
+ id: 'ora-grading.ResponseDisplay.FileRenderer.unknownError',
+ defaultMessage: 'Unknown errors',
+ description: 'Unknown errors message',
+ },
});
export default messages;
diff --git a/src/containers/ResponseDisplay/PreviewDisplay.jsx b/src/containers/ResponseDisplay/PreviewDisplay.jsx
index bda1dbe..a0bb3e1 100644
--- a/src/containers/ResponseDisplay/PreviewDisplay.jsx
+++ b/src/containers/ResponseDisplay/PreviewDisplay.jsx
@@ -1,70 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { StrictDict } from 'utils';
-import { FileTypes } from 'data/constants/files';
-
-import {
- FileCard, PDFRenderer, ImageRenderer, TXTRenderer,
-} from 'components/FilePreview';
+import { FileRenderer, isSupported } from 'components/FilePreview';
/**
*
*/
-export class PreviewDisplay extends React.Component {
- static RENDERERS = StrictDict({
- [FileTypes.pdf]: PDFRenderer,
- [FileTypes.jpg]: ImageRenderer,
- [FileTypes.jpeg]: ImageRenderer,
- [FileTypes.bmp]: ImageRenderer,
- [FileTypes.png]: ImageRenderer,
- [FileTypes.txt]: TXTRenderer,
- });
-
- static SUPPORTED_TYPES = Object.keys(PreviewDisplay.RENDERERS);
-
- constructor(props) {
- super(props);
- this.isSupported = this.isSupported.bind(this);
- this.fileType = this.fileType.bind(this);
- }
-
- get supportedFiles() {
- return this.props.files.filter(this.isSupported);
- }
-
- isSupported(file) {
- return PreviewDisplay.SUPPORTED_TYPES.includes(this.fileType(file.name));
- }
-
- fileType(fileName) {
- return fileName.split('.').pop();
- }
-
- render() {
- return (
-
- {this.supportedFiles.map((file) => {
- const Renderer = PreviewDisplay.RENDERERS[this.fileType(file.name)];
- return (
-
-
-
- );
- })}
-
- );
- }
-}
+export const PreviewDisplay = ({ files }) => (
+
+ {files.filter(isSupported).map((file) => (
+
+ ))}
+
+);
PreviewDisplay.defaultProps = {
files: [],
};
PreviewDisplay.propTypes = {
- files: PropTypes.arrayOf(PropTypes.shape({
- name: PropTypes.string,
- downloadUrl: PropTypes.string,
- })),
+ files: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string,
+ downloadUrl: PropTypes.string,
+ }),
+ ),
};
export default PreviewDisplay;
diff --git a/src/containers/ResponseDisplay/PreviewDisplay.test.jsx b/src/containers/ResponseDisplay/PreviewDisplay.test.jsx
index 2a4831e..b567ffd 100644
--- a/src/containers/ResponseDisplay/PreviewDisplay.test.jsx
+++ b/src/containers/ResponseDisplay/PreviewDisplay.test.jsx
@@ -2,24 +2,17 @@ import React from 'react';
import { shallow } from 'enzyme';
import { FileTypes } from 'data/constants/files';
-import { FileCard, ImageRenderer, PDFRenderer } from 'components/FilePreview';
+import { FileRenderer } from 'components/FilePreview';
import { PreviewDisplay } from './PreviewDisplay';
jest.mock('components/FilePreview', () => ({
- FileCard: () => 'FileCard',
- PDFRenderer: () => 'PDFRenderer',
- ImageRenderer: () => 'ImageRenderer',
+ FileRenderer: () => 'FileRenderer',
+ isSupported: jest.requireActual('components/FilePreview').isSupported,
}));
describe('PreviewDisplay', () => {
describe('component', () => {
- const supportedTypes = [
- FileTypes.pdf,
- FileTypes.jpg,
- FileTypes.jpeg,
- FileTypes.bmp,
- FileTypes.png,
- ];
+ const supportedTypes = Object.values(FileTypes);
const props = {
files: [
...supportedTypes.map((fileType, index) => ({
@@ -40,44 +33,25 @@ describe('PreviewDisplay', () => {
});
describe('snapshot', () => {
- test('files does not exist', () => {
+ test('files render with props', () => {
expect(el).toMatchSnapshot();
});
- test('files exited for props', () => {
+ test('files does not exist', () => {
el.setProps({ files: [] });
- expect(el.instance().render()).toMatchSnapshot();
+ expect(el).toMatchSnapshot();
});
});
describe('component', () => {
test('only renders compatible files', () => {
- const cards = el.find(FileCard);
+ const cards = el.find(FileRenderer);
expect(cards.length).toEqual(supportedTypes.length);
- [0, 1, 2, 3, 4].forEach(index => {
+ cards.forEach((_, index) => {
expect(
cards.at(index).prop('file'),
).toEqual(props.files[index]);
});
});
- describe('uses the correct renderers', () => {
- const loadRenderer = (index) => (
- el.find(FileCard).at(index).children().at(0)
- );
- const checkFile = (index, expectedRenderer) => {
- const file = props.files[index];
- const renderer = loadRenderer(index);
- expect(renderer.type()).toEqual(expectedRenderer);
- expect(renderer.props()).toEqual({
- url: file.downloadUrl,
- fileName: file.name,
- });
- };
- test(FileTypes.pdf, () => checkFile(0, PDFRenderer));
- test(FileTypes.jpg, () => checkFile(1, ImageRenderer));
- test(FileTypes.jpeg, () => checkFile(2, ImageRenderer));
- test(FileTypes.bmp, () => checkFile(3, ImageRenderer));
- test(FileTypes.png, () => checkFile(4, ImageRenderer));
- });
});
});
});
diff --git a/src/containers/ResponseDisplay/__snapshots__/PreviewDisplay.test.jsx.snap b/src/containers/ResponseDisplay/__snapshots__/PreviewDisplay.test.jsx.snap
index 72138fd..dbad76f 100644
--- a/src/containers/ResponseDisplay/__snapshots__/PreviewDisplay.test.jsx.snap
+++ b/src/containers/ResponseDisplay/__snapshots__/PreviewDisplay.test.jsx.snap
@@ -1,10 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PreviewDisplay component snapshot files does not exist 1`] = `
+
+`;
+
+exports[`PreviewDisplay component snapshot files render with props 1`] = `
-
-
-
-
+
-
-
-
+
-
-
-
+
-
-
-
+
-
-
+ key="fake_file_4.bmp"
+ />
+
`;
-
-exports[`PreviewDisplay component snapshot files exited for props 1`] = `
-
-`;
diff --git a/src/setupTest.js b/src/setupTest.js
index 89cf949..cb4bc05 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -74,6 +74,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Row: 'Row',
StatefulButton: 'StatefulButton',
TextFilter: 'TextFilter',
+ Spinner: 'Spinner',
}));
jest.mock('@fortawesome/react-fontawesome', () => ({