feat: thumbnail widget

This commit is contained in:
Kristin Aoki
2022-10-12 15:42:38 -04:00
committed by GitHub
parent b035725344
commit 36576903ea
37 changed files with 820 additions and 192 deletions

View File

@@ -11,46 +11,13 @@ export const {
saveBlock,
} = appHooks;
export const setAssetToStaticUrl = (images, getContent) => {
/* For assets to remain usable across course instances, we convert their url to be course-agnostic.
* For example, /assets/course/<asset hash>/filename gets converted to /static/filename. This is
* important for rerunning courses and importing/exporting course as the /static/ part of the url
* allows the asset to be mapped to the new course run.
*/
let content = getContent();
const imageUrls = [];
const imgsArray = Object.values(images);
imgsArray.forEach(image => {
imageUrls.push({ portableUrl: image.portableUrl, displayName: image.displayName });
});
const imageSrcs = typeof content === 'string' ? content.split('src="') : [];
imageSrcs.forEach(src => {
if (src.startsWith('/asset') && imageUrls.length > 0) {
const nameFromEditorSrc = src.substring(src.lastIndexOf('@') + 1, src.indexOf('"'));
const nameFromStudioSrc = nameFromEditorSrc.substring(nameFromEditorSrc.indexOf('/') + 1);
let portableUrl;
imageUrls.forEach((url) => {
if (url.displayName === nameFromEditorSrc || url.displayName === nameFromStudioSrc) {
portableUrl = url.portableUrl;
}
});
if (portableUrl) {
const currentSrc = src.substring(0, src.indexOf('"'));
const updatedContent = content.replace(currentSrc, portableUrl);
content = updatedContent;
}
}
});
return content;
};
export const handleSaveClicked = ({ dispatch, getContent, validateEntry }) => {
const destination = useSelector(selectors.app.returnUrl);
const analytics = useSelector(selectors.app.analytics);
const images = useSelector(selectors.app.images);
return () => saveBlock({
analytics,
content: setAssetToStaticUrl(images, getContent),
content: getContent({ dispatch }),
destination,
dispatch,
validateEntry,

View File

@@ -26,20 +26,6 @@ jest.mock('../../hooks', () => ({
const dispatch = jest.fn();
describe('EditorContainer hooks', () => {
describe('non-state hooks', () => {
describe('replaceStaticwithAsset', () => {
it('returns content with updated img links', () => {
const getContent = jest.fn(() => '<img src="/asset@asset-block/soMEImagEURl1"/> <img src="/asset@soMEImagEURl" />');
const images = [
{ portableUrl: '/static/soMEImagEURl', displayName: 'soMEImagEURl' },
{ portableUrl: '/static/soMEImagEURl1', displayName: 'soMEImagEURl1' },
];
const content = hooks.setAssetToStaticUrl(images, getContent);
expect(getContent).toHaveBeenCalled();
expect(content).toEqual('<img src="/static/soMEImagEURl1"/> <img src="/static/soMEImagEURl" />');
});
});
});
describe('forwarded hooks', () => {
it('forwards navigateCallback from app hooks', () => {
expect(hooks.navigateCallback).toEqual(appHooks.navigateCallback);

View File

@@ -10,6 +10,11 @@ exports[`TextEditor snapshots ImageUploadModal is not rendered 1`] = `
"value": "something",
},
},
"images": Object {
"sOmEuiMAge": Object {
"staTICUrl": "/assets/sOmEuiMAge",
},
},
"isRaw": false,
},
}
@@ -79,6 +84,11 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
"value": "something",
},
},
"images": Object {
"sOmEuiMAge": Object {
"staTICUrl": "/assets/sOmEuiMAge",
},
},
"isRaw": false,
},
}
@@ -169,6 +179,11 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
"value": "something",
},
},
"images": Object {
"sOmEuiMAge": Object {
"staTICUrl": "/assets/sOmEuiMAge",
},
},
"isRaw": true,
},
}
@@ -250,6 +265,11 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
"value": "something",
},
},
"images": Object {
"sOmEuiMAge": Object {
"staTICUrl": "/assets/sOmEuiMAge",
},
},
"isRaw": false,
},
}
@@ -324,6 +344,11 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
"value": "something",
},
},
"images": Object {
"sOmEuiMAge": Object {
"staTICUrl": "/assets/sOmEuiMAge",
},
},
"isRaw": false,
},
}

View File

@@ -173,11 +173,44 @@ export const prepareEditorRef = () => {
return { editorRef, refReady, setEditorRef };
};
export const getContent = ({ editorRef, isRaw }) => () => {
if (isRaw && editorRef && editorRef.current) {
return editorRef.current.state.doc.toString();
}
return editorRef.current?.getContent();
export const setAssetToStaticUrl = ({ editorValue, images }) => {
/* For assets to remain usable across course instances, we convert their url to be course-agnostic.
* For example, /assets/course/<asset hash>/filename gets converted to /static/filename. This is
* important for rerunning courses and importing/exporting course as the /static/ part of the url
* allows the asset to be mapped to the new course run.
*/
let content = editorValue;
const imageUrls = [];
const imgsArray = Object.values(images);
imgsArray.forEach(image => {
imageUrls.push({ portableUrl: image.portableUrl, displayName: image.displayName });
});
const imageSrcs = typeof content === 'string' ? content.split('src="') : [];
imageSrcs.forEach(src => {
if (src.startsWith('/asset') && imageUrls.length > 0) {
const nameFromEditorSrc = src.substring(src.lastIndexOf('@') + 1, src.indexOf('"'));
const nameFromStudioSrc = nameFromEditorSrc.substring(nameFromEditorSrc.indexOf('/') + 1);
let portableUrl;
imageUrls.forEach((url) => {
if (url.displayName === nameFromEditorSrc || url.displayName === nameFromStudioSrc) {
portableUrl = url.portableUrl;
}
});
if (portableUrl) {
const currentSrc = src.substring(0, src.indexOf('"'));
const updatedContent = content.replace(currentSrc, portableUrl);
content = updatedContent;
}
}
});
return content;
};
export const getContent = ({ editorRef, isRaw, images }) => () => {
const content = (isRaw && editorRef && editorRef.current
? editorRef.current.state.doc.toString()
: editorRef.current?.getContent());
return setAssetToStaticUrl({ editorValue: content, images });
};
export const fetchImageUrls = (images) => {

View File

@@ -96,6 +96,18 @@ describe('TextEditor hooks', () => {
});
});
describe('setAssetToStaticUrl', () => {
it('returns content with updated img links', () => {
const editorValue = '<img src="/asset@asset-block/soMEImagEURl1"/> <img src="/asset@soMEImagEURl" />';
const images = [
{ portableUrl: '/static/soMEImagEURl', displayName: 'soMEImagEURl' },
{ portableUrl: '/static/soMEImagEURl1', displayName: 'soMEImagEURl1' },
];
const content = module.setAssetToStaticUrl({ editorValue, images });
expect(content).toEqual('<img src="/static/soMEImagEURl1"/> <img src="/static/soMEImagEURl" />');
});
});
describe('checkRelativeUrl', () => {
test('it calls editor.on', () => {
const editor = { on: jest.fn() };
@@ -249,9 +261,10 @@ describe('TextEditor hooks', () => {
},
},
};
const images = {};
test('returns correct ontent based on isRaw', () => {
expect(module.getContent({ editorRef, isRaw: false })()).toEqual(visualContent);
expect(module.getContent({ editorRef, isRaw: true })()).toEqual(rawContent);
expect(module.getContent({ editorRef, isRaw: false, images })()).toEqual(visualContent);
expect(module.getContent({ editorRef, isRaw: true, images })()).toEqual(rawContent);
});
});

View File

@@ -88,7 +88,7 @@ export const TextEditor = ({
return (
<EditorContainer
getContent={hooks.getContent({ editorRef, isRaw })}
getContent={hooks.getContent({ editorRef, isRaw, images })}
onClose={onClose}
>
<div className="editor-body h-75 overflow-auto">

View File

@@ -5,7 +5,6 @@ exports[`VideoEditor snapshots renders as expected with default behavior 1`] = `
value="hooks.errorsHook.error"
>
<EditorContainer
getContent={[Function]}
onClose={[MockFunction props.onClose]}
validateEntry={[MockFunction validateEntry]}
>

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { useDispatch } from 'react-redux';
// import PropTypes from 'prop-types';
import hooks from './hooks';
import CollapsibleFormWidget from './CollapsibleFormWidget';
/**
* Collapsible Form widget controlling video thumbnail
*/
export const ThumbnailWidget = () => {
const dispatch = useDispatch();
const { thumbnail } = hooks.widgetValues({
dispatch,
fields: { [hooks.selectorKeys.thumbnail]: hooks.genericWidget },
});
return (
<CollapsibleFormWidget title="Thumbnail">
<p>{thumbnail.formValue}</p>
</CollapsibleFormWidget>
);
};
export default ThumbnailWidget;

View File

@@ -0,0 +1,144 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThumbnailWidget snapshots snapshots: renders as expected where thumbnail uploads are allowed 1`] = `
<injectIntl(ShimmedIntlComponent)
subtitle="Unavailable"
title="Thumbnail"
>
<Alert
variant="info"
>
<FormattedMessage
defaultMessage="Select a video from your library to enable this feature"
description="Message for unavailable thumbnail widget"
id="authoring.videoeditor.thumbnail.unavailable.message"
/>
</Alert>
<Stack
direction="horizontal"
gap={3}
>
<Image
alt="Image used as thumbnail for video"
src="sOMeUrl"
/>
</Stack>
</injectIntl(ShimmedIntlComponent)>
`;
exports[`ThumbnailWidget snapshots snapshots: renders as expected where videoType equals edxVideo 1`] = `
<injectIntl(ShimmedIntlComponent)
subtitle={null}
title="Thumbnail"
>
<Stack
direction="horizontal"
gap={3}
>
<Image
alt="Image used as thumbnail for video"
src="sOMeUrl"
/>
<OverLayTrigger
key="top"
overlay={
<ToolTip>
<FormattedMessage
defaultMessage="Delete"
description="Message presented to user for action to delete thumbnail"
id="authoring.videoeditor.thumbnail.deleteThumbnail"
/>
</ToolTip>
}
placement="top"
>
<IconButton
className="d-inline-block"
iconAs="Icon"
onClick={[Function]}
/>
</OverLayTrigger>
</Stack>
</injectIntl(ShimmedIntlComponent)>
`;
exports[`ThumbnailWidget snapshots snapshots: renders as expected with a thumbnail provided 1`] = `
<injectIntl(ShimmedIntlComponent)
subtitle={null}
title="Thumbnail"
>
<Stack
direction="horizontal"
gap={3}
>
<Image
alt="Image used as thumbnail for video"
src="sOMeUrl"
/>
</Stack>
</injectIntl(ShimmedIntlComponent)>
`;
exports[`ThumbnailWidget snapshots snapshots: renders as expected with default props 1`] = `
<injectIntl(ShimmedIntlComponent)
subtitle="Unavailable"
title="Thumbnail"
>
<Alert
variant="info"
>
<FormattedMessage
defaultMessage="Select a video from your library to enable this feature"
description="Message for unavailable thumbnail widget"
id="authoring.videoeditor.thumbnail.unavailable.message"
/>
</Alert>
<Stack
gap={3}
>
<div>
<FormattedMessage
defaultMessage="Upload an image for learners to see before playing the video."
description="Message for adding thumbnail"
id="authoring.videoeditor.thumbnail.upload.message"
/>
</div>
<div
style={
Object {
"color": "grey",
}
}
>
<FormattedMessage
defaultMessage="Images must have an aspect ratio of 16:9 (1280x720 px recommended)"
description="Message for thumbnail aspectRequirements"
id="authoring.videoeditor.thumbnail.upload.aspectRequirements"
/>
</div>
<FileInput
acceptedFiles=".gif,.jpg,.jpeg,.png,.bmp"
fileInput={
Object {
"addFile": [Function],
"click": [Function],
"ref": Object {
"current": undefined,
},
}
}
/>
<Button
disabled={true}
onClick={[Function]}
variant="link"
>
<FormattedMessage
defaultMessage="Upload Thumbnail"
description="Label for upload button"
id="authoring.videoeditor.thumbnail.upload.label"
/>
</Button>
</Stack>
</injectIntl(ShimmedIntlComponent)>
`;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { thunkActions } from '../../../../../../data/redux';
export const fileInput = ({ setThumbnailSrc }) => {
const dispatch = useDispatch();
const ref = React.useRef();
const click = () => ref.current.click();
const addFile = (e) => {
const file = e.target.files[0];
const reader = new FileReader();
const image = file;
reader.onload = () => {
setThumbnailSrc(reader.result);
};
if (image) {
reader.readAsDataURL(image);
dispatch(thunkActions.video.uploadThumbnail({ thumbnail: image }));
}
};
return {
click,
addFile,
ref,
};
};
export default {
fileInput,
};

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { dispatch } from 'react-redux';
import { thunkActions } from '../../../../../../data/redux';
import * as hooks from './hooks';
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn(val => ({ current: val })),
useEffect: jest.fn(),
useCallback: (cb, prereqs) => ({ cb, prereqs }),
}));
jest.mock('react-redux', () => {
const dispatchFn = jest.fn();
return {
...jest.requireActual('react-redux'),
dispatch: dispatchFn,
useDispatch: jest.fn(() => dispatchFn),
};
});
jest.mock('../../../../../../data/redux', () => ({
thunkActions: {
video: {
uploadThumbnail: jest.fn(),
},
},
}));
let hook;
const setThumbnailSrc = jest.fn();
const testValue = 'testVALUEVALIDIMAGE';
describe('fileInput', () => {
beforeEach(() => {
hook = hooks.fileInput({ setThumbnailSrc });
});
it('returns a ref for the file input', () => {
expect(hook.ref).toEqual({ current: undefined });
});
test('click calls current.click on the ref', () => {
const click = jest.fn();
React.useRef.mockReturnValueOnce({ current: { click } });
hook = hooks.fileInput({ setThumbnailSrc });
hook.click();
expect(click).toHaveBeenCalled();
});
describe('addFile (uploadImage args)', () => {
const eventSuccess = { target: { files: [new File([testValue], 'sOMEUrl.jpg')] } };
const image = eventSuccess.target.files[0];
it('dispatches updateField action with the first target file', () => {
hook.addFile(eventSuccess);
expect(dispatch).toHaveBeenCalledWith(thunkActions.video.uploadThumbnail({ thumbnail: image }));
});
});
});

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import {
Image,
Stack,
Button,
OverlayTrigger,
Icon,
IconButton,
Tooltip,
Alert,
} from '@edx/paragon';
import { Delete, FileUpload } from '@edx/paragon/icons';
import { actions, selectors } from '../../../../../../data/redux';
import { acceptedImgKeys } from './utils';
import * as hooks from './hooks';
import messages from './messages';
import CollapsibleFormWidget from '../CollapsibleFormWidget';
import FileInput from '../../../../../../sharedComponents/FileInput';
/**
* Collapsible Form widget controlling video thumbnail
*/
export const ThumbnailWidget = ({
// injected
intl,
// redux
allowThumbnailUpload,
thumbnail,
updateField,
videoType,
}) => {
const [thumbnailSrc, setThumbnailSrc] = React.useState(thumbnail);
const fileInput = hooks.fileInput({ setThumbnailSrc });
const isEdxVideo = videoType === 'edxVideo';
return (
<CollapsibleFormWidget
title={intl.formatMessage(messages.title)}
subtitle={isEdxVideo ? null : intl.formatMessage(messages.unavailableSubtitle)}
>
{isEdxVideo ? null : (
<Alert variant="info">
<FormattedMessage {...messages.unavailableMessage} />
</Alert>
)}
{thumbnail ? (
<Stack direction="horizontal" gap={3}>
<Image src={thumbnailSrc || thumbnail} alt={intl.formatMessage(messages.thumbnailAltText)} />
{ (allowThumbnailUpload && isEdxVideo) ? (
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip>
<FormattedMessage {...messages.deleteThumbnail} />
</Tooltip>
)}
>
<IconButton
className="d-inline-block"
iconAs={Icon}
src={Delete}
onClick={() => updateField({ thumbnail: null })}
/>
</OverlayTrigger>
) : null }
</Stack>
) : (
<Stack gap={3}>
<div>
<FormattedMessage {...messages.addThumbnail} />
</div>
<div style={{ color: 'grey' }}>
<FormattedMessage {...messages.aspectRequirements} />
</div>
<FileInput fileInput={fileInput} acceptedFiles={Object.values(acceptedImgKeys).join()} />
<Button iconBefore={FileUpload} onClick={fileInput.click} variant="link" disabled={!isEdxVideo}>
<FormattedMessage {...messages.uploadButtonLabel} />
</Button>
</Stack>
)}
</CollapsibleFormWidget>
);
};
ThumbnailWidget.propTypes = {
// injected
intl: intlShape.isRequired,
// redux
allowThumbnailUpload: PropTypes.bool.isRequired,
thumbnail: PropTypes.string.isRequired,
updateField: PropTypes.func.isRequired,
videoType: PropTypes.string.isRequired,
};
export const mapStateToProps = (state) => ({
allowThumbnailUpload: selectors.video.allowThumbnailUpload(state),
thumbnail: selectors.video.thumbnail(state),
videoType: selectors.video.videoType(state),
});
export const mapDispatchToProps = (dispatch) => ({
updateField: (stateUpdate) => dispatch(actions.video.updateField(stateUpdate)),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ThumbnailWidget));

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../../../../testUtils';
import { actions, selectors } from '../../../../../../data/redux';
import { ThumbnailWidget, mapStateToProps, mapDispatchToProps } from '.';
jest.mock('../../../../../../data/redux', () => ({
actions: {
video: {
updateField: jest.fn().mockName('actions.video.updateField'),
},
},
selectors: {
video: {
allowThumbnailUpload: jest.fn(state => ({ allowThumbnailUpload: state })),
thumbnail: jest.fn(state => ({ thumbnail: state })),
videoType: jest.fn(state => ({ videoType: state })),
},
},
}));
describe('ThumbnailWidget', () => {
const props = {
error: {},
title: 'tiTLE',
intl: { formatMessage },
allowThumbnailUpload: false,
thumbnail: null,
videoType: '',
updateField: jest.fn().mockName('args.updateField'),
};
describe('snapshots', () => {
test('snapshots: renders as expected with default props', () => {
expect(
shallow(<ThumbnailWidget {...props} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with a thumbnail provided', () => {
expect(
shallow(<ThumbnailWidget {...props} thumbnail="sOMeUrl" videoType="edxVideo" />),
).toMatchSnapshot();
});
test('snapshots: renders as expected where thumbnail uploads are allowed', () => {
expect(
shallow(<ThumbnailWidget {...props} thumbnail="sOMeUrl" allowThumbnailUpload />),
).toMatchSnapshot();
});
test('snapshots: renders as expected where videoType equals edxVideo', () => {
expect(
shallow(<ThumbnailWidget {...props} thumbnail="sOMeUrl" allowThumbnailUpload videoType="edxVideo" />),
).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
test('allowThumbnailUpload from video.allowThumbnailUpload', () => {
expect(
mapStateToProps(testState).allowThumbnailUpload,
).toEqual(selectors.video.allowThumbnailUpload(testState));
});
test('thumbnail from video.thumbnail', () => {
expect(
mapStateToProps(testState).thumbnail,
).toEqual(selectors.video.thumbnail(testState));
});
test('videoType from video.videoType', () => {
expect(
mapStateToProps(testState).videoType,
).toEqual(selectors.video.videoType(testState));
});
});
describe('mapDispatchToProps', () => {
const dispatch = jest.fn();
test('updateField from actions.video.updateField', () => {
expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField));
});
});
});

View File

@@ -0,0 +1,44 @@
export const messages = {
title: {
id: 'authoring.videoeditor.thumbnail.title',
defaultMessage: 'Thumbnail',
description: 'Title for thumbnail widget',
},
unavailableSubtitle: {
id: 'authoring.videoeditor.thumbnail.unavailable.subtitle',
defaultMessage: 'Unavailable',
description: 'Subtitle for unavailable thumbnail widget',
},
unavailableMessage: {
id: 'authoring.videoeditor.thumbnail.unavailable.message',
defaultMessage: 'Select a video from your library to enable this feature',
description: 'Message for unavailable thumbnail widget',
},
uploadButtonLabel: {
id: 'authoring.videoeditor.thumbnail.upload.label',
defaultMessage: 'Upload Thumbnail',
description: 'Label for upload button',
},
addThumbnail: {
id: 'authoring.videoeditor.thumbnail.upload.message',
defaultMessage: 'Upload an image for learners to see before playing the video.',
description: 'Message for adding thumbnail',
},
aspectRequirements: {
id: 'authoring.videoeditor.thumbnail.upload.aspectRequirements',
defaultMessage: 'Images must have an aspect ratio of 16:9 (1280x720 px recommended)',
description: 'Message for thumbnail aspectRequirements',
},
thumbnailAltText: {
id: 'authoring.videoeditor.thumbnail.altText',
defaultMessage: 'Image used as thumbnail for video',
description: 'Alternative test for thumbnail',
},
deleteThumbnail: {
id: 'authoring.videoeditor.thumbnail.deleteThumbnail',
defaultMessage: 'Delete',
description: 'Message presented to user for action to delete thumbnail',
},
};
export default messages;

View File

@@ -0,0 +1,11 @@
import { StrictDict } from '../../../../../../utils';
export const acceptedImgKeys = StrictDict({
gif: '.gif',
jpg: '.jpg',
jpeg: '.jpeg',
png: '.png',
bmp: '.bmp',
});
export default { acceptedImgKeys };

View File

@@ -17,7 +17,7 @@ jest.mock('react-redux', () => {
jest.mock('../../../../../../data/redux', () => ({
thunkActions: {
video: {
deleteTranscript: jest.fn().mockName('actions.video.deleteTranscript'),
deleteTranscript: jest.fn().mockName('thunkActions.video.deleteTranscript'),
},
},
selectors: {

View File

@@ -1,7 +1,10 @@
import React from 'react';
import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import {
FormattedMessage,
injectIntl,
} from '@edx/frontend-platform/i18n';
import {
Form,
Button,
@@ -12,10 +15,6 @@ import {
Alert,
} from '@edx/paragon';
import { FileUpload, Info } from '@edx/paragon/icons';
import {
FormattedMessage,
injectIntl,
} from '@edx/frontend-platform/i18n';
import { actions, selectors } from '../../../../../../data/redux';
import * as hooks from './hooks';
@@ -46,7 +45,6 @@ export const TranscriptWidget = ({
const languagesArr = hooks.transcriptLanguages(transcripts);
const fileInput = hooks.fileInput({ onAddFile: hooks.addFileCallback({ dispatch: useDispatch() }) });
const hasTranscripts = hooks.hasTranscripts(transcripts);
return (
<CollapsibleFormWidget
isError={Object.keys(error).length !== 0}

View File

@@ -20,7 +20,7 @@ jest.mock('../../../../../../data/redux', () => ({
},
thunkActions: {
video: {
deleteTranscript: jest.fn().mockName('actions.video.deleteTranscript'),
deleteTranscript: jest.fn().mockName('thunkActions.video.deleteTranscript'),
},
},

View File

@@ -34,7 +34,7 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with default
id="authoring.videoeditor.videoSource.fallbackVideo.message"
/>
</Form.Text>
<Component
<Form.Row
className="mt-4.5"
>
<Form.Control
@@ -59,8 +59,8 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with default
onClick={[Function]}
/>
</OverLayTrigger>
</Component>
<Component
</Form.Row>
<Form.Row
className="mt-4"
>
<Form.Checkbox
@@ -95,7 +95,7 @@ exports[`VideoSourceWidget snapshots snapshots: renders as expected with default
className="d-inline-block mx-3"
/>
</OverLayTrigger>
</Component>
</Form.Row>
</Form.Group>
<Button
onClick={[Function]}

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { thunkActions } from '../../../../data/redux';
// import VideoPreview from './components/VideoPreview';
import ErrorSummary from './ErrorSummary';
import DurationWidget from './components/DurationWidget';
@@ -11,14 +10,6 @@ import TranscriptWidget from './components/TranscriptWidget';
import VideoSourceWidget from './components/VideoSourceWidget';
import './index.scss';
export const hooks = {
onInputChange: (handleValue) => (e) => handleValue(e.target.value),
onCheckboxChange: (handleValue) => (e) => handleValue(e.target.checked),
onSave: (dispatch) => () => {
dispatch(thunkActions.video.saveVideoData());
},
};
export const VideoSettingsModal = () => (
<div className="video-settings-modal row">
<div className="video-preview col col-4">

View File

@@ -1,4 +1,5 @@
import { useState, createContext } from 'react';
import { thunkActions } from '../../data/redux';
import { StrictDict } from '../../utils';
import * as module from './hooks';
@@ -42,3 +43,6 @@ export const errorsHook = () => {
},
};
};
export const fetchVideoContent = () => ({ dispatch }) => (
dispatch(thunkActions.video.saveVideoData())
);

View File

@@ -1,9 +1,31 @@
import { dispatch } from 'react-redux';
import { thunkActions } from '../../data/redux';
import { MockUseState } from '../../../testUtils';
import * as module from './hooks';
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn(val => ({ current: val })),
useEffect: jest.fn(),
useCallback: (cb, prereqs) => ({ cb, prereqs }),
}));
jest.mock('react-redux', () => {
const dispatchFn = jest.fn();
return {
...jest.requireActual('react-redux'),
dispatch: dispatchFn,
useDispatch: jest.fn(() => dispatchFn),
};
});
jest.mock('../../data/redux', () => ({
thunkActions: {
video: {
saveVideoData: jest.fn(),
},
},
}));
const state = new MockUseState(module);
@@ -73,4 +95,10 @@ describe('VideoEditorHooks', () => {
});
});
});
describe('fetchVideoContent', () => {
it('equals dispatch(thunkActions.video.saveVideoData())', () => {
hook = module.fetchVideoContent()({ dispatch });
expect(hook).toEqual(dispatch(thunkActions.video.saveVideoData()));
});
});
});

View File

@@ -2,26 +2,21 @@ import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { selectors } from '../../data/redux';
import EditorContainer from '../EditorContainer';
import VideoEditorModal from './components/VideoEditorModal';
import { ErrorContext, errorsHook } from './hooks';
import { ErrorContext, errorsHook, fetchVideoContent } from './hooks';
export const VideoEditor = ({
onClose,
// redux
videoSettings,
}) => {
const {
error,
validateEntry,
} = errorsHook();
return (
<ErrorContext.Provider value={error}>
<EditorContainer
getContent={() => videoSettings}
getContent={fetchVideoContent()}
onClose={onClose}
validateEntry={validateEntry}
>
@@ -35,38 +30,12 @@ export const VideoEditor = ({
VideoEditor.defaultProps = {
onClose: null,
videoSettings: null,
};
VideoEditor.propTypes = {
onClose: PropTypes.func,
// redux
videoSettings: PropTypes.shape({
videoSource: PropTypes.string,
fallbackVideos: PropTypes.arrayOf(PropTypes.string),
allowVideoDownloads: PropTypes.bool,
thumbnail: PropTypes.string,
transcripts: PropTypes.objectOf(PropTypes.string),
allowTranscriptDownloads: PropTypes.bool,
duration: PropTypes.shape({
startTime: PropTypes.number,
stopTime: PropTypes.number,
total: PropTypes.number,
}),
showTranscriptByDefult: PropTypes.bool,
handout: PropTypes.string,
licenseType: PropTypes.string,
licenseDetails: PropTypes.shape({
attribution: PropTypes.bool,
noncommercial: PropTypes.bool,
noDerivatives: PropTypes.bool,
shareAlike: PropTypes.bool,
}),
}),
};
export const mapStateToProps = (state) => ({
videoSettings: selectors.video.videoSettings(state),
});
export const mapStateToProps = () => {};
export const mapDispatchToProps = {};

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { selectors } from '../../data/redux';
import { VideoEditor, mapStateToProps, mapDispatchToProps } from '.';
import { VideoEditor, mapDispatchToProps } from '.';
jest.mock('../EditorContainer', () => 'EditorContainer');
jest.mock('./components/VideoEditorModal', () => 'VideoEditorModal');
@@ -13,37 +12,18 @@ jest.mock('./hooks', () => ({
error: 'hooks.errorsHook.error',
validateEntry: jest.fn().mockName('validateEntry'),
})),
}));
jest.mock('../../data/redux', () => ({
selectors: {
video: {
videoSettings: state => ({ videoSettings: { state } }),
},
},
fetchVideoContent: jest.fn().mockName('fetchVideoContent'),
}));
describe('VideoEditor', () => {
const props = {
onClose: jest.fn().mockName('props.onClose'),
// redux
videoSettings: 'vIdEOsETtings',
};
describe('snapshots', () => {
test('renders as expected with default behavior', () => {
expect(shallow(<VideoEditor {...props} />)).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { some: 'testState' };
test('loads videoSettings from videoSettings selector', () => {
expect(mapStateToProps(testState).videoSettings).toEqual(
selectors.video.videoSettings(testState),
);
});
});
describe('mapDispatchToProps', () => {
test('is empty', () => {
expect(mapDispatchToProps).toEqual({});

View File

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

View File

@@ -11,6 +11,8 @@ const initialState = {
[RequestKeys.saveBlock]: { status: RequestStates.inactive },
[RequestKeys.fetchImages]: { status: RequestStates.inactive },
[RequestKeys.uploadImage]: { status: RequestStates.inactive },
[RequestKeys.allowThumbnailUpload]: { status: RequestStates.inactive },
[RequestKeys.uploadThumbnail]: { status: RequestStates.inactive },
[RequestKeys.uploadTranscript]: { status: RequestStates.inactive },
[RequestKeys.deleteTranscript]: { status: RequestStates.inactive },

View File

@@ -134,7 +134,27 @@ export const fetchImages = ({ ...rest }) => (dispatch, getState) => {
...rest,
}));
};
export const allowThumbnailUpload = ({ ...rest }) => (dispatch, getState) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.allowThumbnailUpload,
promise: api.allowThumbnailUpload({
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
}),
...rest,
}));
};
export const uploadThumbnail = ({ thumbnail, videoId, ...rest }) => (dispatch, getState) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.uploadThumbnail,
promise: api.uploadThumbnail({
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
learningContextId: selectors.app.learningContextId(getState()),
thumbnail,
videoId,
}),
...rest,
}));
};
export const deleteTranscript = ({ language, videoId, ...rest }) => (dispatch, getState) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.deleteTranscript,
@@ -174,6 +194,8 @@ export default StrictDict({
fetchUnit,
saveBlock,
uploadImage,
allowThumbnailUpload,
uploadThumbnail,
deleteTranscript,
uploadTranscript,
});

View File

@@ -28,6 +28,8 @@ jest.mock('../../services/cms/api', () => ({
fetchImages: ({ id, url }) => ({ id, url }),
uploadImage: (args) => args,
loadImages: jest.fn(),
allowThumbnailUpload: jest.fn(),
uploadThumbnail: jest.fn(),
uploadTranscript: jest.fn(),
deleteTranscript: jest.fn(),
}));
@@ -282,6 +284,39 @@ describe('requests thunkActions module', () => {
},
});
});
describe('allowThumbnailUpload', () => {
testNetworkRequestAction({
action: requests.allowThumbnailUpload,
args: { ...fetchParams },
expectedString: 'with allowThumbnailUpload promise',
expectedData: {
...fetchParams,
requestKey: RequestKeys.allowThumbnailUpload,
promise: api.allowThumbnailUpload({
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
}),
},
});
});
describe('uploadThumbnail', () => {
const thumbnail = 'SoME tHumbNAil CoNtent As String';
const videoId = 'SoME VidEOid CoNtent As String';
testNetworkRequestAction({
action: requests.uploadThumbnail,
args: { thumbnail, videoId, ...fetchParams },
expectedString: 'with uploadThumbnail promise',
expectedData: {
...fetchParams,
requestKey: RequestKeys.uploadThumbnail,
promise: api.uploadThumbnail({
learningContextId: selectors.app.learningContextId(testState),
thumbnail,
videoId,
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
}),
},
});
});
describe('deleteTranscript', () => {
const language = 'SoME laNGUage CoNtent As String';
const videoId = 'SoME VidEOid CoNtent As String';

View File

@@ -7,6 +7,7 @@ export const loadVideoData = () => (dispatch, getState) => {
const rawVideoData = state.app.blockValue.data.metadata ? state.app.blockValue.data.metadata : {};
const {
videoSource,
videoType,
videoId,
fallbackVideos,
} = module.determineVideoSource({
@@ -14,12 +15,12 @@ export const loadVideoData = () => (dispatch, getState) => {
youtubeId: rawVideoData.youtube_id_1_0,
html5Sources: rawVideoData.html5_sources,
});
// we don't appear to want to parse license version
const [licenseType, licenseOptions] = module.parseLicense(rawVideoData.license);
dispatch(actions.video.load({
videoSource,
videoType,
videoId,
fallbackVideos,
allowVideoDownloads: rawVideoData.download_video,
@@ -39,6 +40,12 @@ export const loadVideoData = () => (dispatch, getState) => {
noDerivatives: licenseOptions.nd,
shareAlike: licenseOptions.sa,
},
thumbnail: rawVideoData.thumbnail,
}));
dispatch(requests.allowThumbnailUpload({
onSuccess: (response) => dispatch(actions.video.updateField({
allowThumbnailUpload: response.data.allowThumbnailUpload,
})),
}));
};
@@ -52,25 +59,30 @@ export const determineVideoSource = ({
const youtubeUrl = `https://youtu.be/${youtubeId}`;
const videoId = edxVideoId || '';
let videoSource = '';
let videoType = '';
let fallbackVideos = [];
if (edxVideoId) {
[videoSource, fallbackVideos] = [edxVideoId, html5Sources];
// videoSource = edxVideoId;
// fallbackVideos = html5Sources;
} else if (youtubeId) {
[videoSource, fallbackVideos] = [youtubeUrl, html5Sources];
if (youtubeId) {
// videoSource = youtubeUrl;
// fallbackVideos = html5Sources;
[videoSource, fallbackVideos] = [youtubeUrl, html5Sources];
videoType = 'youtube';
} else if (edxVideoId) {
// videoSource = edxVideoId;
// fallbackVideos = html5Sources;
[videoSource, fallbackVideos] = [edxVideoId, html5Sources];
videoType = 'edxVideo';
} else if (Array.isArray(html5Sources) && html5Sources[0]) {
[videoSource, fallbackVideos] = [html5Sources[0], html5Sources.slice(1)];
// videoSource = html5Sources[0];
// fallbackVideos = html5Sources.slice(1);
[videoSource, fallbackVideos] = [html5Sources[0], html5Sources.slice(1)];
videoType = 'html5source';
}
if (fallbackVideos.length === 0) {
fallbackVideos = ['', ''];
}
return {
videoSource,
videoType,
videoId,
fallbackVideos,
};
@@ -144,9 +156,27 @@ export const parseLicense = (license) => {
return [licenseType, options, version];
};
export const saveVideoData = () => () => {
export const saveVideoData = () => (dispatch, getState) => {
// dispatch(actions.app.setBlockContent)
// dispatch(requests.saveBlock({ });
const state = getState();
return selectors.video.videoSettings(state);
};
export const uploadThumbnail = ({ thumbnail }) => (dispatch, getState) => {
const state = getState();
const { videoId } = state.video;
const { studioEndpointUrl } = state.app;
dispatch(requests.uploadThumbnail({
thumbnail,
videoId,
onSuccess: (response) => {
const thumbnailUrl = studioEndpointUrl + response.data.image_url;
dispatch(actions.video.updateField({
thumbnail: thumbnailUrl,
}));
},
}));
};
// Transcript Thunks:
@@ -221,6 +251,7 @@ export default {
determineVideoSource,
parseLicense,
saveVideoData,
uploadThumbnail,
uploadTranscript,
deleteTranscript,
replaceTranscript,

View File

@@ -12,10 +12,13 @@ jest.mock('..', () => ({
selectors: {
video: {
videoId: (state) => ({ videoId: state }),
videoSettings: (state) => ({ videoSettings: state }),
},
},
}));
jest.mock('./requests', () => ({
allowThumbnailUpload: (args) => ({ allowThumbnailUpload: args }),
uploadThumbnail: (args) => ({ uploadThumbnail: args }),
deleteTranscript: (args) => ({ deleteTranscript: args }),
uploadTranscript: (args) => ({ uploadTranscript: args }),
}));
@@ -24,6 +27,10 @@ const thunkActionsKeys = keyStore(thunkActions);
const mockLanguage = 'la';
const mockFile = 'soMEtRANscRipT';
const mockFilename = 'soMEtRANscRipT.srt';
const mockThumbnail = 'sOMefILE';
const mockThumbnailResponse = { data: { image_url: 'soMEimAGEUrL' } };
const thumbnailUrl = 'soMEeNDPoiNTsoMEimAGEUrL';
const mockAllowThumbnailUpload = { data: { allowThumbnailUpload: 'soMEbOolEAn' } };
const testMetadata = {
download_track: 'dOWNlOAdTraCK',
@@ -37,7 +44,12 @@ const testMetadata = {
start_time: 'stARtTiME',
transcripts: { la: 'test VALUE' },
};
const testState = { transcripts: { la: 'test VALUE' }, videoId: 'soMEvIDEo' };
const testState = {
transcripts: { la: 'test VALUE' },
thumbnail: 'sOMefILE',
originalThumbnail: null,
videoId: 'soMEvIDEo',
};
const testUpload = { transcripts: { la: { filename: mockFilename } } };
const testReplaceUpload = {
file: mockFile,
@@ -61,9 +73,25 @@ describe('video thunkActions', () => {
}));
});
describe('loadVideoData', () => {
let dispatchedLoad;
beforeEach(() => {
thunkActions.loadVideoData()(dispatch, getState);
[[dispatchedLoad], [dispatchedAction]] = dispatch.mock.calls;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('dispatches allowThumbnailUpload action', () => {
expect(dispatchedLoad).not.toEqual(undefined);
expect(dispatchedAction.allowThumbnailUpload).not.toEqual(undefined);
});
it('dispatches actions.video.updateField on success', () => {
dispatch.mockClear();
dispatchedAction.allowThumbnailUpload.onSuccess(mockAllowThumbnailUpload);
expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({
allowThumbnailUpload: mockAllowThumbnailUpload.data.allowThumbnailUpload,
}));
});
it('dispatches actions.video.load', () => {
jest.spyOn(thunkActions, thunkActionsKeys.determineVideoSource).mockReturnValue({
videoSource: 'videOsOurce',
@@ -110,15 +138,30 @@ describe('video thunkActions', () => {
const youtubeUrl = `https://youtu.be/${youtubeId}`;
const html5Sources = ['htmLOne', 'hTMlTwo', 'htMLthrEE'];
describe('when there is an edx video id, youtube id and html5 sources', () => {
it('returns the edx video id for video source and html5 sources for fallback videos', () => {
it('returns the youtube id for video source and html5 sources for fallback videos', () => {
expect(thunkActions.determineVideoSource({
edxVideoId,
youtubeId,
html5Sources,
})).toEqual({
videoSource: youtubeUrl,
videoId: edxVideoId,
videoType: 'youtube',
fallbackVideos: html5Sources,
});
});
});
describe('when there is an edx video id', () => {
it('returns the edx video id for video source', () => {
expect(thunkActions.determineVideoSource({
edxVideoId,
youtubeId: '',
html5Sources: '',
})).toEqual({
videoSource: edxVideoId,
videoId: edxVideoId,
fallbackVideos: html5Sources,
videoType: 'edxVideo',
fallbackVideos: ['', ''],
});
});
});
@@ -131,6 +174,7 @@ describe('video thunkActions', () => {
})).toEqual({
videoSource: youtubeUrl,
videoId: '',
videoType: 'youtube',
fallbackVideos: html5Sources,
});
});
@@ -144,6 +188,7 @@ describe('video thunkActions', () => {
})).toEqual({
videoSource: 'htmLOne',
videoId: '',
videoType: 'html5source',
fallbackVideos: ['hTMlTwo', 'htMLthrEE'],
});
});
@@ -156,6 +201,7 @@ describe('video thunkActions', () => {
videoSource: 'htmlOne',
videoId: '',
fallbackVideos: ['', ''],
videoType: 'html5source',
});
});
});
@@ -169,6 +215,7 @@ describe('video thunkActions', () => {
videoSource: '',
videoId: '',
fallbackVideos: ['', ''],
videoType: '',
});
});
});
@@ -201,6 +248,24 @@ describe('video thunkActions', () => {
]);
});
});
describe('uploadThumbnail', () => {
beforeEach(() => {
thunkActions.uploadThumbnail({ thumbnail: mockThumbnail })(dispatch, getState);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches uploadThumbnail action', () => {
expect(dispatchedAction.uploadThumbnail).not.toEqual(undefined);
});
it('dispatches actions.video.updateField on success', () => {
dispatch.mockClear();
dispatchedAction.uploadThumbnail.onSuccess(mockThumbnailResponse);
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
thumbnail: thumbnailUrl,
}),
);
});
});
describe('deleteTranscript', () => {
beforeEach(() => {
thunkActions.deleteTranscript({ language: mockLanguage })(dispatch, getState);

View File

@@ -4,6 +4,7 @@ import { StrictDict } from '../../../utils';
const initialState = {
videoSource: '',
videoType: '',
videoId: '',
fallbackVideos: [
'',
@@ -27,6 +28,7 @@ const initialState = {
noDerivatives: false,
shareAlike: false,
},
allowThumbnailUpload: null,
};
// eslint-disable-next-line no-unused-vars
@@ -41,23 +43,6 @@ 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

@@ -25,6 +25,8 @@ export const simpleSelectors = [
stateKeys.handout,
stateKeys.licenseType,
stateKeys.licenseDetails,
stateKeys.allowThumbnailUpload,
stateKeys.videoType,
].reduce((obj, key) => ({ ...obj, [key]: state => state.video[key] }), {});
export const openLanguages = createSelector(
@@ -52,6 +54,7 @@ export const getTranscriptDownloadUrl = createSelector(
export const videoSettings = createSelector(
[
module.simpleSelectors.videoSource,
module.simpleSelectors.videoId,
module.simpleSelectors.fallbackVideos,
module.simpleSelectors.allowVideoDownloads,
module.simpleSelectors.thumbnail,
@@ -65,6 +68,7 @@ export const videoSettings = createSelector(
],
(
videoSource,
videoId,
fallbackVideos,
allowVideoDownloads,
thumbnail,
@@ -78,6 +82,7 @@ export const videoSettings = createSelector(
) => (
{
videoSource,
videoId,
fallbackVideos,
allowVideoDownloads,
thumbnail,

View File

@@ -29,6 +29,24 @@ export const apiMethods = {
data,
);
},
allowThumbnailUpload: ({
studioEndpointUrl,
}) => get(
urls.allowThumbnailUpload({ studioEndpointUrl }),
),
uploadThumbnail: ({
studioEndpointUrl,
learningContextId,
videoId,
thumbnail,
}) => {
const data = new FormData();
data.append('file', thumbnail);
return post(
urls.thumbnailUpload({ studioEndpointUrl, learningContextId, videoId }),
data,
);
},
deleteTranscript: ({
studioEndpointUrl,
language,
@@ -95,6 +113,7 @@ export const apiMethods = {
edx_video_id: edxVideoId,
html5_sources: html5Sources,
youtube_id_1_0: youtubeId,
thumbnail: content.thumbnail,
download_track: content.allowTranscriptDownloads,
track: '', // TODO Downloadable Transcript URL. Backend expects a file name, for example: "something.srt"
show_captions: content.showTranscriptByDefault,
@@ -149,7 +168,9 @@ export const processVideoIds = ({ videoSource, fallbackVideos }) => {
html5Sources.push(videoSource);
}
fallbackVideos.forEach((src) => (src ? html5Sources.push(src) : null));
if (fallbackVideos) {
fallbackVideos.forEach((src) => (src ? html5Sources.push(src) : null));
}
return {
edxVideoId,

View File

@@ -131,6 +131,7 @@ describe('cms api', () => {
edx_video_id: edxVideoId,
html5_sources: html5Sources,
youtube_id_1_0: youtubeId,
thumbnail: content.thumbnail,
download_track: content.allowTranscriptDownloads,
track: '',
show_captions: content.showTranscriptByDefault,

View File

@@ -23,17 +23,18 @@ export const videoDataProps = {
noDerivatives: PropTypes.bool,
shareAlike: PropTypes.bool,
}),
originalThumbnail: PropTypes.string,
};
export const singleVideoData = {
videoSource: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
videoId: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7',
videoId: '7c12381b-6503-4d52-82bd-6ad01b902220',
fallbackVideos: [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
],
allowVideoDownloads: true,
thumbnail: 'my-thumbnail-file-url', // filename
thumbnail: 'someString', // filename
transcripts: {
en: { filename: 'my-transcript-url' },
},
@@ -52,4 +53,5 @@ export const singleVideoData = {
noDerivatives: false,
shareAlike: false,
},
originalThumbnail: 'someString',
};

View File

@@ -35,6 +35,14 @@ export const courseImages = ({ studioEndpointUrl, learningContextId }) => (
`${courseAssets({ studioEndpointUrl, learningContextId })}?sort=uploadDate&direction=desc&asset_type=Images`
);
export const allowThumbnailUpload = ({ studioEndpointUrl }) => (
`${studioEndpointUrl}/video_images_upload_enabled`
);
export const thumbnailUpload = ({ studioEndpointUrl, learningContextId, videoId }) => (
`${studioEndpointUrl}/video_images/${learningContextId}/${videoId}`
);
export const videoTranscripts = ({ studioEndpointUrl, blockId }) => (
`${block({ studioEndpointUrl, blockId })}/handler/studio_transcript/translation`
);

View File

@@ -102,6 +102,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Group: 'Form.Group',
Label: 'Form.Label',
Text: 'Form.Text',
Row: 'Form.Row',
},
FullscreenModal: 'FullscreenModal',
Scrollable: 'Scrollable',