feat: duration entree features (#143)

This commit is contained in:
Raymond Zhou
2022-11-15 13:22:29 -08:00
committed by GitHub
parent ea50afc165
commit 6c574ac18e
8 changed files with 176 additions and 18 deletions

View File

@@ -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)}
>
<FormattedMessage {...messages.durationDescription} />
<Row className="mt-4">
<Form.Row className="mt-4">
<Form.Group as={Col}>
<Form.Control
floatingLabel={intl.formatMessage(messages.startTimeLabel)}
value={duration.local.startTime}
onBlur={duration.onBlur(timeKeys.startTime)}
onChange={duration.onChange(timeKeys.startTime)}
onKeyDown={duration.onKeyDown(timeKeys.startTime)}
value={duration.local.startTime}
/>
<Form.Control.Feedback>
<FormattedMessage {...messages.durationHint} />
@@ -64,15 +61,16 @@ export const DurationWidget = ({
<Form.Group as={Col}>
<Form.Control
floatingLabel={intl.formatMessage(messages.stopTimeLabel)}
value={duration.local.stopTime}
onBlur={duration.onBlur(timeKeys.stopTime)}
onChange={duration.onChange(timeKeys.stopTime)}
onKeyDown={duration.onKeyDown(timeKeys.stopTime)}
value={duration.local.stopTime}
/>
<Form.Control.Feedback>
<FormattedMessage {...messages.durationHint} />
</Form.Control.Feedback>
</Form.Group>
</Row>
</Form.Row>
<div className="mt-4">
{getTotalLabel(duration.formValue.startTime, duration.formValue.stopTime)}
</div>

View File

@@ -10,7 +10,7 @@ exports[`DurationWidget render snapshots: renders as expected with default props
description="Description of Duration widget"
id="authoring.videoeditor.duration.description"
/>
<Component
<Form.Row
className="mt-4"
>
<Form.Group>
@@ -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"
/>
<Form.Control.Feedback>
@@ -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"
/>
<Form.Control.Feedback>
@@ -43,7 +45,7 @@ exports[`DurationWidget render snapshots: renders as expected with default props
/>
</Form.Control.Feedback>
</Form.Group>
</Component>
</Form.Row>
<div
className="mt-4"
>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

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

View File

@@ -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],

View File

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