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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,10 @@ export default function VideoEditor({
|
||||
onClose,
|
||||
}) {
|
||||
return (
|
||||
<EditorContainer onClose={onClose}>
|
||||
<EditorContainer
|
||||
onClose={onClose}
|
||||
getContent={() => ({})}
|
||||
>
|
||||
<div className="video-editor">
|
||||
<VideoEditorModal />
|
||||
</div>
|
||||
|
||||
10
src/editors/data/constants/video.js
Normal file
10
src/editors/data/constants/video.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictDict } from '../../utils';
|
||||
|
||||
export const timeKeys = StrictDict({
|
||||
startTime: 'startTime',
|
||||
stopTime: 'stopTime',
|
||||
});
|
||||
|
||||
export default {
|
||||
timeKeys,
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user