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

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