From 6c574ac18e8c0e79da83d243ddac15ac38433ca0 Mon Sep 17 00:00:00 2001 From: Raymond Zhou <56318341+rayzhou-bit@users.noreply.github.com> Date: Tue, 15 Nov 2022 13:22:29 -0800 Subject: [PATCH] feat: duration entree features (#143) --- .../components/DurationWidget.jsx | 16 ++--- .../DurationWidget.test.jsx.snap | 6 +- .../VideoSettingsModal/components/duration.js | 65 ++++++++++++++++++- .../components/duration.test.js | 55 +++++++++++++++- .../VideoSettingsModal/components/handlers.js | 11 +++- .../components/handlers.test.js | 7 ++ .../VideoSettingsModal/components/hooks.js | 14 +++- .../components/hooks.test.js | 20 +++++- 8 files changed, 176 insertions(+), 18 deletions(-) diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx index 7c165d0c9..d9595edf9 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget.jsx @@ -1,11 +1,7 @@ import React from 'react'; import { useDispatch } from 'react-redux'; -import { - Col, - Form, - Row, -} from '@edx/paragon'; +import { Col, Form } from '@edx/paragon'; import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; import { keyStore } from '../../../../../utils'; @@ -49,13 +45,14 @@ export const DurationWidget = ({ subtitle={getTotalLabel(duration.formValue.startTime, duration.formValue.stopTime, true)} > - + @@ -64,15 +61,16 @@ export const DurationWidget = ({ - +
{getTotalLabel(duration.formValue.startTime, duration.formValue.stopTime)}
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap index ac33f352c..4b6e30a5c 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/__snapshots__/DurationWidget.test.jsx.snap @@ -10,7 +10,7 @@ exports[`DurationWidget render snapshots: renders as expected with default props description="Description of Duration widget" id="authoring.videoeditor.duration.description" /> - @@ -18,6 +18,7 @@ exports[`DurationWidget render snapshots: renders as expected with default props floatingLabel="Start time" onBlur={[Function]} onChange={[Function]} + onKeyDown={[Function]} value="00:00:00" /> @@ -33,6 +34,7 @@ exports[`DurationWidget render snapshots: renders as expected with default props floatingLabel="Stop time" onBlur={[Function]} onChange={[Function]} + onKeyDown={[Function]} value="00:00:00" /> @@ -43,7 +45,7 @@ exports[`DurationWidget render snapshots: renders as expected with default props /> - +
diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js index 9f6de85c1..3f7c65176 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.js @@ -1,7 +1,64 @@ import { useCallback } from 'react'; import * as module from './duration'; -const durationMatcher = /^(\d+)?:?(\d+)?:?(\d+)?$/i; +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) @@ -15,7 +72,7 @@ export const durationFromValue = (value) => { } const seconds = Math.floor((value / 1000) % 60); const minutes = Math.floor((value / 60000) % 60); - const hours = Math.floor((value / 3600000) % 24); + const hours = Math.floor((value / 3600000) % 60); const zeroPad = (num) => String(num).padStart(2, '0'); return [hours, minutes, seconds].map(zeroPad).join(':'); }; @@ -73,6 +130,10 @@ export const updateDuration = ({ (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; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js index 684e0608c..533289ec3 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/duration.test.js @@ -19,8 +19,8 @@ const durationPairs = [ const trickyDurations = [ ['10:00', 600000], ['23', 23000], - ['100:100:100', 100 * (m + s + h)], - ['23:42:781', 23 * h + 42 * m + 781 * s], + ['99:99:99', 99 * (m + s + h)], + ['23:42:81', 23 * h + 42 * m + 81 * s], ]; let spies = {}; let props; @@ -41,6 +41,57 @@ describe('Video Settings Modal duration hooks', () => { }); }); + 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; diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js index 7386d50ab..1c8bb1ff5 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.js @@ -43,6 +43,15 @@ export const onValue = (handler) => (e) => handler(e.target.value); * returns an event handler that calls the given method with the event target value * Intended for checkbox input types. * @param {func} handler - callback to receive the event value - * @return - event handler that calls passed handler with event.target.value + * @return - event handler that calls passed handler with event.target.checked */ export const onChecked = (handler) => (e) => handler(e.target.checked); + +/** + * onEvent(handler) + * returns an event handler that calls the given method with the event + * Intended for most basic input types. + * @param {func} handler - callback to receive the event value + * @return - event handler that calls passed handler with event + */ +export const onEvent = (handler) => (e) => handler(e); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js index 208b581d0..895d15236 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/handlers.test.js @@ -47,4 +47,11 @@ describe('Video Settings Modal event handler methods', () => { }); }); }); + describe('onEvent', () => { + describe('returned method', () => { + it('calls handler with event', () => { + expect(handlers.onEvent(handler)(val)).toEqual(handler(val)); + }); + }); + }); }); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js index 725bbe1ae..d2275af5b 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.js @@ -12,6 +12,8 @@ import { actions, selectors } from '../../../../../data/redux'; import { updateDuration, durationValue, + onDurationChange, + onDurationKeyDown, } from './duration'; import { @@ -19,6 +21,7 @@ import { handleIndexTransformEvent, onValue, onChecked, + onEvent, } from './handlers'; import * as module from './hooks'; @@ -299,7 +302,16 @@ export const durationWidget = ({ dispatch }) => { handleIndexTransformEvent({ handler: onValue, setter: setLocal, - transform: module.updatedObject, + transform: onDurationChange, + local, + }), + [local], + ), + onKeyDown: useCallback( + handleIndexTransformEvent({ + handler: onEvent, + setter: setLocal, + transform: onDurationKeyDown, local, }), [local], 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 405737d46..571ff57cd 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/hooks.test.js @@ -18,6 +18,8 @@ jest.mock('react', () => ({ })); 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 })), })); @@ -27,6 +29,7 @@ jest.mock('./handlers', () => ({ handleIndexTransformEvent: jest.fn(args => ({ handleIndexTransformEvent: args })), onValue: jest.fn(cb => ({ onValue: cb })), onChecked: jest.fn(cb => ({ onChecked: cb })), + onEvent: jest.fn(cb => ({ onEvent: cb })), })); jest.mock('../../../../../data/redux', () => ({ @@ -344,7 +347,22 @@ describe('Video Settings modal hooks', () => { handlers.handleIndexTransformEvent({ handler: handlers.onValue, setter: widgetValues.setLocal, - transform: hooks.updatedObject, + 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, }), );