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>
This commit is contained in:
Ben Warzeski
2022-05-09 14:23:21 -04:00
committed by GitHub
parent 964f00e563
commit 564b953860
17 changed files with 1192 additions and 39 deletions

View File

@@ -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
* <CollapsibleFormWidget title={<h1>My Title</h1>}>
* <div>My Widget</div>
* </CollapsibleFormWidget>
*/
export const CollapsibleFormWidget = ({
title,
children,

View File

@@ -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 (
<CollapsibleFormWidget title="Duration">
<div>Duration Widget</div>
<p>Start: {duration.formValue.startTime}</p>
<p>Stop: {duration.formValue.stopTime}</p>
<p>Total: {duration.formValue.total}</p>
<FormGroup size="sm">
<div>
<FormControl
className="d-inline-block"
floatingLabel="Start time"
value={duration.local.startTime}
onBlur={duration.onBlur(timeKeys.startTime)}
onChange={duration.onChange(timeKeys.startTime)}
/>
<FormControl
className="d-inline-block"
floatingLabel="Stop time"
value={duration.local.stopTime}
onBlur={duration.onBlur(timeKeys.stopTime)}
onChange={duration.onChange(timeKeys.stopTime)}
/>
</div>
<div className="mt-4">
Total: {duration.formValue.total}
</div>
</FormGroup>
</CollapsibleFormWidget>
);
};

View File

@@ -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 (
<CollapsibleFormWidget title="Handout">
<p>{handout.formValue}</p>

View File

@@ -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 (
<CollapsibleFormWidget title="License">
<div>License Widget</div>

View File

@@ -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 (
<CollapsibleFormWidget title="Thumbnail">
<p>{thumbnail.formValue}</p>

View File

@@ -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 (
<CollapsibleFormWidget title="Transcript">
<b>Transcripts</b>

View File

@@ -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 (
<CollapsibleFormWidget title="Video source">
<div>Video Source Widget</div>
<p><b>Video Source:</b> {source.formValue}</p>
<p><b>Fallback Videos:</b> {fallbackVideos.formValue.join(', ')}</p>
<p><b>Video Source:</b> {allowDownload.formValue ? 'True' : 'False'}</p>
<FormGroup size="sm">
<div className="border-primary-100 border-bottom pb-4">
<FormLabel size="sm">Video ID or URL</FormLabel>
<FormControl
onChange={source.onChange}
onBlur={source.onBlur}
value={source.local}
/>
</div>
<FormLabel>Fallback videos</FormLabel>
<FormLabel>
{`
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.
`}
</FormLabel>
{[0, 1].map((index) => (
<div className="mb-1">
<FormControl
className="d-inline-block"
style={{ width: '260px' }}
onChange={fallbackVideos.onChange(index)}
value={fallbackVideos.local[index]}
onBlur={fallbackVideos.onBlur(index)}
/>
<IconButton
className="d-inline-block"
iconAs={Icon}
src={Delete}
alt="Clear fallback video"
onClick={fallbackVideos.onClear(index)}
/>
</div>
))}
<FormCheck
checked={allowDownload.local}
onChange={allowDownload.onCheckedChange}
label="Alow video downloads"
/>
</FormGroup>
</CollapsibleFormWidget>
);
};

View File

@@ -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],
);

View File

@@ -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();
});
});
});
});
});

View File

@@ -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);

View File

@@ -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));
});
});
});
});

View File

@@ -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 <index> replaced with <val>
* @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 <index> replaced with <val>
* @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} - { <key>: <widgetFn({ key, dispatch })> }
*/
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,
};

View File

@@ -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);
});
});
});
});
});

View File

@@ -8,7 +8,10 @@ export default function VideoEditor({
onClose,
}) {
return (
<EditorContainer onClose={onClose}>
<EditorContainer
onClose={onClose}
getContent={() => ({})}
>
<div className="video-editor">
<VideoEditorModal />
</div>

View File

@@ -0,0 +1,10 @@
import { StrictDict } from '../../utils';
export const timeKeys = StrictDict({
startTime: 'startTime',
stopTime: 'stopTime',
});
export default {
timeKeys,
};

View File

@@ -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,
}),

View File

@@ -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',