From a7abab1236d283ded2135d23a7a1b44f7e73ac89 Mon Sep 17 00:00:00 2001 From: kenclary Date: Tue, 4 Oct 2022 14:59:22 -0400 Subject: [PATCH] Convert video settings from xblock metadata into the redux store. In-progress draft. TNL-10009. (#119) * feat: Convert video settings from xblock metadata into the redux store. In-progress draft. TNL-10009. Co-authored-by: rayzhou-bit --- src/editors/data/redux/thunkActions/video.js | 132 +++++++++++++- .../data/redux/thunkActions/video.test.js | 171 +++++++++++++++++- src/editors/data/services/cms/mockApi.js | 40 ++++ 3 files changed, 339 insertions(+), 4 deletions(-) diff --git a/src/editors/data/redux/thunkActions/video.js b/src/editors/data/redux/thunkActions/video.js index 253a93732..5184b43b2 100644 --- a/src/editors/data/redux/thunkActions/video.js +++ b/src/editors/data/redux/thunkActions/video.js @@ -1,9 +1,133 @@ -import { singleVideoData } from '../../services/cms/mockVideoData'; import { actions, selectors } from '..'; import * as requests from './requests'; +import * as module from './video'; -export const loadVideoData = () => (dispatch) => { - dispatch(actions.video.load(singleVideoData)); +export const loadVideoData = () => (dispatch, getState) => { + const state = getState(); + const rawVideoData = state.app.blockValue.data.metadata ? state.app.blockValue.data.metadata : {}; + const { + videoSource, + videoId, + fallbackVideos, + } = module.determineVideoSource({ + edxVideoId: rawVideoData.edx_video_id, + youtubeId: rawVideoData.youtube_id_1_0, + html5Sources: rawVideoData.html5_sources, + }); + + // we don't appear to want to parse license version + const [licenseType, licenseOptions] = module.parseLicense(rawVideoData.license); + + dispatch(actions.video.load({ + videoSource, + videoId, + fallbackVideos, + allowVideoDownloads: rawVideoData.download_video, + transcripts: rawVideoData.transcripts, + allowTranscriptDownloads: rawVideoData.download_track, + showTranscriptByDefault: rawVideoData.show_captions, + duration: { // TODO duration is not always sent so they should be calculated. + startTime: rawVideoData.start_time, + stopTime: rawVideoData.end_time, + total: null, // TODO can we get total duration? if not, probably dropping from widget + }, + handout: rawVideoData.handout, + licenseType, + licenseDetails: { + attribution: licenseOptions.by, + noncommercial: licenseOptions.nc, + noDerivatives: licenseOptions.nd, + shareAlike: licenseOptions.sa, + }, + })); +}; + +export const determineVideoSource = ({ + edxVideoId, + youtubeId, + html5Sources, +}) => { + // videoSource should be the edx_video_id (if present), or the youtube url (if present), or the first fallback url. + // in that order. + // if we are falling back to the first fallback url, remove it from the list of fallback urls for display + const videoSource = edxVideoId || youtubeId || html5Sources[0] || ''; + const videoId = edxVideoId || ''; + const fallbackVideos = (!edxVideoId && !youtubeId) + ? html5Sources.slice(1) + : html5Sources; + return { + videoSource, + videoId, + fallbackVideos, + }; +}; + +// copied from frontend-app-learning/src/courseware/course/course-license/CourseLicense.jsx +// in the long run, should be shared (perhaps one day the learning MFE will depend on this repo) +export const parseLicense = (license) => { + if (!license) { + // Default to All Rights Reserved if no license + // is detected + return ['all-rights-reserved', {}]; + } + + // Search for a colon character denoting the end + // of the license type and start of the options + const colonIndex = license.indexOf(':'); + if (colonIndex === -1) { + // no options, so the entire thing is the license type + return [license, {}]; + } + + // Split the license on the colon + const licenseType = license.slice(0, colonIndex).trim(); + const optionStr = license.slice(colonIndex + 1).trim(); + + let options = {}; + let version = ''; + + // Set the defaultVersion to 4.0 + const defaultVersion = '4.0'; + optionStr.split(' ').forEach(option => { + // Split the option into key and value + // Default the value to `true` if no value + let key = ''; + let value = ''; + if (option.indexOf('=') !== -1) { + [key, value] = option.split('='); + } else { + key = option; + value = true; + } + + // Check for version + if (key === 'ver') { + version = value; + } else { + // Set the option key to lowercase to make + // it easier to query + options[key.toLowerCase()] = value; + } + }); + + // No options + if (Object.keys(options).length === 0) { + // If no other options are set for the + // license, set version to 1.0 + version = '1.0'; + + // Set the `zero` option so the link + // works correctly + options = { + zero: true, + }; + } + + // Set the version to whatever was included, + // using `defaultVersion` as a fallback if unset + version = version || defaultVersion; + + return [licenseType, options, version]; }; export const saveVideoData = () => () => { @@ -80,6 +204,8 @@ export const replaceTranscript = ({ newFile, newFilename, language }) => (dispat export default { loadVideoData, + determineVideoSource, + parseLicense, saveVideoData, uploadTranscript, deleteTranscript, diff --git a/src/editors/data/redux/thunkActions/video.test.js b/src/editors/data/redux/thunkActions/video.test.js index f27b27f5f..0b1d74428 100644 --- a/src/editors/data/redux/thunkActions/video.test.js +++ b/src/editors/data/redux/thunkActions/video.test.js @@ -2,6 +2,19 @@ import { actions } from '..'; import { keyStore } from '../../../utils'; import * as thunkActions from './video'; +jest.mock('..', () => ({ + actions: { + video: { + load: (args) => ({ load: args }), + updateField: (args) => ({ updateField: args }), + }, + }, + selectors: { + video: { + videoId: (state) => ({ videoId: state }), + }, + }, +})); jest.mock('./requests', () => ({ deleteTranscript: (args) => ({ deleteTranscript: args }), uploadTranscript: (args) => ({ uploadTranscript: args }), @@ -12,6 +25,18 @@ const mockLanguage = 'la'; const mockFile = 'soMEtRANscRipT'; const mockFilename = 'soMEtRANscRipT.srt'; +const testMetadata = { + download_track: 'dOWNlOAdTraCK', + download_video: 'downLoaDViDEo', + edx_video_id: 'soMEvIDEo', + end_time: 'StOpTIMe', + handout: 'hANdoUT', + html5_sources: [], + license: 'liCENse', + show_captions: 'shOWcapTIONS', + start_time: 'stARtTiME', + transcripts: { la: 'test VALUE' }, +}; const testState = { transcripts: { la: 'test VALUE' }, videoId: 'soMEvIDEo' }; const testUpload = { transcripts: { la: { filename: mockFilename } } }; const testReplaceUpload = { @@ -27,10 +52,154 @@ describe('video thunkActions', () => { beforeEach(() => { dispatch = jest.fn((action) => ({ dispatch: action })); getState = jest.fn(() => ({ - app: { studioEndpointUrl: 'soMEeNDPoiNT', blockId: 'soMEBloCk' }, + app: { + blockId: 'soMEBloCk', + blockValue: { data: { metadata: { ...testMetadata } } }, + studioEndpointUrl: 'soMEeNDPoiNT', + }, video: testState, })); }); + describe('loadVideoData', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('dispatches actions.video.load', () => { + jest.spyOn(thunkActions, thunkActionsKeys.determineVideoSource).mockReturnValue({ + videoSource: 'videOsOurce', + videoId: 'videOiD', + fallbackVideos: 'fALLbACKvIDeos', + }); + jest.spyOn(thunkActions, thunkActionsKeys.parseLicense).mockReturnValue([ + 'liCENSEtyPe', + { + by: true, + nc: true, + nd: true, + sa: false, + }, + ]); + thunkActions.loadVideoData()(dispatch, getState); + expect(dispatch).toHaveBeenCalledWith(actions.video.load({ + videoSource: 'videOsOurce', + 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: null, + }, + handout: testMetadata.handout, + licenseType: 'liCENSEtyPe', + licenseDetails: { + attribution: true, + noncommercial: true, + noDerivatives: true, + shareAlike: false, + }, + })); + }); + }); + describe('determineVideoSource', () => { + const edxVideoId = 'EDxviDEoiD'; + const youtubeId = 'yOuTuBEiD'; + const html5Sources = ['htmLOne', 'hTMlTwo', 'htMLthrEE']; + describe('when there is an edx video id, youtube id and html5 sources', () => { + it('returns the edx video id for video source and html5 sources for fallback videos', () => { + expect(thunkActions.determineVideoSource({ + edxVideoId, + youtubeId, + html5Sources, + })).toEqual({ + videoSource: edxVideoId, + videoId: edxVideoId, + fallbackVideos: html5Sources, + }); + }); + }); + describe('when there is no edx video id', () => { + it('returns the youtube id for video source and html5 sources for fallback videos', () => { + expect(thunkActions.determineVideoSource({ + edxVideoId: '', + youtubeId, + html5Sources, + })).toEqual({ + videoSource: youtubeId, + videoId: '', + fallbackVideos: html5Sources, + }); + }); + }); + describe('when there is no edx video id and no youtube id', () => { + it('returns the first html5 source for video source and the rest for fallback videos', () => { + expect(thunkActions.determineVideoSource({ + edxVideoId: '', + youtubeId: '', + html5Sources, + })).toEqual({ + videoSource: 'htmLOne', + videoId: '', + fallbackVideos: ['hTMlTwo', 'htMLthrEE'], + }); + }); + it('returns the html5 source for video source and an empty array for fallback videos', () => { + expect(thunkActions.determineVideoSource({ + edxVideoId: '', + youtubeId: '', + html5Sources: ['htmlOne'], + })).toEqual({ + videoSource: 'htmlOne', + videoId: '', + fallbackVideos: [], + }); + }); + }); + describe('when there is no edx video id, no youtube id and no html5 sources', () => { + it('returns an empty string for video source and an empty array for fallback videos', () => { + expect(thunkActions.determineVideoSource({ + edxVideoId: '', + youtubeId: '', + html5Sources: [], + })).toEqual({ + videoSource: '', + videoId: '', + fallbackVideos: [], + }); + }); + }); + }); + describe('parseLicense', () => { + let license; + it('returns all-rights-reserved when there is no license', () => { + expect(thunkActions.parseLicense(license)).toEqual([ + 'all-rights-reserved', + {}, + ]); + }); + it('returns expected values for a license with no options', () => { + license = 'sOmeLIcense'; + expect(thunkActions.parseLicense(license)).toEqual([ + license, + {}, + ]); + }); + it('returns expected type and options for creative commons', () => { + license = 'creative-commons: ver=4.0 BY NC ND'; + expect(thunkActions.parseLicense(license)).toEqual([ + 'creative-commons', + { + by: true, + nc: true, + nd: true, + }, + '4.0', + ]); + }); + }); describe('deleteTranscript', () => { beforeEach(() => { thunkActions.deleteTranscript({ language: mockLanguage })(dispatch, getState); diff --git a/src/editors/data/services/cms/mockApi.js b/src/editors/data/services/cms/mockApi.js index a73fa0c45..7d6bcb3a9 100644 --- a/src/editors/data/services/cms/mockApi.js +++ b/src/editors/data/services/cms/mockApi.js @@ -10,6 +10,26 @@ export const fetchBlockById = ({ blockId, studioEndpointUrl }) => mockPromise({ data: { data: '

Test prompt content

', display_name: 'My Text Prompt', + metadata: { + display_name: 'Welcome!', + download_track: true, + download_video: true, + edx_video_id: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7', + html5_sources: [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ], + show_captions: true, + sub: '', + track: '', + transcripts: { + en: 'my-transcript-url', + }, + xml_attributes: { + source: '', + }, + youtube_id_1_0: 'dQw4w9WgXcQ', + }, }, }); @@ -21,6 +41,26 @@ export const fetchStudioView = ({ blockId, studioEndpointUrl }) => mockPromise({ html: blockId.includes('mockRaw') ? 'data-editor="raw"' : '', data: '

Test prompt content

', display_name: 'My Text Prompt', + metadata: { + display_name: 'Welcome!', + download_track: true, + download_video: true, + edx_video_id: 'f36f06b5-92e5-47c7-bb26-bcf986799cb7', + html5_sources: [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ], + show_captions: true, + sub: '', + track: '', + transcripts: { + en: 'my-transcript-url', + }, + xml_attributes: { + source: '', + }, + youtube_id_1_0: 'dQw4w9WgXcQ', + }, }, });