Feat: full transcript widget (#117)

This commit is contained in:
connorhaugh
2022-09-27 14:09:29 -04:00
committed by GitHub
parent ff636837cf
commit 45215ba504
31 changed files with 1686 additions and 99 deletions

View File

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

View File

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

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

@@ -0,0 +1,65 @@
// 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,124 @@
// 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,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`] = `
<injectIntl(ShimmedIntlComponent)
isError={false}
subtitle="english"
subtitle="English"
title="Transcript"
>
<ErrorAlert
dismissError={[Function]}
dismissError={null}
hideHeading={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to upload transcript. Please try again."
description="Message presented to user when transcript fails to upload"
id="authoring.videoeditor.transcript.error.uploadTranscriptError"
/>
</ErrorAlert>
<ErrorAlert
dismissError={null}
hideHeading={true}
isError={true}
>
<FormattedMessage
defaultMessage="Failed to delete transcript. Please try again."
description="Message presented to user when transcript fails to delete"
id="authoring.videoeditor.transcript.error.deleteTranscriptError"
/>
</ErrorAlert>
<Stack
gap={3}
>
<Form.Group
className="mt-4.5"
>
<injectIntl(ShimmedIntlComponent)
language="en"
/>
<div
className="mb-1"
>
<Form.Checkbox
checked={false}
className="mt-4.5 decorative-control-label"
onChange={[Function]}
>
<Form.Label>
<FormattedMessage
defaultMessage="Allow transcript downloads"
description="Label for allow transcript downloads checkbox"
id="authoring.videoeditor.transcripts.allowDownloadCheckboxLabel"
/>
</Form.Label>
</Form.Checkbox>
<OverLayTrigger
key="right"
overlay={
<ToolTip>
<FormattedMessage
defaultMessage="Learners will see a link to download the transcript below the video."
description="Message for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.allowDownloadTooltipMessage"
/>
</ToolTip>
}
placement="right"
>
<Icon
className="d-inline-block mx-3"
/>
</OverLayTrigger>
</div>
<Form.Checkbox
checked={false}
className="mt-4.5 decorative-control-label"
onChange={[Function]}
>
<Form.Label
size="sm"
>
<FormattedMessage
defaultMessage="Show transcript in the video player by default"
description="Label for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.showByDefaultCheckboxLabel"
/>
</Form.Label>
</Form.Checkbox>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Upload Transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</Stack>
</injectIntl(ShimmedIntlComponent)>
`;
exports[`TranscriptWidget snapshots snapshot: renders ErrorAlert with upload error message 1`] = `
<injectIntl(ShimmedIntlComponent)
isError={false}
subtitle="English"
title="Transcript"
>
<ErrorAlert
dismissError={null}
hideHeading={true}
isError={true}
>
@@ -18,14 +131,14 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTra
/>
</ErrorAlert>
<ErrorAlert
dismissError={[Function]}
dismissError={null}
hideHeading={true}
isError={true}
isError={false}
>
<FormattedMessage
defaultMessage="Transcript file size exeeds the maximum. Please try again."
description="Message presented to user when transcript file size is too large"
id="authoring.videoeditor.transcript.error.fileSizeError"
defaultMessage="Failed to delete transcript. Please try again."
description="Message presented to user when transcript fails to delete"
id="authoring.videoeditor.transcript.error.deleteTranscriptError"
/>
</ErrorAlert>
<Stack
@@ -34,9 +147,122 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTra
<Form.Group
className="mt-4.5"
>
<b>
Transcript widget:
</b>
<injectIntl(ShimmedIntlComponent)
language="en"
/>
<div
className="mb-1"
>
<Form.Checkbox
checked={false}
className="mt-4.5 decorative-control-label"
onChange={[Function]}
>
<Form.Label>
<FormattedMessage
defaultMessage="Allow transcript downloads"
description="Label for allow transcript downloads checkbox"
id="authoring.videoeditor.transcripts.allowDownloadCheckboxLabel"
/>
</Form.Label>
</Form.Checkbox>
<OverLayTrigger
key="right"
overlay={
<ToolTip>
<FormattedMessage
defaultMessage="Learners will see a link to download the transcript below the video."
description="Message for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.allowDownloadTooltipMessage"
/>
</ToolTip>
}
placement="right"
>
<Icon
className="d-inline-block mx-3"
/>
</OverLayTrigger>
</div>
<Form.Checkbox
checked={false}
className="mt-4.5 decorative-control-label"
onChange={[Function]}
>
<Form.Label
size="sm"
>
<FormattedMessage
defaultMessage="Show transcript in the video player by default"
description="Label for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.showByDefaultCheckboxLabel"
/>
</Form.Label>
</Form.Checkbox>
</Form.Group>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Upload Transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</Stack>
</injectIntl(ShimmedIntlComponent)>
`;
exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTranscriptDownloads true 1`] = `
<injectIntl(ShimmedIntlComponent)
isError={false}
subtitle="English"
title="Transcript"
>
<ErrorAlert
dismissError={null}
hideHeading={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to upload transcript. Please try again."
description="Message presented to user when transcript fails to upload"
id="authoring.videoeditor.transcript.error.uploadTranscriptError"
/>
</ErrorAlert>
<ErrorAlert
dismissError={null}
hideHeading={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to delete transcript. Please try again."
description="Message presented to user when transcript fails to delete"
id="authoring.videoeditor.transcript.error.deleteTranscriptError"
/>
</ErrorAlert>
<Stack
gap={3}
>
<Form.Group
className="mt-4.5"
>
<injectIntl(ShimmedIntlComponent)
language="en"
/>
<div
className="mb-1"
>
@@ -53,23 +279,23 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with allowTra
/>
</Form.Label>
</Form.Checkbox>
<Component
<OverLayTrigger
key="right"
overlay={
<UNDEFINED>
<ToolTip>
<FormattedMessage
defaultMessage="Learners will see a link to download the transcript below the video."
description="Message for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.allowDownloadTooltipMessage"
/>
</UNDEFINED>
</ToolTip>
}
placement="right"
>
<Icon
className="d-inline-block mx-3"
/>
</Component>
</OverLayTrigger>
</div>
<Form.Checkbox
checked={false}
@@ -120,9 +346,9 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with default
title="Transcript"
>
<ErrorAlert
dismissError={[Function]}
dismissError={null}
hideHeading={true}
isError={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to upload transcript. Please try again."
@@ -131,14 +357,14 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with default
/>
</ErrorAlert>
<ErrorAlert
dismissError={[Function]}
dismissError={null}
hideHeading={true}
isError={true}
isError={false}
>
<FormattedMessage
defaultMessage="Transcript file size exeeds the maximum. Please try again."
description="Message presented to user when transcript file size is too large"
id="authoring.videoeditor.transcript.error.fileSizeError"
defaultMessage="Failed to delete transcript. Please try again."
description="Message presented to user when transcript fails to delete"
id="authoring.videoeditor.transcript.error.deleteTranscriptError"
/>
</ErrorAlert>
<Stack
@@ -147,7 +373,11 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with default
<Alert
variant="danger"
>
Only SRT files can be uploaded. Please select a file ending in .srt to upload.
<FormattedMessage
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"
id="authoring.videoeditor.transcripts.fileTypeWarning"
/>
</Alert>
<FormattedMessage
defaultMessage="Add video transcripts for improved accessibility."
@@ -183,13 +413,13 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with default
exports[`TranscriptWidget snapshots snapshots: renders as expected with showTranscriptByDefault true 1`] = `
<injectIntl(ShimmedIntlComponent)
isError={false}
subtitle="english"
subtitle="English"
title="Transcript"
>
<ErrorAlert
dismissError={[Function]}
dismissError={null}
hideHeading={true}
isError={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to upload transcript. Please try again."
@@ -198,14 +428,14 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with showTran
/>
</ErrorAlert>
<ErrorAlert
dismissError={[Function]}
dismissError={null}
hideHeading={true}
isError={true}
isError={false}
>
<FormattedMessage
defaultMessage="Transcript file size exeeds the maximum. Please try again."
description="Message presented to user when transcript file size is too large"
id="authoring.videoeditor.transcript.error.fileSizeError"
defaultMessage="Failed to delete transcript. Please try again."
description="Message presented to user when transcript fails to delete"
id="authoring.videoeditor.transcript.error.deleteTranscriptError"
/>
</ErrorAlert>
<Stack
@@ -214,9 +444,9 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with showTran
<Form.Group
className="mt-4.5"
>
<b>
Transcript widget:
</b>
<injectIntl(ShimmedIntlComponent)
language="en"
/>
<div
className="mb-1"
>
@@ -233,23 +463,23 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with showTran
/>
</Form.Label>
</Form.Checkbox>
<Component
<OverLayTrigger
key="right"
overlay={
<UNDEFINED>
<ToolTip>
<FormattedMessage
defaultMessage="Learners will see a link to download the transcript below the video."
description="Message for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.allowDownloadTooltipMessage"
/>
</UNDEFINED>
</ToolTip>
}
placement="right"
>
<Icon
className="d-inline-block mx-3"
/>
</Component>
</OverLayTrigger>
</div>
<Form.Checkbox
checked={true}
@@ -296,13 +526,13 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with showTran
exports[`TranscriptWidget snapshots snapshots: renders as expected with transcripts 1`] = `
<injectIntl(ShimmedIntlComponent)
isError={false}
subtitle="english"
subtitle="English"
title="Transcript"
>
<ErrorAlert
dismissError={[Function]}
dismissError={null}
hideHeading={true}
isError={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to upload transcript. Please try again."
@@ -311,14 +541,14 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with transcri
/>
</ErrorAlert>
<ErrorAlert
dismissError={[Function]}
dismissError={null}
hideHeading={true}
isError={true}
isError={false}
>
<FormattedMessage
defaultMessage="Transcript file size exeeds the maximum. Please try again."
description="Message presented to user when transcript file size is too large"
id="authoring.videoeditor.transcript.error.fileSizeError"
defaultMessage="Failed to delete transcript. Please try again."
description="Message presented to user when transcript fails to delete"
id="authoring.videoeditor.transcript.error.deleteTranscriptError"
/>
</ErrorAlert>
<Stack
@@ -327,9 +557,9 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with transcri
<Form.Group
className="mt-4.5"
>
<b>
Transcript widget:
</b>
<injectIntl(ShimmedIntlComponent)
language="en"
/>
<div
className="mb-1"
>
@@ -346,23 +576,23 @@ exports[`TranscriptWidget snapshots snapshots: renders as expected with transcri
/>
</Form.Label>
</Form.Checkbox>
<Component
<OverLayTrigger
key="right"
overlay={
<UNDEFINED>
<ToolTip>
<FormattedMessage
defaultMessage="Learners will see a link to download the transcript below the video."
description="Message for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.allowDownloadTooltipMessage"
/>
</UNDEFINED>
</ToolTip>
}
placement="right"
>
<Icon
className="d-inline-block mx-3"
/>
</Component>
</OverLayTrigger>
</div>
<Form.Checkbox
checked={false}

View File

@@ -1,24 +1,63 @@
import React from 'react';
import { thunkActions, actions } from '../../../../../../data/redux';
import * as module from './hooks';
import { videoTranscriptLanguages } from '../../../../../../data/constants/video';
export const state = {
inDeleteConfirmation: (args) => 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,
};

View File

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

View File

@@ -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 (
<CollapsibleFormWidget
@@ -58,23 +53,27 @@ export const TranscriptWidget = ({
title="Transcript"
>
<ErrorAlert
dismissError={upload.error.dismiss}
hideHeading
isError={upload.error.show}
isError={isUploadError}
>
<FormattedMessage {...messages.uploadTranscriptError} />
</ErrorAlert>
<ErrorAlert
dismissError={input.error.dismiss}
hideHeading
isError={input.error.show}
isError={isDeleteError}
>
<FormattedMessage {...messages.fileSizeError} />
<FormattedMessage {...messages.deleteTranscriptError} />
</ErrorAlert>
<Stack gap={3}>
{transcripts ? (
{hasTranscripts ? (
<Form.Group className="mt-4.5">
<b>Transcript widget:</b>
{ Object.entries(transcripts).map(([language, value]) => (
<TranscriptListItem
language={language}
title={value.filename}
/>
))}
<div className="mb-1">
<Form.Checkbox
checked={allowTranscriptDownloads}
@@ -109,8 +108,8 @@ export const TranscriptWidget = ({
</Form.Group>
) : (
<>
<Alert variant="danger" icon={Info}>
Only SRT files can be uploaded. Please select a file ending in .srt to upload.
<Alert variant="danger">
<FormattedMessage {...messages.fileTypeWarning} />
</Alert>
<FormattedMessage {...messages.addFirstTranscript} />
</>
@@ -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) => ({

View File

@@ -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(<TranscriptWidget {...props} transcripts={{ english: 'sOMeUrl' }} />),
shallow(<TranscriptWidget {...props} transcripts={{ en: 'sOMeUrl' }} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with allowTranscriptDownloads true', () => {
expect(
shallow(<TranscriptWidget {...props} allowTranscriptDownloads transcripts={{ english: 'sOMeUrl' }} />),
shallow(<TranscriptWidget {...props} allowTranscriptDownloads transcripts={{ en: 'sOMeUrl' }} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with showTranscriptByDefault true', () => {
expect(
shallow(<TranscriptWidget {...props} showTranscriptByDefault transcripts={{ english: 'sOMeUrl' }} />),
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();
});
});
@@ -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();

View File

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

View File

@@ -14,4 +14,6 @@ export const RequestKeys = StrictDict({
fetchUnit: 'fetchUnit',
saveBlock: 'saveBlock',
uploadImage: 'uploadImage',
uploadTranscript: 'uploadTranscript',
deleteTranscript: 'deleteTranscript',
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,9 @@ export const hooks = {
isDismissed,
dismissAlert: () => {
setIsDismissed(true);
dismissError();
if (dismissError) {
dismissError();
}
},
};
},

View File

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