Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions packages/bindx-uploader/src/components/MultiUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TEntity = Record<string, unknown>> {
export interface MultiUploaderProps<TEntity = Record<string, unknown>> extends Partial<UploaderEvents> {
/**
* The has-many relation to add uploaded files to.
*/
Expand Down Expand Up @@ -53,6 +53,7 @@ export function MultiUploader<TEntity extends Record<string, unknown>>({
field,
fileType,
children,
...events
}: MultiUploaderProps<TEntity>): ReactNode {
const fieldAccessor = useHasMany(field)
// Map file ID -> entity ID
Expand All @@ -71,7 +72,7 @@ export function MultiUploader<TEntity extends Record<string, unknown>>({

// Create entity for file and track mapping
const useCreateRepeaterEntityEvents = useCallback(
(events: UploaderEvents): UploaderEvents => ({
(events: Partial<UploaderEvents>): Partial<UploaderEvents> => ({
...events,
onBeforeUpload: async event => {
if (!(await resolveAcceptingSingleType(event.file, fileType as FileType))) {
Expand Down Expand Up @@ -115,19 +116,10 @@ export function MultiUploader<TEntity extends Record<string, unknown>>({
[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)

Expand Down
47 changes: 33 additions & 14 deletions packages/bindx-uploader/src/components/Uploader.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<TEntity = Record<string, unknown>> {
export interface UploaderProps<TEntity = Record<string, unknown>> extends Partial<UploaderEvents> {
/**
* The entity to fill with uploaded file data.
* Can be an EntityRef or HasOneRef.
*/
entity: EntityRef<TEntity> | HasOneRef<TEntity>
entity: UploaderFillTarget<TEntity>
/**
* File type configuration defining accepted files and extractors.
* Must be created with the same entity type as the entity prop.
*/
fileType: FileType<TEntity>
/**
* 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<UploaderFillTarget<TEntity>>
/**
* Children to render within the uploader context.
*/
children?: ReactNode
}

const noop = (): Promise<undefined> => Promise.resolve(undefined)

/**
* Single file upload component for bindx.
* Provides upload state and upload function to children via context.
Expand Down Expand Up @@ -63,25 +68,39 @@ const noop = (): Promise<undefined> => Promise.resolve(undefined)
* </UploaderEachFile>
* </Uploader>
* ```
*
* @example Copy-on-write: fork a shared asset so the upload lands on a fresh one
* ```tsx
* <Uploader
* entity={block.asset.image}
* fileType={imageFileType}
* prepareTarget={() => {
* block.asset.$disconnect()
* block.asset.$create()
* return block.asset.image
* }}
* >
* <DropZone />
* </Uploader>
* ```
*/
export function Uploader<TEntity extends Record<string, unknown>>({
entity,
fileType,
prepareTarget,
children,
...events
}: UploaderProps<TEntity>): 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(
() => ({
Expand Down
3 changes: 3 additions & 0 deletions packages/bindx-uploader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export type {
AfterUploadEvent,
ErrorEvent,
UploaderEvents,
// Upload target
UploaderFillTarget,
PrepareUploadTarget,
// Extractors
FileDataExtractor,
FileDataExtractorPopulator,
Expand Down
4 changes: 2 additions & 2 deletions packages/bindx-uploader/src/internal/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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'
48 changes: 36 additions & 12 deletions packages/bindx-uploader/src/internal/hooks/useFillEntity.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
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<TEntity = Record<string, unknown>> extends UploaderEvents {
type FillTarget<TEntity> = EntityRef<TEntity> | HasOneAccessor<TEntity>

export interface UseFillEntityArgs<TEntity = Record<string, unknown>> extends Partial<UploaderEvents> {
/**
* The entity to fill. Can be:
* - EntityRef: fill the entity directly
* - HasOneAccessor: fill the related entity (disconnect first on upload start)
*/
entity: EntityRef<TEntity> | HasOneAccessor<TEntity>
entity: FillTarget<TEntity>
fileType: FileType<TEntity>
/**
* Resolves the target of a validated batch, before the upload disconnects or fills anything.
*/
prepareTarget?: PrepareUploadTarget<FillTarget<TEntity>>
}

export interface UseFillEntityResult extends Partial<UploaderEvents> {
prepareUpload: (files: File[]) => Promise<void>
}

/**
* Checks if the entity is a HasOneRef (has $disconnect method)
*/
const isHasOneAccessor = <TEntity>(entity: EntityRef<TEntity> | HasOneAccessor<TEntity>): entity is HasOneAccessor<TEntity> => {
const isHasOneAccessor = <TEntity>(entity: FillTarget<TEntity>): entity is HasOneAccessor<TEntity> => {
return '$disconnect' in entity && typeof entity.$disconnect === 'function'
}

/**
* Gets the target entity for filling.
* For HasOneAccessor, returns the related entity. For EntityRef, returns the entity itself.
*/
const getTargetEntity = <TEntity>(entity: EntityRef<TEntity> | HasOneAccessor<TEntity>): EntityRef<TEntity> => {
const getTargetEntity = <TEntity>(entity: FillTarget<TEntity>): EntityRef<TEntity> => {
if (isHasOneAccessor(entity)) {
return entity.$entity
}
Expand All @@ -39,8 +49,20 @@ const getTargetEntity = <TEntity>(entity: EntityRef<TEntity> | HasOneAccessor<TE
export const useFillEntity = <TEntity extends Record<string, unknown>>({
entity,
fileType,
prepareTarget,
...events
}: UseFillEntityArgs<TEntity>): UploaderEvents => {
}: UseFillEntityArgs<TEntity>): UseFillEntityResult => {
const preparedTargetRef = useRef<FillTarget<TEntity> | undefined>(undefined)

const prepareUpload = useCallback(
async (files: File[]): Promise<void> => {
preparedTargetRef.current = await prepareTarget?.(files)
},
[prepareTarget],
)

const getTarget = useCallback((): FillTarget<TEntity> => preparedTargetRef.current ?? entity, [entity])

const handleBeforeUpload = useCallback(
async (event: Parameters<UploaderEvents['onBeforeUpload']>[0]): Promise<FileType | undefined> => {
if (!(await resolveAcceptingSingleType(event.file, fileType as FileType))) {
Expand All @@ -54,19 +76,20 @@ export const useFillEntity = <TEntity extends Record<string, unknown>>({
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<UploaderEvents['onAfterUpload']>[0]) => {
await Promise.all([
(async () => {
const targetEntity = getTargetEntity(entity)
const targetEntity = getTargetEntity(getTarget())
const extractionResult = await executeExtractors({
fileType: fileType as FileType,
result: event.result,
Expand All @@ -77,11 +100,12 @@ export const useFillEntity = <TEntity extends Record<string, unknown>>({
events.onAfterUpload?.(event),
])
},
[entity, events, fileType],
[events, fileType, getTarget],
)

return {
...events,
prepareUpload,
onBeforeUpload: handleBeforeUpload,
onStartUpload: handleStartUpload,
onAfterUpload: handleAfterUpload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const useUploadState = ({
onError,
onProgress,
onAfterUpload,
}: UploaderEvents): UseUploadStateResult => {
}: Partial<UploaderEvents>): UseUploadStateResult => {
const [files, setFiles] = useState<Record<string, UploaderFileState>>({})

const purgeFinal = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { UploaderError } from '../../UploaderError.js'
import { useUploaderClient } from '../../contexts.js'
import { useGetPreviewUrls } from './useGetPreviewUrls.js'

export interface UseUploaderDoUploadArgs extends Partial<UploaderEvents> {
/**
* 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> | void
}

/**
* Hook that orchestrates the file upload process.
* Handles file preparation, validation, and upload execution.
Expand All @@ -15,7 +23,8 @@ export const useUploaderDoUpload = ({
onSuccess,
onStartUpload,
onAfterUpload,
}: UploaderEvents): ((files: File[]) => Promise<void>) => {
onPrepareUpload,
}: UseUploaderDoUploadArgs): ((files: File[]) => Promise<void>) => {
const getPreviewUrl = useGetPreviewUrls()
const defaultUploader = useUploaderClient()

Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -121,6 +143,7 @@ export const useUploaderDoUpload = ({
onAfterUpload,
onBeforeUpload,
onError,
onPrepareUpload,
onProgress,
onStartUpload,
onSuccess,
Expand Down
11 changes: 11 additions & 0 deletions packages/bindx-uploader/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TEntity = Record<string, unknown>> = EntityRef<TEntity> | HasOneRef<TEntity>

/**
* 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<TTarget> = (files: File[]) => TTarget | undefined | Promise<TTarget | undefined>

export interface UploaderEvents {
onBeforeUpload: (event: BeforeUploadEvent) => Promise<FileType | undefined>
onStartUpload: (event: StartUploadEvent) => void
Expand Down
Loading