feat: admin upload controls (#11615)

### What?
Adds the ability to add additional components to the file upload
component.

```ts
export const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    admin: {
      components: {
        controls: [
          '/collections/components/Control/index.js#UploadControl',
        ],
      },
    },
  },
  fields: [],
}
```

![image](https://github.com/user-attachments/assets/4706e05b-4e95-4f15-8444-a279c589074e)

### Provider
Use the `useUploadControls` provider to either `setUploadControlFile`
passing a file object, or set the file by url using
`setUploadControlFileUrl`.

```tsx
'use client'
import { Button, useUploadControls } from '@payloadcms/ui'
import React, { useCallback } from 'react'

export const UploadControl = () => {
  const { setUploadControlFile, setUploadControlFileUrl } = useUploadControls()

  const loadFromFile = useCallback(async () => {
    const response = await fetch('https://payloadcms.com/images/universal-truth.jpg')
    const blob = await response.blob()
    const file = new File([blob], 'universal-truth.jpg', { type: 'image/jpeg' })
    setUploadControlFile(file)
  }, [setUploadControlFile])

  const loadFromUrl = useCallback(() => {
    setUploadControlFileUrl('https://payloadcms.com/images/universal-truth.jpg')
  }, [setUploadControlFileUrl])

  return (
    <div>
      <Button id="load-from-file-upload-button" onClick={loadFromFile}>
        Load from File
      </Button>
      <br />
      <Button id="load-from-url-upload-button" onClick={loadFromUrl}>
        Load from URL
      </Button>
    </div>
  )
}
```


### Why?
Add the ability to use a custom component to select a document to
upload.
This commit is contained in:
Kendell Joseph
2025-06-13 12:47:46 -04:00
committed by GitHub
parent 3edcc40174
commit f2e04222f4
18 changed files with 329 additions and 27 deletions

View File

@@ -157,6 +157,14 @@ export const renderDocumentSlots: (args: {
})
}
if (collectionConfig?.upload && collectionConfig.upload.admin?.components?.controls) {
components.UploadControls = RenderServerComponent({
Component: collectionConfig.upload.admin.components.controls,
importMap: req.payload.importMap,
serverProps,
})
}
return components
}

View File

@@ -566,6 +566,7 @@ export type DocumentSlots = {
SaveButton?: React.ReactNode
SaveDraftButton?: React.ReactNode
Upload?: React.ReactNode
UploadControls?: React.ReactNode
}
export type {

View File

@@ -44,6 +44,10 @@ export function iterateCollections({
addToImportMap(collection.admin?.components?.edit?.SaveDraftButton)
addToImportMap(collection.admin?.components?.edit?.Upload)
if (collection.upload?.admin?.components?.controls) {
addToImportMap(collection.upload?.admin?.components?.controls)
}
if (collection.admin?.components?.views?.edit) {
for (const editViewConfig of Object.values(collection.admin?.components?.views?.edit)) {
if ('Component' in editViewConfig) {

View File

@@ -1,6 +1,7 @@
import type { ResizeOptions, Sharp } from 'sharp'
import type { TypeWithID } from '../collections/config/types.js'
import type { PayloadComponent } from '../config/types.js'
import type { PayloadRequest } from '../types/index.js'
import type { WithMetadata } from './optionallyAppendMetadata.js'
@@ -101,12 +102,25 @@ export type AllowList = Array<{
search?: string
}>
type Admin = {
components?: {
/**
* The Controls component to extend the upload controls in the admin panel.
*/
controls?: PayloadComponent[]
}
}
export type UploadConfig = {
/**
* The adapter name to use for uploads. Used for storage adapter telemetry.
* @default undefined
*/
adapter?: string
/**
* The admin configuration for the upload field.
*/
admin?: Admin
/**
* Represents an admin thumbnail, which can be either a React component or a string.
* - If a string, it should be one of the image size names.
@@ -201,6 +215,7 @@ export type UploadConfig = {
* @default undefined
*/
modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers
/**
* Controls the behavior of pasting/uploading files from URLs.
* If set to `false`, fetching from remote URLs is disabled.

View File

@@ -22,8 +22,8 @@ import { DocumentFields } from '../../DocumentFields/index.js'
import { MoveDocToFolder } from '../../FolderView/MoveDocToFolder/index.js'
import { Upload_v4 } from '../../Upload/index.js'
import { useFormsManager } from '../FormsManager/index.js'
import { BulkUploadProvider } from '../index.js'
import './index.scss'
import { BulkUploadProvider } from '../index.js'
const baseClass = 'collection-edit'

View File

@@ -9,6 +9,7 @@ import { toast } from 'sonner'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { UploadControlsProvider } from '../../providers/UploadControls/index.js'
import { Drawer, useDrawerDepth } from '../Drawer/index.js'
import { AddFilesView } from './AddFilesView/index.js'
import { AddingFilesView } from './AddingFilesView/index.js'
@@ -75,7 +76,9 @@ export function BulkUploadDrawer() {
return (
<Drawer gutter={false} Header={null} slug={drawerSlug}>
<FormsManagerProvider>
<UploadControlsProvider>
<DrawerContent />
</UploadControlsProvider>
</FormsManagerProvider>
</Drawer>
)

View File

@@ -90,6 +90,7 @@
&__dropzoneButtons {
display: flex;
gap: calc(var(--base) * 0.5);
align-items: center;
}
&__orText {

View File

@@ -14,15 +14,16 @@ import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { EditDepthProvider } from '../../providers/EditDepth/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { UploadControlsProvider, useUploadControls } from '../../providers/UploadControls/index.js'
import { useUploadEdits } from '../../providers/UploadEdits/index.js'
import { Button } from '../Button/index.js'
import { Drawer } from '../Drawer/index.js'
import { Dropzone } from '../Dropzone/index.js'
import { EditUpload } from '../EditUpload/index.js'
import './index.scss'
import { FileDetails } from '../FileDetails/index.js'
import { PreviewSizes } from '../PreviewSizes/index.js'
import { Thumbnail } from '../Thumbnail/index.js'
import './index.scss'
const baseClass = 'file-field'
export const editDrawerSlug = 'edit-upload'
@@ -106,17 +107,20 @@ export type UploadProps = {
readonly initialState?: FormState
readonly onChange?: (file?: File) => void
readonly uploadConfig: SanitizedCollectionConfig['upload']
readonly UploadControls?: React.ReactNode
}
export const Upload: React.FC<UploadProps> = (props) => {
const { resetUploadEdits, updateUploadEdits, uploadEdits } = useUploadEdits()
return (
<UploadControlsProvider>
<Upload_v4
{...props}
resetUploadEdits={resetUploadEdits}
updateUploadEdits={updateUploadEdits}
uploadEdits={uploadEdits}
/>
</UploadControlsProvider>
)
}
@@ -135,9 +139,19 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
resetUploadEdits,
updateUploadEdits,
uploadConfig,
UploadControls,
uploadEdits,
} = props
const {
setUploadControlFile,
setUploadControlFileName,
setUploadControlFileUrl,
uploadControlFile,
uploadControlFileName,
uploadControlFileUrl,
} = useUploadControls()
const {
config: {
routes: { api },
@@ -174,12 +188,15 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
setValue(newFile)
setShowUrlInput(false)
setUploadControlFileUrl('')
setUploadControlFileName(null)
setUploadControlFile(null)
if (typeof onChange === 'function') {
onChange(newFile)
}
},
[onChange, setValue],
[onChange, setValue, setUploadControlFile, setUploadControlFileName, setUploadControlFileUrl],
)
const renameFile = (fileToChange: File, newName: string): File => {
@@ -218,7 +235,16 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
setFileUrl('')
resetUploadEdits()
setShowUrlInput(false)
}, [handleFileChange, resetUploadEdits])
setUploadControlFileUrl('')
setUploadControlFileName(null)
setUploadControlFile(null)
}, [
handleFileChange,
resetUploadEdits,
setUploadControlFile,
setUploadControlFileName,
setUploadControlFileUrl,
])
const onEditsSave = useCallback(
(args: UploadEdits) => {
@@ -228,7 +254,7 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
[setModified, updateUploadEdits],
)
const handleUrlSubmit = async () => {
const handleUrlSubmit = useCallback(async () => {
if (!fileUrl || uploadConfig?.pasteURL === false) {
return
}
@@ -243,7 +269,7 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
}
const blob = await clientResponse.blob()
const fileName = decodeURIComponent(fileUrl.split('/').pop() || '')
const fileName = uploadControlFileName || decodeURIComponent(fileUrl.split('/').pop() || '')
const file = new File([blob], fileName, { type: blob.type })
handleFileChange(file)
@@ -277,7 +303,17 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
toast.error('The provided URL is not allowed.')
setUploadStatus('failed')
}
}
}, [
fileUrl,
uploadConfig,
setUploadStatus,
handleFileChange,
useServerSideFetch,
collectionSlug,
id,
serverURL,
api,
])
useEffect(() => {
if (initialState?.file?.value instanceof File) {
@@ -314,9 +350,26 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
const imageCacheTag = uploadConfig?.cacheTags && savedDocumentData?.updatedAt
if (uploadConfig.hideFileInputOnCreate && !savedDocumentData?.filename) {
return null
useEffect(() => {
const handleControlFileUrl = async () => {
if (uploadControlFileUrl) {
setFileUrl(uploadControlFileUrl)
await handleUrlSubmit()
}
}
void handleControlFileUrl()
}, [uploadControlFileUrl, handleUrlSubmit])
useEffect(() => {
const handleControlFile = () => {
if (uploadControlFile) {
handleFileChange(uploadControlFile)
}
}
void handleControlFile()
}, [uploadControlFile, handleFileChange])
return (
<div className={[fieldBaseClass, baseClass].filter(Boolean).join(' ')}>
@@ -371,6 +424,9 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
buttonStyle="pill"
onClick={() => {
setShowUrlInput(true)
setUploadControlFileUrl('')
setUploadControlFile(null)
setUploadControlFileName(null)
}}
size="small"
>
@@ -378,8 +434,9 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
</Button>
</Fragment>
)}
</div>
{UploadControls ? UploadControls : null}
</div>
<p className={`${baseClass}__dragAndDropText`}>
{t('general:or')} {t('upload:dragAndDrop')}
</p>
@@ -419,6 +476,9 @@ export const Upload_v4: React.FC<UploadProps_v4> = (props) => {
iconStyle="with-border"
onClick={() => {
setShowUrlInput(false)
setUploadControlFileUrl('')
setUploadControlFile(null)
setUploadControlFileName(null)
}}
round
tooltip={t('general:cancel')}

View File

@@ -296,6 +296,7 @@ export { DocumentEventsProvider, useDocumentEvents } from '../../providers/Docum
export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js'
export { useDocumentTitle } from '../../providers/DocumentTitle/index.js'
export type { DocumentInfoContext, DocumentInfoProps } from '../../providers/DocumentInfo/index.js'
export { useUploadControls } from '../../providers/UploadControls/index.js'
export { EditDepthProvider, useEditDepth } from '../../providers/EditDepth/index.js'
export {
EntityVisibilityProvider,

View File

@@ -0,0 +1,42 @@
'use client'
import React, { createContext, use, useState } from 'react'
export type UploadControlsContext = {
setUploadControlFile: (file: File) => void
setUploadControlFileName: (name: string) => void
setUploadControlFileUrl: (url: string) => void
uploadControlFile: File | null
uploadControlFileName: null | string
uploadControlFileUrl: string
}
const Context = createContext<UploadControlsContext>(undefined)
export const UploadControlsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [uploadControlFileName, setUploadControlFileName] = useState<null | string>(null)
const [uploadControlFileUrl, setUploadControlFileUrl] = useState<string>('')
const [uploadControlFile, setUploadControlFile] = useState<File | null>(null)
return (
<Context
value={{
setUploadControlFile,
setUploadControlFileName,
setUploadControlFileUrl,
uploadControlFile,
uploadControlFileName,
uploadControlFileUrl,
}}
>
{children}
</Context>
)
}
export const useUploadControls = (): UploadControlsContext => {
const context = use(Context)
if (!context) {
throw new Error('useUploadControls must be used within an UploadControlsProvider')
}
return context
}

View File

@@ -27,6 +27,7 @@ import { useEditDepth } from '../../providers/EditDepth/index.js'
import { OperationProvider } from '../../providers/Operation/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { UploadControlsProvider } from '../../providers/UploadControls/index.js'
import { useUploadEdits } from '../../providers/UploadEdits/index.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { handleBackToDashboard } from '../../utilities/handleBackToDashboard.js'
@@ -34,8 +35,8 @@ import { handleGoBack } from '../../utilities/handleGoBack.js'
import { handleTakeOver } from '../../utilities/handleTakeOver.js'
import { Auth } from './Auth/index.js'
import { SetDocumentStepNav } from './SetDocumentStepNav/index.js'
import { SetDocumentTitle } from './SetDocumentTitle/index.js'
import './index.scss'
import { SetDocumentTitle } from './SetDocumentTitle/index.js'
const baseClass = 'collection-edit'
@@ -51,6 +52,7 @@ export function DefaultEditView({
SaveButton,
SaveDraftButton,
Upload: CustomUpload,
UploadControls,
}: DocumentViewClientProps) {
const {
id,
@@ -581,13 +583,16 @@ export function DefaultEditView({
)}
{upload && (
<React.Fragment>
<UploadControlsProvider>
{CustomUpload || (
<Upload
collectionSlug={collectionConfig.slug}
initialState={initialState}
uploadConfig={upload}
UploadControls={UploadControls}
/>
)}
</UploadControlsProvider>
</React.Fragment>
)}
</Fragment>

View File

@@ -0,0 +1,30 @@
'use client'
import { Button, useUploadControls } from '@payloadcms/ui'
import React, { useCallback } from 'react'
export const UploadControl = () => {
const { setUploadControlFile, setUploadControlFileUrl } = useUploadControls()
const loadFromFile = useCallback(async () => {
const response = await fetch('https://payloadcms.com/images/universal-truth.jpg')
const blob = await response.blob()
const file = new File([blob], 'universal-truth.jpg', { type: 'image/jpeg' })
setUploadControlFile(file)
}, [setUploadControlFile])
const loadFromUrl = useCallback(() => {
setUploadControlFileUrl('https://payloadcms.com/images/universal-truth.jpg')
}, [setUploadControlFileUrl])
return (
<div>
<Button id="load-from-file-upload-button" onClick={loadFromFile}>
Load from File
</Button>
<br />
<Button id="load-from-url-upload-button" onClick={loadFromUrl}>
Load from URL
</Button>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import { UploadControl } from './index.client.js'
export const UploadControlRSC: React.FC = () => {
return (
<div>
<UploadControl />
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type { CollectionConfig } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { adminUploadControlSlug } from '../../shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const AdminUploadControl: CollectionConfig = {
slug: adminUploadControlSlug,
upload: {
staticDir: path.resolve(dirname, 'test/uploads/media'),
admin: {
components: {
controls: [
'/collections/AdminUploadControl/components/UploadControl/index.js#UploadControlRSC',
],
},
},
},
fields: [],
}

View File

@@ -12,6 +12,7 @@ import removeFiles from '../helpers/removeFiles.js'
import { AdminThumbnailFunction } from './collections/AdminThumbnailFunction/index.js'
import { AdminThumbnailSize } from './collections/AdminThumbnailSize/index.js'
import { AdminThumbnailWithSearchQueries } from './collections/AdminThumbnailWithSearchQueries/index.js'
import { AdminUploadControl } from './collections/AdminUploadControl/index.js'
import { CustomUploadFieldCollection } from './collections/CustomUploadField/index.js'
import { Uploads1 } from './collections/Upload1/index.js'
import { Uploads2 } from './collections/Upload2/index.js'
@@ -639,6 +640,7 @@ export default buildConfigWithDefaults({
AdminThumbnailFunction,
AdminThumbnailWithSearchQueries,
AdminThumbnailSize,
AdminUploadControl,
{
slug: 'optional-file',
fields: [],

View File

@@ -20,11 +20,12 @@ import { assertToastErrors } from '../helpers/assertToastErrors.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { RESTClient } from '../helpers/rest.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
adminThumbnailFunctionSlug,
adminThumbnailSizeSlug,
adminThumbnailWithSearchQueries,
adminUploadControlSlug,
animatedTypeMedia,
audioSlug,
customFileNameMediaSlug,
@@ -55,6 +56,7 @@ let mediaURL: AdminUrlUtil
let animatedTypeMediaURL: AdminUrlUtil
let audioURL: AdminUrlUtil
let relationURL: AdminUrlUtil
let adminUploadControlURL: AdminUrlUtil
let adminThumbnailSizeURL: AdminUrlUtil
let adminThumbnailFunctionURL: AdminUrlUtil
let adminThumbnailWithSearchQueriesURL: AdminUrlUtil
@@ -89,6 +91,7 @@ describe('Uploads', () => {
animatedTypeMediaURL = new AdminUrlUtil(serverURL, animatedTypeMedia)
audioURL = new AdminUrlUtil(serverURL, audioSlug)
relationURL = new AdminUrlUtil(serverURL, relationSlug)
adminUploadControlURL = new AdminUrlUtil(serverURL, adminUploadControlSlug)
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug)
adminThumbnailWithSearchQueriesURL = new AdminUrlUtil(
@@ -520,6 +523,57 @@ describe('Uploads', () => {
)
})
test('should render adminUploadControls', async () => {
await page.goto(adminUploadControlURL.create)
const loadFromFileButton = page.locator('#load-from-file-upload-button')
const loadFromUrlButton = page.locator('#load-from-url-upload-button')
await expect(loadFromFileButton).toBeVisible()
await expect(loadFromUrlButton).toBeVisible()
})
test('should load a file using a file reference from custom controls', async () => {
await page.goto(adminUploadControlURL.create)
const loadFromFileButton = page.locator('#load-from-file-upload-button')
await loadFromFileButton.click()
await wait(1000)
await page.locator('#action-save').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await wait(1000)
const mediaID = page.url().split('/').pop()
const { doc: mediaDoc } = await client.findByID({
id: mediaID as string,
slug: adminUploadControlSlug,
auth: true,
})
await expect
.poll(() => mediaDoc.filename, { timeout: POLL_TOPASS_TIMEOUT })
.toContain('universal-truth')
})
test('should load a file using a URL reference from custom controls', async () => {
await page.goto(adminUploadControlURL.create)
const loadFromUrlButton = page.locator('#load-from-url-upload-button')
await loadFromUrlButton.click()
await page.locator('#action-save').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await wait(1000)
const mediaID = page.url().split('/').pop()
const { doc: mediaDoc } = await client.findByID({
id: mediaID as string,
slug: adminUploadControlSlug,
auth: true,
})
await expect
.poll(() => mediaDoc.filename, { timeout: POLL_TOPASS_TIMEOUT })
.toContain('universal-truth')
})
test('should render adminThumbnail when using a function', async () => {
await page.goto(adminThumbnailFunctionURL.list)

View File

@@ -94,6 +94,7 @@ export interface Config {
'admin-thumbnail-function': AdminThumbnailFunction;
'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQuery;
'admin-thumbnail-size': AdminThumbnailSize;
'admin-upload-control': AdminUploadControl;
'optional-file': OptionalFile;
'required-file': RequiredFile;
versions: Version;
@@ -140,6 +141,7 @@ export interface Config {
'admin-thumbnail-function': AdminThumbnailFunctionSelect<false> | AdminThumbnailFunctionSelect<true>;
'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect<false> | AdminThumbnailWithSearchQueriesSelect<true>;
'admin-thumbnail-size': AdminThumbnailSizeSelect<false> | AdminThumbnailSizeSelect<true>;
'admin-upload-control': AdminUploadControlSelect<false> | AdminUploadControlSelect<true>;
'optional-file': OptionalFileSelect<false> | OptionalFileSelect<true>;
'required-file': RequiredFileSelect<false> | RequiredFileSelect<true>;
versions: VersionsSelect<false> | VersionsSelect<true>;
@@ -1179,6 +1181,24 @@ export interface AdminThumbnailWithSearchQuery {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admin-upload-control".
*/
export interface AdminUploadControl {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "optional-file".
@@ -1479,6 +1499,10 @@ export interface PayloadLockedDocument {
relationTo: 'admin-thumbnail-size';
value: string | AdminThumbnailSize;
} | null)
| ({
relationTo: 'admin-upload-control';
value: string | AdminUploadControl;
} | null)
| ({
relationTo: 'optional-file';
value: string | OptionalFile;
@@ -2616,6 +2640,23 @@ export interface AdminThumbnailSizeSelect<T extends boolean = true> {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admin-upload-control_select".
*/
export interface AdminUploadControlSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "optional-file_select".

View File

@@ -11,6 +11,7 @@ export const relationPreviewSlug = 'relation-preview'
export const mediaWithRelationPreviewSlug = 'media-with-relation-preview'
export const mediaWithoutRelationPreviewSlug = 'media-without-relation-preview'
export const mediaWithoutCacheTagsSlug = 'media-without-cache-tags'
export const adminUploadControlSlug = 'admin-upload-control'
export const adminThumbnailFunctionSlug = 'admin-thumbnail-function'
export const adminThumbnailWithSearchQueries = 'admin-thumbnail-with-search-queries'
export const adminThumbnailSizeSlug = 'admin-thumbnail-size'