diff --git a/apps/journeys-admin/__generated__/CloudflareUploadComplete.ts b/apps/journeys-admin/__generated__/CloudflareUploadComplete.ts new file mode 100644 index 00000000000..3668e84acb5 --- /dev/null +++ b/apps/journeys-admin/__generated__/CloudflareUploadComplete.ts @@ -0,0 +1,16 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: CloudflareUploadComplete +// ==================================================== + +export interface CloudflareUploadComplete { + cloudflareUploadComplete: boolean; +} + +export interface CloudflareUploadCompleteVariables { + id: string; +} diff --git a/apps/journeys-admin/__generated__/NewCloudflareImage.ts b/apps/journeys-admin/__generated__/NewCloudflareImage.ts new file mode 100644 index 00000000000..5952886fe52 --- /dev/null +++ b/apps/journeys-admin/__generated__/NewCloudflareImage.ts @@ -0,0 +1,15 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL fragment: NewCloudflareImage +// ==================================================== + +export interface NewCloudflareImage { + __typename: "CloudflareImage"; + id: string; + url: string | null; + blurhash: string | null; +} diff --git a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx index bb5d2f6d31e..63f877a6c76 100644 --- a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx +++ b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx @@ -5,9 +5,30 @@ import fetch, { Response } from 'node-fetch' import { BlockFields_ImageBlock as ImageBlock } from '../../../../../../../../../__generated__/BlockFields' import { CREATE_CLOUDFLARE_UPLOAD_BY_FILE } from '../../../../../../../../libs/useCloudflareUploadByFileMutation/useCloudflareUploadByFileMutation' +import { CLOUDFLARE_UPLOAD_COMPLETE } from '../../../../../../../../libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation' import { ImageUpload } from './ImageUpload' +const cloudflareUploadCompleteMockResult = jest.fn(() => ({ + data: { cloudflareUploadComplete: true } +})) + +const cloudflareUploadCompleteMock = { + request: { + query: CLOUDFLARE_UPLOAD_COMPLETE, + variables: { id: 'uploadId' } + }, + result: cloudflareUploadCompleteMockResult +} + +const cloudflareUploadCompleteErrorMock = { + request: { + query: CLOUDFLARE_UPLOAD_COMPLETE, + variables: { id: 'uploadId' } + }, + error: new Error('cloudflareUploadComplete failed') +} + jest.mock('node-fetch', () => { const originalModule = jest.requireActual('node-fetch') return { @@ -40,6 +61,7 @@ describe('ImageUpload', () => { afterEach(() => { process.env = originalEnv mockSendGTMEvent.mockClear() + cloudflareUploadCompleteMockResult.mockClear() }) const imageBlock: ImageBlock = { @@ -139,7 +161,8 @@ describe('ImageUpload', () => { } } } - } + }, + cloudflareUploadCompleteMock ]} > { focalTop: 50 }) ) + expect(cloudflareUploadCompleteMockResult).toHaveBeenCalled() expect(screen.getByText('Upload Successful!')).toBeInTheDocument() }) + it('should not call cloudflareUploadComplete on rejected file', async () => { + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + const largeFile = new File([new ArrayBuffer(11000000)], 'large.png', { + type: 'image/png' + }) + Object.defineProperty(inputEl, 'files', { value: [largeFile] }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(screen.getByText('Upload Failed!')).toBeInTheDocument() + ) + expect(cloudflareUploadCompleteMockResult).not.toHaveBeenCalled() + }) + + it('should not call cloudflareUploadComplete on Cloudflare error response', async () => { + const cfErrorResponse = { + result: { id: 'uploadId' }, + errors: [{ code: 5000, message: 'Upload failed' }], + messages: [], + success: false + } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => await Promise.resolve(cfErrorResponse) + } as unknown as Response) + + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + Object.defineProperty(inputEl, 'files', { + value: [ + new File([new Blob(['file'])], 'testFile.png', { type: 'image/png' }) + ] + }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(screen.getByText('Upload Failed!')).toBeInTheDocument() + ) + expect(cloudflareUploadCompleteMockResult).not.toHaveBeenCalled() + }) + + it('should not call cloudflareUploadComplete on fetch exception', async () => { + mockFetch.mockRejectedValueOnce(new Error('network')) + + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + Object.defineProperty(inputEl, 'files', { + value: [ + new File([new Blob(['file'])], 'testFile.png', { type: 'image/png' }) + ] + }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(mockSendGTMEvent).toHaveBeenCalledWith({ + event: 'image_upload_failure', + fileSize: expect.any(Number), + fileType: 'image/png', + errorCode: 'upload-exception' + }) + ) + expect(cloudflareUploadCompleteMockResult).not.toHaveBeenCalled() + }) + + it('should not call cloudflareUploadComplete on invalid response (missing cloudflareId)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => + await Promise.resolve({ + result: null, + errors: [], + messages: [], + success: true + }) + } as unknown as Response) + + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + Object.defineProperty(inputEl, 'files', { + value: [ + new File([new Blob(['file'])], 'testFile.png', { type: 'image/png' }) + ] + }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(mockSendGTMEvent).toHaveBeenCalledWith({ + event: 'image_upload_failure', + fileSize: expect.any(Number), + fileType: 'image/png', + errorCode: 'upload-invalid-response' + }) + ) + expect(cloudflareUploadCompleteMockResult).not.toHaveBeenCalled() + }) + it('should render drop zone text in default state', () => { render( @@ -226,7 +430,8 @@ describe('ImageUpload', () => { } } } - } + }, + cloudflareUploadCompleteMock ]} > { } } } - } + }, + cloudflareUploadCompleteMock ]} > { } } } - } + }, + cloudflareUploadCompleteMock ]} > { } } } - } + }, + cloudflareUploadCompleteMock ]} > { } } } - } + }, + cloudflareUploadCompleteMock ]} > { }) ) }) + + it('should report upload-mark-complete-failed and skip onChange when cloudflareUploadComplete throws', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => await Promise.resolve(cfResponse) + } as unknown as Response) + + const onChange = jest.fn() + const setUploading = jest.fn() + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + Object.defineProperty(inputEl, 'files', { + value: [ + new File([new Blob(['file'])], 'testFile.png', { type: 'image/png' }) + ] + }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(mockSendGTMEvent).toHaveBeenCalledWith({ + event: 'image_upload_failure', + fileSize: expect.any(Number), + fileType: 'image/png', + errorCode: 'upload-mark-complete-failed' + }) + ) + expect(onChange).not.toHaveBeenCalled() + expect(setUploading).toHaveBeenCalledWith(false) + }) }) diff --git a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx index a1f552450b3..53ab0f12290 100644 --- a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx +++ b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx @@ -19,6 +19,7 @@ import { sendImageUploadSuccessEvent } from '../../../../../../../../libs/sendImageUploadEvent' import { useCloudflareUploadByFileMutation } from '../../../../../../../../libs/useCloudflareUploadByFileMutation' +import { useCloudflareUploadCompleteMutation } from '../../../../../../../../libs/useCloudflareUploadCompleteMutation' import { MAX_IMAGE_UPLOAD_BYTES } from '../../../../../../../../libs/useImageUpload' import { UploadDropZoneShell } from '../../../UploadDropZoneShell' import { prependCloudflareImage } from '../../MediaLibrary/prependCloudflareImage' @@ -43,6 +44,7 @@ export function ImageUpload({ const { t } = useTranslation('apps-journeys-admin') const { cache } = useApolloClient() const [createCloudflareUploadByFile] = useCloudflareUploadByFileMutation() + const [cloudflareUploadComplete] = useCloudflareUploadCompleteMutation() const [success, setSuccess] = useState(undefined) const [errorCode, setErrorCode] = useState() const successResetTimerRef = useRef | null>( @@ -83,86 +85,109 @@ export function ImageUpload({ setSuccess(undefined) setErrorCode(undefined) - if (data?.createCloudflareUploadByFile?.uploadUrl != null) { - const file = acceptedFiles[0] - const formData = new FormData() - formData.append('file', file) + if (data?.createCloudflareUploadByFile?.uploadUrl == null) return - const uploadUrl = data.createCloudflareUploadByFile.uploadUrl - try { - const response = await ( - await fetch(uploadUrl, { - method: 'POST', - body: formData as unknown as FormDataType - }) - ).json() + const file = acceptedFiles[0] + const formData = new FormData() + formData.append('file', file) + const uploadUrl = data.createCloudflareUploadByFile.uploadUrl - response.success === true ? setSuccess(true) : setSuccess(false) - if (response.errors?.length) { - const cloudflareError = response.errors[0].code - setSuccess(false) - setUploading?.(false) - setErrorCode(cloudflareError as ErrorCode) - sendImageUploadFailureEvent({ - fileSize: file.size, - fileType: file.type, - errorCode: String(cloudflareError) - }) - return - } - - const cloudflareId = response?.result?.id - const cloudflareUploadKey = - process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY - if ( - cloudflareId == null || - cloudflareUploadKey == null || - cloudflareUploadKey === '' - ) { - setSuccess(false) - setUploading?.(false) - sendImageUploadFailureEvent({ - fileSize: file.size, - fileType: file.type, - errorCode: 'upload-invalid-response' - }) - return - } - const url = `https://imagedelivery.net/${cloudflareUploadKey}/${cloudflareId}` - prependCloudflareImage( - cache, - { id: cloudflareId, url, blurhash: null }, - false - ) - onUploaded?.() - onChange({ - src: `${url}/public`, - scale: 100, - focalLeft: 50, - focalTop: 50 - }) - if (successResetTimerRef.current != null) { - clearTimeout(successResetTimerRef.current) - } - successResetTimerRef.current = setTimeout( - () => setSuccess(undefined), - 4000 - ) - setUploading?.(undefined) - sendImageUploadSuccessEvent({ - fileSize: file.size, - fileType: file.type - }) - } catch { - setSuccess(false) - setUploading?.(false) - sendImageUploadFailureEvent({ - fileSize: file.size, - fileType: file.type, - errorCode: 'upload-exception' + let cloudflareResponse + try { + cloudflareResponse = await ( + await fetch(uploadUrl, { + method: 'POST', + body: formData as unknown as FormDataType }) - } + ).json() + } catch { + setSuccess(false) + setUploading?.(false) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: 'upload-exception' + }) + return + } + + if (cloudflareResponse.errors?.length) { + const cloudflareError = cloudflareResponse.errors[0].code + setSuccess(false) + setUploading?.(false) + setErrorCode(cloudflareError as ErrorCode) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: String(cloudflareError) + }) + return + } + + if (cloudflareResponse.success !== true) { + setSuccess(false) + setUploading?.(false) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: 'upload-failed' + }) + return + } + + const cloudflareId = cloudflareResponse?.result?.id + const cloudflareUploadKey = process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY + if ( + cloudflareId == null || + cloudflareUploadKey == null || + cloudflareUploadKey === '' + ) { + setSuccess(false) + setUploading?.(false) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: 'upload-invalid-response' + }) + return + } + + try { + await cloudflareUploadComplete({ variables: { id: cloudflareId } }) + } catch { + setSuccess(false) + setUploading?.(false) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: 'upload-mark-complete-failed' + }) + return + } + + const url = `https://imagedelivery.net/${cloudflareUploadKey}/${cloudflareId}` + prependCloudflareImage( + cache, + { id: cloudflareId, url, blurhash: null }, + false + ) + onUploaded?.() + onChange({ + src: `${url}/public`, + scale: 100, + focalLeft: 50, + focalTop: 50 + }) + setSuccess(true) + if (successResetTimerRef.current != null) { + clearTimeout(successResetTimerRef.current) } + successResetTimerRef.current = setTimeout(() => setSuccess(undefined), 4000) + setUploading?.(undefined) + sendImageUploadSuccessEvent({ + fileSize: file.size, + fileType: file.type + }) } const { getRootProps, open, getInputProps, isDragAccept } = useDropzone({ diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx index dad7e0c7152..e70ae50f6b2 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx @@ -7,6 +7,7 @@ import { JourneyProvider } from '@core/journeys/ui/JourneyProvider' import { publishedJourney } from '@core/journeys/ui/TemplateView/data' import { cloudflareUploadMutationMock } from '../../../../../../libs/useCloudflareUploadByFileMutation/useCloudflareUploadByFileMutation.mock' +import { cloudflareUploadCompleteMock } from '../../../../../../libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.mock' import { journeyImageBlockAssociationUpdateMock } from '../../../../../../libs/useJourneyImageBlockAssociationUpdateMutation/useJourneyImageBlockAssociationUpdateMutation.mock' import { journeyImageBlockCreateMock } from '../../../../../../libs/useJourneyImageBlockCreateMutation/useJourneyImageBlockCreateMutation.mock' import { journeyImageBlockUpdateMock } from '../../../../../../libs/useJourneyImageBlockUpdateMutation/useJourneyImageBlockUpdateMutation.mock' @@ -152,7 +153,13 @@ describe('SocialScreenSocialImage', () => { })) render( - + { render( { render( { + return { + request: { + query: CLOUDFLARE_UPLOAD_COMPLETE, + variables: { id } + }, + result: jest.fn(() => ({ + data: { cloudflareUploadComplete: true } + })) + } +} diff --git a/apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.ts b/apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.ts new file mode 100644 index 00000000000..af6fa450ad7 --- /dev/null +++ b/apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.ts @@ -0,0 +1,29 @@ +import { + MutationHookOptions, + MutationTuple, + gql, + useMutation +} from '@apollo/client' + +import { + CloudflareUploadComplete, + CloudflareUploadCompleteVariables +} from '../../../__generated__/CloudflareUploadComplete' + +export const CLOUDFLARE_UPLOAD_COMPLETE = gql` + mutation CloudflareUploadComplete($id: ID!) { + cloudflareUploadComplete(id: $id) + } +` + +export function useCloudflareUploadCompleteMutation( + options?: MutationHookOptions< + CloudflareUploadComplete, + CloudflareUploadCompleteVariables + > +): MutationTuple { + return useMutation< + CloudflareUploadComplete, + CloudflareUploadCompleteVariables + >(CLOUDFLARE_UPLOAD_COMPLETE, options) +} diff --git a/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.spec.tsx b/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.spec.tsx index f951087b54a..d273d8382ac 100644 --- a/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.spec.tsx +++ b/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.spec.tsx @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { ErrorCode, useDropzone } from 'react-dropzone' import { useCloudflareUploadByFileMutation } from '../useCloudflareUploadByFileMutation' +import { useCloudflareUploadCompleteMutation } from '../useCloudflareUploadCompleteMutation' import { useImageUpload } from './useImageUpload' @@ -14,11 +15,19 @@ jest.mock('../useCloudflareUploadByFileMutation', () => ({ useCloudflareUploadByFileMutation: jest.fn() })) +jest.mock('../useCloudflareUploadCompleteMutation', () => ({ + useCloudflareUploadCompleteMutation: jest.fn() +})) + const mockUseDropzone = useDropzone as jest.MockedFunction const mockUseCloudflareUploadByFileMutation = useCloudflareUploadByFileMutation as jest.MockedFunction< typeof useCloudflareUploadByFileMutation > +const mockUseCloudflareUploadCompleteMutation = + useCloudflareUploadCompleteMutation as jest.MockedFunction< + typeof useCloudflareUploadCompleteMutation + > describe('useImageUpload', () => { let originalEnv: NodeJS.ProcessEnv @@ -46,6 +55,9 @@ describe('useImageUpload', () => { fileRejections: [] } as any) mockUseCloudflareUploadByFileMutation.mockReturnValue([jest.fn()] as any) + mockUseCloudflareUploadCompleteMutation.mockReturnValue([ + jest.fn().mockResolvedValue({ data: { cloudflareUploadComplete: true } }) + ] as any) }) afterEach(() => { diff --git a/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.ts b/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.ts index 7cf787d7b9c..d92e16e2844 100644 --- a/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.ts +++ b/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.ts @@ -9,6 +9,7 @@ import { } from 'react-dropzone' import { useCloudflareUploadByFileMutation } from '../useCloudflareUploadByFileMutation' +import { useCloudflareUploadCompleteMutation } from '../useCloudflareUploadCompleteMutation' /** * Error codes for image upload. @@ -73,6 +74,7 @@ export function useImageUpload( noKeyboard = false } = useImageUploadOptions const [createCloudflareUploadByFile] = useCloudflareUploadByFileMutation() + const [cloudflareUploadComplete] = useCloudflareUploadCompleteMutation() const [loading, setLoading] = useState(false) const loadingRef = useRef(false) const [success, setSuccess] = useState(undefined) @@ -152,11 +154,23 @@ export function useImageUpload( ).json() if (response.success === true) { - setSuccess(true) + const cloudflareId = response.result.id as string + try { + await cloudflareUploadComplete({ + variables: { id: cloudflareId } + }) + } catch { + setSuccess(false) + const error: ImageUploadErrorCode = 'unknown-error' + setErrorCode(error) + onUploadError?.(error, getErrorMessage(error)) + return + } const src = `https://imagedelivery.net/${ process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY ?? '' - }/${response.result.id as string}/public` + }/${cloudflareId}/public` onUploadComplete(src) + setSuccess(true) if (successTimeoutRef.current != null) { clearTimeout(successTimeoutRef.current) }