feat: video editor fix to disappearing transcript (#178)

* feat: video editor fix to disappearing transcript
This commit is contained in:
Raymond Zhou
2023-01-12 09:51:56 -08:00
committed by GitHub
parent 574c2cc76a
commit aad7a6b706
9 changed files with 178 additions and 248 deletions

View File

@@ -1,70 +1,19 @@
import { actions } from '../../../../../../data/redux';
import { isEdxVideo } from '../../../../../../data/services/cms/api';
/**
* updateVideoId({ dispatch })({e, source})
* updateVideoId takes the current onBlur event, the current object of the video
* source, and dispatch method, and updates the redux value for all the fields to
* their default values except videoId, fallbackVideos, and handouts.
* @param {event} e - object for onBlur event
* @param {func} dispatch - redux dispatch method
* @param {object} source - object for the Video Source field functions and values
*/
export const updateVideoId = ({ dispatch }) => ({ e, source }) => {
if (source.local !== '') {
if (source.formValue !== e.target.value) {
source.onBlur(e);
let videoId;
let videoSource;
if (isEdxVideo(source.local)) {
videoId = source.local;
videoSource = '';
} else if (source.local.includes('youtu.be') || source.local.includes('youtube')) {
videoId = '';
videoSource = source.local;
} else {
videoId = '';
videoSource = source.local;
}
dispatch(actions.video.updateField({
videoId,
videoSource,
allowVideoDownloads: false,
thumbnail: null,
transcripts: [],
allowTranscriptDownloads: false,
showTranscriptByDefault: false,
duration: {
startTime: '00:00:00',
stopTime: '00:00:00',
total: '00:00:00',
},
licenseType: null,
}));
}
}
export const sourceHooks = ({ dispatch }) => ({
updateVideoURL: (e) => dispatch(actions.video.updateField({ videoSource: e.target.value })),
updateVideoId: (e) => dispatch(actions.video.updateField({ videoId: e.target.value })),
});
export const fallbackHooks = ({ fallbackVideos, dispatch }) => ({
addFallbackVideo: () => dispatch(actions.video.updateField({ fallbackVideos: [...fallbackVideos, ''] })),
deleteFallbackVideo: (videoUrl) => {
const updatedFallbackVideos = fallbackVideos.splice(fallbackVideos.indexOf(videoUrl), 1);
dispatch(actions.video.updateField({ fallbackVideos: updatedFallbackVideos }));
},
});
export default {
sourceHooks,
fallbackHooks,
};
/**
* deleteFallbackVideo({ fallbackVideos, dispatch })(videoUrl)
* deleteFallbackVideo takes the current array of fallback videos, string of
* deleted video URL and dispatch method, and updates the redux value for
* fallbackVideos.
* @param {array} fallbackVideos - array of current fallback videos
* @param {func} dispatch - redux dispatch method
* @param {string} videoUrl - string of the video URL for the fallabck video that needs to be deleted
*/
export const deleteFallbackVideo = ({ fallbackVideos, dispatch }) => (videoUrl) => {
const updatedFallbackVideos = [];
let firstOccurence = true;
fallbackVideos.forEach(item => {
if (item === videoUrl && firstOccurence) {
firstOccurence = false;
} else {
updatedFallbackVideos.push(item);
}
});
dispatch(actions.video.updateField({ fallbackVideos: updatedFallbackVideos }));
};
export default { deleteFallbackVideo };

View File

@@ -6,6 +6,7 @@ jest.mock('react-redux', () => {
const dispatchFn = jest.fn();
return {
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
dispatch: dispatchFn,
useDispatch: jest.fn(() => dispatchFn),
};
@@ -20,78 +21,61 @@ jest.mock('../../../../../../data/redux', () => ({
}));
describe('VideoEditorHandout hooks', () => {
describe('updateVideoId', () => {
const sourceEdxVideo = {
onBlur: jest.fn(),
local: '06b15030-7df0-4e70-b979-326e02dbcbe0',
};
const sourceYouTube = {
onBlur: jest.fn(),
local: 'youtu.be',
};
const sourceHtml5Source = {
onBlur: jest.fn(),
local: 'sOMEranDomfILe.mp4',
};
const mockState = {
videoId: '',
videoSource: '',
allowVideoDownloads: false,
thumbnail: null,
transcripts: [],
allowTranscriptDownloads: false,
showTranscriptByDefault: false,
duration: {
startTime: '00:00:00',
stopTime: '00:00:00',
total: '00:00:00',
},
licenseType: null,
};
it('returns dispatches updateField action with default state and edxVideo Id', () => {
hooks.updateVideoId({ dispatch })({ e: { target: { value: sourceEdxVideo.local } }, source: sourceEdxVideo });
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
...mockState,
videoId: sourceEdxVideo.local,
}),
);
let hook;
describe('sourceHooks', () => {
const e = { target: { value: 'soMEvALuE' } };
beforeEach(() => {
hook = hooks.sourceHooks({ dispatch });
});
it('returns dispatches updateField action with default state and YouTube video', () => {
hooks.updateVideoId({ dispatch })({
e: { target: { value: sourceYouTube.local } },
source: sourceYouTube,
describe('updateVideoURL', () => {
it('dispatches updateField action with new videoSource', () => {
hook.updateVideoURL(e);
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
videoSource: e.target.value,
}),
);
});
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
...mockState,
}),
);
});
it('returns dispatches updateField action with default state and html5source video', () => {
hooks.updateVideoId({ dispatch })({
e: { target: { value: sourceHtml5Source.local } },
source: sourceHtml5Source,
describe('updateVideoId', () => {
it('dispatches updateField action with new videoId', () => {
hook.updateVideoId(e);
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
videoId: e.target.value,
}),
);
});
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
...mockState,
}),
);
});
});
describe('deleteFallbackVideo', () => {
describe('fallbackHooks', () => {
const videoUrl = 'sOmERAndoMuRl1';
const fallbackVideos = ['sOmERAndoMuRl1', 'sOmERAndoMuRl2', 'sOmERAndoMuRl1', ''];
const updatedFallbackVideos = ['sOmERAndoMuRl2', 'sOmERAndoMuRl1', ''];
it('returns dispatches updateField action with updatedFallbackVideos', () => {
hooks.deleteFallbackVideo({ fallbackVideos, dispatch })(videoUrl);
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
fallbackVideos: updatedFallbackVideos,
}),
);
beforeEach(() => {
hook = hooks.fallbackHooks({ fallbackVideos, dispatch });
});
describe('addFallbackVideo', () => {
it('dispatches updateField action with updated array appended by a new empty element', () => {
hook.addFallbackVideo();
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
fallbackVideos: [...fallbackVideos, ''],
}),
);
});
});
describe('deleteFallbackVideo', () => {
it('dispatches updateField action with updated array with videoUrl removed', () => {
const updatedFallbackVideos = ['sOmERAndoMuRl2', 'sOmERAndoMuRl1', ''];
hook.deleteFallbackVideo(videoUrl);
expect(dispatch).toHaveBeenCalledWith(
actions.video.updateField({
fallbackVideos: updatedFallbackVideos,
}),
);
});
});
});
});

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import {
Form,
@@ -20,9 +19,8 @@ import {
} from '@edx/frontend-platform/i18n';
import * as widgetHooks from '../hooks';
import * as module from './hooks';
import * as hooks from './hooks';
import messages from './messages';
import { actions } from '../../../../../../data/redux';
import CollapsibleFormWidget from '../CollapsibleFormWidget';
@@ -32,8 +30,6 @@ import CollapsibleFormWidget from '../CollapsibleFormWidget';
export const VideoSourceWidget = ({
// injected
intl,
// redux
updateField,
}) => {
const dispatch = useDispatch();
const {
@@ -50,8 +46,11 @@ export const VideoSourceWidget = ({
[widgetHooks.selectorKeys.allowVideoDownloads]: widgetHooks.genericWidget,
},
});
const deleteFallbackVideo = module.deleteFallbackVideo({ fallbackVideos: fallbackVideos.formValue, dispatch });
const updateVideoId = module.updateVideoId({ dispatch });
const { updateVideoId, updateVideoURL } = hooks.sourceHooks({ dispatch });
const {
addFallbackVideo,
deleteFallbackVideo,
} = hooks.fallbackHooks({ fallbackVideos: fallbackVideos.formValue, dispatch });
return (
<CollapsibleFormWidget
@@ -63,7 +62,7 @@ export const VideoSourceWidget = ({
<Form.Control
floatingLabel={intl.formatMessage(messages.videoIdLabel)}
onChange={videoId.onChange}
onBlur={(e) => updateVideoId({ e, source: videoId })}
onBlur={updateVideoId}
value={videoId.local}
/>
<FormControlFeedback className="text-primary-300 mb-4">
@@ -72,7 +71,7 @@ export const VideoSourceWidget = ({
<Form.Control
floatingLabel={intl.formatMessage(messages.videoUrlLabel)}
onChange={source.onChange}
onBlur={(e) => updateVideoId({ e, source })}
onBlur={updateVideoURL}
value={source.local}
/>
<FormControlFeedback className="text-primary-300">
@@ -134,7 +133,7 @@ export const VideoSourceWidget = ({
size="sm"
iconBefore={Add}
variant="link"
onClick={() => updateField({ fallbackVideos: [...fallbackVideos.formValue, ''] })}
onClick={() => addFallbackVideo()}
>
<FormattedMessage {...messages.addButtonLabel} />
</Button>
@@ -144,12 +143,6 @@ export const VideoSourceWidget = ({
VideoSourceWidget.propTypes = {
// injected
intl: intlShape.isRequired,
// redux
updateField: PropTypes.func.isRequired,
};
export const mapStateToProps = () => ({});
export const mapDispatchToProps = (dispatch) => ({
updateField: (stateUpdate) => dispatch(actions.video.updateField(stateUpdate)),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(VideoSourceWidget));
export default injectIntl(VideoSourceWidget);

View File

@@ -1,25 +1,19 @@
import React from 'react';
import { dispatch } from 'react-redux';
import { shallow } from 'enzyme';
import { formatMessage } from '../../../../../../../testUtils';
import { actions } from '../../../../../../data/redux';
import { VideoSourceWidget, mapDispatchToProps } from '.';
import { VideoSourceWidget } from '.';
import * as hooks from './hooks';
jest.mock('../../../../../../data/redux', () => ({
actions: {
video: {
updateField: jest.fn().mockName('actions.video.updateField'),
},
},
selectors: {
video: {
videoSource: jest.fn(state => ({ videoSource: state })),
videoId: jest.fn(state => ({ videoId: state })),
fallbackVideos: jest.fn(state => ({ fallbackVideos: state })),
allowVideoDownloads: jest.fn(state => ({ allowVideoDownloads: state })),
},
},
}));
jest.mock('react-redux', () => {
const dispatchFn = jest.fn();
return {
...jest.requireActual('react-redux'),
dispatch: dispatchFn,
useDispatch: jest.fn(() => dispatchFn),
};
});
jest.mock('../hooks', () => ({
selectorKeys: ['soMEkEy'],
@@ -36,14 +30,21 @@ jest.mock('../hooks', () => ({
}),
}));
jest.mock('./hooks', () => ({
sourceHooks: jest.fn().mockReturnValue({
updateVideoId: (args) => ({ updateVideoId: args }),
updateVideoURL: (args) => ({ updateVideoURL: args }),
}),
fallbackHooks: jest.fn().mockReturnValue({
addFallbackVideo: jest.fn().mockName('addFallbackVideo'),
deleteFallbackVideo: jest.fn().mockName('deleteFallbackVideo'),
}),
}));
describe('VideoSourceWidget', () => {
const props = {
error: {},
title: 'tiTLE',
// inject
intl: { formatMessage },
// redux
updateField: jest.fn().mockName('args.updateField'),
};
describe('snapshots', () => {
@@ -53,10 +54,27 @@ describe('VideoSourceWidget', () => {
).toMatchSnapshot();
});
});
describe('mapDispatchToProps', () => {
const dispatch = jest.fn();
test('updateField from actions.video.updateField', () => {
expect(mapDispatchToProps.updateField).toEqual(dispatch(actions.video.updateField));
describe('behavior inspection', () => {
let el;
let hook;
beforeEach(() => {
el = shallow(<VideoSourceWidget {...props} />);
hook = hooks.sourceHooks({ dispatch });
});
test('updateVideoId is tied to id field onBlur', () => {
const expected = hook.updateVideoId;
expect(el
// eslint-disable-next-line
.children().at(0).children().at(0).children().at(0)
.props().onBlur).toEqual(expected);
});
test('updateVideoURL is tied to url field onBlur', () => {
const expected = hook.updateVideoURL;
expect(el
// eslint-disable-next-line
.children().at(0).children().at(0).children().at(2)
.props().onBlur).toEqual(expected);
});
});
});

View File

@@ -11,10 +11,10 @@ export const loadVideoData = () => (dispatch, getState) => {
const courseLicenseData = state.app.courseDetails.data ? state.app.courseDetails.data : {};
const studioView = state.app.studioView?.data?.html;
const {
videoSource,
videoId,
videoUrl,
fallbackVideos,
} = module.determineVideoSource({
} = module.determineVideoSources({
edxVideoId: rawVideoData.edx_video_id,
youtubeId: rawVideoData.youtube_id_1_0,
html5Sources: rawVideoData.html5_sources,
@@ -27,7 +27,7 @@ export const loadVideoData = () => (dispatch, getState) => {
});
dispatch(actions.video.load({
videoSource,
videoSource: videoUrl,
videoId,
fallbackVideos,
allowVideoDownloads: rawVideoData.download_video,
@@ -61,7 +61,7 @@ export const loadVideoData = () => (dispatch, getState) => {
allowThumbnailUpload: response.data.allowThumbnailUpload,
})),
}));
const youTubeId = parseYoutubeId(videoSource);
const youTubeId = parseYoutubeId(videoUrl);
if (youTubeId) {
dispatch(requests.checkTranscriptsForImport({
videoId,
@@ -77,33 +77,23 @@ export const loadVideoData = () => (dispatch, getState) => {
}
};
export const determineVideoSource = ({
export const determineVideoSources = ({
edxVideoId,
youtubeId,
html5Sources,
}) => {
// videoSource should be the edx_video_id, the youtube url or the first fallback url in that order.
// If we are falling back to the first fallback url, remove it from the list of fallback urls for display.
const youtubeUrl = `https://youtu.be/${youtubeId}`;
const videoId = edxVideoId || '';
let videoSource = '';
let fallbackVideos = [];
let videoUrl;
let fallbackVideos;
if (youtubeId) {
// videoSource = youtubeUrl;
// fallbackVideos = html5Sources;
[videoSource, fallbackVideos] = [youtubeUrl, html5Sources];
} else if (edxVideoId) {
// fallbackVideos = html5Sources;
fallbackVideos = html5Sources;
[videoUrl, fallbackVideos] = [youtubeUrl, html5Sources];
} else if (Array.isArray(html5Sources) && html5Sources[0]) {
// videoSource = html5Sources[0];
// fallbackVideos = html5Sources.slice(1);
[videoSource, fallbackVideos] = [html5Sources[0], html5Sources.slice(1)];
[videoUrl, fallbackVideos] = [html5Sources[0], html5Sources.slice(1)];
}
return {
videoSource,
videoId,
fallbackVideos,
videoId: edxVideoId,
videoUrl: videoUrl || '',
fallbackVideos: fallbackVideos || [],
};
};
@@ -343,7 +333,7 @@ export const replaceTranscript = ({ newFile, newFilename, language }) => (dispat
export default {
loadVideoData,
determineVideoSource,
determineVideoSources,
parseLicense,
saveVideoData,
uploadThumbnail,

View File

@@ -99,8 +99,8 @@ describe('video thunkActions', () => {
let dispatchedAction1;
let dispatchedAction2;
beforeEach(() => {
jest.spyOn(thunkActions, thunkActionsKeys.determineVideoSource).mockReturnValue({
videoSource: 'videOsOurce',
jest.spyOn(thunkActions, thunkActionsKeys.determineVideoSources).mockReturnValue({
videoUrl: 'videOsOurce',
videoId: 'videOiD',
fallbackVideos: 'fALLbACKvIDeos',
});
@@ -176,69 +176,69 @@ describe('video thunkActions', () => {
}));
});
});
describe('determineVideoSource', () => {
describe('determineVideoSources', () => {
const edxVideoId = 'EDxviDEoiD';
const youtubeId = 'yOuTuBEiD';
const youtubeUrl = `https://youtu.be/${youtubeId}`;
const html5Sources = ['htmLOne', 'hTMlTwo', 'htMLthrEE'];
describe('when there is an edx video id, youtube id and html5 sources', () => {
it('returns the youtube id for video source and html5 sources for fallback videos', () => {
expect(thunkActions.determineVideoSource({
it('returns all three with the youtube id wrapped in url', () => {
expect(thunkActions.determineVideoSources({
edxVideoId,
youtubeId,
html5Sources,
})).toEqual({
videoSource: youtubeUrl,
videoUrl: youtubeUrl,
videoId: edxVideoId,
fallbackVideos: html5Sources,
});
});
});
describe('when there is an edx video id', () => {
describe('when there is only an edx video id', () => {
it('returns the edx video id for video source', () => {
expect(thunkActions.determineVideoSource({
expect(thunkActions.determineVideoSources({
edxVideoId,
youtubeId: '',
html5Sources: '',
})).toEqual({
videoSource: '',
videoUrl: '',
videoId: edxVideoId,
fallbackVideos: '',
fallbackVideos: [],
});
});
});
describe('when there is no edx video id', () => {
it('returns the youtube url for video source and html5 sources for fallback videos', () => {
expect(thunkActions.determineVideoSource({
expect(thunkActions.determineVideoSources({
edxVideoId: '',
youtubeId,
html5Sources,
})).toEqual({
videoSource: youtubeUrl,
videoUrl: youtubeUrl,
videoId: '',
fallbackVideos: html5Sources,
});
});
});
describe('when there is no edx video id and no youtube id', () => {
it('returns the first html5 source for video source and the rest for fallback videos', () => {
expect(thunkActions.determineVideoSource({
it('returns the first html5 source for video url and the rest for fallback videos', () => {
expect(thunkActions.determineVideoSources({
edxVideoId: '',
youtubeId: '',
html5Sources,
})).toEqual({
videoSource: 'htmLOne',
videoUrl: 'htmLOne',
videoId: '',
fallbackVideos: ['hTMlTwo', 'htMLthrEE'],
});
});
it('returns the html5 source for video source and an array with 2 empty values for fallback videos', () => {
expect(thunkActions.determineVideoSource({
expect(thunkActions.determineVideoSources({
edxVideoId: '',
youtubeId: '',
html5Sources: ['htmlOne'],
})).toEqual({
videoSource: 'htmlOne',
videoUrl: 'htmlOne',
videoId: '',
fallbackVideos: [],
});
@@ -246,12 +246,12 @@ describe('video thunkActions', () => {
});
describe('when there is no edx video id, no youtube id and no html5 sources', () => {
it('returns an empty string for video source and an array with 2 empty values for fallback videos', () => {
expect(thunkActions.determineVideoSource({
expect(thunkActions.determineVideoSources({
edxVideoId: '',
youtubeId: '',
html5Sources: [],
})).toEqual({
videoSource: '',
videoUrl: '',
videoId: '',
fallbackVideos: [],
});

View File

@@ -157,7 +157,7 @@ export const apiMethods = {
youtubeId,
} = module.processVideoIds({
videoId: content.videoId,
videoSource: content.videoSource,
videoUrl: content.videoSource,
fallbackVideos: content.fallbackVideos,
});
response = {
@@ -217,21 +217,18 @@ export const loadImages = (rawImages) => camelizeKeys(rawImages).reduce(
export const processVideoIds = ({
videoId,
videoSource,
videoUrl,
fallbackVideos,
edxVideoId,
}) => {
let newEdxVideoId = edxVideoId;
let youtubeId = '';
const html5Sources = [];
// overwrite videoId if source is changed.
if (module.isEdxVideo(videoId)) {
newEdxVideoId = videoId;
} else if (module.parseYoutubeId(videoSource)) {
youtubeId = module.parseYoutubeId(videoSource);
} else if (videoSource) {
html5Sources.push(videoSource);
if (videoUrl) {
if (module.parseYoutubeId(videoUrl)) {
youtubeId = module.parseYoutubeId(videoUrl);
} else {
html5Sources.push(videoUrl);
}
}
if (fallbackVideos) {
@@ -239,7 +236,7 @@ export const processVideoIds = ({
}
return {
edxVideoId: newEdxVideoId,
edxVideoId: videoId,
html5Sources,
youtubeId,
};

View File

@@ -333,7 +333,8 @@ describe('cms api', () => {
});
describe('processVideoIds', () => {
const edxVideoId = 'eDXviDEoid';
const youtubeId = 'yOuTuBeID';
const youtubeId = 'yOuTuBeUrL';
const youtubeUrl = `https://youtu.be/${youtubeId}`;
const html5Sources = [
'sOuRce1',
'sourCE2',
@@ -341,15 +342,14 @@ describe('cms api', () => {
afterEach(() => {
jest.restoreAllMocks();
});
describe('if the videoSource is an edx video id', () => {
describe('if there is a video id', () => {
beforeEach(() => {
jest.spyOn(api, 'isEdxVideo').mockReturnValue(true);
jest.spyOn(api, 'parseYoutubeId').mockReturnValue(null);
jest.spyOn(api, 'parseYoutubeId').mockReturnValue(youtubeId);
});
it('returns edxVideoId when there are no fallbackVideos', () => {
expect(api.processVideoIds({
edxVideoId,
videoSource: '',
videoUrl: '',
fallbackVideos: [],
videoId: edxVideoId,
})).toEqual({
@@ -360,42 +360,39 @@ describe('cms api', () => {
});
it('returns edxVideoId and html5Sources when there are fallbackVideos', () => {
expect(api.processVideoIds({
edxVideoId,
videoSource: 'edxVideoId',
videoUrl: youtubeUrl,
fallbackVideos: html5Sources,
videoId: edxVideoId,
})).toEqual({
edxVideoId,
html5Sources,
youtubeId: '',
youtubeId,
});
});
});
describe('if the videoSource is a youtube url', () => {
describe('if there is a youtube url', () => {
beforeEach(() => {
jest.spyOn(api, 'isEdxVideo').mockReturnValue(false);
jest.spyOn(api, 'parseYoutubeId').mockReturnValue(youtubeId);
});
it('returns youtubeId when there are no fallbackVideos', () => {
expect(api.processVideoIds({
edxVideoId,
videoSource: edxVideoId,
videoUrl: youtubeUrl,
fallbackVideos: [],
videoId: '',
})).toEqual({
edxVideoId,
edxVideoId: '',
html5Sources: [],
youtubeId,
});
});
it('returns youtubeId and html5Sources when there are fallbackVideos', () => {
expect(api.processVideoIds({
edxVideoId,
videoSource: edxVideoId,
videoUrl: youtubeUrl,
fallbackVideos: html5Sources,
videoId: '',
})).toEqual({
edxVideoId,
edxVideoId: '',
html5Sources,
youtubeId,
});
@@ -408,24 +405,22 @@ describe('cms api', () => {
});
it('returns html5Sources when there are no fallbackVideos', () => {
expect(api.processVideoIds({
edxVideoId,
videoSource: html5Sources[0],
videoUrl: html5Sources[0],
fallbackVideos: [],
videoId: '',
})).toEqual({
edxVideoId,
edxVideoId: '',
html5Sources: [html5Sources[0]],
youtubeId: '',
});
});
it('returns html5Sources when there are fallbackVideos', () => {
expect(api.processVideoIds({
edxVideoId,
videoSource: html5Sources[0],
videoUrl: html5Sources[0],
fallbackVideos: [html5Sources[1]],
videoId: '',
})).toEqual({
edxVideoId,
edxVideoId: '',
html5Sources,
youtubeId: '',
});

View File

@@ -287,3 +287,7 @@ export const fetchStudioView = ({ blockId, studioEndpointUrl }) => {
},
});
};
export const checkTranscriptsForImport = () => mockPromise({});
export const uploadTranscript = () => mockPromise({});