From 564b95386072293930303b42065cb4a05663ab78 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Mon, 9 May 2022 14:23:21 -0400 Subject: [PATCH] feat: video skeleton hooks (#65) * feat: video skeleton hooks * fix: lint * Update src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.jsx Co-authored-by: connorhaugh <49422820+connorhaugh@users.noreply.github.com> Co-authored-by: connorhaugh <49422820+connorhaugh@users.noreply.github.com> --- .../components/CollapsibleFormWidget.jsx | 7 + .../components/DurationWidget.jsx | 41 +- .../components/HandoutWidget.jsx | 8 +- .../components/LicenseWidget.jsx | 12 +- .../components/ThumbnailWidget.jsx | 8 +- .../components/TranscriptsWidget.jsx | 23 +- .../components/VideoSourceWidget.jsx | 75 +++- .../VideoSettingsModal/components/duration.js | 81 ++++ .../components/duration.test.js | 134 +++++++ .../VideoSettingsModal/components/handlers.js | 48 +++ .../components/handlers.test.js | 50 +++ .../VideoSettingsModal/components/hooks.js | 329 ++++++++++++++- .../components/hooks.test.js | 377 ++++++++++++++++++ src/editors/containers/VideoEditor/index.jsx | 5 +- src/editors/data/constants/video.js | 10 + src/editors/data/redux/video/reducer.js | 17 +- .../data/services/cms/mockVideoData.js | 6 +- 17 files changed, 1192 insertions(+), 39 deletions(-) create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js create mode 100644 src/editors/data/constants/video.js diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.jsx index a61e3bf55..70f6bd1e2 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/CollapsibleFormWidget.jsx @@ -3,6 +3,13 @@ import PropTypes from 'prop-types'; import { Collapsible } from '@edx/paragon'; +/** + * Simple Wrapper for a Form Widget component in the Video Settings modal + * Takes a title element and children, and produces a collapsible widget container + * My Title}> + *
My Widget
+ *
+ */ export const CollapsibleFormWidget = ({ title, children, diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx index 513bebf32..5043b0d52 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx @@ -2,18 +2,49 @@ import React from 'react'; import { useDispatch } from 'react-redux'; // import PropTypes from 'prop-types'; +import { + FormControl, + FormGroup, +} from '@edx/paragon'; + +import { keyStore } from '../../../../../utils'; import CollapsibleFormWidget from './CollapsibleFormWidget'; import hooks from './hooks'; +/** + * Collapsible Form widget controlling video start and end times + * Also displays the total run time of the video. + */ export const DurationWidget = () => { const dispatch = useDispatch(); - const duration = hooks.widgetValue(hooks.selectorKeys.duration, dispatch); + const { duration } = hooks.widgetValues({ + dispatch, + fields: { [hooks.selectorKeys.duration]: hooks.durationWidget }, + }); + const timeKeys = keyStore(duration.formValue); return ( -
Duration Widget
-

Start: {duration.formValue.startTime}

-

Stop: {duration.formValue.stopTime}

-

Total: {duration.formValue.total}

+ +
+ + +
+
+ Total: {duration.formValue.total} +
+
); }; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget.jsx index 573176a43..0aca58d38 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget.jsx @@ -5,9 +5,15 @@ import { useDispatch } from 'react-redux'; import hooks from './hooks'; import CollapsibleFormWidget from './CollapsibleFormWidget'; +/** + * Collapsible Form widget controlling video handouts + */ export const HandoutWidget = () => { const dispatch = useDispatch(); - const handout = hooks.widgetValue(hooks.selectorKeys.handout, dispatch); + const { handout } = hooks.widgetValues({ + dispatch, + fields: { [hooks.selectorKeys.handout]: hooks.genericWidget }, + }); return (

{handout.formValue}

diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget.jsx index 9540a5b5a..f32840b84 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/LicenseWidget.jsx @@ -5,10 +5,18 @@ import { useDispatch } from 'react-redux'; import hooks from './hooks'; import CollapsibleFormWidget from './CollapsibleFormWidget'; +/** + * Collapsible Form widget controlling videe licence type and details + */ export const LicenseWidget = () => { const dispatch = useDispatch(); - const licenseType = hooks.widgetValue(hooks.selectorKeys.licenseType, dispatch); - const licenseDetails = hooks.widgetValue(hooks.selectorKeys.licenseDetails, dispatch); + const { licenseType, licenseDetails } = hooks.widgetValues({ + dispatch, + fields: { + [hooks.selectorKeys.licenseType]: hooks.genericWidget, + [hooks.selectorKeys.licenseDetails]: hooks.objectWidget, + }, + }); return (
License Widget
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget.jsx index 9760d06e6..94e5d95d1 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/ThumbnailWidget.jsx @@ -5,9 +5,15 @@ import { useDispatch } from 'react-redux'; import hooks from './hooks'; import CollapsibleFormWidget from './CollapsibleFormWidget'; +/** + * Collapsible Form widget controlling video thumbnail + */ export const ThumbnailWidget = () => { const dispatch = useDispatch(); - const thumbnail = hooks.widgetValue(hooks.selectorKeys.thumbnail, dispatch); + const { thumbnail } = hooks.widgetValues({ + dispatch, + fields: { [hooks.selectorKeys.thumbnail]: hooks.genericWidget }, + }); return (

{thumbnail.formValue}

diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptsWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptsWidget.jsx index 5f07dc6fd..a36de6d18 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptsWidget.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptsWidget.jsx @@ -5,17 +5,24 @@ import { useDispatch } from 'react-redux'; import hooks from './hooks'; import CollapsibleFormWidget from './CollapsibleFormWidget'; +/** + * Collapsible Form widget controlling video transcripts + */ export const TranscriptWidget = () => { const dispatch = useDispatch(); - const transcripts = hooks.widgetValue(hooks.selectorKeys.transcripts, dispatch); - const allowDownload = hooks.widgetValue( - hooks.selectorKeys.allowTranscriptDownloads, + const values = hooks.widgetValues({ dispatch, - ); - const showByDefault = hooks.widgetValue( - hooks.selectorKeys.showTranscriptByDefault, - dispatch, - ); + fields: { + [hooks.selectorKeys.transcripts]: hooks.objectWidget, + [hooks.selectorKeys.allowTranscriptDownloads]: hooks.genericWidget, + [hooks.selectorKeys.showTranscriptByDefault]: hooks.genericWidget, + }, + }); + const { + transcripts, + allowTranscriptDownloads: allowDownload, + showTranscriptByDefault: showByDefault, + } = values; return ( Transcripts diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget.jsx index 8fb7f26e5..512e6551d 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/VideoSourceWidget.jsx @@ -2,20 +2,81 @@ import React from 'react'; import { useDispatch } from 'react-redux'; // import PropTypes from 'prop-types'; +import { + FormCheck, + FormControl, + FormGroup, + FormLabel, + IconButton, + Icon, +} from '@edx/paragon'; +import { Delete } from '@edx/paragon/icons'; + import hooks from './hooks'; import CollapsibleFormWidget from './CollapsibleFormWidget'; +/** + * Collapsible Form widget controlling video source as well as fallback sources + */ export const VideoSourceWidget = () => { const dispatch = useDispatch(); - const source = hooks.widgetValue(hooks.selectorKeys.videoSource, dispatch); - const fallbackVideos = hooks.widgetValue(hooks.selectorKeys.fallbackVideos, dispatch); - const allowDownload = hooks.widgetValue(hooks.selectorKeys.allowVideoDownloads, dispatch); + const { + videoSource: source, + fallbackVideos, + allowVideoDownloads: allowDownload, + } = hooks.widgetValues({ + dispatch, + fields: { + [hooks.selectorKeys.videoSource]: hooks.genericWidget, + [hooks.selectorKeys.fallbackVideos]: hooks.arrayWidget, + [hooks.selectorKeys.allowVideoDownloads]: hooks.genericWidget, + }, + }); + return ( -
Video Source Widget
-

Video Source: {source.formValue}

-

Fallback Videos: {fallbackVideos.formValue.join(', ')}

-

Video Source: {allowDownload.formValue ? 'True' : 'False'}

+ +
+ Video ID or URL + +
+ Fallback videos + + {` + To be sure all learners can access the video, edX + recommends providing additional videos in both .mp4 and + .webm formats. The first listed video compatible with the + learner's device will play. + `} + + {[0, 1].map((index) => ( +
+ + +
+ ))} + +
); }; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js new file mode 100644 index 000000000..b55bdf87a --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js @@ -0,0 +1,81 @@ +import { useCallback } from 'react'; +import * as module from './duration'; + +const durationMatcher = /^(\d+)?:?(\d+)?:?(\d+)?$/i; + +/** + * durationFromValue(value) + * Returns a duration string in 'hh:mm:ss' format from the given ms value + * @param {number} value - duration (in milliseconds) + * @return {string} - duration in 'hh:mm:ss' format + */ +export const durationFromValue = (value) => { + const seconds = Math.floor((value / 1000) % 60); + const minutes = Math.floor((value / 60000) % 60); + const hours = Math.floor((value / 3600000) % 24); + const zeroPad = (num) => String(num).padStart(2, '0'); + return [hours, minutes, seconds].map(zeroPad).join(':'); +}; + +/** + * valueFromDuration(duration) + * Returns a millisecond duration value from the given 'hh:mm:ss' format string + * @param {string} duration - duration in 'hh:mm:ss' format + * @return {number} - duration in milliseconds. Returns null if duration is invalid. + */ +export const valueFromDuration = (duration) => { + let matches = duration.trim().match(durationMatcher); + if (!matches) { + return null; + } + matches = matches.slice(1).filter(v => v !== undefined); + if (matches.length < 3) { + for (let i = 0; i <= 3 - matches.length; i++) { + matches.unshift(0); + } + } + const [hours, minutes, seconds] = matches.map(x => parseInt(x, 10) || 0); + return ((hours * 60 + minutes) * 60 + seconds) * 1000; +}; + +/** + * durationValue(duration) + * Returns the display value for embedded start and stop times + * @param {object} duration - object containing startTime and stopTime millisecond values + * @return {object} - start and stop time from incoming object mapped to duration strings. + */ +export const durationValue = (duration) => ({ + startTime: module.durationFromValue(duration.startTime), + stopTime: module.durationFromValue(duration.stopTime), +}); + +/** + * updateDuration({ formValue, local, setLocal, setFormValue }) + * Returns a memoized callback based on inputs that updates local value and form value + * if the new string is valid (formValue stores a number, local stores a string). + * If the duration string is invalid, resets the local value to the latest good value. + * @param {object} formValue - redux-stored durations in milliseconds + * @param {object} local - hook-stored duration in 'hh:mm:ss' format + * @param {func} setFormValue - set form value + * @param {func} setLocal - set local object + * @return {func} - callback to update duration locally and in redux + * updateDuration(args)(index, durationString) + */ +export const updateDuration = ({ + formValue, + local, + setFormValue, + setLocal, +}) => useCallback( + (index, durationString) => { + const newValue = module.valueFromDuration(durationString); + if (newValue !== null) { + setLocal({ ...local, [index]: durationString }); + setFormValue({ ...formValue, [index]: newValue }); + } else { + // If invalid duration string, reset to last valid value + setLocal({ ...local, [index]: module.durationFromValue(formValue[index]) }); + } + }, + [formValue, local, setLocal, setFormValue], +); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js new file mode 100644 index 000000000..13affb448 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js @@ -0,0 +1,134 @@ +import { keyStore } from '../../../../../utils'; +import * as duration from './duration'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useCallback: (cb, prereqs) => ({ useCallback: { cb, prereqs } }), +})); + +let hook; +const durationKeys = keyStore(duration); +const [h, m, s] = [3600000, 60000, 1000]; +const durationPairs = [ + [0, '00:00:00'], + [5000, '00:00:05'], + [60000, '00:01:00'], + [3600000, '01:00:00'], + [3665000, '01:01:05'], +]; +const trickyDurations = [ + ['10:00', 600000], + ['23', 23000], + ['100:100:100', 100 * (m + s + h)], + ['23:42:781', 23 * h + 42 * m + 781 * s], +]; +let spies = {}; +let props; +let cb; +let prereqs; +let oldDuration; +describe('Video Settings Modal duration hooks', () => { + beforeEach(() => { + spies = {}; + oldDuration = { ...jest.requireActual('./duration') }; + }); + afterEach(() => { + Object.keys(oldDuration).forEach((key) => { + duration[key] = oldDuration[key]; + }); + Object.keys(spies).forEach((key) => { + spies[key].mockRestore(); + }); + }); + + describe('durationFromValue', () => { + it('translates milliseconds into hh:mm:ss format', () => { + durationPairs.forEach( + ([val, dur]) => expect(duration.durationFromValue(val)).toEqual(dur), + ); + }); + }); + describe('valueFromDuration', () => { + beforeEach(() => { + hook = duration.valueFromDuration; + }); + it('returns null if given a bad duration string', () => { + const badChecks = ['a', '00:00:1f', '0adg:00:04']; + badChecks.forEach(dur => expect(hook(dur)).toEqual(null)); + }); + it('returns simple durations', () => { + durationPairs.forEach(([val, dur]) => expect(hook(dur)).toEqual(val)); + }); + it('returns tricky durations, prepending zeros and expanding out sections', () => { + trickyDurations.forEach(([dur, val]) => expect(hook(dur)).toEqual(val)); + }); + }); + describe('durationValue', () => { + const mock = jest.fn(v => ({ duration: v })); + beforeEach(() => { + jest.spyOn(duration, durationKeys.durationFromValue).mockImplementation(mock); + }); + it('returns an object that maps durationFromValue to the passed duration keys', () => { + const testDuration = { startTime: 1, stopTime: 2, other: 'values' }; + expect(duration.durationValue(testDuration)).toEqual({ + startTime: mock(testDuration.startTime), + stopTime: mock(testDuration.stopTime), + }); + }); + }); + describe('updateDuration', () => { + const testDuration = 'myDuration'; + const testIndex = 'startTime'; + const mockValueFromDuration = (dur) => ({ value: dur }); + const mockDurationFromValue = (value) => ({ duration: value }); + beforeEach(() => { + props = { + formValue: { startTime: 230000, stopTime: 0 }, + local: { startTime: '00:00:23', stopTime: '00:00:00' }, + setLocal: jest.fn(), + setFormValue: jest.fn(), + }; + spies.valueFromDuration = jest.spyOn(duration, durationKeys.valueFromDuration) + .mockImplementation(mockValueFromDuration); + hook = duration.updateDuration; + ({ cb, prereqs } = hook(props).useCallback); + }); + it('returns a useCallback field based on the passed args', () => { + expect(prereqs).toEqual([ + props.formValue, + props.local, + props.setLocal, + props.setFormValue, + ]); + }); + describe('callback', () => { + describe('if the passed durationString is valid', () => { + it('sets the local value to updated strings and form value to new timestamp value', () => { + cb(testIndex, testDuration); + expect(duration.valueFromDuration).toHaveBeenCalledWith(testDuration); + expect(props.setLocal).toHaveBeenCalledWith({ + ...props.local, + [testIndex]: testDuration, + }); + expect(props.setFormValue).toHaveBeenCalledWith({ + ...props.formValue, + [testIndex]: mockValueFromDuration(testDuration), + }); + }); + }); + describe('if the passed durationString is not valid', () => { + it('updates local back to the string for the form-stored timestamp value', () => { + spies.valueFromDuration.mockReturnValue(null); + spies.durationFromValue = jest.spyOn(duration, durationKeys.durationFromValue) + .mockImplementationOnce(mockDurationFromValue); + hook(props).useCallback.cb(testIndex, testDuration); + expect(props.setLocal).toHaveBeenCalledWith({ + ...props.local, + [testIndex]: mockDurationFromValue(props.formValue[testIndex]), + }); + expect(props.setFormValue).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js new file mode 100644 index 000000000..7386d50ab --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js @@ -0,0 +1,48 @@ +/** + * handleIndexEvent({ handler, transform }) + * return a method that takes an index and returns an event handler of the given type + * that calls a transform with the given index and the incoming value. + * @param {func} handler - event handler (onValue, onChecked, etc) + * @param {func} transform - transform method taking an index and a new value + * @return {func} - event handler creator for index-tied values + */ +export const handleIndexEvent = ({ handler, transform }) => (index) => ( + handler(val => transform(index, val)) +); + +/** + * handleIndexTransformEvent({ handler, setter, local, transform }) + * return a method that takes an index and returns an event handler of the given type + * that calls a transform with the given index and the incoming value. + * @param {func} handler - event handler (onValue, onChecked, etc) + * @param {string|number|object} local - local hook values + * @param {func} setter - method that updates models based on event + * @param {func} transform - transform method taking an index and a new value + * @return {func} - event handler creator for index-tied values with separate setter and transforms + */ +export const handleIndexTransformEvent = ({ + handler, + local, + setter, + transform, +}) => (index) => ( + handler(val => setter(transform(local, index, val))) +); + +/** + * onValue(handler) + * returns an event handler that calls the given method with the event target value + * Intended for most basic input types. + * @param {func} handler - callback to receive the event value + * @return - event handler that calls passed handler with event.target.value + */ +export const onValue = (handler) => (e) => handler(e.target.value); + +/** + * onValue(handler) + * returns an event handler that calls the given method with the event target value + * Intended for checkbox input types. + * @param {func} handler - callback to receive the event value + * @return - event handler that calls passed handler with event.target.value + */ +export const onChecked = (handler) => (e) => handler(e.target.checked); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js new file mode 100644 index 000000000..208b581d0 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js @@ -0,0 +1,50 @@ +import * as handlers from './handlers'; + +const handler = jest.fn(cb => ({ handler: cb })); +const transform = jest.fn((...args) => ({ transform: args })); +const setter = jest.fn(val => ({ setter: val })); +const index = 'test-index'; +const val = 'TEST value'; +const local = 'local-test-value'; +describe('Video Settings Modal event handler methods', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('handleIndexEvent', () => { + describe('returned method', () => { + it('takes index and calls handler with transform handler based on index', () => { + expect( + handlers.handleIndexEvent({ handler, transform })(index).handler(val), + ).toEqual(transform(index, val)); + }); + }); + }); + describe('handleIndexTransformEvent', () => { + describe('returned method', () => { + it('takes index and calls handler with setter(transform(local, index, val))', () => { + expect( + handlers.handleIndexTransformEvent({ + handler, + setter, + local, + transform, + })(index).handler(val), + ).toEqual(setter(transform(local, index, val))); + }); + }); + }); + describe('onValue', () => { + describe('returned method', () => { + it('calls handler with event.target.value', () => { + expect(handlers.onValue(handler)({ target: { value: val } })).toEqual(handler(val)); + }); + }); + }); + describe('onChecked', () => { + describe('returned method', () => { + it('calls handler with event.target.checked', () => { + expect(handlers.onChecked(handler)({ target: { checked: val } })).toEqual(handler(val)); + }); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js index 6a9b5f0fa..6b961a4a0 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js @@ -1,16 +1,333 @@ +import { + useCallback, + useState, + useEffect, + useMemo, +} from 'react'; import { useSelector } from 'react-redux'; -import { keyStore } from '../../../../../utils'; +import { StrictDict, keyStore } from '../../../../../utils'; import { actions, selectors } from '../../../../../data/redux'; +import { + updateDuration, + durationValue, +} from './duration'; + +import { + handleIndexEvent, + handleIndexTransformEvent, + onValue, + onChecked, +} from './handlers'; +import * as module from './hooks'; + export const selectorKeys = keyStore(selectors.video); -export const widgetValue = (key, dispatch) => ({ - formValue: useSelector(selectors.video[key]), - setFormValue: (val) => dispatch(actions.video.load({ [key]: val })), -}); +export const state = StrictDict( + [ + selectorKeys.videoSource, + selectorKeys.fallbackVideos, + selectorKeys.allowVideoDownloads, + + selectorKeys.thumbnail, + + selectorKeys.transcripts, + selectorKeys.allowTranscriptDownloads, + selectorKeys.showTranscriptByDefault, + + selectorKeys.duration, + + selectorKeys.handout, + + selectorKeys.licenseType, + selectorKeys.licenseDetails, + ].reduce( + (obj, key) => ({ ...obj, [key]: (val) => useState(val) }), + {}, + ), +); + +/** + * updateArray(array, index, val) + * Returns a new array with the element at replaced with + * @param {any[]} array - array of values + * @param {number} index - array index to replace + * @param {any} val - new value + * @return {any[]} - new array with element at index replaced with val + */ +export const updatedArray = (array, index, val) => { + const newArray = [...array]; + newArray.splice(index, 1, val); + return newArray; +}; + +/** + * updateObject(object, index, val) + * Returns a new object with the element at replaced with + * @param {object} object - object of values + * @param {string} index - object index to replace + * @param {any} val - new value + * @return {any[]} - new object with element at index replaced with val + */ +export const updatedObject = (obj, index, val) => ({ ...obj, [index]: val }); + +/** + * updateFormField({ dispatch, key })(val) + * Creates a callback to update a given form field based on an incoming value. + * @param {func} dispatch - redux dispatch method + * @param {string} key - form key + * @return {func} - callback taking a value and updating the video redux field + */ +export const updateFormField = ({ dispatch, key }) => useCallback( + (val) => dispatch(actions.video.updateField({ [key]: val })), + [], +); + +/** + * currentValue({ key, formValue }) + * Returns the current display value based on the form value. + * If duration, uses durationValue to transform the formValue + * @param {string} key - redux video state key + * @param {any} formValue - current value in the redux + * @return {any} - to-local translation of formValue + */ +export const currentValue = ({ key, formValue }) => { + if (key === selectorKeys.duration) { + return durationValue(formValue); + } + return formValue; +}; + +/** + * valueHooks({ dispatch, key }) + * returns local and redux state associated with the given data key, as well as methods + * to update either or both of those. + * @param {string} key - redux video state key + * @param {func} dispatch - redux dispatch method + * @return {object} - hooks based on the local and redux value associated with the given key + * formValue - value state in redux + * setFormValue - sets form field in redux + * local - value state in hook + * setLocal - sets form field in hook + * setAll - sets form field in hook AND redux + */ +export const valueHooks = ({ dispatch, key }) => { + const formValue = useSelector(selectors.video[key]); + const initialValue = useMemo(() => module.currentValue({ key, formValue }), []); + const [local, setLocal] = module.state[key](initialValue); + const setFormValue = module.updateFormField({ dispatch, key }); + + useEffect(() => { + if (key === selectorKeys.duration) { + setLocal(durationValue(formValue)); + } else { + setLocal(formValue); + } + }, [formValue]); + + const setAll = useCallback( + (val) => { + setLocal(val); + setFormValue(val); + }, + [setLocal, setFormValue], + ); + return { + formValue, + local, + setLocal, + setFormValue, + setAll, + }; +}; + +/** + * genericWidget({ dispatch, key }) + * Returns the value-tied hooks for inputs associated with a flat value in redux + * Tied to redux video shape based on data key + * includes onChange, onBlur, and onCheckedChange methods. blur and checked change + * instantly affect both redux and local, while change (while typing) only affects + * the local component. + * @param {func} dispatch - redux dispatch method + * @param {string} key - redux video shape key + * @return {object} - state hooks + * formValue - value state in redux + * setFormValue - sets form field in redux + * local - value state in hook + * setLocal - sets form field in hook + * setAll - sets form field in hook AND redux + * onChange - handle input change by updating local state + * onCheckedChange - handle checked change by updating local and redux state + * onBlur - handle input blur by updating local and redux states + */ +export const genericWidget = ({ dispatch, key }) => { + const { + formValue, + local, + setLocal, + setFormValue, + setAll, + } = module.valueHooks({ dispatch, key }); + return { + formValue, + local, + setLocal, + setAll, + setFormValue, + onChange: onValue(setLocal), + onCheckedChange: onChecked(setAll), + onBlur: onValue(setAll), + }; +}; + +/** + * arrayWidget({ dispatch, key }) + * Returns the value-tied hooks for inputs associated with a value in an array in the + * video redux shape. + * Tied to redux video shape based on data key + * includes onChange, onBlur, and onClear methods. blur changes local and redux state, + * on change affects only local state, and onClear sets both to an empty string. + * The creators from this widget will require an index to provide the final event-handler. + * @param {func} dispatch - redux dispatch method + * @param {string} key - redux video shape key + * @return {object} - state hooks + * formValue - value state in redux + * setFormValue - sets form field in redux + * local - value state in hook + * setLocal - sets form field in hook + * setAll - sets form field in hook AND redux + * onChange(index) - handle input change by updating local state + * onBlur(index) - handle input blur by updating local and redux states + * onClear(index) - handle clear event by setting value to empty string + */ +export const arrayWidget = ({ dispatch, key }) => { + const widget = module.valueHooks({ dispatch, key }); + return { + ...widget, + onChange: handleIndexTransformEvent({ + handler: onValue, + setter: widget.setLocal, + transform: module.updatedArray, + local: widget.local, + }), + onBlur: handleIndexTransformEvent({ + handler: onValue, + setter: widget.setAll, + transform: module.updatedArray, + local: widget.local, + }), + onClear: (index) => () => widget.setAll(module.updatedArray(widget.local, index, '')), + }; +}; + +/** + * objectWidget({ dispatch, key }) + * Returns the value-tied hooks for inputs associated with a value in an object in the + * video redux shape. + * Tied to redux video shape based on data key + * includes onChange and onBlur methods. blur changes local and redux state, + * on change affects only local state. + * The creators from this widget will require an index to provide the final event-handler. + * @param {func} dispatch - redux dispatch method + * @param {string} key - redux video shape key + * @return {object} - state hooks + * formValue - value state in redux + * setFormValue - sets form field in redux + * local - value state in hook + * setLocal - sets form field in hook + * setAll - sets form field in hook AND redux + * onChange(index) - handle input change by updating local state + * onBlur(index) - handle input blur by updating local and redux states + * onClear(index) - handle clear event by setting value to empty string + */ +export const objectWidget = ({ dispatch, key }) => { + const widget = module.valueHooks({ dispatch, key }); + return { + ...widget, + onChange: handleIndexTransformEvent({ + handler: onValue, + setter: widget.setLocal, + transform: module.updatedObject, + local: widget.local, + }), + onBlur: handleIndexTransformEvent({ + handler: onValue, + setter: widget.setAll, + transform: module.updatedObject, + local: widget.local, + }), + }; +}; + +/** + * durationWidget({ dispatch, key }) + * Returns the value-tied hooks for the video duration widget. + * Includes onChange, and onBlur. blur changes local and redux state, on-change affects + * only local state. + * The creators from this widget will require an index to provide the final event-handler. + * @param {func} dispatch - redux dispatch method + * @param {string} key - redux video shape key + * @return {object} - state hooks + * formValue - value state in redux + * setFormValue - sets form field in redux + * local - value state in hook + * setLocal - sets form field in hook + * setAll - sets form field in hook AND redux + * onChange(index) - handle input change by updating local state + * onBlur(index) - handle input blur by updating local and redux states + * onClear(index) - handle clear event by setting value to empty string + */ +export const durationWidget = ({ dispatch }) => { + const widget = module.valueHooks({ dispatch, key: selectorKeys.duration }); + const { + formValue, + local, + setFormField, + setLocal, + } = widget; + return { + ...widget, + onBlur: useCallback( + handleIndexEvent({ + handler: onValue, + transform: updateDuration(widget), + }), + [formValue, local, setFormField], + ), + onChange: useCallback( + handleIndexTransformEvent({ + handler: onValue, + setter: setLocal, + transform: module.updatedObject, + local, + }), + [local], + ), + }; +}; + +/** + * widgetValues({ fields, dispatch }) + * widget value populator, that takes a fields mapping (dataKey: widgetFn) and dispatch + * method, and returns object of widget values. + * @param {object} fields - object with video data keys for keys and widget methods for values + * @param {func} dispatch - redux dispatch method + * @return {object} - { : } + */ +export const widgetValues = ({ fields, dispatch }) => Object.keys(fields).reduce( + (obj, key) => ({ + ...obj, + [key]: fields[key]({ key, dispatch }), + }), + {}, +); export default { + arrayWidget, + durationWidget, + genericWidget, + objectWidget, selectorKeys, - widgetValue, + widgetValues, }; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js new file mode 100644 index 000000000..405737d46 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js @@ -0,0 +1,377 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; + +import { keyStore } from '../../../../../utils'; +import { actions, selectors } from '../../../../../data/redux'; +import { MockUseState } from '../../../../../../testUtils'; + +import * as duration from './duration'; +import * as handlers from './handlers'; +import * as hooks from './hooks'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useState: (val) => ({ useState: val }), + useEffect: jest.fn(), + useCallback: jest.fn((cb, prereqs) => ({ useCallback: { cb, prereqs } })), + useMemo: jest.fn((cb, prereqs) => ({ useMemo: { cb, prereqs } })), +})); + +jest.mock('./duration', () => ({ + updateDuration: jest.fn(value => ({ updateDuration: value })), + durationValue: jest.fn(value => ({ durationValue: value })), +})); + +jest.mock('./handlers', () => ({ + handleIndexEvent: jest.fn(args => ({ handleIndexEvent: args })), + handleIndexTransformEvent: jest.fn(args => ({ handleIndexTransformEvent: args })), + onValue: jest.fn(cb => ({ onValue: cb })), + onChecked: jest.fn(cb => ({ onChecked: cb })), +})); + +jest.mock('../../../../../data/redux', () => ({ + actions: { + video: { + updateField: (val) => ({ updateField: val }), + }, + }, + selectors: { + video: { + videoSource: (state) => ({ videoSource: state }), + fallbackVideos: (state) => ({ fallbackVideos: state }), + allowVideoDownloads: (state) => ({ allowVideoDownloads: state }), + thumbnail: (state) => ({ thumbnail: state }), + transcripts: (state) => ({ transcripts: state }), + allowTranscriptDownloads: (state) => ({ allowTranscriptDownloads: state }), + showTranscriptByDefault: (state) => ({ showTranscriptByDefault: state }), + duration: (state) => ({ duration: state }), + handout: (state) => ({ handout: state }), + licenseType: (state) => ({ licenseType: state }), + licenseDetails: (state) => ({ licenseDetails: state }), + }, + }, +})); + +const keys = { + duration: keyStore(duration), + handlers: keyStore(handlers), + hooks: keyStore(hooks), + selectors: hooks.selectorKeys, +}; + +const state = new MockUseState(hooks); +const testValue = 'my-test-value'; +const testValue2 = 'my-test-value-2'; +const testKey = keys.selectors.handout; +const dispatch = jest.fn(val => ({ dispatch: val })); + +let out; + +describe('Video Settings modal hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('state hooks', () => { + state.testGetter(state.keys.videoSource); + state.testGetter(state.keys.fallbackVideos); + state.testGetter(state.keys.allowVideoDownloads); + + state.testGetter(state.keys.thumbnail); + + state.testGetter(state.keys.transcripts); + state.testGetter(state.keys.allowTranscriptDownloads); + state.testGetter(state.keys.showTranscriptByDefault); + + state.testGetter(state.keys.duration); + + state.testGetter(state.keys.handout); + + state.testGetter(state.keys.licenseType); + state.testGetter(state.keys.licenseDetails); + }); + describe('non-state hooks', () => { + beforeEach(() => state.mock()); + afterEach(() => state.restore()); + describe('updatedArray', () => { + it('returns a new array with the given index replaced', () => { + const testArray = ['0', '1', '2', '3', '4']; + const oldArray = [...testArray]; + expect(hooks.updatedArray(testArray, 3, testValue)).toEqual( + ['0', '1', '2', testValue, '4'], + ); + expect(testArray).toEqual(oldArray); + }); + }); + describe('updatedObject', () => { + it('returns a new object with the given index replaced', () => { + const testObj = { some: 'data', [testKey]: testValue }; + const oldObj = { ...testObj }; + expect(hooks.updatedObject(testObj, testKey, testValue2)).toEqual( + { ...testObj, [testKey]: testValue2 }, + ); + expect(testObj).toEqual(oldObj); + }); + }); + describe('updateFormField', () => { + it('returns a memoized callback that is only created once', () => { + expect(hooks.updateFormField({ dispatch, key: testKey }).useCallback.prereqs).toEqual([]); + }); + it('returns memoized callback that dispaches updateField with val on the given key', () => { + hooks.updateFormField({ dispatch, key: testKey }).useCallback.cb(testValue); + expect(dispatch).toHaveBeenCalledWith(actions.video.updateField({ + [testKey]: testValue, + })); + }); + }); + describe('currentValue', () => { + it('returns duration display of form value if is duration key', () => { + expect( + hooks.currentValue({ key: keys.selectors.duration, formValue: testValue }), + ).toEqual(duration.durationValue(testValue)); + }); + it('returns the raw formValue by default', () => { + expect( + hooks.currentValue({ key: testKey, formValue: testValue }), + ).toEqual(testValue); + }); + }); + describe('valueHooks', () => { + let formValue; + beforeEach(() => { + formValue = useSelector(selectors.video[testKey]); + }); + describe('behavior', () => { + describe('initialization', () => { + test('useEffect memoized on formValue', () => { + hooks.valueHooks({ dispatch, key: testKey }); + expect(useEffect).toHaveBeenCalled(); + expect(useEffect.mock.calls[0][1]).toEqual([formValue]); + }); + test('calls setLocal with durationValue(formValue) if is duration', () => { + hooks.valueHooks({ dispatch, key: keys.selectors.duration }); + useEffect.mock.calls[0][0](); + expect(state.setState[keys.selectors.duration]).toHaveBeenCalledWith( + duration.durationValue(useSelector(selectors.video.duration)), + ); + }); + test('calls setLocal with formValue by default', () => { + hooks.valueHooks({ dispatch, key: testKey }); + useEffect.mock.calls[0][0](); + expect(state.setState[testKey]).toHaveBeenCalledWith(formValue); + }); + }); + }); + describe('returned object', () => { + const mockCurrentValue = (args) => ({ currentValue: args }); + const mockUpdateFormField = (args) => jest.fn( + (val) => ({ updateFormField: { args, val } }), + ); + beforeEach(() => { + jest.spyOn(hooks, keys.hooks.currentValue) + .mockImplementationOnce(mockCurrentValue); + jest.spyOn(hooks, keys.hooks.updateFormField) + .mockImplementationOnce(mockUpdateFormField); + out = hooks.valueHooks({ dispatch, key: testKey }); + }); + test('formValue from selectors.video[key]', () => { + expect(out.formValue).toEqual(useSelector(selectors.video[testKey])); + }); + describe('local and setLocal', () => { + test('keyed to state, initialized with memo of currentValue that never updates', () => { + const { local, setLocal } = out; + expect(local.useMemo.cb()).toEqual(mockCurrentValue({ key: testKey, formValue })); + expect(local.useMemo.prereqs).toEqual([]); + setLocal(testValue); + expect(state.setState[testKey]).toHaveBeenCalledWith(testValue); + }); + }); + test('setFormValue forwarded from module', () => { + expect(out.setFormValue(testValue)).toEqual( + mockUpdateFormField({ dispatch, key: testKey })(testValue), + ); + }); + describe('setAll', () => { + it('returns a memoized callback based on setLocal and setFormValue', () => { + expect(out.setAll.useCallback.prereqs).toEqual([out.setLocal, out.setFormValue]); + }); + it('calls setLocal and setFormValue with the passed value', () => { + out.setAll.useCallback.cb(testValue); + expect(out.setLocal).toHaveBeenCalledWith(testValue); + expect(out.setFormValue).toHaveBeenCalledWith(testValue); + }); + }); + }); + }); + describe('genericWidget', () => { + const valueProps = { + formValue: '123', + local: 23, + setLocal: jest.fn(), + setFormValue: jest.fn(), + setAll: jest.fn(), + }; + beforeEach(() => { + jest.spyOn(hooks, keys.hooks.valueHooks).mockReturnValueOnce(valueProps); + out = hooks.genericWidget({ dispatch, key: testKey }); + }); + describe('returned object', () => { + it('forwards formValue and local from valueHooks', () => { + expect(hooks.valueHooks).toHaveBeenCalledWith({ dispatch, key: testKey }); + expect(out.formValue).toEqual(valueProps.formValue); + expect(out.local).toEqual(valueProps.local); + }); + test('setFormValue mapped to valueHooks.setFormValue', () => { + expect(out.setFormValue).toEqual(valueProps.setFormValue); + }); + test('onChange mapped to handlers.onValue(valueHooks.setLocal)', () => { + expect(out.onChange).toEqual(handlers.onValue(valueProps.setLocal)); + }); + test('onCheckedChange mapped to handlers.onChecked(valueHooks.setAll)', () => { + expect(out.onCheckedChange).toEqual(handlers.onChecked(valueProps.setAll)); + }); + test('onBlur mapped to handlers.onValue(valueHooks.setAll)', () => { + expect(out.onBlur).toEqual(handlers.onValue(valueProps.setAll)); + }); + }); + }); + describe('non-generic widgets', () => { + const widgetValues = { + formValue: '123', + local: 23, + setLocal: jest.fn(), + setFormValue: jest.fn(), + setAll: jest.fn(), + }; + let valueHooksSpy; + beforeEach(() => { + valueHooksSpy = jest.spyOn(hooks, keys.hooks.valueHooks).mockReturnValue(widgetValues); + }); + afterEach(() => { + valueHooksSpy.mockRestore(); + }); + describe('arrayWidget', () => { + const mockUpdatedArray = (...args) => ({ updatedArray: args }); + let arraySpy; + beforeEach(() => { + arraySpy = jest.spyOn(hooks, keys.hooks.updatedArray) + .mockImplementation(mockUpdatedArray); + out = hooks.arrayWidget({ dispatch, key: testKey }); + }); + afterEach(() => { + arraySpy.mockRestore(); + }); + it('forwards widget values', () => { + expect(out.formValue).toEqual(widgetValues.formValue); + expect(out.local).toEqual(widgetValues.local); + }); + it('overrides onChange with handleIndexTransformEvent', () => { + expect(out.onChange).toEqual(handlers.handleIndexTransformEvent({ + handler: handlers.onValue, + setter: widgetValues.setLocal, + transform: arraySpy, + local: widgetValues.local, + })); + }); + it('overrides onBlur with handleIndexTransformEvent', () => { + expect(out.onBlur).toEqual(handlers.handleIndexTransformEvent({ + handler: handlers.onValue, + setter: widgetValues.setAll, + transform: arraySpy, + local: widgetValues.local, + })); + }); + it('adds onClear event that calls setAll with empty string', () => { + out.onClear(testKey)(); + expect(widgetValues.setAll).toHaveBeenCalledWith( + arraySpy(widgetValues.local, testKey, ''), + ); + }); + }); + describe('objectWidget', () => { + beforeEach(() => { + out = hooks.objectWidget({ dispatch, key: testKey }); + }); + it('forwards widget values', () => { + expect(out.formValue).toEqual(widgetValues.formValue); + expect(out.local).toEqual(widgetValues.local); + }); + it('overrides onChange with handleIndexTransformEvent', () => { + expect(out.onChange).toEqual(handlers.handleIndexTransformEvent({ + handler: handlers.onValue, + setter: widgetValues.setLocal, + transform: hooks.updatedObject, + local: widgetValues.local, + })); + }); + it('overrides onBlur with handleIndexTransformEvent', () => { + expect(out.onBlur).toEqual(handlers.handleIndexTransformEvent({ + handler: handlers.onValue, + setter: widgetValues.setAll, + transform: hooks.updatedObject, + local: widgetValues.local, + })); + }); + }); + describe('durationWidget', () => { + beforeEach(() => { + out = hooks.durationWidget({ dispatch }); + }); + it('forwards widget values', () => { + expect(out.formValue).toEqual(widgetValues.formValue); + expect(out.local).toEqual(widgetValues.local); + }); + describe('onBlur', () => { + test('memoized callback based on formValue, local, and setFormValue from widget', () => { + expect(out.onBlur.useCallback.prereqs).toEqual( + [widgetValues.formValue, widgetValues.local, widgetValues.setFormField], + ); + }); + test('calls handleIndexEvent with updateDuration', () => { + expect(out.onBlur.useCallback.cb).toEqual( + handlers.handleIndexEvent({ + handler: handlers.onValue, + transform: duration.updateDuration(widgetValues), + }), + ); + }); + }); + describe('onChange', () => { + test('memoized callback based on local from widget', () => { + expect(out.onChange.useCallback.prereqs).toEqual([widgetValues.local]); + }); + test('calls handleIndexTransformEvent with setLocal', () => { + expect(out.onChange.useCallback.cb).toEqual( + handlers.handleIndexTransformEvent({ + handler: handlers.onValue, + setter: widgetValues.setLocal, + transform: hooks.updatedObject, + local: widgetValues.local, + }), + ); + }); + }); + }); + }); + describe('widgetValues', () => { + describe('returned object', () => { + test('shaped to the fields object, where each value is called with key and dispatch', () => { + const testKeys = ['1', '24', '23gsa']; + const fieldMethods = [ + jest.fn(v => ({ v1: v })), + jest.fn(v => ({ v2: v })), + jest.fn(v => ({ v3: v })), + ]; + const fields = testKeys.reduce((obj, key, index) => ({ + ...obj, + [key]: fieldMethods[index], + }), {}); + const expected = testKeys.reduce((obj, key, index) => ({ + ...obj, + [key]: fieldMethods[index]({ key, dispatch }), + }), {}); + expect(hooks.widgetValues({ fields, dispatch })).toMatchObject(expected); + }); + }); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/index.jsx b/src/editors/containers/VideoEditor/index.jsx index d85dddf6c..c15c48ca0 100644 --- a/src/editors/containers/VideoEditor/index.jsx +++ b/src/editors/containers/VideoEditor/index.jsx @@ -8,7 +8,10 @@ export default function VideoEditor({ onClose, }) { return ( - + ({})} + >
diff --git a/src/editors/data/constants/video.js b/src/editors/data/constants/video.js new file mode 100644 index 000000000..69fd7063f --- /dev/null +++ b/src/editors/data/constants/video.js @@ -0,0 +1,10 @@ +import { StrictDict } from '../../utils'; + +export const timeKeys = StrictDict({ + startTime: 'startTime', + stopTime: 'stopTime', +}); + +export default { + timeKeys, +}; diff --git a/src/editors/data/redux/video/reducer.js b/src/editors/data/redux/video/reducer.js index 4725fd85a..09a21f1d5 100644 --- a/src/editors/data/redux/video/reducer.js +++ b/src/editors/data/redux/video/reducer.js @@ -3,16 +3,19 @@ import { createSlice } from '@reduxjs/toolkit'; import { StrictDict } from '../../../utils'; const initialState = { - videoSource: null, - fallbackVideos: [], + videoSource: '', + fallbackVideos: [ + '', + '', + ], allowVideoDownloads: false, thumbnail: null, transcripts: {}, allowTranscriptDownloads: false, duration: { - startTime: null, - stopTime: null, - total: null, + startTime: '00:00:00', + stopTime: '00:00:00', + total: '00:00:00', }, showTranscriptByDefault: false, handout: null, @@ -30,6 +33,10 @@ const video = createSlice({ name: 'video', initialState, reducers: { + updateField: (state, { payload }) => ({ + ...state, + ...payload, + }), load: (state, { payload }) => ({ ...payload, }), diff --git a/src/editors/data/services/cms/mockVideoData.js b/src/editors/data/services/cms/mockVideoData.js index 6e8ee5c9b..20b3d8aaf 100644 --- a/src/editors/data/services/cms/mockVideoData.js +++ b/src/editors/data/services/cms/mockVideoData.js @@ -36,10 +36,10 @@ export const singleVideoData = { english: 'my-transcript-url', }, allowTranscriptDownloads: false, - duration: { // in ms + duration: { startTime: 0, - endTime: 0, - total: 5000, + stopTime: 0, + total: 0, }, showTranscriptByDefault: false, handout: 'my-handout-url',