Skip to content

Commit 835a0ad

Browse files
paulpopusCopilotrobinscholz
authored
feat(next): add support for custom collection views (#16243)
Originally this PR with extra changes: #15410 - Adds ability to register custom views at the collection level via `admin.components.views[key]` with a `Component` and `path` — resolves #15386 - Folders take routing precedence over custom views when both are defined on an upload collection - Adds a startup `console.warn` when a custom view is misconfigured without a `path` property ## Usage ```ts { slug: 'products', admin: { components: { views: { grid: { Component: '/components/GridView', path: '/grid', exact: true, }, }, }, }, } --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com> Co-authored-by: Robin Scholz <robin@maas.engineering> Co-authored-by: Robin Vey <mail@robinscholz.com>
1 parent 4b4d61c commit 835a0ad

20 files changed

Lines changed: 846 additions & 26 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import type { AdminViewConfig, SanitizedCollectionConfig } from 'payload'
2+
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { getCustomCollectionViewByRoute } from './getCustomCollectionViewByRoute.js'
6+
7+
type Views = SanitizedCollectionConfig['admin']['components']['views']
8+
9+
const gridView: Views = {
10+
grid: {
11+
Component: '/components/views/GridView/index.js#GridView',
12+
exact: true,
13+
path: '/grid',
14+
},
15+
}
16+
17+
const gridViewPrefixMatch: Views = {
18+
grid: {
19+
Component: '/components/views/GridView/index.js#GridView',
20+
exact: false,
21+
path: '/grid',
22+
},
23+
}
24+
25+
describe('getCustomCollectionViewByRoute', () => {
26+
describe('route matching with default /admin prefix', () => {
27+
it('should match a custom view at the correct path', () => {
28+
const result = getCustomCollectionViewByRoute({
29+
adminRoute: '/admin',
30+
baseRoute: '/collections/my-collection',
31+
currentRoute: '/admin/collections/my-collection/grid',
32+
views: gridView,
33+
})
34+
35+
expect(result.viewKey).toBe('grid')
36+
expect(result.view.payloadComponent).toBeDefined()
37+
})
38+
39+
it('should not match when the path segment does not correspond to any custom view', () => {
40+
const result = getCustomCollectionViewByRoute({
41+
adminRoute: '/admin',
42+
baseRoute: '/collections/my-collection',
43+
currentRoute: '/admin/collections/my-collection/abc123',
44+
views: gridView,
45+
})
46+
47+
expect(result.viewKey).toBeNull()
48+
expect(result.view.payloadComponent).toBeUndefined()
49+
})
50+
})
51+
52+
describe('route matching with custom adminRoute prefix', () => {
53+
it('should match when adminRoute is a non-default prefix', () => {
54+
const result = getCustomCollectionViewByRoute({
55+
adminRoute: '/cms',
56+
baseRoute: '/collections/my-collection',
57+
currentRoute: '/cms/collections/my-collection/grid',
58+
views: gridView,
59+
})
60+
61+
expect(result.viewKey).toBe('grid')
62+
expect(result.view.payloadComponent).toBeDefined()
63+
})
64+
65+
it('should match when adminRoute is /', () => {
66+
const result = getCustomCollectionViewByRoute({
67+
adminRoute: '/',
68+
baseRoute: '/collections/my-collection',
69+
currentRoute: '/collections/my-collection/grid',
70+
views: gridView,
71+
})
72+
73+
expect(result.viewKey).toBe('grid')
74+
expect(result.view.payloadComponent).toBeDefined()
75+
})
76+
})
77+
78+
describe('route matching with exact: false (prefix matching)', () => {
79+
it('should match a sub-path when exact is false', () => {
80+
const result = getCustomCollectionViewByRoute({
81+
adminRoute: '/admin',
82+
baseRoute: '/collections/my-collection',
83+
currentRoute: '/admin/collections/my-collection/grid/detail',
84+
views: gridViewPrefixMatch,
85+
})
86+
87+
expect(result.viewKey).toBe('grid')
88+
expect(result.view.payloadComponent).toBeDefined()
89+
})
90+
91+
it('should match the exact path when exact is false', () => {
92+
const result = getCustomCollectionViewByRoute({
93+
adminRoute: '/admin',
94+
baseRoute: '/collections/my-collection',
95+
currentRoute: '/admin/collections/my-collection/grid',
96+
views: gridViewPrefixMatch,
97+
})
98+
99+
expect(result.viewKey).toBe('grid')
100+
expect(result.view.payloadComponent).toBeDefined()
101+
})
102+
103+
it('should not match an unrelated path when exact is false', () => {
104+
const result = getCustomCollectionViewByRoute({
105+
adminRoute: '/admin',
106+
baseRoute: '/collections/my-collection',
107+
currentRoute: '/admin/collections/my-collection/map',
108+
views: gridViewPrefixMatch,
109+
})
110+
111+
expect(result.viewKey).toBeNull()
112+
expect(result.view.payloadComponent).toBeUndefined()
113+
})
114+
})
115+
116+
describe('edge cases', () => {
117+
it('should return no match when views is undefined', () => {
118+
const result = getCustomCollectionViewByRoute({
119+
adminRoute: '/admin',
120+
baseRoute: '/collections/my-collection',
121+
currentRoute: '/admin/collections/my-collection/grid',
122+
views: undefined,
123+
})
124+
125+
expect(result.viewKey).toBeNull()
126+
expect(result.view.payloadComponent).toBeUndefined()
127+
})
128+
129+
it('should not match built-in "edit" or "list" keys', () => {
130+
const viewsWithBuiltins: Views = {
131+
edit: {
132+
default: { Component: '/components/views/Edit/index.js#EditView' },
133+
},
134+
list: {
135+
Component: '/components/views/List/index.js#ListView',
136+
},
137+
}
138+
139+
const result = getCustomCollectionViewByRoute({
140+
adminRoute: '/admin',
141+
baseRoute: '/collections/my-collection',
142+
currentRoute: '/admin/collections/my-collection/edit',
143+
views: viewsWithBuiltins,
144+
})
145+
146+
expect(result.viewKey).toBeNull()
147+
})
148+
149+
it('should not match a custom view that has no path defined', () => {
150+
const viewsWithNoPath: Views = {
151+
grid: {
152+
Component: '/components/views/GridView/index.js#GridView',
153+
} as unknown as AdminViewConfig,
154+
}
155+
156+
const result = getCustomCollectionViewByRoute({
157+
adminRoute: '/admin',
158+
baseRoute: '/collections/my-collection',
159+
currentRoute: '/admin/collections/my-collection/grid',
160+
views: viewsWithNoPath,
161+
})
162+
163+
expect(result.viewKey).toBeNull()
164+
})
165+
166+
it('should match the correct view when multiple custom views are defined', () => {
167+
const multipleViews: Views = {
168+
grid: {
169+
Component: '/components/views/GridView/index.js#GridView',
170+
exact: true,
171+
path: '/grid',
172+
},
173+
map: {
174+
Component: '/components/views/MapView/index.js#MapView',
175+
exact: true,
176+
path: '/map',
177+
},
178+
}
179+
180+
const gridResult = getCustomCollectionViewByRoute({
181+
adminRoute: '/admin',
182+
baseRoute: '/collections/my-collection',
183+
currentRoute: '/admin/collections/my-collection/grid',
184+
views: multipleViews,
185+
})
186+
187+
expect(gridResult.viewKey).toBe('grid')
188+
189+
const mapResult = getCustomCollectionViewByRoute({
190+
adminRoute: '/admin',
191+
baseRoute: '/collections/my-collection',
192+
currentRoute: '/admin/collections/my-collection/map',
193+
views: multipleViews,
194+
})
195+
196+
expect(mapResult.viewKey).toBe('map')
197+
})
198+
})
199+
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type {
2+
AdminViewConfig,
3+
AdminViewServerProps,
4+
PayloadComponent,
5+
SanitizedCollectionConfig,
6+
} from 'payload'
7+
8+
import type { ViewFromConfig } from './getRouteData.js'
9+
10+
import { isPathMatchingRoute } from './isPathMatchingRoute.js'
11+
12+
export const getCustomCollectionViewByRoute = ({
13+
adminRoute,
14+
baseRoute,
15+
currentRoute: currentRouteWithAdmin,
16+
views,
17+
}: {
18+
adminRoute: string
19+
baseRoute: string
20+
currentRoute: string
21+
views: SanitizedCollectionConfig['admin']['components']['views']
22+
}): {
23+
view: ViewFromConfig
24+
viewKey: null | string
25+
} => {
26+
const currentRoute =
27+
adminRoute === '/'
28+
? currentRouteWithAdmin
29+
: currentRouteWithAdmin.startsWith(adminRoute)
30+
? currentRouteWithAdmin.slice(adminRoute.length)
31+
: currentRouteWithAdmin
32+
33+
if (views && typeof views === 'object') {
34+
const foundEntry = Object.entries(views).find(([key, view]) => {
35+
// Skip the known collection view types: edit and list
36+
if (key === 'edit' || key === 'list') {
37+
return false
38+
}
39+
40+
// Type guard: custom views should be AdminViewConfig with path and Component
41+
const isAdminViewConfig =
42+
typeof view === 'object' &&
43+
view !== null &&
44+
'path' in view &&
45+
'Component' in view &&
46+
typeof view.path === 'string'
47+
48+
if (isAdminViewConfig) {
49+
const adminView = view as AdminViewConfig
50+
const viewPath = `${baseRoute}${adminView.path}`
51+
52+
return isPathMatchingRoute({
53+
currentRoute,
54+
exact: adminView.exact,
55+
path: viewPath,
56+
sensitive: adminView.sensitive,
57+
strict: adminView.strict,
58+
})
59+
}
60+
61+
return false
62+
})
63+
64+
if (foundEntry) {
65+
const [viewKey, foundViewConfig] = foundEntry
66+
const adminView = foundViewConfig as AdminViewConfig
67+
return {
68+
view: {
69+
payloadComponent: adminView.Component as PayloadComponent<AdminViewServerProps>,
70+
},
71+
viewKey,
72+
}
73+
}
74+
}
75+
76+
return {
77+
view: {
78+
Component: null,
79+
},
80+
viewKey: null,
81+
}
82+
}

packages/next/src/views/Root/getRouteData.ts

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { ResetPassword, resetPasswordBaseClass } from '../ResetPassword/index.js
3131
import { UnauthorizedView } from '../Unauthorized/index.js'
3232
import { Verify, verifyBaseClass } from '../Verify/index.js'
3333
import { getSubViewActions, getViewActions } from './attachViewActions.js'
34+
import { getCustomCollectionViewByRoute } from './getCustomCollectionViewByRoute.js'
3435
import { getCustomViewByKey } from './getCustomViewByKey.js'
3536
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
3637
import { getDocumentViewInfo } from './getDocumentViewInfo.js'
@@ -370,32 +371,50 @@ export const getRouteData = ({
370371

371372
viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || []))
372373
} else {
373-
// Collection Edit Views
374-
// --> /collections/:collectionSlug/create
375-
// --> /collections/:collectionSlug/:id
376-
// --> /collections/:collectionSlug/:id/api
377-
// --> /collections/:collectionSlug/:id/versions
378-
// --> /collections/:collectionSlug/:id/versions/:versionID
379-
routeParams.id = segmentThree === 'create' ? undefined : segmentThree
380-
routeParams.versionID = segmentFive
381-
382-
ViewToRender = {
383-
Component: DocumentView,
374+
// Check for custom collection views before assuming it's an edit view
375+
const baseRoute = `/${segmentOne}/${segmentTwo}`
376+
const customCollectionView = getCustomCollectionViewByRoute({
377+
adminRoute,
378+
baseRoute,
379+
currentRoute,
380+
views: collectionConfig.admin.components?.views,
381+
})
382+
383+
if (customCollectionView.viewKey && customCollectionView.view.payloadComponent) {
384+
// --> /collections/:collectionSlug/:customViewPath
385+
ViewToRender = customCollectionView.view
386+
387+
templateClassName = `collection-${customCollectionView.viewKey}`
388+
templateType = 'default'
389+
viewType = customCollectionView.viewKey
390+
} else {
391+
// Collection Edit Views
392+
// --> /collections/:collectionSlug/create
393+
// --> /collections/:collectionSlug/:id
394+
// --> /collections/:collectionSlug/:id/api
395+
// --> /collections/:collectionSlug/:id/versions
396+
// --> /collections/:collectionSlug/:id/versions/:versionID
397+
routeParams.id = segmentThree === 'create' ? undefined : segmentThree
398+
routeParams.versionID = segmentFive
399+
400+
ViewToRender = {
401+
Component: DocumentView,
402+
}
403+
404+
templateClassName = `collection-default-edit`
405+
templateType = 'default'
406+
407+
const viewInfo = getDocumentViewInfo([segmentFour, segmentFive])
408+
viewType = viewInfo.viewType
409+
documentSubViewType = viewInfo.documentSubViewType
410+
411+
viewActions.push(
412+
...getSubViewActions({
413+
collectionOrGlobal: collectionConfig,
414+
viewKeyArg: documentSubViewType,
415+
}),
416+
)
384417
}
385-
386-
templateClassName = `collection-default-edit`
387-
templateType = 'default'
388-
389-
const viewInfo = getDocumentViewInfo([segmentFour, segmentFive])
390-
viewType = viewInfo.viewType
391-
documentSubViewType = viewInfo.documentSubViewType
392-
393-
viewActions.push(
394-
...getSubViewActions({
395-
collectionOrGlobal: collectionConfig,
396-
viewKeyArg: documentSubViewType,
397-
}),
398-
)
399418
}
400419
}
401420
} else if (globalConfig) {

0 commit comments

Comments
 (0)