Merge pull request #326 from open-craft/chris/FAL-3383-new-video-editor-flow

[FAL-3383] Implement new video UX flow on new video editor
This commit is contained in:
kenclary
2023-05-18 11:49:10 -04:00
committed by GitHub
49 changed files with 1181 additions and 136 deletions

View File

@@ -5,6 +5,7 @@ import VideoGallery from './containers/VideoGallery';
import * as hooks from './hooks';
export const VideoSelector = ({
blockId,
learningContextId,
lmsEndpointUrl,
studioEndpointUrl,
@@ -13,7 +14,7 @@ export const VideoSelector = ({
hooks.initializeApp({
dispatch,
data: {
blockId: '',
blockId,
blockType: 'video',
learningContextId,
lmsEndpointUrl,
@@ -26,6 +27,7 @@ export const VideoSelector = ({
};
VideoSelector.propTypes = {
blockId: PropTypes.string.isRequired,
learningContextId: PropTypes.string.isRequired,
lmsEndpointUrl: PropTypes.string.isRequired,
studioEndpointUrl: PropTypes.string.isRequired,

View File

@@ -11,13 +11,13 @@ jest.mock('./hooks', () => ({
jest.mock('./containers/VideoGallery', () => 'VideoGallery');
const props = {
blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4',
learningContextId: 'course-v1:edX+DemoX+Demo_Course',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',
};
const initData = {
blockId: '',
blockType: 'video',
...props,
};

View File

@@ -6,6 +6,7 @@ import VideoSelector from './VideoSelector';
import store from './data/store';
const VideoSelectorPage = ({
blockId,
courseId,
lmsEndpointUrl,
studioEndpointUrl,
@@ -19,6 +20,7 @@ const VideoSelectorPage = ({
>
<VideoSelector
{...{
blockId,
learningContextId: courseId,
lmsEndpointUrl,
studioEndpointUrl,
@@ -29,12 +31,14 @@ const VideoSelectorPage = ({
);
VideoSelectorPage.defaultProps = {
blockId: null,
courseId: null,
lmsEndpointUrl: null,
studioEndpointUrl: null,
};
VideoSelectorPage.propTypes = {
blockId: PropTypes.string,
courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
studioEndpointUrl: PropTypes.string,

View File

@@ -3,6 +3,7 @@ import { shallow } from 'enzyme';
import VideoSelectorPage from './VideoSelectorPage';
const props = {
blockId: 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4',
courseId: 'course-v1:edX+DemoX+Demo_Course',
lmsEndpointUrl: 'evenfakerurl.com',
studioEndpointUrl: 'fakeurl.com',

View File

@@ -17,6 +17,7 @@ exports[`Video Selector Page snapshots rendering correctly with expected Input 1
studioEndpointUrl="fakeurl.com"
>
<VideoSelector
blockId="block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
learningContextId="course-v1:edX+DemoX+Demo_Course"
lmsEndpointUrl="evenfakerurl.com"
studioEndpointUrl="fakeurl.com"
@@ -42,6 +43,7 @@ exports[`Video Selector Page snapshots rendering with props to null 1`] = `
studioEndpointUrl={null}
>
<VideoSelector
blockId={null}
learningContextId={null}
lmsEndpointUrl={null}
studioEndpointUrl={null}

View File

@@ -8,11 +8,22 @@ exports[`VideoEditor snapshots renders as expected with default behavior 1`] = `
onClose={[MockFunction props.onClose]}
validateEntry={[MockFunction validateEntry]}
>
<Spinner
animation="border"
className="m-3"
screenreadertext="loading"
/>
<div
style={
Object {
"left": "50%",
"position": "absolute",
"top": "50%",
"transform": "translate(-50%, -50%)",
}
}
>
<Spinner
animation="border"
className="m-3"
screenreadertext="loading"
/>
</div>
</EditorContainer>
</Component>
`;

View File

@@ -1,18 +1,27 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { thunkActions } from '../../../data/redux';
import * as appHooks from '../../../hooks';
import { thunkActions, selectors } from '../../../data/redux';
import VideoSettingsModal from './VideoSettingsModal';
// import SelectVideoModal from './SelectVideoModal';
import * as module from './VideoEditorModal';
export const {
navigateTo,
} = appHooks;
export const hooks = {
initialize: (dispatch) => {
initialize: (dispatch, selectedVideoId, selectedVideoUrl) => {
React.useEffect(() => {
dispatch(thunkActions.video.loadVideoData());
dispatch(thunkActions.video.loadVideoData(selectedVideoId, selectedVideoUrl));
}, []);
},
returnToGallery: () => {
const learningContextId = useSelector(selectors.app.learningContextId);
const blockId = useSelector(selectors.app.blockId);
return () => (navigateTo(`/course/${learningContextId}/editor/course-videos/${blockId}`));
},
};
const VideoEditorModal = ({
@@ -20,9 +29,18 @@ const VideoEditorModal = ({
isOpen,
}) => {
const dispatch = useDispatch();
module.hooks.initialize(dispatch);
const searchParams = new URLSearchParams(document.location.search);
const selectedVideoId = searchParams.get('selectedVideoId');
const selectedVideoUrl = searchParams.get('selectedVideoUrl');
const onReturn = module.hooks.returnToGallery();
module.hooks.initialize(dispatch, selectedVideoId, selectedVideoUrl);
return (
<VideoSettingsModal {...{ close, isOpen }} />
<VideoSettingsModal {...{
close,
isOpen,
onReturn,
}}
/>
);
// TODO: add logic to show SelectVideoModal if no selection
};

View File

@@ -40,6 +40,7 @@ export const hooks = {
export const Transcript = ({
index,
language,
transcriptUrl,
// redux
deleteTranscript,
}) => {
@@ -90,6 +91,7 @@ export const Transcript = ({
<TranscriptActionMenu
index={index}
language={language}
transcriptUrl={transcriptUrl}
launchDeleteConfirmation={launchDeleteConfirmation}
/>
)}
@@ -99,9 +101,14 @@ export const Transcript = ({
);
};
Transcript.defaultProps = {
transcriptUrl: undefined,
};
Transcript.propTypes = {
index: PropTypes.number.isRequired,
language: PropTypes.string.isRequired,
transcriptUrl: PropTypes.string,
deleteTranscript: PropTypes.func.isRequired,
};

View File

@@ -80,6 +80,16 @@ describe('Transcript Component', () => {
shallow(<module.Transcript {...props} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with transcriptUrl', () => {
jest.spyOn(module.hooks, 'setUpDeleteConfirmation').mockImplementationOnce(() => ({
inDeleteConfirmation: false,
launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'),
cancelDelete: jest.fn().mockName('cancelDelete'),
}));
expect(
shallow(<module.Transcript {...props} transcriptUrl="url" />),
).toMatchSnapshot();
});
});
});
});

View File

@@ -25,13 +25,14 @@ export const hooks = {
export const TranscriptActionMenu = ({
index,
language,
transcriptUrl,
launchDeleteConfirmation,
// redux
getTranscriptDownloadUrl,
buildTranscriptUrl,
}) => {
const input = fileInput({ onAddFile: module.hooks.replaceFileCallback({ language, dispatch: useDispatch() }) });
const downloadLink = getTranscriptDownloadUrl({ language });
const downloadLink = transcriptUrl ? buildTranscriptUrl({ transcriptUrl }) : getTranscriptDownloadUrl({ language });
return (
<Dropdown>
<Dropdown.Toggle
@@ -61,16 +62,23 @@ export const TranscriptActionMenu = ({
);
};
TranscriptActionMenu.defaultProps = {
transcriptUrl: undefined,
};
TranscriptActionMenu.propTypes = {
index: PropTypes.number.isRequired,
language: PropTypes.string.isRequired,
transcriptUrl: PropTypes.string,
launchDeleteConfirmation: PropTypes.func.isRequired,
// redux
getTranscriptDownloadUrl: PropTypes.func.isRequired,
buildTranscriptUrl: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
getTranscriptDownloadUrl: selectors.video.getTranscriptDownloadUrl(state),
buildTranscriptUrl: selectors.video.buildTranscriptUrl(state),
});
export const mapDispatchToProps = {

View File

@@ -25,6 +25,7 @@ jest.mock('../../../../../../data/redux', () => ({
selectors: {
video: {
getTranscriptDownloadUrl: jest.fn(args => ({ getTranscriptDownloadUrl: args })).mockName('selectors.video.getTranscriptDownloadUrl'),
buildTranscriptUrl: jest.fn(args => ({ buildTranscriptUrl: args })).mockName('selectors.video.buildTranscriptUrl'),
},
},
}));
@@ -62,6 +63,7 @@ describe('TranscriptActionMenu', () => {
launchDeleteConfirmation: jest.fn().mockName('launchDeleteConfirmation'),
// redux
getTranscriptDownloadUrl: jest.fn().mockName('selectors.video.getTranscriptDownloadUrl'),
buildTranscriptUrl: jest.fn().mockName('selectors.video.buildTranscriptUrl'),
};
afterAll(() => {
jest.clearAllMocks();
@@ -72,6 +74,12 @@ describe('TranscriptActionMenu', () => {
shallow(<module.TranscriptActionMenu {...props} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with transcriptUrl props: dont show confirm delete', () => {
jest.spyOn(module.hooks, 'replaceFileCallback').mockImplementationOnce(() => jest.fn().mockName('module.hooks.replaceFileCallback'));
expect(
shallow(<module.TranscriptActionMenu {...props} transcriptUrl="url" />),
).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };

View File

@@ -83,3 +83,21 @@ exports[`Transcript Component component component snapshots: renders as expected
</Card>
</Fragment>
`;
exports[`Transcript Component component component snapshots: renders as expected with transcriptUrl 1`] = `
<Fragment>
<ActionRow>
<LanguageSelector
language="lAnG"
title="sOmenUmBer"
/>
<ActionRow.Spacer />
<TranscriptActionMenu
index="sOmenUmBer"
language="lAnG"
launchDeleteConfirmation={[MockFunction launchDeleteConfirmation]}
transcriptUrl="url"
/>
</ActionRow>
</Fragment>
`;

View File

@@ -53,3 +53,57 @@ exports[`TranscriptActionMenu Snapshots snapshots: renders as expected with defa
/>
</Dropdown>
`;
exports[`TranscriptActionMenu Snapshots snapshots: renders as expected with transcriptUrl props: dont show confirm delete 1`] = `
<Dropdown>
<Dropdown.Toggle
alt="Actions dropdown"
as="IconButton"
iconAs="Icon"
id="dropdown-toggle-with-iconbutton-video-transcript-widget"
variant="primary"
/>
<Dropdown.Menu
className="video_transcript Action Menu"
>
<Dropdown.Item
key="transcript-actions-sOmenUmBer-replace"
onClick={[MockFunction click input]}
>
<FormattedMessage
defaultMessage="Replace"
description="Message Presented To user for action to replace transcript"
id="authoring.videoeditor.transcript.replaceTranscript"
/>
</Dropdown.Item>
<Dropdown.Item
key="transcript-actions-sOmenUmBer-download"
>
<FormattedMessage
defaultMessage="Download"
description="Message Presented To user for action to download transcript"
id="authoring.videoeditor.transcript.downloadTranscript"
/>
</Dropdown.Item>
<Dropdown.Item
key="transcript-actions-sOmenUmBer-delete"
onClick={[MockFunction launchDeleteConfirmation]}
>
<FormattedMessage
defaultMessage="Delete"
description="Message Presented To user for action to delete transcript"
id="authoring.videoeditor.transcript.deleteTranscript"
/>
</Dropdown.Item>
</Dropdown.Menu>
<FileInput
acceptedFiles=".srt"
fileInput={
Object {
"click": [MockFunction click input],
"onAddFile": [MockFunction module.hooks.replaceFileCallback],
}
}
/>
</Dropdown>
`;

View File

@@ -597,6 +597,63 @@ exports[`TranscriptWidget component snapshots snapshots: renders as expected wit
</CollapsibleFormWidget>
`;
exports[`TranscriptWidget component snapshots snapshots: renders as expected with transcript urls 1`] = `
<CollapsibleFormWidget
fontSize="x-small"
isError={true}
subtitle="None"
title="Transcripts"
>
<ErrorAlert
dismissError={null}
hideHeading={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to upload transcript. Please try again."
description="Message presented to user when transcript fails to upload"
id="authoring.videoeditor.transcript.error.uploadTranscriptError"
/>
</ErrorAlert>
<ErrorAlert
dismissError={null}
hideHeading={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to delete transcript. Please try again."
description="Message presented to user when transcript fails to delete"
id="authoring.videoeditor.transcript.error.deleteTranscriptError"
/>
</ErrorAlert>
<Stack
gap={3}
>
<FormattedMessage
defaultMessage="Add video transcripts (.srt files only) for improved accessibility."
description="Message for adding first transcript"
id="authoring.videoeditor.transcripts.upload.firstTranscriptMessage"
/>
<div
className="mt-2"
>
<Button
className="text-primary-500 font-weight-bold justify-content-start pl-0"
onClick={[Function]}
size="sm"
variant="link"
>
<FormattedMessage
defaultMessage="Add a transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</div>
</Stack>
</CollapsibleFormWidget>
`;
exports[`TranscriptWidget component snapshots snapshots: renders as expected with transcripts 1`] = `
<CollapsibleFormWidget
fontSize="x-small"
@@ -715,3 +772,122 @@ exports[`TranscriptWidget component snapshots snapshots: renders as expected wit
</Stack>
</CollapsibleFormWidget>
`;
exports[`TranscriptWidget component snapshots snapshots: renders as expected with transcripts and urls 1`] = `
<CollapsibleFormWidget
fontSize="x-small"
isError={true}
subtitle="Spanish"
title="Transcripts"
>
<ErrorAlert
dismissError={null}
hideHeading={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to upload transcript. Please try again."
description="Message presented to user when transcript fails to upload"
id="authoring.videoeditor.transcript.error.uploadTranscriptError"
/>
</ErrorAlert>
<ErrorAlert
dismissError={null}
hideHeading={true}
isError={false}
>
<FormattedMessage
defaultMessage="Failed to delete transcript. Please try again."
description="Message presented to user when transcript fails to delete"
id="authoring.videoeditor.transcript.error.deleteTranscriptError"
/>
</ErrorAlert>
<Stack
gap={3}
>
<Form.Group
className="border-primary-100 border-bottom"
>
<Transcript
index={0}
language="es"
/>
<ActionRow
className="mt-3.5"
>
<Form.Checkbox
checked={false}
className="decorative-control-label"
onChange={[Function]}
>
<div
className="small text-gray-700"
>
<FormattedMessage
defaultMessage="Allow transcript downloads"
description="Label for allow transcript downloads checkbox"
id="authoring.videoeditor.transcripts.allowDownloadCheckboxLabel"
/>
</div>
</Form.Checkbox>
<OverlayTrigger
key="top"
overlay={
<Tooltip
id="tooltip-top"
>
<FormattedMessage
defaultMessage="Learners will see a link to download the transcript below the video."
description="Message for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.allowDownloadTooltipMessage"
/>
</Tooltip>
}
placement="top"
>
<Icon
style={
Object {
"height": "16px",
"width": "16px",
}
}
/>
</OverlayTrigger>
<ActionRow.Spacer />
</ActionRow>
<Form.Checkbox
checked={false}
className="mt-3 decorative-control-label"
onChange={[Function]}
>
<div
className="small text-gray-700"
>
<FormattedMessage
defaultMessage="Show transcript in the video player by default"
description="Label for show by default checkbox"
id="authoring.videoeditor.transcripts.upload.showByDefaultCheckboxLabel"
/>
</div>
</Form.Checkbox>
</Form.Group>
<div
className="mt-2"
>
<Button
className="text-primary-500 font-weight-bold justify-content-start pl-0"
onClick={[Function]}
size="sm"
variant="link"
>
<FormattedMessage
defaultMessage="Add a transcript"
description="Label for upload button"
id="authoring.videoeditor.transcripts.upload.label"
/>
</Button>
</div>
</Stack>
</CollapsibleFormWidget>
`;

View File

@@ -78,6 +78,7 @@ export const hooks = {
export const TranscriptWidget = ({
// redux
transcripts,
selectedVideoTranscriptUrls,
allowTranscriptDownloads,
showTranscriptByDefault,
allowTranscriptImport,
@@ -117,6 +118,7 @@ export const TranscriptWidget = ({
{transcripts.map((language, index) => (
<Transcript
language={language}
transcriptUrl={selectedVideoTranscriptUrls[language]}
index={index}
/>
))}
@@ -178,10 +180,12 @@ export const TranscriptWidget = ({
};
TranscriptWidget.defaultProps = {
selectedVideoTranscriptUrls: {},
};
TranscriptWidget.propTypes = {
// redux
transcripts: PropTypes.arrayOf(PropTypes.string).isRequired,
selectedVideoTranscriptUrls: PropTypes.shape(),
allowTranscriptDownloads: PropTypes.bool.isRequired,
showTranscriptByDefault: PropTypes.bool.isRequired,
allowTranscriptImport: PropTypes.bool.isRequired,
@@ -192,6 +196,7 @@ TranscriptWidget.propTypes = {
};
export const mapStateToProps = (state) => ({
transcripts: selectors.video.transcripts(state),
selectedVideoTranscriptUrls: selectors.video.selectedVideoTranscriptUrls(state),
allowTranscriptDownloads: selectors.video.allowTranscriptDownloads(state),
showTranscriptByDefault: selectors.video.showTranscriptByDefault(state),
allowTranscriptImport: selectors.video.allowTranscriptImport(state),

View File

@@ -27,6 +27,7 @@ jest.mock('../../../../../../data/redux', () => ({
selectors: {
video: {
transcripts: jest.fn(state => ({ transcripts: state })),
selectedVideoTranscriptUrls: jest.fn(state => ({ selectedVideoTranscriptUrls: state })),
allowTranscriptDownloads: jest.fn(state => ({ allowTranscriptDownloads: state })),
showTranscriptByDefault: jest.fn(state => ({ showTranscriptByDefault: state })),
allowTranscriptImport: jest.fn(state => ({ allowTranscriptImport: state })),
@@ -88,6 +89,7 @@ describe('TranscriptWidget', () => {
title: 'tiTLE',
intl: { formatMessage },
transcripts: [],
selectedVideoTranscriptUrls: {},
allowTranscriptDownloads: false,
showTranscriptByDefault: false,
allowTranscriptImport: false,
@@ -112,6 +114,16 @@ describe('TranscriptWidget', () => {
shallow(<module.TranscriptWidget {...props} transcripts={['en']} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with transcript urls', () => {
expect(
shallow(<module.TranscriptWidget {...props} selectedVideoTranscriptUrls={{ en: 'url' }} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with transcripts and urls', () => {
expect(
shallow(<module.TranscriptWidget {...props} transcripts={['es']} selectedVideoTranscriptUrls={{ en: 'url' }} />),
).toMatchSnapshot();
});
test('snapshots: renders as expected with allowTranscriptDownloads true', () => {
expect(
shallow(<module.TranscriptWidget {...props} allowTranscriptDownloads transcripts={['en']} />),

View File

@@ -10,18 +10,16 @@ export const LanguageNamesWidget = ({ transcripts, intl }) => {
let icon = ClosedCaptionOff;
const hasTranscripts = transcriptHooks.hasTranscripts(transcripts);
let message = intl.formatMessage(messages.noTranscriptsAdded);
let fontClass = 'text-gray';
if (hasTranscripts) {
message = transcriptHooks.transcriptLanguages(transcripts, intl);
fontClass = 'text-primary';
icon = ClosedCaption;
}
return (
<div className="d-flex flex-row align-items-center x-small">
<Icon className="mr-1" src={icon} />
<span className={fontClass}>{message}</span>
<Icon className="mr-1 text-primary-500" src={icon} />
<span className="text-gray-700">{message}</span>
</div>
);
};

View File

@@ -1,4 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Icon } from '@edx/paragon';
import { ArrowBackIos } from '@edx/paragon/icons';
import {
FormattedMessage,
} from '@edx/frontend-platform/i18n';
// import VideoPreview from './components/VideoPreview';
import ErrorSummary from './ErrorSummary';
@@ -11,9 +17,25 @@ import VideoSourceWidget from './components/VideoSourceWidget';
import VideoPreviewWidget from './components/VideoPreviewWidget';
import './index.scss';
import SocialShareWidget from './components/SocialShareWidget';
import messages from '../../messages';
export const VideoSettingsModal = () => (
export const VideoSettingsModal = ({
onReturn,
}) => (
<>
<Button
variant="link"
className="text-primary-500"
size="sm"
onClick={onReturn}
style={{
textDecoration: 'none',
marginLeft: '3px',
}}
>
<Icon src={ArrowBackIos} style={{ height: '13px' }} />
<FormattedMessage {...messages.replaceVideoButtonLabel} />
</Button>
<ErrorSummary />
<VideoPreviewWidget />
<VideoSourceWidget />
@@ -26,4 +48,9 @@ export const VideoSettingsModal = () => (
</>
);
VideoSettingsModal.propTypes = {
showReturn: PropTypes.bool.isRequired,
onReturn: PropTypes.func.isRequired,
};
export default VideoSettingsModal;

View File

@@ -38,11 +38,19 @@ export const VideoEditor = ({
<VideoEditorModal />
</div>
) : (
<Spinner
animation="border"
className="m-3"
screenreadertext={intl.formatMessage(messages.spinnerScreenReaderText)}
/>
<div style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<Spinner
animation="border"
className="m-3"
screenreadertext={intl.formatMessage(messages.spinnerScreenReaderText)}
/>
</div>
)}
</EditorContainer>
</ErrorContext.Provider>

View File

@@ -1,12 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
spinnerScreenReaderText: {
id: 'authoring.videoEditor.spinnerScreenReaderText',
defaultMessage: 'loading',
description: 'Loading message for spinner screenreader text.',
},
replaceVideoButtonLabel: {
id: 'authoring.videoEditor.replaceVideoButtonLabel',
defaultMessage: 'Replace video',
description: 'Text of the replace video button to return to the video gallery',
},
});
export default messages;

View File

@@ -1,6 +1,10 @@
import React from 'react';
import { useSelector } from 'react-redux';
import * as module from './hooks';
import messages from './messages';
import * as appHooks from '../../hooks';
import { selectors } from '../../data/redux';
import analyticsEvt from '../../data/constants/analyticsEvt';
import {
filterKeys,
filterMessages,
@@ -9,6 +13,11 @@ import {
sortFunctions,
} from './utils';
export const {
navigateCallback,
navigateTo,
} = appHooks;
export const state = {
highlighted: (val) => React.useState(val),
searchString: (val) => React.useState(val),
@@ -91,6 +100,8 @@ export const videoListProps = ({ searchSortProps, videos }) => {
setShowSizeError,
] = module.state.showSizeError(false);
const filteredList = module.filterList({ ...searchSortProps, videos });
const learningContextId = useSelector(selectors.app.learningContextId);
const blockId = useSelector(selectors.app.blockId);
return {
galleryError: {
show: showSelectVideoError,
@@ -116,25 +127,38 @@ export const videoListProps = ({ searchSortProps, videos }) => {
height: '100%',
},
selectBtnProps: {
onclick: () => {
// TODO Update this when implementing the selection feature
onClick: () => {
if (highlighted) {
navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${highlighted}`);
} else {
setShowSelectVideoError(true);
}
},
},
};
};
export const fileInputProps = () => {
// TODO [Update video] Implement this
const ref = React.useRef();
const click = () => ref.current.click();
const click = module.handleVideoUpload();
return {
click,
addFile: () => {},
ref,
};
};
export const handleVideoUpload = () => {
const learningContextId = useSelector(selectors.app.learningContextId);
const blockId = useSelector(selectors.app.blockId);
return () => navigateTo(`/course/${learningContextId}/editor/video_upload/${blockId}`);
};
export const handleCancel = () => (
navigateCallback({
destination: useSelector(selectors.app.returnUrl),
analytics: useSelector(selectors.app.analytics),
analyticsEvent: analyticsEvt.videoGalleryCancelClick,
})
);
export const buildVideos = ({ rawVideos }) => {
let videos = [];
const rawVideoList = Object.values(rawVideos);
@@ -191,4 +215,6 @@ export const videoProps = ({ videos }) => {
export default {
videoProps,
buildVideos,
handleCancel,
handleVideoUpload,
};

View File

@@ -1,7 +1,11 @@
import * as reactRedux from 'react-redux';
import * as hooks from './hooks';
import { filterKeys, sortKeys } from './utils';
import { MockUseState } from '../../../testUtils';
import { keyStore } from '../../utils';
import * as appHooks from '../../hooks';
import { selectors } from '../../data/redux';
import analyticsEvt from '../../data/constants/analyticsEvt';
jest.mock('react', () => ({
...jest.requireActual('react'),
@@ -16,9 +20,25 @@ jest.mock('react-redux', () => {
...jest.requireActual('react-redux'),
dispatch: dispatchFn,
useDispatch: jest.fn(() => dispatchFn),
useSelector: jest.fn(),
};
});
jest.mock('../../data/redux', () => ({
selectors: {
app: {
returnUrl: 'returnUrl',
analytics: 'analytics',
},
},
}));
jest.mock('../../hooks', () => ({
...jest.requireActual('../../hooks'),
navigateCallback: jest.fn((args) => ({ navigateCallback: args })),
navigateTo: jest.fn((args) => ({ navigateTo: args })),
}));
const state = new MockUseState(hooks);
const hookKeys = keyStore(hooks);
let hook;
@@ -175,6 +195,23 @@ describe('VideoGallery hooks', () => {
beforeEach(() => {
load();
});
describe('selectBtnProps', () => {
test('on click, if sets selection', () => {
const highlighted = 'videoId';
state.mockVal(state.keys.highlighted, highlighted);
load();
expect(appHooks.navigateTo).not.toHaveBeenCalled();
hook.selectBtnProps.onClick();
expect(appHooks.navigateTo).toHaveBeenCalled();
});
test('on click, sets showSelectVideoError to true if nothing is highlighted', () => {
state.mockVal(state.keys.highlighted, null);
load();
hook.selectBtnProps.onClick();
expect(appHooks.navigateTo).not.toHaveBeenCalled();
expect(state.setState.showSelectVideoError).toHaveBeenCalledWith(true);
});
});
describe('galleryProps', () => {
it('returns highlighted value, initialized to null', () => {
expect(hook.galleryProps.highlighted).toEqual(state.stateVals.highlighted);
@@ -210,6 +247,17 @@ describe('VideoGallery hooks', () => {
});
});
});
describe('fileInputHooks', () => {
test('click calls current.click on the ref', () => {
jest.spyOn(hooks, hookKeys.handleVideoUpload).mockImplementationOnce();
expect(hooks.handleVideoUpload).not.toHaveBeenCalled();
hook = hooks.fileInputProps();
expect(hooks.handleVideoUpload).toHaveBeenCalled();
expect(appHooks.navigateTo).not.toHaveBeenCalled();
hook.click();
expect(appHooks.navigateTo).toHaveBeenCalled();
});
});
describe('videoProps', () => {
const videoList = {
galleryProps: 'some gallery props',
@@ -250,4 +298,15 @@ describe('VideoGallery hooks', () => {
expect(hook.selectBtnProps).toEqual(videoList.selectBtnProps);
});
});
describe('handleCancel', () => {
it('calls navigateCallback', () => {
expect(hooks.handleCancel()).toEqual(
appHooks.navigateCallback({
destination: reactRedux.useSelector(selectors.app.returnUrl),
analyticsEvent: analyticsEvt.videoGalleryCancelClick,
analytics: reactRedux.useSelector(selectors.app.analytics),
}),
);
});
});
});

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { selectors } from '../../data/redux';
@@ -16,6 +16,14 @@ export const VideoGallery = ({
isUploadError,
}) => {
const videos = hooks.buildVideos({ rawVideos });
const handleVideoUpload = hooks.handleVideoUpload();
useEffect(() => {
// If no videos exists redirects to the video upload screen
if (isLoaded && videos.length === 0) {
handleVideoUpload();
}
}, [isLoaded]);
const {
galleryError,
inputError,
@@ -24,6 +32,7 @@ export const VideoGallery = ({
searchSortProps,
selectBtnProps,
} = hooks.videoProps({ videos });
const handleCancel = hooks.handleCancel();
const modalMessages = {
confirmMsg: messages.selectVideoButtonlabel,
@@ -38,7 +47,7 @@ export const VideoGallery = ({
<SelectionModal
{...{
isOpen: true,
close: () => { /* TODO */ },
close: handleCancel,
size: 'fullscreen',
isFullscreenScroll: false,
galleryError,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { shallow } from 'enzyme';
import { shallow, mount } from 'enzyme';
import SelectionModal from '../../sharedComponents/SelectionModal';
import hooks from './hooks';
@@ -7,6 +7,8 @@ import * as module from '.';
jest.mock('../../sharedComponents/SelectionModal', () => 'SelectionModal');
const mockHandleVideoUploadHook = jest.fn();
jest.mock('./hooks', () => ({
buildVideos: jest.fn(() => []),
videoProps: jest.fn(() => ({
@@ -39,6 +41,8 @@ jest.mock('./hooks', () => ({
searchSortProps: { search: 'sortProps' },
selectBtnProps: { select: 'btnProps' },
})),
handleCancel: jest.fn(),
handleVideoUpload: () => mockHandleVideoUploadHook,
}));
jest.mock('../../data/redux', () => ({
@@ -51,6 +55,11 @@ jest.mock('../../data/redux', () => ({
},
}));
jest.mock('../../hooks', () => ({
...jest.requireActual('../../hooks'),
navigateCallback: jest.fn((args) => ({ navigateCallback: args })),
}));
describe('VideoGallery', () => {
describe('component', () => {
const props = {
@@ -63,6 +72,7 @@ describe('VideoGallery', () => {
const videoProps = hooks.videoProps();
beforeEach(() => {
el = shallow(<module.VideoGallery {...props} />);
mockHandleVideoUploadHook.mockReset();
});
it('provides confirm action, forwarding selectBtnProps from imgHooks', () => {
expect(el.find(SelectionModal).props().selectBtnProps).toEqual(
@@ -83,5 +93,12 @@ describe('VideoGallery', () => {
it('provides a FileInput component with fileInput props from imgHooks', () => {
expect(el.find(SelectionModal).props().fileInput).toMatchObject(videoProps.fileInput);
});
it('handleVideoUpload called if there are no videos', () => {
el = mount(<module.VideoGallery {...props} />);
expect(mockHandleVideoUploadHook).not.toHaveBeenCalled();
el.setProps({ rawVideos: {}, isLoaded: true });
el.mount();
expect(mockHandleVideoUploadHook).toHaveBeenCalled();
});
});
});

View File

@@ -62,21 +62,26 @@ exports[`VideoUploadEditor renders without errors 1`] = `
/>
</div>
<div
class="d-flex video-id-prompt"
class="d-flex video-id-container"
>
<input
placeholder="Paste your video ID or URL"
type="text"
value=""
/>
<button
class="border-start-0"
type="button"
<div
class="d-flex video-id-prompt"
>
<icon
class="rounded-circle text-dark"
<input
placeholder="Paste your video ID or URL"
type="text"
value=""
/>
</button>
<button
class="border-start-0"
data-testid="inputSaveButton"
type="button"
>
<icon
class="rounded-circle text-dark"
/>
</button>
</div>
</div>
</div>
</div>
@@ -135,21 +140,26 @@ exports[`VideoUploader renders without errors 1`] = `
/>
</div>
<div
class="d-flex video-id-prompt"
class="d-flex video-id-container"
>
<input
placeholder="Paste your video ID or URL"
type="text"
value=""
/>
<button
class="border-start-0"
type="button"
<div
class="d-flex video-id-prompt"
>
<icon
class="rounded-circle text-dark"
<input
placeholder="Paste your video ID or URL"
type="text"
value=""
/>
</button>
<button
class="border-start-0"
data-testid="inputSaveButton"
type="button"
>
<icon
class="rounded-circle text-dark"
/>
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,12 @@
import * as requests from '../../data/redux/thunkActions/requests';
import * as module from './hooks';
import { selectors } from '../../data/redux';
import store from '../../data/store';
import * as appHooks from '../../hooks';
export const {
navigateTo,
} = appHooks;
export const uploadVideo = async ({ dispatch, supportedFiles }) => {
const data = { files: [] };
@@ -8,14 +16,23 @@ export const uploadVideo = async ({ dispatch, supportedFiles }) => {
content_type: file.type,
});
});
const onFileUploadedHook = module.onFileUploaded();
dispatch(await requests.uploadVideo({
data,
onSuccess: async (response) => {
const { files } = response.json();
const { files } = response.data;
await Promise.all(Object.values(files).map(async (fileObj) => {
const fileName = fileObj.file_name;
const edxVideoId = fileObj.edx_video_id;
const uploadUrl = fileObj.upload_url;
const uploadFile = supportedFiles.find((file) => file.name === fileName);
// TODO I added this temporally to test the redirecton without
// make the post to the upload URL. I added this also after the success post
// To test this I overwriten my own response with an existing edx_video_id on
// the edx-platform view: https://github.com/openedx/edx-platform/blob/master/cms/djangoapps/contentstore/views/videos.py#L224
onFileUploadedHook(edxVideoId);
if (!uploadFile) {
console.error(`Could not find file object with name "${fileName}" in supportedFiles array.`);
return;
@@ -29,14 +46,27 @@ export const uploadVideo = async ({ dispatch, supportedFiles }) => {
'Content-Type': 'multipart/form-data',
},
})
.then((resp) => resp.json())
.then((responseData) => console.log('File uploaded:', responseData))
.then(() => onFileUploadedHook(edxVideoId))
.catch((error) => console.error('Error uploading file:', error));
}));
},
}));
};
export const onFileUploaded = () => {
const state = store.getState();
const learningContextId = selectors.app.learningContextId(state);
const blockId = selectors.app.blockId(state);
return (edxVideoId) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${edxVideoId}`);
};
export const onUrlUploaded = () => {
const state = store.getState();
const learningContextId = selectors.app.learningContextId(state);
const blockId = selectors.app.blockId(state);
return (videoUrl) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoUrl=${videoUrl}`);
};
export default {
uploadVideo,
};

View File

@@ -32,7 +32,7 @@ describe('uploadVideo', () => {
it('should call fetch with correct arguments for each file', async () => {
const mockResponseData = { success: true };
const mockFetchResponse = Promise.resolve({ json: () => Promise.resolve(mockResponseData) });
const mockFetchResponse = Promise.resolve({ data: mockResponseData });
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const response = {
files: [
@@ -40,8 +40,7 @@ describe('uploadVideo', () => {
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
],
};
const spyConsoleLog = jest.spyOn(console, 'log');
const mockRequestResponse = { json: () => response };
const mockRequestResponse = { data: response };
requests.uploadVideo.mockImplementation(async ({ onSuccess }) => {
await onSuccess(mockRequestResponse);
});
@@ -49,8 +48,6 @@ describe('uploadVideo', () => {
await hooks.uploadVideo({ dispatch, supportedFiles });
expect(fetch).toHaveBeenCalledTimes(2);
expect(spyConsoleLog).toHaveBeenCalledTimes(2);
expect(spyConsoleLog).toHaveBeenCalledWith('File uploaded:', mockResponseData);
response.files.forEach(({ upload_url: uploadUrl }, index) => {
expect(fetch.mock.calls[index][0]).toEqual(uploadUrl);
});
@@ -68,16 +65,12 @@ describe('uploadVideo', () => {
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
],
};
const spyConsoleError = jest.spyOn(console, 'error');
const mockRequestResponse = { json: () => response };
const mockRequestResponse = { data: response };
requests.uploadVideo.mockImplementation(async ({ onSuccess }) => {
await onSuccess(mockRequestResponse);
});
await hooks.uploadVideo({ dispatch, supportedFiles });
expect(spyConsoleError).toHaveBeenCalledTimes(2);
expect(spyConsoleError).toHaveBeenCalledWith('Error uploading file:', error);
});
it('should log an error if file object is not found in supportedFiles array', () => {
@@ -86,7 +79,7 @@ describe('uploadVideo', () => {
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
],
};
const mockRequestResponse = { json: () => response };
const mockRequestResponse = { data: response };
const spyConsoleError = jest.spyOn(console, 'error');
requests.uploadVideo.mockImplementation(({ onSuccess }) => {
onSuccess(mockRequestResponse);

View File

@@ -13,6 +13,7 @@ import * as editorHooks from '../EditorContainer/hooks';
export const VideoUploader = ({ onUpload, errorMessage }) => {
const [, setUploadedFile] = useState();
const [textInputValue, setTextInputValue] = useState('');
const onUrlUpdatedHook = hooks.onUrlUploaded();
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: 'video/*',
@@ -31,8 +32,7 @@ export const VideoUploader = ({ onUpload, errorMessage }) => {
};
const handleSaveButtonClick = () => {
// do something with the textInputValue, e.g. save to state or send to server
console.log(`Saving input value: ${textInputValue}`);
onUrlUpdatedHook(textInputValue);
};
if (errorMessage) {
@@ -60,18 +60,20 @@ export const VideoUploader = ({ onUpload, errorMessage }) => {
</div>
<input {...getInputProps()} data-testid="fileInput" />
</div>
<div className="d-flex video-id-prompt">
<input
type="text"
placeholder="Paste your video ID or URL"
value={textInputValue}
onChange={handleInputChange}
onKeyDown={(e) => e.key === 'Enter' && handleSaveButtonClick()}
onClick={(event) => event.preventDefault()}
/>
<button className="border-start-0" type="button" onClick={handleSaveButtonClick}>
<Icon src={ArrowForward} className="rounded-circle text-dark" />
</button>
<div className="d-flex video-id-container">
<div className="d-flex video-id-prompt">
<input
type="text"
placeholder="Paste your video ID or URL"
value={textInputValue}
onChange={handleInputChange}
onKeyDown={(e) => e.key === 'Enter' && handleSaveButtonClick()}
onClick={(event) => event.preventDefault()}
/>
<button className="border-start-0" type="button" onClick={handleSaveButtonClick} data-testid="inputSaveButton">
<Icon src={ArrowForward} className="rounded-circle text-dark" />
</button>
</div>
</div>
</div>
);

View File

@@ -11,6 +11,11 @@
}
}
.video-id-container {
width: 100%;
justify-content: center;
}
.video-id-prompt {
position: absolute;
top: 68%;

View File

@@ -4,6 +4,7 @@ import { render, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import VideoUploadEditor, { VideoUploader } from '.';
import * as hooks from './hooks';
import * as appHooks from '../../hooks';
const mockDispatch = jest.fn();
const mockOnUpload = jest.fn();
@@ -11,6 +12,10 @@ const mockOnUpload = jest.fn();
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
}));
jest.mock('../../hooks', () => ({
...jest.requireActual('../../hooks'),
navigateTo: jest.fn((args) => ({ navigateTo: args })),
}));
const defaultEditorProps = {
intl: {},
@@ -52,6 +57,16 @@ describe('VideoUploadEditor', () => {
expect(input.value).toBe('test value');
});
it('click on the save button', () => {
const { getByPlaceholderText, getByTestId } = renderEditorComponent();
const testValue = 'test vale';
const input = getByPlaceholderText('Paste your video ID or URL');
fireEvent.change(input, { target: { value: testValue } });
const button = getByTestId('inputSaveButton');
fireEvent.click(button);
expect(appHooks.navigateTo).toHaveBeenCalled();
});
it('shows error message with unsupported files', async () => {
const { getByTestId, findByText } = renderEditorComponent();
const fileInput = getByTestId('fileInput');

View File

@@ -1,6 +1,7 @@
export const analyticsEvt = {
editorSaveClick: 'edx.ui.authoring.editor.save',
editorCancelClick: 'edx.ui.authoring.editor.cancel',
videoGalleryCancelClick: 'edx.ui.authoring.videogallery.cancel',
};
export default analyticsEvt;

View File

@@ -6,11 +6,22 @@ import * as module from './video';
import { valueFromDuration } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks';
import { parseYoutubeId } from '../../services/cms/api';
export const loadVideoData = () => (dispatch, getState) => {
export const loadVideoData = (selectedVideoId, selectedVideoUrl) => (dispatch, getState) => {
const state = getState();
const blockValueData = state.app.blockValue.data;
const rawVideoData = blockValueData.metadata ? blockValueData.metadata : {};
let rawVideoData = blockValueData.metadata ? blockValueData.metadata : {};
const courseData = state.app.courseDetails.data ? state.app.courseDetails.data : {};
if (selectedVideoId != null) {
const rawVideos = Object.values(selectors.app.videos(state));
const selectedVideo = rawVideos.find(video => video.edx_video_id === selectedVideoId);
rawVideoData = {
edx_video_id: selectedVideo.edx_video_id,
thumbnail: selectedVideo.course_video_image_url,
duration: selectedVideo.duration,
transcriptsFromSelected: selectedVideo.transcripts,
selectedVideoTranscriptUrls: selectedVideo.transcript_urls,
};
}
const studioView = state.app.studioView?.data?.html;
const {
videoId,
@@ -21,8 +32,13 @@ export const loadVideoData = () => (dispatch, getState) => {
youtubeId: rawVideoData.youtube_id_1_0,
html5Sources: rawVideoData.html5_sources,
});
// Use the selected video url first
const videoSourceUrl = selectedVideoUrl != null ? selectedVideoUrl : videoUrl;
const [licenseType, licenseOptions] = module.parseLicense({ licenseData: studioView, level: 'block' });
const transcripts = module.parseTranscripts({ transcriptsData: studioView });
const transcripts = rawVideoData.transcriptsFromSelected ? rawVideoData.transcriptsFromSelected
: module.parseTranscripts({ transcriptsData: studioView });
const [courseLicenseType, courseLicenseDetails] = module.parseLicense({
licenseData: courseData.license,
level: 'course',
@@ -32,7 +48,7 @@ export const loadVideoData = () => (dispatch, getState) => {
blockSetting: rawVideoData.public_access,
});
dispatch(actions.video.load({
videoSource: videoUrl || '',
videoSource: videoSourceUrl || '',
videoId,
fallbackVideos,
allowVideoDownloads: rawVideoData.download_video,
@@ -40,12 +56,13 @@ export const loadVideoData = () => (dispatch, getState) => {
videoSharingLearnMoreLink: blockValueData?.video_sharing_doc_url,
videoSharingEnabledForCourse: blockValueData?.video_sharing_enabled,
transcripts,
selectedVideoTranscriptUrls: rawVideoData.selectedVideoTranscriptUrls,
allowTranscriptDownloads: rawVideoData.download_track,
showTranscriptByDefault: rawVideoData.show_captions,
duration: { // TODO duration is not always sent so they should be calculated.
startTime: valueFromDuration(rawVideoData.start_time || '00:00:00'),
stopTime: valueFromDuration(rawVideoData.end_time || '00:00:00'),
total: 0, // TODO can we get total duration? if not, probably dropping from widget
total: rawVideoData.duration || 0, // TODO can we get total duration? if not, probably dropping from widget
},
handout: rawVideoData.handout,
licenseType,
@@ -70,7 +87,7 @@ export const loadVideoData = () => (dispatch, getState) => {
videoSharingEnabledForAll: response.data.videoSharingEnabled,
})),
}));
const youTubeId = parseYoutubeId(videoUrl);
const youTubeId = parseYoutubeId(videoSourceUrl);
if (youTubeId) {
dispatch(requests.checkTranscriptsForImport({
videoId,

View File

@@ -12,6 +12,7 @@ jest.mock('..', () => ({
selectors: {
app: {
courseDetails: (state) => ({ courseDetails: state }),
videos: (state) => ({ videos: state.app.videos }),
},
video: {
videoId: (state) => ({ videoId: state }),
@@ -34,6 +35,7 @@ jest.mock('./requests', () => ({
}));
jest.mock('../../../utils', () => ({
...jest.requireActual('../../../utils'),
removeItemOnce: (args) => (args),
}));
@@ -56,6 +58,8 @@ const mockVideoFeatures = {
videoSharingEnabled: 'soMEbOolEAn',
},
};
const mockSelectedVideoId = 'ThisIsAVideoId';
const mockSelectedVideoUrl = 'ThisIsAYoutubeUrl';
const testMetadata = {
download_track: 'dOWNlOAdTraCK',
@@ -80,6 +84,13 @@ const testState = {
originalThumbnail: null,
videoId: 'soMEvIDEo',
};
const testVideosState = {
edx_video_id: mockSelectedVideoId,
thumbnail: 'thumbnail',
duration: 60,
transcripts: ['es'],
transcript_urls: { es: 'url' },
};
const testUpload = { transcripts: ['la', 'en'] };
const testReplaceUpload = {
file: mockFile,
@@ -130,25 +141,37 @@ describe('video thunkActions', () => {
jest.spyOn(thunkActions, thunkActionsKeys.parseTranscripts).mockReturnValue(
testMetadata.transcripts,
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('dispatches fetchVideoFeatures action', () => {
thunkActions.loadVideoData()(dispatch, getState);
[
[dispatchedLoad],
[dispatchedAction1],
[dispatchedAction2],
] = dispatch.mock.calls;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('dispatches fetchVideoFeatures action', () => {
expect(dispatchedLoad).not.toEqual(undefined);
expect(dispatchedAction1.fetchVideoFeatures).not.toEqual(undefined);
});
it('dispatches checkTranscriptsForImport action', () => {
thunkActions.loadVideoData()(dispatch, getState);
[
[dispatchedLoad],
[dispatchedAction1],
[dispatchedAction2],
] = dispatch.mock.calls;
expect(dispatchedLoad).not.toEqual(undefined);
expect(dispatchedAction2.checkTranscriptsForImport).not.toEqual(undefined);
});
it('dispatches actions.video.load', () => {
thunkActions.loadVideoData()(dispatch, getState);
[
[dispatchedLoad],
[dispatchedAction1],
[dispatchedAction2],
] = dispatch.mock.calls;
expect(dispatchedLoad.load).toEqual({
videoSource: 'videOsOurce',
videoId: 'videOiD',
@@ -186,7 +209,113 @@ describe('video thunkActions', () => {
thumbnail: testMetadata.thumbnail,
});
});
it('dispatches actions.video.load with selectedVideoId', () => {
getState = jest.fn(() => ({
app: {
blockId: 'soMEBloCk',
studioEndpointUrl: 'soMEeNDPoiNT',
blockValue: { data: { metadata: {} } },
courseDetails: { data: { license: null } },
studioView: { data: { html: 'sOMeHTml' } },
videos: testVideosState,
},
}));
thunkActions.loadVideoData(mockSelectedVideoId, null)(dispatch, getState);
[
[dispatchedLoad],
[dispatchedAction1],
[dispatchedAction2],
] = dispatch.mock.calls;
expect(dispatchedLoad.load).toEqual({
videoSource: 'videOsOurce',
videoId: 'videOiD',
fallbackVideos: 'fALLbACKvIDeos',
allowVideoDownloads: undefined,
transcripts: testVideosState.transcripts,
selectedVideoTranscriptUrls: testVideosState.transcript_urls,
allowTranscriptDownloads: undefined,
allowVideoSharing: {
level: 'course',
value: true,
},
showTranscriptByDefault: undefined,
duration: {
startTime: testMetadata.start_time,
stopTime: 0,
total: testVideosState.duration,
},
handout: undefined,
licenseType: 'liCENSEtyPe',
licenseDetails: {
attribution: true,
noncommercial: true,
noDerivatives: true,
shareAlike: false,
},
videoSharingEnabledForCourse: undefined,
videoSharingLearnMoreLink: undefined,
courseLicenseType: 'liCENSEtyPe',
courseLicenseDetails: {
attribution: true,
noncommercial: true,
noDerivatives: true,
shareAlike: false,
},
thumbnail: undefined,
});
});
it('dispatches actions.video.load with selectedVideoUrl', () => {
thunkActions.loadVideoData(null, mockSelectedVideoUrl)(dispatch, getState);
[
[dispatchedLoad],
[dispatchedAction1],
[dispatchedAction2],
] = dispatch.mock.calls;
expect(dispatchedLoad.load).toEqual({
videoSource: mockSelectedVideoUrl,
videoId: 'videOiD',
fallbackVideos: 'fALLbACKvIDeos',
allowVideoDownloads: testMetadata.download_video,
transcripts: testMetadata.transcripts,
allowTranscriptDownloads: testMetadata.download_track,
showTranscriptByDefault: testMetadata.show_captions,
duration: {
startTime: testMetadata.start_time,
stopTime: testMetadata.end_time,
total: 0,
},
allowVideoSharing: {
level: 'course',
value: true,
},
handout: testMetadata.handout,
licenseType: 'liCENSEtyPe',
licenseDetails: {
attribution: true,
noncommercial: true,
noDerivatives: true,
shareAlike: false,
},
selectedVideoTranscriptUrls: undefined,
videoSharingEnabledForCourse: undefined,
videoSharingLearnMoreLink: 'SomEUrL.Com',
courseLicenseType: 'liCENSEtyPe',
courseLicenseDetails: {
attribution: true,
noncommercial: true,
noDerivatives: true,
shareAlike: false,
},
thumbnail: testMetadata.thumbnail,
});
});
it('dispatches actions.video.updateField on success', () => {
thunkActions.loadVideoData()(dispatch, getState);
[
[dispatchedLoad],
[dispatchedAction1],
[dispatchedAction2],
] = dispatch.mock.calls;
dispatch.mockClear();
dispatchedAction1.fetchVideoFeatures.onSuccess(mockVideoFeatures);
expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({

View File

@@ -19,6 +19,7 @@ const initialState = {
videoSharingLearnMoreLink: '',
thumbnail: null,
transcripts: [],
selectedVideoTranscriptUrls: {},
allowTranscriptDownloads: false,
duration: {
startTime: '00:00:00',

View File

@@ -6,7 +6,7 @@ import { videoTranscriptLanguages } from '../../constants/video';
import { initialState } from './reducer';
import * as module from './selectors';
import * as AppSelectors from '../app/selectors';
import { downloadVideoTranscriptURL, downloadVideoHandoutUrl } from '../../services/cms/urls';
import { downloadVideoTranscriptURL, downloadVideoHandoutUrl, mediaTranscriptURL } from '../../services/cms/urls';
const stateKeys = keyStore(initialState);
@@ -23,6 +23,7 @@ export const simpleSelectors = [
stateKeys.allowVideoSharing,
stateKeys.thumbnail,
stateKeys.transcripts,
stateKeys.selectedVideoTranscriptUrls,
stateKeys.allowTranscriptDownloads,
stateKeys.duration,
stateKeys.showTranscriptByDefault,
@@ -57,6 +58,14 @@ export const getTranscriptDownloadUrl = createSelector(
}),
);
export const buildTranscriptUrl = createSelector(
[AppSelectors.simpleSelectors.studioEndpointUrl],
(studioEndpointUrl) => ({ transcriptUrl }) => mediaTranscriptURL({
studioEndpointUrl,
transcriptUrl,
}),
);
export const getHandoutDownloadUrl = createSelector(
[AppSelectors.simpleSelectors.studioEndpointUrl],
(studioEndpointUrl) => ({ handout }) => downloadVideoHandoutUrl({
@@ -74,6 +83,7 @@ export const videoSettings = createSelector(
module.simpleSelectors.allowVideoSharing,
module.simpleSelectors.thumbnail,
module.simpleSelectors.transcripts,
module.simpleSelectors.selectedVideoTranscriptUrls,
module.simpleSelectors.allowTranscriptDownloads,
module.simpleSelectors.duration,
module.simpleSelectors.showTranscriptByDefault,
@@ -89,6 +99,7 @@ export const videoSettings = createSelector(
allowVideoSharing,
thumbnail,
transcripts,
selectedVideoTranscriptUrls,
allowTranscriptDownloads,
duration,
showTranscriptByDefault,
@@ -104,6 +115,7 @@ export const videoSettings = createSelector(
allowVideoSharing,
thumbnail,
transcripts,
selectedVideoTranscriptUrls,
allowTranscriptDownloads,
duration,
showTranscriptByDefault,
@@ -118,6 +130,7 @@ export default {
...simpleSelectors,
openLanguages,
getTranscriptDownloadUrl,
buildTranscriptUrl,
getHandoutDownloadUrl,
videoSettings,
};

View File

@@ -26,8 +26,7 @@ jest.mock('./urls', () => ({
courseAdvanceSettings: jest.fn().mockName('urls.courseAdvanceSettings'),
replaceTranscript: jest.fn().mockName('urls.replaceTranscript'),
videoFeatures: jest.fn().mockName('urls.videoFeatures'),
courseVideos: jest.fn().mockName('urls.courseVideos'),
videoUpload: jest.fn()
courseVideos: jest.fn()
.mockName('urls.courseVideos')
.mockImplementation(
({ studioEndpointUrl, learningContextId }) => `${studioEndpointUrl}/some_video_upload_url/${learningContextId}`,

View File

@@ -43,6 +43,10 @@ export const downloadVideoTranscriptURL = ({ studioEndpointUrl, blockId, languag
`${videoTranscripts({ studioEndpointUrl, blockId })}?language_code=${language}`
);
export const mediaTranscriptURL = ({ studioEndpointUrl, transcriptUrl }) => (
`${studioEndpointUrl}${transcriptUrl}`
);
export const downloadVideoHandoutUrl = ({ studioEndpointUrl, handout }) => (
`${studioEndpointUrl}${handout}`
);

View File

@@ -14,6 +14,7 @@ import {
checkTranscriptsForImport,
replaceTranscript,
courseAdvanceSettings,
mediaTranscriptURL,
videoFeatures,
courseVideos,
} from './urls';
@@ -145,4 +146,11 @@ describe('cms url methods', () => {
.toEqual(`${studioEndpointUrl}/videos/${learningContextId}`);
});
});
describe('mediaTranscriptURL', () => {
it('returns url with studioEndpointUrl', () => {
const transcriptUrl = 'this-is-a-transcript';
expect(mediaTranscriptURL({ studioEndpointUrl, transcriptUrl }))
.toEqual(`${studioEndpointUrl}${transcriptUrl}`);
});
});
});

View File

@@ -13,7 +13,8 @@ exports[`BaseModal ImageUploadModal template component snapshot 1`] = `
<ModalDialog.Header
style={
Object {
"zIndex": 10000,
"boxShadow": "2px 2px 5px rgba(0, 0, 0, 0.3)",
"zIndex": 1,
}
}
>

View File

@@ -30,7 +30,7 @@ export const BaseModal = ({
isFullscreenOnMobile
isFullscreenScroll={isFullscreenScroll}
>
<ModalDialog.Header style={{ zIndex: 10000 }}>
<ModalDialog.Header style={{ zIndex: 1, boxShadow: '2px 2px 5px rgba(0, 0, 0, 0.3)' }}>
<ModalDialog.Title>
{title}
</ModalDialog.Title>

View File

@@ -50,20 +50,20 @@ export const Gallery = ({
}
if (galleryIsEmpty) {
return (
<div className="gallery p-4 bg-gray-100" style={{ height, margin: '0 -1.5rem' }}>
<div className="gallery p-4 bg-light-400" style={{ height, margin: '0 -1.5rem' }}>
<FormattedMessage {...emptyGalleryLabel} />
</div>
);
}
if (searchIsEmpty) {
return (
<div className="gallery p-4 bg-gray-100" style={{ height, margin: '0 -1.5rem' }}>
<div className="gallery p-4 bg-light-400" style={{ height, margin: '0 -1.5rem' }}>
<FormattedMessage {...messages.emptySearchLabel} />
</div>
);
}
return (
<Scrollable className="gallery bg-gray-100" style={{ height, margin: '0 -1.5rem' }}>
<Scrollable className="gallery bg-light-400" style={{ height, margin: '0 -1.5rem' }}>
<div className="p-4">
<SelectableBox.Set
columns={1}

View File

@@ -15,17 +15,27 @@ import LanguageNamesWidget from '../../containers/VideoEditor/components/VideoSe
export const GalleryCard = ({
asset,
}) => (
<SelectableBox className="card bg-white" key={asset.externalUrl} type="radio" value={asset.id} style={{ padding: '10px 20px' }}>
<SelectableBox
className="card bg-white"
key={asset.externalUrl}
type="radio"
value={asset.id}
style={{
padding: '10px 20px',
border: 'none',
boxShadow: 'none',
}}
>
<div className="card-div d-flex flex-row flex-nowrap">
<div style={{
position: 'relative',
width: '200px',
height: '100px',
margin: '16px 0 0 0',
margin: '18px 0 0 0',
}}
>
<Image
style={{ width: '200px', height: '100px' }}
style={{ border: 'none', width: '200px', height: '100px' }}
src={asset.externalUrl}
/>
{ asset.status && asset.statusBadgeVariant && (
@@ -34,13 +44,21 @@ export const GalleryCard = ({
</Badge>
)}
{ asset.duration >= 0 && (
<Badge variant="dark" style={{ position: 'absolute', right: '6px', bottom: '6px' }}>
<Badge
variant="dark"
style={{
position: 'absolute',
right: '6px',
bottom: '6px',
backgroundColor: 'black',
}}
>
{formatDuration(asset.duration)}
</Badge>
)}
</div>
<div className="card-text p-3">
<h3>{asset.displayName}</h3>
<div className="card-text p-3" style={{ marginTop: '10px' }}>
<h3 className="text-primary-500">{asset.displayName}</h3>
{ asset.transcripts && (
<div style={{ margin: '0 0 5px 0' }}>
<LanguageNamesWidget
@@ -48,7 +66,7 @@ export const GalleryCard = ({
/>
</div>
)}
<p style={{ fontSize: '11px' }}>
<p className="text-gray-500" style={{ fontSize: '11px' }}>
<FormattedMessage
{...messages.addedDate}
values={{

View File

@@ -5,19 +5,31 @@ import { Image } from '@edx/paragon';
import { GalleryCard } from './GalleryCard';
describe('GalleryCard component', () => {
const img = {
const asset = {
externalUrl: 'props.img.externalUrl',
displayName: 'props.img.displayName',
dateAdded: 12345,
};
let el;
beforeEach(() => {
el = shallow(<GalleryCard asset={img} />);
el = shallow(<GalleryCard asset={asset} />);
});
test(`snapshot: dateAdded=${img.dateAdded}`, () => {
test(`snapshot: dateAdded=${asset.dateAdded}`, () => {
expect(el).toMatchSnapshot();
});
it('loads Image with src from image external url', () => {
expect(el.find(Image).props().src).toEqual(img.externalUrl);
expect(el.find(Image).props().src).toEqual(asset.externalUrl);
});
it('snapshot with status badge', () => {
el = shallow(<GalleryCard asset={{ ...asset, status: 'failed', statusBadgeVariant: 'danger' }} />);
expect(el).toMatchSnapshot();
});
it('snapshot with duration badge', () => {
el = shallow(<GalleryCard asset={{ ...asset, duration: 60 }} />);
expect(el).toMatchSnapshot();
});
it('snapshot with duration transcripts', () => {
el = shallow(<GalleryCard asset={{ ...asset, transcripts: ['es'] }} />);
expect(el).toMatchSnapshot();
});
});

View File

@@ -57,7 +57,7 @@ export const SearchSort = ({
{ !showSwitch && <ActionRow.Spacer /> }
<Dropdown>
<Dropdown.Toggle id="gallery-sort-button" variant="tertiary">
<Dropdown.Toggle className="text-gray-700" id="gallery-sort-button" variant="tertiary">
<FormattedMessage {...sortMessages[sortBy]} />
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -71,7 +71,7 @@ export const SearchSort = ({
{ filterKeys && filterMessages && (
<Dropdown>
<Dropdown.Toggle id="gallery-filter-button" variant="tertiary">
<Dropdown.Toggle className="text-gray-700" id="gallery-filter-button" variant="tertiary">
<FormattedMessage {...filterMessages[filterBy]} />
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -92,7 +92,7 @@ export const SearchSort = ({
onChange={onSwitchClick}
isInline
>
<Form.Switch value="switch-value" floatLabelLeft>
<Form.Switch className="text-gray-700" value="switch-value" floatLabelLeft>
<FormattedMessage {...switchMessage} />
</Form.Switch>
</Form.SwitchSet>

View File

@@ -2,7 +2,7 @@
exports[`TextEditor Image Gallery component component snapshot: loaded but no images, show empty gallery 1`] = `
<div
className="gallery p-4 bg-gray-100"
className="gallery p-4 bg-light-400"
style={
Object {
"height": "375px",
@@ -16,7 +16,7 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but no im
exports[`TextEditor Image Gallery component component snapshot: loaded but search returns no images, show 0 search result gallery 1`] = `
<div
className="gallery p-4 bg-gray-100"
className="gallery p-4 bg-light-400"
style={
Object {
"height": "375px",
@@ -34,7 +34,7 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but searc
exports[`TextEditor Image Gallery component component snapshot: loaded, show gallery 1`] = `
<Scrollable
className="gallery bg-gray-100"
className="gallery bg-light-400"
style={
Object {
"height": "375px",

View File

@@ -1,11 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
exports[`GalleryCard component snapshot with duration badge 1`] = `
<SelectableBox
className="card bg-white"
key="props.img.externalUrl"
style={
Object {
"border": "none",
"boxShadow": "none",
"padding": "10px 20px",
}
}
@@ -18,7 +20,7 @@ exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
style={
Object {
"height": "100px",
"margin": "16px 0 0 0",
"margin": "18px 0 0 0",
"position": "relative",
"width": "200px",
}
@@ -28,19 +30,305 @@ exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
src="props.img.externalUrl"
style={
Object {
"border": "none",
"height": "100px",
"width": "200px",
}
}
/>
<Component
style={
Object {
"backgroundColor": "black",
"bottom": "6px",
"position": "absolute",
"right": "6px",
}
}
variant="dark"
>
01:00
</Component>
</div>
<div
className="card-text p-3"
style={
Object {
"marginTop": "10px",
}
}
>
<h3>
<h3
className="text-primary-500"
>
props.img.displayName
</h3>
<p
className="text-gray-500"
style={
Object {
"fontSize": "11px",
}
}
>
<FormattedMessage
defaultMessage="Added {date} at {time}"
description="File date-added string"
id="authoring.selectionmodal.addedDate.label"
values={
Object {
"date": <FormattedDate
value={12345}
/>,
"time": <FormattedTime
value={12345}
/>,
}
}
/>
</p>
</div>
</div>
</SelectableBox>
`;
exports[`GalleryCard component snapshot with duration transcripts 1`] = `
<SelectableBox
className="card bg-white"
key="props.img.externalUrl"
style={
Object {
"border": "none",
"boxShadow": "none",
"padding": "10px 20px",
}
}
type="radio"
>
<div
className="card-div d-flex flex-row flex-nowrap"
>
<div
style={
Object {
"height": "100px",
"margin": "18px 0 0 0",
"position": "relative",
"width": "200px",
}
}
>
<Image
src="props.img.externalUrl"
style={
Object {
"border": "none",
"height": "100px",
"width": "200px",
}
}
/>
</div>
<div
className="card-text p-3"
style={
Object {
"marginTop": "10px",
}
}
>
<h3
className="text-primary-500"
>
props.img.displayName
</h3>
<div
style={
Object {
"margin": "0 0 5px 0",
}
}
>
<injectIntl(ShimmedIntlComponent)
transcripts={
Array [
"es",
]
}
/>
</div>
<p
className="text-gray-500"
style={
Object {
"fontSize": "11px",
}
}
>
<FormattedMessage
defaultMessage="Added {date} at {time}"
description="File date-added string"
id="authoring.selectionmodal.addedDate.label"
values={
Object {
"date": <FormattedDate
value={12345}
/>,
"time": <FormattedTime
value={12345}
/>,
}
}
/>
</p>
</div>
</div>
</SelectableBox>
`;
exports[`GalleryCard component snapshot with status badge 1`] = `
<SelectableBox
className="card bg-white"
key="props.img.externalUrl"
style={
Object {
"border": "none",
"boxShadow": "none",
"padding": "10px 20px",
}
}
type="radio"
>
<div
className="card-div d-flex flex-row flex-nowrap"
>
<div
style={
Object {
"height": "100px",
"margin": "18px 0 0 0",
"position": "relative",
"width": "200px",
}
}
>
<Image
src="props.img.externalUrl"
style={
Object {
"border": "none",
"height": "100px",
"width": "200px",
}
}
/>
<Component
style={
Object {
"left": "6px",
"position": "absolute",
"top": "6px",
}
}
variant="danger"
>
failed
</Component>
</div>
<div
className="card-text p-3"
style={
Object {
"marginTop": "10px",
}
}
>
<h3
className="text-primary-500"
>
props.img.displayName
</h3>
<p
className="text-gray-500"
style={
Object {
"fontSize": "11px",
}
}
>
<FormattedMessage
defaultMessage="Added {date} at {time}"
description="File date-added string"
id="authoring.selectionmodal.addedDate.label"
values={
Object {
"date": <FormattedDate
value={12345}
/>,
"time": <FormattedTime
value={12345}
/>,
}
}
/>
</p>
</div>
</div>
</SelectableBox>
`;
exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
<SelectableBox
className="card bg-white"
key="props.img.externalUrl"
style={
Object {
"border": "none",
"boxShadow": "none",
"padding": "10px 20px",
}
}
type="radio"
>
<div
className="card-div d-flex flex-row flex-nowrap"
>
<div
style={
Object {
"height": "100px",
"margin": "18px 0 0 0",
"position": "relative",
"width": "200px",
}
}
>
<Image
src="props.img.externalUrl"
style={
Object {
"border": "none",
"height": "100px",
"width": "200px",
}
}
/>
</div>
<div
className="card-text p-3"
style={
Object {
"marginTop": "10px",
}
}
>
<h3
className="text-primary-500"
>
props.img.displayName
</h3>
<p
className="text-gray-500"
style={
Object {
"fontSize": "11px",

View File

@@ -28,6 +28,7 @@ exports[`SearchSort component snapshots with filterKeys with search string (clos
</Form.Group>
<Dropdown>
<Dropdown.Toggle
className="text-gray-700"
id="gallery-sort-button"
variant="tertiary"
>
@@ -78,6 +79,7 @@ exports[`SearchSort component snapshots with filterKeys with search string (clos
</Dropdown>
<Dropdown>
<Dropdown.Toggle
className="text-gray-700"
id="gallery-filter-button"
variant="tertiary"
>
@@ -138,6 +140,7 @@ exports[`SearchSort component snapshots with filterKeys with search string (clos
onChange={null}
>
<Component
className="text-gray-700"
floatLabelLeft={true}
value="switch-value"
>
@@ -166,6 +169,7 @@ exports[`SearchSort component snapshots with filterKeys without search string (s
</Form.Group>
<Dropdown>
<Dropdown.Toggle
className="text-gray-700"
id="gallery-sort-button"
variant="tertiary"
>
@@ -216,6 +220,7 @@ exports[`SearchSort component snapshots with filterKeys without search string (s
</Dropdown>
<Dropdown>
<Dropdown.Toggle
className="text-gray-700"
id="gallery-filter-button"
variant="tertiary"
>
@@ -276,6 +281,7 @@ exports[`SearchSort component snapshots with filterKeys without search string (s
onChange={null}
>
<Component
className="text-gray-700"
floatLabelLeft={true}
value="switch-value"
>
@@ -314,6 +320,7 @@ exports[`SearchSort component snapshots without filterKeys with search string (c
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
className="text-gray-700"
id="gallery-sort-button"
variant="tertiary"
>
@@ -385,6 +392,7 @@ exports[`SearchSort component snapshots without filterKeys without search string
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
className="text-gray-700"
id="gallery-sort-button"
variant="tertiary"
>

View File

@@ -47,7 +47,7 @@ export const SelectionModal = ({
let background = '#FFFFFF';
let showGallery = true;
if (isLoaded && !isFetchError && !isUploadError && !inputError.show) {
background = '#EBEBEB';
background = '#E9E6E4';
} else if (isLoaded) {
showGallery = false;
}
@@ -69,14 +69,22 @@ export const SelectionModal = ({
size={size}
isFullscreenScroll={isFullscreenScroll}
footerAction={(
<Button iconBefore={Add} onClick={fileInput.click} variant="link">
<Button
className="text-primary-500"
iconBefore={Add}
onClick={fileInput.click}
variant="link"
style={{
textDecoration: 'none',
}}
>
<FormattedMessage {...uploadButtonMsg} />
</Button>
)}
title={intl.formatMessage(titleMsg)}
bodyStyle={{ background, padding: '9px 24px' }}
bodyStyle={{ background, padding: '3px 24px' }}
headerComponent={(
<div style={{ zIndex: 10000, margin: '18px 0' }}>
<div style={{ margin: '18px 0' }}>
<SearchSort {...searchSortProps} />
</div>
)}
@@ -132,7 +140,6 @@ SelectionModal.propTypes = {
}).isRequired,
fileInput: PropTypes.shape({
click: PropTypes.func.isRequired,
addFile: PropTypes.func.isRequired,
}).isRequired,
galleryProps: PropTypes.shape({}).isRequired,
searchSortProps: PropTypes.shape({}).isRequired,

View File

@@ -1,9 +1,9 @@
import TextEditor from './containers/TextEditor';
import VideoEditor from './containers/VideoEditor';
import ProblemEditor from './containers/ProblemEditor';
import VideoUploadEditor from './containers/VideoUploadEditor';
// ADDED_EDITOR_IMPORTS GO HERE
import VideoUploadEditor from './containers/VideoUploadEditor';
import { blockTypes } from './data/constants/app';
@@ -11,8 +11,8 @@ const supportedEditors = {
[blockTypes.html]: TextEditor,
[blockTypes.video]: VideoEditor,
[blockTypes.problem]: ProblemEditor,
// ADDED_EDITORS GO BELOW
[blockTypes.video_upload]: VideoUploadEditor,
// ADDED_EDITORS GO BELOW
};
export default supportedEditors;