feat: duration widget for video settings. TNL-9823.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user