= (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 (
@@ -371,6 +424,9 @@ export const Upload_v4: React.FC = (props) => {
buttonStyle="pill"
onClick={() => {
setShowUrlInput(true)
+ setUploadControlFileUrl('')
+ setUploadControlFile(null)
+ setUploadControlFileName(null)
}}
size="small"
>
@@ -378,8 +434,9 @@ export const Upload_v4: React.FC = (props) => {
)}
-
+ {UploadControls ? UploadControls : null}
+
{t('general:or')} {t('upload:dragAndDrop')}
@@ -419,6 +476,9 @@ export const Upload_v4: React.FC = (props) => {
iconStyle="with-border"
onClick={() => {
setShowUrlInput(false)
+ setUploadControlFileUrl('')
+ setUploadControlFile(null)
+ setUploadControlFileName(null)
}}
round
tooltip={t('general:cancel')}
diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts
index 366609d15..da847eb64 100644
--- a/packages/ui/src/exports/client/index.ts
+++ b/packages/ui/src/exports/client/index.ts
@@ -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,
diff --git a/packages/ui/src/providers/UploadControls/index.tsx b/packages/ui/src/providers/UploadControls/index.tsx
new file mode 100644
index 000000000..e8f5d05b7
--- /dev/null
+++ b/packages/ui/src/providers/UploadControls/index.tsx
@@ -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(undefined)
+
+export const UploadControlsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [uploadControlFileName, setUploadControlFileName] = useState(null)
+ const [uploadControlFileUrl, setUploadControlFileUrl] = useState('')
+ const [uploadControlFile, setUploadControlFile] = useState(null)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useUploadControls = (): UploadControlsContext => {
+ const context = use(Context)
+ if (!context) {
+ throw new Error('useUploadControls must be used within an UploadControlsProvider')
+ }
+ return context
+}
diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx
index d4efb1ce1..373b982bc 100644
--- a/packages/ui/src/views/Edit/index.tsx
+++ b/packages/ui/src/views/Edit/index.tsx
@@ -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 && (
- {CustomUpload || (
-
- )}
+
+ {CustomUpload || (
+
+ )}
+
)}
diff --git a/test/uploads/collections/AdminUploadControl/components/UploadControl/index.client.tsx b/test/uploads/collections/AdminUploadControl/components/UploadControl/index.client.tsx
new file mode 100644
index 000000000..56e3e8881
--- /dev/null
+++ b/test/uploads/collections/AdminUploadControl/components/UploadControl/index.client.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
diff --git a/test/uploads/collections/AdminUploadControl/components/UploadControl/index.tsx b/test/uploads/collections/AdminUploadControl/components/UploadControl/index.tsx
new file mode 100644
index 000000000..4504ff7c6
--- /dev/null
+++ b/test/uploads/collections/AdminUploadControl/components/UploadControl/index.tsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+import { UploadControl } from './index.client.js'
+
+export const UploadControlRSC: React.FC = () => {
+ return (
+
+
+
+ )
+}
diff --git a/test/uploads/collections/AdminUploadControl/index.ts b/test/uploads/collections/AdminUploadControl/index.ts
new file mode 100644
index 000000000..9195082df
--- /dev/null
+++ b/test/uploads/collections/AdminUploadControl/index.ts
@@ -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: [],
+}
diff --git a/test/uploads/config.ts b/test/uploads/config.ts
index 2751266e3..b0ad5b57e 100644
--- a/test/uploads/config.ts
+++ b/test/uploads/config.ts
@@ -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: [],
diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts
index ebdb479fa..5aaeac935 100644
--- a/test/uploads/e2e.spec.ts
+++ b/test/uploads/e2e.spec.ts
@@ -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)
diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts
index bf730324c..d2ec1ab02 100644
--- a/test/uploads/payload-types.ts
+++ b/test/uploads/payload-types.ts
@@ -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 | AdminThumbnailFunctionSelect;
'admin-thumbnail-with-search-queries': AdminThumbnailWithSearchQueriesSelect | AdminThumbnailWithSearchQueriesSelect;
'admin-thumbnail-size': AdminThumbnailSizeSelect | AdminThumbnailSizeSelect;
+ 'admin-upload-control': AdminUploadControlSelect | AdminUploadControlSelect;
'optional-file': OptionalFileSelect | OptionalFileSelect;
'required-file': RequiredFileSelect | RequiredFileSelect;
versions: VersionsSelect | VersionsSelect;
@@ -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 {
};
};
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "admin-upload-control_select".
+ */
+export interface AdminUploadControlSelect {
+ 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".
diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts
index b271c7508..bdcc50e47 100644
--- a/test/uploads/shared.ts
+++ b/test/uploads/shared.ts
@@ -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'