From cc3a2d8b85206b8bfd1072e2554229cb45e51e2f Mon Sep 17 00:00:00 2001
From: Raymond Zhou <56318341+rayzhou-bit@users.noreply.github.com>
Date: Fri, 30 Sep 2022 11:00:06 -0400
Subject: [PATCH] feat: videosource to backend (#118)
* feat: videosource to backend
---
.../__snapshots__/index.test.jsx.snap | 17 ++
src/editors/containers/VideoEditor/index.jsx | 49 +++-
.../containers/VideoEditor/index.test.jsx | 56 +++++
src/editors/data/redux/video/selectors.js | 32 +++
src/editors/data/services/cms/api.js | 82 ++++++-
src/editors/data/services/cms/api.test.js | 210 +++++++++++++++++-
6 files changed, 437 insertions(+), 9 deletions(-)
create mode 100644 src/editors/containers/VideoEditor/__snapshots__/index.test.jsx.snap
create mode 100644 src/editors/containers/VideoEditor/index.test.jsx
diff --git a/src/editors/containers/VideoEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/__snapshots__/index.test.jsx.snap
new file mode 100644
index 000000000..bb656ac34
--- /dev/null
+++ b/src/editors/containers/VideoEditor/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`VideoEditor snapshots renders as expected with default behavior 1`] = `
+
+
+
+
+
+`;
diff --git a/src/editors/containers/VideoEditor/index.jsx b/src/editors/containers/VideoEditor/index.jsx
index a7f6208d2..ef1c8e99b 100644
--- a/src/editors/containers/VideoEditor/index.jsx
+++ b/src/editors/containers/VideoEditor/index.jsx
@@ -1,21 +1,26 @@
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 * as hooks from './hooks';
+import { errorsHook } from './hooks';
-export default function VideoEditor({
+export const VideoEditor = ({
onClose,
-}) {
+ // redux
+ videoSettings,
+}) => {
const {
error,
validateEntry,
- } = hooks.errorsHook();
+ } = errorsHook();
return (
({})}
+ getContent={() => videoSettings}
onClose={onClose}
validateEntry={validateEntry}
>
@@ -24,11 +29,43 @@ export default function 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 mapDispatchToProps = {};
+
+export default connect(mapStateToProps, mapDispatchToProps)(VideoEditor);
diff --git a/src/editors/containers/VideoEditor/index.test.jsx b/src/editors/containers/VideoEditor/index.test.jsx
new file mode 100644
index 000000000..fb7b8bce4
--- /dev/null
+++ b/src/editors/containers/VideoEditor/index.test.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { selectors } from '../../data/redux';
+import { errorsHook } from './hooks';
+import { VideoEditor, mapStateToProps, mapDispatchToProps } from '.';
+
+jest.mock('../EditorContainer', () => 'EditorContainer');
+jest.mock('./components/VideoEditorModal', () => 'VideoEditorModal');
+
+jest.mock('./hooks', () => ({
+ errorsHook: jest.fn(() => ({
+ error: 'hooks.errorsHook.error',
+ validateEntry: jest.fn().mockName('validateEntry'),
+ })),
+}));
+
+jest.mock('../../data/redux', () => ({
+ selectors: {
+ video: {
+ videoSettings: state => ({ videoSettings: { state } }),
+ },
+ },
+}));
+
+describe('VideoEditor', () => {
+ const props = {
+ onClose: jest.fn().mockName('props.onClose'),
+ // redux
+ videoSettings: 'vIdEOsETtings',
+ };
+ errorsHook.mockReturnValue({
+ error: 'errORsHooKErroR',
+ validateEntry: jest.fn().mockName('validateEntry'),
+ });
+ describe('snapshots', () => {
+ test('renders as expected with default behavior', () => {
+ expect(shallow()).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({});
+ });
+ });
+});
diff --git a/src/editors/data/redux/video/selectors.js b/src/editors/data/redux/video/selectors.js
index 097aa3635..2501ba706 100644
--- a/src/editors/data/redux/video/selectors.js
+++ b/src/editors/data/redux/video/selectors.js
@@ -46,8 +46,40 @@ export const getTranscriptDownloadUrl = createSelector(
}),
);
+export const videoSettings = createSelector(
+ Object.values(module.simpleSelectors),
+ (
+ videoSource,
+ fallbackVideos,
+ allowVideoDownloads,
+ thumbnail,
+ transcripts,
+ allowTranscriptDownloads,
+ duration,
+ showTranscriptByDefault,
+ handout,
+ licenseType,
+ licenseDetails,
+ ) => (
+ {
+ videoSource,
+ fallbackVideos,
+ allowVideoDownloads,
+ thumbnail,
+ transcripts,
+ allowTranscriptDownloads,
+ duration,
+ showTranscriptByDefault,
+ handout,
+ licenseType,
+ licenseDetails,
+ }
+ ),
+);
+
export default {
openLanguages,
getTranscriptDownloadUrl,
...simpleSelectors,
+ videoSettings,
};
diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js
index 11491137c..64a27ee28 100644
--- a/src/editors/data/services/cms/api.js
+++ b/src/editors/data/services/cms/api.js
@@ -68,13 +68,43 @@ export const apiMethods = {
if (blockType === 'html') {
return {
category: blockType,
- couseKey: learningContextId,
+ courseKey: learningContextId,
data: content,
has_changes: true,
id: blockId,
metadata: { display_name: title },
};
}
+ if (blockType === 'video') {
+ const {
+ html5Sources,
+ edxVideoId,
+ youtubeId,
+ } = module.processVideoIds({
+ videoSource: content.videoSource,
+ fallbackVideos: content.fallbackVideos,
+ });
+ return {
+ category: blockType,
+ courseKey: learningContextId,
+ display_name: title,
+ id: blockId,
+ metadata: {
+ display_name: title,
+ download_video: content.allowVideoDownloads,
+ edx_video_id: edxVideoId,
+ html5_sources: html5Sources,
+ youtube_id_1_0: youtubeId,
+ download_track: content.allowTranscriptDownloads,
+ track: '', // TODO Downloadable Transcript URL. Backend expects a file name, for example: "something.srt"
+ show_captions: content.showTranscriptByDefault,
+ handout: content.handout,
+ start_time: content.duration.startTime,
+ end_time: content.duration.stopTime,
+ license: module.processLicense(content.licenseType, content.licenseDetails),
+ },
+ };
+ }
throw new TypeError(`No Block in V2 Editors named /"${blockType}/", Cannot Save Content.`);
},
saveBlock: ({
@@ -106,6 +136,56 @@ export const loadImages = (rawImages) => camelizeKeys(rawImages).reduce(
{},
);
+export const processVideoIds = ({ videoSource, fallbackVideos }) => {
+ let edxVideoId = '';
+ let youtubeId = '';
+ const html5Sources = [];
+
+ if (module.isEdxVideo(videoSource)) {
+ edxVideoId = videoSource;
+ } else if (module.parseYoutubeId(videoSource)) {
+ youtubeId = module.parseYoutubeId(videoSource);
+ } else {
+ html5Sources.push(videoSource);
+ }
+
+ fallbackVideos.forEach((src) => html5Sources.push(src));
+
+ return {
+ edxVideoId,
+ html5Sources,
+ youtubeId,
+ };
+};
+
+export const isEdxVideo = (src) => {
+ const uuid4Regex = new RegExp(/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/);
+ if (src.match(uuid4Regex)) {
+ return true;
+ }
+ return false;
+};
+
+export const parseYoutubeId = (src) => {
+ const youtubeRegex = new RegExp(/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/);
+ if (!src.match(youtubeRegex)) {
+ return null;
+ }
+ return src.match(youtubeRegex)[5];
+};
+
+export const processLicense = (licenseType, licenseDetails) => {
+ if (licenseType === 'all-rights-reserved') {
+ return 'all-rights-reserved';
+ }
+ return 'creative-commons: ver=4.0'.concat(
+ (licenseDetails.attribution ? ' BY' : ''),
+ (licenseDetails.noncommercial ? ' NC' : ''),
+ (licenseDetails.noDerivatives ? ' ND' : ''),
+ (licenseDetails.shareAlike ? ' SA' : ''),
+ );
+};
+
export const checkMockApi = (key) => {
if (process.env.REACT_APP_DEVGALLERY) {
return mockApi[key];
diff --git a/src/editors/data/services/cms/api.test.js b/src/editors/data/services/cms/api.test.js
index dd4e2a9b4..29e96715d 100644
--- a/src/editors/data/services/cms/api.test.js
+++ b/src/editors/data/services/cms/api.test.js
@@ -32,12 +32,14 @@ const { camelize } = utils;
const { apiMethods } = api;
const blockId = 'coursev1:2uX@4345432';
-const content = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.';
const learningContextId = 'demo2uX';
const studioEndpointUrl = 'hortus.coa';
const title = 'remember this needs to go into metadata to save';
describe('cms api', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
describe('apiMethods', () => {
describe('fetchBlockId', () => {
it('should call get with url.blocks', () => {
@@ -69,6 +71,7 @@ describe('cms api', () => {
describe('normalizeContent', () => {
test('return value for blockType: html', () => {
+ const content = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.';
expect(apiMethods.normalizeContent({
blockId,
blockType: 'html',
@@ -77,13 +80,68 @@ describe('cms api', () => {
title,
})).toEqual({
category: 'html',
- couseKey: learningContextId,
+ courseKey: learningContextId,
data: content,
has_changes: true,
id: blockId,
metadata: { display_name: title },
});
});
+ test('return value for blockType: video', () => {
+ const content = {
+ videoSource: 'viDeOSouRCE',
+ fallbackVideos: 'FalLBacKVidEOs',
+ allowVideoDownloads: 'alLOwViDeodownLOads',
+ thumbnail: 'THUmbNaIL',
+ transcripts: 'traNScRiPts',
+ allowTranscriptDownloads: 'aLloWTRaNScriPtdoWnlOADS',
+ duration: {
+ startTime: 'StArTTime',
+ stopTime: 'sToPTiME',
+ },
+ showTranscriptByDefault: 'ShOWtrANscriPTBYDeFAulT',
+ handout: 'HAnDOuT',
+ licenseType: 'LiCeNsETYpe',
+ licenseDetails: 'liCENSeDetAIls',
+ };
+ const html5Sources = 'hTML5souRCES';
+ const edxVideoId = 'eDXviDEOid';
+ const youtubeId = 'yOUtUBeid';
+ const license = 'LiCEnsE';
+ jest.spyOn(api, 'processVideoIds').mockReturnValue({
+ html5Sources,
+ edxVideoId,
+ youtubeId,
+ });
+ jest.spyOn(api, 'processLicense').mockReturnValue(license);
+ expect(apiMethods.normalizeContent({
+ blockId,
+ blockType: 'video',
+ content,
+ learningContextId,
+ title,
+ })).toEqual({
+ category: 'video',
+ courseKey: learningContextId,
+ display_name: title,
+ id: blockId,
+ metadata: {
+ display_name: title,
+ download_video: content.allowVideoDownloads,
+ edx_video_id: edxVideoId,
+ html5_sources: html5Sources,
+ youtube_id_1_0: youtubeId,
+ download_track: content.allowTranscriptDownloads,
+ track: '',
+ show_captions: content.showTranscriptByDefault,
+ handout: content.handout,
+ start_time: content.duration.startTime,
+ end_time: content.duration.stopTime,
+ license,
+ },
+ });
+ jest.restoreAllMocks();
+ });
test('throw error for invalid blockType', () => {
expect(() => { apiMethods.normalizeContent({ blockType: 'somethingINVALID' }); })
.toThrow(TypeError);
@@ -91,6 +149,7 @@ describe('cms api', () => {
});
describe('saveBlock', () => {
+ const content = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.';
it('should call post with urls.block and normalizeContent', () => {
apiMethods.saveBlock({
blockId,
@@ -201,4 +260,151 @@ describe('cms api', () => {
});
});
});
+ describe('processVideoIds', () => {
+ const edxVideoId = 'eDXviDEoid';
+ const youtubeId = 'yOuTuBeID';
+ const html5Sources = [
+ 'sOuRce1',
+ 'sourCE2',
+ ];
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ describe('if the videoSource is an edx video id', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'isEdxVideo').mockReturnValue(true);
+ jest.spyOn(api, 'parseYoutubeId').mockReturnValue(null);
+ });
+ it('returns edxVideoId when there are no fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoSource: edxVideoId,
+ fallbackVideos: [],
+ })).toEqual({
+ edxVideoId,
+ html5Sources: [],
+ youtubeId: '',
+ });
+ });
+ it('returns edxVideoId and html5Sources when there are fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoSource: edxVideoId,
+ fallbackVideos: html5Sources,
+ })).toEqual({
+ edxVideoId,
+ html5Sources,
+ youtubeId: '',
+ });
+ });
+ });
+ describe('if the videoSource is a youtube url', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'isEdxVideo').mockReturnValue(false);
+ jest.spyOn(api, 'parseYoutubeId').mockReturnValue(youtubeId);
+ });
+ it('returns youtubeId when there are no fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoSource: edxVideoId,
+ fallbackVideos: [],
+ })).toEqual({
+ edxVideoId: '',
+ html5Sources: [],
+ youtubeId,
+ });
+ });
+ it('returns youtubeId and html5Sources when there are fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoSource: edxVideoId,
+ fallbackVideos: html5Sources,
+ })).toEqual({
+ edxVideoId: '',
+ html5Sources,
+ youtubeId,
+ });
+ });
+ });
+ describe('if the videoSource is an html5 source', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'isEdxVideo').mockReturnValue(false);
+ jest.spyOn(api, 'parseYoutubeId').mockReturnValue(null);
+ });
+ it('returns html5Sources when there are no fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoSource: html5Sources[0],
+ fallbackVideos: [],
+ })).toEqual({
+ edxVideoId: '',
+ html5Sources: [html5Sources[0]],
+ youtubeId: '',
+ });
+ });
+ it('returns html5Sources when there are fallbackVideos', () => {
+ expect(api.processVideoIds({
+ videoSource: html5Sources[0],
+ fallbackVideos: [html5Sources[1]],
+ })).toEqual({
+ edxVideoId: '',
+ html5Sources,
+ youtubeId: '',
+ });
+ });
+ });
+ });
+ describe('isEdxVideo', () => {
+ it('returns true if id is in uuid4 format', () => {
+ const id = 'c2afd8c8-3329-4dfc-95be-4ee6d986c3e5';
+ expect(api.isEdxVideo(id)).toEqual(true);
+ });
+ it('returns false if id is not in uuid4 format', () => {
+ const id = 'someB-ad-Id';
+ expect(api.isEdxVideo(id)).toEqual(false);
+ });
+ });
+ describe('parseYoutubeId', () => {
+ it('returns the youtube id in an url', () => {
+ const id = '3_yD_cEKoCk';
+ const testURLs = [
+ 'https://www.youtube.com/watch?v=3_yD_cEKoCk&feature=featured',
+ 'https://www.youtube.com/watch?v=3_yD_cEKoCk',
+ 'http://www.youtube.com/watch?v=3_yD_cEKoCk',
+ '//www.youtube.com/watch?v=3_yD_cEKoCk',
+ 'www.youtube.com/watch?v=3_yD_cEKoCk',
+ 'https://youtube.com/watch?v=3_yD_cEKoCk',
+ 'http://youtube.com/watch?v=3_yD_cEKoCk',
+ '//youtube.com/watch?v=3_yD_cEKoCk',
+ 'youtube.com/watch?v=3_yD_cEKoCk',
+ 'https://m.youtube.com/watch?v=3_yD_cEKoCk',
+ 'http://m.youtube.com/watch?v=3_yD_cEKoCk',
+ '//m.youtube.com/watch?v=3_yD_cEKoCk',
+ 'm.youtube.com/watch?v=3_yD_cEKoCk',
+ 'https://www.youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ 'http://www.youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ '//www.youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ 'www.youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ 'youtube.com/v/3_yD_cEKoCk?fs=1&hl=en_US',
+ 'https://www.youtube.com/embed/3_yD_cEKoCk?autoplay=1',
+ 'https://www.youtube.com/embed/3_yD_cEKoCk',
+ 'http://www.youtube.com/embed/3_yD_cEKoCk',
+ '//www.youtube.com/embed/3_yD_cEKoCk',
+ 'www.youtube.com/embed/3_yD_cEKoCk',
+ 'https://youtube.com/embed/3_yD_cEKoCk',
+ 'http://youtube.com/embed/3_yD_cEKoCk',
+ '//youtube.com/embed/3_yD_cEKoCk',
+ 'youtube.com/embed/3_yD_cEKoCk',
+ 'https://youtu.be/3_yD_cEKoCk?t=120',
+ 'https://youtu.be/3_yD_cEKoCk',
+ 'http://youtu.be/3_yD_cEKoCk',
+ '//youtu.be/3_yD_cEKoCk',
+ 'youtu.be/3_yD_cEKoCk',
+ ];
+ testURLs.forEach((url) => {
+ expect(api.parseYoutubeId(url)).toEqual(id);
+ });
+ });
+ it('returns null if the url is not a youtube url', () => {
+ const badURL = 'https://someothersite.com/3_yD_cEKoCk';
+ expect(api.parseYoutubeId(badURL)).toEqual(null);
+ });
+ });
+ // TODO FOR LICENSE
+ describe('processLicense', () => {});
});