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', () => ({