From 3506db7c14cd1465cf7ebcda8072ed5200394ff0 Mon Sep 17 00:00:00 2001 From: connorhaugh <49422820+connorhaugh@users.noreply.github.com> Date: Tue, 15 Nov 2022 12:03:04 -0500 Subject: [PATCH] Feat improve transcript flow (#141) https://2u-internal.atlassian.net/browse/TNL-10199 Rewrite of Transcripts widget to improve flow. --- .../components/TitleHeader/index.jsx | 2 +- .../TranscriptWidget/LanguageSelect.jsx | 62 ---- .../TranscriptWidget/LanguageSelector.jsx | 93 ++++++ ...ect.test.jsx => LanguageSelector.test.jsx} | 8 +- .../TranscriptWidget/Transcript.jsx | 112 ++++++++ .../TranscriptWidget/Transcript.test.jsx | 85 ++++++ .../TranscriptWidget/TranscriptActionMenu.jsx | 80 ++++++ .../TranscriptActionMenu.test.jsx | 89 ++++++ .../TranscriptWidget/TranscriptListItem.jsx | 108 ------- .../TranscriptListItem.test.jsx | 82 ------ .../LanguageSelect.test.jsx.snap | 65 ----- .../LanguageSelector.test.jsx.snap | 97 +++++++ .../__snapshots__/Transcript.test.jsx.snap | 84 ++++++ .../TranscriptActionMenu.test.jsx.snap | 55 ++++ .../TranscriptListItem.test.jsx.snap | 124 -------- .../__snapshots__/index.test.jsx.snap | 267 ++++++++---------- .../components/TranscriptWidget/hooks.js | 96 ------- .../TranscriptWidget/hooks.test.jsx | 129 --------- .../components/TranscriptWidget/index.jsx | 84 ++++-- .../TranscriptWidget/index.test.jsx | 191 ++++++++----- .../components/TranscriptWidget/messages.js | 2 +- src/editors/data/constants/requests.js | 2 + src/editors/data/constants/video.js | 16 ++ .../data/redux/thunkActions/requests.js | 45 ++- .../data/redux/thunkActions/requests.test.js | 47 +++ src/editors/data/redux/thunkActions/video.js | 91 +++--- .../data/redux/thunkActions/video.test.js | 50 +++- src/editors/data/redux/video/reducer.js | 2 +- src/editors/data/redux/video/selectors.js | 4 +- src/editors/data/services/cms/api.js | 25 +- src/editors/data/services/cms/api.test.js | 29 +- .../sharedComponents/FileInput/index.jsx | 22 +- src/editors/utils/index.js | 1 + src/editors/utils/removeOnce.js | 13 + 34 files changed, 1286 insertions(+), 976 deletions(-) delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx rename src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/{LanguageSelect.test.jsx => LanguageSelector.test.jsx} (81%) create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.test.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.jsx delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.jsx delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.test.jsx delete 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__/LanguageSelector.test.jsx.snap create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/Transcript.test.jsx.snap create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptActionMenu.test.jsx.snap delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptListItem.test.jsx.snap delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.js delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.test.jsx create mode 100644 src/editors/utils/removeOnce.js diff --git a/src/editors/containers/EditorContainer/components/TitleHeader/index.jsx b/src/editors/containers/EditorContainer/components/TitleHeader/index.jsx index e764220d6..f0f0f5104 100644 --- a/src/editors/containers/EditorContainer/components/TitleHeader/index.jsx +++ b/src/editors/containers/EditorContainer/components/TitleHeader/index.jsx @@ -50,8 +50,8 @@ export const TitleHeader = ({ { - 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/LanguageSelector.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx new file mode 100644 index 000000000..d10871caf --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx @@ -0,0 +1,93 @@ +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 { thunkActions, selectors } from '../../../../../../data/redux'; +import { videoTranscriptLanguages } from '../../../../../../data/constants/video'; +import { FileInput, fileInput } from '../../../../../../sharedComponents/FileInput'; +import messages from './messages'; +import * as module from './LanguageSelector'; + +export const hooks = { + onSelectLanguage: ({ + dispatch, languageBeforeChange, triggerupload, setLocalLang, + }) => (event) => { + // IF Language is unset, set language and begin upload prompt. + setLocalLang(event.target.value); + if (languageBeforeChange === '') { + triggerupload(); + return; + } + // Else: update language + dispatch( + thunkActions.video.updateTranscriptLanguage({ + newLanguageCode: event.target.value, languageBeforeChange, + }), + ); + }, + + addFileCallback: ({ dispatch, localLang }) => (file) => { + dispatch(thunkActions.video.uploadTranscript({ + file, + filename: file.name, + language: localLang, + })); + }, + +}; + +export const LanguageSelector = ({ + index, // For a unique id for the form control + language, + // Redux + openLanguages, // Only allow those languages not already associated with a transcript to be selected + // intl + intl, + +}) => { + const [localLang, setLocalLang] = React.useState(language); + const input = fileInput({ onAddFile: hooks.addFileCallback({ dispatch: useDispatch(), localLang }) }); + const onLanguageChange = module.hooks.onSelectLanguage({ + dispatch: useDispatch(), languageBeforeChange: localLang, setLocalLang, triggerupload: input.click, + }); + + 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 (); + })} + + + +
+ ); +}; + +LanguageSelector.defaultProps = { + openLanguages: [], +}; + +LanguageSelector.propTypes = { + openLanguages: PropTypes.arrayOf(PropTypes.string), + index: PropTypes.number.isRequired, + language: PropTypes.string.isRequired, + intl: intlShape.isRequired, +}; + +export const mapStateToProps = (state) => ({ + openLanguages: selectors.video.openLanguages(state), +}); + +export const mapDispatchToProps = {}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(LanguageSelector)); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx similarity index 81% rename from src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.test.jsx rename to src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx index 0b97f0589..aaadf0645 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelect.test.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { LanguageSelect } from './LanguageSelect'; +import { LanguageSelector } from './LanguageSelector'; import { formatMessage } from '../../../../../../../testUtils'; const lang1 = 'kLinGon'; @@ -18,7 +18,7 @@ jest.mock('../../../../../../data/constants/video', () => ({ }, })); -describe('LanguageSelect', () => { +describe('LanguageSelector', () => { const props = { intl: { formatMessage }, onSelect: jest.fn().mockName('props.OnSelect'), @@ -30,14 +30,14 @@ describe('LanguageSelect', () => { describe('snapshot', () => { test('transcript option', () => { expect( - shallow(), + shallow(), ).toMatchSnapshot(); }); }); describe('snapshots -- no', () => { test('transcripts no Open Languages, all should be disabled', () => { expect( - shallow(), + shallow(), ).toMatchSnapshot(); }); }); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.jsx new file mode 100644 index 000000000..325e37848 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.jsx @@ -0,0 +1,112 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { connect } from 'react-redux'; + +import { + Card, Button, IconButton, Row, + Icon, +} from '@edx/paragon'; +import { Delete } from '@edx/paragon/icons'; + +import { + FormattedMessage, + injectIntl, +} from '@edx/frontend-platform/i18n'; +import { thunkActions } from '../../../../../../data/redux'; + +import TranscriptActionMenu from './TranscriptActionMenu'; +import LanguageSelector from './LanguageSelector'; +import * as module from './Transcript'; +import messages from './messages'; + +export const hooks = { + state: { + inDeleteConfirmation: (args) => React.useState(args), + }, + setUpDeleteConfirmation: () => { + const [inDeleteConfirmation, setInDeleteConfirmation] = module.hooks.state.inDeleteConfirmation(false); + return { + inDeleteConfirmation, + launchDeleteConfirmation: () => setInDeleteConfirmation(true), + cancelDelete: () => setInDeleteConfirmation(false), + }; + }, +}; + +export const Transcript = ({ + index, + language, + // redux + deleteTranscript, +}) => { + const { inDeleteConfirmation, launchDeleteConfirmation, cancelDelete } = module.hooks.setUpDeleteConfirmation(); + return ( + <> + {inDeleteConfirmation + ? ( + + )} /> + + + + + + + + + + + ) + : ( + + + { language === '' ? ( + launchDeleteConfirmation()} + /> + ) : ( + + )} + + + )} + + ); +}; + +Transcript.propTypes = { + index: PropTypes.number.isRequired, + language: PropTypes.string.isRequired, + deleteTranscript: PropTypes.func.isRequired, +}; + +export const mapStateToProps = () => ({ +}); +export const mapDispatchToProps = { + deleteTranscript: thunkActions.video.deleteTranscript, +}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Transcript)); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.test.jsx new file mode 100644 index 000000000..428532179 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.test.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import * as module from './Transcript'; + +import { MockUseState } from '../../../../../../../testUtils'; + +jest.mock('./LanguageSelector', () => 'LanguageSelector'); +jest.mock('./TranscriptActionMenu', () => 'TranscriptActionMenu'); + +describe('Transcript Component', () => { + describe('state hooks', () => { + const state = new MockUseState(module.hooks); + + 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.hooks.setUpDeleteConfirmation().inDeleteConfirmation).toEqual(false); + }); + test('inDeleteConfirmation setters: launch', () => { + module.hooks.setUpDeleteConfirmation().launchDeleteConfirmation(); + expect(state.setState[state.keys.inDeleteConfirmation]).toHaveBeenCalledWith(true); + }); + test('inDeleteConfirmation setters: cancel', () => { + module.hooks.setUpDeleteConfirmation().cancelDelete(); + expect(state.setState[state.keys.inDeleteConfirmation]).toHaveBeenCalledWith(false); + }); + }); + }); + + describe('component', () => { + describe('component', () => { + const props = { + index: 'sOmenUmBer', + language: 'lAnG', + deleteTranscript: jest.fn().mockName('thunkActions.video.deleteTranscript'), + }; + afterAll(() => { + jest.clearAllMocks(); + }); + test('snapshots: renders as expected with default props: dont show confirm delete', () => { + jest.spyOn(module.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: dont show confirm delete, language is blank so delete is shown instead of action menu', () => { + jest.spyOn(module.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(module.hooks, 'setUpDeleteConfirmation').mockImplementationOnce(() => ({ + inDeleteConfirmation: true, + launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'), + cancelDelete: jest.fn().mockName('cancelDelete'), + })); + expect( + shallow(), + ).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.jsx new file mode 100644 index 000000000..625ea99fb --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect, useDispatch } from 'react-redux'; + +import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; +import { Dropdown, Icon, IconButton } from '@edx/paragon'; +import { MoreHoriz } from '@edx/paragon/icons'; + +import { thunkActions, selectors } from '../../../../../../data/redux'; + +import { FileInput, fileInput } from '../../../../../../sharedComponents/FileInput'; +import * as module from './TranscriptActionMenu'; +import messages from './messages'; + +export const hooks = { + replaceFileCallback: ({ language, dispatch }) => (file) => { + dispatch(thunkActions.video.replaceTranscript({ + newFile: file, + newFilename: file.name, + language, + })); + }, +}; + +export const TranscriptActionMenu = ({ + index, + language, + launchDeleteConfirmation, + // redux + getTranscriptDownloadUrl, + +}) => { + const input = fileInput({ onAddFile: module.hooks.replaceFileCallback({ language, dispatch: useDispatch() }) }); + const downloadLink = getTranscriptDownloadUrl({ language }); + return ( + + + + + + + + + + + + + + + + ); +}; + +TranscriptActionMenu.propTypes = { + index: PropTypes.number.isRequired, + language: PropTypes.string.isRequired, + launchDeleteConfirmation: PropTypes.func.isRequired, + // redux + getTranscriptDownloadUrl: PropTypes.func.isRequired, +}; + +export const mapStateToProps = (state) => ({ + getTranscriptDownloadUrl: selectors.video.getTranscriptDownloadUrl(state), +}); + +export const mapDispatchToProps = { + downloadTranscript: thunkActions.video.downloadTranscript, +}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TranscriptActionMenu)); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.jsx new file mode 100644 index 000000000..32936179f --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { thunkActions, selectors } from '../../../../../../data/redux'; + +import * as module from './TranscriptActionMenu'; + +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('thunkActions.video.deleteTranscript'), + replaceTranscript: jest.fn((args) => ({ replaceTranscript: args })).mockName('thunkActions.video.replaceTranscript'), + downloadTranscript: jest.fn().mockName('thunkActions.video.downloadTranscript'), + }, + }, + selectors: { + video: { + getTranscriptDownloadUrl: jest.fn(args => ({ getTranscriptDownloadUrl: args })).mockName('selectors.video.getTranscriptDownloadUrl'), + }, + }, +})); + +jest.mock('../../../../../../sharedComponents/FileInput', () => ({ + FileInput: 'FileInput', + fileInput: jest.fn((args) => ({ click: jest.fn().mockName('click input'), onAddFile: args.onAddFile })), +})); + +describe('TranscriptActionMenu', () => { + describe('hooks', () => { + describe('replaceFileCallback', () => { + const lang1Code = 'coDe'; + 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.hooks.replaceFileCallback({ + dispatch: mockDispatch, language: lang1Code, + }); + cb(mockEvent); + expect(thunkActions.video.replaceTranscript).toHaveBeenCalledWith(result); + expect(mockDispatch).toHaveBeenCalledWith({ replaceTranscript: result }); + }); + }); + }); + + describe('Snapshots', () => { + const props = { + index: 'sOmenUmBer', + language: 'lAnG', + launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'), + // redux + getTranscriptDownloadUrl: jest.fn().mockName('selectors.video.getTranscriptDownloadUrl'), + }; + afterAll(() => { + jest.clearAllMocks(); + }); + test('snapshots: renders as expected with default props: dont show confirm delete', () => { + jest.spyOn(module.hooks, 'replaceFileCallback').mockImplementationOnce(() => jest.fn().mockName('module.hooks.replaceFileCallback')); + expect( + shallow(), + ).toMatchSnapshot(); + }); + }); + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('getTranscriptDownloadUrl from video.getTranscriptDownloadUrl', () => { + expect( + module.mapStateToProps(testState).getTranscriptDownloadUrl, + ).toEqual(selectors.video.getTranscriptDownloadUrl(testState)); + }); + }); + describe('mapDispatchToProps', () => { + test('deleteTranscript from thunkActions.video.deleteTranscript', () => { + expect(module.mapDispatchToProps.downloadTranscript).toEqual(thunkActions.video.downloadTranscript); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.jsx deleted file mode 100644 index 151fed0f4..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.jsx +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 866d776fd..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptListItem.test.jsx +++ /dev/null @@ -1,82 +0,0 @@ -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('thunkActions.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 deleted file mode 100644 index 5b13edc23..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelect.test.jsx.snap +++ /dev/null @@ -1,65 +0,0 @@ -// 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__/LanguageSelector.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelector.test.jsx.snap new file mode 100644 index 000000000..801f7f291 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/LanguageSelector.test.jsx.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LanguageSelector snapshot transcript option 1`] = ` +
+ + + + + + + + +
+`; + +exports[`LanguageSelector snapshots -- no transcripts no Open Languages, all should be disabled 1`] = ` +
+ + + + + + + + +
+`; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/Transcript.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/Transcript.test.jsx.snap new file mode 100644 index 000000000..832e9b51b --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/Transcript.test.jsx.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Transcript Component component component snapshots: renders as expected with default props: dont show confirm delete 1`] = ` + + + + + + +`; + +exports[`Transcript Component component component snapshots: renders as expected with default props: dont show confirm delete, language is blank so delete is shown instead of action menu 1`] = ` + + + + + + +`; + +exports[`Transcript Component component component snapshots: renders as expected with default props: show confirm delete 1`] = ` + + + + } + /> + + + + + + + + + + + +`; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptActionMenu.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptActionMenu.test.jsx.snap new file mode 100644 index 000000000..0e305fd12 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptActionMenu.test.jsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TranscriptActionMenu Snapshots snapshots: renders as expected with default props: dont show confirm delete 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 deleted file mode 100644 index beaeb2e42..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/__snapshots__/TranscriptListItem.test.jsx.snap +++ /dev/null @@ -1,124 +0,0 @@ -// 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 e00e4cede..42bfab5c7 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,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with delete error message 1`] = ` - -
- - + + - + `; -exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with upload error message 1`] = ` - - +
@@ -200,34 +199,27 @@ exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with upload err - - + + - + `; -exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTranscriptDownloads true 1`] = ` - -
- - + + - + `; -exports[`TranscriptWidget snapshots snapshots: renders as expected with default props 1`] = ` - - - + + - + `; -exports[`TranscriptWidget snapshots snapshots: renders as expected with showTranscriptByDefault true 1`] = ` - -
- - + + - + `; -exports[`TranscriptWidget snapshots snapshots: renders as expected with transcripts 1`] = ` - -
- - + + - + `; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.js deleted file mode 100644 index e6601ff38..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.js +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import { thunkActions, actions } from '../../../../../../data/redux'; -import * as module from './hooks'; -import { videoTranscriptLanguages } from '../../../../../../data/constants/video'; -import { ErrorContext } from '../../../../hooks'; -import messages from './messages'; - -export const state = { - inDeleteConfirmation: (args) => React.useState(args), -}; - -export const updateErrors = ({ isUploadError, isDeleteError }) => { - const [error, setError] = React.useContext(ErrorContext).transcripts; - if (isUploadError) { - setError({ ...error, uploadError: messages.uploadTranscriptError.defaultMessage }); - } - if (isDeleteError) { - setError({ ...error, deleteError: messages.deleteTranscriptError.defaultMessage }); - } -}; - -export const transcriptLanguages = (transcripts) => { - const languages = []; - if (transcripts && Object.keys(transcripts).length > 0) { - Object.keys(transcripts).forEach(transcript => { - languages.push(videoTranscriptLanguages[transcript]); - }); - return languages.join(', '); - } - return 'None'; -}; - -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 file = e.target.files[0]; - if (file) { - onAddFile(file); - } - }; - return { - click, - addFile, - ref, - }; -}; - -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 deleted file mode 100644 index 30ba3df05..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/hooks.test.jsx +++ /dev/null @@ -1,129 +0,0 @@ -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 595f289e7..de3b4cf8e 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/index.jsx @@ -1,9 +1,10 @@ import React from 'react'; -import { connect, useDispatch } from 'react-redux'; +import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { FormattedMessage, injectIntl, + intlShape, } from '@edx/frontend-platform/i18n'; import { Form, @@ -14,20 +15,61 @@ import { Tooltip, Alert, } from '@edx/paragon'; -import { FileUpload, Info } from '@edx/paragon/icons'; +import { Add, Info } from '@edx/paragon/icons'; import { actions, selectors } from '../../../../../../data/redux'; -import * as hooks from './hooks'; import messages from './messages'; import { RequestKeys } from '../../../../../../data/constants/requests'; +import { in8lTranscriptLanguages } from '../../../../../../data/constants/video'; -import FileInput from '../../../../../../sharedComponents/FileInput'; import ErrorAlert from '../../../../../../sharedComponents/ErrorAlerts/ErrorAlert'; import CollapsibleFormWidget from '../CollapsibleFormWidget'; -import TranscriptListItem from './TranscriptListItem'; +import Transcript from './Transcript'; import { ErrorContext } from '../../../../hooks'; +import * as module from './index'; + +export const hooks = { + updateErrors: ({ isUploadError, isDeleteError }) => { + const [error, setError] = React.useContext(ErrorContext).transcripts; + if (isUploadError) { + setError({ ...error, uploadError: messages.uploadTranscriptError.defaultMessage }); + } + if (isDeleteError) { + setError({ ...error, deleteError: messages.deleteTranscriptError.defaultMessage }); + } + }, + transcriptLanguages: (transcripts, intl) => { + const languages = []; + if (transcripts && transcripts.length > 0) { + const fullTextTranslatedStrings = in8lTranscriptLanguages(intl); + transcripts.forEach(transcript => { + if (!(transcript === '')) { + languages.push(fullTextTranslatedStrings[transcript]); + } + }); + + return languages.join(', '); + } + return 'None'; + }, + hasTranscripts: (transcripts) => { + if (transcripts && transcripts.length > 0) { + return true; + } + return false; + }, + onAddNewTranscript: ({ transcripts, updateField }) => { + // keep blank lang code for now, will be updated once lang is selected. + if (!transcripts) { + updateField({ transcripts: [''] }); + return; + } + const newTranscripts = [...transcripts, '']; + updateField({ transcripts: newTranscripts }); + }, +}; /** * Collapsible Form widget controlling video transcripts @@ -40,15 +82,16 @@ export const TranscriptWidget = ({ updateField, isUploadError, isDeleteError, + // intl + intl, }) => { const [error] = React.useContext(ErrorContext).transcripts; - const languagesArr = hooks.transcriptLanguages(transcripts); - const fileInput = hooks.fileInput({ onAddFile: hooks.addFileCallback({ dispatch: useDispatch() }) }); - const hasTranscripts = hooks.hasTranscripts(transcripts); + const fullTextLanguages = module.hooks.transcriptLanguages(transcripts, intl); + const hasTranscripts = module.hooks.hasTranscripts(transcripts); return ( - { Object.entries(transcripts).map(([language, value]) => ( - ( + ))}
@@ -113,10 +156,16 @@ export const TranscriptWidget = ({ )} - - + + + + ); @@ -126,12 +175,13 @@ TranscriptWidget.defaultProps = { }; TranscriptWidget.propTypes = { // redux - transcripts: PropTypes.shape({}).isRequired, + transcripts: PropTypes.arrayOf(PropTypes.string).isRequired, allowTranscriptDownloads: PropTypes.bool.isRequired, showTranscriptByDefault: PropTypes.bool.isRequired, updateField: PropTypes.func.isRequired, isUploadError: PropTypes.bool.isRequired, isDeleteError: PropTypes.bool.isRequired, + intl: PropTypes.shape(intlShape).isRequired, }; export const mapStateToProps = (state) => ({ transcripts: selectors.video.transcripts(state), 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 2615b9ae5..018e396b9 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 @@ -5,7 +5,7 @@ import { RequestKeys } from '../../../../../../data/constants/requests'; import { formatMessage } from '../../../../../../../testUtils'; import { actions, selectors } from '../../../../../../data/redux'; -import { TranscriptWidget, mapStateToProps, mapDispatchToProps } from '.'; +import * as module from './index'; jest.mock('react', () => ({ ...jest.requireActual('react'), @@ -36,85 +36,130 @@ jest.mock('../../../../../../data/redux', () => ({ }, }, })); +jest.mock('../CollapsibleFormWidget', () => 'CollapsibleFormWidget'); +jest.mock('./Transcript', () => 'Transcript'); describe('TranscriptWidget', () => { - const props = { - error: {}, - subtitle: 'SuBTItle', - title: 'tiTLE', - intl: { formatMessage }, - transcripts: {}, - allowTranscriptDownloads: false, - showTranscriptByDefault: false, - updateField: jest.fn().mockName('args.updateField'), - isUploadError: false, - isDeleteError: false, - }; + describe('hooks', () => { + describe('transcriptLanguages', () => { + test('empty list of transcripts returns ', () => { + expect(module.hooks.transcriptLanguages([])).toEqual('None'); + }); + test('unset gives none', () => { + expect(module.hooks.transcriptLanguages(['', ''])).toEqual(''); + }); + test('en gives English', () => { + expect(module.hooks.transcriptLanguages(['en'])).toEqual('English'); + }); + test('en, FR gives English, French', () => { + expect(module.hooks.transcriptLanguages(['en', 'fr'])).toEqual('English, French'); + }); + }); + describe('hasTranscripts', () => { + test('null returns false ', () => { + expect(module.hooks.hasTranscripts(null)).toEqual(false); + }); + test('empty list returns false', () => { + expect(module.hooks.hasTranscripts([])).toEqual(false); + }); + test('content returns true', () => { + expect(module.hooks.hasTranscripts(['en'])).toEqual(true); + }); + }); + describe('onAddNewTranscript', () => { + const mockUpdateField = jest.fn(); + test('null returns [empty string] ', () => { + module.hooks.onAddNewTranscript({ transcripts: null, updateField: mockUpdateField }); + expect(mockUpdateField).toHaveBeenCalledWith({ transcripts: [''] }); + }); + test(' transcripts return list with blank added', () => { + const mocklist = ['en', 'fr', 3]; + module.hooks.onAddNewTranscript({ transcripts: mocklist, updateField: mockUpdateField }); - describe('snapshots', () => { - test('snapshots: renders as expected with default props', () => { - expect( - shallow(), - ).toMatchSnapshot(); - }); - test('snapshots: renders as expected with transcripts', () => { - expect( - shallow(), - ).toMatchSnapshot(); - }); - test('snapshots: renders as expected with allowTranscriptDownloads true', () => { - expect( - shallow(), - ).toMatchSnapshot(); - }); - test('snapshots: renders as expected with showTranscriptByDefault true', () => { - expect( - shallow(), - ).toMatchSnapshot(); - }); - test('snapshot: renders ErrorAlert with upload error message', () => { - expect( - shallow(), - ).toMatchSnapshot(); - }); - test('snapshot: renders ErrorAlert with delete error message', () => { - expect( - shallow(), - ).toMatchSnapshot(); + expect(mockUpdateField).toHaveBeenCalledWith({ transcripts: ['en', 'fr', 3, ''] }); + }); }); }); - describe('mapStateToProps', () => { - const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; - test('transcripts from video.transcript', () => { - expect( - mapStateToProps(testState).transcripts, - ).toEqual(selectors.video.transcripts(testState)); + + describe('component', () => { + const props = { + error: {}, + subtitle: 'SuBTItle', + title: 'tiTLE', + intl: { formatMessage }, + transcripts: [], + allowTranscriptDownloads: false, + showTranscriptByDefault: false, + updateField: jest.fn().mockName('args.updateField'), + isUploadError: false, + isDeleteError: false, + }; + + describe('snapshots', () => { + test('snapshots: renders as expected with default props', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with transcripts', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with allowTranscriptDownloads true', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + test('snapshots: renders as expected with showTranscriptByDefault true', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + test('snapshot: renders ErrorAlert with upload error message', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + test('snapshot: renders ErrorAlert with delete error message', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); }); - test('allowTranscriptDownloads from video.allowTranscriptDownloads', () => { - expect( - mapStateToProps(testState).allowTranscriptDownloads, - ).toEqual(selectors.video.allowTranscriptDownloads(testState)); + describe('mapStateToProps', () => { + const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; + test('transcripts from video.transcript', () => { + expect( + module.mapStateToProps(testState).transcripts, + ).toEqual(selectors.video.transcripts(testState)); + }); + test('allowTranscriptDownloads from video.allowTranscriptDownloads', () => { + expect( + module.mapStateToProps(testState).allowTranscriptDownloads, + ).toEqual(selectors.video.allowTranscriptDownloads(testState)); + }); + test('showTranscriptByDefault from video.showTranscriptByDefault', () => { + expect( + module.mapStateToProps(testState).showTranscriptByDefault, + ).toEqual(selectors.video.showTranscriptByDefault(testState)); + }); + test('isUploadError from requests.isFinished', () => { + expect( + module.mapStateToProps(testState).isUploadError, + ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.uploadTranscript })); + }); + test('isDeleteError from requests.isFinished', () => { + expect( + module.mapStateToProps(testState).isDeleteError, + ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.deleteTranscript })); + }); }); - test('showTranscriptByDefault from video.showTranscriptByDefault', () => { - expect( - 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(); - test('updateField from actions.video.updateField', () => { - expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField)); + describe('mapDispatchToProps', () => { + const dispatch = jest.fn(); + test('updateField from actions.video.updateField', () => { + expect(module.mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField)); + }); }); }); }); 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 32ebfafff..64c0246e9 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js @@ -1,7 +1,7 @@ export const messages = { uploadButtonLabel: { id: 'authoring.videoeditor.transcripts.upload.label', - defaultMessage: 'Upload Transcript', + defaultMessage: 'Add a transcript', description: 'Label for upload button', }, addFirstTranscript: { diff --git a/src/editors/data/constants/requests.js b/src/editors/data/constants/requests.js index 6cd0d5d07..b105f0c5d 100644 --- a/src/editors/data/constants/requests.js +++ b/src/editors/data/constants/requests.js @@ -20,4 +20,6 @@ export const RequestKeys = StrictDict({ uploadTranscript: 'uploadTranscript', deleteTranscript: 'deleteTranscript', fetchCourseDetails: 'fetchCourseDetails', + updateTranscriptLanguage: 'updateTranscriptLanguage', + getTranscriptFile: 'getTranscriptFile', }); diff --git a/src/editors/data/constants/video.js b/src/editors/data/constants/video.js index 21f101227..a0f8e18b7 100644 --- a/src/editors/data/constants/video.js +++ b/src/editors/data/constants/video.js @@ -189,6 +189,22 @@ export const videoTranscriptLanguages = StrictDict({ zu: 'Zulu', }); +export const in8lTranscriptLanguages = (intl) => { + const messageLookup = {}; + // for tests and non-internationlized setups, return en + if (!intl?.formatMessage) { + return videoTranscriptLanguages; + } + Object.keys(videoTranscriptLanguages).forEach((code) => { + messageLookup[code] = intl.formatMessage({ + id: `authoring.videoeditor.transcripts.language.${code}`, + defaultMessage: videoTranscriptLanguages[code], + description: `Name of Language called in English ${videoTranscriptLanguages[code]}`, + }); + }); + return messageLookup; +}; + export const timeKeys = StrictDict({ startTime: 'startTime', stopTime: 'stopTime', diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js index 69c383286..36bf38471 100644 --- a/src/editors/data/redux/thunkActions/requests.js +++ b/src/editors/data/redux/thunkActions/requests.js @@ -190,14 +190,47 @@ export const uploadTranscript = ({ })); }; +export const updateTranscriptLanguage = ({ + file, + languageBeforeChange, + newLanguageCode, + videoId, + ...rest +}) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.updateTranscriptLanguage, + promise: api.uploadTranscript({ + blockId: selectors.app.blockId(getState()), + transcript: file, + videoId, + language: languageBeforeChange, + newLanguage: newLanguageCode, + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + }), + ...rest, + })); +}; + +export const getTranscriptFile = ({ language, videoId, ...rest }) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.getTranscriptFile, + promise: api.getTranscript({ + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + blockId: selectors.app.blockId(getState()), + videoId, + language, + }), + ...rest, + })); +}; + export const fetchCourseDetails = ({ ...rest }) => (dispatch, getState) => { dispatch(module.networkRequest({ requestKey: RequestKeys.fetchCourseDetails, - promise: api - .fetchCourseDetails({ - studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), - learningContextId: selectors.app.learningContextId(getState()), - }), + promise: api.fetchCourseDetails({ + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + learningContextId: selectors.app.learningContextId(getState()), + }), ...rest, })); }; @@ -213,5 +246,7 @@ export default StrictDict({ uploadThumbnail, deleteTranscript, uploadTranscript, + updateTranscriptLanguage, fetchCourseDetails, + getTranscriptFile, }); diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js index e5b7007ae..7f5762f36 100644 --- a/src/editors/data/redux/thunkActions/requests.test.js +++ b/src/editors/data/redux/thunkActions/requests.test.js @@ -33,6 +33,7 @@ jest.mock('../../services/cms/api', () => ({ uploadThumbnail: jest.fn(), uploadTranscript: jest.fn(), deleteTranscript: jest.fn(), + getTranscript: jest.fn(), })); const apiKeys = keyStore(api); @@ -351,6 +352,52 @@ describe('requests thunkActions module', () => { }, }); }); + describe('getTranscriptFile', () => { + const language = 'SoME laNGUage CoNtent As String'; + const videoId = 'SoME VidEOid CoNtent As String'; + testNetworkRequestAction({ + action: requests.getTranscriptFile, + args: { language, videoId, ...fetchParams }, + expectedString: 'with getTranscriptFile promise', + expectedData: { + ...fetchParams, + requestKey: RequestKeys.getTranscriptFile, + promise: api.getTranscript({ + blockId: selectors.app.blockId(testState), + language, + videoId, + studioEndpointUrl: selectors.app.studioEndpointUrl(testState), + }), + }, + }); + }); + describe('updateTranscriptLanguage', () => { + const languageBeforeChange = 'SoME laNGUage CoNtent As String'; + const newLanguageCode = 'SoME NEW laNGUage CoNtent As String'; + const videoId = 'SoME VidEOid CoNtent As String'; + testNetworkRequestAction({ + action: requests.updateTranscriptLanguage, + args: { + languageBeforeChange, + newLanguageCode, + videoId, + ...fetchParams, + }, + expectedString: 'with uploadTranscript promise', + expectedData: { + ...fetchParams, + requestKey: RequestKeys.updateTranscriptLanguage, + promise: api.uploadTranscript({ + blockId: selectors.app.blockId(testState), + videoId, + language: languageBeforeChange, + newLanguage: newLanguageCode, + studioEndpointUrl: selectors.app.studioEndpointUrl(testState), + }), + }, + }); + }); + describe('uploadTranscript', () => { const language = 'SoME laNGUage CoNtent As String'; const videoId = 'SoME VidEOid CoNtent As String'; diff --git a/src/editors/data/redux/thunkActions/video.js b/src/editors/data/redux/thunkActions/video.js index aa10c5dfa..b41c72b4d 100644 --- a/src/editors/data/redux/thunkActions/video.js +++ b/src/editors/data/redux/thunkActions/video.js @@ -1,4 +1,5 @@ import { actions, selectors } from '..'; +import { removeItemOnce } from '../../../utils'; import * as requests from './requests'; import * as module from './video'; import { valueFromDuration } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/duration'; @@ -7,7 +8,7 @@ export const loadVideoData = () => (dispatch, getState) => { const state = getState(); const rawVideoData = state.app.blockValue.data.metadata ? state.app.blockValue.data.metadata : {}; const courseLicenseData = state.app.courseDetails.data ? state.app.courseDetails.data : {}; - const licenseData = state.app.studioView?.data?.html; + const studioView = state.app.studioView?.data?.html; const { videoSource, videoType, @@ -18,18 +19,20 @@ export const loadVideoData = () => (dispatch, getState) => { youtubeId: rawVideoData.youtube_id_1_0, html5Sources: rawVideoData.html5_sources, }); - const [licenseType, licenseOptions] = module.parseLicense({ licenseData, level: 'block' }); + const [licenseType, licenseOptions] = module.parseLicense({ licenseData: studioView, level: 'block' }); + const transcripts = module.parseTranscripts({ transcriptsData: studioView }); const [courseLicenseType, courseLicenseDetails] = module.parseLicense({ licenseData: courseLicenseData.license, level: 'course', }); + dispatch(actions.video.load({ videoSource, videoType, videoId, fallbackVideos, allowVideoDownloads: rawVideoData.download_video, - transcripts: rawVideoData.transcripts || {}, + transcripts, allowTranscriptDownloads: rawVideoData.download_track, showTranscriptByDefault: rawVideoData.show_captions, duration: { // TODO duration is not always sent so they should be calculated. @@ -100,6 +103,16 @@ export const determineVideoSource = ({ }; }; +export const parseTranscripts = ({ transcriptsData }) => { + if (!transcriptsData) { + return []; + } + const startString = 'language.", "value": '; + const cleanedStr = transcriptsData.replace(/"/g, '"'); + const metadataStr = cleanedStr.substring(cleanedStr.indexOf(startString) + startString.length, cleanedStr.indexOf(', "type": "VideoTranslations"')); + return Object.keys(JSON.parse(metadataStr)); +}; + // partially copied from frontend-app-learning/src/courseware/course/course-license/CourseLicense.jsx export const parseLicense = ({ licenseData, level }) => { if (!licenseData) { @@ -205,31 +218,32 @@ export const uploadHandout = ({ file }) => (dispatch) => { // Transcript Thunks: -export const uploadTranscript = ({ language, filename, file }) => (dispatch, getState) => { +export const uploadTranscript = ({ language, file }) => (dispatch, getState) => { const state = getState(); const { transcripts, videoId } = state.video; - let lang = language; - if (!language) { - [[lang]] = selectors.video.openLanguages(state); - } + // Remove the placeholder '' from the unset language from the list of transcripts. + const transcriptsPlaceholderRemoved = (transcripts === []) ? transcripts : removeItemOnce(transcripts, ''); + dispatch(requests.uploadTranscript({ - language: lang, + language, videoId, transcript: file, onSuccess: (response) => { - dispatch(actions.video.updateField({ - transcripts: { - ...transcripts, - [lang]: { filename }, - }, - })); + // if we aren't replacing, add the language to the redux store. + if (!transcriptsPlaceholderRemoved.includes(language)) { + dispatch(actions.video.updateField({ + transcripts: [ + ...transcriptsPlaceholderRemoved, + language], + })); + } + if (selectors.video.videoId(state) === '') { dispatch(actions.video.updateField({ - videoId: response.edx_video_id, + videoId: response.data.edx_video_id, })); } }, - })); }; @@ -240,31 +254,43 @@ export const deleteTranscript = ({ language }) => (dispatch, getState) => { language, videoId, onSuccess: () => { - const updateTranscripts = {}; - Object.keys(transcripts).forEach((key) => { - if (key !== language) { - updateTranscripts[key] = transcripts[key]; - } - }); - dispatch(actions.video.updateField({ transcripts: updateTranscripts })); + const updatedTranscripts = transcripts.filter((langCode) => langCode !== language); + dispatch(actions.video.updateField({ transcripts: updatedTranscripts })); + }, + })); +}; + +export const updateTranscriptLanguage = ({ newLanguageCode, languageBeforeChange }) => (dispatch, getState) => { + const state = getState(); + const { video: { transcripts, videoId } } = state; + selectors.video.getTranscriptDownloadUrl(state); + dispatch(requests.getTranscriptFile({ + videoId, + language: languageBeforeChange, + onSuccess: (response) => { + dispatch(requests.updateTranscriptLanguage({ + languageBeforeChange, + file: new File([new Blob([response.data], { type: 'text/plain' })], `${videoId}_${newLanguageCode}.srt`, { type: 'text/plain' }), + newLanguageCode, + videoId, + onSuccess: () => { + const newTranscripts = transcripts + .filter(transcript => transcript !== languageBeforeChange); + newTranscripts.push(newLanguageCode); + dispatch(actions.video.updateField({ transcripts: newTranscripts })); + }, + })); }, })); }; export const replaceTranscript = ({ newFile, newFilename, language }) => (dispatch, getState) => { const state = getState(); - const { transcripts, videoId } = state.video; + const { 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 })); }, })); @@ -278,6 +304,7 @@ export default { uploadThumbnail, uploadTranscript, deleteTranscript, + updateTranscriptLanguage, replaceTranscript, uploadHandout, }; diff --git a/src/editors/data/redux/thunkActions/video.test.js b/src/editors/data/redux/thunkActions/video.test.js index 718185c01..94b87bc1f 100644 --- a/src/editors/data/redux/thunkActions/video.test.js +++ b/src/editors/data/redux/thunkActions/video.test.js @@ -1,5 +1,5 @@ import { actions } from '..'; -import { keyStore } from '../../../utils'; +import keyStore from '../../../utils/keyStore'; import * as thunkActions from './video'; jest.mock('..', () => ({ @@ -16,6 +16,7 @@ jest.mock('..', () => ({ video: { videoId: (state) => ({ videoId: state }), videoSettings: (state) => ({ videoSettings: state }), + getTranscriptDownloadUrl: (state) => ({ getTranscriptDownloadUrl: state }), }, }, })); @@ -25,10 +26,17 @@ jest.mock('./requests', () => ({ uploadThumbnail: (args) => ({ uploadThumbnail: args }), deleteTranscript: (args) => ({ deleteTranscript: args }), uploadTranscript: (args) => ({ uploadTranscript: args }), + getTranscriptFile: (args) => ({ getTranscriptFile: args }), + updateTranscriptLanguage: (args) => ({ updateTranscriptLanguage: args }), })); + +jest.mock('../../../utils', () => ({ + removeItemOnce: (args) => (args), +})); + const thunkActionsKeys = keyStore(thunkActions); -const mockLanguage = 'la'; +const mockLanguage = 'na'; const mockFile = 'soMEtRANscRipT'; const mockFilename = 'soMEtRANscRipT.srt'; const mockThumbnail = 'sOMefILE'; @@ -46,16 +54,16 @@ const testMetadata = { license: 'liCENse', show_captions: 'shOWcapTIONS', start_time: 0, - transcripts: { la: 'test VALUE' }, + transcripts: ['do', 're', 'mi'], thumbnail: 'thuMBNaIl', }; const testState = { - transcripts: { la: 'test VALUE' }, + transcripts: ['la'], thumbnail: 'sOMefILE', originalThumbnail: null, videoId: 'soMEvIDEo', }; -const testUpload = { transcripts: { la: { filename: mockFilename } } }; +const testUpload = { transcripts: ['la', 'na'] }; const testReplaceUpload = { file: mockFile, language: mockLanguage, @@ -97,6 +105,9 @@ describe('video thunkActions', () => { sa: false, }, ]); + jest.spyOn(thunkActions, thunkActionsKeys.parseTranscripts).mockReturnValue( + testMetadata.transcripts, + ); thunkActions.loadVideoData()(dispatch, getState); [[dispatchedLoad], [dispatchedAction]] = dispatch.mock.calls; }); @@ -320,7 +331,7 @@ describe('video thunkActions', () => { }); describe('deleteTranscript', () => { beforeEach(() => { - thunkActions.deleteTranscript({ language: mockLanguage })(dispatch, getState); + thunkActions.deleteTranscript({ language: 'la' })(dispatch, getState); [[dispatchedAction]] = dispatch.mock.calls; }); it('dispatches deleteTranscript action', () => { @@ -329,7 +340,7 @@ describe('video thunkActions', () => { it('dispatches actions.video.updateField on success', () => { dispatch.mockClear(); dispatchedAction.deleteTranscript.onSuccess(); - expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({ transcripts: {} })); + expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({ transcripts: [] })); }); }); describe('uploadTranscript', () => { @@ -350,11 +361,28 @@ describe('video thunkActions', () => { expect(dispatch).toHaveBeenCalledWith(actions.video.updateField(testUpload)); }); }); + describe('updateTranscriptLanguage', () => { + beforeEach(() => { + thunkActions.updateTranscriptLanguage({ + newLanguageCode: mockLanguage, + languageBeforeChange: `${mockLanguage}i`, + })(dispatch, getState); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches uploadTranscript action', () => { + expect(dispatchedAction.getTranscriptFile).not.toEqual(undefined); + }); + it('dispatches actions.video.updateField on success', () => { + dispatch.mockClear(); + dispatchedAction.getTranscriptFile.onSuccess({ data: 'sOme StRinG Data' }); + expect(dispatch).toHaveBeenCalled(); + }); + }); describe('replaceTranscript', () => { const spies = {}; beforeEach(() => { - spies.uploadTranscript = jest.spyOn(thunkActions, thunkActionsKeys.uploadTranscript) - .mockReturnValueOnce(testReplaceUpload); + spies.uploadTranscript = jest.spyOn(thunkActions, 'uploadTranscript') + .mockReturnValue(testReplaceUpload).mockName('uploadTranscript'); thunkActions.replaceTranscript({ newFile: mockFile, newFilename: mockFilename, @@ -368,9 +396,7 @@ describe('video thunkActions', () => { 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)); + expect(dispatch).toHaveBeenCalled(); }); }); }); diff --git a/src/editors/data/redux/video/reducer.js b/src/editors/data/redux/video/reducer.js index 17a94564f..2307cab49 100644 --- a/src/editors/data/redux/video/reducer.js +++ b/src/editors/data/redux/video/reducer.js @@ -12,7 +12,7 @@ const initialState = { ], allowVideoDownloads: false, thumbnail: null, - transcripts: {}, + transcripts: [], allowTranscriptDownloads: false, duration: { startTime: '00:00:00', diff --git a/src/editors/data/redux/video/selectors.js b/src/editors/data/redux/video/selectors.js index 037b91d8e..a2f2f20ab 100644 --- a/src/editors/data/redux/video/selectors.js +++ b/src/editors/data/redux/video/selectors.js @@ -37,8 +37,8 @@ export const openLanguages = createSelector( if (!transcripts) { return videoTranscriptLanguages; } - const open = Object.entries(videoTranscriptLanguages).filter( - ([lang]) => !Object.keys(transcripts).includes(lang), + const open = Object.keys(videoTranscriptLanguages).filter( + (lang) => !transcripts.includes(lang), ); return open; }, diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js index 9a7f2faea..ef9fb01b8 100644 --- a/src/editors/data/services/cms/api.js +++ b/src/editors/data/services/cms/api.js @@ -51,6 +51,19 @@ export const apiMethods = { data, ); }, + getTranscript: ({ + studioEndpointUrl, + language, + blockId, + videoId, + }) => { + const getJSON = { data: { lang: language, edx_video_id: videoId } }; + return get( + `${urls.videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`, + getJSON, + ); + }, + deleteTranscript: ({ studioEndpointUrl, language, @@ -69,12 +82,13 @@ export const apiMethods = { transcript, videoId, language, + newLanguage = null, }) => { 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); + data.append('new_language_code', newLanguage || language); return post( urls.videoTranscripts({ studioEndpointUrl, blockId }), data, @@ -159,13 +173,14 @@ export const loadImages = (rawImages) => camelizeKeys(rawImages).reduce( {}, ); -export const processVideoIds = ({ videoSource, fallbackVideos }) => { - let edxVideoId = ''; +export const processVideoIds = ({ videoSource, fallbackVideos, edxVideoId }) => { + let newEdxVideoId = edxVideoId; let youtubeId = ''; const html5Sources = []; + // overwrite videoId if source is changed. if (module.isEdxVideo(videoSource)) { - edxVideoId = videoSource; + newEdxVideoId = videoSource; } else if (module.parseYoutubeId(videoSource)) { youtubeId = module.parseYoutubeId(videoSource); } else if (videoSource) { @@ -177,7 +192,7 @@ export const processVideoIds = ({ videoSource, fallbackVideos }) => { } return { - edxVideoId, + edxVideoId: newEdxVideoId, html5Sources, youtubeId, }; diff --git a/src/editors/data/services/cms/api.test.js b/src/editors/data/services/cms/api.test.js index 1a94b1c3f..f9fb0e8c2 100644 --- a/src/editors/data/services/cms/api.test.js +++ b/src/editors/data/services/cms/api.test.js @@ -287,6 +287,21 @@ describe('cms api', () => { ); }); }); + describe('transcript get', () => { + it('should call get with urls.videoTranscripts and transcript data', () => { + const mockJSON = { data: { lang: language, edx_video_id: videoId } }; + apiMethods.getTranscript({ + blockId, + studioEndpointUrl, + videoId, + language, + }); + expect(get).toHaveBeenCalledWith( + `${urls.videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`, + mockJSON, + ); + }); + }); }); describe('processVideoIds', () => { const edxVideoId = 'eDXviDEoid'; @@ -305,6 +320,7 @@ describe('cms api', () => { }); it('returns edxVideoId when there are no fallbackVideos', () => { expect(api.processVideoIds({ + edxVideoId, videoSource: edxVideoId, fallbackVideos: [], })).toEqual({ @@ -315,6 +331,7 @@ describe('cms api', () => { }); it('returns edxVideoId and html5Sources when there are fallbackVideos', () => { expect(api.processVideoIds({ + edxVideoId, videoSource: edxVideoId, fallbackVideos: html5Sources, })).toEqual({ @@ -331,20 +348,22 @@ describe('cms api', () => { }); it('returns youtubeId when there are no fallbackVideos', () => { expect(api.processVideoIds({ + edxVideoId, videoSource: edxVideoId, fallbackVideos: [], })).toEqual({ - edxVideoId: '', + edxVideoId, html5Sources: [], youtubeId, }); }); it('returns youtubeId and html5Sources when there are fallbackVideos', () => { expect(api.processVideoIds({ + edxVideoId, videoSource: edxVideoId, fallbackVideos: html5Sources, })).toEqual({ - edxVideoId: '', + edxVideoId, html5Sources, youtubeId, }); @@ -357,20 +376,22 @@ describe('cms api', () => { }); it('returns html5Sources when there are no fallbackVideos', () => { expect(api.processVideoIds({ + edxVideoId, videoSource: html5Sources[0], fallbackVideos: [], })).toEqual({ - edxVideoId: '', + edxVideoId, html5Sources: [html5Sources[0]], youtubeId: '', }); }); it('returns html5Sources when there are fallbackVideos', () => { expect(api.processVideoIds({ + edxVideoId, videoSource: html5Sources[0], fallbackVideos: [html5Sources[1]], })).toEqual({ - edxVideoId: '', + edxVideoId, html5Sources, youtubeId: '', }); diff --git a/src/editors/sharedComponents/FileInput/index.jsx b/src/editors/sharedComponents/FileInput/index.jsx index 42b8db03f..c1dca14c1 100644 --- a/src/editors/sharedComponents/FileInput/index.jsx +++ b/src/editors/sharedComponents/FileInput/index.jsx @@ -1,12 +1,28 @@ import React from 'react'; import PropTypes from 'prop-types'; -export const FileInput = ({ fileInput, acceptedFiles }) => ( +export const fileInput = ({ onAddFile }) => { + const ref = React.useRef(); + const click = () => ref.current.click(); + const addFile = (e) => { + const file = e.target.files[0]; + if (file) { + onAddFile(file); + } + }; + return { + click, + addFile, + ref, + }; +}; + +export const FileInput = ({ fileInput: hook, acceptedFiles }) => ( ); diff --git a/src/editors/utils/index.js b/src/editors/utils/index.js index dcaffa95f..b765c1382 100644 --- a/src/editors/utils/index.js +++ b/src/editors/utils/index.js @@ -2,3 +2,4 @@ export { default as StrictDict } from './StrictDict'; export { default as keyStore } from './keyStore'; export { default as camelizeKeys } from './camelizeKeys'; +export { default as removeItemOnce } from './removeOnce'; diff --git a/src/editors/utils/removeOnce.js b/src/editors/utils/removeOnce.js new file mode 100644 index 000000000..739f7dcb1 --- /dev/null +++ b/src/editors/utils/removeOnce.js @@ -0,0 +1,13 @@ +const removeItemOnce = (arr, value) => { + // create a deep copy as array.splice doesn't work if the array has been dereferenced. + // structuredClone works in node >11, and we are on node 16. + // eslint-disable-next-line + const deepCopy = structuredClone(arr); + const index = deepCopy.indexOf(value); + if (index > -1) { + deepCopy.splice(index, 1); + } + return deepCopy; +}; + +export default removeItemOnce;