From 0cb73ca92238abf11b9a1b2a7411692bdf9e7486 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:47:51 +0000 Subject: [PATCH 01/18] Initial plan From b7d93d20c25c6e2d1018478d10890a165a62a5a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:56:17 +0000 Subject: [PATCH 02/18] feat(next): add support for custom collection views - Update CollectionAdminOptions type to allow custom views at collection level - Create getCustomCollectionViewByRoute helper function - Modify getRouteData.ts to check for custom collection views before defaulting to edit views - Add test collection with custom grid view - Add test to verify custom collection view routing Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com> --- .../Root/getCustomCollectionViewByRoute.ts | 65 +++++++++++++++++++ packages/next/src/views/Root/getRouteData.ts | 22 ++++++- .../payload/src/collections/config/types.ts | 3 + .../admin/collections/CustomCollectionView.ts | 24 +++++++ .../views/CustomCollectionView/index.js | 28 ++++++++ test/admin/config.ts | 2 + test/admin/e2e/general/e2e.spec.ts | 15 +++++ test/admin/shared.ts | 2 + test/admin/slugs.ts | 2 + 9 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/next/src/views/Root/getCustomCollectionViewByRoute.ts create mode 100644 test/admin/collections/CustomCollectionView.ts create mode 100644 test/admin/components/views/CustomCollectionView/index.js diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts new file mode 100644 index 00000000000..7a5132c4c7a --- /dev/null +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts @@ -0,0 +1,65 @@ +import type { SanitizedCollectionConfig } from 'payload' + +import type { ViewFromConfig } from './getRouteData.js' + +import { isPathMatchingRoute } from './isPathMatchingRoute.js' + +export const getCustomCollectionViewByRoute = ({ + baseRoute, + currentRoute, + views, +}: { + baseRoute: string + currentRoute: string + views: SanitizedCollectionConfig['admin']['components']['views'] +}): { + view: ViewFromConfig + viewKey?: string +} => { + if (typeof views === 'object') { + let viewKey: string + + const foundViewConfig = Object.entries(views).find(([key, view]) => { + // Skip the known collection view types: edit and list + if (key === 'edit' || key === 'list') { + return false + } + + if (typeof view === 'object' && 'path' in view) { + const viewPath = `${baseRoute}${view.path}` + + const isMatching = isPathMatchingRoute({ + currentRoute, + exact: view.exact, + path: viewPath, + sensitive: view.sensitive, + strict: view.strict, + }) + + if (isMatching) { + viewKey = key + } + + return isMatching + } + + return false + })?.[1] + + if (foundViewConfig && 'Component' in foundViewConfig) { + return { + view: { + payloadComponent: foundViewConfig.Component, + }, + viewKey, + } + } + } + + return { + view: { + Component: null, + }, + viewKey: null, + } +} diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts index 3a04aeca73f..30c93e6ffc4 100644 --- a/packages/next/src/views/Root/getRouteData.ts +++ b/packages/next/src/views/Root/getRouteData.ts @@ -31,6 +31,7 @@ import { ResetPassword, resetPasswordBaseClass } from '../ResetPassword/index.js import { UnauthorizedView } from '../Unauthorized/index.js' import { Verify, verifyBaseClass } from '../Verify/index.js' import { getSubViewActions, getViewActions } from './attachViewActions.js' +import { getCustomCollectionViewByRoute } from './getCustomCollectionViewByRoute.js' import { getCustomViewByKey } from './getCustomViewByKey.js' import { getCustomViewByRoute } from './getCustomViewByRoute.js' import { getDocumentViewInfo } from './getDocumentViewInfo.js' @@ -353,7 +354,26 @@ export const getRouteData = ({ viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || [])) } else { - if (config.folders && segmentThree === config.folders.slug && collectionConfig.folders) { + // Check for custom collection views before assuming it's an edit view + const baseRoute = `/${segmentOne}/${segmentTwo}` + const customCollectionView = getCustomCollectionViewByRoute({ + baseRoute, + currentRoute, + views: collectionConfig.admin.components?.views, + }) + + if (customCollectionView.viewKey && customCollectionView.view.payloadComponent) { + // --> /collections/:collectionSlug/:customViewPath + ViewToRender = customCollectionView.view + + templateClassName = `collection-${customCollectionView.viewKey}` + templateType = 'default' + viewType = customCollectionView.viewKey as ViewTypes + } else if ( + config.folders && + segmentThree === config.folders.slug && + collectionConfig.folders + ) { // Collection Folder Views // --> /collections/:collectionSlug/:folderCollectionSlug // --> /collections/:collectionSlug/:folderCollectionSlug/:folderID diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index b5eeeb79af1..3fa93c5c74b 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -2,6 +2,7 @@ import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql' import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials' +import type { AdminViewConfig } from '../../admin/views/index.js' import type { CustomStatus, CustomUpload, ViewTypes } from '../../admin/types.js' import type { Arguments as MeArguments } from '../../auth/operations/me.js' import type { @@ -382,6 +383,8 @@ export type CollectionAdminOptions = { } listMenuItems?: CustomComponent[] views?: { + /** Add custom collection views */ + [key: string]: AdminViewConfig /** * Replace, modify, or add new "document" views. * @link https://payloadcms.com/docs/custom-components/document-views diff --git a/test/admin/collections/CustomCollectionView.ts b/test/admin/collections/CustomCollectionView.ts new file mode 100644 index 00000000000..233a9ef39aa --- /dev/null +++ b/test/admin/collections/CustomCollectionView.ts @@ -0,0 +1,24 @@ +import type { CollectionConfig } from 'payload' + +import { customCollectionViewSlug } from '../slugs.js' + +export const CustomCollectionView: CollectionConfig = { + slug: customCollectionViewSlug, + admin: { + components: { + views: { + grid: { + Component: '/components/views/CustomCollectionView/index.js#CustomCollectionView', + path: '/grid', + exact: true, + }, + }, + }, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], +} diff --git a/test/admin/components/views/CustomCollectionView/index.js b/test/admin/components/views/CustomCollectionView/index.js new file mode 100644 index 00000000000..b6827e41260 --- /dev/null +++ b/test/admin/components/views/CustomCollectionView/index.js @@ -0,0 +1,28 @@ +import type { AdminViewServerProps } from 'payload' + +import React from 'react' + +import { customCollectionViewTitle } from '../../../shared.js' + +export function CustomCollectionView({ initPageResult }: AdminViewServerProps) { + return ( +
+

{customCollectionViewTitle}

+

This is a custom collection-level view (e.g., grid view) added via:

+ +
+ ) +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 1e889fe4cdc..4d5da943dc6 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -4,6 +4,7 @@ import path from 'path' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { Array } from './collections/Array.js' import { BaseListFilter } from './collections/BaseListFilter.js' +import { CustomCollectionView } from './collections/CustomCollectionView.js' import { CollectionCustomDocumentControls } from './collections/CustomDocumentControls.js' import { CustomFields } from './collections/CustomFields/index.js' import { CustomListDrawer } from './collections/CustomListDrawer/index.js' @@ -183,6 +184,7 @@ export default buildConfigWithDefaults({ CollectionCustomDocumentControls, CustomViews1, CustomViews2, + CustomCollectionView, ReorderTabs, CustomFields, CollectionGroup1A, diff --git a/test/admin/e2e/general/e2e.spec.ts b/test/admin/e2e/general/e2e.spec.ts index 002e5c03edc..84b0f8da193 100644 --- a/test/admin/e2e/general/e2e.spec.ts +++ b/test/admin/e2e/general/e2e.spec.ts @@ -770,6 +770,21 @@ describe('General', () => { }) }) + describe('custom collection views', () => { + test('should render custom collection view at custom path', async () => { + await page.goto( + formatAdminURL({ + adminRoute, + path: '/collections/custom-collection-view/grid', + serverURL, + }), + ) + await expect(page.locator('h1#custom-collection-view-title')).toContainText( + 'Custom Collection View', + ) + }) + }) + describe('header actions', () => { test('should show admin level action in admin panel', async () => { await page.goto(postsUrl.admin) diff --git a/test/admin/shared.ts b/test/admin/shared.ts index 5a9d146f737..60f1f308b55 100644 --- a/test/admin/shared.ts +++ b/test/admin/shared.ts @@ -18,6 +18,8 @@ export const customParamViewPath = `${customParamViewPathBase}/:id` export const customViewTitle = 'Custom View' +export const customCollectionViewTitle = 'Custom Collection View' + export const customParamViewTitle = 'Custom Param View' export const customNestedViewTitle = 'Custom Nested View' diff --git a/test/admin/slugs.ts b/test/admin/slugs.ts index 19acda19793..12440c4da93 100644 --- a/test/admin/slugs.ts +++ b/test/admin/slugs.ts @@ -1,6 +1,7 @@ export const usersCollectionSlug = 'users' export const customViews1CollectionSlug = 'custom-views-one' export const customViews2CollectionSlug = 'custom-views-two' +export const customCollectionViewSlug = 'custom-collection-view' export const reorderTabsSlug = 'reorder-tabs' export const geoCollectionSlug = 'geo' export const arrayCollectionSlug = 'array' @@ -32,6 +33,7 @@ export const collectionSlugs = [ usersCollectionSlug, customViews1CollectionSlug, customViews2CollectionSlug, + customCollectionViewSlug, geoCollectionSlug, arrayCollectionSlug, postsCollectionSlug, From f089c8c8f1cf2b338afd50345ee4eae72d50eb36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:04:56 +0000 Subject: [PATCH 03/18] fix(next): fix TypeScript errors in custom collection view helper Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com> --- .../Root/getCustomCollectionViewByRoute.ts | 24 +++++++++++++------ .../payload/src/collections/config/types.ts | 4 +++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts index 7a5132c4c7a..9e241c484f8 100644 --- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts @@ -1,4 +1,4 @@ -import type { SanitizedCollectionConfig } from 'payload' +import type { AdminViewConfig, AdminViewServerProps, PayloadComponent, SanitizedCollectionConfig } from 'payload' import type { ViewFromConfig } from './getRouteData.js' @@ -25,15 +25,24 @@ export const getCustomCollectionViewByRoute = ({ return false } - if (typeof view === 'object' && 'path' in view) { - const viewPath = `${baseRoute}${view.path}` + // Type guard: custom views should be AdminViewConfig with path and Component + const isAdminViewConfig = + typeof view === 'object' && + view !== null && + 'path' in view && + 'Component' in view && + typeof view.path === 'string' + + if (isAdminViewConfig) { + const adminView = view as AdminViewConfig + const viewPath = `${baseRoute}${adminView.path}` const isMatching = isPathMatchingRoute({ currentRoute, - exact: view.exact, + exact: adminView.exact, path: viewPath, - sensitive: view.sensitive, - strict: view.strict, + sensitive: adminView.sensitive, + strict: adminView.strict, }) if (isMatching) { @@ -47,9 +56,10 @@ export const getCustomCollectionViewByRoute = ({ })?.[1] if (foundViewConfig && 'Component' in foundViewConfig) { + const adminView = foundViewConfig as AdminViewConfig return { view: { - payloadComponent: foundViewConfig.Component, + payloadComponent: adminView.Component as PayloadComponent, }, viewKey, } diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 3fa93c5c74b..b087b416897 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -2,8 +2,8 @@ import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql' import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials' -import type { AdminViewConfig } from '../../admin/views/index.js' import type { CustomStatus, CustomUpload, ViewTypes } from '../../admin/types.js' +import type { AdminViewConfig } from '../../admin/views/index.js' import type { Arguments as MeArguments } from '../../auth/operations/me.js' import type { Arguments as RefreshArguments, @@ -389,11 +389,13 @@ export type CollectionAdminOptions = { * Replace, modify, or add new "document" views. * @link https://payloadcms.com/docs/custom-components/document-views */ + // @ts-expect-error - edit allows more complex nested structure than AdminViewConfig edit?: EditConfig /** * Replace or modify the "list" view. * @link https://payloadcms.com/docs/custom-components/list-view */ + // @ts-expect-error - list allows actions array which doesn't match AdminViewConfig list?: { actions?: CustomComponent[] Component?: PayloadComponent From 693be751811cf651e0685180a70d5f3b7a0a56f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:05:39 +0000 Subject: [PATCH 04/18] docs: update progress Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com> --- tsconfig.base.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.base.json b/tsconfig.base.json index 2df7499ba51..2e13e9eb8c0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,7 +32,7 @@ ], "paths": { "@payloadcms/figma": ["../enterprise-plugins/packages/figma/src/index.ts"], - "@payload-config": ["./test/plugin-import-export/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From c7fbb3a84d31f83731abab436ac0d86b5ccbfb5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:16:29 +0000 Subject: [PATCH 05/18] fix(next): address code review feedback - Add null check for views object - Change viewKey return value from null to undefined for consistency - Address code cleanliness issues Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com> --- .../Root/getCustomCollectionViewByRoute.ts | 11 +++++--- test/admin/payload-types.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts index 9e241c484f8..8733adcc016 100644 --- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts @@ -1,4 +1,9 @@ -import type { AdminViewConfig, AdminViewServerProps, PayloadComponent, SanitizedCollectionConfig } from 'payload' +import type { + AdminViewConfig, + AdminViewServerProps, + PayloadComponent, + SanitizedCollectionConfig, +} from 'payload' import type { ViewFromConfig } from './getRouteData.js' @@ -16,7 +21,7 @@ export const getCustomCollectionViewByRoute = ({ view: ViewFromConfig viewKey?: string } => { - if (typeof views === 'object') { + if (views && typeof views === 'object') { let viewKey: string const foundViewConfig = Object.entries(views).find(([key, view]) => { @@ -70,6 +75,6 @@ export const getCustomCollectionViewByRoute = ({ view: { Component: null, }, - viewKey: null, + viewKey: undefined, } } diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index e0953f269f3..bba3861dc5e 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -77,6 +77,7 @@ export interface Config { 'custom-document-controls': CustomDocumentControl; 'custom-views-one': CustomViewsOne; 'custom-views-two': CustomViewsTwo; + 'custom-collection-view': CustomCollectionView; 'reorder-tabs': ReorderTab; 'custom-fields': CustomField; 'group-one-collection-ones': GroupOneCollectionOne; @@ -117,6 +118,7 @@ export interface Config { 'custom-document-controls': CustomDocumentControlsSelect | CustomDocumentControlsSelect; 'custom-views-one': CustomViewsOneSelect | CustomViewsOneSelect; 'custom-views-two': CustomViewsTwoSelect | CustomViewsTwoSelect; + 'custom-collection-view': CustomCollectionViewSelect | CustomCollectionViewSelect; 'reorder-tabs': ReorderTabsSelect | ReorderTabsSelect; 'custom-fields': CustomFieldsSelect | CustomFieldsSelect; 'group-one-collection-ones': GroupOneCollectionOnesSelect | GroupOneCollectionOnesSelect; @@ -386,6 +388,16 @@ export interface CustomViewsTwo { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "custom-collection-view". + */ +export interface CustomCollectionView { + id: string; + title?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "reorder-tabs". @@ -733,6 +745,10 @@ export interface PayloadLockedDocument { relationTo: 'custom-views-two'; value: string | CustomViewsTwo; } | null) + | ({ + relationTo: 'custom-collection-view'; + value: string | CustomCollectionView; + } | null) | ({ relationTo: 'reorder-tabs'; value: string | ReorderTab; @@ -1045,6 +1061,15 @@ export interface CustomViewsTwoSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "custom-collection-view_select". + */ +export interface CustomCollectionViewSelect { + title?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "reorder-tabs_select". From 88cfdb7870e1dd9f293ec8b5276f2b794199a3e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:34:59 +0000 Subject: [PATCH 06/18] fix(next): ensure folders take precedence over custom collection views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reordered the routing logic so that folder views are checked before custom collection views. Order is now: trash → folders → custom views → edit views Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com> --- packages/next/src/views/Root/getRouteData.ts | 38 ++++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts index 30c93e6ffc4..2431ba4c57d 100644 --- a/packages/next/src/views/Root/getRouteData.ts +++ b/packages/next/src/views/Root/getRouteData.ts @@ -354,26 +354,7 @@ export const getRouteData = ({ viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || [])) } else { - // Check for custom collection views before assuming it's an edit view - const baseRoute = `/${segmentOne}/${segmentTwo}` - const customCollectionView = getCustomCollectionViewByRoute({ - baseRoute, - currentRoute, - views: collectionConfig.admin.components?.views, - }) - - if (customCollectionView.viewKey && customCollectionView.view.payloadComponent) { - // --> /collections/:collectionSlug/:customViewPath - ViewToRender = customCollectionView.view - - templateClassName = `collection-${customCollectionView.viewKey}` - templateType = 'default' - viewType = customCollectionView.viewKey as ViewTypes - } else if ( - config.folders && - segmentThree === config.folders.slug && - collectionConfig.folders - ) { + if (config.folders && segmentThree === config.folders.slug && collectionConfig.folders) { // Collection Folder Views // --> /collections/:collectionSlug/:folderCollectionSlug // --> /collections/:collectionSlug/:folderCollectionSlug/:folderID @@ -390,6 +371,22 @@ export const getRouteData = ({ viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || [])) } else { + // Check for custom collection views before assuming it's an edit view + const baseRoute = `/${segmentOne}/${segmentTwo}` + const customCollectionView = getCustomCollectionViewByRoute({ + baseRoute, + currentRoute, + views: collectionConfig.admin.components?.views, + }) + + if (customCollectionView.viewKey && customCollectionView.view.payloadComponent) { + // --> /collections/:collectionSlug/:customViewPath + ViewToRender = customCollectionView.view + + templateClassName = `collection-${customCollectionView.viewKey}` + templateType = 'default' + viewType = customCollectionView.viewKey as ViewTypes + } else { // Collection Edit Views // --> /collections/:collectionSlug/create // --> /collections/:collectionSlug/:id @@ -416,6 +413,7 @@ export const getRouteData = ({ viewKeyArg: documentSubViewType, }), ) + } } } } else if (globalConfig) { From 55bad886c76c6498488e1a03aa205bc913a37834 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:03:32 +0000 Subject: [PATCH 07/18] fix(payload): resolve TypeScript error with collection views type definition Changed the index signature to use a union type that includes undefined, allowing it to be compatible with optional properties like edit and list. This fixes the build error in @payloadcms/plugin-search and other packages. Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com> --- .../payload/src/collections/config/types.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index b087b416897..3b83090bbf7 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -383,19 +383,35 @@ export type CollectionAdminOptions = { } listMenuItems?: CustomComponent[] views?: { - /** Add custom collection views */ - [key: string]: AdminViewConfig + /** + * Add custom collection views. + * Any additional keys define custom collection views that are matched by path and rendered at the collection level. + * @link https://payloadcms.com/docs/custom-components/custom-views + * @example + * ```ts + * views: { + * grid: { + * Component: '/path/to/GridView', + * path: '/grid', + * exact: true, + * } + * } + * ``` + */ + [key: string]: + | { actions?: CustomComponent[]; Component?: PayloadComponent } + | AdminViewConfig + | EditConfig + | undefined /** * Replace, modify, or add new "document" views. * @link https://payloadcms.com/docs/custom-components/document-views */ - // @ts-expect-error - edit allows more complex nested structure than AdminViewConfig edit?: EditConfig /** * Replace or modify the "list" view. * @link https://payloadcms.com/docs/custom-components/list-view */ - // @ts-expect-error - list allows actions array which doesn't match AdminViewConfig list?: { actions?: CustomComponent[] Component?: PayloadComponent From c7a02ca73475dc4a17643d7737d2fec3a3e4a142 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 10 Apr 2026 18:40:25 +0100 Subject: [PATCH 08/18] update test coverage and fix some issues in config --- .../getCustomCollectionViewByRoute.spec.ts | 153 ++++++++++++++++++ .../Root/getCustomCollectionViewByRoute.ts | 9 +- packages/next/src/views/Root/getRouteData.ts | 55 +++---- packages/payload/src/admin/views/index.ts | 2 +- .../payload/src/collections/config/types.ts | 1 - packages/payload/src/config/types.ts | 2 +- .../{index.js => index.tsx} | 0 7 files changed, 190 insertions(+), 32 deletions(-) create mode 100644 packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts rename test/admin/components/views/CustomCollectionView/{index.js => index.tsx} (100%) diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts new file mode 100644 index 00000000000..a8d71e2eee4 --- /dev/null +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts @@ -0,0 +1,153 @@ +import type { SanitizedCollectionConfig } from 'payload' + +import { describe, expect, it } from 'vitest' + +import { getCustomCollectionViewByRoute } from './getCustomCollectionViewByRoute.js' + +type Views = SanitizedCollectionConfig['admin']['components']['views'] + +const gridView: Views = { + grid: { + Component: '/components/views/GridView/index.js#GridView', + exact: true, + path: '/grid', + }, +} + +describe('getCustomCollectionViewByRoute', () => { + describe('route matching with default /admin prefix', () => { + it('should match a custom view at the correct path', () => { + const result = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/grid', + views: gridView, + }) + + expect(result.viewKey).toBe('grid') + expect(result.view.payloadComponent).toBeDefined() + }) + + it('should not match when the path segment does not correspond to any custom view', () => { + const result = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/abc123', + views: gridView, + }) + + expect(result.viewKey).toBeUndefined() + expect(result.view.payloadComponent).toBeUndefined() + }) + }) + + describe('route matching with custom adminRoute prefix', () => { + it('should match when adminRoute is a non-default prefix', () => { + const result = getCustomCollectionViewByRoute({ + adminRoute: '/cms', + baseRoute: '/collections/my-collection', + currentRoute: '/cms/collections/my-collection/grid', + views: gridView, + }) + + expect(result.viewKey).toBe('grid') + expect(result.view.payloadComponent).toBeDefined() + }) + + it('should match when adminRoute is /', () => { + const result = getCustomCollectionViewByRoute({ + adminRoute: '/', + baseRoute: '/collections/my-collection', + currentRoute: '/collections/my-collection/grid', + views: gridView, + }) + + expect(result.viewKey).toBe('grid') + expect(result.view.payloadComponent).toBeDefined() + }) + }) + + describe('edge cases', () => { + it('should return no match when views is undefined', () => { + const result = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/grid', + views: undefined, + }) + + expect(result.viewKey).toBeUndefined() + expect(result.view.payloadComponent).toBeUndefined() + }) + + it('should not match built-in "edit" or "list" keys', () => { + const viewsWithBuiltins: Views = { + edit: { + default: { Component: '/components/views/Edit/index.js#EditView' }, + }, + list: { + Component: '/components/views/List/index.js#ListView', + }, + } + + const result = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/edit', + views: viewsWithBuiltins, + }) + + expect(result.viewKey).toBeUndefined() + }) + + it('should not match a custom view that has no path defined', () => { + const viewsWithNoPath: Views = { + grid: { + Component: '/components/views/GridView/index.js#GridView', + } as any, + } + + const result = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/grid', + views: viewsWithNoPath, + }) + + expect(result.viewKey).toBeUndefined() + }) + + it('should match the correct view when multiple custom views are defined', () => { + const multipleViews: Views = { + grid: { + Component: '/components/views/GridView/index.js#GridView', + exact: true, + path: '/grid', + }, + map: { + Component: '/components/views/MapView/index.js#MapView', + exact: true, + path: '/map', + }, + } + + const gridResult = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/grid', + views: multipleViews, + }) + + expect(gridResult.viewKey).toBe('grid') + + const mapResult = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/map', + views: multipleViews, + }) + + expect(mapResult.viewKey).toBe('map') + }) + }) +}) diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts index 8733adcc016..52a0d4898e3 100644 --- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts @@ -10,10 +10,12 @@ import type { ViewFromConfig } from './getRouteData.js' import { isPathMatchingRoute } from './isPathMatchingRoute.js' export const getCustomCollectionViewByRoute = ({ + adminRoute, baseRoute, - currentRoute, + currentRoute: currentRouteWithAdmin, views, }: { + adminRoute: string baseRoute: string currentRoute: string views: SanitizedCollectionConfig['admin']['components']['views'] @@ -21,6 +23,9 @@ export const getCustomCollectionViewByRoute = ({ view: ViewFromConfig viewKey?: string } => { + const currentRoute = + adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '') + if (views && typeof views === 'object') { let viewKey: string @@ -60,7 +65,7 @@ export const getCustomCollectionViewByRoute = ({ return false })?.[1] - if (foundViewConfig && 'Component' in foundViewConfig) { + if (foundViewConfig) { const adminView = foundViewConfig as AdminViewConfig return { view: { diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts index 2431ba4c57d..1160a20270a 100644 --- a/packages/next/src/views/Root/getRouteData.ts +++ b/packages/next/src/views/Root/getRouteData.ts @@ -84,7 +84,7 @@ type GetRouteDataResult = { templateClassName: string templateType: 'default' | 'minimal' viewActions?: CustomComponent[] - viewType?: ViewTypes + viewType?: string | ViewTypes } type GetRouteDataArgs = { @@ -121,7 +121,7 @@ export const getRouteData = ({ let templateClassName: string let templateType: 'default' | 'minimal' | undefined let documentSubViewType: DocumentSubViewTypes - let viewType: ViewTypes + let viewType: string | ViewTypes const routeParams: GetRouteDataResult['routeParams'] = {} const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments @@ -374,6 +374,7 @@ export const getRouteData = ({ // Check for custom collection views before assuming it's an edit view const baseRoute = `/${segmentOne}/${segmentTwo}` const customCollectionView = getCustomCollectionViewByRoute({ + adminRoute, baseRoute, currentRoute, views: collectionConfig.admin.components?.views, @@ -385,34 +386,34 @@ export const getRouteData = ({ templateClassName = `collection-${customCollectionView.viewKey}` templateType = 'default' - viewType = customCollectionView.viewKey as ViewTypes + viewType = customCollectionView.viewKey } else { - // Collection Edit Views - // --> /collections/:collectionSlug/create - // --> /collections/:collectionSlug/:id - // --> /collections/:collectionSlug/:id/api - // --> /collections/:collectionSlug/:id/versions - // --> /collections/:collectionSlug/:id/versions/:versionID - routeParams.id = segmentThree === 'create' ? undefined : segmentThree - routeParams.versionID = segmentFive - - ViewToRender = { - Component: DocumentView, - } - - templateClassName = `collection-default-edit` - templateType = 'default' + // Collection Edit Views + // --> /collections/:collectionSlug/create + // --> /collections/:collectionSlug/:id + // --> /collections/:collectionSlug/:id/api + // --> /collections/:collectionSlug/:id/versions + // --> /collections/:collectionSlug/:id/versions/:versionID + routeParams.id = segmentThree === 'create' ? undefined : segmentThree + routeParams.versionID = segmentFive + + ViewToRender = { + Component: DocumentView, + } + + templateClassName = `collection-default-edit` + templateType = 'default' - const viewInfo = getDocumentViewInfo([segmentFour, segmentFive]) - viewType = viewInfo.viewType - documentSubViewType = viewInfo.documentSubViewType + const viewInfo = getDocumentViewInfo([segmentFour, segmentFive]) + viewType = viewInfo.viewType + documentSubViewType = viewInfo.documentSubViewType - viewActions.push( - ...getSubViewActions({ - collectionOrGlobal: collectionConfig, - viewKeyArg: documentSubViewType, - }), - ) + viewActions.push( + ...getSubViewActions({ + collectionOrGlobal: collectionConfig, + viewKeyArg: documentSubViewType, + }), + ) } } } diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts index 028abc29e0e..96e91080b28 100644 --- a/packages/payload/src/admin/views/index.ts +++ b/packages/payload/src/admin/views/index.ts @@ -35,7 +35,7 @@ export type AdminViewClientProps = { browseByFolderSlugs?: SanitizedCollectionConfig['slug'][] clientConfig: ClientConfig documentSubViewType?: DocumentSubViewTypes - viewType: ViewTypes + viewType: string | ViewTypes } export type AdminViewServerPropsOnly = { diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index dee1a217b8e..1e54b5a50ab 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -461,7 +461,6 @@ export type CollectionAdminOptions = { [key: string]: | { actions?: CustomComponent[]; Component?: PayloadComponent } | AdminViewConfig - | EditConfig | undefined /** * Replace, modify, or add new "document" views. diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index b31bf29fdb8..f3852ba0523 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -425,7 +425,7 @@ export type ServerProps = { readonly permissions?: SanitizedPermissions readonly searchParams?: Params readonly user?: TypedUser - readonly viewType?: ViewTypes + readonly viewType?: string | ViewTypes readonly visibleEntities?: VisibleEntities } diff --git a/test/admin/components/views/CustomCollectionView/index.js b/test/admin/components/views/CustomCollectionView/index.tsx similarity index 100% rename from test/admin/components/views/CustomCollectionView/index.js rename to test/admin/components/views/CustomCollectionView/index.tsx From 614fc11a4a989f0b5d88d41abede9f2d2931f32f Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 10 Apr 2026 19:37:22 +0100 Subject: [PATCH 09/18] added tests --- .../src/collections/config/sanitize.spec.ts | 109 ++++++++++++++++++ .../src/collections/config/sanitize.ts | 25 ++++ test/folders/collections/Media/index.ts | 11 ++ .../components/ConflictingView/index.tsx | 5 + test/folders/e2e.spec.ts | 18 +++ 5 files changed, 168 insertions(+) create mode 100644 packages/payload/src/collections/config/sanitize.spec.ts create mode 100644 test/folders/components/ConflictingView/index.tsx diff --git a/packages/payload/src/collections/config/sanitize.spec.ts b/packages/payload/src/collections/config/sanitize.spec.ts new file mode 100644 index 00000000000..2d2e199cf0e --- /dev/null +++ b/packages/payload/src/collections/config/sanitize.spec.ts @@ -0,0 +1,109 @@ +import type { CollectionConfig } from './types.js' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { warnOnInvalidCustomViews } from './sanitize.js' + +describe('warnOnInvalidCustomViews', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('should warn when a custom view is missing path', () => { + const collection: CollectionConfig = { + slug: 'my-collection', + fields: [], + admin: { + components: { + views: { + grid: { + Component: '/components/GridView/index.js#GridView', + } as any, + }, + }, + }, + } + + warnOnInvalidCustomViews(collection) + + expect(warnSpy).toHaveBeenCalledOnce() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"grid"')) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"my-collection"')) + }) + + it('should not warn when a custom view has a path', () => { + const collection: CollectionConfig = { + slug: 'my-collection', + fields: [], + admin: { + components: { + views: { + grid: { + Component: '/components/GridView/index.js#GridView', + path: '/grid', + }, + }, + }, + }, + } + + warnOnInvalidCustomViews(collection) + + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('should not warn for built-in "edit" or "list" keys even without path', () => { + const collection: CollectionConfig = { + slug: 'my-collection', + fields: [], + admin: { + components: { + views: { + edit: { default: { Component: '/components/Edit/index.js#Edit' } }, + list: { Component: '/components/List/index.js#List' }, + }, + }, + }, + } + + warnOnInvalidCustomViews(collection) + + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('should warn for each custom view missing path independently', () => { + const collection: CollectionConfig = { + slug: 'my-collection', + fields: [], + admin: { + components: { + views: { + grid: { Component: '/components/GridView/index.js#GridView' } as any, + map: { Component: '/components/MapView/index.js#MapView' } as any, + }, + }, + }, + } + + warnOnInvalidCustomViews(collection) + + expect(warnSpy).toHaveBeenCalledTimes(2) + }) + + it('should not warn when views is undefined', () => { + const collection: CollectionConfig = { + slug: 'my-collection', + fields: [], + } + + warnOnInvalidCustomViews(collection) + + expect(warnSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 77152325344..171a3e25c02 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -30,6 +30,29 @@ import { import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js' import { validateUseAsTitle } from './useAsTitle.js' +/** + * Warns at startup when custom collection views are misconfigured with a missing `path`. + * Views without `path` will never be matched by the router and are silently ignored. + */ +export const warnOnInvalidCustomViews = (collection: CollectionConfig): void => { + const views = collection.admin?.components?.views + if (!views || typeof views !== 'object') { + return + } + + for (const [key, view] of Object.entries(views)) { + if (key === 'edit' || key === 'list') { + continue + } + + if (view && typeof view === 'object' && 'Component' in view && !('path' in view)) { + console.warn( + `[Payload] Custom collection view "${key}" in collection "${collection.slug}" is missing a "path" property. The view will never be rendered.`, + ) + } + } +} + export const sanitizeCollection = async ( config: Config, collection: CollectionConfig, @@ -50,6 +73,8 @@ export const sanitizeCollection = async ( collection._sanitized = true + warnOnInvalidCustomViews(collection) + // ///////////////////////////////// // Make copy of collection config // ///////////////////////////////// diff --git a/test/folders/collections/Media/index.ts b/test/folders/collections/Media/index.ts index b6306375ef6..4fa187d35a4 100644 --- a/test/folders/collections/Media/index.ts +++ b/test/folders/collections/Media/index.ts @@ -11,6 +11,17 @@ export const Media: CollectionConfig = { }, }, folders: true, + admin: { + components: { + views: { + conflictingView: { + Component: '/components/ConflictingView/index.js#ConflictingView', + exact: true, + path: '/payload-folders', + }, + }, + }, + }, fields: [ { name: 'testAdminThumbnail', diff --git a/test/folders/components/ConflictingView/index.tsx b/test/folders/components/ConflictingView/index.tsx new file mode 100644 index 00000000000..ed607707dad --- /dev/null +++ b/test/folders/components/ConflictingView/index.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export function ConflictingView() { + return
Conflicting Custom View
+} diff --git a/test/folders/e2e.spec.ts b/test/folders/e2e.spec.ts index a3474468740..e63bda25f86 100644 --- a/test/folders/e2e.spec.ts +++ b/test/folders/e2e.spec.ts @@ -798,6 +798,24 @@ test.describe('Folders', () => { } }) +test.describe('custom view / folder routing precedence', () => { + test('should render folder list view, not conflicting custom view, at the folders slug route', async () => { + await page.goto( + formatAdminURL({ + adminRoute, + path: '/collections/media/payload-folders', + serverURL, + }), + ) + + // The folder list view renders a create-folder button (even with no folders seeded) + await expect(page.locator('.create-new-doc-in-folder__button').first()).toBeVisible() + + // The conflicting custom view must NOT be rendered + await expect(page.locator('[data-testid="conflicting-custom-view"]')).not.toBeVisible() + }) +}) + // tests to write // ------ NICE TO HAVE ------- // - check copy is correct in the confirm modal and toast notifications when moving docs / folders From 8b24e3897b61fd937db1c4e7a181f83b5fedf2c4 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 10 Apr 2026 19:37:50 +0100 Subject: [PATCH 10/18] fix linting on test assertion --- test/folders/e2e.spec.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/folders/e2e.spec.ts b/test/folders/e2e.spec.ts index e63bda25f86..9d52c0854ef 100644 --- a/test/folders/e2e.spec.ts +++ b/test/folders/e2e.spec.ts @@ -5,6 +5,14 @@ import * as path from 'path' import { formatAdminURL } from 'payload/shared' import { fileURLToPath } from 'url' +import { applyBrowseByFolderTypeFilter } from '../__helpers/e2e/folders/applyBrowseByFolderTypeFilter.js' +import { clickFolderCard } from '../__helpers/e2e/folders/clickFolderCard.js' +import { createFolder } from '../__helpers/e2e/folders/createFolder.js' +import { createFolderDoc } from '../__helpers/e2e/folders/createFolderDoc.js' +import { createFolderFromDoc } from '../__helpers/e2e/folders/createFolderFromDoc.js' +import { expectNoResultsAndCreateFolderButton } from '../__helpers/e2e/folders/expectNoResultsAndCreateFolderButton.js' +import { selectFolderAndConfirmMove } from '../__helpers/e2e/folders/selectFolderAndConfirmMove.js' +import { selectFolderAndConfirmMoveFromList } from '../__helpers/e2e/folders/selectFolderAndConfirmMoveFromList.js' import { closeAllToasts, ensureCompilationIsDone, @@ -12,22 +20,14 @@ import { initPageConsoleErrorCatch, saveDocAndAssert, } from '../__helpers/e2e/helpers.js' -import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' import { getSelectInputOptions, getSelectInputValue, openSelectMenu, } from '../__helpers/e2e/selectInput.js' -import { applyBrowseByFolderTypeFilter } from '../__helpers/e2e/folders/applyBrowseByFolderTypeFilter.js' -import { clickFolderCard } from '../__helpers/e2e/folders/clickFolderCard.js' -import { createFolder } from '../__helpers/e2e/folders/createFolder.js' -import { createFolderDoc } from '../__helpers/e2e/folders/createFolderDoc.js' -import { createFolderFromDoc } from '../__helpers/e2e/folders/createFolderFromDoc.js' -import { expectNoResultsAndCreateFolderButton } from '../__helpers/e2e/folders/expectNoResultsAndCreateFolderButton.js' -import { selectFolderAndConfirmMove } from '../__helpers/e2e/folders/selectFolderAndConfirmMove.js' -import { selectFolderAndConfirmMoveFromList } from '../__helpers/e2e/folders/selectFolderAndConfirmMoveFromList.js' -import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' +import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' import { reInitializeDB } from '../__helpers/shared/clearAndSeed/reInitializeDB.js' +import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' import { TEST_TIMEOUT_LONG } from '../playwright.config.js' import { omittedFromBrowseBySlug, postSlug } from './shared.js' @@ -812,7 +812,7 @@ test.describe('custom view / folder routing precedence', () => { await expect(page.locator('.create-new-doc-in-folder__button').first()).toBeVisible() // The conflicting custom view must NOT be rendered - await expect(page.locator('[data-testid="conflicting-custom-view"]')).not.toBeVisible() + await expect(page.locator('[data-testid="conflicting-custom-view"]')).toBeHidden() }) }) From 13274f3819ca8fb3e849782d3a19c7153fe27663 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Sat, 11 Apr 2026 21:31:05 +0100 Subject: [PATCH 11/18] fix builds --- packages/payload/src/admin/views/index.ts | 3 ++- packages/payload/src/collections/config/types.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts index 96e91080b28..be9a9d0560f 100644 --- a/packages/payload/src/admin/views/index.ts +++ b/packages/payload/src/admin/views/index.ts @@ -35,7 +35,7 @@ export type AdminViewClientProps = { browseByFolderSlugs?: SanitizedCollectionConfig['slug'][] clientConfig: ClientConfig documentSubViewType?: DocumentSubViewTypes - viewType: string | ViewTypes + viewType: ViewTypes } export type AdminViewServerPropsOnly = { @@ -100,6 +100,7 @@ export type ViewTypes = | 'trash' | 'verify' | 'version' + | ({} & string) export type ServerPropsFromView = { collectionConfig?: SanitizedConfig['collections'][number] diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 1e54b5a50ab..128ee802901 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -3,6 +3,7 @@ import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from ' import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials' import type { + AdminViewConfig, CustomStatus, CustomUpload, PublishButtonClientProps, @@ -461,6 +462,7 @@ export type CollectionAdminOptions = { [key: string]: | { actions?: CustomComponent[]; Component?: PayloadComponent } | AdminViewConfig + | EditConfig | undefined /** * Replace, modify, or add new "document" views. From 8ad157b637353769562fc92ffa46a190a3a11dd7 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Sun, 12 Apr 2026 21:34:59 +0100 Subject: [PATCH 12/18] updates --- .../Root/getCustomCollectionViewByRoute.ts | 17 +++++------------ .../bin/generateImportMap/iterateCollections.ts | 13 +++++++++++++ .../payload/src/collections/config/sanitize.ts | 13 +++++++++++++ packages/payload/src/config/types.ts | 2 +- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts index 52a0d4898e3..6bf44ce5c6d 100644 --- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts @@ -27,9 +27,7 @@ export const getCustomCollectionViewByRoute = ({ adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '') if (views && typeof views === 'object') { - let viewKey: string - - const foundViewConfig = Object.entries(views).find(([key, view]) => { + const foundEntry = Object.entries(views).find(([key, view]) => { // Skip the known collection view types: edit and list if (key === 'edit' || key === 'list') { return false @@ -47,25 +45,20 @@ export const getCustomCollectionViewByRoute = ({ const adminView = view as AdminViewConfig const viewPath = `${baseRoute}${adminView.path}` - const isMatching = isPathMatchingRoute({ + return isPathMatchingRoute({ currentRoute, exact: adminView.exact, path: viewPath, sensitive: adminView.sensitive, strict: adminView.strict, }) - - if (isMatching) { - viewKey = key - } - - return isMatching } return false - })?.[1] + }) - if (foundViewConfig) { + if (foundEntry) { + const [viewKey, foundViewConfig] = foundEntry const adminView = foundViewConfig as AdminViewConfig return { view: { diff --git a/packages/payload/src/bin/generateImportMap/iterateCollections.ts b/packages/payload/src/bin/generateImportMap/iterateCollections.ts index 28501f878ec..99749cd876c 100644 --- a/packages/payload/src/bin/generateImportMap/iterateCollections.ts +++ b/packages/payload/src/bin/generateImportMap/iterateCollections.ts @@ -1,3 +1,4 @@ +import type { AdminViewConfig } from '../../admin/views/index.js' import type { SanitizedCollectionConfig } from '../../collections/config/types.js' import type { SanitizedConfig } from '../../config/types.js' import type { AddToImportMap, Imports, InternalImportMap } from './index.js' @@ -69,5 +70,17 @@ export function iterateCollections({ addToImportMap(collection.admin?.components?.views?.list?.Component) addToImportMap(collection.admin?.components?.views?.list?.actions) + + // Register custom collection view components (any key other than 'edit' and 'list') + if (collection.admin?.components?.views) { + for (const [key, view] of Object.entries(collection.admin.components.views)) { + if (key === 'edit' || key === 'list') { + continue + } + if (view && typeof view === 'object' && 'Component' in view && 'path' in view) { + addToImportMap((view as AdminViewConfig).Component) + } + } + } } } diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 171a3e25c02..3ead521e80b 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -50,6 +50,19 @@ export const warnOnInvalidCustomViews = (collection: CollectionConfig): void => `[Payload] Custom collection view "${key}" in collection "${collection.slug}" is missing a "path" property. The view will never be rendered.`, ) } + + const reservedPaths = ['/create', '/trash'] + if ( + view && + typeof view === 'object' && + 'path' in view && + typeof view.path === 'string' && + reservedPaths.includes(view.path) + ) { + console.warn( + `[Payload] Custom collection view "${key}" in collection "${collection.slug}" uses the reserved path "${view.path}". This will shadow the built-in route and may break document creation or trash functionality.`, + ) + } } } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index f3852ba0523..b31bf29fdb8 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -425,7 +425,7 @@ export type ServerProps = { readonly permissions?: SanitizedPermissions readonly searchParams?: Params readonly user?: TypedUser - readonly viewType?: string | ViewTypes + readonly viewType?: ViewTypes readonly visibleEntities?: VisibleEntities } From c8c279d3ccabeb6b10eeb93a3b2b68d2423c5323 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 13 Apr 2026 00:46:37 +0100 Subject: [PATCH 13/18] remove reservedPaths --- .../getCustomCollectionViewByRoute.spec.ts | 12 +++++----- .../Root/getCustomCollectionViewByRoute.ts | 4 ++-- packages/payload/src/admin/views/index.ts | 3 +-- .../src/collections/config/sanitize.spec.ts | 23 +++++++++++++++++++ .../src/collections/config/sanitize.ts | 11 ++------- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts index a8d71e2eee4..9e029436b38 100644 --- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts @@ -1,4 +1,4 @@ -import type { SanitizedCollectionConfig } from 'payload' +import type { AdminViewConfig, SanitizedCollectionConfig } from 'payload' import { describe, expect, it } from 'vitest' @@ -36,7 +36,7 @@ describe('getCustomCollectionViewByRoute', () => { views: gridView, }) - expect(result.viewKey).toBeUndefined() + expect(result.viewKey).toBeNull() expect(result.view.payloadComponent).toBeUndefined() }) }) @@ -76,7 +76,7 @@ describe('getCustomCollectionViewByRoute', () => { views: undefined, }) - expect(result.viewKey).toBeUndefined() + expect(result.viewKey).toBeNull() expect(result.view.payloadComponent).toBeUndefined() }) @@ -97,14 +97,14 @@ describe('getCustomCollectionViewByRoute', () => { views: viewsWithBuiltins, }) - expect(result.viewKey).toBeUndefined() + expect(result.viewKey).toBeNull() }) it('should not match a custom view that has no path defined', () => { const viewsWithNoPath: Views = { grid: { Component: '/components/views/GridView/index.js#GridView', - } as any, + } as unknown as AdminViewConfig, } const result = getCustomCollectionViewByRoute({ @@ -114,7 +114,7 @@ describe('getCustomCollectionViewByRoute', () => { views: viewsWithNoPath, }) - expect(result.viewKey).toBeUndefined() + expect(result.viewKey).toBeNull() }) it('should match the correct view when multiple custom views are defined', () => { diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts index 6bf44ce5c6d..58effccef3b 100644 --- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts @@ -21,7 +21,7 @@ export const getCustomCollectionViewByRoute = ({ views: SanitizedCollectionConfig['admin']['components']['views'] }): { view: ViewFromConfig - viewKey?: string + viewKey: null | string } => { const currentRoute = adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '') @@ -73,6 +73,6 @@ export const getCustomCollectionViewByRoute = ({ view: { Component: null, }, - viewKey: undefined, + viewKey: null, } } diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts index be9a9d0560f..ed82f8e6103 100644 --- a/packages/payload/src/admin/views/index.ts +++ b/packages/payload/src/admin/views/index.ts @@ -35,7 +35,7 @@ export type AdminViewClientProps = { browseByFolderSlugs?: SanitizedCollectionConfig['slug'][] clientConfig: ClientConfig documentSubViewType?: DocumentSubViewTypes - viewType: ViewTypes + viewType: string } export type AdminViewServerPropsOnly = { @@ -100,7 +100,6 @@ export type ViewTypes = | 'trash' | 'verify' | 'version' - | ({} & string) export type ServerPropsFromView = { collectionConfig?: SanitizedConfig['collections'][number] diff --git a/packages/payload/src/collections/config/sanitize.spec.ts b/packages/payload/src/collections/config/sanitize.spec.ts index 2d2e199cf0e..20238f3c4e0 100644 --- a/packages/payload/src/collections/config/sanitize.spec.ts +++ b/packages/payload/src/collections/config/sanitize.spec.ts @@ -96,6 +96,29 @@ describe('warnOnInvalidCustomViews', () => { expect(warnSpy).toHaveBeenCalledTimes(2) }) + it('should warn when a custom view has a path but is missing Component', () => { + const collection: CollectionConfig = { + slug: 'my-collection', + fields: [], + admin: { + components: { + views: { + grid: { + path: '/grid', + } as any, + }, + }, + }, + } + + warnOnInvalidCustomViews(collection) + + expect(warnSpy).toHaveBeenCalledOnce() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"grid"')) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"my-collection"')) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"Component"')) + }) + it('should not warn when views is undefined', () => { const collection: CollectionConfig = { slug: 'my-collection', diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 3ead521e80b..4c980ec1f56 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -51,16 +51,9 @@ export const warnOnInvalidCustomViews = (collection: CollectionConfig): void => ) } - const reservedPaths = ['/create', '/trash'] - if ( - view && - typeof view === 'object' && - 'path' in view && - typeof view.path === 'string' && - reservedPaths.includes(view.path) - ) { + if (view && typeof view === 'object' && 'path' in view && !('Component' in view)) { console.warn( - `[Payload] Custom collection view "${key}" in collection "${collection.slug}" uses the reserved path "${view.path}". This will shadow the built-in route and may break document creation or trash functionality.`, + `[Payload] Custom collection view "${key}" in collection "${collection.slug}" has a "path" but is missing a "Component". The view will never be rendered.`, ) } } From 9af48ee75d499b3f36d6bba57218d49f1e2d86c9 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 13 Apr 2026 00:47:57 +0100 Subject: [PATCH 14/18] e2e --- test/folders/e2e.spec.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/test/folders/e2e.spec.ts b/test/folders/e2e.spec.ts index 9d52c0854ef..67542f1c15d 100644 --- a/test/folders/e2e.spec.ts +++ b/test/folders/e2e.spec.ts @@ -796,23 +796,23 @@ test.describe('Folders', () => { .filter({ hasText: 'Select' }) await selectButton.click() } -}) -test.describe('custom view / folder routing precedence', () => { - test('should render folder list view, not conflicting custom view, at the folders slug route', async () => { - await page.goto( - formatAdminURL({ - adminRoute, - path: '/collections/media/payload-folders', - serverURL, - }), - ) - - // The folder list view renders a create-folder button (even with no folders seeded) - await expect(page.locator('.create-new-doc-in-folder__button').first()).toBeVisible() - - // The conflicting custom view must NOT be rendered - await expect(page.locator('[data-testid="conflicting-custom-view"]')).toBeHidden() + test.describe('custom view / folder routing precedence', () => { + test('should render folder list view, not conflicting custom view, at the folders slug route', async () => { + await page.goto( + formatAdminURL({ + adminRoute, + path: '/collections/media/payload-folders', + serverURL, + }), + ) + + // The folder list view renders a create-folder button (even with no folders seeded) + await expect(page.locator('.create-new-doc-in-folder__button').first()).toBeVisible() + + // The conflicting custom view must NOT be rendered + await expect(page.locator('[data-testid="conflicting-custom-view"]')).toBeHidden() + }) }) }) From 75099cd4e2979590ae9c9744c433cf3b8a83c9f6 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 13 Apr 2026 01:59:24 +0100 Subject: [PATCH 15/18] re-add generic string union --- packages/payload/src/admin/views/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/admin/views/index.ts b/packages/payload/src/admin/views/index.ts index ed82f8e6103..be9a9d0560f 100644 --- a/packages/payload/src/admin/views/index.ts +++ b/packages/payload/src/admin/views/index.ts @@ -35,7 +35,7 @@ export type AdminViewClientProps = { browseByFolderSlugs?: SanitizedCollectionConfig['slug'][] clientConfig: ClientConfig documentSubViewType?: DocumentSubViewTypes - viewType: string + viewType: ViewTypes } export type AdminViewServerPropsOnly = { @@ -100,6 +100,7 @@ export type ViewTypes = | 'trash' | 'verify' | 'version' + | ({} & string) export type ServerPropsFromView = { collectionConfig?: SanitizedConfig['collections'][number] From 26cf6a66dff28fc92c64d079bec00c336dcdbed6 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 13 Apr 2026 16:40:49 +0100 Subject: [PATCH 16/18] address some comments --- .../getCustomCollectionViewByRoute.spec.ts | 46 +++++++++++++++++++ .../Root/getCustomCollectionViewByRoute.ts | 6 ++- .../src/views/Root/isPathMatchingRoute.ts | 2 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts index 9e029436b38..ff54067293f 100644 --- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.spec.ts @@ -14,6 +14,14 @@ const gridView: Views = { }, } +const gridViewPrefixMatch: Views = { + grid: { + Component: '/components/views/GridView/index.js#GridView', + exact: false, + path: '/grid', + }, +} + describe('getCustomCollectionViewByRoute', () => { describe('route matching with default /admin prefix', () => { it('should match a custom view at the correct path', () => { @@ -67,6 +75,44 @@ describe('getCustomCollectionViewByRoute', () => { }) }) + describe('route matching with exact: false (prefix matching)', () => { + it('should match a sub-path when exact is false', () => { + const result = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/grid/detail', + views: gridViewPrefixMatch, + }) + + expect(result.viewKey).toBe('grid') + expect(result.view.payloadComponent).toBeDefined() + }) + + it('should match the exact path when exact is false', () => { + const result = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/grid', + views: gridViewPrefixMatch, + }) + + expect(result.viewKey).toBe('grid') + expect(result.view.payloadComponent).toBeDefined() + }) + + it('should not match an unrelated path when exact is false', () => { + const result = getCustomCollectionViewByRoute({ + adminRoute: '/admin', + baseRoute: '/collections/my-collection', + currentRoute: '/admin/collections/my-collection/map', + views: gridViewPrefixMatch, + }) + + expect(result.viewKey).toBeNull() + expect(result.view.payloadComponent).toBeUndefined() + }) + }) + describe('edge cases', () => { it('should return no match when views is undefined', () => { const result = getCustomCollectionViewByRoute({ diff --git a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts index 58effccef3b..be8e9887cbb 100644 --- a/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts +++ b/packages/next/src/views/Root/getCustomCollectionViewByRoute.ts @@ -24,7 +24,11 @@ export const getCustomCollectionViewByRoute = ({ viewKey: null | string } => { const currentRoute = - adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '') + adminRoute === '/' + ? currentRouteWithAdmin + : currentRouteWithAdmin.startsWith(adminRoute) + ? currentRouteWithAdmin.slice(adminRoute.length) + : currentRouteWithAdmin if (views && typeof views === 'object') { const foundEntry = Object.entries(views).find(([key, view]) => { diff --git a/packages/next/src/views/Root/isPathMatchingRoute.ts b/packages/next/src/views/Root/isPathMatchingRoute.ts index 193fc0042a8..b8447518549 100644 --- a/packages/next/src/views/Root/isPathMatchingRoute.ts +++ b/packages/next/src/views/Root/isPathMatchingRoute.ts @@ -35,6 +35,6 @@ export const isPathMatchingRoute = ({ } if (!exact) { - return viewRoute.startsWith(currentRoute) + return currentRoute.startsWith(viewRoute) } } From 09f0c4541ab4e4586ff873bc82462ab22d510a98 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Mon, 13 Apr 2026 16:41:44 +0100 Subject: [PATCH 17/18] remove the string | ViewTypes pattern that was left over --- packages/next/src/views/Root/getRouteData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/src/views/Root/getRouteData.ts b/packages/next/src/views/Root/getRouteData.ts index 1160a20270a..e9cb4761d09 100644 --- a/packages/next/src/views/Root/getRouteData.ts +++ b/packages/next/src/views/Root/getRouteData.ts @@ -84,7 +84,7 @@ type GetRouteDataResult = { templateClassName: string templateType: 'default' | 'minimal' viewActions?: CustomComponent[] - viewType?: string | ViewTypes + viewType?: ViewTypes } type GetRouteDataArgs = { @@ -121,7 +121,7 @@ export const getRouteData = ({ let templateClassName: string let templateType: 'default' | 'minimal' | undefined let documentSubViewType: DocumentSubViewTypes - let viewType: string | ViewTypes + let viewType: ViewTypes const routeParams: GetRouteDataResult['routeParams'] = {} const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments From d17a7208965bfdbde47d4680e5bece3fe978bc9e Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 15 Apr 2026 15:52:59 +0100 Subject: [PATCH 18/18] adjust ispathmatching route for non exact matches --- .../views/Root/isPathMatchingRoute.spec.ts | 184 ++++++++++++++++++ .../src/views/Root/isPathMatchingRoute.ts | 8 +- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 packages/next/src/views/Root/isPathMatchingRoute.spec.ts diff --git a/packages/next/src/views/Root/isPathMatchingRoute.spec.ts b/packages/next/src/views/Root/isPathMatchingRoute.spec.ts new file mode 100644 index 00000000000..369a5e39923 --- /dev/null +++ b/packages/next/src/views/Root/isPathMatchingRoute.spec.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' + +import { isPathMatchingRoute } from './isPathMatchingRoute.js' + +describe('isPathMatchingRoute', () => { + describe('no path defined', () => { + it('should return false when path is undefined', () => { + expect(isPathMatchingRoute({ currentRoute: '/anything', path: undefined })).toBe(false) + }) + + it('should return false when path is empty string', () => { + expect(isPathMatchingRoute({ currentRoute: '/anything', path: '' })).toBe(false) + }) + }) + + describe('exact matching', () => { + it('should match when currentRoute exactly equals the static path', () => { + expect( + isPathMatchingRoute({ currentRoute: '/dashboard', exact: true, path: '/dashboard' }), + ).toBe(true) + }) + + it('should not match when currentRoute differs from the path', () => { + expect( + isPathMatchingRoute({ currentRoute: '/settings', exact: true, path: '/dashboard' }), + ).toBe(false) + }) + + it('should not match a sub-path when exact is true', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/dashboard/settings', + exact: true, + path: '/dashboard', + }), + ).toBe(false) + }) + + it('should match a parameterized path exactly', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/custom/123', + exact: true, + path: '/custom/:id', + }), + ).toBe(true) + }) + + it('should not match a parameterized path with extra segments', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/custom/123/edit', + exact: true, + path: '/custom/:id', + }), + ).toBe(false) + }) + + it('should match root path exactly', () => { + expect(isPathMatchingRoute({ currentRoute: '/', exact: true, path: '/' })).toBe(true) + }) + + it('should not match root path against other routes when exact', () => { + expect(isPathMatchingRoute({ currentRoute: '/login', exact: true, path: '/' })).toBe(false) + }) + }) + + describe('non-exact (prefix) matching', () => { + it('should match when currentRoute equals the path', () => { + expect(isPathMatchingRoute({ currentRoute: '/dashboard', path: '/dashboard' })).toBe(true) + }) + + it('should match a sub-path at a segment boundary', () => { + expect(isPathMatchingRoute({ currentRoute: '/dashboard/settings', path: '/dashboard' })).toBe( + true, + ) + }) + + it('should match deeply nested sub-paths', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/dashboard/settings/advanced/debug', + path: '/dashboard', + }), + ).toBe(true) + }) + + it('should not match when route shares a prefix but not at a segment boundary', () => { + expect(isPathMatchingRoute({ currentRoute: '/dashboard-extra', path: '/dashboard' })).toBe( + false, + ) + }) + + it('should not match a completely different route', () => { + expect(isPathMatchingRoute({ currentRoute: '/settings', path: '/dashboard' })).toBe(false) + }) + + it('should match a parameterized path with a sub-path', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/custom/123/edit', + path: '/custom/:id', + }), + ).toBe(false) + // pathToRegexp is end-anchored by default, so /custom/:id does not match /custom/123/edit. + // The literal fallback '/custom/:id' also won't startsWith-match. + // Parameterized views should use exact: true or include all segments in the pattern. + }) + + it('should match when :id captures the full segment including non-slash characters', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/custom/123extra', + path: '/custom/:id', + }), + ).toBe(true) + // :id captures everything up to the next / — '123extra' is a valid :id value + }) + }) + + describe('root path / without exact — regression for route shadowing bug', () => { + it('should match root path against itself', () => { + expect(isPathMatchingRoute({ currentRoute: '/', path: '/' })).toBe(true) + }) + + it('should not match root path against /login', () => { + expect(isPathMatchingRoute({ currentRoute: '/login', path: '/' })).toBe(false) + }) + + it('should not match root path against /collections/posts', () => { + expect(isPathMatchingRoute({ currentRoute: '/collections/posts', path: '/' })).toBe(false) + }) + + it('should not match root path against /collections/posts/123', () => { + expect(isPathMatchingRoute({ currentRoute: '/collections/posts/123', path: '/' })).toBe(false) + }) + }) + + describe('sensitive option', () => { + it('should match case-insensitively by default', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/Dashboard', + exact: true, + path: '/dashboard', + }), + ).toBe(true) + }) + + it('should not match different cases when sensitive is true', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/Dashboard', + exact: true, + path: '/dashboard', + sensitive: true, + }), + ).toBe(false) + }) + }) + + describe('strict option', () => { + it('should match with or without trailing slash by default', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/dashboard/', + exact: true, + path: '/dashboard', + }), + ).toBe(true) + }) + + it('should not match trailing slash mismatch when strict is true', () => { + expect( + isPathMatchingRoute({ + currentRoute: '/dashboard/', + exact: true, + path: '/dashboard', + strict: true, + }), + ).toBe(false) + }) + }) +}) diff --git a/packages/next/src/views/Root/isPathMatchingRoute.ts b/packages/next/src/views/Root/isPathMatchingRoute.ts index b8447518549..cd0e7d5d0a8 100644 --- a/packages/next/src/views/Root/isPathMatchingRoute.ts +++ b/packages/next/src/views/Root/isPathMatchingRoute.ts @@ -35,6 +35,12 @@ export const isPathMatchingRoute = ({ } if (!exact) { - return currentRoute.startsWith(viewRoute) + if (!currentRoute.startsWith(viewRoute)) { + return false + } + + const remainingPath = currentRoute.slice(viewRoute.length) + + return remainingPath === '' || remainingPath.startsWith('/') } }