From 45215ba504723635a4c61ccc5ce508b6c85ed75e Mon Sep 17 00:00:00 2001 From: connorhaugh <49422820+connorhaugh@users.noreply.github.com> Date: Tue, 27 Sep 2022 14:09:29 -0400 Subject: [PATCH] Feat: full transcript widget (#117) --- .../TranscriptWidget/LanguageSelect.jsx | 62 ++++ .../TranscriptWidget/LanguageSelect.test.jsx | 44 +++ .../TranscriptWidget/TranscriptListItem.jsx | 108 ++++++ .../TranscriptListItem.test.jsx | 82 +++++ .../LanguageSelect.test.jsx.snap | 65 ++++ .../TranscriptListItem.test.jsx.snap | 124 +++++++ .../__snapshots__/index.test.jsx.snap | 336 +++++++++++++++--- .../components/TranscriptWidget/hooks.js | 69 +++- .../TranscriptWidget/hooks.test.jsx | 129 +++++++ .../components/TranscriptWidget/index.jsx | 49 +-- .../TranscriptWidget/index.test.jsx | 44 ++- .../components/TranscriptWidget/messages.js | 50 +++ src/editors/data/constants/requests.js | 2 + src/editors/data/constants/video.js | 190 ++++++++++ src/editors/data/redux/requests/reducer.js | 2 + .../data/redux/thunkActions/requests.js | 34 ++ .../data/redux/thunkActions/requests.test.js | 53 ++- src/editors/data/redux/thunkActions/video.js | 73 +++- .../data/redux/thunkActions/video.test.js | 89 +++++ src/editors/data/redux/video/reducer.js | 18 + src/editors/data/redux/video/selectors.js | 29 +- src/editors/data/services/cms/api.js | 31 +- src/editors/data/services/cms/api.test.js | 46 ++- .../data/services/cms/mockVideoData.js | 4 +- src/editors/data/services/cms/types.js | 1 + src/editors/data/services/cms/urls.js | 8 + src/editors/data/services/cms/urls.test.js | 15 + src/editors/data/services/cms/utils.js | 7 + src/editors/data/services/cms/utils.test.js | 8 + .../ErrorAlerts/ErrorAlert.jsx | 4 +- src/setupTest.js | 9 + 31 files changed, 1686 insertions(+), 99 deletions(-) create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.test.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.test.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelect.test.jsx.snap create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptListItem.test.jsx.snap create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.test.jsx create mode 100644 src/editors/data/redux/thunkActions/video.test.js diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.jsx new file mode 100644 index 000000000..5d6aeab37 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Form, +} from '@edx/paragon'; +import { connect, useDispatch } from 'react-redux'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import hooks from './hooks'; +import { selectors } from '../../../../../../data/redux'; +import { videoTranscriptLanguages } from '../../../../../../data/constants/video'; +import messages from './messages'; + +export const LanguageSelect = ({ + title, // For a unique id for the form control + language, + // Redux + openLanguages, // Only allow those languages not already associated with a transcript to be selected + transcripts, + // intl + intl, + +}) => { + const onLanguageChange = hooks.onSelectLanguage({ + filename: title, dispatch: useDispatch(), transcripts, languageBeforeChange: language, + }); + + return ( + + onLanguageChange(e)} floatingLabel={intl.formatMessage(messages.languageSelectLabel)}> + {Object.entries(videoTranscriptLanguages).map(([lang, text]) => { + if (language === lang) { return (); } + if (openLanguages.some(row => row.includes(lang))) { + return (); + } + return (); + })} + + + ); +}; + +LanguageSelect.defaultProps = { + openLanguages: [], +}; + +LanguageSelect.propTypes = { + openLanguages: PropTypes.arrayOf(PropTypes.string), + title: PropTypes.string.isRequired, + language: PropTypes.string.isRequired, + transcripts: PropTypes.objectOf(PropTypes.string).isRequired, + intl: intlShape.isRequired, +}; + +export const mapStateToProps = (state) => ({ + openLanguages: selectors.video.openLanguages(state), + transcripts: selectors.video.transcripts(state), +}); + +export const mapDispatchToProps = {}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(LanguageSelect)); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.test.jsx new file mode 100644 index 000000000..0b97f0589 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.test.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { LanguageSelect } from './LanguageSelect'; +import { formatMessage } from '../../../../../../../testUtils'; + +const lang1 = 'kLinGon'; +const lang1Code = 'kl'; +const lang2 = 'eLvIsh'; +const lang2Code = 'el'; +const lang3 = 'sImLisH'; +const lang3Code = 'sl'; + +jest.mock('../../../../../../data/constants/video', () => ({ + videoTranscriptLanguages: { + [lang1Code]: lang1, + [lang2Code]: lang2, + [lang3Code]: lang3, + }, +})); + +describe('LanguageSelect', () => { + const props = { + intl: { formatMessage }, + onSelect: jest.fn().mockName('props.OnSelect'), + title: 'tITle', + language: lang1Code, + openLanguages: [[lang2Code, lang2], [lang3Code, lang3]], + + }; + describe('snapshot', () => { + test('transcript option', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + }); + describe('snapshots -- no', () => { + test('transcripts no Open Languages, all should be disabled', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.jsx new file mode 100644 index 000000000..151fed0f4 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect, useDispatch } from 'react-redux'; +import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; +import { + Card, Dropdown, Icon, IconButton, Button, +} from '@edx/paragon'; + +import { MoreVert } from '@edx/paragon/icons'; + +import LanguageSelect from './LanguageSelect'; +import hooks from './hooks'; +import { thunkActions, selectors } from '../../../../../../data/redux'; +import FileInput from '../../../../../../sharedComponents/FileInput'; +import messages from './messages'; + +export const TranscriptListItem = ({ + title, + language, + // redux + deleteTranscript, + getTranscriptDownloadUrl, +}) => { + const fileInput = hooks.fileInput({ onAddFile: hooks.replaceFileCallback({ language, dispatch: useDispatch() }) }); + const { inDeleteConfirmation, launchDeleteConfirmation, cancelDelete } = hooks.setUpDeleteConfirmation(); + const downloadLink = getTranscriptDownloadUrl({ language }); + + return ( +
+ + { inDeleteConfirmation ? ( + <> + )} /> + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + )} + /> + + + )} + +
+ ); +}; + +TranscriptListItem.propTypes = { + title: PropTypes.string.isRequired, + language: PropTypes.string.isRequired, + // redux + deleteTranscript: PropTypes.func.isRequired, + getTranscriptDownloadUrl: PropTypes.func.isRequired, +}; + +export const mapStateToProps = (state) => ({ + getTranscriptDownloadUrl: selectors.video.getTranscriptDownloadUrl(state), +}); + +export const mapDispatchToProps = { + deleteTranscript: thunkActions.video.deleteTranscript, + downloadTranscript: thunkActions.video.downloadTranscript, +}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TranscriptListItem)); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.test.jsx new file mode 100644 index 000000000..7dcfac2b9 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.test.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { TranscriptListItem, mapDispatchToProps, mapStateToProps } from './TranscriptListItem'; +import { thunkActions, selectors } from '../../../../../../data/redux'; +import hooks from './hooks'; + +jest.mock('react-redux', () => { + const dispatchFn = jest.fn().mockName('mockUseDispatch'); + return { + ...jest.requireActual('react-redux'), + dispatch: dispatchFn, + useDispatch: jest.fn(() => dispatchFn), + }; +}); + +jest.mock('../../../../../../data/redux', () => ({ + thunkActions: { + video: { + deleteTranscript: jest.fn().mockName('actions.video.deleteTranscript'), + }, + }, + selectors: { + video: { + getTranscriptDownloadUrl: jest.fn(args => ({ getTranscriptDownloadUrl: args })).mockName('selectors.video.getTranscriptDownloadUrl'), + }, + }, +})); + +jest.mock('./hooks', () => ({ + fileInput: jest.fn((args) => ({ fileInput: args, click: jest.fn().mockName('mockInputClick') })), + replaceFileCallback: jest.fn((args) => ({ replaceFileCallback: args })), + setUpDeleteConfirmation: jest.fn((args) => ({ setUpDeleteConfirmation: args })).mockName('setUpDeleteConfirmation'), +})); + +describe('TranscriptListItem', () => { + const props = { + getTranscriptDownloadUrl: jest.fn().mockName('selectors..video.getTranscriptDownloadUrl'), + title: 'sOmeTiTLE', + language: 'lAnG', + deleteTranscript: jest.fn().mockName('thunkActions.video.deleteTranscript'), + }; + + describe('Snapshots', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + test('snapshots: renders as expected with default props: dont show confirm delete', () => { + jest.spyOn(hooks, 'setUpDeleteConfirmation').mockImplementationOnce(() => ({ + inDeleteConfirmation: false, + launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'), + cancelDelete: jest.fn().mockName('cancelDelete'), + })); + expect( + shallow(), + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with default props: show confirm delete', () => { + jest.spyOn(hooks, 'setUpDeleteConfirmation').mockImplementationOnce(() => ({ + inDeleteConfirmation: true, + launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'), + cancelDelete: jest.fn().mockName('cancelDelete'), + })); + expect( + shallow(), + ).toMatchSnapshot(); + }); + }); + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('getTranscriptDownloadUrl from video.getTranscriptDownloadUrl', () => { + expect( + mapStateToProps(testState).getTranscriptDownloadUrl, + ).toEqual(selectors.video.getTranscriptDownloadUrl(testState)); + }); + }); + describe('mapDispatchToProps', () => { + test('deleteTranscript from thunkActions.video.deleteTranscript', () => { + expect(mapDispatchToProps.deleteTranscript).toEqual(thunkActions.video.deleteTranscript); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelect.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelect.test.jsx.snap new file mode 100644 index 000000000..5b13edc23 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelect.test.jsx.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LanguageSelect snapshot transcript option 1`] = ` + + + + + + + +`; + +exports[`LanguageSelect snapshots -- no transcripts no Open Languages, all should be disabled 1`] = ` + + + + + + + +`; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptListItem.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptListItem.test.jsx.snap new file mode 100644 index 000000000..beaeb2e42 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptListItem.test.jsx.snap @@ -0,0 +1,124 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TranscriptListItem Snapshots snapshots: renders as expected with default props: dont show confirm delete 1`] = ` +
+ + + + + + + + + + + + + + + + + } + subtitle="sOmeTiTLE" + /> + + +
+`; + +exports[`TranscriptListItem Snapshots snapshots: renders as expected with default props: show confirm delete 1`] = ` +
+ + + } + /> + + + + + + + + + + +
+`; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/index.test.jsx.snap index 83fe094dd..cebc1fff1 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/index.test.jsx.snap @@ -1,13 +1,126 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTranscriptDownloads true 1`] = ` +exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with delete error message 1`] = ` + + + + + + + + +
+ + + + + + + + + } + placement="right" + > + + +
+ + + + + +
+ + +
+
+`; + +exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with upload error message 1`] = ` + + @@ -18,14 +131,14 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTra /> - - Transcript widget: - + +
+ + + + + + + + + } + placement="right" + > + + +
+ + + + + + + + +
+
+`; + +exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTranscriptDownloads true 1`] = ` + + + + + + + + + +
@@ -53,23 +279,23 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTra /> - + - + } placement="right" > - +
- Only SRT files can be uploaded. Please select a file ending in .srt to upload. + - - Transcript widget: - +
@@ -233,23 +463,23 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with showTran /> - + - + } placement="right" > - +
- - Transcript widget: - +
@@ -346,23 +576,23 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with transcri /> - + - + } placement="right" > - +
React.useState(args), +}; export const transcriptLanguages = (transcripts) => { const languages = []; - if (transcripts) { + if (Object.keys(transcripts).length > 0) { Object.keys(transcripts).forEach(transcript => { - languages.push(transcript); + languages.push(videoTranscriptLanguages[transcript]); }); return languages.join(', '); } return 'None'; }; -export const fileInput = () => { +export const hasTranscripts = (transcripts) => { + if (transcripts && Object.keys(transcripts).length > 0) { + return true; + } + return false; +}; + +export const onSelectLanguage = ({ + filename, dispatch, transcripts, languageBeforeChange, +}) => (e) => { + const { [languageBeforeChange]: removedProperty, ...trimmedTranscripts } = transcripts; + const newTranscripts = { [e.target.value]: { filename }, ...trimmedTranscripts }; + dispatch(actions.video.updateField({ transcripts: newTranscripts })); +}; + +export const replaceFileCallback = ({ language, dispatch }) => (file) => { + dispatch(thunkActions.video.replaceTranscript({ + newFile: file, + newFilename: file.name, + language, + })); +}; + +export const addFileCallback = ({ dispatch }) => (file) => { + dispatch(thunkActions.video.uploadTranscript({ + file, + filename: file.name, + language: null, + })); +}; + +export const fileInput = ({ onAddFile }) => { const ref = React.useRef(); const click = () => ref.current.click(); const addFile = (e) => { - const selectedFile = e.target.files[0]; - console.log(selectedFile); + const file = e.target.files[0]; + if (file) { + onAddFile(file); + } }; - return { click, addFile, @@ -26,4 +65,20 @@ export const fileInput = () => { }; }; -export default { transcriptLanguages, fileInput }; +export const setUpDeleteConfirmation = () => { + const [inDeleteConfirmation, setInDeleteConfirmation] = module.state.inDeleteConfirmation(false); + return { + inDeleteConfirmation, + launchDeleteConfirmation: () => setInDeleteConfirmation(true), + cancelDelete: () => setInDeleteConfirmation(false), + }; +}; + +export default { + transcriptLanguages, + fileInput, + onSelectLanguage, + replaceFileCallback, + addFileCallback, + setUpDeleteConfirmation, +}; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.test.jsx new file mode 100644 index 000000000..30ba3df05 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.test.jsx @@ -0,0 +1,129 @@ +import * as module from './hooks'; + +import { actions, thunkActions } from '../../../../../../data/redux'; +import { MockUseState } from '../../../../../../../testUtils'; + +const lang1 = 'Kalaallisut'; +const lang2 = 'Greek'; +const lang1Code = 'kl'; +const lang2Code = 'el'; +const transcript1 = 'fIlEnAme1.srt'; +const transcript2 = 'fIlenAME2.srt'; + +const transcripts = { + [lang1Code]: { + filename: transcript1, + }, + [lang2Code]: { + filename: transcript2, + }, +}; + +jest.mock('../../../../../../data/redux', () => ({ + thunkActions: { + video: { + replaceTranscript: jest.fn(args => ({ replaceTranscript: args })).mockName('thunkActions.video.replaceTranscript'), + uploadTranscript: jest.fn(args => ({ uploadTranscript: args })).mockName('thunkActions.video.uploadTranscript'), + }, + }, + actions: { + video: { + updateField: jest.fn(args => ({ updateField: args })).mockName('actions.video.updateField'), + }, + }, +})); + +describe('VideoEditorTranscript hooks', () => { + describe('transcriptLanguages', () => { + test('it returns none when given empty object', () => { + expect(module.transcriptLanguages({})).toEqual('None'); + }); + test('it creates a list based on transcript object', () => { + expect(module.transcriptLanguages(transcripts)).toEqual(`${lang1}, ${lang2}`); + }); + }); + + describe('onSelectLanguage', () => { + const mockLangValue = 'soMeLanGuaGeCoDE'; + const mockEvent = { target: { value: mockLangValue } }; + const mockDispatch = jest.fn(); + + test('it dispatches the correct thunk', () => { + const cb = module.onSelectLanguage({ + filename: transcript1, dispatch: mockDispatch, transcripts, languageBeforeChange: lang1Code, + }); + const newTranscripts = { + transcripts: { [lang2Code]: { filename: transcript2 }, [mockLangValue]: { filename: transcript1 } }, + }; + cb(mockEvent); + expect(actions.video.updateField).toHaveBeenCalledWith(newTranscripts); + expect(mockDispatch).toHaveBeenCalledWith({ updateField: newTranscripts }); + }); + }); + + describe('replaceFileCallback', () => { + const mockFile = 'sOmeEbytes'; + const mockFileName = 'one.srt'; + const mockEvent = { mockFile, name: mockFileName }; + const mockDispatch = jest.fn(); + + const result = { newFile: { mockFile, name: mockFileName }, newFilename: mockFileName, language: lang1Code }; + + test('it dispatches the correct thunk', () => { + const cb = module.replaceFileCallback({ + dispatch: mockDispatch, language: lang1Code, + }); + cb(mockEvent); + expect(thunkActions.video.replaceTranscript).toHaveBeenCalledWith(result); + expect(mockDispatch).toHaveBeenCalledWith({ replaceTranscript: result }); + }); + }); + describe('addFileCallback', () => { + const mockFile = 'sOmeEbytes'; + const mockFileName = 'one.srt'; + const mockEvent = { mockFile, name: mockFileName }; + const mockDispatch = jest.fn(); + + const result = { file: { mockFile, name: mockFileName }, filename: mockFileName, language: null }; + + test('it dispatches the correct thunk', () => { + const cb = module.addFileCallback({ + dispatch: mockDispatch, + }); + cb(mockEvent); + expect(thunkActions.video.uploadTranscript).toHaveBeenCalledWith(result); + expect(mockDispatch).toHaveBeenCalledWith({ uploadTranscript: result }); + }); + }); + + describe('state hooks', () => { + const state = new MockUseState(module); + + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('state hooks', () => { + state.testGetter(state.keys.inDeleteConfirmation); + }); + + describe('setUpDeleteConfirmation hook', () => { + beforeEach(() => { + state.mock(); + }); + afterEach(() => { + state.restore(); + }); + test('inDeleteConfirmation: state values', () => { + expect(module.setUpDeleteConfirmation().inDeleteConfirmation).toEqual(false); + }); + test('inDeleteConfirmation setters: launch', () => { + module.setUpDeleteConfirmation().launchDeleteConfirmation(); + expect(state.setState[state.keys.inDeleteConfirmation]).toHaveBeenCalledWith(true); + }); + test('inDeleteConfirmation setters: cancel', () => { + module.setUpDeleteConfirmation().cancelDelete(); + expect(state.setState[state.keys.inDeleteConfirmation]).toHaveBeenCalledWith(false); + }); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx index 3e2775440..3f87fe27c 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { @@ -21,10 +21,14 @@ import { actions, selectors } from '../../../../../../data/redux'; import * as hooks from './hooks'; import messages from './messages'; +import { RequestKeys } from '../../../../../../data/constants/requests'; + import FileInput from '../../../../../../sharedComponents/FileInput'; import ErrorAlert from '../../../../../../sharedComponents/ErrorAlerts/ErrorAlert'; import CollapsibleFormWidget from '../CollapsibleFormWidget'; +import TranscriptListItem from './TranscriptListItem'; + /** * Collapsible Form widget controlling video transcripts */ @@ -35,21 +39,12 @@ export const TranscriptWidget = ({ allowTranscriptDownloads, showTranscriptByDefault, updateField, + isUploadError, + isDeleteError, }) => { const languagesArr = hooks.transcriptLanguages(transcripts); - const fileInput = hooks.fileInput(); - const input = { - error: { - dismiss: () => { console.log('dismiss'); }, - show: true, - }, - }; - const upload = { - error: { - dismiss: () => { console.log('dismiss'); }, - show: true, - }, - }; + const fileInput = hooks.fileInput({ onAddFile: hooks.addFileCallback({ dispatch: useDispatch() }) }); + const hasTranscripts = hooks.hasTranscripts(transcripts); return ( - + - {transcripts ? ( + {hasTranscripts ? ( + - Transcript widget: + { Object.entries(transcripts).map(([language, value]) => ( + + ))}
) : ( <> - - Only SRT files can be uploaded. Please select a file ending in .srt to upload. + + @@ -134,11 +133,15 @@ TranscriptWidget.propTypes = { allowTranscriptDownloads: PropTypes.bool.isRequired, showTranscriptByDefault: PropTypes.bool.isRequired, updateField: PropTypes.func.isRequired, + isUploadError: PropTypes.bool.isRequired, + isDeleteError: PropTypes.bool.isRequired, }; export const mapStateToProps = (state) => ({ transcripts: selectors.video.transcripts(state), allowTranscriptDownloads: selectors.video.allowTranscriptDownloads(state), showTranscriptByDefault: selectors.video.showTranscriptByDefault(state), + isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadTranscript }), + isDeleteError: selectors.requests.isFailed(state, { requestKey: RequestKeys.deleteTranscript }), }); export const mapDispatchToProps = (dispatch) => ({ diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.test.jsx index 227b4fae5..01b16484a 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.test.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.test.jsx @@ -1,6 +1,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { RequestKeys } from '../../../../../../data/constants/requests'; + import { formatMessage } from '../../../../../../../testUtils'; import { actions, selectors } from '../../../../../../data/redux'; import { TranscriptWidget, mapStateToProps, mapDispatchToProps } from '.'; @@ -11,11 +13,21 @@ jest.mock('../../../../../../data/redux', () => ({ updateField: jest.fn().mockName('actions.video.updateField'), }, }, + thunkActions: { + video: { + deleteTranscript: jest.fn().mockName('actions.video.deleteTranscript'), + }, + }, + selectors: { video: { transcripts: jest.fn(state => ({ transcripts: state })), allowTranscriptDownloads: jest.fn(state => ({ allowTranscriptDownloads: state })), showTranscriptByDefault: jest.fn(state => ({ showTranscriptByDefault: state })), + + }, + requests: { + isFailed: jest.fn(state => ({ isFailed: state })), }, }, })); @@ -25,13 +37,13 @@ describe('TranscriptWidget', () => { error: {}, subtitle: 'SuBTItle', title: 'tiTLE', - // inject intl: { formatMessage }, - // redux - transcripts: null, + transcripts: {}, allowTranscriptDownloads: false, showTranscriptByDefault: false, updateField: jest.fn().mockName('args.updateField'), + isUploadError: false, + isDeleteError: false, }; describe('snapshots', () => { @@ -42,17 +54,27 @@ describe('TranscriptWidget', () => { }); test('snapshots: renders as expected with transcripts', () => { expect( - shallow(), + shallow(), ).toMatchSnapshot(); }); test('snapshots: renders as expected with allowTranscriptDownloads true', () => { expect( - shallow(), + shallow(), ).toMatchSnapshot(); }); test('snapshots: renders as expected with showTranscriptByDefault true', () => { expect( - shallow(), + shallow(), + ).toMatchSnapshot(); + }); + test('snapshot: renders ErrorAlert with upload error message', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + test('snapshot: renders ErrorAlert with delete error message', () => { + expect( + shallow(), ).toMatchSnapshot(); }); }); @@ -73,6 +95,16 @@ describe('TranscriptWidget', () => { mapStateToProps(testState).showTranscriptByDefault, ).toEqual(selectors.video.showTranscriptByDefault(testState)); }); + test('isUploadError from requests.isFinished', () => { + expect( + mapStateToProps(testState).isUploadError, + ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.uploadTranscript })); + }); + test('isDeleteError from requests.isFinished', () => { + expect( + mapStateToProps(testState).isDeleteError, + ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.deleteTranscript })); + }); }); describe('mapDispatchToProps', () => { const dispatch = jest.fn(); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js index a7fd486d3..32ebfafff 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js @@ -39,6 +39,56 @@ export const messages = { defaultMessage: 'Transcript file size exeeds the maximum. Please try again.', description: 'Message presented to user when transcript file size is too large', }, + deleteTranscript: { + id: 'authoring.videoeditor.transcript.deleteTranscript', + defaultMessage: 'Delete', + description: 'Message Presented To user for action to delete transcript', + }, + deleteTranscriptError: { + id: 'authoring.videoeditor.transcript.error.deleteTranscriptError', + defaultMessage: 'Failed to delete transcript. Please try again.', + description: 'Message presented to user when transcript fails to delete', + }, + replaceTranscript: { + id: 'authoring.videoeditor.transcript.replaceTranscript', + defaultMessage: 'Replace', + description: 'Message Presented To user for action to replace transcript', + }, + downloadTranscript: { + id: 'authoring.videoeditor.transcript.downloadTranscript', + defaultMessage: 'Download', + description: 'Message Presented To user for action to download transcript', + }, + languageSelectLabel: { + id: 'authoring.videoeditor.transcripts.languageSelectLabel', + defaultMessage: 'Languages', + description: 'Label For Dropdown, which allows users to set the language associtated with a transcript', + }, + cancelDeleteLabel: { + id: 'authoring.videoeditor.transcripts.cancelDeleteLabel', + defaultMessage: 'Cancel', + description: 'Label For Button, which allows users to stop the process of deleting a transcript', + }, + confirmDeleteLabel: { + id: 'authoring.videoeditor.transcripts.confirmDeleteLabel', + defaultMessage: 'Delete', + description: 'Label For Button, which allows users to confirm the process of deleting a transcript', + }, + deleteConfirmationMessage: { + id: 'authoring.videoeditor.transcripts.deleteConfirmationMessage', + defaultMessage: 'Are you sure you want to delete this transcript?', + description: 'Warning which allows users to select next step in the process of deleting a transcript', + }, + deleteConfirmationHeader: { + id: 'authoring.videoeditor.transcripts.deleteConfirmationTitle', + defaultMessage: 'Delete This Transcript?', + description: 'Title for Warning which allows users to select next step in the process of deleting a transcript', + }, + fileTypeWarning: { + id: 'authoring.videoeditor.transcripts.fileTypeWarning', + defaultMessage: 'Only SRT files can be uploaded. Please select a file ending in .srt to upload.', + description: 'Message warning users to only upload .srt files', + }, }; export default messages; diff --git a/src/editors/data/constants/requests.js b/src/editors/data/constants/requests.js index 4a953c2dc..eb0a4d82d 100644 --- a/src/editors/data/constants/requests.js +++ b/src/editors/data/constants/requests.js @@ -14,4 +14,6 @@ export const RequestKeys = StrictDict({ fetchUnit: 'fetchUnit', saveBlock: 'saveBlock', uploadImage: 'uploadImage', + uploadTranscript: 'uploadTranscript', + deleteTranscript: 'deleteTranscript', }); diff --git a/src/editors/data/constants/video.js b/src/editors/data/constants/video.js index 69fd7063f..21f101227 100644 --- a/src/editors/data/constants/video.js +++ b/src/editors/data/constants/video.js @@ -1,5 +1,194 @@ import { StrictDict } from '../../utils'; +export const videoTranscriptLanguages = StrictDict({ + aa: 'Afar', + ab: 'Abkhazian', + af: 'Afrikaans', + ak: 'Akan', + sq: 'Albanian', + am: 'Amharic', + ar: 'Arabic', + an: 'Aragonese', + hy: 'Armenian', + as: 'Assamese', + av: 'Avaric', + ae: 'Avestan', + ay: 'Aymara', + az: 'Azerbaijani', + ba: 'Bashkir', + bm: 'Bambara', + eu: 'Basque', + be: 'Belarusian', + bn: 'Bengali', + bh: 'Bihari languages', + bi: 'Bislama', + bs: 'Bosnian', + br: 'Breton', + bg: 'Bulgarian', + my: 'Burmese', + ca: 'Catalan', + ch: 'Chamorro', + ce: 'Chechen', + zh: 'Chinese', + zh_HANS: 'Simplified Chinese', + zh_HANT: 'Traditional Chinese', + cu: 'Church Slavic', + cv: 'Chuvash', + kw: 'Cornish', + co: 'Corsican', + cr: 'Cree', + cs: 'Czech', + da: 'Danish', + dv: 'Divehi', + nl: 'Dutch', + dz: 'Dzongkha', + en: 'English', + eo: 'Esperanto', + et: 'Estonian', + ee: 'Ewe', + fo: 'Faroese', + fj: 'Fijian', + fi: 'Finnish', + fr: 'French', + fy: 'Western Frisian', + ff: 'Fulah', + ka: 'Georgian', + de: 'German', + gd: 'Gaelic', + ga: 'Irish', + gl: 'Galician', + gv: 'Manx', + el: 'Greek', + gn: 'Guarani', + gu: 'Gujarati', + ht: 'Haitian', + ha: 'Hausa', + he: 'Hebrew', + hz: 'Herero', + hi: 'Hindi', + ho: 'Hiri Motu', + hr: 'Croatian', + hu: 'Hungarian', + ig: 'Igbo', + is: 'Icelandic', + io: 'Ido', + ii: 'Sichuan Yi', + iu: 'Inuktitut', + ie: 'Interlingue', + ia: 'Interlingua', + id: 'Indonesian', + ik: 'Inupiaq', + it: 'Italian', + jv: 'Javanese', + ja: 'Japanese', + kl: 'Kalaallisut', + kn: 'Kannada', + ks: 'Kashmiri', + kr: 'Kanuri', + kk: 'Kazakh', + km: 'Central Khmer', + ki: 'Kikuyu', + rw: 'Kinyarwanda', + ky: 'Kirghiz', + kv: 'Komi', + kg: 'Kongo', + ko: 'Korean', + kj: 'Kuanyama', + ku: 'Kurdish', + lo: 'Lao', + la: 'Latin', + lv: 'Latvian', + li: 'Limburgan', + ln: 'Lingala', + lt: 'Lithuanian', + lb: 'Luxembourgish', + lu: 'Luba-Katanga', + lg: 'Ganda', + mk: 'Macedonian', + mh: 'Marshallese', + ml: 'Malayalam', + mi: 'Maori', + mr: 'Marathi', + ms: 'Malay', + mg: 'Malagasy', + mt: 'Maltese', + mn: 'Mongolian', + na: 'Nauru', + nv: 'Navajo', + nr: 'Ndebele: South', + nd: 'Ndebele: North', + ng: 'Ndonga', + ne: 'Nepali', + nn: 'Norwegian Nynorsk', + nb: 'Bokmål: Norwegian', + no: 'Norwegian', + ny: 'Chichewa', + oc: 'Occitan', + oj: 'Ojibwa', + or: 'Oriya', + om: 'Oromo', + os: 'Ossetian', + pa: 'Panjabi', + fa: 'Persian', + pi: 'Pali', + pl: 'Polish', + pt: 'Portuguese', + ps: 'Pushto', + qu: 'Quechua', + rm: 'Romansh', + ro: 'Romanian', + rn: 'Rundi', + ru: 'Russian', + sg: 'Sango', + sa: 'Sanskrit', + si: 'Sinhala', + sk: 'Slovak', + sl: 'Slovenian', + se: 'Northern Sami', + sm: 'Samoan', + sn: 'Shona', + sd: 'Sindhi', + so: 'Somali', + st: 'Sotho: Southern', + es: 'Spanish', + sc: 'Sardinian', + sr: 'Serbian', + ss: 'Swati', + su: 'Sundanese', + sw: 'Swahili', + sv: 'Swedish', + ty: 'Tahitian', + ta: 'Tamil', + tt: 'Tatar', + te: 'Telugu', + tg: 'Tajik', + tl: 'Tagalog', + th: 'Thai', + bo: 'Tibetan', + ti: 'Tigrinya', + to: 'Tonga (Tonga Islands)', + tn: 'Tswana', + ts: 'Tsonga', + tk: 'Turkmen', + tr: 'Turkish', + tw: 'Twi', + ug: 'Uighur', + uk: 'Ukrainian', + ur: 'Urdu', + uz: 'Uzbek', + ve: 'Venda', + vi: 'Vietnamese', + vo: 'Volapük', + cy: 'Welsh', + wa: 'Walloon', + wo: 'Wolof', + xh: 'Xhosa', + yi: 'Yiddish', + yo: 'Yoruba', + za: 'Zhuang', + zu: 'Zulu', +}); + export const timeKeys = StrictDict({ startTime: 'startTime', stopTime: 'stopTime', @@ -7,4 +196,5 @@ export const timeKeys = StrictDict({ export default { timeKeys, + videoTranscriptLanguages, }; diff --git a/src/editors/data/redux/requests/reducer.js b/src/editors/data/redux/requests/reducer.js index cec256bf7..cf6b831c0 100644 --- a/src/editors/data/redux/requests/reducer.js +++ b/src/editors/data/redux/requests/reducer.js @@ -11,6 +11,8 @@ const initialState = { [RequestKeys.saveBlock]: { status: RequestStates.inactive }, [RequestKeys.fetchImages]: { status: RequestStates.inactive }, [RequestKeys.uploadImage]: { status: RequestStates.inactive }, + [RequestKeys.uploadTranscript]: { status: RequestStates.inactive }, + [RequestKeys.deleteTranscript]: { status: RequestStates.inactive }, }; diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js index 80a01c46d..e62cc805f 100644 --- a/src/editors/data/redux/thunkActions/requests.js +++ b/src/editors/data/redux/thunkActions/requests.js @@ -135,6 +135,38 @@ export const fetchImages = ({ ...rest }) => (dispatch, getState) => { })); }; +export const deleteTranscript = ({ language, videoId, ...rest }) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.deleteTranscript, + promise: api.deleteTranscript({ + blockId: selectors.app.blockId(getState()), + language, + videoId, + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + }), + ...rest, + })); +}; + +export const uploadTranscript = ({ + transcript, + videoId, + language, + ...rest +}) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.uploadTranscript, + promise: api.uploadTranscript({ + blockId: selectors.app.blockId(getState()), + transcript, + videoId, + language, + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + }), + ...rest, + })); +}; + export default StrictDict({ fetchBlock, fetchImages, @@ -142,4 +174,6 @@ export default StrictDict({ fetchUnit, saveBlock, uploadImage, + deleteTranscript, + uploadTranscript, }); diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js index 326f685bb..a50bc58f6 100644 --- a/src/editors/data/redux/thunkActions/requests.test.js +++ b/src/editors/data/redux/thunkActions/requests.test.js @@ -9,6 +9,10 @@ const testState = { }; jest.mock('../app/selectors', () => ({ + simpleSelectors: { + studioEndpointUrl: (state) => ({ studioEndpointUrl: state }), + blockId: (state) => ({ blockId: state }), + }, studioEndpointUrl: (state) => ({ studioEndpointUrl: state }), blockId: (state) => ({ blockId: state }), blockType: (state) => ({ blockType: state }), @@ -24,6 +28,8 @@ jest.mock('../../services/cms/api', () => ({ fetchImages: ({ id, url }) => ({ id, url }), uploadImage: (args) => args, loadImages: jest.fn(), + uploadTranscript: jest.fn(), + deleteTranscript: jest.fn(), })); const apiKeys = keyStore(api); @@ -239,7 +245,6 @@ describe('requests thunkActions module', () => { expect(loadImages).toHaveBeenCalledWith({ fetchImages: expectedArgs }); }); }); - describe('saveBlock', () => { const content = 'SoME HtMl CoNtent As String'; testNetworkRequestAction({ @@ -260,7 +265,6 @@ describe('requests thunkActions module', () => { }, }); }); - describe('uploadImage', () => { const image = 'SoME iMage CoNtent As String'; testNetworkRequestAction({ @@ -278,5 +282,50 @@ describe('requests thunkActions module', () => { }, }); }); + describe('deleteTranscript', () => { + const language = 'SoME laNGUage CoNtent As String'; + const videoId = 'SoME VidEOid CoNtent As String'; + testNetworkRequestAction({ + action: requests.deleteTranscript, + args: { language, videoId, ...fetchParams }, + expectedString: 'with deleteTranscript promise', + expectedData: { + ...fetchParams, + requestKey: RequestKeys.deleteTranscript, + promise: api.deleteTranscript({ + blockId: selectors.app.blockId(testState), + language, + videoId, + studioEndpointUrl: selectors.app.studioEndpointUrl(testState), + }), + }, + }); + }); + describe('uploadTranscript', () => { + const language = 'SoME laNGUage CoNtent As String'; + const videoId = 'SoME VidEOid CoNtent As String'; + const transcript = 'SoME tRANscRIPt CoNtent As String'; + testNetworkRequestAction({ + action: requests.uploadTranscript, + args: { + transcript, + language, + videoId, + ...fetchParams, + }, + expectedString: 'with uploadTranscript promise', + expectedData: { + ...fetchParams, + requestKey: RequestKeys.uploadTranscript, + promise: api.uploadTranscript({ + blockId: selectors.app.blockId(testState), + transcript, + videoId, + language, + studioEndpointUrl: selectors.app.studioEndpointUrl(testState), + }), + }, + }); + }); }); }); diff --git a/src/editors/data/redux/thunkActions/video.js b/src/editors/data/redux/thunkActions/video.js index f5b1db57d..253a93732 100644 --- a/src/editors/data/redux/thunkActions/video.js +++ b/src/editors/data/redux/thunkActions/video.js @@ -1,5 +1,6 @@ import { singleVideoData } from '../../services/cms/mockVideoData'; -import { actions } from '..'; +import { actions, selectors } from '..'; +import * as requests from './requests'; export const loadVideoData = () => (dispatch) => { dispatch(actions.video.load(singleVideoData)); @@ -10,7 +11,77 @@ export const saveVideoData = () => () => { // dispatch(requests.saveBlock({ }); }; +// Transcript Thunks: + +export const uploadTranscript = ({ language, filename, file }) => (dispatch, getState) => { + const state = getState(); + const { transcripts, videoId } = state.video; + let lang = language; + if (!language) { + [[lang]] = selectors.video.openLanguages(state); + } + dispatch(requests.uploadTranscript({ + language: lang, + videoId, + transcript: file, + onSuccess: (response) => { + dispatch(actions.video.updateField({ + transcripts: { + ...transcripts, + [lang]: { filename }, + }, + })); + if (selectors.video.videoId(state) === '') { + dispatch(actions.video.updateField({ + videoId: response.edx_video_id, + })); + } + }, + + })); +}; + +export const deleteTranscript = ({ language }) => (dispatch, getState) => { + const state = getState(); + const { transcripts, videoId } = state.video; + dispatch(requests.deleteTranscript({ + language, + videoId, + onSuccess: () => { + const updateTranscripts = {}; + Object.keys(transcripts).forEach((key) => { + if (key !== language) { + updateTranscripts[key] = transcripts[key]; + } + }); + dispatch(actions.video.updateField({ transcripts: updateTranscripts })); + }, + })); +}; + +export const replaceTranscript = ({ newFile, newFilename, language }) => (dispatch, getState) => { + const state = getState(); + const { transcripts, videoId } = state.video; + dispatch(requests.deleteTranscript({ + language, + videoId, + onSuccess: () => { + const updateTranscripts = {}; + Object.keys(transcripts).forEach((key) => { + if (key !== language) { + updateTranscripts[key] = transcripts[key]; + } + }); + dispatch(actions.video.updateField({ transcripts: updateTranscripts })); + dispatch(uploadTranscript({ language, file: newFile, filename: newFilename })); + }, + })); +}; + export default { loadVideoData, saveVideoData, + uploadTranscript, + deleteTranscript, + replaceTranscript, }; diff --git a/src/editors/data/redux/thunkActions/video.test.js b/src/editors/data/redux/thunkActions/video.test.js new file mode 100644 index 000000000..f27b27f5f --- /dev/null +++ b/src/editors/data/redux/thunkActions/video.test.js @@ -0,0 +1,89 @@ +import { actions } from '..'; +import { keyStore } from '../../../utils'; +import * as thunkActions from './video'; + +jest.mock('./requests', () => ({ + deleteTranscript: (args) => ({ deleteTranscript: args }), + uploadTranscript: (args) => ({ uploadTranscript: args }), +})); +const thunkActionsKeys = keyStore(thunkActions); + +const mockLanguage = 'la'; +const mockFile = 'soMEtRANscRipT'; +const mockFilename = 'soMEtRANscRipT.srt'; + +const testState = { transcripts: { la: 'test VALUE' }, videoId: 'soMEvIDEo' }; +const testUpload = { transcripts: { la: { filename: mockFilename } } }; +const testReplaceUpload = { + file: mockFile, + language: mockLanguage, + filename: mockFilename, +}; + +describe('video thunkActions', () => { + let dispatch; + let getState; + let dispatchedAction; + beforeEach(() => { + dispatch = jest.fn((action) => ({ dispatch: action })); + getState = jest.fn(() => ({ + app: { studioEndpointUrl: 'soMEeNDPoiNT', blockId: 'soMEBloCk' }, + video: testState, + })); + }); + describe('deleteTranscript', () => { + beforeEach(() => { + thunkActions.deleteTranscript({ language: mockLanguage })(dispatch, getState); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches deleteTranscript action', () => { + expect(dispatchedAction.deleteTranscript).not.toEqual(undefined); + }); + it('dispatches actions.video.updateField on success', () => { + dispatch.mockClear(); + dispatchedAction.deleteTranscript.onSuccess(); + expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({ transcripts: {} })); + }); + }); + describe('uploadTranscript', () => { + beforeEach(() => { + thunkActions.uploadTranscript({ + language: mockLanguage, + filename: mockFilename, + file: mockFile, + })(dispatch, getState); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches uploadTranscript action', () => { + expect(dispatchedAction.uploadTranscript).not.toEqual(undefined); + }); + it('dispatches actions.video.updateField on success', () => { + dispatch.mockClear(); + dispatchedAction.uploadTranscript.onSuccess(); + expect(dispatch).toHaveBeenCalledWith(actions.video.updateField(testUpload)); + }); + }); + describe('replaceTranscript', () => { + const spies = {}; + beforeEach(() => { + spies.uploadTranscript = jest.spyOn(thunkActions, thunkActionsKeys.uploadTranscript) + .mockReturnValueOnce(testReplaceUpload); + thunkActions.replaceTranscript({ + newFile: mockFile, + newFilename: mockFilename, + language: mockLanguage, + })(dispatch, getState, spies.uploadTranscript); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches deleteTranscript action', () => { + expect(dispatchedAction.deleteTranscript).not.toEqual(undefined); + }); + it('dispatches actions.video.updateField and replaceTranscript success', () => { + dispatch.mockClear(); + dispatchedAction.deleteTranscript.onSuccess(); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, actions.video.updateField({ transcripts: {} })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + }); + }); +}); diff --git a/src/editors/data/redux/video/reducer.js b/src/editors/data/redux/video/reducer.js index 09a21f1d5..1a9670228 100644 --- a/src/editors/data/redux/video/reducer.js +++ b/src/editors/data/redux/video/reducer.js @@ -4,6 +4,7 @@ import { StrictDict } from '../../../utils'; const initialState = { videoSource: '', + videoId: '', fallbackVideos: [ '', '', @@ -40,6 +41,23 @@ const video = createSlice({ load: (state, { payload }) => ({ ...payload, }), + addTranscript: (state, { payload }) => ({ + transcripts: { [payload.language]: payload.filename, ...state.transcripts }, + ...state, + }), + replaceTranscript: (state, { payload }) => ({ + transcripts: { [payload.language]: payload.newFilename, ...state.transcripts }, + ...state, + }), + deleteTranscript: (state, { payload }) => { + const lang = payload.language; + const { [lang]: removedProperty, ...trimmedTranscripts } = state.transcripts; + return { + transcripts: trimmedTranscripts, + ...state, + }; + }, + }, }); diff --git a/src/editors/data/redux/video/selectors.js b/src/editors/data/redux/video/selectors.js index 1b6cd1967..097aa3635 100644 --- a/src/editors/data/redux/video/selectors.js +++ b/src/editors/data/redux/video/selectors.js @@ -1,9 +1,12 @@ -// import { createSelector } from 'reselect'; +import { createSelector } from 'reselect'; import { keyStore } from '../../../utils'; +import { videoTranscriptLanguages } from '../../constants/video'; import { initialState } from './reducer'; -// import * as module from './selectors'; +import * as module from './selectors'; +import * as AppSelectors from '../app/selectors'; +import { downloadVideoTranscriptURL } from '../../services/cms/urls'; const stateKeys = keyStore(initialState); @@ -11,6 +14,7 @@ export const video = (state) => state.video; export const simpleSelectors = [ stateKeys.videoSource, + stateKeys.videoId, stateKeys.fallbackVideos, stateKeys.allowVideoDownloads, stateKeys.thumbnail, @@ -23,6 +27,27 @@ export const simpleSelectors = [ stateKeys.licenseDetails, ].reduce((obj, key) => ({ ...obj, [key]: state => state.video[key] }), {}); +export const openLanguages = createSelector( + [module.simpleSelectors.transcripts], + (transcripts) => { + const open = Object.entries(videoTranscriptLanguages).filter( + ([lang]) => !Object.keys(transcripts).includes(lang), + ); + return open; + }, +); + +export const getTranscriptDownloadUrl = createSelector( + [AppSelectors.simpleSelectors.studioEndpointUrl, AppSelectors.simpleSelectors.blockId], + (studioEndpointUrl, blockId) => ({ language }) => downloadVideoTranscriptURL({ + studioEndpointUrl, + blockId, + language, + }), +); + export default { + openLanguages, + getTranscriptDownloadUrl, ...simpleSelectors, }; diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js index a7999fa34..11491137c 100644 --- a/src/editors/data/services/cms/api.js +++ b/src/editors/data/services/cms/api.js @@ -1,6 +1,6 @@ import { camelizeKeys } from '../../../utils'; import * as urls from './urls'; -import { get, post } from './utils'; +import { get, post, deleteObject } from './utils'; import * as module from './api'; import * as mockApi from './mockApi'; @@ -29,6 +29,35 @@ export const apiMethods = { data, ); }, + deleteTranscript: ({ + studioEndpointUrl, + language, + blockId, + videoId, + }) => { + const deleteJSON = { data: { lang: language, edx_video_id: videoId } }; + return deleteObject( + urls.videoTranscripts({ studioEndpointUrl, blockId }), + deleteJSON, + ); + }, + uploadTranscript: ({ + blockId, + studioEndpointUrl, + transcript, + videoId, + language, + }) => { + const data = new FormData(); + data.append('file', transcript); + data.append('edx_video_id', videoId); + data.append('language_code', language); + data.append('new_language_code', language); + return post( + urls.videoTranscripts({ studioEndpointUrl, blockId }), + data, + ); + }, normalizeContent: ({ blockId, blockType, diff --git a/src/editors/data/services/cms/api.test.js b/src/editors/data/services/cms/api.test.js index cd736e64d..dd4e2a9b4 100644 --- a/src/editors/data/services/cms/api.test.js +++ b/src/editors/data/services/cms/api.test.js @@ -1,7 +1,7 @@ import * as utils from '../../../utils'; import * as api from './api'; import * as urls from './urls'; -import { get, post } from './utils'; +import { get, post, deleteObject } from './utils'; jest.mock('../../../utils', () => { const camelizeMap = (obj) => ({ ...obj, camelized: true }); @@ -18,11 +18,13 @@ jest.mock('./urls', () => ({ blockStudioView: jest.fn().mockName('urls.StudioView'), courseImages: jest.fn().mockName('urls.courseImages'), courseAssets: jest.fn().mockName('urls.courseAssets'), + videoTranscripts: jest.fn().mockName('urls.videoTranscripts'), })); jest.mock('./utils', () => ({ get: jest.fn().mockName('get'), post: jest.fn().mockName('post'), + deleteObject: jest.fn().mockName('deleteObject'), })); const { camelize } = utils; @@ -122,7 +124,7 @@ describe('cms api', () => { image, }); expect(post).toHaveBeenCalledWith( - urls.courseAssets({ studioEndpointUrl, learningContextId }), + urls.videoTranscripts({ studioEndpointUrl, learningContextId }), mockFormdata, ); }); @@ -159,4 +161,44 @@ describe('cms api', () => { api.loadImage = oldLoadImage; }); }); + describe('videoTranscripts', () => { + const language = 'la'; + const videoId = 'sOmeVIDeoiD'; + describe('uploadTranscript', () => { + const transcript = { transcript: 'dAta' }; + it('should call post with urls.videoTranscripts and transcript data', () => { + const mockFormdata = new FormData(); + mockFormdata.append('file', transcript); + mockFormdata.append('edx_video_id', videoId); + mockFormdata.append('language_code', language); + mockFormdata.append('new_language_code', language); + apiMethods.uploadTranscript({ + blockId, + studioEndpointUrl, + transcript, + videoId, + language, + }); + expect(post).toHaveBeenCalledWith( + urls.videoTranscripts({ studioEndpointUrl, blockId }), + mockFormdata, + ); + }); + }); + describe('transcript delete', () => { + it('should call deleteObject with urls.videoTranscripts and transcript data', () => { + const mockDeleteJSON = { data: { lang: language, edx_video_id: videoId } }; + apiMethods.deleteTranscript({ + blockId, + studioEndpointUrl, + videoId, + language, + }); + expect(deleteObject).toHaveBeenCalledWith( + urls.videoTranscripts({ studioEndpointUrl, blockId }), + mockDeleteJSON, + ); + }); + }); + }); }); diff --git a/src/editors/data/services/cms/mockVideoData.js b/src/editors/data/services/cms/mockVideoData.js index 20b3d8aaf..42bd8036f 100644 --- a/src/editors/data/services/cms/mockVideoData.js +++ b/src/editors/data/services/cms/mockVideoData.js @@ -3,6 +3,7 @@ import LicenseTypes from '../../constants/licenses'; export const videoDataProps = { videoSource: PropTypes.string, + videoId: PropTypes.string, fallbackVideos: PropTypes.arrayOf(PropTypes.string), allowVideoDownloads: PropTypes.bool, thumbnail: PropTypes.string, @@ -26,6 +27,7 @@ export const videoDataProps = { export const singleVideoData = { videoSource: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + videoId: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7', fallbackVideos: [ 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', @@ -33,7 +35,7 @@ export const singleVideoData = { allowVideoDownloads: true, thumbnail: 'my-thumbnail-file-url', // filename transcripts: { - english: 'my-transcript-url', + en: { filename: 'my-transcript-url' }, }, allowTranscriptDownloads: false, duration: { diff --git a/src/editors/data/services/cms/types.js b/src/editors/data/services/cms/types.js index 46f616886..0c468bc08 100644 --- a/src/editors/data/services/cms/types.js +++ b/src/editors/data/services/cms/types.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; export const videoDataProps = { videoSource: PropTypes.string, + videoId: PropTypes.string, fallbackVideos: PropTypes.arrayOf(PropTypes.string), allowVideoDownloads: PropTypes.bool, thumbnail: PropTypes.string, diff --git a/src/editors/data/services/cms/urls.js b/src/editors/data/services/cms/urls.js index b81614ffd..95b0f6c3b 100644 --- a/src/editors/data/services/cms/urls.js +++ b/src/editors/data/services/cms/urls.js @@ -34,3 +34,11 @@ export const courseAssets = ({ studioEndpointUrl, learningContextId }) => ( export const courseImages = ({ studioEndpointUrl, learningContextId }) => ( `${courseAssets({ studioEndpointUrl, learningContextId })}?sort=uploadDate&direction=desc&asset_type=Images` ); + +export const videoTranscripts = ({ studioEndpointUrl, blockId }) => ( + `${block({ studioEndpointUrl, blockId })}/handler/studio_transcript/translation` +); + +export const downloadVideoTranscriptURL = ({ studioEndpointUrl, blockId, language }) => ( + `${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}` +); diff --git a/src/editors/data/services/cms/urls.test.js b/src/editors/data/services/cms/urls.test.js index 3e8580d00..59bb37c99 100644 --- a/src/editors/data/services/cms/urls.test.js +++ b/src/editors/data/services/cms/urls.test.js @@ -7,6 +7,8 @@ import { blockStudioView, courseAssets, courseImages, + downloadVideoTranscriptURL, + videoTranscripts, } from './urls'; describe('cms url methods', () => { @@ -15,6 +17,7 @@ describe('cms url methods', () => { const learningContextId = 'lEarnIngCOntextId123'; const courseId = 'course-v1:courseId123'; const libraryV1Id = 'library-v1:libaryId123'; + const language = 'la'; describe('return to learning context urls', () => { const unitUrl = { data: { @@ -77,4 +80,16 @@ describe('cms url methods', () => { .toEqual(`${courseAssets({ studioEndpointUrl, learningContextId })}?sort=uploadDate&direction=desc&asset_type=Images`); }); }); + describe('videoTranscripts', () => { + it('returns url with studioEndpointUrl and blockId', () => { + expect(videoTranscripts({ studioEndpointUrl, blockId })) + .toEqual(`${block({ studioEndpointUrl, blockId })}/handler/studio_transcript/translation`); + }); + }); + describe('downloadVideoTranscriptURL', () => { + it('returns url with studioEndpointUrl, blockId and language query', () => { + expect(downloadVideoTranscriptURL({ studioEndpointUrl, blockId, language })) + .toEqual(`${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`); + }); + }); }); diff --git a/src/editors/data/services/cms/utils.js b/src/editors/data/services/cms/utils.js index c34782e2b..ec9c56741 100644 --- a/src/editors/data/services/cms/utils.js +++ b/src/editors/data/services/cms/utils.js @@ -13,5 +13,12 @@ export const get = (...args) => getAuthenticatedHttpClient().get(...args); * @param {object|string} data - post payload */ export const post = (...args) => getAuthenticatedHttpClient().post(...args); +/** + * delete(url, data) + * simple wrapper providing an authenticated Http client delete action + * @param {string} url - target url + * @param {object|string} data - delete payload + */ +export const deleteObject = (...args) => getAuthenticatedHttpClient().delete(...args); export const client = getAuthenticatedHttpClient; diff --git a/src/editors/data/services/cms/utils.test.js b/src/editors/data/services/cms/utils.test.js index 6760e1cd9..19aef7a36 100644 --- a/src/editors/data/services/cms/utils.test.js +++ b/src/editors/data/services/cms/utils.test.js @@ -22,4 +22,12 @@ describe('cms service utils', () => { expect(utils.post(...args)).toEqual(post(...args)); }); }); + // describe('deleteObject', () => { + // it('forwards arguments to authenticatedHttpClient().delete', () => { + // const deleteObject = jest.fn((...args) => ({ delete: args })); + // getAuthenticatedHttpClient.mockReturnValue({ deleteObject }); + // const args = ['some', 'args', 'for', 'the', 'test']; + // expect(utils.deleteObject(...args)).toEqual(deleteObject(...args)); + // }); + // }); }); diff --git a/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx b/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx index 00ffd9245..4300df0e5 100644 --- a/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx +++ b/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx @@ -21,7 +21,9 @@ export const hooks = { isDismissed, dismissAlert: () => { setIsDismissed(true); - dismissError(); + if (dismissError) { + dismissError(); + } }, }; }, diff --git a/src/setupTest.js b/src/setupTest.js index 5d9521c41..2c01fdbfe 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -73,6 +73,12 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon Trigger: 'Trigger', Visible: 'Visible', }, + Card: { + Header: 'Card.Header', + Section: 'Card.Section', + Footer: 'Card.Footer', + Body: 'Card.Body', + }, Dropdown: { Item: 'Dropdown.Item', Menu: 'Dropdown.Menu', @@ -101,9 +107,12 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon SelectableBox: { Set: 'SelectableBox.Set', }, + Spinner: 'Spinner', Stack: 'Stack', Toast: 'Toast', + Tooltip: 'ToolTip', + OverlayTrigger: 'OverLayTrigger', })); jest.mock('@edx/paragon/icons', () => ({