From e1ea07441e65be16562dfb2fccec977f04811c95 Mon Sep 17 00:00:00 2001 From: Patrik <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:56:57 -0400 Subject: [PATCH] feat: adds disableListColumn and disableListFilter to imageSize admin props (#13699) ### What? Added support for `disableListColumn` and `disableListFilter` admin properties on imageSize configurations that automatically apply to all fields within the corresponding size group. ### Why? Upload collections with multiple image sizes can clutter the admin list view with many size-specific columns and filters. This feature allows developers to selectively hide size fields from list views while keeping them accessible in the document edit view. ### How? Modified `getBaseFields.ts` to inherit admin properties from imageSize configuration and apply them to all nested fields (url, width, height, mimeType, filesize, filename) within each size group. The implementation uses conditional spread operators to only apply these properties when explicitly set to `true`, maintaining backward compatibility. --- docs/upload/overview.mdx | 23 ++++ packages/payload/src/uploads/getBaseFields.ts | 51 +++++++- packages/payload/src/uploads/types.ts | 21 ++++ test/uploads/config.ts | 40 ++++++ test/uploads/e2e.spec.ts | 103 +++++++++++++++ test/uploads/payload-types.ts | 119 ++++++++++++++++++ test/uploads/shared.ts | 1 + 7 files changed, 354 insertions(+), 4 deletions(-) diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 1167c957b..d9e60ef31 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -192,6 +192,29 @@ Behind the scenes, Payload relies on [`sharp`](https://sharp.pixelplumbing.com/a Note that for image resizing to work, `sharp` must be specified in your [Payload Config](../configuration/overview). This is configured by default if you created your Payload project with `create-payload-app`. See `sharp` in [Config Options](../configuration/overview#config-options). +#### Admin List View Options + +Each image size also supports `admin` options to control whether it appears in the [Admin Panel](../admin/overview) list view. + +```ts +{ + name: 'thumbnail', + width: 400, + height: 300, + admin: { + disableListColumn: true, // hide from list view columns + disableListFilter: true, // hide from list view filters + }, +} +``` + +| Option | Description | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **`disableListColumn`** | If set to `true`, this image size will not be available as a selectable column in the collection list view. Defaults to `false`. | +| **`disableListFilter`** | If set to `true`, this image size will not be available as a filter option in the collection list view. Defaults to `false`. | + +This is useful for hiding large or rarely used image sizes from the list view UI while still keeping them available in the API. + #### Accessing the resized images in hooks All auto-resized images are exposed to be re-used in hooks and similar via an object that is bound to `req.payloadUploadSizes`. diff --git a/packages/payload/src/uploads/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts index ba0647651..ca083adb9 100644 --- a/packages/payload/src/uploads/getBaseFields.ts +++ b/packages/payload/src/uploads/getBaseFields.ts @@ -183,6 +183,9 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => mimeType.validate = mimeTypeValidator(uploadOptions.mimeTypes) } + // In Payload v4, image size subfields (`url`, `width`, `height`, etc.) should + // default to `disableListColumn: true` and `disableListFilter: true` + // to avoid cluttering the collection list view and filters by default. if (uploadOptions.imageSizes) { uploadFields = uploadFields.concat([ { @@ -196,10 +199,17 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => type: 'group', admin: { hidden: true, + ...(size.admin?.disableListColumn && { disableListColumn: true }), + ...(size.admin?.disableListFilter && { disableListFilter: true }), }, fields: [ { ...url, + admin: { + ...url.admin, + ...(size.admin?.disableListColumn && { disableListColumn: true }), + ...(size.admin?.disableListFilter && { disableListFilter: true }), + }, hooks: { afterRead: [ ({ data, value }) => { @@ -218,12 +228,45 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => ], }, }, - width, - height, - mimeType, - filesize, + { + ...width, + admin: { + ...width.admin, + ...(size.admin?.disableListColumn && { disableListColumn: true }), + ...(size.admin?.disableListFilter && { disableListFilter: true }), + }, + }, + { + ...height, + admin: { + ...height.admin, + ...(size.admin?.disableListColumn && { disableListColumn: true }), + ...(size.admin?.disableListFilter && { disableListFilter: true }), + }, + }, + { + ...mimeType, + admin: { + ...mimeType.admin, + ...(size.admin?.disableListColumn && { disableListColumn: true }), + ...(size.admin?.disableListFilter && { disableListFilter: true }), + }, + }, + { + ...filesize, + admin: { + ...filesize.admin, + ...(size.admin?.disableListColumn && { disableListColumn: true }), + ...(size.admin?.disableListFilter && { disableListFilter: true }), + }, + }, { ...filename, + admin: { + ...filename.admin, + ...(size.admin?.disableListColumn && { disableListColumn: true }), + ...(size.admin?.disableListFilter && { disableListFilter: true }), + }, unique: false, }, ], diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index 11c7537df..26a8a0551 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -69,6 +69,27 @@ export type GenerateImageName = (args: { }) => string export type ImageSize = { + /** + * Admin UI options that control how this image size appears in list views. + * + * NOTE: In Payload v4, these options (`disableListColumn`, `disableListFilter`) + * should default to `true` so image size subfields are hidden from list columns + * and filters by default, reducing noise in the admin UI. + */ + admin?: { + /** + * If set to true, this image size will not be available + * as a selectable column in the collection list view. + * @default false + */ + disableListColumn?: boolean + /** + * If set to true, this image size will not be available + * as a filter option in the collection list view. + * @default false + */ + disableListFilter?: boolean + } /** * @deprecated prefer position */ diff --git a/test/uploads/config.ts b/test/uploads/config.ts index d121be5a7..39f4ce4c6 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -30,6 +30,7 @@ import { imageSizesOnlySlug, listViewPreviewSlug, mediaSlug, + mediaWithImageSizeAdminPropsSlug, mediaWithoutCacheTagsSlug, mediaWithoutDeleteAccessSlug, mediaWithoutRelationPreviewSlug, @@ -944,6 +945,45 @@ export default buildConfigWithDefaults({ upload: true, access: { delete: () => false }, }, + { + slug: mediaWithImageSizeAdminPropsSlug, + fields: [], + upload: { + imageSizes: [ + { + name: 'one', + height: 200, + width: 200, + admin: { + disableListFilter: true, + disableListColumn: true, + }, + }, + { + name: 'two', + height: 300, + width: 300, + admin: { + disableListColumn: true, + }, + }, + { + name: 'three', + height: 400, + width: 400, + admin: { + disableListColumn: false, + disableListFilter: true, + }, + }, + { + name: 'four', + height: 400, + width: 300, + }, + ], + }, + }, ], onInit: async (payload) => { const uploadsDir = path.resolve(dirname, './media') diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 4d4d3dda9..bdc355e11 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -2,6 +2,8 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { statSync } from 'fs' +import { openListColumns } from 'helpers/e2e/openListColumns.js' +import { openListFilters } from 'helpers/e2e/openListFilters.js' import { toggleColumn } from 'helpers/e2e/toggleColumn.js' import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js' import path from 'path' @@ -40,6 +42,7 @@ import { imageSizesOnlySlug, listViewPreviewSlug, mediaSlug, + mediaWithImageSizeAdminPropsSlug, mediaWithoutCacheTagsSlug, mediaWithoutDeleteAccessSlug, relationPreviewSlug, @@ -92,6 +95,7 @@ let bulkUploadsURL: AdminUrlUtil let fileMimeTypeURL: AdminUrlUtil let svgOnlyURL: AdminUrlUtil let mediaWithoutDeleteAccessURL: AdminUrlUtil +let mediaWithImageSizeAdminPropsURL: AdminUrlUtil describe('Uploads', () => { let page: Page @@ -133,6 +137,7 @@ describe('Uploads', () => { fileMimeTypeURL = new AdminUrlUtil(serverURL, fileMimeTypeSlug) svgOnlyURL = new AdminUrlUtil(serverURL, svgOnlySlug) mediaWithoutDeleteAccessURL = new AdminUrlUtil(serverURL, mediaWithoutDeleteAccessSlug) + mediaWithImageSizeAdminPropsURL = new AdminUrlUtil(serverURL, mediaWithImageSizeAdminPropsSlug) const context = await browser.newContext() await context.grantPermissions(['clipboard-read', 'clipboard-write']) @@ -1656,4 +1661,102 @@ describe('Uploads', () => { ).docs[0]?.filename expect(filenameFromAPI).toBe('test-image.jpg') }) + + test('should not show image sizes in column selector in list view if imageSize has admin.disableListColumn true', async () => { + await page.goto(mediaWithImageSizeAdminPropsURL.list) + + await openListColumns(page, {}) + + await expect(page.locator('button:has-text("Sizes > one > URL")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > one > Width")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > one > Height")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > one > MIME Type")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > one > File Size")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > one > File Name")')).toBeHidden() + + await expect(page.locator('button:has-text("Sizes > two > URL")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > two > Width")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > two > Height")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > two > MIME Type")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > two > File Size")')).toBeHidden() + await expect(page.locator('button:has-text("Sizes > two > File Name")')).toBeHidden() + }) + + test('should show image size in column selector in list view if imageSize has admin.disableListColumn false', async () => { + await page.goto(mediaWithImageSizeAdminPropsURL.list) + + await openListColumns(page, {}) + + await expect(page.locator('button:has-text("Sizes > three > URL")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > three > Width")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > three > Height")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > three > MIME Type")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > three > File Size")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > three > File Name")')).toBeVisible() + + await expect(page.locator('button:has-text("Sizes > four > URL")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > four > Width")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > four > Height")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > four > MIME Type")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > four > File Size")')).toBeVisible() + await expect(page.locator('button:has-text("Sizes > four > File Name")')).toBeVisible() + }) + + test('should not show image size in where filter drodown in list view if imageSize has admin.disableListFilter true', async () => { + await page.goto(mediaWithImageSizeAdminPropsURL.list) + + await openListFilters(page, {}) + + const whereBuilder = page.locator('.where-builder') + await whereBuilder.locator('.where-builder__add-first-filter').click() + + const conditionField = whereBuilder.locator('.condition__field') + await conditionField.click() + + const menuList = conditionField.locator('.rs__menu-list') + + // ensure the image size is not present + await expect(menuList.getByText('Sizes > one > URL', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > one > Width', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > one > Height', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > one > MIME Type', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > one > File Size', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > one > File Name', { exact: true })).toHaveCount(0) + + await expect(menuList.getByText('Sizes > three > URL', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > three > Width', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > three > Height', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > three > MIME Type', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > three > File Size', { exact: true })).toHaveCount(0) + await expect(menuList.getByText('Sizes > three > File Name', { exact: true })).toHaveCount(0) + }) + + test('should show image size in where filter drodown in list view if imageSize has admin.disableListFilter false', async () => { + await page.goto(mediaWithImageSizeAdminPropsURL.list) + + await openListFilters(page, {}) + + const whereBuilder = page.locator('.where-builder') + await whereBuilder.locator('.where-builder__add-first-filter').click() + + const conditionField = whereBuilder.locator('.condition__field') + await conditionField.click() + + const menuList = conditionField.locator('.rs__menu-list') + + // ensure the image size is present + await expect(menuList.getByText('Sizes > two > URL', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > two > Width', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > two > Height', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > two > MIME Type', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > two > File Size', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > two > File Name', { exact: true })).toHaveCount(1) + + await expect(menuList.getByText('Sizes > four > URL', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > four > Width', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > four > Height', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > four > MIME Type', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > four > File Size', { exact: true })).toHaveCount(1) + await expect(menuList.getByText('Sizes > four > File Name', { exact: true })).toHaveCount(1) + }) }) diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 57740743b..4c60e5cd1 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -121,6 +121,7 @@ export interface Config { 'file-mime-type': FileMimeType; 'svg-only': SvgOnly; 'media-without-delete-access': MediaWithoutDeleteAccess; + 'media-with-image-size-admin-props': MediaWithImageSizeAdminProp; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -182,6 +183,7 @@ export interface Config { 'file-mime-type': FileMimeTypeSelect | FileMimeTypeSelect; 'svg-only': SvgOnlySelect | SvgOnlySelect; 'media-without-delete-access': MediaWithoutDeleteAccessSelect | MediaWithoutDeleteAccessSelect; + 'media-with-image-size-admin-props': MediaWithImageSizeAdminPropsSelect | MediaWithImageSizeAdminPropsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -1664,6 +1666,58 @@ export interface MediaWithoutDeleteAccess { focalX?: number | null; focalY?: number | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-with-image-size-admin-props". + */ +export interface MediaWithImageSizeAdminProp { + 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; + sizes?: { + one?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + two?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + three?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + four?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -1911,6 +1965,10 @@ export interface PayloadLockedDocument { relationTo: 'media-without-delete-access'; value: string | MediaWithoutDeleteAccess; } | null) + | ({ + relationTo: 'media-with-image-size-admin-props'; + value: string | MediaWithImageSizeAdminProp; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -3470,6 +3528,67 @@ export interface MediaWithoutDeleteAccessSelect { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-with-image-size-admin-props_select". + */ +export interface MediaWithImageSizeAdminPropsSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; + sizes?: + | T + | { + one?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + two?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + three?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + four?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts index 57439b9bd..b5e33e2ae 100644 --- a/test/uploads/shared.ts +++ b/test/uploads/shared.ts @@ -41,3 +41,4 @@ export const fileMimeTypeSlug = 'file-mime-type' export const svgOnlySlug = 'svg-only' export const anyImagesSlug = 'any-images' export const mediaWithoutDeleteAccessSlug = 'media-without-delete-access' +export const mediaWithImageSizeAdminPropsSlug = 'media-with-image-size-admin-props'