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

@@ -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'