diff --git a/packages/bindx-uploader/src/components/MultiUploader.tsx b/packages/bindx-uploader/src/components/MultiUploader.tsx index dd9e6b1e..7471f4fc 100644 --- a/packages/bindx-uploader/src/components/MultiUploader.tsx +++ b/packages/bindx-uploader/src/components/MultiUploader.tsx @@ -15,7 +15,7 @@ import { resolveAcceptingSingleType } from '../internal/utils/resolveAccept.js' import { executeExtractors } from '../internal/utils/executeExtractors.js' import { uploaderErrorHandler } from '../internal/utils/uploaderErrorHandler.js' -export interface MultiUploaderProps> { +export interface MultiUploaderProps> extends Partial { /** * The has-many relation to add uploaded files to. */ @@ -53,6 +53,7 @@ export function MultiUploader>({ field, fileType, children, + ...events }: MultiUploaderProps): ReactNode { const fieldAccessor = useHasMany(field) // Map file ID -> entity ID @@ -71,7 +72,7 @@ export function MultiUploader>({ // Create entity for file and track mapping const useCreateRepeaterEntityEvents = useCallback( - (events: UploaderEvents): UploaderEvents => ({ + (events: Partial): Partial => ({ ...events, onBeforeUpload: async event => { if (!(await resolveAcceptingSingleType(event.file, fileType as FileType))) { @@ -115,19 +116,10 @@ export function MultiUploader>({ [field, fileType, getEntityForFile], ) - const baseEvents: UploaderEvents = useMemo( - () => ({ - onError: uploaderErrorHandler, - onStartUpload: () => {}, - onBeforeUpload: async () => undefined, - onProgress: () => {}, - onAfterUpload: async () => {}, - onSuccess: () => {}, - }), - [], - ) - - const fillEntityEvents = useCreateRepeaterEntityEvents(baseEvents) + const fillEntityEvents = useCreateRepeaterEntityEvents({ + ...events, + onError: events.onError ?? uploaderErrorHandler, + }) const { files, ...stateEvents } = useUploadState(fillEntityEvents) const onDrop = useUploaderDoUpload(stateEvents) diff --git a/packages/bindx-uploader/src/components/Uploader.tsx b/packages/bindx-uploader/src/components/Uploader.tsx index a7842878..c4296f1a 100644 --- a/packages/bindx-uploader/src/components/Uploader.tsx +++ b/packages/bindx-uploader/src/components/Uploader.tsx @@ -1,8 +1,8 @@ import { useMemo, type ReactNode } from 'react' -import type { EntityRef, HasOneRef, SelectionFieldMeta, SelectionMeta } from '@contember/bindx' +import type { SelectionFieldMeta, SelectionMeta } from '@contember/bindx' import { FIELD_REF_META } from '@contember/bindx' import { BINDX_COMPONENT, type SelectionProvider, createEmptySelection } from '@contember/bindx-react' -import type { FileType } from '../types.js' +import type { FileType, PrepareUploadTarget, UploaderEvents, UploaderFillTarget } from '../types.js' import { UploaderOptionsContext, UploaderStateContext, @@ -13,25 +13,30 @@ import { useUploaderDoUpload } from '../internal/hooks/useUploaderDoUpload.js' import { useFillEntity } from '../internal/hooks/useFillEntity.js' import { uploaderErrorHandler } from '../internal/utils/uploaderErrorHandler.js' -export interface UploaderProps> { +export interface UploaderProps> extends Partial { /** * The entity to fill with uploaded file data. * Can be an EntityRef or HasOneRef. */ - entity: EntityRef | HasOneRef + entity: UploaderFillTarget /** * File type configuration defining accepted files and extractors. * Must be created with the same entity type as the entity prop. */ fileType: FileType + /** + * Resolves the target of an upload batch: it runs once, after the files pass the accept + * check and before the uploader disconnects or fills anything, and receives the accepted + * files. A rejected batch never reaches it. Return another target to fork first + * (copy-on-write); returning nothing keeps the entity prop. + */ + prepareTarget?: PrepareUploadTarget> /** * Children to render within the uploader context. */ children?: ReactNode } -const noop = (): Promise => Promise.resolve(undefined) - /** * Single file upload component for bindx. * Provides upload state and upload function to children via context. @@ -63,25 +68,39 @@ const noop = (): Promise => Promise.resolve(undefined) * * * ``` + * + * @example Copy-on-write: fork a shared asset so the upload lands on a fresh one + * ```tsx + * { + * block.asset.$disconnect() + * block.asset.$create() + * return block.asset.image + * }} + * > + * + * + * ``` */ export function Uploader>({ entity, fileType, + prepareTarget, children, + ...events }: UploaderProps): ReactNode { - const fillEntityEvents = useFillEntity({ + const { prepareUpload, ...fillEntityEvents } = useFillEntity({ entity, fileType, - onError: uploaderErrorHandler, - onStartUpload: noop, - onBeforeUpload: noop, - onProgress: noop, - onAfterUpload: noop, - onSuccess: noop, + prepareTarget, + ...events, + onError: events.onError ?? uploaderErrorHandler, }) const { files, ...stateEvents } = useUploadState(fillEntityEvents) - const onDrop = useUploaderDoUpload(stateEvents) + const onDrop = useUploaderDoUpload({ ...stateEvents, onPrepareUpload: prepareUpload }) const options = useMemo( () => ({ diff --git a/packages/bindx-uploader/src/index.ts b/packages/bindx-uploader/src/index.ts index e3bf59f3..d61a4fa0 100644 --- a/packages/bindx-uploader/src/index.ts +++ b/packages/bindx-uploader/src/index.ts @@ -23,6 +23,9 @@ export type { AfterUploadEvent, ErrorEvent, UploaderEvents, + // Upload target + UploaderFillTarget, + PrepareUploadTarget, // Extractors FileDataExtractor, FileDataExtractorPopulator, diff --git a/packages/bindx-uploader/src/internal/hooks/index.ts b/packages/bindx-uploader/src/internal/hooks/index.ts index 221c60ec..88f1ed57 100644 --- a/packages/bindx-uploader/src/internal/hooks/index.ts +++ b/packages/bindx-uploader/src/internal/hooks/index.ts @@ -1,4 +1,4 @@ export { useUploadState, type UseUploadStateResult } from './useUploadState.js' -export { useUploaderDoUpload } from './useUploaderDoUpload.js' +export { useUploaderDoUpload, type UseUploaderDoUploadArgs } from './useUploaderDoUpload.js' export { useGetPreviewUrls } from './useGetPreviewUrls.js' -export { useFillEntity, type UseFillEntityArgs } from './useFillEntity.js' +export { useFillEntity, type UseFillEntityArgs, type UseFillEntityResult } from './useFillEntity.js' diff --git a/packages/bindx-uploader/src/internal/hooks/useFillEntity.ts b/packages/bindx-uploader/src/internal/hooks/useFillEntity.ts index 3f6f0422..265c52d9 100644 --- a/packages/bindx-uploader/src/internal/hooks/useFillEntity.ts +++ b/packages/bindx-uploader/src/internal/hooks/useFillEntity.ts @@ -1,23 +1,33 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import type { EntityRef, HasOneAccessor } from '@contember/bindx' -import type { FileType, StartUploadEvent, UploaderEvents } from '../../types.js' +import type { FileType, PrepareUploadTarget, StartUploadEvent, UploaderEvents } from '../../types.js' import { resolveAcceptingSingleType } from '../utils/resolveAccept.js' import { executeExtractors } from '../utils/executeExtractors.js' -export interface UseFillEntityArgs> extends UploaderEvents { +type FillTarget = EntityRef | HasOneAccessor + +export interface UseFillEntityArgs> extends Partial { /** * The entity to fill. Can be: * - EntityRef: fill the entity directly * - HasOneAccessor: fill the related entity (disconnect first on upload start) */ - entity: EntityRef | HasOneAccessor + entity: FillTarget fileType: FileType + /** + * Resolves the target of a validated batch, before the upload disconnects or fills anything. + */ + prepareTarget?: PrepareUploadTarget> +} + +export interface UseFillEntityResult extends Partial { + prepareUpload: (files: File[]) => Promise } /** * Checks if the entity is a HasOneRef (has $disconnect method) */ -const isHasOneAccessor = (entity: EntityRef | HasOneAccessor): entity is HasOneAccessor => { +const isHasOneAccessor = (entity: FillTarget): entity is HasOneAccessor => { return '$disconnect' in entity && typeof entity.$disconnect === 'function' } @@ -25,7 +35,7 @@ const isHasOneAccessor = (entity: EntityRef | HasOneAccessor(entity: EntityRef | HasOneAccessor): EntityRef => { +const getTargetEntity = (entity: FillTarget): EntityRef => { if (isHasOneAccessor(entity)) { return entity.$entity } @@ -39,8 +49,20 @@ const getTargetEntity = (entity: EntityRef | HasOneAccessor>({ entity, fileType, + prepareTarget, ...events -}: UseFillEntityArgs): UploaderEvents => { +}: UseFillEntityArgs): UseFillEntityResult => { + const preparedTargetRef = useRef | undefined>(undefined) + + const prepareUpload = useCallback( + async (files: File[]): Promise => { + preparedTargetRef.current = await prepareTarget?.(files) + }, + [prepareTarget], + ) + + const getTarget = useCallback((): FillTarget => preparedTargetRef.current ?? entity, [entity]) + const handleBeforeUpload = useCallback( async (event: Parameters[0]): Promise => { if (!(await resolveAcceptingSingleType(event.file, fileType as FileType))) { @@ -54,19 +76,20 @@ export const useFillEntity = >({ const handleStartUpload = useCallback( (event: StartUploadEvent) => { // Disconnect existing relation before upload - if (isHasOneAccessor(entity)) { - entity.$disconnect() + const target = getTarget() + if (isHasOneAccessor(target)) { + target.$disconnect() } events.onStartUpload?.(event) }, - [entity, events], + [events, getTarget], ) const handleAfterUpload = useCallback( async (event: Parameters[0]) => { await Promise.all([ (async () => { - const targetEntity = getTargetEntity(entity) + const targetEntity = getTargetEntity(getTarget()) const extractionResult = await executeExtractors({ fileType: fileType as FileType, result: event.result, @@ -77,11 +100,12 @@ export const useFillEntity = >({ events.onAfterUpload?.(event), ]) }, - [entity, events, fileType], + [events, fileType, getTarget], ) return { ...events, + prepareUpload, onBeforeUpload: handleBeforeUpload, onStartUpload: handleStartUpload, onAfterUpload: handleAfterUpload, diff --git a/packages/bindx-uploader/src/internal/hooks/useUploadState.ts b/packages/bindx-uploader/src/internal/hooks/useUploadState.ts index 5f24a4eb..dcdaeea2 100644 --- a/packages/bindx-uploader/src/internal/hooks/useUploadState.ts +++ b/packages/bindx-uploader/src/internal/hooks/useUploadState.ts @@ -28,7 +28,7 @@ export const useUploadState = ({ onError, onProgress, onAfterUpload, -}: UploaderEvents): UseUploadStateResult => { +}: Partial): UseUploadStateResult => { const [files, setFiles] = useState>({}) const purgeFinal = useCallback(() => { diff --git a/packages/bindx-uploader/src/internal/hooks/useUploaderDoUpload.ts b/packages/bindx-uploader/src/internal/hooks/useUploaderDoUpload.ts index 19c5794c..5f8abca0 100644 --- a/packages/bindx-uploader/src/internal/hooks/useUploaderDoUpload.ts +++ b/packages/bindx-uploader/src/internal/hooks/useUploaderDoUpload.ts @@ -4,6 +4,14 @@ import { UploaderError } from '../../UploaderError.js' import { useUploaderClient } from '../../contexts.js' import { useGetPreviewUrls } from './useGetPreviewUrls.js' +export interface UseUploaderDoUploadArgs extends Partial { + /** + * Runs once per batch with the files that passed validation, before any of them is + * uploaded or written to the target. Skipped when no file passed. + */ + onPrepareUpload?: (files: File[]) => Promise | void +} + /** * Hook that orchestrates the file upload process. * Handles file preparation, validation, and upload execution. @@ -15,7 +23,8 @@ export const useUploaderDoUpload = ({ onSuccess, onStartUpload, onAfterUpload, -}: UploaderEvents): ((files: File[]) => Promise) => { + onPrepareUpload, +}: UseUploaderDoUploadArgs): ((files: File[]) => Promise) => { const getPreviewUrl = useGetPreviewUrls() const defaultUploader = useUploaderClient() @@ -73,6 +82,19 @@ export const useUploaderDoUpload = ({ ) .map(p => p.value) + if (preparedFiles.length === 0) { + return + } + + try { + await onPrepareUpload?.(preparedFiles.map(({ file }) => file.file)) + } catch (error) { + for (const { file } of preparedFiles) { + onError?.({ file, error }) + } + return + } + // Upload files await Promise.allSettled( preparedFiles.map(async ({ file, fileType }) => { @@ -121,6 +143,7 @@ export const useUploaderDoUpload = ({ onAfterUpload, onBeforeUpload, onError, + onPrepareUpload, onProgress, onStartUpload, onSuccess, diff --git a/packages/bindx-uploader/src/types.ts b/packages/bindx-uploader/src/types.ts index baab5cfe..7438506f 100644 --- a/packages/bindx-uploader/src/types.ts +++ b/packages/bindx-uploader/src/types.ts @@ -120,6 +120,17 @@ export interface ErrorEvent { fileType?: FileType } +/** + * Fill target of a single-file uploader: an entity, or a has-one relation pointing at one. + */ +export type UploaderFillTarget> = EntityRef | HasOneRef + +/** + * Resolves the target of an upload batch after the files pass validation and before + * the target is written. Returning nothing keeps the target the uploader was given. + */ +export type PrepareUploadTarget = (files: File[]) => TTarget | undefined | Promise + export interface UploaderEvents { onBeforeUpload: (event: BeforeUploadEvent) => Promise onStartUpload: (event: StartUploadEvent) => void diff --git a/packages/bindx-uploader/tests/uploader.test.tsx b/packages/bindx-uploader/tests/uploader.test.tsx new file mode 100644 index 00000000..94954c20 --- /dev/null +++ b/packages/bindx-uploader/tests/uploader.test.tsx @@ -0,0 +1,518 @@ +import './setup' +import { describe, test, expect, afterEach, mock } from 'bun:test' +import { render, cleanup, waitFor, fireEvent, act } from '@testing-library/react' +import React, { type ReactNode } from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + hasMany, + hasOne, + scalar, + useEntity, +} from '@contember/bindx-react' +import { + MultiUploader, + Uploader, + UploaderClientContext, + UploaderError, + getFileUrlDataExtractor, + useUploaderUploadFiles, + type ErrorEvent, + type FileType, + type UploadClient, +} from '../src/index.js' + +afterEach(() => { + cleanup() +}) + +type Image = { + id: string + url: string | null +} + +type Asset = { + id: string + title: string + image: Image | null +} + +type Block = { + id: string + asset: Asset | null + gallery: Image[] +} + +interface MediaSchema { + Block: Block + Asset: Asset + Image: Image +} + +const mediaSchema = defineSchema({ + entities: { + Block: { + fields: { + id: scalar(), + asset: hasOne('Asset', { nullable: true }), + gallery: hasMany('Image'), + }, + }, + Asset: { + fields: { + id: scalar(), + title: scalar(), + image: hasOne('Image', { nullable: true }), + }, + }, + Image: { + fields: { + id: scalar(), + url: scalar(), + }, + }, + }, +}) + +const entityDefs = { + Block: entityDef('Block'), + Asset: entityDef('Asset'), + Image: entityDef('Image'), +} as const + +const ORIGINAL_URL = 'https://cdn.test/original.jpg' +const UPLOADED_URL = 'https://cdn.test/uploaded.jpg' + +const createMediaData = (): Record> => ({ + Block: { + 'block-1': { + id: 'block-1', + asset: { + id: 'asset-1', + title: 'Shared asset', + image: { id: 'image-1', url: ORIGINAL_URL }, + }, + gallery: [], + }, + }, + Asset: { + 'asset-1': { + id: 'asset-1', + title: 'Shared asset', + image: { id: 'image-1', url: ORIGINAL_URL }, + }, + }, + Image: { + 'image-1': { id: 'image-1', url: ORIGINAL_URL }, + }, +}) + +const imageFileType: FileType = { + extractors: [getFileUrlDataExtractor({ urlField: 'url' })], +} + +const jpegOnlyFileType: FileType = { + accept: { 'image/jpeg': ['.jpg'] }, + extractors: [getFileUrlDataExtractor({ urlField: 'url' })], +} + +const uploadClient: UploadClient = { + upload: async () => ({ publicUrl: UPLOADED_URL }), +} + +const createTestFile = (): File => new File(['binary'], 'photo.jpg', { type: 'image/jpeg' }) + +const createTextFile = (): File => new File(['text'], 'notes.txt', { type: 'text/plain' }) + +function getByTestId(container: Element, testId: string): Element { + const el = container.querySelector(`[data-testid="${testId}"]`) + if (!el) throw new Error(`Element with data-testid="${testId}" not found`) + return el +} + +function UploadTrigger({ files }: { files: File[] }): ReactNode { + const uploadFiles = useUploaderUploadFiles() + return ( + + ) +} + +/** Observes the shared asset through its own subscription, independently of the block. */ +function SharedAssetProbe(): ReactNode { + const asset = useEntity(entityDefs.Asset, { by: { id: 'asset-1' } }, e => e.id().title().image(i => i.id().url())) + + if (asset.$isLoading || asset.$isError || asset.$isNotFound) { + return null + } + + return ( + <> + {asset.image.url.value ?? ''} + {asset.image.$id} + + ) +} + +interface ForkingUploaderProps { + fileType: FileType + files: File[] + onPrepare: () => void + onError?: (event: ErrorEvent) => void +} + +/** Forks the shared asset in prepareTarget, so the upload must land on the fresh one. */ +function ForkingUploader({ fileType, files, onPrepare, onError }: ForkingUploaderProps): ReactNode { + const block = useEntity(entityDefs.Block, { by: { id: 'block-1' } }, e => + e.id().asset(a => a.id().title().image(i => i.id().url())), + ) + + if (block.$isLoading || block.$isError || block.$isNotFound) { + return null + } + + return ( + <> + { + onPrepare() + block.asset.$disconnect() + block.asset.$create({ title: 'Forked asset' }) + return block.asset.image + }} + > + + + {block.asset.image.url.value ?? ''} + {block.asset.image.$id} + {block.asset.$id} + + ) +} + +const uploadFile = async (container: Element): Promise => { + await act(async () => { + fireEvent.click(getByTestId(container, 'upload')) + }) +} + +const renderMedia = async (children: ReactNode): Promise<{ container: Element }> => { + const adapter = new MockAdapter(createMediaData(), { delay: 0 }) + const { container } = render( + + {children} + , + ) + + await waitFor(() => { + expect(container.querySelector('[data-testid="upload"]')).not.toBeNull() + }) + + return { container } +} + +describe('Uploader events', () => { + test('a user onBeforeUpload can reject a file, and onError receives the rejection', async () => { + const onBeforeUpload = mock(async ({ reject }: { reject: (reason: string) => never }) => reject('file too large')) + const errors: ErrorEvent[] = [] + + function TestApp(): ReactNode { + const block = useEntity(entityDefs.Block, { by: { id: 'block-1' } }, e => + e.id().asset(a => a.id().title().image(i => i.id().url())), + ) + + if (block.$isLoading || block.$isError || block.$isNotFound) { + return null + } + + return ( + <> + errors.push(event)} + > + + + {block.asset.image.url.value ?? ''} + + ) + } + + const { container } = await renderMedia() + await uploadFile(container) + + expect(onBeforeUpload).toHaveBeenCalledTimes(1) + expect(errors).toHaveLength(1) + expect(errors[0]?.error).toBeInstanceOf(UploaderError) + expect(getByTestId(container, 'block-image-url').textContent).toBe(ORIGINAL_URL) + }) + + test('user handlers compose with the internal fill instead of replacing it', async () => { + const calls: string[] = [] + + function TestApp(): ReactNode { + const block = useEntity(entityDefs.Block, { by: { id: 'block-1' } }, e => + e.id().asset(a => a.id().title().image(i => i.id().url())), + ) + + if (block.$isLoading || block.$isError || block.$isNotFound) { + return null + } + + return ( + <> + { + calls.push('before') + return undefined + }} + onStartUpload={() => calls.push('start')} + onAfterUpload={() => { + calls.push('after') + }} + onSuccess={() => calls.push('success')} + > + + + {block.asset.image.url.value ?? ''} + + ) + } + + const { container } = await renderMedia() + await uploadFile(container) + + await waitFor(() => { + expect(getByTestId(container, 'block-image-url').textContent).toBe(UPLOADED_URL) + }) + expect(calls).toEqual(['before', 'start', 'after', 'success']) + }) + + test('without event props the upload still fills the passed target', async () => { + function TestApp(): ReactNode { + const block = useEntity(entityDefs.Block, { by: { id: 'block-1' } }, e => + e.id().asset(a => a.id().title().image(i => i.id().url())), + ) + + if (block.$isLoading || block.$isError || block.$isNotFound) { + return null + } + + return ( + <> + + + + {block.asset.image.url.value ?? ''} + {block.asset.$id} + + ) + } + + const { container } = await renderMedia() + await uploadFile(container) + + await waitFor(() => { + expect(getByTestId(container, 'block-image-url').textContent).toBe(UPLOADED_URL) + }) + expect(getByTestId(container, 'block-asset-id').textContent).toBe('asset-1') + }) +}) + +describe('Uploader prepareTarget', () => { + test('fills the target returned by prepareTarget and leaves the original untouched', async () => { + function TestApp(): ReactNode { + const block = useEntity(entityDefs.Block, { by: { id: 'block-1' } }, e => + e.id().asset(a => a.id().title().image(i => i.id().url())), + ) + + if (block.$isLoading || block.$isError || block.$isNotFound) { + return null + } + + return ( + <> + { + block.asset.$disconnect() + block.asset.$create({ title: 'Forked asset' }) + return block.asset.image + }} + > + + + {block.asset.image.url.value ?? ''} + {block.asset.$id} + {block.asset.title.value ?? ''} + + ) + } + + const { container } = await renderMedia( + <> + + + , + ) + await uploadFile(container) + + await waitFor(() => { + expect(getByTestId(container, 'block-image-url').textContent).toBe(UPLOADED_URL) + }) + expect(getByTestId(container, 'block-asset-title').textContent).toBe('Forked asset') + expect(getByTestId(container, 'block-asset-id').textContent).not.toBe('asset-1') + + expect(getByTestId(container, 'shared-image-url').textContent).toBe(ORIGINAL_URL) + expect(getByTestId(container, 'shared-image-id').textContent).toBe('image-1') + }) + + test('prepareTarget runs after the accept check and before the target is written', async () => { + const order: string[] = [] + + function TestApp(): ReactNode { + const block = useEntity(entityDefs.Block, { by: { id: 'block-1' } }, e => + e.id().asset(a => a.id().title().image(i => i.id().url())), + ) + + if (block.$isLoading || block.$isError || block.$isNotFound) { + return null + } + + return ( + <> + { + await Promise.resolve() + order.push('prepare') + return undefined + }} + onBeforeUpload={async () => { + order.push('before') + return undefined + }} + onStartUpload={() => order.push('start')} + > + + + {block.asset.image.url.value ?? ''} + + ) + } + + const { container } = await renderMedia() + await uploadFile(container) + + await waitFor(() => { + expect(getByTestId(container, 'block-image-url').textContent).toBe(UPLOADED_URL) + }) + expect(order).toEqual(['before', 'prepare', 'start']) + }) + + test('a batch rejected by the accept check never forks the target', async () => { + const onPrepare = mock(() => {}) + const errors: ErrorEvent[] = [] + + const { container } = await renderMedia( + <> + errors.push(event)} + /> + + , + ) + await uploadFile(container) + + expect(errors).toHaveLength(1) + expect(errors[0]?.error).toBeInstanceOf(UploaderError) + expect(onPrepare).toHaveBeenCalledTimes(0) + expect(getByTestId(container, 'block-asset-id').textContent).toBe('asset-1') + expect(getByTestId(container, 'block-image-id').textContent).toBe('image-1') + expect(getByTestId(container, 'block-image-url').textContent).toBe(ORIGINAL_URL) + expect(getByTestId(container, 'shared-image-url').textContent).toBe(ORIGINAL_URL) + }) + + test('a mixed batch forks once and lands the accepted file on the prepared target', async () => { + const onPrepare = mock(() => {}) + const errors: ErrorEvent[] = [] + + const { container } = await renderMedia( + <> + errors.push(event)} + /> + + , + ) + await uploadFile(container) + + await waitFor(() => { + expect(getByTestId(container, 'block-image-url').textContent).toBe(UPLOADED_URL) + }) + expect(errors).toHaveLength(1) + expect(onPrepare).toHaveBeenCalledTimes(1) + expect(getByTestId(container, 'block-asset-id').textContent).not.toBe('asset-1') + expect(getByTestId(container, 'shared-image-url').textContent).toBe(ORIGINAL_URL) + expect(getByTestId(container, 'shared-image-id').textContent).toBe('image-1') + }) +}) + +describe('MultiUploader events', () => { + test('forwards user handlers while still creating and filling an item', async () => { + const calls: string[] = [] + + function TestApp(): ReactNode { + const block = useEntity(entityDefs.Block, { by: { id: 'block-1' } }, e => + e.id().gallery(g => g.id().url()), + ) + + if (block.$isLoading || block.$isError || block.$isNotFound) { + return null + } + + return ( + <> + calls.push('start')} + onAfterUpload={() => { + calls.push('after') + }} + onSuccess={() => calls.push('success')} + > + + + {block.gallery.items.map(it => it.url.value ?? '').join(',')} + + ) + } + + const { container } = await renderMedia() + await uploadFile(container) + + await waitFor(() => { + expect(getByTestId(container, 'gallery-urls').textContent).toBe(UPLOADED_URL) + }) + expect(calls).toEqual(['start', 'after', 'success']) + }) +})