Skip to content

Commit 1d1240f

Browse files
authored
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
1 parent 512a8fa commit 1d1240f

File tree

14 files changed

+402
-10
lines changed

14 files changed

+402
-10
lines changed

docs/configuration/collections.mdx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ The following options are available:
136136
| `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. |
137137
| `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. |
138138
| `folders` | A boolean to enable folders for a given collection. Defaults to `false`. [More details](../folders/overview). |
139+
| `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). |
139140
| `meta` | Page metadata overrides to apply to this Collection within the Admin Panel. [More details](../admin/metadata). |
140141
| `preview` | Function to generate preview URLs within the Admin Panel that can point to your app. [More details](../admin/preview). |
141142
| `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 = {
333334
}
334335
```
335336

337+
### Format Document URLs
338+
339+
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.
340+
341+
To define a custom document URL formatter, use the `admin.formatDocURL` property in your Collection Config:
342+
343+
```ts
344+
import type { CollectionConfig } from 'payload'
345+
346+
export const Posts: CollectionConfig = {
347+
// ...
348+
admin: {
349+
formatDocURL: ({ doc, defaultURL, req, collectionSlug, viewType }) => {
350+
// Disable linking for documents with specific status
351+
if (doc.status === 'private') {
352+
return null
353+
}
354+
355+
// Custom destination for featured posts
356+
if (doc.featured) {
357+
return '/admin/featured-posts'
358+
}
359+
360+
// Add query parameters based on user role
361+
if (req.user?.role === 'admin') {
362+
return defaultURL + '?admin=true'
363+
}
364+
365+
// Use default URL for all other cases
366+
return defaultURL
367+
},
368+
},
369+
}
370+
```
371+
372+
The `formatDocURL` function receives the following arguments:
373+
374+
| Argument | Description |
375+
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
376+
| `doc` | The document data for the current row |
377+
| `defaultURL` | The default URL that Payload would normally generate for this document. You can return this as-is, modify it, or replace it entirely. |
378+
| `req` | The full [PayloadRequest](../types/payload-request) object, providing access to user context, payload instance, and other request data |
379+
| `collectionSlug` | The slug of the current collection |
380+
| `viewType` | The current view context (`'list'`, `'trash'`, etc.) where the link is being generated |
381+
382+
The function should return:
383+
384+
- `null` to disable the link entirely (no link will be rendered)
385+
- A `string` containing the custom URL to use for the link
386+
- The `defaultURL` parameter to use Payload's default linking behavior
387+
388+
<Banner type="success">
389+
**Tip:** The `defaultURL` parameter saves you from having to reconstruct URLs
390+
manually. You can modify it by appending query parameters or use it as a
391+
fallback for your custom logic.
392+
</Banner>
393+
394+
#### Examples
395+
396+
**Disable linking for certain users:**
397+
398+
```ts
399+
formatDocURL: ({ defaultURL, req }) => {
400+
if (req.user?.role === 'editor') {
401+
return null // No link rendered
402+
}
403+
return defaultURL
404+
}
405+
```
406+
336407
## GraphQL
337408

338409
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.

packages/next/src/views/List/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ export const renderListView = async (
280280
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
281281
payload: req.payload,
282282
query,
283+
req,
283284
useAsTitle: collectionConfig.admin.useAsTitle,
284285
viewType,
285286
}))

packages/payload/src/admin/elements/Cell.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export type DefaultCellComponentProps<
7777
customCellProps?: Record<string, any>
7878
field: TField
7979
link?: boolean
80+
linkURL?: string
8081
onClick?: (args: {
8182
cellData: unknown
8283
collectionSlug: SanitizedCollectionConfig['slug']

packages/payload/src/collections/config/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export type ServerOnlyCollectionProperties = keyof Pick<
2929

3030
export type ServerOnlyCollectionAdminProperties = keyof Pick<
3131
SanitizedCollectionConfig['admin'],
32-
'baseFilter' | 'baseListFilter' | 'components' | 'hidden'
32+
'baseFilter' | 'baseListFilter' | 'components' | 'formatDocURL' | 'hidden'
3333
>
3434

3535
export type ServerOnlyUploadProperties = keyof Pick<
@@ -50,6 +50,7 @@ export type ClientCollectionConfig = {
5050
SanitizedCollectionConfig['admin'],
5151
| 'components'
5252
| 'description'
53+
| 'formatDocURL'
5354
| 'joins'
5455
| 'livePreview'
5556
| 'preview'
@@ -97,6 +98,7 @@ const serverOnlyCollectionAdminProperties: Partial<ServerOnlyCollectionAdminProp
9798
'baseFilter',
9899
'baseListFilter',
99100
'components',
101+
'formatDocURL',
100102
// 'preview' is handled separately
101103
// `livePreview` is handled separately
102104
]

packages/payload/src/collections/config/types.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql'
33
import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials'
44

5-
import type { CustomUpload } from '../../admin/types.js'
5+
import type { CustomUpload, ViewTypes } from '../../admin/types.js'
66
import type { Arguments as MeArguments } from '../../auth/operations/me.js'
77
import type {
88
Arguments as RefreshArguments,
@@ -396,6 +396,27 @@ export type CollectionAdminOptions = {
396396
enableListViewSelectAPI?: boolean
397397
enableRichTextLink?: boolean
398398
enableRichTextRelationship?: boolean
399+
/**
400+
* Function to format the URL for document links in the list view.
401+
* Return null to disable linking for that document.
402+
* Return a string to customize the link destination.
403+
* If not provided, uses the default admin edit URL.
404+
*/
405+
formatDocURL?: (args: {
406+
collectionSlug: string
407+
/**
408+
* The default URL that would normally be used for this document link.
409+
* You can return this as-is, modify it, or completely replace it.
410+
*/
411+
defaultURL: string
412+
doc: Record<string, unknown>
413+
req: PayloadRequest
414+
/**
415+
* The current view context where the link is being generated.
416+
* Most relevant values for document linking are 'list' and 'trash'.
417+
*/
418+
viewType?: ViewTypes
419+
}) => null | string
399420
/**
400421
* Specify a navigational group for collections in the admin sidebar.
401422
* - Provide a string to place the entity in a custom group.

packages/ui/src/elements/Table/DefaultCell/index.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
2222
field,
2323
field: { admin },
2424
link,
25+
linkURL,
2526
onClick: onClickFromProps,
2627
rowData,
2728
viewType,
@@ -62,12 +63,18 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
6263
if (link) {
6364
wrapElementProps.prefetch = false
6465
WrapElement = Link
65-
wrapElementProps.href = collectionConfig?.slug
66-
? formatAdminURL({
67-
adminRoute,
68-
path: `/collections/${collectionConfig?.slug}${viewType === 'trash' ? '/trash' : ''}/${encodeURIComponent(rowData.id)}`,
69-
})
70-
: ''
66+
67+
// Use custom linkURL if provided, otherwise use default URL generation
68+
if (linkURL) {
69+
wrapElementProps.href = linkURL
70+
} else {
71+
wrapElementProps.href = collectionConfig?.slug
72+
? formatAdminURL({
73+
adminRoute,
74+
path: `/collections/${collectionConfig?.slug}${viewType === 'trash' ? '/trash' : ''}/${encodeURIComponent(rowData.id)}`,
75+
})
76+
: ''
77+
}
7178
}
7279

7380
if (typeof onClick === 'function') {

packages/ui/src/providers/TableColumns/buildColumnState/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
Field,
1111
PaginatedDocs,
1212
Payload,
13+
PayloadRequest,
1314
SanitizedCollectionConfig,
1415
ServerComponentProps,
1516
StaticLabel,
@@ -46,6 +47,7 @@ export type BuildColumnStateArgs = {
4647
enableRowTypes?: boolean
4748
i18n: I18nClient
4849
payload: Payload
50+
req?: PayloadRequest
4951
serverFields: Field[]
5052
sortColumnProps?: Partial<SortColumnProps>
5153
useAsTitle: SanitizedCollectionConfig['admin']['useAsTitle']
@@ -79,6 +81,7 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
7981
enableRowSelections,
8082
i18n,
8183
payload,
84+
req,
8285
serverFields,
8386
sortColumnProps,
8487
useAsTitle,
@@ -249,6 +252,7 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
249252
i18n,
250253
isLinkedColumn: enableLinkedCell && colIndex === activeColumnsIndices[0],
251254
payload,
255+
req,
252256
rowIndex,
253257
serverField,
254258
viewType,

packages/ui/src/providers/TableColumns/buildColumnState/renderCell.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import type {
66
Document,
77
Field,
88
Payload,
9+
PayloadRequest,
910
ViewTypes,
1011
} from 'payload'
1112

1213
import { MissingEditorProp } from 'payload'
14+
import { formatAdminURL } from 'payload/shared'
1315

1416
import { RenderCustomComponent } from '../../../elements/RenderCustomComponent/index.js'
1517
import { RenderServerComponent } from '../../../elements/RenderServerComponent/index.js'
@@ -31,6 +33,7 @@ type RenderCellArgs = {
3133
readonly i18n: I18nClient
3234
readonly isLinkedColumn: boolean
3335
readonly payload: Payload
36+
readonly req?: PayloadRequest
3437
readonly rowIndex: number
3538
readonly serverField: Field
3639
readonly viewType?: ViewTypes
@@ -45,6 +48,7 @@ export function renderCell({
4548
i18n,
4649
isLinkedColumn,
4750
payload,
51+
req,
4852
rowIndex,
4953
serverField,
5054
viewType,
@@ -62,10 +66,49 @@ export function renderCell({
6266
('accessor' in clientField ? (clientField.accessor as string) : undefined) ??
6367
('name' in clientField ? clientField.name : undefined)
6468

69+
// Check if there's a custom formatDocURL function for this linked column
70+
let shouldLink = isLinkedColumn
71+
let customLinkURL: string | undefined
72+
73+
if (isLinkedColumn && req) {
74+
const collectionConfig = payload.collections[collectionSlug]?.config
75+
const formatDocURL = collectionConfig?.admin?.formatDocURL
76+
77+
if (typeof formatDocURL === 'function') {
78+
// Generate the default URL that would normally be used
79+
const adminRoute = req.payload.config.routes?.admin || '/admin'
80+
const defaultURL = formatAdminURL({
81+
adminRoute,
82+
path: `/collections/${collectionSlug}${viewType === 'trash' ? '/trash' : ''}/${encodeURIComponent(String(doc.id))}`,
83+
})
84+
85+
const customURL = formatDocURL({
86+
collectionSlug,
87+
defaultURL,
88+
doc,
89+
req,
90+
viewType,
91+
})
92+
93+
if (customURL === null) {
94+
// formatDocURL returned null = disable linking entirely
95+
shouldLink = false
96+
} else if (typeof customURL === 'string') {
97+
// formatDocURL returned a string = use custom URL
98+
shouldLink = true
99+
customLinkURL = customURL
100+
} else {
101+
// formatDocURL returned unexpected type = disable linking for safety
102+
shouldLink = false
103+
}
104+
}
105+
}
106+
65107
const cellClientProps: DefaultCellComponentProps = {
66108
...baseCellClientProps,
67109
cellData: 'name' in clientField ? findValueFromPath(doc, accessor) : undefined,
68-
link: isLinkedColumn,
110+
link: shouldLink,
111+
linkURL: customLinkURL,
69112
rowData: doc,
70113
}
71114

@@ -78,7 +121,8 @@ export function renderCell({
78121
customCellProps: baseCellClientProps.customCellProps,
79122
field: serverField,
80123
i18n,
81-
link: cellClientProps.link,
124+
link: shouldLink,
125+
linkURL: customLinkURL,
82126
onClick: baseCellClientProps.onClick,
83127
payload,
84128
rowData: doc,

packages/ui/src/utilities/renderTable.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
ListQuery,
1111
PaginatedDocs,
1212
Payload,
13+
PayloadRequest,
1314
SanitizedCollectionConfig,
1415
ViewTypes,
1516
} from 'payload'
@@ -80,6 +81,7 @@ export const renderTable = ({
8081
payload,
8182
query,
8283
renderRowTypes,
84+
req,
8385
tableAppearance,
8486
useAsTitle,
8587
viewType,
@@ -102,6 +104,7 @@ export const renderTable = ({
102104
payload: Payload
103105
query?: ListQuery
104106
renderRowTypes?: boolean
107+
req?: PayloadRequest
105108
tableAppearance?: 'condensed' | 'default'
106109
useAsTitle: CollectionConfig['admin']['useAsTitle']
107110
viewType?: ViewTypes
@@ -159,6 +162,7 @@ export const renderTable = ({
159162
| 'enableRowSelections'
160163
| 'i18n'
161164
| 'payload'
165+
| 'req'
162166
| 'serverFields'
163167
| 'useAsTitle'
164168
| 'viewType'
@@ -170,6 +174,7 @@ export const renderTable = ({
170174
// sortColumnProps,
171175
customCellProps,
172176
payload,
177+
req,
173178
serverFields,
174179
useAsTitle,
175180
viewType,

0 commit comments

Comments
 (0)