feat: duration entree features (#143)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user