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,
}),
);