feat: thumbnail widget
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
@@ -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)>
|
||||
`;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -20,7 +20,7 @@ jest.mock('../../../../../../data/redux', () => ({
|
||||
},
|
||||
thunkActions: {
|
||||
video: {
|
||||
deleteTranscript: jest.fn().mockName('actions.video.deleteTranscript'),
|
||||
deleteTranscript: jest.fn().mockName('thunkActions.video.deleteTranscript'),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -14,6 +14,8 @@ export const RequestKeys = StrictDict({
|
||||
fetchUnit: 'fetchUnit',
|
||||
saveBlock: 'saveBlock',
|
||||
uploadImage: 'uploadImage',
|
||||
allowThumbnailUpload: 'allowThumbnailUpload',
|
||||
uploadThumbnail: 'uploadThumbnail',
|
||||
uploadTranscript: 'uploadTranscript',
|
||||
deleteTranscript: 'deleteTranscript',
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user