From b7c654399bcb7a5c2f9f8253e50da1eab7f1f210 Mon Sep 17 00:00:00 2001 From: rayzhou-bit Date: Mon, 23 Jan 2023 15:42:13 -0500 Subject: [PATCH] fix: fix revert --- .../components/DurationWidget.jsx | 87 +++++ .../components/DurationWidget.test.jsx | 22 ++ .../components/DurationWidget/hooks.js | 216 ------------ .../components/DurationWidget/hooks.test.js | 308 ------------------ .../components/DurationWidget/index.jsx | 99 ------ .../components/DurationWidget/index.test.jsx | 51 --- .../DurationWidget.test.jsx.snap} | 4 +- .../VideoSettingsModal/components/duration.js | 155 +++++++++ .../components/duration.test.js | 216 ++++++++++++ .../VideoSettingsModal/components/hooks.js | 91 +++++- .../components/hooks.test.js | 91 +++++- src/editors/data/redux/thunkActions/video.js | 2 +- src/editors/data/services/cms/api.js | 6 +- 13 files changed, 664 insertions(+), 684 deletions(-) create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.test.jsx delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx delete mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.test.jsx rename src/editors/containers/VideoEditor/components/VideoSettingsModal/components/{DurationWidget/__snapshots__/index.test.jsx.snap => __snapshots__/DurationWidget.test.jsx.snap} (96%) create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js create mode 100644 src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx new file mode 100644 index 000000000..779266af6 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; + +import { Col, Form } from '@edx/paragon'; + +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { keyStore } from '../../../../../utils'; +import CollapsibleFormWidget from './CollapsibleFormWidget'; +import hooks from './hooks'; +import { durationFromValue } from './duration'; +import messages from './messages'; + +/** + * Collapsible Form widget controlling video start and end times + * Also displays the total run time of the video. + */ +export const DurationWidget = ({ + // injected + intl, +}) => { + const dispatch = useDispatch(); + const { duration } = hooks.widgetValues({ + dispatch, + fields: { [hooks.selectorKeys.duration]: hooks.durationWidget }, + }); + const timeKeys = keyStore(duration.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) }); + }; + + return ( + + + + + + + + + + + + + + + + +
+ {getTotalLabel(duration.formValue.startTime, duration.formValue.stopTime)} +
+
+ ); +}; + +DurationWidget.propTypes = { + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(DurationWidget); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.test.jsx new file mode 100644 index 000000000..68a9f8e57 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.test.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { formatMessage } from '../../../../../../testUtils'; +import { DurationWidget } from './DurationWidget'; + +describe('DurationWidget', () => { + const props = { + isError: false, + subtitle: 'SuBTItle', + title: 'tiTLE', + // inject + intl: { formatMessage }, + }; + describe('render', () => { + test('snapshots: renders as expected with default props', () => { + expect( + shallow(), + ).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js deleted file mode 100644 index 50b390b62..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.js +++ /dev/null @@ -1,216 +0,0 @@ -import { useEffect, useState } from 'react'; - -import messages from '../messages'; - -import * as module from './hooks'; - -const durationMatcher = /^(\d{0,2}):?(\d{0,2})?:?(\d{0,2})?$/i; - -export const durationWidget = ({ duration, updateField }) => { - const setDuration = (val) => updateField({ duration: val }); - const initialState = module.durationString(duration); - const [unsavedDuration, setUnsavedDuration] = useState(initialState); - - useEffect(() => { - setUnsavedDuration(module.durationString(duration)); - }, [duration]); - - return { - unsavedDuration, - onBlur: (index) => ( - (e) => module.updateDuration({ - duration, - setDuration, - unsavedDuration, - setUnsavedDuration, - index, - inputString: e.target.value, - }) - ), - onChange: (index) => ( - (e) => setUnsavedDuration(module.onDurationChange(unsavedDuration, index, e.target.value)) - ), - onKeyDown: (index) => ( - (e) => setUnsavedDuration(module.onDurationKeyDown(unsavedDuration, index, e)) - ), - getTotalLabel: ({ durationString, subtitle, intl }) => { - if (!durationString.stopTime) { - if (!durationString.startTime) { - return intl.formatMessage(messages.fullVideoLength); - } - if (subtitle) { - return intl.formatMessage( - messages.startsAt, - { startTime: module.durationStringFromValue(durationString.startTime) }, - ); - } - return null; - } - const total = durationString.stopTime - (durationString.startTime || 0); - return intl.formatMessage(messages.total, { total: module.durationStringFromValue(total) }); - }, - }; -}; - -/** - * 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 durationString = (duration) => ({ - startTime: module.durationStringFromValue(duration.startTime), - stopTime: module.durationStringFromValue(duration.stopTime), -}); - -/** - * 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 durationStringFromValue = (value) => { - // return 'why'; - if (!value || typeof value !== 'number' || value <= 0) { - return '00:00:00'; - } - const seconds = Math.floor((value / 1000) % 60); - const minutes = Math.floor((value / 60000) % 60); - const hours = Math.floor((value / 3600000) % 60); - const zeroPad = (num) => String(num).padStart(2, '0'); - return [hours, minutes, seconds].map(zeroPad).join(':'); -}; - -/** - * updateDuration({ duration, unsavedDuration, setUnsavedDuration, setDuration }) - * Returns a memoized callback based on inputs that updates unsavedDuration value and form value - * if the new string is valid (duration stores a number, unsavedDuration stores a string). - * If the duration string is invalid, resets the unsavedDuration value to the latest good value. - * @param {object} duration - redux-stored durations in milliseconds - * @param {object} unsavedDuration - hook-stored duration in 'hh:mm:ss' format - * @param {func} setDuration - set form value - * @param {func} setUnsavedDuration - set unsavedDuration object - * @param {string} index - startTime or stopTime - * @param {string} inputString - string value of the the time user has inputted in either the startTime or stopTime field - * @return {func} - callback to update duration unsavedDurationly and in redux - * updateDuration(args)(index, durationString) - */ -export const updateDuration = ({ - duration, - unsavedDuration, - setDuration, - setUnsavedDuration, - index, - inputString, -}) => { - 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 < (duration.startTime + 1000)) { - newValue = duration.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' && duration.stopTime >= 1000 && newValue > (duration.stopTime - 1000)) { - newValue = duration.stopTime - 1000; - } - newDurationString = module.durationStringFromValue(newValue); - setUnsavedDuration({ ...unsavedDuration, [index]: newDurationString }); - setDuration({ ...duration, [index]: newValue }); -}; - -/** - * onDurationChange(duration) - * Returns a new duration value based on onChange event - * @param {object} duration - object containing startTime and stopTime millisecond values - * @param {string} index - 'startTime or 'stopTime' - * @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) => { - const match = val.trim().match(durationMatcher); - if (!match) { - return duration; - } - - const caretPos = document.activeElement.selectionStart; - let newDuration = val; - if (caretPos === newDuration.length && (newDuration.length === 2 || newDuration.length === 5)) { - newDuration += ':'; - } - - return { - ...duration, - [index]: newDuration, - }; -}; - -/** - * onDurationKeyDown(duration) - * Returns a new duration value based on onKeyDown event - * @param {object} duration - object containing startTime and stopTime millisecond values - * @param {string} index - 'startTime or 'stopTime' - * @param {Event} event - event from onKeyDown - * @return {object} duration - object containing startTime and stopTime millisecond values - */ -export const onDurationKeyDown = (duration, index, event) => { - const caretPos = document.activeElement.selectionStart; - let newDuration = duration[index]; - - switch (event.key) { - case 'Enter': - document.activeElement.blur(); - break; - case 'Backspace': - if (caretPos === newDuration.length && newDuration.slice(-1) === ':') { - newDuration = newDuration.slice(0, -1); - } - break; - default: - break; - } - - return { - ...duration, - [index]: newDuration, - }; -}; - -/** - * 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 0; - } - 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; -}; - -export default { - durationWidget, - durationString, - durationStringFromValue, - updateDuration, - onDurationChange, - onDurationKeyDown, - valueFromDuration, -}; 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 deleted file mode 100644 index 782558829..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks.test.js +++ /dev/null @@ -1,308 +0,0 @@ -import React from 'react'; - -import * as hooks from './hooks'; -import messages from '../messages'; - -jest.mock('react', () => { - const updateState = 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(), - }; -}); - -let testMethod; -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', () => { - const duration = { - startTime: '00:00:00', - stopTime: '00:00:10', - }; - const updateField = jest.fn(); - beforeEach(() => { - testMethod = hooks.durationWidget({ duration, updateField }); - }); - describe('behavior', () => { - describe('initialization', () => { - test('useEffect memoized on duration', () => { - hooks.durationWidget({ duration, updateField }); - expect(React.useEffect).toHaveBeenCalled(); - expect(React.useEffect.mock.calls[0][1]).toEqual([duration]); - }); - test('calls setUnsavedDuration with durationString(duration)', () => { - hooks.durationWidget({ duration, updateField }); - React.useEffect.mock.calls[0][0](); - expect(React.updateState).toHaveBeenCalled(); - }); - }); - }); - describe('returns', () => { - testMethod = hooks.durationWidget({ duration, updateField }); - afterEach(() => { - jest.restoreAllMocks(); - }); - describe('unsavedDuration, defaulted to duration', () => { - expect(testMethod.unsavedDuration).toEqual({ state: hooks.durationString(duration) }); - }); - describe('onBlur, calls updateDuration', () => { - jest.spyOn(hooks, 'updateDuration').mockImplementation(jest.fn()); - testMethod.onBlur('IndEX')(e); - expect(hooks.updateDuration).toHaveBeenCalled(); - }); - describe('onChange', () => { - testMethod.onChange('IndEX')(e); - expect(React.updateState).toHaveBeenCalled(); - }); - describe('onKeyDown', () => { - testMethod.onKeyDown('iNDex')(e); - expect(React.updateState).toHaveBeenCalled(); - }); - describe('getTotalLabel', () => { - describe('returns fullVideoLength message when no startTime and no stop Time are set', () => { - expect(testMethod.getTotalLabel({ - durationString: {}, - subtitle: true, - intl, - })).toEqual(messages.fullVideoLength); - }); - describe('returns startAt message for subtitle when only startTime is set', () => { - expect(testMethod.getTotalLabel({ - durationString: { - startTime: '00:00:00', - }, - subtitle: true, - intl, - })).toEqual(messages.startsAt); - }); - describe('returns null for widget (not subtitle) when there only startTime is set', () => { - expect(testMethod.getTotalLabel({ - durationString: { - startTime: '00:00:00', - }, - subtitle: false, - intl, - })).toEqual(null); - }); - describe('returns total message when at least stopTime is set', () => { - expect(testMethod.getTotalLabel({ - durationString: { - startTime: '00:00:00', - stopTime: '00:00:10', - }, - subtitle: true, - intl, - })).toEqual(messages.total); - }); - }); - }); - }); - describe('durationString', () => { - beforeEach(() => { - testMethod = hooks.durationString; - }); - it('returns an object that maps durationStringFromValue to the passed duration keys', () => { - const testDuration = { startTime: 1000, stopTime: 2000, other: 'values' }; - expect(testMethod(testDuration)).toEqual({ - startTime: '00:00:01', - stopTime: '00:00:02', - }); - }); - }); - describe('durationStringFromValue', () => { - beforeEach(() => { - testMethod = hooks.durationStringFromValue; - }); - it('returns 00:00:00 if given a bad value', () => { - const badChecks = ['a', '', null, -1]; - badChecks.forEach(val => expect(testMethod(val)).toEqual('00:00:00')); - }); - it('translates milliseconds into hh:mm:ss format', () => { - durationPairs.forEach( - ([val, dur]) => expect(testMethod(val)).toEqual(dur), - ); - }); - }); - describe('updateDuration', () => { - const testValidIndex = 'startTime'; - const testStopIndex = 'stopTime'; - const testValidDuration = '00:00:00'; - const testValidValue = 0; - const testInvalidDuration = 'abc'; - beforeEach(() => { - testMethod = hooks.updateDuration; - props = { - duration: { startTime: 23000, stopTime: 600000 }, - unsavedDuration: { startTime: '00:00:23', stopTime: '00:10:00' }, - setDuration: jest.fn(), - setUnsavedDuration: jest.fn(), - index: 'startTime', - inputString: '01:23:45', - }; - }); - describe('if the passed durationString is valid', () => { - it('sets the unsavedDuration to updated strings and duration to new timestamp value', () => { - testMethod({ - ...props, - index: testValidIndex, - inputString: testValidDuration, - }); - expect(props.setUnsavedDuration).toHaveBeenCalledWith({ - ...props.unsavedDuration, - [testValidIndex]: testValidDuration, - }); - expect(props.setDuration).toHaveBeenCalledWith({ - ...props.duration, - [testValidIndex]: testValidValue, - }); - }); - }); - describe('if the passed durationString is not valid', () => { - it('updates unsavedDuration values to 0 (the default)', () => { - testMethod({ - ...props, - index: testValidIndex, - inputString: testInvalidDuration, - }); - expect(props.setUnsavedDuration).toHaveBeenCalledWith({ - ...props.unsavedDuration, - [testValidIndex]: testValidDuration, - }); - expect(props.setDuration).toHaveBeenCalledWith({ - ...props.duration, - [testValidIndex]: testValidValue, - }); - }); - }); - describe('if the passed startTime is after (or equal to) the stored non-zero stopTime', () => { - it('updates unsavedDuration startTime values to 1 second before stopTime', () => { - testMethod({ - ...props, - index: testValidIndex, - inputString: '00:10:00', - }); - expect(props.setUnsavedDuration).toHaveBeenCalledWith({ - ...props.unsavedDuration, - [testValidIndex]: '00:09:59', - }); - expect(props.setDuration).toHaveBeenCalledWith({ - ...props.duration, - [testValidIndex]: 599000, - }); - }); - }); - describe('if the passed stopTime is before (or equal to) the stored startTime', () => { - it('updates unsavedDuration stopTime values to 1 second after startTime', () => { - testMethod({ - ...props, - index: testStopIndex, - inputString: '00:00:22', - }); - expect(props.setUnsavedDuration).toHaveBeenCalledWith({ - ...props.unsavedDuration, - [testStopIndex]: '00:00:24', - }); - expect(props.setDuration).toHaveBeenCalledWith({ - ...props.duration, - [testStopIndex]: 24000, - }); - }); - }); - }); - describe('onDurationChange', () => { - beforeEach(() => { - props = { - duration: { startTime: '00:00:00' }, - index: 'startTime', - val: 'vAl', - }; - testMethod = 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(testMethod(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(testMethod(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(testMethod(props.duration, props.index, props.val)).toEqual({ startTime: '00:00:' }); - }); - }); - describe('onDurationKeyDown', () => { - beforeEach(() => { - props = { - duration: { startTime: '00:00:00' }, - index: 'startTime', - event: 'eVeNt', - }; - testMethod = hooks.onDurationKeyDown; - }); - it('enter event: calls blur()', () => { - props.event = { key: 'Enter' }; - const blurSpy = jest.spyOn(document.activeElement, 'blur'); - testMethod(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(testMethod(props.duration, props.index, props.event)).toEqual({ startTime: '00' }); - }); - }); - describe('valueFromDuration', () => { - beforeEach(() => { - testMethod = hooks.valueFromDuration; - }); - it('returns 0 if given a bad duration string', () => { - const badChecks = ['a', '00:00:1f', '0adg:00:04']; - badChecks.forEach(dur => expect(testMethod(dur)).toEqual(0)); - }); - it('returns simple durations', () => { - durationPairs.forEach(([val, dur]) => expect(testMethod(dur)).toEqual(val)); - }); - it('returns tricky durations, prepending zeros and expanding out sections', () => { - trickyDurations.forEach(([dur, val]) => expect(testMethod(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 deleted file mode 100644 index 7b2b1144c..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; - -import { Col, Form } from '@edx/paragon'; -import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; - -import { actions, selectors } from '../../../../../../data/redux'; -import { keyStore } from '../../../../../../utils'; -import CollapsibleFormWidget from '../CollapsibleFormWidget'; -import hooks from './hooks'; -import messages from '../messages'; - -/** - * Collapsible Form widget controlling video start and end times - * Also displays the total run time of the video. - */ -export const DurationWidget = ({ - // redux - duration, - updateField, - // injected - intl, -}) => { - const { - unsavedDuration, - onBlur, - onChange, - onKeyDown, - getTotalLabel, - } = hooks.durationWidget({ duration, updateField }); - - const timeKeys = keyStore(duration); - - return ( - - - - - - - - - - - - - - - - -
- {getTotalLabel({ - durationString: duration, - subtitle: false, - intl, - })} -
-
- ); -}; - -DurationWidget.propTypes = { - // redux - duration: PropTypes.objectOf(PropTypes.number).isRequired, - updateField: PropTypes.func.isRequired, - // injected - intl: intlShape.isRequired, -}; - -export const mapStateToProps = (state) => ({ - duration: selectors.video.duration(state), -}); - -export const mapDispatchToProps = { - updateField: actions.video.updateField, -}; - -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(DurationWidget)); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.test.jsx deleted file mode 100644 index ddb9e8df9..000000000 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/index.test.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import { actions, selectors } from '../../../../../../data/redux'; -import { formatMessage } from '../../../../../../../testUtils'; -import { DurationWidget, mapStateToProps, mapDispatchToProps } from '.'; - -jest.mock('../../../../../../data/redux', () => ({ - actions: { - video: { - updateField: jest.fn().mockName('actions.video.updateField'), - }, - }, - selectors: { - video: { - duration: jest.fn(state => ({ duration: state })), - }, - }, -})); - -describe('DurationWidget', () => { - const props = { - duration: { - startTime: '00:00:00', - stopTime: '00:00:10', - }, - updateField: jest.fn().mockName('updateField'), - // inject - intl: { formatMessage }, - }; - describe('render', () => { - test('snapshots: renders as expected with default props', () => { - expect( - shallow(), - ).toMatchSnapshot(); - }); - }); - describe('mapStateToProps', () => { - const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; - test('duration from video.duration', () => { - expect( - mapStateToProps(testState).duration, - ).toEqual(selectors.video.duration(testState)); - }); - }); - describe('mapDispatchToProps', () => { - test('updateField from actions.video.updateField', () => { - expect(mapDispatchToProps.updateField).toEqual(actions.video.updateField); - }); - }); -}); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/__snapshots__/index.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap similarity index 96% rename from src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/__snapshots__/index.test.jsx.snap rename to src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap index a4c282e9a..53fdb4043 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap @@ -3,7 +3,7 @@ exports[`DurationWidget render snapshots: renders as expected with default props 1`] = ` - Total: 00:00:00 + Full video length `; 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..3f7c65176 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js @@ -0,0 +1,155 @@ +import { useCallback } from 'react'; +import * as module from './duration'; + +const durationMatcher = /^(\d{0,2}):?(\d{0,2})?:?(\d{0,2})?$/i; + +/** + * onDurationChange(duration) + * Returns a new duration value based on onChange event + * @param {object} duration - object containing startTime and stopTime millisecond values + * @param {string} index - 'startTime or 'stopTime' + * @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) => { + const match = val.trim().match(durationMatcher); + if (!match) { + return duration; + } + + const caretPos = document.activeElement.selectionStart; + let newDuration = val; + if (caretPos === newDuration.length && (newDuration.length === 2 || newDuration.length === 5)) { + newDuration += ':'; + } + + return { + ...duration, + [index]: newDuration, + }; +}; + +/** + * onDurationKeyDown(duration) + * Returns a new duration value based on onKeyDown event + * @param {object} duration - object containing startTime and stopTime millisecond values + * @param {string} index - 'startTime or 'stopTime' + * @param {Event} event - event from onKeyDown + * @return {object} duration - object containing startTime and stopTime millisecond values + */ +export const onDurationKeyDown = (duration, index, event) => { + const caretPos = document.activeElement.selectionStart; + let newDuration = duration[index]; + + switch (event.key) { + case 'Enter': + document.activeElement.blur(); + break; + case 'Backspace': + if (caretPos === newDuration.length && newDuration.slice(-1) === ':') { + newDuration = newDuration.slice(0, -1); + } + break; + default: + break; + } + + return { + ...duration, + [index]: newDuration, + }; +}; + +/** + * 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) => { + if (!value || typeof value !== 'number' || value <= 0) { + return '00:00:00'; + } + const seconds = Math.floor((value / 1000) % 60); + const minutes = Math.floor((value / 60000) % 60); + const hours = Math.floor((value / 3600000) % 60); + 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 0; + } + 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) => { + let newDurationString = durationString; + 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 < (formValue.startTime + 1000)) { + newValue = formValue.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' && formValue.stopTime >= 1000 && newValue > (formValue.stopTime - 1000)) { + newValue = formValue.stopTime - 1000; + } + newDurationString = module.durationFromValue(newValue); + setLocal({ ...local, [index]: newDurationString }); + setFormValue({ ...formValue, [index]: newValue }); + }, + [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..533289ec3 --- /dev/null +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js @@ -0,0 +1,216 @@ +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/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js index 621b079a2..97400b049 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js @@ -2,6 +2,7 @@ import { useCallback, useState, useEffect, + useMemo, } from 'react'; import { useSelector } from 'react-redux'; @@ -9,9 +10,18 @@ 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'; @@ -78,6 +88,21 @@ 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 @@ -93,11 +118,16 @@ export const updateFormField = ({ dispatch, key }) => useCallback( */ export const valueHooks = ({ dispatch, key }) => { const formValue = useSelector(selectors.video[key]); - const [local, setLocal] = module.state[key](formValue); + const initialValue = useMemo(() => module.currentValue({ key, formValue }), []); + const [local, setLocal] = module.state[key](initialValue); const setFormValue = module.updateFormField({ dispatch, key }); useEffect(() => { - setLocal(formValue); + if (key === selectorKeys.duration) { + setLocal(durationValue(formValue)); + } else { + setLocal(formValue); + } }, [formValue]); const setAll = useCallback( @@ -234,6 +264,62 @@ 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; + 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 @@ -252,6 +338,7 @@ 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 9f6d8a4e5..571ff57cd 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js @@ -5,6 +5,7 @@ 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'; @@ -16,6 +17,13 @@ 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 })), @@ -48,6 +56,8 @@ jest.mock('../../../../../data/redux', () => ({ })); const keys = { + duration: keyStore(duration), + handlers: keyStore(handlers), hooks: keyStore(hooks), selectors: hooks.selectorKeys, }; @@ -116,6 +126,18 @@ 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(() => { @@ -128,6 +150,13 @@ 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](); @@ -136,10 +165,13 @@ 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 }); @@ -148,9 +180,10 @@ describe('Video Settings modal hooks', () => { expect(out.formValue).toEqual(useSelector(selectors.video[testKey])); }); describe('local and setLocal', () => { - test('keyed to state, initialized with formValue', () => { + test('keyed to state, initialized with memo of currentValue that never updates', () => { const { local, setLocal } = out; - expect(local).toEqual(formValue); + expect(local.useMemo.cb()).toEqual(mockCurrentValue({ key: testKey, formValue })); + expect(local.useMemo.prereqs).toEqual([]); setLocal(testValue); expect(state.setState[testKey]).toHaveBeenCalledWith(testValue); }); @@ -282,6 +315,60 @@ 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/redux/thunkActions/video.js b/src/editors/data/redux/thunkActions/video.js index 38eda532f..f36ecd86d 100644 --- a/src/editors/data/redux/thunkActions/video.js +++ b/src/editors/data/redux/thunkActions/video.js @@ -2,7 +2,7 @@ import { actions, selectors } from '..'; import { removeItemOnce } from '../../../utils'; import * as requests from './requests'; import * as module from './video'; -import { valueFromDuration } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks'; +import { valueFromDuration } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/duration'; import { parseYoutubeId } from '../../services/cms/api'; export const loadVideoData = () => (dispatch, getState) => { diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js index 0c6f0ea03..434df60f5 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 { durationStringFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks'; +import { durationFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/duration'; 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: durationStringFromValue(content.duration.startTime), - end_time: durationStringFromValue(content.duration.stopTime), + start_time: durationFromValue(content.duration.startTime), + end_time: durationFromValue(content.duration.stopTime), license: module.processLicense(content.licenseType, content.licenseDetails), }, };