From e66863795c27f564ad40214960dca29fd4807a29 Mon Sep 17 00:00:00 2001 From: rayzhou-bit Date: Wed, 11 Jan 2023 20:14:03 -0500 Subject: [PATCH] feat: tests argh --- .../__snapshots__/index.test.jsx.snap} | 0 .../DurationWidget/duration.test.js | 216 ------------ .../components/DurationWidget/hooks.js | 141 ++++---- .../components/DurationWidget/hooks.test.js | 328 ++++++++++++++++++ .../components/DurationWidget/index.jsx | 38 +- .../VideoSettingsModal/components/hooks.js | 92 +---- .../components/hooks.test.js | 91 +---- src/editors/data/services/cms/api.js | 6 +- 8 files changed, 430 insertions(+), 482 deletions(-) rename src/editors/containers/VideoEditor/components/VideoSettingsModal/components/{__snapshots__/DurationWidget.test.jsx.snap => DurationWidget/__snapshots__/index.test.jsx.snap} (100%) delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/duration.test.js create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/__snapshots__/index.test.jsx.snap similarity index 100% rename from src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap rename to src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/__snapshots__/index.test.jsx.snap diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/duration.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/duration.test.js deleted file mode 100644 index d02dfe43c..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/duration.test.js +++ /dev/null @@ -1,216 +0,0 @@ -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], - ['99:99:99', 99 * (m + s + h)], - ['23:42:81', 23 * h + 42 * m + 81 * 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('onDurationChange', () => { - beforeEach(() => { - props = { - duration: { startTime: '00:00:00' }, - index: 'startTime', - val: 'vAl', - }; - hook = duration.onDurationChange; - }); - it('returns duration with no change if duration[index] does not match HH:MM:SS format', () => { - const badChecks = [ - 'ab:cd:ef', // non-digit characters - '12:34:567', // characters past max length - ]; - badChecks.forEach(val => expect(hook(props.duration, props.index, val)).toEqual(props.duration)); - }); - it('returns duration with an added \':\' after 2 characters when caret is at end', () => { - props.duration = { startTime: '0' }; - props.val = '00'; - document.activeElement.selectionStart = props.duration[props.index].length + 1; - expect(hook(props.duration, props.index, props.val)).toEqual({ startTime: '00:' }); - }); - it('returns duration with an added \':\' after 5 characters when caret is at end', () => { - props.duration = { startTime: '00:0' }; - props.val = '00:00'; - document.activeElement.selectionStart = props.duration[props.index].length + 1; - expect(hook(props.duration, props.index, props.val)).toEqual({ startTime: '00:00:' }); - }); - }); - describe('onDurationKeyDown', () => { - beforeEach(() => { - props = { - duration: { startTime: '00:00:00' }, - index: 'startTime', - event: 'eVeNt', - }; - hook = duration.onDurationKeyDown; - }); - it('enter event: calls blur()', () => { - props.event = { key: 'Enter' }; - const blurSpy = jest.spyOn(document.activeElement, 'blur'); - hook(props.duration, props.index, props.event); - expect(blurSpy).toHaveBeenCalled(); - }); - it('backspace event: returns duration with deleted end character when that character is \':\' and caret is at end', () => { - props.duration = { startTime: '00:' }; - props.event = { key: 'Backspace' }; - document.activeElement.selectionStart = props.duration[props.index].length; - expect(hook(props.duration, props.index, props.event)).toEqual({ startTime: '00' }); - }); - }); - describe('durationFromValue', () => { - beforeEach(() => { - hook = duration.durationFromValue; - }); - it('returns 00:00:00 if given a bad value', () => { - const badChecks = ['a', '', null, -1]; - badChecks.forEach(val => expect(hook(val)).toEqual('00:00:00')); - }); - it('translates milliseconds into hh:mm:ss format', () => { - durationPairs.forEach( - ([val, dur]) => expect(hook(val)).toEqual(dur), - ); - }); - }); - describe('valueFromDuration', () => { - beforeEach(() => { - hook = duration.valueFromDuration; - }); - it('returns 0 if given a bad duration string', () => { - const badChecks = ['a', '00:00:1f', '0adg:00:04']; - badChecks.forEach(dur => expect(hook(dur)).toEqual(0)); - }); - 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 testValidIndex = 'startTime'; - const testStopIndex = 'stopTime'; - const testValidDuration = '00:00:00'; - const testValidValue = 0; - const testInvalidDuration = 'abc'; - beforeEach(() => { - props = { - formValue: { startTime: 23000, stopTime: 600000 }, - local: { startTime: '00:00:23', stopTime: '00:10:00' }, - setLocal: jest.fn(), - setFormValue: jest.fn(), - }; - 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(testValidIndex, testValidDuration); - expect(props.setLocal).toHaveBeenCalledWith({ - ...props.local, - [testValidIndex]: testValidDuration, - }); - expect(props.setFormValue).toHaveBeenCalledWith({ - ...props.formValue, - [testValidIndex]: testValidValue, - }); - }); - }); - describe('if the passed durationString is not valid', () => { - it('updates local values to 0 (the default)', () => { - hook(props).useCallback.cb(testValidIndex, testInvalidDuration); - expect(props.setLocal).toHaveBeenCalledWith({ - ...props.local, - [testValidIndex]: testValidDuration, - }); - expect(props.setFormValue).toHaveBeenCalledWith({ - ...props.formValue, - [testValidIndex]: testValidValue, - }); - }); - }); - describe('if the passed startTime is after (or equal to) the stored non-zero stopTime', () => { - it('updates local startTime values to 1 second before stopTime', () => { - hook(props).useCallback.cb(testValidIndex, '00:10:00'); - expect(props.setLocal).toHaveBeenCalledWith({ - ...props.local, - [testValidIndex]: '00:09:59', - }); - expect(props.setFormValue).toHaveBeenCalledWith({ - ...props.formValue, - [testValidIndex]: 599000, - }); - }); - }); - describe('if the passed stopTime is before (or equal to) the stored startTime', () => { - it('updates local stopTime values to 1 second after startTime', () => { - hook(props).useCallback.cb(testStopIndex, '00:00:22'); - expect(props.setLocal).toHaveBeenCalledWith({ - ...props.local, - [testStopIndex]: '00:00:24', - }); - expect(props.setFormValue).toHaveBeenCalledWith({ - ...props.formValue, - [testStopIndex]: 24000, - }); - }); - }); - }); - }); -}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js index 060a7b6e6..02707b0b6 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js @@ -2,63 +2,79 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { actions, selectors } from '../../../../../../data/redux'; +import messages from '../messages'; import * as module from './hooks'; const durationMatcher = /^(\d{0,2}):?(\d{0,2})?:?(\d{0,2})?$/i; -const MAXTIME = 86399000; -const MINTIME = 1000; export const durationWidget = ({ dispatch }) => { - const formValue = useSelector(selectors.video.duration); - const setFormValue = (val) => dispatch(actions.video.updateField({ duration: val })); - const initialState = durationValue(formValue); - const [local, setLocal] = useState(initialState); + const reduxStartStopTimes = useSelector(selectors.video.duration); + const setReduxStartStopTimes = (val) => dispatch(actions.video.updateField({ duration: val })); + const initialState = module.durationString(reduxStartStopTimes); + const [unsavedStartStopTimes, setUnsavedStartStopTimes] = useState(initialState); useEffect(() => { - setLocal(durationValue(formValue)) - }, [formValue]); + setUnsavedStartStopTimes(module.durationString(reduxStartStopTimes)); + }, [reduxStartStopTimes]); return { - formValue, - local, + reduxStartStopTimes, + unsavedStartStopTimes, onBlur: (index) => ( (e) => module.updateDuration({ - formValue, - setFormValue, - local, - setLocal, + reduxStartStopTimes, + setReduxStartStopTimes, + unsavedStartStopTimes, + setUnsavedStartStopTimes, index, durationString: e.target.value, }) ), onChange: (index) => ( - (e) => setLocal(module.onDurationChange(local, index, e.target.value)) + (e) => setUnsavedStartStopTimes(module.onDurationChange(unsavedStartStopTimes, index, e.target.value)) ), onKeyDown: (index) => ( - (e) => setLocal(module.onDurationKeyDown(local, index, e)) + (e) => setUnsavedStartStopTimes(module.onDurationKeyDown(unsavedStartStopTimes, index, e)) ), + getTotalLabel: ({ duration, subtitle, intl }) => { + if (!duration.stopTime) { + if (!duration.startTime) { + return intl.formatMessage(messages.fullVideoLength); + } + if (subtitle) { + return intl.formatMessage( + messages.startsAt, + { startTime: module.durationStringFromValue(duration.startTime) }, + ); + } + return null; + } + const total = duration.stopTime - (duration.startTime || 0); + return intl.formatMessage(messages.total, { total: module.durationStringFromValue(total) }); + }, }; }; /** - * durationValue(duration) + * durationString(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), +export const durationString = (duration) => ({ + startTime: module.durationStringFromValue(duration.startTime), + stopTime: module.durationStringFromValue(duration.stopTime), }); /** - * durationFromValue(value) + * durationStringFromValue(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) => { +export const durationStringFromValue = (value) => { + // return 'why'; if (!value || typeof value !== 'number' || value <= 0) { return '00:00:00'; } @@ -70,48 +86,49 @@ export const durationFromValue = (value) => { }; /** - * 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({ reduxStartStopTimes, unsavedStartStopTimes, setUnsavedStartStopTimes, setReduxStartStopTimes }) + * Returns a memoized callback based on inputs that updates unsavedStartStopTimes value and form value + * if the new string is valid (reduxStartStopTimes stores a number, unsavedStartStopTimes stores a string). + * If the duration string is invalid, resets the unsavedStartStopTimes value to the latest good value. + * @param {object} reduxStartStopTimes - redux-stored durations in milliseconds + * @param {object} unsavedStartStopTimes - hook-stored duration in 'hh:mm:ss' format + * @param {func} setReduxStartStopTimes - set form value + * @param {func} setUnsavedStartStopTimes - set unsavedStartStopTimes object + * @param {string} index - startTime or stopTime + * @return {func} - callback to update duration unsavedStartStopTimesly and in redux * updateDuration(args)(index, durationString) */ export const updateDuration = ({ - formValue, - local, - setFormValue, - setLocal, + reduxStartStopTimes, + unsavedStartStopTimes, + setReduxStartStopTimes, + setUnsavedStartStopTimes, index, - durationString, + inputString, }) => { - let newDurationString = durationString; - let newValue = module.valueFromDuration(newDurationString); - // maxTime is 23:59:59 or 86399 seconds - if (newValue > MAXTIME) { - newValue = MAXTIME; - } - // stopTime must be at least 1 second, if not zero - if (index === 'stopTime' && newValue > 0 && newValue < MINTIME) { - newValue = MINTIME; - } - // stopTime must be at least 1 second after startTime, except 0 means no custom stopTime - if (index === 'stopTime' && newValue > 0 && newValue < (formValue.startTime + MINTIME)) { - newValue = formValue.startTime + MINTIME; - } - // startTime must be at least 1 second before stopTime, except when stopTime is less than a second - // (stopTime should only be less than a second if it's zero, but we're being paranoid) - if (index === 'startTime' && formValue.stopTime >= MINTIME && newValue > (formValue.stopTime - MINTIME)) { - newValue = formValue.stopTime - MINTIME; - } - newDurationString = module.durationFromValue(newValue); - setLocal({ ...local, [index]: newDurationString }); - setFormValue({ ...formValue, [index]: newValue }); - }; + let newDurationString = inputString; + let newValue = module.valueFromDuration(newDurationString); + // maxTime is 23:59:59 or 86399 seconds + if (newValue > 86399000) { + newValue = 86399000; + } + // stopTime must be at least 1 second, if not zero + if (index === 'stopTime' && newValue > 0 && newValue < 1000) { + newValue = 1000; + } + // stopTime must be at least 1 second after startTime, except 0 means no custom stopTime + if (index === 'stopTime' && newValue > 0 && newValue < (reduxStartStopTimes.startTime + 1000)) { + newValue = reduxStartStopTimes.startTime + 1000; + } + // startTime must be at least 1 second before stopTime, except when stopTime is less than a second + // (stopTime should only be less than a second if it's zero, but we're being paranoid) + if (index === 'startTime' && reduxStartStopTimes.stopTime >= 1000 && newValue > (reduxStartStopTimes.stopTime - 1000)) { + newValue = reduxStartStopTimes.stopTime - 1000; + } + newDurationString = module.durationStringFromValue(newValue); + setUnsavedStartStopTimes({ ...unsavedStartStopTimes, [index]: newDurationString }); + setReduxStartStopTimes({ ...reduxStartStopTimes, [index]: newValue }); +}; /** * onDurationChange(duration) @@ -121,7 +138,7 @@ export const updateDuration = ({ * @param {string} val - duration in 'hh:mm:ss' format * @return {object} duration - object containing startTime and stopTime millisecond values */ - export const onDurationChange = (duration, index, val) => { +export const onDurationChange = (duration, index, val) => { const match = val.trim().match(durationMatcher); if (!match) { return duration; @@ -193,8 +210,8 @@ export const valueFromDuration = (duration) => { export default { durationWidget, - durationValue, - durationFromValue, + durationString, + durationStringFromValue, updateDuration, onDurationChange, onDurationKeyDown, diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js new file mode 100644 index 000000000..2707497b8 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js @@ -0,0 +1,328 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { selectors } from '../../../../../../data/redux'; +import * as hooks from './hooks'; +import messages from '../messages'; + +jest.mock('react', () => { + const updateState = jest.fn(); + const dispatchFn = jest.fn(); + return { + ...jest.requireActual('react'), + updateState, + useState: jest.fn(val => ([{ state: val }, (newVal) => updateState({ val, newVal })])), + useCallback: (cb, prereqs) => ({ useCallback: { cb, prereqs } }), + useEffect: jest.fn(), + useSelector: jest.fn(), + dispatch: dispatchFn, + useDispatch: jest.fn(() => dispatchFn), + }; +}); + +jest.mock('../../../../../../data/redux', () => ({ + actions: { + video: { + updateField: (val) => ({ updateField: val }), + }, + }, + selectors: { + video: { + duration: (state) => ({ duration: state }), + }, + }, +})); + +let hook; +const dispatch = jest.fn(val => ({ dispatch: val })); +const intl = { + formatMessage: jest.fn(val => val), +}; + +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], + ['99:99:99', 99 * (m + s + h)], + ['23:42:81', 23 * h + 42 * m + 81 * s], +]; +let props; +const e = { + target: { + value: 'vAlUE', + }, +}; + +describe('Video Settings DurationWidget hooks', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('durationWidget', () => { + let reduxStartStopTimes; + beforeEach(() => { + hook = hooks.durationWidget({ dispatch }); + reduxStartStopTimes = useSelector(selectors.video.duration); + }); + describe('behavior', () => { + describe('initialization', () => { + test('useEffect memoized on reduxStartStopTimes', () => { + hooks.durationWidget({ dispatch }); + expect(React.useEffect).toHaveBeenCalled(); + expect(React.useEffect.mock.calls[0][1]).toEqual([reduxStartStopTimes]); + }); + test('calls setUnsavedStartStopTimes with durationString(reduxStartStopTimes)', () => { + hooks.durationWidget({ dispatch }); + React.useEffect.mock.calls[0][0](); + expect(React.updateState).toHaveBeenCalled(); + }); + }); + }); + describe('returns', () => { + hook = hooks.durationWidget({ dispatch }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('reduxStartStopTimes, with redux duration value', () => { + expect(hook.reduxStartStopTimes).toEqual(useSelector(selectors.video.duration)); + }); + describe('unsavedStartStopTimes, defaulted to reduxStartStopTimes', () => { + expect(hook.unsavedStartStopTimes).toEqual({ state: hooks.durationString(hook.reduxStartStopTimes) }); + }); + describe('onBlur, calls updateDuration', () => { + jest.spyOn(hooks, 'updateDuration').mockImplementation(jest.fn()); + hook.onBlur('IndEX')(e); + expect(hooks.updateDuration).toHaveBeenCalled(); + }); + describe('onChange', () => { + hook.onChange('IndEX')(e); + expect(React.updateState).toHaveBeenCalled(); + }); + describe('onKeyDown', () => { + hook.onKeyDown('iNDex')(e); + expect(React.updateState).toHaveBeenCalled(); + }); + describe('getTotalLabel', () => { + describe('returns fullVideoLength message when no startTime and no stop Time are set', () => { + expect(hook.getTotalLabel({ + duration: {}, + subtitle: true, + intl, + })).toEqual(messages.fullVideoLength); + }); + describe('returns startAt message for subtitle when only startTime is set', () => { + expect(hook.getTotalLabel({ + duration: { + startTime: '00:00:00', + }, + subtitle: true, + intl, + })).toEqual(messages.startsAt); + }); + describe('returns null for widget (not subtitle) when there only startTime is set', () => { + expect(hook.getTotalLabel({ + duration: { + startTime: '00:00:00', + }, + subtitle: false, + intl, + })).toEqual(null); + }); + describe('returns total message when at least stopTime is set', () => { + expect(hook.getTotalLabel({ + duration: { + startTime: '00:00:00', + stopTime: '00:00:10', + }, + subtitle: true, + intl, + })).toEqual(messages.total); + }); + }); + }); + }); + describe('durationString', () => { + beforeEach(() => { + hook = hooks.durationString; + }); + it('returns an object that maps durationStringFromValue to the passed duration keys', () => { + const testDuration = { startTime: 1000, stopTime: 2000, other: 'values' }; + expect(hook(testDuration)).toEqual({ + startTime: '00:00:01', + stopTime: '00:00:02', + }); + }); + }); + describe('durationStringFromValue', () => { + beforeEach(() => { + hook = hooks.durationStringFromValue; + }); + it('returns 00:00:00 if given a bad value', () => { + const badChecks = ['a', '', null, -1]; + badChecks.forEach(val => expect(hook(val)).toEqual('00:00:00')); + }); + it('translates milliseconds into hh:mm:ss format', () => { + durationPairs.forEach( + ([val, dur]) => expect(hook(val)).toEqual(dur), + ); + }); + }); + describe('updateDuration', () => { + const testValidIndex = 'startTime'; + const testStopIndex = 'stopTime'; + const testValidDuration = '00:00:00'; + const testValidValue = 0; + const testInvalidDuration = 'abc'; + beforeEach(() => { + hook = hooks.updateDuration; + props = { + reduxStartStopTimes: { startTime: 23000, stopTime: 600000 }, + unsavedStartStopTimes: { startTime: '00:00:23', stopTime: '00:10:00' }, + setReduxStartStopTimes: jest.fn(), + setUnsavedStartStopTimes: jest.fn(), + index: 'startTime', + inputString: '01:23:45', + }; + }); + describe('if the passed durationString is valid', () => { + it('sets the unsavedStartStopTimes to updated strings and reduxStartStopTimes to new timestamp value', () => { + hook({ + ...props, + index: testValidIndex, + inputString: testValidDuration, + }); + expect(props.setUnsavedStartStopTimes).toHaveBeenCalledWith({ + ...props.unsavedStartStopTimes, + [testValidIndex]: testValidDuration, + }); + expect(props.setReduxStartStopTimes).toHaveBeenCalledWith({ + ...props.reduxStartStopTimes, + [testValidIndex]: testValidValue, + }); + }); + }); + describe('if the passed durationString is not valid', () => { + it('updates unsavedStartStopTimes values to 0 (the default)', () => { + hook({ + ...props, + index: testValidIndex, + inputString: testInvalidDuration, + }); + expect(props.setUnsavedStartStopTimes).toHaveBeenCalledWith({ + ...props.unsavedStartStopTimes, + [testValidIndex]: testValidDuration, + }); + expect(props.setReduxStartStopTimes).toHaveBeenCalledWith({ + ...props.reduxStartStopTimes, + [testValidIndex]: testValidValue, + }); + }); + }); + describe('if the passed startTime is after (or equal to) the stored non-zero stopTime', () => { + it('updates unsavedStartStopTimes startTime values to 1 second before stopTime', () => { + hook({ + ...props, + index: testValidIndex, + inputString: '00:10:00', + }); + expect(props.setUnsavedStartStopTimes).toHaveBeenCalledWith({ + ...props.unsavedStartStopTimes, + [testValidIndex]: '00:09:59', + }); + expect(props.setReduxStartStopTimes).toHaveBeenCalledWith({ + ...props.reduxStartStopTimes, + [testValidIndex]: 599000, + }); + }); + }); + describe('if the passed stopTime is before (or equal to) the stored startTime', () => { + it('updates unsavedStartStopTimes stopTime values to 1 second after startTime', () => { + hook({ + ...props, + index: testStopIndex, + inputString: '00:00:22', + }); + expect(props.setUnsavedStartStopTimes).toHaveBeenCalledWith({ + ...props.unsavedStartStopTimes, + [testStopIndex]: '00:00:24', + }); + expect(props.setReduxStartStopTimes).toHaveBeenCalledWith({ + ...props.reduxStartStopTimes, + [testStopIndex]: 24000, + }); + }); + }); + }); + describe('onDurationChange', () => { + beforeEach(() => { + props = { + duration: { startTime: '00:00:00' }, + index: 'startTime', + val: 'vAl', + }; + hook = hooks.onDurationChange; + }); + it('returns duration with no change if duration[index] does not match HH:MM:SS format', () => { + const badChecks = [ + 'ab:cd:ef', // non-digit characters + '12:34:567', // characters past max length + ]; + badChecks.forEach(val => expect(hook(props.duration, props.index, val)).toEqual(props.duration)); + }); + it('returns duration with an added \':\' after 2 characters when caret is at end', () => { + props.duration = { startTime: '0' }; + props.val = '00'; + document.activeElement.selectionStart = props.duration[props.index].length + 1; + expect(hook(props.duration, props.index, props.val)).toEqual({ startTime: '00:' }); + }); + it('returns duration with an added \':\' after 5 characters when caret is at end', () => { + props.duration = { startTime: '00:0' }; + props.val = '00:00'; + document.activeElement.selectionStart = props.duration[props.index].length + 1; + expect(hook(props.duration, props.index, props.val)).toEqual({ startTime: '00:00:' }); + }); + }); + describe('onDurationKeyDown', () => { + beforeEach(() => { + props = { + duration: { startTime: '00:00:00' }, + index: 'startTime', + event: 'eVeNt', + }; + hook = hooks.onDurationKeyDown; + }); + it('enter event: calls blur()', () => { + props.event = { key: 'Enter' }; + const blurSpy = jest.spyOn(document.activeElement, 'blur'); + hook(props.duration, props.index, props.event); + expect(blurSpy).toHaveBeenCalled(); + }); + it('backspace event: returns duration with deleted end character when that character is \':\' and caret is at end', () => { + props.duration = { startTime: '00:' }; + props.event = { key: 'Backspace' }; + document.activeElement.selectionStart = props.duration[props.index].length; + expect(hook(props.duration, props.index, props.event)).toEqual({ startTime: '00' }); + }); + }); + describe('valueFromDuration', () => { + beforeEach(() => { + hook = hooks.valueFromDuration; + }); + it('returns 0 if given a bad duration string', () => { + const badChecks = ['a', '00:00:1f', '0adg:00:04']; + badChecks.forEach(dur => expect(hook(dur)).toEqual(0)); + }); + 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)); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx index 32309fae2..85f2c5e79 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx @@ -7,7 +7,6 @@ import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/ import { keyStore } from '../../../../../../utils'; import CollapsibleFormWidget from '../CollapsibleFormWidget'; import hooks from './hooks'; -import { durationFromValue } from '../duration'; import messages from '../messages'; /** @@ -21,34 +20,25 @@ export const DurationWidget = ({ const dispatch = useDispatch(); const { - formValue, - local, + reduxStartStopTimes, + unsavedStartStopTimes, onBlur, onChange, onKeyDown, + getTotalLabel, } = hooks.durationWidget({ dispatch }); - const timeKeys = keyStore(formValue); - - const getTotalLabel = (startTime, stopTime, subtitle) => { - if (!stopTime) { - if (!startTime) { - return intl.formatMessage(messages.fullVideoLength); - } - if (subtitle) { - return intl.formatMessage(messages.startsAt, { startTime: durationFromValue(startTime) }); - } - return null; - } - const total = stopTime - (startTime || 0); - return intl.formatMessage(messages.total, { total: durationFromValue(total) }); - }; + const timeKeys = keyStore(reduxStartStopTimes); return ( @@ -58,7 +48,7 @@ export const DurationWidget = ({ onBlur={onBlur(timeKeys.startTime)} onChange={onChange(timeKeys.startTime)} onKeyDown={onKeyDown(timeKeys.startTime)} - value={local.startTime} + value={unsavedStartStopTimes.startTime} /> @@ -70,7 +60,7 @@ export const DurationWidget = ({ onBlur={onBlur(timeKeys.stopTime)} onChange={onChange(timeKeys.stopTime)} onKeyDown={onKeyDown(timeKeys.stopTime)} - value={local.stopTime} + value={unsavedStartStopTimes.stopTime} /> @@ -78,7 +68,11 @@ export const DurationWidget = ({
- {getTotalLabel(formValue.startTime, formValue.stopTime)} + {getTotalLabel({ + duration: reduxStartStopTimes, + subtitle: false, + intl, + })}
); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js index e07c4f03f..621b079a2 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js @@ -2,7 +2,6 @@ import { useCallback, useState, useEffect, - useMemo, } from 'react'; import { useSelector } from 'react-redux'; @@ -10,18 +9,9 @@ import { StrictDict, keyStore } from '../../../../../utils'; import { actions, selectors } from '../../../../../data/redux'; import { - updateDuration, - durationValue, - onDurationChange, - onDurationKeyDown, -} from './duration'; - -import { - handleIndexEvent, handleIndexTransformEvent, onValue, onChecked, - onEvent, } from './handlers'; import * as module from './hooks'; @@ -88,21 +78,6 @@ export const updateFormField = ({ dispatch, key }) => useCallback( [], ); -/** - * 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 @@ -118,16 +93,11 @@ export const currentValue = ({ key, formValue }) => { */ 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 [local, setLocal] = module.state[key](formValue); const setFormValue = module.updateFormField({ dispatch, key }); useEffect(() => { - if (key === selectorKeys.duration) { - setLocal(durationValue(formValue)); - } else { - setLocal(formValue); - } + setLocal(formValue); }, [formValue]); const setAll = useCallback( @@ -264,63 +234,6 @@ export const objectWidget = ({ dispatch, key }) => { }; }; -/** - * 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; - console.log('test', widget) - return { - ...widget, - onBlur: useCallback( - handleIndexEvent({ - handler: onValue, - transform: updateDuration(widget), - }), - [formValue, local, setFormField], - ), - onChange: useCallback( - handleIndexTransformEvent({ - handler: onValue, - setter: setLocal, - transform: onDurationChange, - local, - }), - [local], - ), - onKeyDown: useCallback( - handleIndexTransformEvent({ - handler: onEvent, - setter: setLocal, - transform: onDurationKeyDown, - local, - }), - [local], - ), - }; -}; - /** * widgetValues({ fields, dispatch }) * widget value populator, that takes a fields mapping (dataKey: widgetFn) and dispatch @@ -339,7 +252,6 @@ export const widgetValues = ({ fields, dispatch }) => Object.keys(fields).reduce export default { arrayWidget, - durationWidget, genericWidget, objectWidget, selectorKeys, diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js index 571ff57cd..9f6d8a4e5 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js @@ -5,7 +5,6 @@ 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'; @@ -17,13 +16,6 @@ jest.mock('react', () => ({ useMemo: jest.fn((cb, prereqs) => ({ useMemo: { cb, prereqs } })), })); -jest.mock('./duration', () => ({ - onDurationChange: jest.fn(value => ({ onDurationChange: value })), - onDurationKeyDown: jest.fn(value => ({ onDurationKeyDown: value })), - 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 })), @@ -56,8 +48,6 @@ jest.mock('../../../../../data/redux', () => ({ })); const keys = { - duration: keyStore(duration), - handlers: keyStore(handlers), hooks: keyStore(hooks), selectors: hooks.selectorKeys, }; @@ -126,18 +116,6 @@ describe('Video Settings modal hooks', () => { })); }); }); - 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(() => { @@ -150,13 +128,6 @@ describe('Video Settings modal hooks', () => { 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](); @@ -165,13 +136,10 @@ describe('Video Settings modal hooks', () => { }); }); 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 }); @@ -180,10 +148,9 @@ describe('Video Settings modal hooks', () => { expect(out.formValue).toEqual(useSelector(selectors.video[testKey])); }); describe('local and setLocal', () => { - test('keyed to state, initialized with memo of currentValue that never updates', () => { + test('keyed to state, initialized with formValue', () => { const { local, setLocal } = out; - expect(local.useMemo.cb()).toEqual(mockCurrentValue({ key: testKey, formValue })); - expect(local.useMemo.prereqs).toEqual([]); + expect(local).toEqual(formValue); setLocal(testValue); expect(state.setState[testKey]).toHaveBeenCalledWith(testValue); }); @@ -315,60 +282,6 @@ describe('Video Settings modal hooks', () => { })); }); }); - 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: duration.onDurationChange, - local: widgetValues.local, - }), - ); - }); - }); - describe('onKeyDown', () => { - test('memoized callback based on local from widget', () => { - expect(out.onKeyDown.useCallback.prereqs).toEqual([widgetValues.local]); - }); - test('calls handleIndexTransformEvent with setLocal', () => { - expect(out.onKeyDown.useCallback.cb).toEqual( - handlers.handleIndexTransformEvent({ - handler: handlers.onEvent, - setter: widgetValues.setLocal, - transform: duration.onDurationKeyDown, - local: widgetValues.local, - }), - ); - }); - }); - }); }); describe('widgetValues', () => { describe('returned object', () => { diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js index 11dd52d14..0c6f0ea03 100644 --- a/src/editors/data/services/cms/api.js +++ b/src/editors/data/services/cms/api.js @@ -3,7 +3,7 @@ import * as urls from './urls'; import { get, post, deleteObject } from './utils'; import * as module from './api'; import * as mockApi from './mockApi'; -import { durationFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks'; +import { durationStringFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks'; export const apiMethods = { fetchBlockById: ({ blockId, studioEndpointUrl }) => get( @@ -176,8 +176,8 @@ export const apiMethods = { track: '', // TODO Downloadable Transcript URL. Backend expects a file name, for example: "something.srt" show_captions: content.showTranscriptByDefault, handout: content.handout, - start_time: durationFromValue(content.duration.startTime), - end_time: durationFromValue(content.duration.stopTime), + start_time: durationStringFromValue(content.duration.startTime), + end_time: durationStringFromValue(content.duration.stopTime), license: module.processLicense(content.licenseType, content.licenseDetails), }, };