- Video Source Widget
- Video Source: {source.formValue}
- Fallback Videos: {fallbackVideos.formValue.join(', ')}
- Video Source: {allowDownload.formValue ? 'True' : 'False'}
+
+
+ Video ID or URL
+
+
+ Fallback videos
+
+ {`
+ To be sure all learners can access the video, edX
+ recommends providing additional videos in both .mp4 and
+ .webm formats. The first listed video compatible with the
+ learner's device will play.
+ `}
+
+ {[0, 1].map((index) => (
+
+
+
+
+ ))}
+
+
);
};
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js
new file mode 100644
index 000000000..b55bdf87a
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js
@@ -0,0 +1,81 @@
+import { useCallback } from 'react';
+import * as module from './duration';
+
+const durationMatcher = /^(\d+)?:?(\d+)?:?(\d+)?$/i;
+
+/**
+ * durationFromValue(value)
+ * Returns a duration string in 'hh:mm:ss' format from the given ms value
+ * @param {number} value - duration (in milliseconds)
+ * @return {string} - duration in 'hh:mm:ss' format
+ */
+export const durationFromValue = (value) => {
+ const seconds = Math.floor((value / 1000) % 60);
+ const minutes = Math.floor((value / 60000) % 60);
+ const hours = Math.floor((value / 3600000) % 24);
+ const zeroPad = (num) => String(num).padStart(2, '0');
+ return [hours, minutes, seconds].map(zeroPad).join(':');
+};
+
+/**
+ * valueFromDuration(duration)
+ * Returns a millisecond duration value from the given 'hh:mm:ss' format string
+ * @param {string} duration - duration in 'hh:mm:ss' format
+ * @return {number} - duration in milliseconds. Returns null if duration is invalid.
+ */
+export const valueFromDuration = (duration) => {
+ let matches = duration.trim().match(durationMatcher);
+ if (!matches) {
+ return null;
+ }
+ matches = matches.slice(1).filter(v => v !== undefined);
+ if (matches.length < 3) {
+ for (let i = 0; i <= 3 - matches.length; i++) {
+ matches.unshift(0);
+ }
+ }
+ const [hours, minutes, seconds] = matches.map(x => parseInt(x, 10) || 0);
+ return ((hours * 60 + minutes) * 60 + seconds) * 1000;
+};
+
+/**
+ * durationValue(duration)
+ * Returns the display value for embedded start and stop times
+ * @param {object} duration - object containing startTime and stopTime millisecond values
+ * @return {object} - start and stop time from incoming object mapped to duration strings.
+ */
+export const durationValue = (duration) => ({
+ startTime: module.durationFromValue(duration.startTime),
+ stopTime: module.durationFromValue(duration.stopTime),
+});
+
+/**
+ * updateDuration({ formValue, local, setLocal, setFormValue })
+ * Returns a memoized callback based on inputs that updates local value and form value
+ * if the new string is valid (formValue stores a number, local stores a string).
+ * If the duration string is invalid, resets the local value to the latest good value.
+ * @param {object} formValue - redux-stored durations in milliseconds
+ * @param {object} local - hook-stored duration in 'hh:mm:ss' format
+ * @param {func} setFormValue - set form value
+ * @param {func} setLocal - set local object
+ * @return {func} - callback to update duration locally and in redux
+ * updateDuration(args)(index, durationString)
+ */
+export const updateDuration = ({
+ formValue,
+ local,
+ setFormValue,
+ setLocal,
+}) => useCallback(
+ (index, durationString) => {
+ const newValue = module.valueFromDuration(durationString);
+ if (newValue !== null) {
+ setLocal({ ...local, [index]: durationString });
+ setFormValue({ ...formValue, [index]: newValue });
+ } else {
+ // If invalid duration string, reset to last valid value
+ setLocal({ ...local, [index]: module.durationFromValue(formValue[index]) });
+ }
+ },
+ [formValue, local, setLocal, setFormValue],
+);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js
new file mode 100644
index 000000000..13affb448
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js
@@ -0,0 +1,134 @@
+import { keyStore } from '../../../../../utils';
+import * as duration from './duration';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useCallback: (cb, prereqs) => ({ useCallback: { cb, prereqs } }),
+}));
+
+let hook;
+const durationKeys = keyStore(duration);
+const [h, m, s] = [3600000, 60000, 1000];
+const durationPairs = [
+ [0, '00:00:00'],
+ [5000, '00:00:05'],
+ [60000, '00:01:00'],
+ [3600000, '01:00:00'],
+ [3665000, '01:01:05'],
+];
+const trickyDurations = [
+ ['10:00', 600000],
+ ['23', 23000],
+ ['100:100:100', 100 * (m + s + h)],
+ ['23:42:781', 23 * h + 42 * m + 781 * s],
+];
+let spies = {};
+let props;
+let cb;
+let prereqs;
+let oldDuration;
+describe('Video Settings Modal duration hooks', () => {
+ beforeEach(() => {
+ spies = {};
+ oldDuration = { ...jest.requireActual('./duration') };
+ });
+ afterEach(() => {
+ Object.keys(oldDuration).forEach((key) => {
+ duration[key] = oldDuration[key];
+ });
+ Object.keys(spies).forEach((key) => {
+ spies[key].mockRestore();
+ });
+ });
+
+ describe('durationFromValue', () => {
+ it('translates milliseconds into hh:mm:ss format', () => {
+ durationPairs.forEach(
+ ([val, dur]) => expect(duration.durationFromValue(val)).toEqual(dur),
+ );
+ });
+ });
+ describe('valueFromDuration', () => {
+ beforeEach(() => {
+ hook = duration.valueFromDuration;
+ });
+ it('returns null if given a bad duration string', () => {
+ const badChecks = ['a', '00:00:1f', '0adg:00:04'];
+ badChecks.forEach(dur => expect(hook(dur)).toEqual(null));
+ });
+ it('returns simple durations', () => {
+ durationPairs.forEach(([val, dur]) => expect(hook(dur)).toEqual(val));
+ });
+ it('returns tricky durations, prepending zeros and expanding out sections', () => {
+ trickyDurations.forEach(([dur, val]) => expect(hook(dur)).toEqual(val));
+ });
+ });
+ describe('durationValue', () => {
+ const mock = jest.fn(v => ({ duration: v }));
+ beforeEach(() => {
+ jest.spyOn(duration, durationKeys.durationFromValue).mockImplementation(mock);
+ });
+ it('returns an object that maps durationFromValue to the passed duration keys', () => {
+ const testDuration = { startTime: 1, stopTime: 2, other: 'values' };
+ expect(duration.durationValue(testDuration)).toEqual({
+ startTime: mock(testDuration.startTime),
+ stopTime: mock(testDuration.stopTime),
+ });
+ });
+ });
+ describe('updateDuration', () => {
+ const testDuration = 'myDuration';
+ const testIndex = 'startTime';
+ const mockValueFromDuration = (dur) => ({ value: dur });
+ const mockDurationFromValue = (value) => ({ duration: value });
+ beforeEach(() => {
+ props = {
+ formValue: { startTime: 230000, stopTime: 0 },
+ local: { startTime: '00:00:23', stopTime: '00:00:00' },
+ setLocal: jest.fn(),
+ setFormValue: jest.fn(),
+ };
+ spies.valueFromDuration = jest.spyOn(duration, durationKeys.valueFromDuration)
+ .mockImplementation(mockValueFromDuration);
+ hook = duration.updateDuration;
+ ({ cb, prereqs } = hook(props).useCallback);
+ });
+ it('returns a useCallback field based on the passed args', () => {
+ expect(prereqs).toEqual([
+ props.formValue,
+ props.local,
+ props.setLocal,
+ props.setFormValue,
+ ]);
+ });
+ describe('callback', () => {
+ describe('if the passed durationString is valid', () => {
+ it('sets the local value to updated strings and form value to new timestamp value', () => {
+ cb(testIndex, testDuration);
+ expect(duration.valueFromDuration).toHaveBeenCalledWith(testDuration);
+ expect(props.setLocal).toHaveBeenCalledWith({
+ ...props.local,
+ [testIndex]: testDuration,
+ });
+ expect(props.setFormValue).toHaveBeenCalledWith({
+ ...props.formValue,
+ [testIndex]: mockValueFromDuration(testDuration),
+ });
+ });
+ });
+ describe('if the passed durationString is not valid', () => {
+ it('updates local back to the string for the form-stored timestamp value', () => {
+ spies.valueFromDuration.mockReturnValue(null);
+ spies.durationFromValue = jest.spyOn(duration, durationKeys.durationFromValue)
+ .mockImplementationOnce(mockDurationFromValue);
+ hook(props).useCallback.cb(testIndex, testDuration);
+ expect(props.setLocal).toHaveBeenCalledWith({
+ ...props.local,
+ [testIndex]: mockDurationFromValue(props.formValue[testIndex]),
+ });
+ expect(props.setFormValue).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js
new file mode 100644
index 000000000..7386d50ab
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js
@@ -0,0 +1,48 @@
+/**
+ * handleIndexEvent({ handler, transform })
+ * return a method that takes an index and returns an event handler of the given type
+ * that calls a transform with the given index and the incoming value.
+ * @param {func} handler - event handler (onValue, onChecked, etc)
+ * @param {func} transform - transform method taking an index and a new value
+ * @return {func} - event handler creator for index-tied values
+ */
+export const handleIndexEvent = ({ handler, transform }) => (index) => (
+ handler(val => transform(index, val))
+);
+
+/**
+ * handleIndexTransformEvent({ handler, setter, local, transform })
+ * return a method that takes an index and returns an event handler of the given type
+ * that calls a transform with the given index and the incoming value.
+ * @param {func} handler - event handler (onValue, onChecked, etc)
+ * @param {string|number|object} local - local hook values
+ * @param {func} setter - method that updates models based on event
+ * @param {func} transform - transform method taking an index and a new value
+ * @return {func} - event handler creator for index-tied values with separate setter and transforms
+ */
+export const handleIndexTransformEvent = ({
+ handler,
+ local,
+ setter,
+ transform,
+}) => (index) => (
+ handler(val => setter(transform(local, index, val)))
+);
+
+/**
+ * onValue(handler)
+ * returns an event handler that calls the given method with the event target value
+ * Intended for most basic input types.
+ * @param {func} handler - callback to receive the event value
+ * @return - event handler that calls passed handler with event.target.value
+ */
+export const onValue = (handler) => (e) => handler(e.target.value);
+
+/**
+ * onValue(handler)
+ * returns an event handler that calls the given method with the event target value
+ * Intended for checkbox input types.
+ * @param {func} handler - callback to receive the event value
+ * @return - event handler that calls passed handler with event.target.value
+ */
+export const onChecked = (handler) => (e) => handler(e.target.checked);
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js
new file mode 100644
index 000000000..208b581d0
--- /dev/null
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js
@@ -0,0 +1,50 @@
+import * as handlers from './handlers';
+
+const handler = jest.fn(cb => ({ handler: cb }));
+const transform = jest.fn((...args) => ({ transform: args }));
+const setter = jest.fn(val => ({ setter: val }));
+const index = 'test-index';
+const val = 'TEST value';
+const local = 'local-test-value';
+describe('Video Settings Modal event handler methods', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('handleIndexEvent', () => {
+ describe('returned method', () => {
+ it('takes index and calls handler with transform handler based on index', () => {
+ expect(
+ handlers.handleIndexEvent({ handler, transform })(index).handler(val),
+ ).toEqual(transform(index, val));
+ });
+ });
+ });
+ describe('handleIndexTransformEvent', () => {
+ describe('returned method', () => {
+ it('takes index and calls handler with setter(transform(local, index, val))', () => {
+ expect(
+ handlers.handleIndexTransformEvent({
+ handler,
+ setter,
+ local,
+ transform,
+ })(index).handler(val),
+ ).toEqual(setter(transform(local, index, val)));
+ });
+ });
+ });
+ describe('onValue', () => {
+ describe('returned method', () => {
+ it('calls handler with event.target.value', () => {
+ expect(handlers.onValue(handler)({ target: { value: val } })).toEqual(handler(val));
+ });
+ });
+ });
+ describe('onChecked', () => {
+ describe('returned method', () => {
+ it('calls handler with event.target.checked', () => {
+ expect(handlers.onChecked(handler)({ target: { checked: val } })).toEqual(handler(val));
+ });
+ });
+ });
+});
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js
index 6a9b5f0fa..6b961a4a0 100644
--- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js
+++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js
@@ -1,16 +1,333 @@
+import {
+ useCallback,
+ useState,
+ useEffect,
+ useMemo,
+} from 'react';
import { useSelector } from 'react-redux';
-import { keyStore } from '../../../../../utils';
+import { StrictDict, keyStore } from '../../../../../utils';
import { actions, selectors } from '../../../../../data/redux';
+import {
+ updateDuration,
+ durationValue,
+} from './duration';
+
+import {
+ handleIndexEvent,
+ handleIndexTransformEvent,
+ onValue,
+ onChecked,
+} from './handlers';
+import * as module from './hooks';
+
export const selectorKeys = keyStore(selectors.video);
-export const widgetValue = (key, dispatch) => ({
- formValue: useSelector(selectors.video[key]),
- setFormValue: (val) => dispatch(actions.video.load({ [key]: val })),
-});
+export const state = StrictDict(
+ [
+ selectorKeys.videoSource,
+ selectorKeys.fallbackVideos,
+ selectorKeys.allowVideoDownloads,
+
+ selectorKeys.thumbnail,
+
+ selectorKeys.transcripts,
+ selectorKeys.allowTranscriptDownloads,
+ selectorKeys.showTranscriptByDefault,
+
+ selectorKeys.duration,
+
+ selectorKeys.handout,
+
+ selectorKeys.licenseType,
+ selectorKeys.licenseDetails,
+ ].reduce(
+ (obj, key) => ({ ...obj, [key]: (val) => useState(val) }),
+ {},
+ ),
+);
+
+/**
+ * updateArray(array, index, val)
+ * Returns a new array with the element at