feat: adds admin.formatDocURL function to control list view linking (#13773)

### What?

Adds a new `formatDocURL` function to collection admin configuration
that allows users to control the linkable state and URLs of first column
fields in list views.

### Why?

To provide a way to disable automatic link creation from the first
column or provide custom URLs based on document data, user permissions,
view context, and document state.

### How?

- Added `formatDocURL` function type to `CollectionAdminOptions` that
receives document data, default URL, request context, collection slug,
and view type
- Modified `renderCell` to call the function when available and handle
three return types:
  - `null`: disables linking entirely
  - `string`: uses custom URL
  - other: falls back to no linking for safety
- Added function to server-only properties to prevent React Server
Components serialization issues
- Updated `DefaultCell` component to support custom `linkURL` prop


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211211792037945
This commit is contained in:
Patrik
2025-09-24 15:20:54 -04:00
committed by GitHub
parent 512a8fa19f
commit 1d1240fd13
14 changed files with 402 additions and 10 deletions

View File

@@ -136,6 +136,7 @@ The following options are available:
| `enableRichTextLink` | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `enableRichTextRelationship` | The [Rich Text](../fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `folders` | A boolean to enable folders for a given collection. Defaults to `false`. [More details](../folders/overview). |
| `formatDocURL` | Function to customize document links in the List View. Return `null` to disable linking, or a string for custom URLs. [More details](#format-document-urls). |
| `meta` | Page metadata overrides to apply to this Collection within the Admin Panel. [More details](../admin/metadata). |
| `preview` | Function to generate preview URLs within the Admin Panel that can point to your app. [More details](../admin/preview). |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
@@ -333,6 +334,76 @@ export const Posts: CollectionConfig = {
}
```
### Format Document URLs
The `formatDocURL` function allows you to customize how document links are generated in the List View. This is useful for disabling links for certain documents, redirecting to custom destinations, or modifying URLs based on user context or document state.
To define a custom document URL formatter, use the `admin.formatDocURL` property in your Collection Config:
```ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
// ...
admin: {
formatDocURL: ({ doc, defaultURL, req, collectionSlug, viewType }) => {
// Disable linking for documents with specific status
if (doc.status === 'private') {
return null
}
// Custom destination for featured posts
if (doc.featured) {
return '/admin/featured-posts'
}
// Add query parameters based on user role
if (req.user?.role === 'admin') {
return defaultURL + '?admin=true'
}
// Use default URL for all other cases
return defaultURL
},
},
}
```
The `formatDocURL` function receives the following arguments:
| Argument | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `doc` | The document data for the current row |
| `defaultURL` | The default URL that Payload would normally generate for this document. You can return this as-is, modify it, or replace it entirely. |
| `req` | The full [PayloadRequest](../types/payload-request) object, providing access to user context, payload instance, and other request data |
| `collectionSlug` | The slug of the current collection |
| `viewType` | The current view context (`'list'`, `'trash'`, etc.) where the link is being generated |
The function should return:
- `null` to disable the link entirely (no link will be rendered)
- A `string` containing the custom URL to use for the link
- The `defaultURL` parameter to use Payload's default linking behavior
<Banner type="success">
**Tip:** The `defaultURL` parameter saves you from having to reconstruct URLs
manually. You can modify it by appending query parameters or use it as a
fallback for your custom logic.
</Banner>
#### Examples
**Disable linking for certain users:**
```ts
formatDocURL: ({ defaultURL, req }) => {
if (req.user?.role === 'editor') {
return null // No link rendered
}
return defaultURL
}
```
## GraphQL
You can completely disable GraphQL for this collection by passing `graphQL: false` to your collection config. This will completely disable all queries, mutations, and types from appearing in your GraphQL schema.

View File

@@ -280,6 +280,7 @@ export const renderListView = async (
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
req,
useAsTitle: collectionConfig.admin.useAsTitle,
viewType,
}))

View File

@@ -77,6 +77,7 @@ export type DefaultCellComponentProps<
customCellProps?: Record<string, any>
field: TField
link?: boolean
linkURL?: string
onClick?: (args: {
cellData: unknown
collectionSlug: SanitizedCollectionConfig['slug']

View File

@@ -29,7 +29,7 @@ export type ServerOnlyCollectionProperties = keyof Pick<
export type ServerOnlyCollectionAdminProperties = keyof Pick<
SanitizedCollectionConfig['admin'],
'baseFilter' | 'baseListFilter' | 'components' | 'hidden'
'baseFilter' | 'baseListFilter' | 'components' | 'formatDocURL' | 'hidden'
>
export type ServerOnlyUploadProperties = keyof Pick<
@@ -50,6 +50,7 @@ export type ClientCollectionConfig = {
SanitizedCollectionConfig['admin'],
| 'components'
| 'description'
| 'formatDocURL'
| 'joins'
| 'livePreview'
| 'preview'
@@ -97,6 +98,7 @@ const serverOnlyCollectionAdminProperties: Partial<ServerOnlyCollectionAdminProp
'baseFilter',
'baseListFilter',
'components',
'formatDocURL',
// 'preview' is handled separately
// `livePreview` is handled separately
]

View File

@@ -2,7 +2,7 @@
import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql'
import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials'
import type { CustomUpload } from '../../admin/types.js'
import type { CustomUpload, ViewTypes } from '../../admin/types.js'
import type { Arguments as MeArguments } from '../../auth/operations/me.js'
import type {
Arguments as RefreshArguments,
@@ -396,6 +396,27 @@ export type CollectionAdminOptions = {
enableListViewSelectAPI?: boolean
enableRichTextLink?: boolean
enableRichTextRelationship?: boolean
/**
* Function to format the URL for document links in the list view.
* Return null to disable linking for that document.
* Return a string to customize the link destination.
* If not provided, uses the default admin edit URL.
*/
formatDocURL?: (args: {
collectionSlug: string
/**
* The default URL that would normally be used for this document link.
* You can return this as-is, modify it, or completely replace it.
*/
defaultURL: string
doc: Record<string, unknown>
req: PayloadRequest
/**
* The current view context where the link is being generated.
* Most relevant values for document linking are 'list' and 'trash'.
*/
viewType?: ViewTypes
}) => null | string
/**
* Specify a navigational group for collections in the admin sidebar.
* - Provide a string to place the entity in a custom group.

View File

@@ -22,6 +22,7 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
field,
field: { admin },
link,
linkURL,
onClick: onClickFromProps,
rowData,
viewType,
@@ -62,6 +63,11 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
if (link) {
wrapElementProps.prefetch = false
WrapElement = Link
// Use custom linkURL if provided, otherwise use default URL generation
if (linkURL) {
wrapElementProps.href = linkURL
} else {
wrapElementProps.href = collectionConfig?.slug
? formatAdminURL({
adminRoute,
@@ -69,6 +75,7 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
})
: ''
}
}
if (typeof onClick === 'function') {
WrapElement = 'button'

View File

@@ -10,6 +10,7 @@ import type {
Field,
PaginatedDocs,
Payload,
PayloadRequest,
SanitizedCollectionConfig,
ServerComponentProps,
StaticLabel,
@@ -46,6 +47,7 @@ export type BuildColumnStateArgs = {
enableRowTypes?: boolean
i18n: I18nClient
payload: Payload
req?: PayloadRequest
serverFields: Field[]
sortColumnProps?: Partial<SortColumnProps>
useAsTitle: SanitizedCollectionConfig['admin']['useAsTitle']
@@ -79,6 +81,7 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
enableRowSelections,
i18n,
payload,
req,
serverFields,
sortColumnProps,
useAsTitle,
@@ -249,6 +252,7 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
i18n,
isLinkedColumn: enableLinkedCell && colIndex === activeColumnsIndices[0],
payload,
req,
rowIndex,
serverField,
viewType,

View File

@@ -6,10 +6,12 @@ import type {
Document,
Field,
Payload,
PayloadRequest,
ViewTypes,
} from 'payload'
import { MissingEditorProp } from 'payload'
import { formatAdminURL } from 'payload/shared'
import { RenderCustomComponent } from '../../../elements/RenderCustomComponent/index.js'
import { RenderServerComponent } from '../../../elements/RenderServerComponent/index.js'
@@ -31,6 +33,7 @@ type RenderCellArgs = {
readonly i18n: I18nClient
readonly isLinkedColumn: boolean
readonly payload: Payload
readonly req?: PayloadRequest
readonly rowIndex: number
readonly serverField: Field
readonly viewType?: ViewTypes
@@ -45,6 +48,7 @@ export function renderCell({
i18n,
isLinkedColumn,
payload,
req,
rowIndex,
serverField,
viewType,
@@ -62,10 +66,49 @@ export function renderCell({
('accessor' in clientField ? (clientField.accessor as string) : undefined) ??
('name' in clientField ? clientField.name : undefined)
// Check if there's a custom formatDocURL function for this linked column
let shouldLink = isLinkedColumn
let customLinkURL: string | undefined
if (isLinkedColumn && req) {
const collectionConfig = payload.collections[collectionSlug]?.config
const formatDocURL = collectionConfig?.admin?.formatDocURL
if (typeof formatDocURL === 'function') {
// Generate the default URL that would normally be used
const adminRoute = req.payload.config.routes?.admin || '/admin'
const defaultURL = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}${viewType === 'trash' ? '/trash' : ''}/${encodeURIComponent(String(doc.id))}`,
})
const customURL = formatDocURL({
collectionSlug,
defaultURL,
doc,
req,
viewType,
})
if (customURL === null) {
// formatDocURL returned null = disable linking entirely
shouldLink = false
} else if (typeof customURL === 'string') {
// formatDocURL returned a string = use custom URL
shouldLink = true
customLinkURL = customURL
} else {
// formatDocURL returned unexpected type = disable linking for safety
shouldLink = false
}
}
}
const cellClientProps: DefaultCellComponentProps = {
...baseCellClientProps,
cellData: 'name' in clientField ? findValueFromPath(doc, accessor) : undefined,
link: isLinkedColumn,
link: shouldLink,
linkURL: customLinkURL,
rowData: doc,
}
@@ -78,7 +121,8 @@ export function renderCell({
customCellProps: baseCellClientProps.customCellProps,
field: serverField,
i18n,
link: cellClientProps.link,
link: shouldLink,
linkURL: customLinkURL,
onClick: baseCellClientProps.onClick,
payload,
rowData: doc,

View File

@@ -10,6 +10,7 @@ import type {
ListQuery,
PaginatedDocs,
Payload,
PayloadRequest,
SanitizedCollectionConfig,
ViewTypes,
} from 'payload'
@@ -80,6 +81,7 @@ export const renderTable = ({
payload,
query,
renderRowTypes,
req,
tableAppearance,
useAsTitle,
viewType,
@@ -102,6 +104,7 @@ export const renderTable = ({
payload: Payload
query?: ListQuery
renderRowTypes?: boolean
req?: PayloadRequest
tableAppearance?: 'condensed' | 'default'
useAsTitle: CollectionConfig['admin']['useAsTitle']
viewType?: ViewTypes
@@ -159,6 +162,7 @@ export const renderTable = ({
| 'enableRowSelections'
| 'i18n'
| 'payload'
| 'req'
| 'serverFields'
| 'useAsTitle'
| 'viewType'
@@ -170,6 +174,7 @@ export const renderTable = ({
// sortColumnProps,
customCellProps,
payload,
req,
serverFields,
useAsTitle,
viewType,

View File

@@ -0,0 +1,56 @@
import type { CollectionConfig } from 'payload'
export const FormatDocURL: CollectionConfig = {
slug: 'format-doc-url',
admin: {
// Custom formatDocURL function to control linking behavior
formatDocURL: ({ doc, defaultURL, req, collectionSlug, viewType }) => {
// Disable linking for documents with title 'no-link'
if (doc.title === 'no-link') {
return null
}
// Custom link for documents with title 'custom-link'
if (doc.title === 'custom-link') {
return '/custom-destination'
}
// Example: Add query params based on user email (fallback for normal cases)
if (
req.user?.email === 'dev@payloadcms.com' &&
viewType !== 'trash' &&
doc._status === 'draft'
) {
return defaultURL + '?admin=true'
}
// Example: Different behavior in trash view (check this before user-specific logic)
if (viewType === 'trash') {
return defaultURL + '?from=trash'
}
// Example: Collection-specific behavior for published docs
if (collectionSlug === 'format-doc-url' && doc._status === 'published') {
return defaultURL + '?published=true'
}
// For all other documents, just return the default URL
return defaultURL
},
},
trash: true,
versions: {
drafts: true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
},
],
}

View File

@@ -12,6 +12,7 @@ import { DisableBulkEdit } from './collections/DisableBulkEdit.js'
import { DisableCopyToLocale } from './collections/DisableCopyToLocale.js'
import { DisableDuplicate } from './collections/DisableDuplicate.js'
import { EditMenuItems } from './collections/editMenuItems.js'
import { FormatDocURL } from './collections/FormatDocURL/index.js'
import { Geo } from './collections/Geo.js'
import { CollectionGroup1A } from './collections/Group1A.js'
import { CollectionGroup1B } from './collections/Group1B.js'
@@ -183,6 +184,7 @@ export default buildConfigWithDefaults({
DisableDuplicate,
DisableCopyToLocale,
EditMenuItems,
FormatDocURL,
BaseListFilter,
with300Documents,
ListDrawer,

View File

@@ -19,6 +19,7 @@ import { customAdminRoutes } from '../../shared.js'
import {
arrayCollectionSlug,
customViews1CollectionSlug,
formatDocURLCollectionSlug,
geoCollectionSlug,
listDrawerSlug,
placeholderCollectionSlug,
@@ -75,6 +76,7 @@ describe('List View', () => {
let withListViewUrl: AdminUrlUtil
let placeholderUrl: AdminUrlUtil
let disableBulkEditUrl: AdminUrlUtil
let formatDocURLUrl: AdminUrlUtil
let user: any
let virtualsUrl: AdminUrlUtil
let noTimestampsUrl: AdminUrlUtil
@@ -102,6 +104,7 @@ describe('List View', () => {
withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug)
placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug)
disableBulkEditUrl = new AdminUrlUtil(serverURL, 'disable-bulk-edit')
formatDocURLUrl = new AdminUrlUtil(serverURL, formatDocURLCollectionSlug)
virtualsUrl = new AdminUrlUtil(serverURL, virtualsSlug)
noTimestampsUrl = new AdminUrlUtil(serverURL, noTimestampsSlug)
const context = await browser.newContext()
@@ -1912,6 +1915,148 @@ describe('List View', () => {
await expect(drawer.locator('.table > table > tbody > tr')).toHaveCount(2)
})
describe('formatDocURL', () => {
beforeEach(async () => {
// Clean up any existing formatDocURL documents
await payload.delete({
collection: formatDocURLCollectionSlug,
where: { id: { exists: true } },
})
})
test('should disable linking for documents with title "no-link"', async () => {
// Create test documents
await payload.create({
collection: formatDocURLCollectionSlug,
data: { title: 'no-link', description: 'This should not be linkable' },
})
const normalDoc = await payload.create({
collection: formatDocURLCollectionSlug,
data: { title: 'normal', description: 'This should be linkable normally' },
})
await page.goto(formatDocURLUrl.list)
await expect(page.locator(tableRowLocator)).toHaveCount(2)
// Find the row with "no-link" title - it should NOT have a link
const noLinkRow = page.locator(tableRowLocator).filter({ hasText: 'no-link' })
const noLinkTitleCell = noLinkRow.locator('td').nth(1)
await expect(noLinkTitleCell.locator('a')).toHaveCount(0)
// Find the row with "normal" title - it should have a link with admin=true query param
// (because we're logged in as dev@payloadcms.com)
const normalRow = page.locator(tableRowLocator).filter({ hasText: 'normal' })
const normalTitleCell = normalRow.locator('td').nth(1)
const normalLink = normalTitleCell.locator('a')
await expect(normalLink).toHaveCount(1)
await expect(normalLink).toHaveAttribute(
'href',
`${adminRoutes.routes?.admin}/collections/${formatDocURLCollectionSlug}/${normalDoc.id}?admin=true`,
)
})
test('should use custom destination for documents with title "custom-link"', async () => {
await payload.create({
collection: formatDocURLCollectionSlug,
data: { title: 'custom-link', description: 'This should link to custom destination' },
})
await page.goto(formatDocURLUrl.list)
await expect(page.locator(tableRowLocator)).toHaveCount(1)
// Find the row with "custom-link" title - it should link to custom destination
const customLinkRow = page.locator(tableRowLocator).filter({ hasText: 'custom-link' })
const customLinkTitleCell = customLinkRow.locator('td').nth(1)
const customLink = customLinkTitleCell.locator('a')
await expect(customLink).toHaveCount(1)
await expect(customLink).toHaveAttribute('href', '/custom-destination')
})
test('should add admin query param for dev@payloadcms.com user', async () => {
// This test verifies the user-based URL modification
const adminDoc = await payload.create({
collection: formatDocURLCollectionSlug,
data: { title: 'admin-test', description: 'This should have admin query param' },
})
await page.goto(formatDocURLUrl.list)
await expect(page.locator(tableRowLocator)).toHaveCount(1)
// Since we're logged in as dev@payloadcms.com, links should have ?admin=true
const adminRow = page.locator(tableRowLocator).filter({ hasText: 'admin-test' })
const adminTitleCell = adminRow.locator('td').nth(1)
const adminLink = adminTitleCell.locator('a')
await expect(adminLink).toHaveCount(1)
await expect(adminLink).toHaveAttribute(
'href',
new RegExp(
`${adminRoutes.routes?.admin}/collections/${formatDocURLCollectionSlug}/${adminDoc.id}\\?admin=true`,
),
)
})
test('should use different URL for trash view', async () => {
// Create a document and then move it to trash
const trashDoc = await payload.create({
collection: formatDocURLCollectionSlug,
data: { title: 'trash-test', description: 'This should show trash URL' },
})
// Move the document to trash by setting deletedAt (not delete)
await payload.update({
collection: formatDocURLCollectionSlug,
id: trashDoc.id,
data: {
deletedAt: new Date().toISOString(),
},
})
// Go to trash view
await page.goto(`${formatDocURLUrl.list}/trash`)
await expect(page.locator(tableRowLocator)).toHaveCount(1)
// In trash view, the formatDocURL should add ?from=trash
const trashRow = page.locator(tableRowLocator).filter({ hasText: 'trash-test' })
const trashTitleCell = trashRow.locator('td').nth(1)
const trashLink = trashTitleCell.locator('a')
await expect(trashLink).toHaveCount(1)
await expect(trashLink).toHaveAttribute(
'href',
new RegExp(
`${adminRoutes.routes?.admin}/collections/${formatDocURLCollectionSlug}/trash/${trashDoc.id}\\?from=trash`,
),
)
})
test('should add published query param for published documents', async () => {
// Create a published document
const publishedDoc = await payload.create({
collection: formatDocURLCollectionSlug,
data: {
title: 'published-test',
description: 'This is a published document',
_status: 'published',
},
})
await page.goto(formatDocURLUrl.list)
await expect(page.locator(tableRowLocator)).toHaveCount(1)
// Published documents should have ?published=true added
const publishedRow = page.locator(tableRowLocator).filter({ hasText: 'published-test' })
const publishedTitleCell = publishedRow.locator('td').nth(1)
const publishedLink = publishedTitleCell.locator('a')
await expect(publishedLink).toHaveCount(1)
await expect(publishedLink).toHaveAttribute(
'href',
new RegExp(
`${adminRoutes.routes?.admin}/collections/${formatDocURLCollectionSlug}/${publishedDoc.id}\\?published=true`,
),
)
})
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {

View File

@@ -87,6 +87,7 @@ export interface Config {
'disable-duplicate': DisableDuplicate;
'disable-copy-to-locale': DisableCopyToLocale;
'edit-menu-items': EditMenuItem;
'format-doc-url': FormatDocUrl;
'base-list-filters': BaseListFilter;
with300documents: With300Document;
'with-list-drawer': WithListDrawer;
@@ -123,6 +124,7 @@ export interface Config {
'disable-duplicate': DisableDuplicateSelect<false> | DisableDuplicateSelect<true>;
'disable-copy-to-locale': DisableCopyToLocaleSelect<false> | DisableCopyToLocaleSelect<true>;
'edit-menu-items': EditMenuItemsSelect<false> | EditMenuItemsSelect<true>;
'format-doc-url': FormatDocUrlSelect<false> | FormatDocUrlSelect<true>;
'base-list-filters': BaseListFiltersSelect<false> | BaseListFiltersSelect<true>;
with300documents: With300DocumentsSelect<false> | With300DocumentsSelect<true>;
'with-list-drawer': WithListDrawerSelect<false> | WithListDrawerSelect<true>;
@@ -514,6 +516,19 @@ export interface EditMenuItem {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "format-doc-url".
*/
export interface FormatDocUrl {
id: string;
title: string;
description?: string | null;
updatedAt: string;
createdAt: string;
deletedAt?: string | null;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "base-list-filters".
@@ -709,6 +724,10 @@ export interface PayloadLockedDocument {
relationTo: 'edit-menu-items';
value: string | EditMenuItem;
} | null)
| ({
relationTo: 'format-doc-url';
value: string | FormatDocUrl;
} | null)
| ({
relationTo: 'base-list-filters';
value: string | BaseListFilter;
@@ -1096,6 +1115,18 @@ export interface EditMenuItemsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "format-doc-url_select".
*/
export interface FormatDocUrlSelect<T extends boolean = true> {
title?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
deletedAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "base-list-filters_select".

View File

@@ -24,6 +24,7 @@ export const customFieldsSlug = 'custom-fields'
export const listDrawerSlug = 'with-list-drawer'
export const virtualsSlug = 'virtuals'
export const formatDocURLCollectionSlug = 'format-doc-url'
export const collectionSlugs = [
usersCollectionSlug,
customViews1CollectionSlug,
@@ -41,6 +42,7 @@ export const collectionSlugs = [
disableDuplicateSlug,
listDrawerSlug,
virtualsSlug,
formatDocURLCollectionSlug,
]
export const customGlobalViews1GlobalSlug = 'custom-global-views-one'