Feat improve transcript flow (#141)

https://2u-internal.atlassian.net/browse/TNL-10199
Rewrite of Transcripts widget to improve flow.
This commit is contained in:
connorhaugh
2022-11-15 12:03:04 -05:00
committed by GitHub
parent 8eab620b65
commit 3506db7c14
34 changed files with 1286 additions and 976 deletions

View File

@@ -50,8 +50,8 @@ export const TitleHeader = ({
</Truncate>
<IconButton
alt={intl.formatMessage(messages.editTitleLabel)}
className="mr-2"
iconAs={Icon}
className="mr-2"
onClick={startEditing}
size="sm"
src={Edit}

View File

@@ -1,62 +0,0 @@
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 (
<Form.Group controlId={`selectLanguage-form-${title}`} className="mt-2 mx-2">
<Form.Control as="select" defaultValue={language} onChange={(e) => onLanguageChange(e)} floatingLabel={intl.formatMessage(messages.languageSelectLabel)}>
{Object.entries(videoTranscriptLanguages).map(([lang, text]) => {
if (language === lang) { return (<option value={lang} selected>{text}</option>); }
if (openLanguages.some(row => row.includes(lang))) {
return (<option value={lang}>{text}</option>);
}
return (<option value={lang} disabled>{text}</option>);
})}
</Form.Control>
</Form.Group>
);
};
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));

View File

@@ -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 (
<div className="col col-11">
<Form.Group controlId={`selectLanguage-form-${index}`} className="mw-100">
<Form.Control as="select" defaultValue={language} onChange={(e) => onLanguageChange(e)} floatingLabel={intl.formatMessage(messages.languageSelectLabel)}>
{Object.entries(videoTranscriptLanguages).map(([lang, text]) => {
if (language === lang) { return (<option value={lang} selected>{text}</option>); }
if (openLanguages.some(row => row.includes(lang))) {
return (<option value={lang}>{text}</option>);
}
return (<option value={lang} disabled>{text}</option>);
})}
</Form.Control>
</Form.Group>
<FileInput fileInput={input} acceptedFiles=".srt" />
</div>
);
};
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));

View File

@@ -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(<LanguageSelect {...props} />),
shallow(<LanguageSelector {...props} />),
).toMatchSnapshot();
});
});
describe('snapshots -- no', () => {
test('transcripts no Open Languages, all should be disabled', () => {
expect(
shallow(<LanguageSelect {...props} openLanguages={[]} />),
shallow(<LanguageSelector {...props} openLanguages={[]} />),
).toMatchSnapshot();
});
});

View File

@@ -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
? (
<Card className="mb-2">
<Card.Header title={(<FormattedMessage {...messages.deleteConfirmationHeader} />)} />
<Card.Body>
<Card.Section>
<FormattedMessage {...messages.deleteConfirmationMessage} />
</Card.Section>
<Card.Footer>
<Button variant="tertiary" className="mb-2 mb-sm-0" onClick={cancelDelete}>
<FormattedMessage {...messages.cancelDeleteLabel} />
</Button>
<Button
variant="danger"
className="mb-2 mb-sm-0"
onClick={() => {
deleteTranscript({ language });
// stop showing the card
cancelDelete();
}}
>
<FormattedMessage {...messages.confirmDeleteLabel} />
</Button>
</Card.Footer>
</Card.Body>
</Card>
)
: (
<Row>
<LanguageSelector
title={index}
language={language}
/>
{ language === '' ? (
<IconButton
className="d-inline-block"
iconAs={Icon}
src={Delete}
onClick={() => launchDeleteConfirmation()}
/>
) : (
<TranscriptActionMenu
index={index}
language={language}
launchDeleteConfirmation={launchDeleteConfirmation}
/>
)}
</Row>
)}
</>
);
};
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));

View File

@@ -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(<module.Transcript {...props} />),
).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(<module.Transcript {...props} language="" />),
).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(<module.Transcript {...props} />),
).toMatchSnapshot();
});
});
});
});

View File

@@ -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 (
<Dropdown>
<Dropdown.Toggle
id="dropdown-toggle-with-iconbutton-video-transcript-widget"
as={IconButton}
src={MoreHoriz}
iconAs={Icon}
variant="primary"
alt="Actions dropdown"
/>
<Dropdown.Menu className="video_transcript Action Menu">
<Dropdown.Item
key={`transcript-actions-${index}-replace`}
onClick={input.click}
>
<FormattedMessage {...messages.replaceTranscript} />
</Dropdown.Item>
<Dropdown.Item key={`transcript-actions-${index}-download`} href={downloadLink}>
<FormattedMessage {...messages.downloadTranscript} />
</Dropdown.Item>
<Dropdown.Item key={`transcript-actions-${index}-delete`} onClick={launchDeleteConfirmation}>
<FormattedMessage {...messages.deleteTranscript} />
</Dropdown.Item>
</Dropdown.Menu>
<FileInput fileInput={input} acceptedFiles=".srt" />
</Dropdown>
);
};
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));

View File

@@ -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(<module.TranscriptActionMenu {...props} />),
).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);
});
});
});

View File

@@ -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 (
<div className="mb-2">
<Card>
{ inDeleteConfirmation ? (
<>
<Card.Header title={(<FormattedMessage {...messages.deleteConfirmationHeader} />)} />
<Card.Body>
<Card.Section>
<FormattedMessage {...messages.deleteConfirmationMessage} />
</Card.Section>
<Card.Footer>
<Button variant="tertiary" className="mb-2 mb-sm-0" onClick={cancelDelete}>
<FormattedMessage {...messages.cancelDeleteLabel} />
</Button>
<Button variant="danger" className="mb-2 mb-sm-0" onClick={() => deleteTranscript({ language })}>
<FormattedMessage {...messages.confirmDeleteLabel} />
</Button>
</Card.Footer>
</Card.Body>
</>
) : (
<>
<Card.Header
subtitle={title}
actions={(
<Dropdown>
<Dropdown.Toggle
id="dropdown-toggle-with-iconbutton-video-transcript-widget"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt="Actions dropdown"
/>
<Dropdown.Menu className="video_transcript Action Menu">
<Dropdown.Item
key={`transcript-actions-${title}-replace`}
onClick={fileInput.click}
>
<FormattedMessage {...messages.replaceTranscript} />
</Dropdown.Item>
<Dropdown.Item key={`transcript-actions-${title}-download`} href={downloadLink}>
<FormattedMessage {...messages.downloadTranscript} />
</Dropdown.Item>
<Dropdown.Item key={`transcript-actions-${title}-delete`} onClick={launchDeleteConfirmation}>
<FormattedMessage {...messages.deleteTranscript} />
</Dropdown.Item>
</Dropdown.Menu>
<FileInput fileInput={fileInput} acceptedFiles=".srt" />
</Dropdown>
)}
/>
<LanguageSelect
title={title}
language={language}
/>
</>
)}
</Card>
</div>
);
};
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));

View File

@@ -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(<TranscriptListItem {...props} />),
).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(<TranscriptListItem {...props} />),
).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);
});
});
});

View File

@@ -1,65 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LanguageSelect snapshot transcript option 1`] = `
<Form.Group
className="mt-2 mx-2"
controlId="selectLanguage-form-tITle"
>
<Form.Control
as="select"
defaultValue="kl"
floatingLabel="Languages"
onChange={[Function]}
>
<option
selected={true}
value="kl"
>
kLinGon
</option>
<option
value="el"
>
eLvIsh
</option>
<option
value="sl"
>
sImLisH
</option>
</Form.Control>
</Form.Group>
`;
exports[`LanguageSelect snapshots -- no transcripts no Open Languages, all should be disabled 1`] = `
<Form.Group
className="mt-2 mx-2"
controlId="selectLanguage-form-tITle"
>
<Form.Control
as="select"
defaultValue="kl"
floatingLabel="Languages"
onChange={[Function]}
>
<option
selected={true}
value="kl"
>
kLinGon
</option>
<option
disabled={true}
value="el"
>
eLvIsh
</option>
<option
disabled={true}
value="sl"
>
sImLisH
</option>
</Form.Control>
</Form.Group>
`;

View File

@@ -0,0 +1,97 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LanguageSelector snapshot transcript option 1`] = `
<div
className="col col-11"
>
<Form.Group
className="mw-100"
controlId="selectLanguage-form-undefined"
>
<Form.Control
as="select"
defaultValue="kl"
floatingLabel="Languages"
onChange={[Function]}
>
<option
selected={true}
value="kl"
>
kLinGon
</option>
<option
value="el"
>
eLvIsh
</option>
<option
value="sl"
>
sImLisH
</option>
</Form.Control>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
</div>
`;
exports[`LanguageSelector snapshots -- no transcripts no Open Languages, all should be disabled 1`] = `
<div
className="col col-11"
>
<Form.Group
className="mw-100"
controlId="selectLanguage-form-undefined"
>
<Form.Control
as="select"
defaultValue="kl"
floatingLabel="Languages"
onChange={[Function]}
>
<option
selected={true}
value="kl"
>
kLinGon
</option>
<option
disabled={true}
value="el"
>
eLvIsh
</option>
<option
disabled={true}
value="sl"
>
sImLisH
</option>
</Form.Control>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
</div>
`;

View File

@@ -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`] = `
<Fragment>
<Component>
<LanguageSelector
language="lAnG"
title="sOmenUmBer"
/>
<TranscriptActionMenu
index="sOmenUmBer"
language="lAnG"
launchDeleteConfirmation={[MockFunction launchDeleteConfirmation]}
/>
</Component>
</Fragment>
`;
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`] = `
<Fragment>
<Component>
<LanguageSelector
language=""
title="sOmenUmBer"
/>
<IconButton
className="d-inline-block"
iconAs="Icon"
onClick={[Function]}
/>
</Component>
</Fragment>
`;
exports[`Transcript Component component component snapshots: renders as expected with default props: show confirm delete 1`] = `
<Fragment>
<Card
className="mb-2"
>
<Card.Header
title={
<FormattedMessage
defaultMessage="Delete This Transcript?"
description="Title for Warning which allows users to select next step in the process of deleting a transcript"
id="authoring.videoeditor.transcripts.deleteConfirmationTitle"
/>
}
/>
<Card.Body>
<Card.Section>
<FormattedMessage
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"
id="authoring.videoeditor.transcripts.deleteConfirmationMessage"
/>
</Card.Section>
<Card.Footer>
<Button
className="mb-2 mb-sm-0"
onClick={[MockFunction cancelDelete]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label For Button, which allows users to stop the process of deleting a transcript"
id="authoring.videoeditor.transcripts.cancelDeleteLabel"
/>
</Button>
<Button
className="mb-2 mb-sm-0"
onClick={[Function]}
variant="danger"
>
<FormattedMessage
defaultMessage="Delete"
description="Label For Button, which allows users to confirm the process of deleting a transcript"
id="authoring.videoeditor.transcripts.confirmDeleteLabel"
/>
</Button>
</Card.Footer>
</Card.Body>
</Card>
</Fragment>
`;

View File

@@ -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`] = `
<Dropdown>
<Dropdown.Toggle
alt="Actions dropdown"
as="IconButton"
iconAs="Icon"
id="dropdown-toggle-with-iconbutton-video-transcript-widget"
variant="primary"
/>
<Dropdown.Menu
className="video_transcript Action Menu"
>
<Dropdown.Item
key="transcript-actions-sOmenUmBer-replace"
onClick={[MockFunction click input]}
>
<FormattedMessage
defaultMessage="Replace"
description="Message Presented To user for action to replace transcript"
id="authoring.videoeditor.transcript.replaceTranscript"
/>
</Dropdown.Item>
<Dropdown.Item
key="transcript-actions-sOmenUmBer-download"
>
<FormattedMessage
defaultMessage="Download"
description="Message Presented To user for action to download transcript"
id="authoring.videoeditor.transcript.downloadTranscript"
/>
</Dropdown.Item>
<Dropdown.Item
key="transcript-actions-sOmenUmBer-delete"
onClick={[MockFunction launchDeleteConfirmation]}
>
<FormattedMessage
defaultMessage="Delete"
description="Message Presented To user for action to delete transcript"
id="authoring.videoeditor.transcript.deleteTranscript"
/>
</Dropdown.Item>
</Dropdown.Menu>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"click": [MockFunction click input],
"onAddFile": [MockFunction module.hooks.replaceFileCallback],
}
}
/>
</Dropdown>
`;

View File

@@ -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`] = `
<div
className="mb-2"
>
<Card>
<Card.Header
actions={
<Dropdown>
<Dropdown.Toggle
alt="Actions dropdown"
as="IconButton"
iconAs="Icon"
id="dropdown-toggle-with-iconbutton-video-transcript-widget"
variant="primary"
/>
<Dropdown.Menu
className="video_transcript Action Menu"
>
<Dropdown.Item
onClick={[MockFunction mockInputClick]}
>
<FormattedMessage
defaultMessage="Replace"
description="Message Presented To user for action to replace transcript"
id="authoring.videoeditor.transcript.replaceTranscript"
/>
</Dropdown.Item>
<Dropdown.Item>
<FormattedMessage
defaultMessage="Download"
description="Message Presented To user for action to download transcript"
id="authoring.videoeditor.transcript.downloadTranscript"
/>
</Dropdown.Item>
<Dropdown.Item
onClick={[MockFunction launchDeleteConfirmation]}
>
<FormattedMessage
defaultMessage="Delete"
description="Message Presented To user for action to delete transcript"
id="authoring.videoeditor.transcript.deleteTranscript"
/>
</Dropdown.Item>
</Dropdown.Menu>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"click": [MockFunction mockInputClick],
"fileInput": Object {
"onAddFile": Object {
"replaceFileCallback": Object {
"dispatch": [MockFunction mockUseDispatch],
"language": "lAnG",
},
},
},
}
}
/>
</Dropdown>
}
subtitle="sOmeTiTLE"
/>
<injectIntl(ShimmedIntlComponent)
language="lAnG"
title="sOmeTiTLE"
/>
</Card>
</div>
`;
exports[`TranscriptListItem Snapshots snapshots: renders as expected with default props: show confirm delete 1`] = `
<div
className="mb-2"
>
<Card>
<Card.Header
title={
<FormattedMessage
defaultMessage="Delete This Transcript?"
description="Title for Warning which allows users to select next step in the process of deleting a transcript"
id="authoring.videoeditor.transcripts.deleteConfirmationTitle"
/>
}
/>
<Card.Body>
<Card.Section>
<FormattedMessage
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"
id="authoring.videoeditor.transcripts.deleteConfirmationMessage"
/>
</Card.Section>
<Card.Footer>
<Button
className="mb-2 mb-sm-0"
onClick={[MockFunction cancelDelete]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Label For Button, which allows users to stop the process of deleting a transcript"
id="authoring.videoeditor.transcripts.cancelDeleteLabel"
/>
</Button>
<Button
className="mb-2 mb-sm-0"
onClick={[Function]}
variant="danger"
>
<FormattedMessage
defaultMessage="Delete"
description="Label For Button, which allows users to confirm the process of deleting a transcript"
id="authoring.videoeditor.transcripts.confirmDeleteLabel"
/>
</Button>
</Card.Footer>
</Card.Body>
</Card>
</div>
`;

View File

@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with delete error message 1`] = `
<injectIntl(ShimmedIntlComponent)
exports[`TranscriptWidget component snapshots snapshot: renders ErrorAlert with delete error message 1`] = `
<CollapsibleFormWidget
isError={true}
subtitle="English"
title="Transcript"
@@ -34,7 +34,8 @@ exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with delete err
<Form.Group
className="mt-4.5"
>
<injectIntl(ShimmedIntlComponent)
<Transcript
index={0}
language="en"
/>
<div
@@ -87,36 +88,29 @@ exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with delete err
</Form.Label>
</Form.Checkbox>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
onClick={[Function]}
variant="link"
<Stack
className="border-primary-100 border-top"
gap={3}
>
<FormattedMessage
defaultMessage="Upload Transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
<Button
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Add a transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</Stack>
</Stack>
</injectIntl(ShimmedIntlComponent)>
</CollapsibleFormWidget>
`;
exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with upload error message 1`] = `
<injectIntl(ShimmedIntlComponent)
exports[`TranscriptWidget component snapshots snapshot: renders ErrorAlert with upload error message 1`] = `
<CollapsibleFormWidget
isError={true}
subtitle="English"
subtitle="English, French"
title="Transcript"
>
<ErrorAlert
@@ -147,9 +141,14 @@ exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with upload err
<Form.Group
className="mt-4.5"
>
<injectIntl(ShimmedIntlComponent)
<Transcript
index={0}
language="en"
/>
<Transcript
index={1}
language="fr"
/>
<div
className="mb-1"
>
@@ -200,34 +199,27 @@ exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with upload err
</Form.Label>
</Form.Checkbox>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
onClick={[Function]}
variant="link"
<Stack
className="border-primary-100 border-top"
gap={3}
>
<FormattedMessage
defaultMessage="Upload Transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
<Button
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Add a transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</Stack>
</Stack>
</injectIntl(ShimmedIntlComponent)>
</CollapsibleFormWidget>
`;
exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTranscriptDownloads true 1`] = `
<injectIntl(ShimmedIntlComponent)
exports[`TranscriptWidget component snapshots snapshots: renders as expected with allowTranscriptDownloads true 1`] = `
<CollapsibleFormWidget
isError={true}
subtitle="English"
title="Transcript"
@@ -260,7 +252,8 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTra
<Form.Group
className="mt-4.5"
>
<injectIntl(ShimmedIntlComponent)
<Transcript
index={0}
language="en"
/>
<div
@@ -313,34 +306,27 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTra
</Form.Label>
</Form.Checkbox>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
onClick={[Function]}
variant="link"
<Stack
className="border-primary-100 border-top"
gap={3}
>
<FormattedMessage
defaultMessage="Upload Transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
<Button
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Add a transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</Stack>
</Stack>
</injectIntl(ShimmedIntlComponent)>
</CollapsibleFormWidget>
`;
exports[`TranscriptWidget snapshots snapshots: renders as expected with default props 1`] = `
<injectIntl(ShimmedIntlComponent)
exports[`TranscriptWidget component snapshots snapshots: renders as expected with default props 1`] = `
<CollapsibleFormWidget
isError={true}
subtitle="None"
title="Transcript"
@@ -384,34 +370,27 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with default
description="Message for adding first transcript"
id="authoring.videoeditor.transcripts.upload.firstTranscriptMessage"
/>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
onClick={[Function]}
variant="link"
<Stack
className="border-primary-100 border-top"
gap={3}
>
<FormattedMessage
defaultMessage="Upload Transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
<Button
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Add a transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</Stack>
</Stack>
</injectIntl(ShimmedIntlComponent)>
</CollapsibleFormWidget>
`;
exports[`TranscriptWidget snapshots snapshots: renders as expected with showTranscriptByDefault true 1`] = `
<injectIntl(ShimmedIntlComponent)
exports[`TranscriptWidget component snapshots snapshots: renders as expected with showTranscriptByDefault true 1`] = `
<CollapsibleFormWidget
isError={true}
subtitle="English"
title="Transcript"
@@ -444,7 +423,8 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with showTran
<Form.Group
className="mt-4.5"
>
<injectIntl(ShimmedIntlComponent)
<Transcript
index={0}
language="en"
/>
<div
@@ -497,34 +477,27 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with showTran
</Form.Label>
</Form.Checkbox>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
onClick={[Function]}
variant="link"
<Stack
className="border-primary-100 border-top"
gap={3}
>
<FormattedMessage
defaultMessage="Upload Transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
<Button
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Add a transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</Stack>
</Stack>
</injectIntl(ShimmedIntlComponent)>
</CollapsibleFormWidget>
`;
exports[`TranscriptWidget snapshots snapshots: renders as expected with transcripts 1`] = `
<injectIntl(ShimmedIntlComponent)
exports[`TranscriptWidget component snapshots snapshots: renders as expected with transcripts 1`] = `
<CollapsibleFormWidget
isError={true}
subtitle="English"
title="Transcript"
@@ -557,7 +530,8 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with transcri
<Form.Group
className="mt-4.5"
>
<injectIntl(ShimmedIntlComponent)
<Transcript
index={0}
language="en"
/>
<div
@@ -610,28 +584,21 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with transcri
</Form.Label>
</Form.Checkbox>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
onClick={[Function]}
variant="link"
<Stack
className="border-primary-100 border-top"
gap={3}
>
<FormattedMessage
defaultMessage="Upload Transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
<Button
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Add a transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</Stack>
</Stack>
</injectIntl(ShimmedIntlComponent)>
</CollapsibleFormWidget>
`;

View File

@@ -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,
};

View File

@@ -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);
});
});
});
});

View File

@@ -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 (
<CollapsibleFormWidget
isError={Object.keys(error).length !== 0}
subtitle={languagesArr}
subtitle={fullTextLanguages}
title="Transcript"
>
<ErrorAlert
@@ -67,10 +110,10 @@ export const TranscriptWidget = ({
{hasTranscripts ? (
<Form.Group className="mt-4.5">
{ Object.entries(transcripts).map(([language, value]) => (
<TranscriptListItem
{ transcripts.map((language, index) => (
<Transcript
language={language}
title={value.filename}
index={index}
/>
))}
<div className="mb-1">
@@ -113,10 +156,16 @@ export const TranscriptWidget = ({
<FormattedMessage {...messages.addFirstTranscript} />
</>
)}
<FileInput fileInput={fileInput} acceptedFiles=".srt" />
<Button iconBefore={FileUpload} onClick={fileInput.click} variant="link">
<FormattedMessage {...messages.uploadButtonLabel} />
</Button>
<Stack gap={3} className="border-primary-100 border-top">
<Button
iconBefore={Add}
variant="link"
onClick={() => module.hooks.onAddNewTranscript({ transcripts, updateField })}
>
<FormattedMessage {...messages.uploadButtonLabel} />
</Button>
</Stack>
</Stack>
</CollapsibleFormWidget>
);
@@ -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),

View File

@@ -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(<TranscriptWidget {...props} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with transcripts', () => {
expect(
shallow(<TranscriptWidget {...props} transcripts={{ en: 'sOMeUrl' }} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with allowTranscriptDownloads true', () => {
expect(
shallow(<TranscriptWidget {...props} allowTranscriptDownloads transcripts={{ en: 'sOMeUrl' }} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with showTranscriptByDefault true', () => {
expect(
shallow(<TranscriptWidget {...props} showTranscriptByDefault transcripts={{ en: 'sOMeUrl' }} />),
).toMatchSnapshot();
});
test('snapshot: renders ErrorAlert with upload error message', () => {
expect(
shallow(<TranscriptWidget {...props} isUploadError transcripts={{ en: 'sOMeUrl' }} />),
).toMatchSnapshot();
});
test('snapshot: renders ErrorAlert with delete error message', () => {
expect(
shallow(<TranscriptWidget {...props} isDeleteError transcripts={{ en: 'sOMeUrl' }} />),
).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(<module.TranscriptWidget {...props} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with transcripts', () => {
expect(
shallow(<module.TranscriptWidget {...props} transcripts={['en']} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with allowTranscriptDownloads true', () => {
expect(
shallow(<module.TranscriptWidget {...props} allowTranscriptDownloads transcripts={['en']} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with showTranscriptByDefault true', () => {
expect(
shallow(<module.TranscriptWidget {...props} showTranscriptByDefault transcripts={['en']} />),
).toMatchSnapshot();
});
test('snapshot: renders ErrorAlert with upload error message', () => {
expect(
shallow(<module.TranscriptWidget {...props} isUploadError transcripts={['en', 'fr']} />),
).toMatchSnapshot();
});
test('snapshot: renders ErrorAlert with delete error message', () => {
expect(
shallow(<module.TranscriptWidget {...props} isDeleteError transcripts={['en']} />),
).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));
});
});
});
});

View File

@@ -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: {

View File

@@ -20,4 +20,6 @@ export const RequestKeys = StrictDict({
uploadTranscript: 'uploadTranscript',
deleteTranscript: 'deleteTranscript',
fetchCourseDetails: 'fetchCourseDetails',
updateTranscriptLanguage: 'updateTranscriptLanguage',
getTranscriptFile: 'getTranscriptFile',
});

View File

@@ -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',

View File

@@ -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,
});

View File

@@ -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';

View File

@@ -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(/&#34;/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,
};

View File

@@ -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();
});
});
});

View File

@@ -12,7 +12,7 @@ const initialState = {
],
allowVideoDownloads: false,
thumbnail: null,
transcripts: {},
transcripts: [],
allowTranscriptDownloads: false,
duration: {
startTime: '00:00:00',

View File

@@ -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;
},

View File

@@ -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,
};

View File

@@ -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: '',
});

View File

@@ -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 }) => (
<input
accept={acceptedFiles}
className="upload d-none"
onChange={fileInput.addFile}
ref={fileInput.ref}
onChange={hook.addFile}
ref={hook.ref}
type="file"
/>
);

View File

@@ -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';

View File

@@ -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;