fix: fix revert
This commit is contained in:
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)>
|
||||
`;
|
||||
@@ -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],
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user