feat(ui): add hideFileInputOnCreate and hideRemoveFile to collection upload config (#11217)

### What?

Two new configuration properties added for upload enabled collections.
- *hideFileInputOnCreate* - Set to `true` to prevent the admin UI from
showing file inputs during document creation, useful for programmatic
file generation.
- *hideRemoveFile* - Set to `true` to prevent the admin UI having a way
to remove an existing file while editing.

### Why?

When using file uploads that get created programmatically in
`beforeOperation` hooks or files created using `jobs`, or when
`filesRequiredOnCreate` is false, you may want to use these new flags to
prevent users from interacting with these controls.

### How?

The new properties only impact the admin UI components to dial in the UX
for various use cases.

Screenshot showing that the upload controls are not available on create:

![image](https://github.com/user-attachments/assets/5560b9ac-271d-4ee0-8bcf-6080012ff75f)

Screenshot showing hideRemoveFile has removed the ability to remove the
existing file:

![image](https://github.com/user-attachments/assets/71c562dd-c425-40e6-b980-f65895979885)

Prerequisite for https://github.com/payloadcms/payload/pull/10795
This commit is contained in:
Dan Ribbens
2025-02-17 16:36:38 -05:00
committed by GitHub
parent ee0ac7f9c0
commit daaaa5f1be
12 changed files with 221 additions and 32 deletions

View File

@@ -89,7 +89,7 @@ export const Media: CollectionConfig = {
_An asterisk denotes that an option is required._
| Option | Description |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
@@ -109,6 +109,8 @@ _An asterisk denotes that an option is required._
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
### Payload-wide Upload Options

View File

@@ -181,6 +181,14 @@ export type UploadConfig = {
params: { collection: string; filename: string }
},
) => Promise<Response> | Promise<void> | Response | void)[]
/**
* Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation.
*/
hideFileInputOnCreate?: boolean
/**
* Set to `true` to prevent the admin UI having a way to remove an existing file while editing.
*/
hideRemoveFile?: boolean
imageSizes?: ImageSize[]
/**
* Restrict mimeTypes in the file picker. Array of valid mime types or mimetype wildcards

View File

@@ -23,6 +23,7 @@ export type DraggableFileDetailsProps = {
enableAdjustments?: boolean
hasImageSizes?: boolean
hasMany: boolean
hideRemoveFile?: boolean
imageCacheTag?: string
isSortable?: boolean
removeItem?: (index: number) => void
@@ -31,8 +32,16 @@ export type DraggableFileDetailsProps = {
}
export const DraggableFileDetails: React.FC<DraggableFileDetailsProps> = (props) => {
const { collectionSlug, doc, imageCacheTag, isSortable, removeItem, rowIndex, uploadConfig } =
props
const {
collectionSlug,
doc,
hideRemoveFile,
imageCacheTag,
isSortable,
removeItem,
rowIndex,
uploadConfig,
} = props
const { id, filename, thumbnailURL, url } = doc
@@ -84,7 +93,7 @@ export const DraggableFileDetails: React.FC<DraggableFileDetailsProps> = (props)
<DocumentDrawerToggler>
<EditIcon />
</DocumentDrawerToggler>
{removeItem && (
{!hideRemoveFile && removeItem && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__remove`}

View File

@@ -19,6 +19,7 @@ export type StaticFileDetailsProps = {
enableAdjustments?: boolean
handleRemove?: () => void
hasImageSizes?: boolean
hideRemoveFile?: boolean
imageCacheTag?: string
uploadConfig: SanitizedCollectionConfig['upload']
}
@@ -30,6 +31,7 @@ export const StaticFileDetails: React.FC<StaticFileDetailsProps> = (props) => {
enableAdjustments,
handleRemove,
hasImageSizes,
hideRemoveFile,
imageCacheTag,
uploadConfig,
} = props
@@ -66,7 +68,7 @@ export const StaticFileDetails: React.FC<StaticFileDetailsProps> = (props) => {
/>
)}
</div>
{handleRemove && (
{!hideRemoveFile && handleRemove && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__remove`}

View File

@@ -14,6 +14,7 @@ type SharedFileDetailsProps = {
} & Data
enableAdjustments?: boolean
hasImageSizes?: boolean
hideRemoveFile?: boolean
imageCacheTag?: string
uploadConfig: SanitizedCollectionConfig['upload']
}

View File

@@ -270,6 +270,10 @@ export const Upload: React.FC<UploadProps> = (props) => {
const imageCacheTag = uploadConfig?.cacheTags && savedDocumentData?.updatedAt
if (uploadConfig.hideFileInputOnCreate && !savedDocumentData?.filename) {
return null
}
return (
<div className={[fieldBaseClass, baseClass].filter(Boolean).join(' ')}>
<FieldError message={errorMessage} showError={showError} />
@@ -281,11 +285,12 @@ export const Upload: React.FC<UploadProps> = (props) => {
enableAdjustments={showCrop || showFocalPoint}
handleRemove={canRemoveUpload ? handleFileRemoval : undefined}
hasImageSizes={hasImageSizes}
hideRemoveFile={uploadConfig.hideRemoveFile}
imageCacheTag={imageCacheTag}
uploadConfig={uploadConfig}
/>
)}
{(!savedDocumentData?.filename || removedFile) && (
{((!uploadConfig.hideFileInputOnCreate && !savedDocumentData?.filename) || removedFile) && (
<div className={`${baseClass}__upload`}>
{!value && !showUrlInput && (
<Dropzone onChange={handleFileSelection}>

View File

@@ -58,6 +58,7 @@ export type UploadInputProps = {
readonly Error?: React.ReactNode
readonly filterOptions?: FilterOptionsResult
readonly hasMany?: boolean
readonly hideRemoveFile?: boolean
readonly isSortable?: boolean
readonly Label?: React.ReactNode
readonly label?: StaticLabel

View File

@@ -177,7 +177,7 @@ export function DefaultListView(props: ListViewClientProps) {
}
hasCreatePermission={hasCreatePermission}
i18n={i18n}
isBulkUploadEnabled={isBulkUploadEnabled}
isBulkUploadEnabled={isBulkUploadEnabled && !upload.hideFileInputOnCreate}
newDocumentURL={newDocumentURL}
openBulkUpload={openBulkUpload}
smallBreak={smallBreak}

View File

@@ -17,6 +17,7 @@ import {
customFileNameMediaSlug,
enlargeSlug,
focalNoSizesSlug,
hideFileInputOnCreateSlug,
mediaSlug,
mediaWithoutCacheTagsSlug,
mediaWithoutRelationPreviewSlug,
@@ -50,6 +51,11 @@ export default buildConfigWithDefaults({
type: 'upload',
relationTo: versionSlug,
},
{
name: 'hideFileInputOnCreate',
type: 'upload',
relationTo: hideFileInputOnCreateSlug,
},
],
},
{
@@ -693,6 +699,36 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: hideFileInputOnCreateSlug,
upload: {
hideFileInputOnCreate: true,
hideRemoveFile: true,
staticDir: path.resolve(dirname, 'uploads'),
},
hooks: {
beforeOperation: [
({ req, operation }) => {
if (operation !== 'create') {
return
}
const buffer = Buffer.from('This file was generated by a hook', 'utf-8')
req.file = {
name: `${new Date().toISOString()}.txt`,
data: buffer,
mimetype: 'text/plain',
size: buffer.length,
}
},
],
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
],
onInit: async (payload) => {
const uploadsDir = path.resolve(dirname, './media')

View File

@@ -29,6 +29,7 @@ import {
customFileNameMediaSlug,
customUploadFieldSlug,
focalOnlySlug,
hideFileInputOnCreateSlug,
mediaSlug,
mediaWithoutCacheTagsSlug,
relationPreviewSlug,
@@ -63,6 +64,7 @@ let customFileNameURL: AdminUrlUtil
let uploadsOne: AdminUrlUtil
let uploadsTwo: AdminUrlUtil
let customUploadFieldURL: AdminUrlUtil
let hideFileInputOnCreateURL: AdminUrlUtil
describe('Uploads', () => {
let page: Page
@@ -92,6 +94,7 @@ describe('Uploads', () => {
uploadsOne = new AdminUrlUtil(serverURL, 'uploads-1')
uploadsTwo = new AdminUrlUtil(serverURL, 'uploads-2')
customUploadFieldURL = new AdminUrlUtil(serverURL, customUploadFieldSlug)
hideFileInputOnCreateURL = new AdminUrlUtil(serverURL, hideFileInputOnCreateSlug)
const context = await browser.newContext()
page = await context.newPage()
@@ -366,10 +369,10 @@ describe('Uploads', () => {
await page.locator('.doc-drawer__header-close').click()
// remove the selected versioned image
await page.locator('.field-type:nth-of-type(2) .icon--x').click()
await page.locator('#field-versionedImage .icon--x').click()
// choose from existing
await openDocDrawer(page, '.upload__listToggler')
await openDocDrawer(page, '#field-versionedImage .upload__listToggler')
await expect(page.locator('.row-3 .cell-title')).toContainText('draft')
})
@@ -456,7 +459,6 @@ describe('Uploads', () => {
test('should render adminThumbnail when using a function', async () => {
await page.goto(adminThumbnailFunctionURL.list)
await page.waitForURL(adminThumbnailFunctionURL.list)
// Ensure sure false or null shows generic file svg
const genericUploadImage = page.locator('tr.row-1 .thumbnail img')
@@ -468,7 +470,6 @@ describe('Uploads', () => {
test('should render adminThumbnail when using a custom thumbnail URL with additional queries', async () => {
await page.goto(adminThumbnailWithSearchQueriesURL.list)
await page.waitForURL(adminThumbnailWithSearchQueriesURL.list)
const genericUploadImage = page.locator('tr.row-1 .thumbnail img')
// Match the URL with the regex pattern
@@ -539,7 +540,6 @@ describe('Uploads', () => {
test('should render adminThumbnail when using a specific size', async () => {
await page.goto(adminThumbnailSizeURL.list)
await page.waitForURL(adminThumbnailSizeURL.list)
// Ensure sure false or null shows generic file svg
const genericUploadImage = page.locator('tr.row-1 .thumbnail img')
@@ -747,7 +747,6 @@ describe('Uploads', () => {
test('should apply field value to all bulk upload files after edit many', async () => {
// Navigate to the upload creation page
await page.goto(uploadsOne.create)
await page.waitForURL(uploadsOne.create)
// Upload single file
await page.setInputFiles(
@@ -1120,4 +1119,29 @@ describe('Uploads', () => {
const relationPreview6 = page.locator('.cell-imageWithoutPreview3 img')
await expect(relationPreview6).toBeHidden()
})
test('should hide file input when disableCreateFileInput is true on collection create', async () => {
await page.goto(hideFileInputOnCreateURL.create)
await page.waitForURL(hideFileInputOnCreateURL.create)
await expect(page.locator('.file-field__upload')).toBeHidden()
})
test('should hide bulk upload from list view when disableCreateFileInput is true', async () => {
await page.goto(hideFileInputOnCreateURL.list)
await expect(page.locator('.list-header')).not.toContainText('Bulk Upload')
})
test('should hide remove button in file input when hideRemove is true', async () => {
const doc = await payload.create({
collection: hideFileInputOnCreateSlug,
data: {
title: 'test',
},
})
await page.goto(hideFileInputOnCreateURL.edit(doc.id))
await expect(page.locator('.file-field .file-details__remove')).toBeHidden()
})
})

View File

@@ -6,10 +6,65 @@
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
relation: Relation;
audio: Audio;
@@ -44,6 +99,7 @@ export interface Config {
'media-without-cache-tags': MediaWithoutCacheTag;
'media-without-relation-preview': MediaWithoutRelationPreview;
'relation-preview': RelationPreview;
'hide-file-input-on-create': HideFileInputOnCreate;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -84,6 +140,7 @@ export interface Config {
'media-without-cache-tags': MediaWithoutCacheTagsSelect<false> | MediaWithoutCacheTagsSelect<true>;
'media-without-relation-preview': MediaWithoutRelationPreviewSelect<false> | MediaWithoutRelationPreviewSelect<true>;
'relation-preview': RelationPreviewSelect<false> | RelationPreviewSelect<true>;
'hide-file-input-on-create': HideFileInputOnCreateSelect<false> | HideFileInputOnCreateSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -129,6 +186,7 @@ export interface Relation {
id: string;
image?: (string | null) | Media;
versionedImage?: (string | null) | Version;
hideFileInputOnCreate?: (string | null) | HideFileInputOnCreate;
updatedAt: string;
createdAt: string;
}
@@ -292,6 +350,25 @@ export interface Version {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hide-file-input-on-create".
*/
export interface HideFileInputOnCreate {
id: string;
title?: string | null;
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` "audio".
@@ -1328,6 +1405,10 @@ export interface PayloadLockedDocument {
relationTo: 'relation-preview';
value: string | RelationPreview;
} | null)
| ({
relationTo: 'hide-file-input-on-create';
value: string | HideFileInputOnCreate;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -1381,6 +1462,7 @@ export interface PayloadMigration {
export interface RelationSelect<T extends boolean = true> {
image?: T;
versionedImage?: T;
hideFileInputOnCreate?: T;
updatedAt?: T;
createdAt?: T;
}
@@ -2509,6 +2591,24 @@ export interface RelationPreviewSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hide-file-input-on-create_select".
*/
export interface HideFileInputOnCreateSelect<T extends boolean = true> {
title?: T;
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` "users_select".

View File

@@ -17,6 +17,7 @@ export const unstoredMediaSlug = 'unstored-media'
export const versionSlug = 'versions'
export const animatedTypeMedia = 'animated-type-media'
export const customUploadFieldSlug = 'custom-upload-field'
export const hideFileInputOnCreateSlug = 'hide-file-input-on-create'
export const withMetadataSlug = 'with-meta-data'
export const withoutMetadataSlug = 'without-meta-data'
export const withOnlyJPEGMetadataSlug = 'with-only-jpeg-meta-data'