fix: fix revert

This commit is contained in:
rayzhou-bit
2023-01-23 15:42:13 -05:00
parent bafc3c8de8
commit b7c654399b
13 changed files with 664 additions and 684 deletions

View File

@@ -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 (
<CollapsibleFormWidget
fontSize="x-small"
title={intl.formatMessage(messages.durationTitle)}
subtitle={getTotalLabel(duration.formValue.startTime, duration.formValue.stopTime, true)}
>
<FormattedMessage {...messages.durationDescription} />
<Form.Row className="mt-4.5">
<Form.Group as={Col}>
<Form.Control
floatingLabel={intl.formatMessage(messages.startTimeLabel)}
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} />
</Form.Control.Feedback>
</Form.Group>
<Form.Group as={Col}>
<Form.Control
floatingLabel={intl.formatMessage(messages.stopTimeLabel)}
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>
</Form.Row>
<div className="mt-4">
{getTotalLabel(duration.formValue.startTime, duration.formValue.stopTime)}
</div>
</CollapsibleFormWidget>
);
};
DurationWidget.propTypes = {
// injected
intl: intlShape.isRequired,
};
export default injectIntl(DurationWidget);

View File

@@ -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(<DurationWidget {...props} />),
).toMatchSnapshot();
});
});
});

View File

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

View File

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

View File

@@ -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 (
<CollapsibleFormWidget
fontSize="x-small"
title={intl.formatMessage(messages.durationTitle)}
subtitle={getTotalLabel({
durationString: duration,
subtitle: true,
intl,
})}
>
<FormattedMessage {...messages.durationDescription} />
<Form.Row className="mt-4.5">
<Form.Group as={Col}>
<Form.Control
floatingLabel={intl.formatMessage(messages.startTimeLabel)}
onBlur={onBlur(timeKeys.startTime)}
onChange={onChange(timeKeys.startTime)}
onKeyDown={onKeyDown(timeKeys.startTime)}
value={unsavedDuration.startTime}
/>
<Form.Control.Feedback>
<FormattedMessage {...messages.durationHint} />
</Form.Control.Feedback>
</Form.Group>
<Form.Group as={Col}>
<Form.Control
floatingLabel={intl.formatMessage(messages.stopTimeLabel)}
onBlur={onBlur(timeKeys.stopTime)}
onChange={onChange(timeKeys.stopTime)}
onKeyDown={onKeyDown(timeKeys.stopTime)}
value={unsavedDuration.stopTime}
/>
<Form.Control.Feedback>
<FormattedMessage {...messages.durationHint} />
</Form.Control.Feedback>
</Form.Group>
</Form.Row>
<div className="mt-4">
{getTotalLabel({
durationString: duration,
subtitle: false,
intl,
})}
</div>
</CollapsibleFormWidget>
);
};
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));

View File

@@ -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(<DurationWidget {...props} />),
).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);
});
});
});

View File

@@ -3,7 +3,7 @@
exports[`DurationWidget render snapshots: renders as expected with default props 1`] = `
<injectIntl(ShimmedIntlComponent)
fontSize="x-small"
subtitle="Total: 00:00:00"
subtitle="Full video length"
title="Duration"
>
<FormattedMessage
@@ -50,7 +50,7 @@ exports[`DurationWidget render snapshots: renders as expected with default props
<div
className="mt-4"
>
Total: 00:00:00
Full video length
</div>
</injectIntl(ShimmedIntlComponent)>
`;

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

@@ -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) => {

View File

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