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