diff --git a/src/editors/containers/TextEditor/components/SelectImageModal/index.jsx b/src/editors/containers/TextEditor/components/SelectImageModal/index.jsx index b1033e243..b9bf9faa4 100644 --- a/src/editors/containers/TextEditor/components/SelectImageModal/index.jsx +++ b/src/editors/containers/TextEditor/components/SelectImageModal/index.jsx @@ -106,7 +106,7 @@ SelectImageModal.propTypes = { }; export const mapStateToProps = (state) => ({ - inputIsLoading: selectors.requests.isPending(state, { requestKey: RequestKeys.uploadImage }), + inputIsLoading: selectors.requests.isPending(state, { requestKey: RequestKeys.uploadAsset }), }); export const mapDispatchToProps = {}; diff --git a/src/editors/containers/TextEditor/components/SelectImageModal/index.test.jsx b/src/editors/containers/TextEditor/components/SelectImageModal/index.test.jsx index 1780fc255..0738b6295 100644 --- a/src/editors/containers/TextEditor/components/SelectImageModal/index.test.jsx +++ b/src/editors/containers/TextEditor/components/SelectImageModal/index.test.jsx @@ -95,9 +95,9 @@ describe('SelectImageModal', () => { }); describe('mapStateToProps', () => { const testState = { some: 'testState' }; - test('loads inputIsLoading from requests.isPending selector for uploadImage request', () => { + test('loads inputIsLoading from requests.isPending selector for uploadAsset request', () => { expect(mapStateToProps(testState).inputIsLoading).toEqual( - selectors.requests.isPending(testState, { requestKey: RequestKeys.uploadImage }), + selectors.requests.isPending(testState, { requestKey: RequestKeys.uploadAsset }), ); }); }); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget.jsx deleted file mode 100644 index 0aca58d38..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { useDispatch } from 'react-redux'; -// import PropTypes from 'prop-types'; - -import hooks from './hooks'; -import CollapsibleFormWidget from './CollapsibleFormWidget'; - -/** - * Collapsible Form widget controlling video handouts - */ -export const HandoutWidget = () => { - const dispatch = useDispatch(); - const { handout } = hooks.widgetValues({ - dispatch, - fields: { [hooks.selectorKeys.handout]: hooks.genericWidget }, - }); - return ( - -

{handout.formValue}

-
- ); -}; - -export default HandoutWidget; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/__snapshots__/index.test.jsx.snap new file mode 100644 index 000000000..77d570038 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/__snapshots__/index.test.jsx.snap @@ -0,0 +1,149 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HandoutWidget snapshots snapshots: renders as expected with default props 1`] = ` + + + + + + + + + + + +`; + +exports[`HandoutWidget snapshots snapshots: renders as expected with handout 1`] = ` + + + + + + + + + + + + + + + + + + + + + + } + className="mt-1" + subtitle="sOMeUrl " + /> + + +`; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.jsx new file mode 100644 index 000000000..3dc7cc477 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { thunkActions } from '../../../../../../data/redux'; +import * as module from './hooks'; + +export const state = { + showSizeError: (args) => React.useState(args), +}; + +export const parseHandoutName = ({ handout }) => { + if (handout) { + const handoutName = handout.slice(handout.lastIndexOf('@') + 1); + return handoutName; + } + return 'None'; +}; + +export const checkValidFileSize = ({ + file, + onSizeFail, +}) => { + // Check if the file size is greater than 20 MB, upload size limit + if (file.size > 20000000) { + onSizeFail(); + return false; + } + return true; +}; + +export const fileInput = ({ fileSizeError }) => { + const dispatch = useDispatch(); + const ref = React.useRef(); + const click = () => ref.current.click(); + const addFile = (e) => { + const file = e.target.files[0]; + if (file && module.checkValidFileSize({ + file, + onSizeFail: () => { + fileSizeError.set(); + }, + })) { + dispatch(thunkActions.video.uploadHandout({ + file, + })); + } + }; + return { + click, + addFile, + ref, + }; +}; + +export const fileSizeError = () => { + const [showSizeError, setShowSizeError] = module.state.showSizeError(false); + return { + fileSizeError: { + show: showSizeError, + set: () => setShowSizeError(true), + dismiss: () => setShowSizeError(false), + }, + }; +}; + +export default { fileInput, fileSizeError, parseHandoutName }; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.test.jsx new file mode 100644 index 000000000..a733ff570 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/hooks.test.jsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { dispatch } from 'react-redux'; +import { thunkActions } from '../../../../../../data/redux'; +import { MockUseState } from '../../../../../../../testUtils'; +import { keyStore } from '../../../../../../utils'; +import * as hooks from './hooks'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useRef: jest.fn(val => ({ current: val })), + useEffect: jest.fn(), + useCallback: (cb, prereqs) => ({ cb, prereqs }), +})); + +jest.mock('react-redux', () => { + const dispatchFn = jest.fn(); + return { + ...jest.requireActual('react-redux'), + dispatch: dispatchFn, + useDispatch: jest.fn(() => dispatchFn), + }; +}); + +jest.mock('../../../../../../data/redux', () => ({ + thunkActions: { + video: { + uploadHandout: jest.fn(), + }, + }, +})); + +const state = new MockUseState(hooks); +const hookKeys = keyStore(hooks); +let hook; +const testValue = '@testVALUEVALIDhANdoUT'; +const selectedFileSuccess = { name: testValue, size: 20000 }; +describe('VideoEditorHandout hooks', () => { + describe('state hooks', () => { + state.testGetter(state.keys.showSizeError); + }); + + describe('parseHandoutName', () => { + test('it returns none when given null', () => { + expect(hooks.parseHandoutName({ handout: null })).toEqual('None'); + }); + test('it creates a list based on transcript object', () => { + expect(hooks.parseHandoutName({ handout: testValue })).toEqual('testVALUEVALIDhANdoUT'); + }); + }); + + describe('checkValidSize', () => { + const onSizeFail = jest.fn(); + it('returns false for file size', () => { + hook = hooks.checkValidFileSize({ file: { name: testValue, size: 20000001 }, onSizeFail }); + expect(onSizeFail).toHaveBeenCalled(); + expect(hook).toEqual(false); + }); + it('returns true for valid file size', () => { + hook = hooks.checkValidFileSize({ file: selectedFileSuccess, onSizeFail }); + expect(hook).toEqual(true); + }); + }); + describe('fileInput', () => { + const spies = {}; + const fileSizeError = { set: jest.fn() }; + beforeEach(() => { + jest.clearAllMocks(); + hook = hooks.fileInput({ fileSizeError }); + }); + it('returns a ref for the file input', () => { + expect(hook.ref).toEqual({ current: undefined }); + }); + test('click calls current.click on the ref', () => { + const click = jest.fn(); + React.useRef.mockReturnValueOnce({ current: { click } }); + hook = hooks.fileInput({ fileSizeError }); + hook.click(); + expect(click).toHaveBeenCalled(); + }); + describe('addFile', () => { + const eventSuccess = { target: { files: [{ selectedFileSuccess }] } }; + const eventFailure = { target: { files: [{ name: testValue, size: 20000001 }] } }; + it('image fails to upload if file size is greater than 2000000', () => { + const checkValidFileSize = false; + spies.checkValidFileSize = jest.spyOn(hooks, hookKeys.checkValidFileSize) + .mockReturnValueOnce(checkValidFileSize); + hook.addFile(eventFailure); + expect(spies.checkValidFileSize.mock.calls.length).toEqual(1); + expect(spies.checkValidFileSize).toHaveReturnedWith(false); + }); + it('dispatches updateField action with the first target file', () => { + const checkValidFileSize = true; + spies.checkValidFileSize = jest.spyOn(hooks, hookKeys.checkValidFileSize) + .mockReturnValueOnce(checkValidFileSize); + hook.addFile(eventSuccess); + expect(spies.checkValidFileSize.mock.calls.length).toEqual(1); + expect(spies.checkValidFileSize).toHaveReturnedWith(true); + expect(dispatch).toHaveBeenCalledWith( + thunkActions.video.uploadHandout({ + thumbnail: eventSuccess.target.files[0], + }), + ); + }); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx new file mode 100644 index 000000000..65c047173 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { + Button, + Stack, + Icon, + IconButton, + Card, + Dropdown, +} from '@edx/paragon'; +import { FileUpload, MoreHoriz } from '@edx/paragon/icons'; +import { + FormattedMessage, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; + +import { actions, selectors } from '../../../../../../data/redux'; +import * as hooks from './hooks'; +import messages from './messages'; + +import FileInput from '../../../../../../sharedComponents/FileInput'; +import { ErrorAlert } from '../../../../../../sharedComponents/ErrorAlerts/ErrorAlert'; +import { UploadErrorAlert } from '../../../../../../sharedComponents/ErrorAlerts/UploadErrorAlert'; +import CollapsibleFormWidget from '../CollapsibleFormWidget'; +import { ErrorContext } from '../../../../hooks'; + +/** + * Collapsible Form widget controlling video handouts + */ +export const HandoutWidget = ({ + // injected + intl, + // redux + handout, + getHandoutDownloadUrl, + updateField, +}) => { + const [error] = React.useContext(ErrorContext).handout; + const { fileSizeError } = hooks.fileSizeError(); + const fileInput = hooks.fileInput({ fileSizeError }); + const handoutName = hooks.parseHandoutName({ handout }); + const downloadLink = getHandoutDownloadUrl({ handout }); + + return ( + + + + + + + {handout ? ( + + + + + + + + + + + updateField({ handout: null })}> + + + + + )} + /> + + ) : ( + + + + + )} + + ); +}; + +HandoutWidget.propTypes = { + // injected + intl: intlShape.isRequired, + // redux + handout: PropTypes.shape({}).isRequired, + updateField: PropTypes.func.isRequired, + isUploadError: PropTypes.bool.isRequired, + getHandoutDownloadUrl: PropTypes.func.isRequired, +}; +export const mapStateToProps = (state) => ({ + handout: selectors.video.handout(state), + getHandoutDownloadUrl: selectors.video.getHandoutDownloadUrl(state), +}); + +export const mapDispatchToProps = (dispatch) => ({ + updateField: (payload) => dispatch(actions.video.updateField(payload)), +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(HandoutWidget)); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx new file mode 100644 index 000000000..68ee4d3bf --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { formatMessage } from '../../../../../../../testUtils'; +import { actions, selectors } from '../../../../../../data/redux'; +import { HandoutWidget, mapStateToProps, mapDispatchToProps } from '.'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(() => ({ handout: ['error.handout', jest.fn().mockName('error.setHandout')] })), +})); + +jest.mock('../../../../../../data/redux', () => ({ + actions: { + video: { + updateField: jest.fn().mockName('actions.video.updateField'), + }, + }, + selectors: { + video: { + getHandoutDownloadUrl: jest.fn(args => ({ getHandoutDownloadUrl: args })).mockName('selectors.video.getHandoutDownloadUrl'), + handout: jest.fn(state => ({ handout: state })), + }, + }, +})); + +describe('HandoutWidget', () => { + const props = { + subtitle: 'SuBTItle', + title: 'tiTLE', + intl: { formatMessage }, + handout: '', + getHandoutDownloadUrl: jest.fn().mockName('args.getHandoutDownloadUrl'), + updateField: jest.fn().mockName('args.updateField'), + }; + + describe('snapshots', () => { + test('snapshots: renders as expected with default props', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with handout', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + }); + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('handout from video.handout', () => { + expect( + mapStateToProps(testState).handout, + ).toEqual(selectors.video.handout(testState)); + }); + test('getHandoutDownloadUrl from video.getHandoutDownloadUrl', () => { + expect( + mapStateToProps(testState).getHandoutDownloadUrl, + ).toEqual(selectors.video.getHandoutDownloadUrl(testState)); + }); + }); + describe('mapDispatchToProps', () => { + const dispatch = jest.fn(); + test('updateField from actions.video.updateField', () => { + expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField)); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/messages.js new file mode 100644 index 000000000..8520ccb0f --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/messages.js @@ -0,0 +1,50 @@ +export const messages = { + titleLabel: { + id: 'authoring.videoeditor.handout.title.label', + defaultMessage: 'Handout', + description: 'Title for the handout widget', + }, + uploadButtonLabel: { + id: 'authoring.videoeditor.handout.upload.label', + defaultMessage: 'Upload Handout', + description: 'Label for upload button', + }, + addHandoutMessage: { + id: 'authoring.videoeditor.handout.upload.addHandoutMessage', + defaultMessage: `Add a handout to accompany this video. Learners can download + this file by clicking "Download Handout" below the video.`, + description: 'Message displayed when uploading a handout', + }, + uploadHandoutError: { + id: 'authoring.videoeditor.handout.error.uploadHandoutError', + defaultMessage: 'Failed to upload handout. Please try again.', + description: 'Message presented to user when handout fails to upload', + }, + fileSizeError: { + id: 'authoring.videoeditor.handout.error.fileSizeError', + defaultMessage: 'Handout files must be 20 MB or less. Please resize the file and try again.', + description: 'Message presented to user when handout file size is larger than 20 MB', + }, + handoutHelpMessage: { + id: 'authoring.videoeditor.handout.handoutHelpMessage', + defaultMessage: 'Learners can download this file by clicking "Download Handout" below the video.', + description: 'Message presented to user when a handout is present', + }, + deleteHandout: { + id: 'authoring.videoeditor.handout.deleteHandout', + defaultMessage: 'Delete', + description: 'Message Presented To user for action to delete handout', + }, + replaceHandout: { + id: 'authoring.videoeditor.handout.replaceHandout', + defaultMessage: 'Replace', + description: 'Message Presented To user for action to replace handout', + }, + downloadHandout: { + id: 'authoring.videoeditor.handout.downloadHandout', + defaultMessage: 'Download', + description: 'Message Presented To user for action to download handout', + }, +}; + +export default messages; diff --git a/src/editors/data/constants/requests.js b/src/editors/data/constants/requests.js index be0c129df..7b43fdc91 100644 --- a/src/editors/data/constants/requests.js +++ b/src/editors/data/constants/requests.js @@ -13,7 +13,7 @@ export const RequestKeys = StrictDict({ fetchStudioView: 'fetchStudioView', fetchUnit: 'fetchUnit', saveBlock: 'saveBlock', - uploadImage: 'uploadImage', + uploadAsset: 'uploadAsset', allowThumbnailUpload: 'allowThumbnailUpload', uploadThumbnail: 'uploadThumbnail', uploadTranscript: 'uploadTranscript', diff --git a/src/editors/data/redux/requests/reducer.js b/src/editors/data/redux/requests/reducer.js index 7d714ede7..84e79f10a 100644 --- a/src/editors/data/redux/requests/reducer.js +++ b/src/editors/data/redux/requests/reducer.js @@ -10,12 +10,11 @@ const initialState = { [RequestKeys.fetchStudioView]: { status: RequestStates.inactive }, [RequestKeys.saveBlock]: { status: RequestStates.inactive }, [RequestKeys.fetchImages]: { status: RequestStates.inactive }, - [RequestKeys.uploadImage]: { status: RequestStates.inactive }, + [RequestKeys.uploadAsset]: { status: RequestStates.inactive }, [RequestKeys.allowThumbnailUpload]: { status: RequestStates.inactive }, [RequestKeys.uploadThumbnail]: { status: RequestStates.inactive }, [RequestKeys.uploadTranscript]: { status: RequestStates.inactive }, [RequestKeys.deleteTranscript]: { status: RequestStates.inactive }, - }; // eslint-disable-next-line no-unused-vars diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index 4fabd3388..f6d45b895 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -59,8 +59,8 @@ export const saveBlock = ({ content, returnToUnit }) => (dispatch) => { }; export const uploadImage = ({ file, setSelection }) => (dispatch) => { - dispatch(requests.uploadImage({ - image: file, + dispatch(requests.uploadAsset({ + asset: file, onSuccess: (response) => setSelection(camelizeKeys(response.data.asset)), })); }; diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 263e49eb1..486825a10 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -7,7 +7,7 @@ jest.mock('./requests', () => ({ fetchUnit: (args) => ({ fetchUnit: args }), saveBlock: (args) => ({ saveBlock: args }), fetchImages: (args) => ({ fetchImages: args }), - uploadImage: (args) => ({ uploadImage: args }), + uploadAsset: (args) => ({ uploadAsset: args }), fetchStudioView: (args) => ({ fetchStudioView: args }), })); @@ -143,14 +143,14 @@ describe('app thunkActions', () => { thunkActions.uploadImage({ file: testValue, setSelection })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; }); - it('dispatches uploadImage action', () => { - expect(dispatchedAction.uploadImage).not.toBe(undefined); + it('dispatches uploadAsset action', () => { + expect(dispatchedAction.uploadAsset).not.toBe(undefined); }); test('passes file as image prop', () => { - expect(dispatchedAction.uploadImage.image).toEqual(testValue); + expect(dispatchedAction.uploadAsset.asset).toEqual(testValue); }); test('onSuccess: calls setSelection with camelized response.data.asset', () => { - dispatchedAction.uploadImage.onSuccess({ data: { asset: testValue } }); + dispatchedAction.uploadAsset.onSuccess({ data: { asset: testValue } }); expect(setSelection).toHaveBeenCalledWith(camelizeKeys(testValue)); }); }); diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js index 39be38647..c02316ba7 100644 --- a/src/editors/data/redux/thunkActions/requests.js +++ b/src/editors/data/redux/thunkActions/requests.js @@ -110,12 +110,12 @@ export const saveBlock = ({ content, ...rest }) => (dispatch, getState) => { ...rest, })); }; -export const uploadImage = ({ image, ...rest }) => (dispatch, getState) => { +export const uploadAsset = ({ asset, ...rest }) => (dispatch, getState) => { dispatch(module.networkRequest({ - requestKey: RequestKeys.uploadImage, - promise: api.uploadImage({ + requestKey: RequestKeys.uploadAsset, + promise: api.uploadAsset({ learningContextId: selectors.app.learningContextId(getState()), - image, + asset, studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), }), ...rest, @@ -193,7 +193,7 @@ export default StrictDict({ fetchStudioView, fetchUnit, saveBlock, - uploadImage, + uploadAsset, allowThumbnailUpload, uploadThumbnail, deleteTranscript, diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js index 58d2a48cd..4da286cbc 100644 --- a/src/editors/data/redux/thunkActions/requests.test.js +++ b/src/editors/data/redux/thunkActions/requests.test.js @@ -26,7 +26,7 @@ jest.mock('../../services/cms/api', () => ({ fetchByUnitId: ({ id, url }) => ({ id, url }), saveBlock: (args) => args, fetchImages: ({ id, url }) => ({ id, url }), - uploadImage: (args) => args, + uploadAsset: (args) => args, loadImages: jest.fn(), allowThumbnailUpload: jest.fn(), uploadThumbnail: jest.fn(), @@ -267,18 +267,18 @@ describe('requests thunkActions module', () => { }, }); }); - describe('uploadImage', () => { - const image = 'SoME iMage CoNtent As String'; + describe('uploadAsset', () => { + const asset = 'SoME iMage CoNtent As String'; testNetworkRequestAction({ - action: requests.uploadImage, - args: { image, ...fetchParams }, - expectedString: 'with uploadImage promise', + action: requests.uploadAsset, + args: { asset, ...fetchParams }, + expectedString: 'with uploadAsset promise', expectedData: { ...fetchParams, - requestKey: RequestKeys.uploadImage, - promise: api.uploadImage({ + requestKey: RequestKeys.uploadAsset, + promise: api.uploadAsset({ learningContextId: selectors.app.learningContextId(testState), - image, + asset, studioEndpointUrl: selectors.app.studioEndpointUrl(testState), }), }, diff --git a/src/editors/data/redux/thunkActions/video.js b/src/editors/data/redux/thunkActions/video.js index aa29440c2..8b034eea8 100644 --- a/src/editors/data/redux/thunkActions/video.js +++ b/src/editors/data/redux/thunkActions/video.js @@ -180,6 +180,18 @@ export const uploadThumbnail = ({ thumbnail }) => (dispatch, getState) => { })); }; +// Handout Thunks: + +export const uploadHandout = ({ file }) => (dispatch) => { + dispatch(requests.uploadAsset({ + asset: file, + onSuccess: (response) => { + const handout = response.data.asset.url; + dispatch(actions.video.updateField({ handout })); + }, + })); +}; + // Transcript Thunks: export const uploadTranscript = ({ language, filename, file }) => (dispatch, getState) => { @@ -256,4 +268,5 @@ export default { uploadTranscript, deleteTranscript, replaceTranscript, + uploadHandout, }; diff --git a/src/editors/data/redux/thunkActions/video.test.js b/src/editors/data/redux/thunkActions/video.test.js index 2c43047f2..dec480432 100644 --- a/src/editors/data/redux/thunkActions/video.test.js +++ b/src/editors/data/redux/thunkActions/video.test.js @@ -17,6 +17,7 @@ jest.mock('..', () => ({ }, })); jest.mock('./requests', () => ({ + uploadAsset: (args) => ({ uploadAsset: args }), allowThumbnailUpload: (args) => ({ allowThumbnailUpload: args }), uploadThumbnail: (args) => ({ uploadThumbnail: args }), deleteTranscript: (args) => ({ deleteTranscript: args }), @@ -248,6 +249,23 @@ describe('video thunkActions', () => { ]); }); }); + describe('uploadHandout', () => { + beforeEach(() => { + thunkActions.uploadHandout({ file: mockFilename })(dispatch); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches uploadAsset action', () => { + expect(dispatchedAction.uploadAsset).not.toBe(undefined); + }); + test('passes file as image prop', () => { + expect(dispatchedAction.uploadAsset.asset).toEqual(mockFilename); + }); + test('onSuccess: calls setSelection with camelized response.data.asset', () => { + const handout = mockFilename; + dispatchedAction.uploadAsset.onSuccess({ data: { asset: { url: mockFilename } } }); + expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({ handout })); + }); + }); describe('uploadThumbnail', () => { beforeEach(() => { thunkActions.uploadThumbnail({ thumbnail: mockThumbnail })(dispatch, getState); diff --git a/src/editors/data/redux/video/selectors.js b/src/editors/data/redux/video/selectors.js index cf6193586..b6c071e26 100644 --- a/src/editors/data/redux/video/selectors.js +++ b/src/editors/data/redux/video/selectors.js @@ -6,7 +6,7 @@ import { videoTranscriptLanguages } from '../../constants/video'; import { initialState } from './reducer'; import * as module from './selectors'; import * as AppSelectors from '../app/selectors'; -import { downloadVideoTranscriptURL } from '../../services/cms/urls'; +import { downloadVideoTranscriptURL, downloadVideoHandoutUrl } from '../../services/cms/urls'; const stateKeys = keyStore(initialState); @@ -51,6 +51,14 @@ export const getTranscriptDownloadUrl = createSelector( }), ); +export const getHandoutDownloadUrl = createSelector( + [AppSelectors.simpleSelectors.studioEndpointUrl], + (studioEndpointUrl) => ({ handout }) => downloadVideoHandoutUrl({ + studioEndpointUrl, + handout, + }), +); + export const videoSettings = createSelector( [ module.simpleSelectors.videoSource, @@ -101,5 +109,6 @@ export default { ...simpleSelectors, openLanguages, getTranscriptDownloadUrl, + getHandoutDownloadUrl, videoSettings, }; diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js index 0932ecd9d..71cd7a6a9 100644 --- a/src/editors/data/services/cms/api.js +++ b/src/editors/data/services/cms/api.js @@ -17,13 +17,13 @@ export const apiMethods = { fetchImages: ({ learningContextId, studioEndpointUrl }) => get( urls.courseImages({ studioEndpointUrl, learningContextId }), ), - uploadImage: ({ + uploadAsset: ({ learningContextId, studioEndpointUrl, - image, + asset, }) => { const data = new FormData(); - data.append('file', image); + data.append('file', asset); return post( urls.courseAssets({ studioEndpointUrl, learningContextId }), data, diff --git a/src/editors/data/services/cms/api.test.js b/src/editors/data/services/cms/api.test.js index f105c9468..64aa57c16 100644 --- a/src/editors/data/services/cms/api.test.js +++ b/src/editors/data/services/cms/api.test.js @@ -173,15 +173,15 @@ describe('cms api', () => { }); }); - describe('uploadImage', () => { - const image = { photo: 'dAta' }; + describe('uploadAsset', () => { + const asset = { photo: 'dAta' }; it('should call post with urls.courseAssets and imgdata', () => { const mockFormdata = new FormData(); - mockFormdata.append('file', image); - apiMethods.uploadImage({ + mockFormdata.append('file', asset); + apiMethods.uploadAsset({ learningContextId, studioEndpointUrl, - image, + asset, }); expect(post).toHaveBeenCalledWith( urls.videoTranscripts({ studioEndpointUrl, learningContextId }), diff --git a/src/editors/data/services/cms/mockApi.js b/src/editors/data/services/cms/mockApi.js index ce9e32754..1a32d38eb 100644 --- a/src/editors/data/services/cms/mockApi.js +++ b/src/editors/data/services/cms/mockApi.js @@ -160,13 +160,13 @@ export const saveBlock = ({ }), }); -export const uploadImage = ({ +export const uploadAsset = ({ learningContextId, studioEndpointUrl, // image, }) => mockPromise({ url: urls.courseAssets({ studioEndpointUrl, learningContextId }), - image: { + asset: { asset: { display_name: 'journey_escape.jpg', content_type: 'image/jpeg', diff --git a/src/editors/data/services/cms/urls.js b/src/editors/data/services/cms/urls.js index 27424164c..70ebcecec 100644 --- a/src/editors/data/services/cms/urls.js +++ b/src/editors/data/services/cms/urls.js @@ -50,3 +50,7 @@ export const videoTranscripts = ({ studioEndpointUrl, blockId }) => ( export const downloadVideoTranscriptURL = ({ studioEndpointUrl, blockId, language }) => ( `${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}` ); + +export const downloadVideoHandoutUrl = ({ studioEndpointUrl, handout }) => ( + `${studioEndpointUrl}${handout}` +); diff --git a/src/editors/data/services/cms/urls.test.js b/src/editors/data/services/cms/urls.test.js index 59bb37c99..141047bf4 100644 --- a/src/editors/data/services/cms/urls.test.js +++ b/src/editors/data/services/cms/urls.test.js @@ -9,6 +9,7 @@ import { courseImages, downloadVideoTranscriptURL, videoTranscripts, + downloadVideoHandoutUrl, } from './urls'; describe('cms url methods', () => { @@ -18,6 +19,7 @@ describe('cms url methods', () => { const courseId = 'course-v1:courseId123'; const libraryV1Id = 'library-v1:libaryId123'; const language = 'la'; + const handout = '/aSSet@hANdoUt'; describe('return to learning context urls', () => { const unitUrl = { data: { @@ -92,4 +94,10 @@ describe('cms url methods', () => { .toEqual(`${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`); }); }); + describe('downloadVideoHandoutUrl', () => { + it('returns url with studioEndpointUrl and handout', () => { + expect(downloadVideoHandoutUrl({ studioEndpointUrl, handout })) + .toEqual(`${studioEndpointUrl}${handout}`); + }); + }); }); diff --git a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx index fd2552f17..74f4e664a 100644 --- a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx +++ b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx @@ -32,7 +32,7 @@ UploadErrorAlert.propTypes = { isUploadError: PropTypes.bool.isRequired, }; export const mapStateToProps = (state) => ({ - isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadImage }), + isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadAsset }), }); export const mapDispatchToProps = {}; export default connect(mapStateToProps, mapDispatchToProps)(UploadErrorAlert); diff --git a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx index 1ddf44b28..8ae5d03ff 100644 --- a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx +++ b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx @@ -23,7 +23,7 @@ describe('UploadErrorAlert', () => { test('isUploadError from requests.isFinished', () => { expect( mapStateToProps(testState).isUploadError, - ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.uploadImage })); + ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.uploadAsset })); }); }); });