feat: duration widget for video settings. TNL-9823.

This commit is contained in:
Ken Clary
2022-10-20 10:10:21 -04:00
parent 79ae64b562
commit a4f0a8f162
8 changed files with 175 additions and 54 deletions

View File

@@ -3,50 +3,85 @@ import { useDispatch } from 'react-redux';
// import PropTypes from 'prop-types';
import {
Col,
FormControl,
FormGroup,
Row,
} 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 = () => {
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) => {
if (!stopTime) {
if (!startTime) {
return intl.formatMessage(messages.fullVideoLength);
}
return intl.formatMessage(messages.startsAt, { startTime: durationFromValue(startTime) });
}
const total = stopTime - (startTime || 0);
return intl.formatMessage(messages.total, { total: durationFromValue(total) });
};
return (
<CollapsibleFormWidget title="Duration">
<FormGroup size="sm">
<div>
<CollapsibleFormWidget
title={intl.formatMessage(messages.durationTitle)}
subtitle={getTotalLabel(duration.formValue.startTime, duration.formValue.stopTime)}
>
<FormattedMessage {...messages.durationDescription} />
<Row className="mt-4">
<FormGroup as={Col}>
<FormControl
className="d-inline-block"
floatingLabel="Start time"
floatingLabel={intl.formatMessage(messages.startTimeLabel)}
value={duration.local.startTime}
onBlur={duration.onBlur(timeKeys.startTime)}
onChange={duration.onChange(timeKeys.startTime)}
/>
<FormControl.Feedback>
<FormattedMessage {...messages.durationHint} />
</FormControl.Feedback>
</FormGroup>
<FormGroup as={Col}>
<FormControl
className="d-inline-block"
floatingLabel="Stop time"
floatingLabel={intl.formatMessage(messages.stopTimeLabel)}
value={duration.local.stopTime}
onBlur={duration.onBlur(timeKeys.stopTime)}
onChange={duration.onChange(timeKeys.stopTime)}
/>
</div>
<div className="mt-4">
Total: {duration.formValue.total}
</div>
</FormGroup>
<FormControl.Feedback>
<FormattedMessage {...messages.durationHint} />
</FormControl.Feedback>
</FormGroup>
</Row>
<div className="mt-4">
{getTotalLabel(duration.formValue.startTime, duration.formValue.stopTime)}
</div>
</CollapsibleFormWidget>
);
};
export default DurationWidget;
DurationWidget.propTypes = {
// injected
intl: intlShape.isRequired,
};
export default injectIntl(DurationWidget);

View File

@@ -10,6 +10,9 @@ const durationMatcher = /^(\d+)?:?(\d+)?:?(\d+)?$/i;
* @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) % 24);
@@ -26,7 +29,7 @@ export const durationFromValue = (value) => {
export const valueFromDuration = (duration) => {
let matches = duration.trim().match(durationMatcher);
if (!matches) {
return null;
return 0;
}
matches = matches.slice(1).filter(v => v !== undefined);
if (matches.length < 3) {
@@ -68,14 +71,24 @@ export const updateDuration = ({
setLocal,
}) => useCallback(
(index, durationString) => {
const newValue = module.valueFromDuration(durationString);
if (newValue !== null) {
setLocal({ ...local, [index]: durationString });
setFormValue({ ...formValue, [index]: newValue });
} else {
// If invalid duration string, reset to last valid value
setLocal({ ...local, [index]: module.durationFromValue(formValue[index]) });
let newDurationString = durationString;
let newValue = module.valueFromDuration(newDurationString);
// 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

@@ -42,9 +42,16 @@ describe('Video Settings Modal duration hooks', () => {
});
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(duration.durationFromValue(val)).toEqual(dur),
([val, dur]) => expect(hook(val)).toEqual(dur),
);
});
});
@@ -52,9 +59,9 @@ describe('Video Settings Modal duration hooks', () => {
beforeEach(() => {
hook = duration.valueFromDuration;
});
it('returns null if given a bad duration string', () => {
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(null));
badChecks.forEach(dur => expect(hook(dur)).toEqual(0));
});
it('returns simple durations', () => {
durationPairs.forEach(([val, dur]) => expect(hook(dur)).toEqual(val));
@@ -77,19 +84,18 @@ describe('Video Settings Modal duration hooks', () => {
});
});
describe('updateDuration', () => {
const testDuration = 'myDuration';
const testIndex = 'startTime';
const mockValueFromDuration = (dur) => ({ value: dur });
const mockDurationFromValue = (value) => ({ duration: value });
const testValidIndex = 'startTime';
const testStopIndex = 'stopTime';
const testValidDuration = '00:00:00';
const testValidValue = 0;
const testInvalidDuration = 'abc';
beforeEach(() => {
props = {
formValue: { startTime: 230000, stopTime: 0 },
local: { startTime: '00:00:23', stopTime: '00:00:00' },
formValue: { startTime: 23000, stopTime: 600000 },
local: { startTime: '00:00:23', stopTime: '00:10:00' },
setLocal: jest.fn(),
setFormValue: jest.fn(),
};
spies.valueFromDuration = jest.spyOn(duration, durationKeys.valueFromDuration)
.mockImplementation(mockValueFromDuration);
hook = duration.updateDuration;
({ cb, prereqs } = hook(props).useCallback);
});
@@ -104,29 +110,54 @@ describe('Video Settings Modal duration hooks', () => {
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(testIndex, testDuration);
expect(duration.valueFromDuration).toHaveBeenCalledWith(testDuration);
cb(testValidIndex, testValidDuration);
expect(props.setLocal).toHaveBeenCalledWith({
...props.local,
[testIndex]: testDuration,
[testValidIndex]: testValidDuration,
});
expect(props.setFormValue).toHaveBeenCalledWith({
...props.formValue,
[testIndex]: mockValueFromDuration(testDuration),
[testValidIndex]: testValidValue,
});
});
});
describe('if the passed durationString is not valid', () => {
it('updates local back to the string for the form-stored timestamp value', () => {
spies.valueFromDuration.mockReturnValue(null);
spies.durationFromValue = jest.spyOn(duration, durationKeys.durationFromValue)
.mockImplementationOnce(mockDurationFromValue);
hook(props).useCallback.cb(testIndex, testDuration);
it('updates local values to 0 (the default)', () => {
hook(props).useCallback.cb(testValidIndex, testInvalidDuration);
expect(props.setLocal).toHaveBeenCalledWith({
...props.local,
[testIndex]: mockDurationFromValue(props.formValue[testIndex]),
[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,
});
expect(props.setFormValue).not.toHaveBeenCalled();
});
});
});

View File

@@ -17,6 +17,46 @@ export const messages = {
defaultMessage: 'Please check your entries and try again.',
description: 'Body of validation error.',
},
durationTitle: {
id: 'authoring.videoeditor.duration.title',
defaultMessage: 'Duration',
description: 'Title of Duration widget',
},
durationDescription: {
id: 'authoring.videoeditor.duration.description',
defaultMessage: 'Set a specific section of the video to play.',
description: 'Description of Duration widget',
},
startTimeLabel: {
id: 'authoring.videoeditor.duration.startTime.label',
defaultMessage: 'Start time',
description: 'Label of start time input field',
},
stopTimeLabel: {
id: 'authoring.videoeditor.duration.stopTime.label',
defaultMessage: 'Stop time',
description: 'Label of stop time input field',
},
durationHint: {
id: 'authoring.videoeditor.duration.hint',
defaultMessage: 'Enter time as HH:MM:SS',
description: 'Hint text for start and stop time input fields',
},
fullVideoLength: {
id: 'authoring.videoeditor.duration.fullVideoLength',
defaultMessage: 'Full video length',
description: 'Text describing a video with neither custom start time nor custom stop time',
},
startsAt: {
id: 'authoring.videoeditor.duration.startsAt',
defaultMessage: 'Starts at {startTime}',
description: 'Text describing a video with custom start time and default stop time',
},
total: {
id: 'authoring.videoeditor.duration.total',
defaultMessage: 'Total: {total}',
description: 'Text describing a video with custom start time and custom stop time, or just a custom stop time',
},
};
export default messages;

View File

@@ -1,6 +1,7 @@
import { actions, selectors } from '..';
import * as requests from './requests';
import * as module from './video';
import { valueFromDuration } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/duration';
export const loadVideoData = () => (dispatch, getState) => {
const state = getState();
@@ -32,9 +33,9 @@ export const loadVideoData = () => (dispatch, getState) => {
allowTranscriptDownloads: rawVideoData.download_track,
showTranscriptByDefault: rawVideoData.show_captions,
duration: { // TODO duration is not always sent so they should be calculated.
startTime: rawVideoData.start_time,
stopTime: rawVideoData.end_time,
total: null, // TODO can we get total duration? if not, probably dropping from widget
startTime: valueFromDuration(rawVideoData.start_time || '00:00:00'),
stopTime: valueFromDuration(rawVideoData.end_time || '00:00:00'),
total: 0, // TODO can we get total duration? if not, probably dropping from widget
},
handout: rawVideoData.handout,
licenseType,

View File

@@ -40,12 +40,12 @@ const testMetadata = {
download_track: 'dOWNlOAdTraCK',
download_video: 'downLoaDViDEo',
edx_video_id: 'soMEvIDEo',
end_time: 'StOpTIMe',
end_time: 0,
handout: 'hANdoUT',
html5_sources: [],
license: 'liCENse',
show_captions: 'shOWcapTIONS',
start_time: 'stARtTiME',
start_time: 0,
transcripts: { la: 'test VALUE' },
thumbnail: 'thuMBNaIl',
};
@@ -120,7 +120,7 @@ describe('video thunkActions', () => {
duration: {
startTime: testMetadata.start_time,
stopTime: testMetadata.end_time,
total: null,
total: 0,
},
handout: testMetadata.handout,
licenseType: 'liCENSEtyPe',

View File

@@ -3,6 +3,7 @@ import * as urls from './urls';
import { get, post, deleteObject } from './utils';
import * as module from './api';
import * as mockApi from './mockApi';
import { durationFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/duration';
export const apiMethods = {
fetchBlockById: ({ blockId, studioEndpointUrl }) => get(
@@ -121,8 +122,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: content.duration.startTime,
end_time: content.duration.stopTime,
start_time: durationFromValue(content.duration.startTime),
end_time: durationFromValue(content.duration.stopTime),
license: module.processLicense(content.licenseType, content.licenseDetails),
},
};

View File

@@ -97,8 +97,8 @@ describe('cms api', () => {
transcripts: 'traNScRiPts',
allowTranscriptDownloads: 'aLloWTRaNScriPtdoWnlOADS',
duration: {
startTime: 'StArTTime',
stopTime: 'sToPTiME',
startTime: '00:00:00',
stopTime: '00:00:00',
},
showTranscriptByDefault: 'ShOWtrANscriPTBYDeFAulT',
handout: 'HAnDOuT',