diff --git a/client/state/media/thunks/add-external-media.js b/client/state/media/thunks/add-external-media.js new file mode 100644 index 00000000000000..bb47e29ab3c399 --- /dev/null +++ b/client/state/media/thunks/add-external-media.js @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import { uploadMedia, uploadSingleMedia } from './upload-media'; +import wpcom from 'lib/wp'; + +const getExternalUploader = ( service ) => ( file, siteId ) => { + return wpcom.undocumented().site( siteId ).uploadExternalMedia( service, [ file.guid ] ); +}; + +/** + * Add a single external media file. + * + * @param {object} site The site for which to upload the file(s) + * @param {object|object[]} file The media file or files to upload + * @param {object} service The external media service used + */ +export const addExternalMedia = ( site, file, service ) => ( dispatch ) => { + const uploader = getExternalUploader( service ); + + const action = Array.isArray( file ) ? uploadMedia : uploadSingleMedia; + + return dispatch( action( file, site, uploader ) ); +}; diff --git a/client/state/media/thunks/add-media.js b/client/state/media/thunks/add-media.js index 2a48c3b954e7d9..6820b7998ee9d6 100644 --- a/client/state/media/thunks/add-media.js +++ b/client/state/media/thunks/add-media.js @@ -1,94 +1,19 @@ /** * Internal dependencies */ -import { createTransientMedia, getFileUploader, validateMediaItem } from 'lib/media/utils'; -import { getTransientDate } from 'state/media/utils/transient-date'; -import { - dispatchFluxCreateMediaItem, - dispatchFluxFetchMediaLimits, - dispatchFluxReceiveMediaItemError, - dispatchFluxReceiveMediaItemSuccess, -} from 'state/media/utils/flux-adapter'; -import { - createMediaItem, - receiveMedia, - successMediaItemRequest, - failMediaItemRequest, - setMediaItemErrors, -} from 'state/media/actions'; +import { uploadMedia, uploadSingleMedia } from './upload-media'; +import { getFileUploader } from 'lib/media/utils'; /** - * Add a single media item. Allow passing in the transient date so - * that consumers can upload in series. Use a safe default for when - * only a single item is being uploaded. + * Upload a single media item * - * Restrict this function to purely a single media item. - * - * Note: Temporarily this action will dispatch to the flux store, until - * the flux store is removed. - * - * @param {object} site The site to add the media to - * @param {object} file The file to upload - * @param {string?} transientDate Date for the transient item - * @returns {import('redux-thunk').ThunkAction, any, any, any>} A thunk resolving with the uploaded media item + * @param {object} site The site for which to upload the file(s) + * @param {object|object[]} file The file or files to upload */ -export const addMedia = ( site, file, transientDate = getTransientDate() ) => async ( - dispatch, - getState -) => { - const uploader = getFileUploader( getState(), site, file ); - - const transientMedia = { - date: transientDate, - ...createTransientMedia( file ), - }; - - if ( file.ID ) { - transientMedia.ID = file.ID; - } - - const { ID: siteId } = site; - - dispatchFluxCreateMediaItem( transientMedia, site ); - - const errors = validateMediaItem( site, transientMedia ); - if ( errors?.length ) { - dispatch( setMediaItemErrors( siteId, transientMedia.ID, errors ) ); - // throw rather than silently escape so consumers know the upload failed based on Promise resolution rather than state having to re-derive the failure themselves from state - throw errors; - } - - dispatch( createMediaItem( site, transientMedia ) ); - - try { - const { - media: [ uploadedMedia ], - found, - } = await uploader( file, siteId ); - - dispatchFluxReceiveMediaItemSuccess( transientMedia.ID, siteId, uploadedMedia ); - - dispatch( successMediaItemRequest( siteId, transientMedia.ID ) ); - dispatch( - receiveMedia( - siteId, - { - ...uploadedMedia, - transientId: transientMedia.ID, - }, - found - ) - ); - - dispatchFluxFetchMediaLimits( siteId ); +export const addMedia = ( site, file ) => ( dispatch ) => { + const uploader = getFileUploader(); - return uploadedMedia; - } catch ( error ) { - dispatchFluxReceiveMediaItemError( transientMedia.ID, siteId, error ); + const action = Array.isArray( file ) ? uploadMedia : uploadSingleMedia; - dispatch( failMediaItemRequest( siteId, transientMedia.ID, error ) ); - // no need to dispatch `deleteMedia` as `createMediaItem` won't have added it to the MediaQueryManager which tracks instances. - // rethrow so consumers know the upload failed - throw error; - } + return dispatch( action( file, site, uploader ) ); }; diff --git a/client/state/media/thunks/index.js b/client/state/media/thunks/index.js index 1b987342a1e59c..bcf28cfeaeb10b 100644 --- a/client/state/media/thunks/index.js +++ b/client/state/media/thunks/index.js @@ -1,2 +1,3 @@ export { addMedia } from './add-media'; export { uploadSiteIcon } from './upload-site-icon'; +export { addExternalMedia } from './add-external-media'; diff --git a/client/state/media/thunks/serially.js b/client/state/media/thunks/serially.js new file mode 100644 index 00000000000000..1ed7cfd4f5f474 --- /dev/null +++ b/client/state/media/thunks/serially.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { getBaseTime, getTransientDate } from 'state/media/utils/transient-date'; + +/** + * Creates a function that serially uploads a list of media files using + * the passed in thunk. + * + * @param {Function} mediaAddingAction Dispatchable action accepting a file as a first argument and date as the last argument + */ +export const serially = ( mediaAddingAction ) => ( files, ...extraArgs ) => ( dispatch ) => { + const baseTime = getBaseTime(); + const fileCount = files.length; + + return files.reduce( async ( previousUpload, file, index ) => { + await previousUpload; + const transientDate = getTransientDate( baseTime, index, fileCount ); + try { + return await dispatch( mediaAddingAction( file, ...extraArgs, transientDate ) ); + } catch { + // Swallow the error because inner `mediaAddingAction` will have already handled it + return Promise.resolve(); + } + }, Promise.resolve() ); +}; diff --git a/client/state/media/thunks/test/add-external-media.js b/client/state/media/thunks/test/add-external-media.js new file mode 100644 index 00000000000000..110479c4b331d5 --- /dev/null +++ b/client/state/media/thunks/test/add-external-media.js @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import { addExternalMedia as addExternalMediaThunk } from 'state/media/thunks/add-external-media'; +import { uploadMedia, uploadSingleMedia } from 'state/media/thunks/upload-media'; + +jest.mock( 'state/media/thunks/upload-media', () => ( { + uploadMedia: jest.fn(), + uploadSingleMedia: jest.fn(), +} ) ); + +describe( 'media - thunks - addExternalMedia', () => { + const site = Symbol( 'site' ); + const file = Symbol( 'file' ); + const service = Symbol( 'service' ); + const dispatch = jest.fn(); + const getState = jest.fn(); + + const addExternalMedia = ( ...args ) => addExternalMediaThunk( ...args )( dispatch, getState ); + + it( 'should dispatch to uploadSingleMedia with the file uploader', async () => { + await addExternalMedia( site, file, service ); + + expect( uploadSingleMedia ).toHaveBeenCalledWith( file, site, expect.any( Function ) ); + } ); + + it( 'should dispatch to uploadMedia with the file uploader', async () => { + await addExternalMedia( site, [ file, file ], service ); + + expect( uploadMedia ).toHaveBeenCalledWith( [ file, file ], site, expect.any( Function ) ); + } ); +} ); diff --git a/client/state/media/thunks/test/add-media.js b/client/state/media/thunks/test/add-media.js index 45ea7e3b23fde5..cc168269e72585 100644 --- a/client/state/media/thunks/test/add-media.js +++ b/client/state/media/thunks/test/add-media.js @@ -1,216 +1,37 @@ /** * Internal dependencies */ -import { addMedia as addMediaThunk } from 'state/media/thunks'; -import { getFileUploader, createTransientMedia, validateMediaItem } from 'lib/media/utils'; -import * as dateUtils from 'state/media/utils/transient-date'; -import * as fluxUtils from 'state/media/utils/flux-adapter'; -import * as syncActions from 'state/media/actions'; - -jest.mock( 'lib/media/utils', () => ( { - getFileUploader: jest.fn(), - createTransientMedia: jest.fn(), - validateMediaItem: jest.fn(), +import { addMedia as addMediaThunk } from 'state/media/thunks/add-media'; +import { uploadMedia, uploadSingleMedia } from 'state/media/thunks/upload-media'; +import { getFileUploader } from 'lib/media/utils'; + +jest.mock( 'lib/media/utils', () => ( { getFileUploader: jest.fn() } ) ); +jest.mock( 'state/media/thunks/upload-media', () => ( { + uploadMedia: jest.fn(), + uploadSingleMedia: jest.fn(), } ) ); -describe( 'media - thunks - add-media', () => { +describe( 'media - thunks - addMedia', () => { + const site = Symbol( 'site' ); + const file = Symbol( 'file' ); const dispatch = jest.fn(); const getState = jest.fn(); - const fileUploader = jest.fn(); - - const siteId = 1343323; - const site = { ID: siteId }; - const transientId = Symbol( 'transient id' ); - const transientDate = Symbol( 'transient date' ); - const file = Object.freeze( { - ID: transientId, - fileContents: Symbol( 'file contents' ), - fileName: Symbol( 'file name' ), - } ); - const generatedId = Symbol( 'generated ID' ); - const savedId = Symbol( 'saved id' ); - - beforeEach( () => { - getFileUploader.mockReturnValue( fileUploader ); - fileUploader.mockImplementation( ( mediaFile ) => - Promise.resolve( { media: [ { ...mediaFile, ID: savedId } ], found: 1 } ) - ); - - createTransientMedia.mockImplementation( ( mediaFile ) => ( { - ...mediaFile, - ID: generatedId, - } ) ); - } ); - - afterEach( () => { - jest.resetAllMocks(); - } ); const addMedia = ( ...args ) => addMediaThunk( ...args )( dispatch, getState ); - describe( 'transient id', () => { - it( 'should generate a transient ID', async () => { - createTransientMedia.mockReturnValueOnce( { ID: generatedId } ); - - const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); - - const { ID, ...fileWithoutPassedInId } = file; - - await addMedia( site, fileWithoutPassedInId, transientDate ); - - expect( createMediaItem ).toHaveBeenCalledWith( site, { - date: transientDate, - ID: generatedId, - } ); - } ); - - it( 'should override the generated transient ID with the one passed in', async () => { - // just a descriptive alias - const { ID: passedInId } = file; - const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); - - await addMedia( site, file, transientDate ); - - expect( createMediaItem ).toHaveBeenCalledWith( site, { - ...file, - date: transientDate, - ID: passedInId, - } ); - } ); - } ); - - describe( 'transient date', () => { - it( 'should automatically generate one when one is not provided', async () => { - const getTransientDate = jest.spyOn( dateUtils, 'getTransientDate' ); - const generatedTransientDate = Symbol( 'generated transient date' ); - getTransientDate.mockReturnValueOnce( generatedTransientDate ); - const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); - - await addMedia( site, file ); - - expect( createMediaItem ).toHaveBeenCalledWith( site, { - ...file, - date: generatedTransientDate, - } ); - } ); - } ); - - describe( 'validation', () => { - it( 'should set media item errors and throw if validation returns errors', async () => { - const errors = [ 'an error' ]; - validateMediaItem.mockReturnValueOnce( errors ); - - const setMediaItemErrors = jest.spyOn( syncActions, 'setMediaItemErrors' ); - - await expect( addMedia( site, file, transientDate ) ).rejects.toBe( errors ); - - expect( validateMediaItem ).toHaveBeenCalledWith( site, { - date: transientDate, - ...file, - } ); - expect( setMediaItemErrors ).toHaveBeenCalledWith( siteId, transientId, errors ); - } ); - it.each( [ [], undefined, null ] )( - 'should not set media item errors or throw if validation returns %s', - async ( nonError ) => { - validateMediaItem.mockReturnValueOnce( nonError ); + it( 'should dispatch to uploadSingleMedia with the file uploader', async () => { + const uploader = jest.fn(); + getFileUploader.mockReturnValueOnce( uploader ); + await addMedia( site, file ); - const setMediaItemErrors = jest.spyOn( syncActions, 'setMediaItemErrors' ); - - await addMedia( site, file, transientDate ); - - expect( validateMediaItem ).toHaveBeenCalledWith( site, { - date: transientDate, - ...file, - } ); - expect( setMediaItemErrors ).not.toHaveBeenCalled(); - } - ); + expect( uploadSingleMedia ).toHaveBeenCalledWith( file, site, uploader ); } ); - describe( 'when upload succeeds', () => { - it( 'should create the media item', async () => { - const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); - const successMediaItemRequest = jest.spyOn( syncActions, 'successMediaItemRequest' ); - const receiveMedia = jest.spyOn( syncActions, 'receiveMedia' ); - const failMediaItemRequest = jest.spyOn( syncActions, 'failMediaItemRequest' ); - - await addMedia( site, file, transientDate ); - - expect( createMediaItem ).toHaveBeenCalledWith( site, { ...file, date: transientDate } ); - expect( successMediaItemRequest ).toHaveBeenCalledWith( siteId, transientId ); - expect( receiveMedia ).toHaveBeenCalledWith( - siteId, - { - ...file, - ID: savedId, - transientId, - }, - 1 // found - ); - expect( failMediaItemRequest ).not.toHaveBeenCalled(); - } ); - - describe( 'flux adaptation', () => { - it( 'should dispatch flux create, receive, and media limits actions', async () => { - const fluxCreateMediaItem = jest.spyOn( fluxUtils, 'dispatchFluxCreateMediaItem' ); - const fluxReceiveMediaItemSuccess = jest.spyOn( - fluxUtils, - 'dispatchFluxReceiveMediaItemSuccess' - ); - const fluxFetchMediaLimits = jest.spyOn( fluxUtils, 'dispatchFluxFetchMediaLimits' ); - - await addMedia( site, file, transientDate ); - - expect( fluxCreateMediaItem ).toHaveBeenCalledWith( - { ...file, date: transientDate }, - site - ); - expect( fluxReceiveMediaItemSuccess ).toHaveBeenCalledWith( transientId, siteId, { - ...file, - ID: savedId, - } ); - expect( fluxFetchMediaLimits ).toHaveBeenCalledWith( siteId ); - } ); - } ); - } ); - - describe( 'when upload fails', () => { - it( 'should dispatch failMediaItemRequest and throw', async () => { - const error = new Error( 'mock error' ); - fileUploader.mockRejectedValueOnce( error ); - const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); - const successMediaItemRequest = jest.spyOn( syncActions, 'successMediaItemRequest' ); - const receiveMedia = jest.spyOn( syncActions, 'receiveMedia' ); - const failMediaItemRequest = jest.spyOn( syncActions, 'failMediaItemRequest' ); - - await expect( addMedia( site, file, transientDate ) ).rejects.toBe( error ); - - expect( createMediaItem ).toHaveBeenCalledWith( site, { ...file, date: transientDate } ); - expect( successMediaItemRequest ).not.toHaveBeenCalled(); - expect( receiveMedia ).not.toHaveBeenCalled(); - expect( failMediaItemRequest ).toHaveBeenCalledWith( siteId, transientId, error ); - } ); - - describe( 'flux adaptation', () => { - it( 'should dispatch flux create and error actions', async () => { - const error = new Error( 'mock error' ); - fileUploader.mockRejectedValueOnce( error ); - - const fluxCreateMediaItem = jest.spyOn( fluxUtils, 'dispatchFluxCreateMediaItem' ); - const fluxReceiveMediaItemError = jest.spyOn( - fluxUtils, - 'dispatchFluxReceiveMediaItemError' - ); - - await expect( addMedia( site, file, transientDate ) ).rejects.toBe( error ); + it( 'should dispatch to uploadMedia with the file uploader', async () => { + const uploader = jest.fn(); + getFileUploader.mockReturnValueOnce( uploader ); + await addMedia( site, [ file, file ] ); - expect( fluxCreateMediaItem ).toHaveBeenCalledWith( - { ...file, date: transientDate }, - site - ); - expect( fluxReceiveMediaItemError ).toHaveBeenCalledWith( transientId, siteId, error ); - } ); - } ); + expect( uploadMedia ).toHaveBeenCalledWith( [ file, file ], site, uploader ); } ); } ); diff --git a/client/state/media/thunks/test/serially.js b/client/state/media/thunks/test/serially.js new file mode 100644 index 00000000000000..c6a046602c9484 --- /dev/null +++ b/client/state/media/thunks/test/serially.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { range } from 'lodash'; + +/** + * Internal dependencies + */ +import { serially } from 'state/media/thunks/serially'; +import * as dateUtils from 'state/media/utils/transient-date'; + +describe( 'serially', () => { + const innerThunk = jest.fn(); + const mediaAddingAction = jest.fn( ( ...args ) => () => innerThunk( ...args ) ); + const wrapped = serially( mediaAddingAction ); + const dispatch = jest.fn( ( fn ) => fn() ); + const extraArg1 = Symbol( 'extra arg 1' ); + const extraArg2 = Symbol( 'extra arg 2' ); + + const constantBaseTime = dateUtils.getBaseTime(); + + beforeEach( () => { + // force a constant base time so we can call `getTransientDate` with controlled values + const getBaseTime = jest.spyOn( dateUtils, 'getBaseTime' ); + getBaseTime.mockReturnValue( constantBaseTime ); + mediaAddingAction.mockClear(); + } ); + + const files = range( 3 ).map( ( i ) => Symbol( `file${ i }` ) ); + + it( 'should call the media adding action for each file', async () => { + await wrapped( files, extraArg1, extraArg2 )( dispatch ); + + expect( mediaAddingAction ).toHaveBeenCalledTimes( 3 ); + files.forEach( ( theFile, index ) => { + expect( mediaAddingAction ).toHaveBeenCalledWith( + theFile, + extraArg1, + extraArg2, + dateUtils.getTransientDate( constantBaseTime, index, files.length ) + ); + } ); + } ); + + it( 'should not fail all the uploads if one of them fails', async () => { + innerThunk.mockResolvedValueOnce( 1 ).mockRejectedValueOnce( 2 ).mockResolvedValueOnce( 3 ); + + await expect( wrapped( files, extraArg1, extraArg2 )( dispatch ) ).resolves.toEqual( 3 ); + + expect( mediaAddingAction ).toHaveBeenCalledTimes( 3 ); + } ); +} ); diff --git a/client/state/media/thunks/test/upload-media.js b/client/state/media/thunks/test/upload-media.js new file mode 100644 index 00000000000000..c46554da018317 --- /dev/null +++ b/client/state/media/thunks/test/upload-media.js @@ -0,0 +1,221 @@ +/** + * Internal dependencies + */ +import { uploadSingleMedia as uploadSingleMediaThunk } from 'state/media/thunks/upload-media'; +import { createTransientMedia, validateMediaItem } from 'lib/media/utils'; +import * as dateUtils from 'state/media/utils/transient-date'; +import * as fluxUtils from 'state/media/utils/flux-adapter'; +import * as syncActions from 'state/media/actions'; + +jest.mock( 'lib/media/utils', () => ( { + createTransientMedia: jest.fn(), + validateMediaItem: jest.fn(), +} ) ); + +describe( 'media - thunks - uploadSingleMedia', () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + const fileUploader = jest.fn(); + + const siteId = 1343323; + const site = { ID: siteId }; + const transientId = Symbol( 'transient id' ); + const transientDate = Symbol( 'transient date' ); + const file = Object.freeze( { + ID: transientId, + fileContents: Symbol( 'file contents' ), + fileName: Symbol( 'file name' ), + } ); + const generatedId = Symbol( 'generated ID' ); + const savedId = Symbol( 'saved id' ); + + beforeEach( () => { + fileUploader.mockImplementation( ( mediaFile ) => + Promise.resolve( { media: [ { ...mediaFile, ID: savedId } ], found: 1 } ) + ); + + createTransientMedia.mockImplementation( ( mediaFile ) => ( { + ...mediaFile, + ID: generatedId, + } ) ); + } ); + + afterEach( () => { + jest.resetAllMocks(); + } ); + + const uploadSingleMedia = ( ...args ) => uploadSingleMediaThunk( ...args )( dispatch, getState ); + + describe( 'transient id', () => { + it( 'should generate a transient ID', async () => { + createTransientMedia.mockReturnValueOnce( { ID: generatedId } ); + + const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); + + const { ID, ...fileWithoutPassedInId } = file; + + await uploadSingleMedia( fileWithoutPassedInId, site, fileUploader, transientDate ); + + expect( createMediaItem ).toHaveBeenCalledWith( site, { + date: transientDate, + ID: generatedId, + } ); + } ); + + it( 'should override the generated transient ID with the one passed in', async () => { + // just a descriptive alias + const { ID: passedInId } = file; + const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); + + await uploadSingleMedia( file, site, fileUploader, transientDate ); + + expect( createMediaItem ).toHaveBeenCalledWith( site, { + ...file, + date: transientDate, + ID: passedInId, + } ); + } ); + } ); + + describe( 'transient date', () => { + it( 'should automatically generate one when one is not provided', async () => { + const getTransientDate = jest.spyOn( dateUtils, 'getTransientDate' ); + const generatedTransientDate = Symbol( 'generated transient date' ); + getTransientDate.mockReturnValueOnce( generatedTransientDate ); + const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); + + await uploadSingleMedia( file, site, fileUploader ); + + expect( createMediaItem ).toHaveBeenCalledWith( site, { + ...file, + date: generatedTransientDate, + } ); + } ); + } ); + + describe( 'validation', () => { + it( 'should set media item errors and throw if validation returns errors', async () => { + const errors = [ 'an error' ]; + validateMediaItem.mockReturnValueOnce( errors ); + + const setMediaItemErrors = jest.spyOn( syncActions, 'setMediaItemErrors' ); + + await expect( uploadSingleMedia( file, site, fileUploader, transientDate ) ).rejects.toBe( + errors + ); + + expect( validateMediaItem ).toHaveBeenCalledWith( site, { + date: transientDate, + ...file, + } ); + expect( setMediaItemErrors ).toHaveBeenCalledWith( siteId, transientId, errors ); + } ); + + it.each( [ [], undefined, null ] )( + 'should not set media item errors or throw if validation returns %s', + async ( nonError ) => { + validateMediaItem.mockReturnValueOnce( nonError ); + + const setMediaItemErrors = jest.spyOn( syncActions, 'setMediaItemErrors' ); + + await uploadSingleMedia( file, site, fileUploader, transientDate ); + + expect( validateMediaItem ).toHaveBeenCalledWith( site, { + date: transientDate, + ...file, + } ); + expect( setMediaItemErrors ).not.toHaveBeenCalled(); + } + ); + } ); + + describe( 'when upload succeeds', () => { + it( 'should create the media item', async () => { + const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); + const successMediaItemRequest = jest.spyOn( syncActions, 'successMediaItemRequest' ); + const receiveMedia = jest.spyOn( syncActions, 'receiveMedia' ); + const failMediaItemRequest = jest.spyOn( syncActions, 'failMediaItemRequest' ); + + await uploadSingleMedia( file, site, fileUploader, transientDate ); + + expect( createMediaItem ).toHaveBeenCalledWith( site, { ...file, date: transientDate } ); + expect( successMediaItemRequest ).toHaveBeenCalledWith( siteId, transientId ); + expect( receiveMedia ).toHaveBeenCalledWith( + siteId, + { + ...file, + ID: savedId, + transientId, + }, + 1 // found + ); + expect( failMediaItemRequest ).not.toHaveBeenCalled(); + } ); + + describe( 'flux adaptation', () => { + it( 'should dispatch flux create, receive, and media limits actions', async () => { + const fluxCreateMediaItem = jest.spyOn( fluxUtils, 'dispatchFluxCreateMediaItem' ); + const fluxReceiveMediaItemSuccess = jest.spyOn( + fluxUtils, + 'dispatchFluxReceiveMediaItemSuccess' + ); + const fluxFetchMediaLimits = jest.spyOn( fluxUtils, 'dispatchFluxFetchMediaLimits' ); + + await uploadSingleMedia( file, site, fileUploader, transientDate ); + + expect( fluxCreateMediaItem ).toHaveBeenCalledWith( + { ...file, date: transientDate }, + site + ); + expect( fluxReceiveMediaItemSuccess ).toHaveBeenCalledWith( transientId, siteId, { + ...file, + ID: savedId, + } ); + expect( fluxFetchMediaLimits ).toHaveBeenCalledWith( siteId ); + } ); + } ); + } ); + + describe( 'when upload fails', () => { + it( 'should dispatch failMediaItemRequest and throw', async () => { + const error = new Error( 'mock error' ); + fileUploader.mockRejectedValueOnce( error ); + const createMediaItem = jest.spyOn( syncActions, 'createMediaItem' ); + const successMediaItemRequest = jest.spyOn( syncActions, 'successMediaItemRequest' ); + const receiveMedia = jest.spyOn( syncActions, 'receiveMedia' ); + const failMediaItemRequest = jest.spyOn( syncActions, 'failMediaItemRequest' ); + + await expect( uploadSingleMedia( file, site, fileUploader, transientDate ) ).rejects.toBe( + error + ); + + expect( createMediaItem ).toHaveBeenCalledWith( site, { ...file, date: transientDate } ); + expect( successMediaItemRequest ).not.toHaveBeenCalled(); + expect( receiveMedia ).not.toHaveBeenCalled(); + expect( failMediaItemRequest ).toHaveBeenCalledWith( siteId, transientId, error ); + } ); + + describe( 'flux adaptation', () => { + it( 'should dispatch flux create and error actions', async () => { + const error = new Error( 'mock error' ); + fileUploader.mockRejectedValueOnce( error ); + + const fluxCreateMediaItem = jest.spyOn( fluxUtils, 'dispatchFluxCreateMediaItem' ); + const fluxReceiveMediaItemError = jest.spyOn( + fluxUtils, + 'dispatchFluxReceiveMediaItemError' + ); + + await expect( uploadSingleMedia( file, site, fileUploader, transientDate ) ).rejects.toBe( + error + ); + + expect( fluxCreateMediaItem ).toHaveBeenCalledWith( + { ...file, date: transientDate }, + site + ); + expect( fluxReceiveMediaItemError ).toHaveBeenCalledWith( transientId, siteId, error ); + } ); + } ); + } ); +} ); diff --git a/client/state/media/thunks/upload-media.js b/client/state/media/thunks/upload-media.js new file mode 100644 index 00000000000000..9c7b42b881c919 --- /dev/null +++ b/client/state/media/thunks/upload-media.js @@ -0,0 +1,98 @@ +/** + * Internal dependencies + */ +import { createTransientMedia, validateMediaItem } from 'lib/media/utils'; +import { getTransientDate } from 'state/media/utils/transient-date'; +import { + dispatchFluxCreateMediaItem, + dispatchFluxFetchMediaLimits, + dispatchFluxReceiveMediaItemError, + dispatchFluxReceiveMediaItemSuccess, +} from 'state/media/utils/flux-adapter'; +import { + createMediaItem, + receiveMedia, + successMediaItemRequest, + failMediaItemRequest, + setMediaItemErrors, +} from 'state/media/actions'; +import { serially } from 'state/media/thunks/serially'; + +/** + * Add a single media item. Allow passing in the transient date so + * that consumers can upload in series. Use a safe default for when + * only a single item is being uploaded. + * + * Restrict this function to purely a single media item. + * + * Note: Temporarily this action will dispatch to the flux store, until + * the flux store is removed. + * + * @param {object} file The file to upload + * @param {object} site The site to add the media to + * @param {Function} uploader The file uploader to use + * @param {string?} transientDate Date for the transient item + * @returns {import('redux-thunk').ThunkAction, any, any, any>} A thunk resolving with the uploaded media item + */ +export const uploadSingleMedia = ( + file, + site, + uploader, + transientDate = getTransientDate() +) => async ( dispatch ) => { + const transientMedia = { + date: transientDate, + ...createTransientMedia( file ), + }; + + if ( file.ID ) { + transientMedia.ID = file.ID; + } + + const { ID: siteId } = site; + + dispatchFluxCreateMediaItem( transientMedia, site ); + + const errors = validateMediaItem( site, transientMedia ); + if ( errors?.length ) { + dispatch( setMediaItemErrors( siteId, transientMedia.ID, errors ) ); + // throw rather than silently escape so consumers know the upload failed based on Promise resolution rather than state having to re-derive the failure themselves from state + throw errors; + } + + dispatch( createMediaItem( site, transientMedia ) ); + + try { + const { + media: [ uploadedMedia ], + found, + } = await uploader( file, siteId ); + + dispatchFluxReceiveMediaItemSuccess( transientMedia.ID, siteId, uploadedMedia ); + + dispatch( successMediaItemRequest( siteId, transientMedia.ID ) ); + dispatch( + receiveMedia( + siteId, + { + ...uploadedMedia, + transientId: transientMedia.ID, + }, + found + ) + ); + + dispatchFluxFetchMediaLimits( siteId ); + + return uploadedMedia; + } catch ( error ) { + dispatchFluxReceiveMediaItemError( transientMedia.ID, siteId, error ); + + dispatch( failMediaItemRequest( siteId, transientMedia.ID, error ) ); + // no need to dispatch `deleteMedia` as `createMediaItem` won't have added it to the MediaQueryManager which tracks instances. + // rethrow so consumers know the upload failed + throw error; + } +}; + +export const uploadMedia = serially( uploadSingleMedia );