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:
56
test/admin/collections/FormatDocURL/index.ts
Normal file
56
test/admin/collections/FormatDocURL/index.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user