fix(next): dynamic params for custom collection and global views

This commit is contained in:
Jacob Fletcher
2024-03-25 18:08:08 -04:00
parent 7654ff686a
commit 1c1847f63c
18 changed files with 310 additions and 167 deletions

View File

@@ -17,7 +17,11 @@ import { Settings } from './Settings/index.js'
export { generateAccountMetadata } from './meta.js'
export const Account: React.FC<AdminViewProps> = async ({ initPageResult, searchParams }) => {
export const Account: React.FC<AdminViewProps> = async ({
initPageResult,
params,
searchParams,
}) => {
const {
locale,
permissions,
@@ -87,6 +91,7 @@ export const Account: React.FC<AdminViewProps> = async ({ initPageResult, search
const viewComponentProps: ServerSideEditViewProps = {
initPageResult,
params,
routeSegments: [],
searchParams,
}

View File

@@ -1,16 +1,29 @@
import type { EditViewComponent } from 'payload/config'
import type { SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload/types'
export const getCustomViewByPath = (
import { isPathMatchingRoute } from '../Root/isPathMatchingRoute.js'
export const getCustomViewByRoute = ({
baseRoute,
currentRoute,
views,
}: {
baseRoute: string
currentRoute: string
views:
| SanitizedCollectionConfig['admin']['components']['views']
| SanitizedGlobalConfig['admin']['components']['views'],
path: string,
): EditViewComponent => {
| SanitizedGlobalConfig['admin']['components']['views']
}): EditViewComponent => {
if (typeof views?.Edit === 'object' && typeof views?.Edit !== 'function') {
const foundViewConfig = Object.entries(views.Edit).find(([, view]) => {
if (typeof view === 'object' && typeof view !== 'function' && 'path' in view) {
return view.path === path
const viewPath = `${baseRoute}${view.path}`
return isPathMatchingRoute({
currentRoute,
exact: true,
path: viewPath,
})
}
return false
})?.[1]

View File

@@ -8,15 +8,17 @@ import type {
} from 'payload/types'
import { isEntityHidden } from 'payload/utilities'
import React from 'react'
import { APIView as DefaultAPIView } from '../API/index.js'
import { EditView as DefaultEditView } from '../Edit/index.js'
import { LivePreviewView as DefaultLivePreviewView } from '../LivePreview/index.js'
import { NotFoundClient } from '../NotFound/index.client.js'
import { Unauthorized } from '../Unauthorized/index.js'
import { VersionView as DefaultVersionView } from '../Version/index.js'
import { VersionsView as DefaultVersionsView } from '../Versions/index.js'
import { getCustomViewByKey } from './getCustomViewByKey.js'
import { getCustomViewByPath } from './getCustomViewByPath.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
export const getViewsFromConfig = ({
collectionConfig,
@@ -46,6 +48,10 @@ export const getViewsFromConfig = ({
let CustomView: EditViewComponent = null
let ErrorView: AdminViewComponent = null
const {
routes: { admin: adminRoute },
} = config
const views =
(collectionConfig && collectionConfig?.admin?.components?.views) ||
(globalConfig && globalConfig?.admin?.components?.views)
@@ -65,8 +71,7 @@ export const getViewsFromConfig = ({
}
if (!EditOverride) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [collectionEntity, collectionSlug, createOrID, nestedViewSlug, segmentFive] =
const [collectionEntity, collectionSlug, segment3, segment4, segment5, ...remainingSegments] =
routeSegments
const {
@@ -78,74 +83,110 @@ export const getViewsFromConfig = ({
}
// `../:id`, or `../create`
if (routeSegments.length === 3) {
switch (createOrID) {
case 'create': {
if ('create' in docPermissions && docPermissions?.create?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = Unauthorized
switch (routeSegments.length) {
case 3: {
switch (segment3) {
case 'create': {
if ('create' in docPermissions && docPermissions?.create?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = Unauthorized
}
break
}
break
}
default: {
if (docPermissions?.read?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = Unauthorized
default: {
if (docPermissions?.read?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = Unauthorized
}
break
}
}
break
}
}
// `../:id/api`, `../:id/preview`, `../:id/versions`, etc
if (routeSegments?.length === 4) {
switch (nestedViewSlug) {
case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
// `../:id/api`, `../:id/preview`, `../:id/versions`, etc
case 4: {
switch (segment4) {
case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
}
break
}
break
}
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
}
break
}
break
}
case 'versions': {
case 'versions': {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
} else {
ErrorView = Unauthorized
}
break
}
default: {
const baseRoute = [adminRoute, 'collections', collectionSlug, segment3]
.filter(Boolean)
.join('/')
const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments]
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
if (!CustomView) ErrorView = () => <NotFoundClient />
break
}
}
break
}
// `../:id/versions/:version`, etc
default: {
if (segment4 === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
} else {
ErrorView = Unauthorized
}
break
}
default: {
const path = `/${nestedViewSlug}`
CustomView = getCustomViewByPath(views, path)
break
}
}
}
// `../:id/versions/:version`, etc
if (routeSegments.length === 5) {
if (nestedViewSlug === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
} else {
ErrorView = Unauthorized
const baseRoute = [adminRoute, collectionEntity, collectionSlug, segment3]
.filter(Boolean)
.join('/')
const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments]
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
if (!CustomView) ErrorView = () => <NotFoundClient />
}
break
}
}
}
@@ -161,7 +202,7 @@ export const getViewsFromConfig = ({
if (!EditOverride) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [globalEntity, globalSlug, nestedViewSlug] = routeSegments
const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments
const {
admin: { hidden },
@@ -171,64 +212,83 @@ export const getViewsFromConfig = ({
return null
}
if (routeSegments?.length === 2) {
if (docPermissions?.read?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = Unauthorized
}
}
if (routeSegments?.length === 3) {
// `../:slug/api`, `../:slug/preview`, `../:slug/versions`, etc
switch (nestedViewSlug) {
case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
}
break
}
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
}
break
}
case 'versions': {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
} else {
ErrorView = Unauthorized
}
break
}
default: {
if (docPermissions?.read?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = Unauthorized
}
break
}
}
}
if (routeSegments?.length === 4) {
// `../:slug/versions/:version`, etc
if (nestedViewSlug === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
switch (routeSegments.length) {
case 2: {
if (docPermissions?.read?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = Unauthorized
}
break
}
case 3: {
// `../:slug/api`, `../:slug/preview`, `../:slug/versions`, etc
switch (segment3) {
case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
}
break
}
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
}
break
}
case 'versions': {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
} else {
ErrorView = Unauthorized
}
break
}
default: {
if (docPermissions?.read?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = Unauthorized
}
break
}
}
break
}
default: {
// `../:slug/versions/:version`, etc
if (segment3 === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
} else {
ErrorView = Unauthorized
}
} else {
const baseRoute = [adminRoute, 'globals', globalSlug].filter(Boolean).join('/')
const currentRoute = [baseRoute, segment3, ...remainingSegments]
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
if (!CustomView) ErrorView = () => <NotFoundClient />
}
break
}
}
}

View File

@@ -1,23 +1,14 @@
import type { EditViewComponent } from 'payload/config'
import type {
AdminViewComponent,
DocumentPreferences,
Document as DocumentType,
Field,
ServerSideEditViewProps,
} from 'payload/types'
import type { AdminViewComponent, ServerSideEditViewProps } from 'payload/types'
import type { DocumentPermissions } from 'payload/types'
import type { AdminViewProps } from 'payload/types'
import { DocumentHeader } from '@payloadcms/ui/elements/DocumentHeader'
import { HydrateClientUser } from '@payloadcms/ui/elements/HydrateClientUser'
import { RenderCustomComponent } from '@payloadcms/ui/elements/RenderCustomComponent'
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
import { DocumentInfoProvider } from '@payloadcms/ui/providers/DocumentInfo'
import { EditDepthProvider } from '@payloadcms/ui/providers/EditDepth'
import { FormQueryParamsProvider } from '@payloadcms/ui/providers/FormQueryParams'
import { formatDocTitle } from '@payloadcms/ui/utilities/formatDocTitle'
import { formatFields } from '@payloadcms/ui/utilities/formatFields'
import { docAccessOperation } from 'payload/operations'
import React from 'react'
@@ -222,6 +213,7 @@ export const Document: React.FC<AdminViewProps> = async ({
const viewComponentProps: ServerSideEditViewProps = {
initPageResult,
params,
routeSegments: segments,
searchParams,
}

View File

@@ -1,6 +1,6 @@
import type { AdminViewComponent, SanitizedConfig } from 'payload/types'
import { pathToRegexp } from 'path-to-regexp'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'
export const getCustomViewByRoute = ({
config,
@@ -23,22 +23,13 @@ export const getCustomViewByRoute = ({
typeof views === 'object' &&
Object.entries(views).find(([, view]) => {
if (typeof view === 'object') {
const { exact, path: viewPath, sensitive, strict } = view
const keys = []
// run the view path through `pathToRegexp` to resolve any dynamic segments
// i.e. `/admin/custom-view/:id` -> `/admin/custom-view/123`
const regex = pathToRegexp(viewPath, keys, {
sensitive,
strict,
return isPathMatchingRoute({
currentRoute,
exact: view.exact,
path: view.path,
sensitive: view.sensitive,
strict: view.strict,
})
const match = regex.exec(currentRoute)
const viewRoute = match?.[0] || viewPath
if (exact) return currentRoute === viewRoute
if (!exact) return viewRoute.startsWith(currentRoute)
}
})?.[1]

View File

@@ -0,0 +1,30 @@
import { pathToRegexp } from 'path-to-regexp'
export const isPathMatchingRoute = ({
currentRoute,
exact,
path: viewPath,
sensitive,
strict,
}: {
currentRoute: string
exact?: boolean
path?: string
sensitive?: boolean
strict?: boolean
}) => {
const keys = []
// run the view path through `pathToRegexp` to resolve any dynamic segments
// i.e. `/admin/custom-view/:id` -> `/admin/custom-view/123`
const regex = pathToRegexp(viewPath, keys, {
sensitive,
strict,
})
const match = regex.exec(currentRoute)
const viewRoute = match?.[0] || viewPath
if (exact) return currentRoute === viewRoute
if (!exact) return viewRoute.startsWith(currentRoute)
}

View File

@@ -42,6 +42,7 @@ export type InitPageResult = {
export type ServerSideEditViewProps = {
initPageResult: InitPageResult
params: { [key: string]: string | string[] | undefined }
routeSegments: string[]
searchParams: { [key: string]: string | string[] | undefined }
}

View File

@@ -5,7 +5,7 @@ import type {
SanitizedGlobalConfig,
} from 'payload/types'
import { isPlainObject } from 'packages/payload/src/utilities/isPlainObject.js'
import { isPlainObject } from 'payload/utilities'
import React from 'react'
import { ShouldRenderTabs } from './ShouldRenderTabs.js'

View File

@@ -1,11 +1,14 @@
import type { CollectionConfig } from 'payload/types'
import { CustomTabComponent } from '../components/CustomTabComponent/index.js'
import { CustomTabView } from '../components/views/CustomTab/index.js'
import { CustomTabView2 } from '../components/views/CustomTab2/index.js'
import { CustomTabComponentView } from '../components/views/CustomTabComponent/index.js'
import { CustomTabLabelView } from '../components/views/CustomTabLabel/index.js'
import { CustomNestedTabView } from '../components/views/CustomTabNested/index.js'
import { CustomTabWithParamView } from '../components/views/CustomTabWithParam/index.js'
import { CustomVersionsView } from '../components/views/CustomVersions/index.js'
import {
customCollectionParamViewPath,
customCollectionParamViewPathBase,
customEditLabel,
customNestedTabViewPath,
customTabLabel,
@@ -26,7 +29,7 @@ export const CustomViews2: CollectionConfig = {
},
},
MyCustomView: {
Component: CustomTabView,
Component: CustomTabLabelView,
Tab: {
href: '/custom-tab-view',
label: customTabLabel,
@@ -34,15 +37,27 @@ export const CustomViews2: CollectionConfig = {
path: '/custom-tab-view',
},
MyCustomViewWithCustomTab: {
Component: CustomTabView2,
Component: CustomTabComponentView,
Tab: CustomTabComponent,
path: customTabViewPath,
},
MyCustomViewWithNestedPath: {
Component: CustomNestedTabView,
path: customNestedTabViewPath,
tab: {
label: 'Custom Nested Tab View',
href: customNestedTabViewPath,
},
},
Versions: CustomVersionsView,
CustomViewWithParam: {
Component: CustomTabWithParamView,
path: customCollectionParamViewPath,
Tab: {
label: 'Custom Param View',
href: `${customCollectionParamViewPathBase}/123`,
},
},
},
},
},

View File

@@ -1,16 +1,21 @@
'use client'
import LinkImport from 'next/link'
import { usePathname } from 'next/navigation'
import { useConfig } from '@payloadcms/ui/providers/Config'
import LinkImport from 'next/link.js'
import { useParams } from 'next/navigation'
import React from 'react'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const CustomTabComponentClient: React.FC<{
path: string
}> = (props) => {
const { path } = props
const pathname = usePathname()
}> = ({ path }) => {
const {
routes: { admin: adminRoute },
} = useConfig()
const params = useParams()
return <Link href={`${pathname}${path}`}>Custom Tab Component</Link>
const baseRoute = (params.segments.slice(0, 3) as string[]).join('/')
return <Link href={`${adminRoute}/${baseRoute}${path}`}>Custom Tab Component</Link>
}

View File

@@ -1,5 +1,5 @@
// As this is the demo folder, we import Payload SCSS functions relatively.
@import '../../../../../packages/ui/src/exports/scss.scss';
@import '../../../../../packages/ui/src/scss/styles.scss';
// In your own projects, you'd import as follows:
// @import '~payload/scss';

View File

@@ -1,5 +1,5 @@
// As this is the demo folder, we import Payload SCSS functions relatively.
@import '../../../../../packages/ui/src/exports/scss.scss';
@import '../../../../../packages/ui/src/scss/styles.scss';
// In your own projects, you'd import as follows:
// @import '~payload/scss';

View File

@@ -4,9 +4,9 @@ import React, { Fragment } from 'react'
import type { ServerSideEditViewProps } from '../../../../../packages/payload/types.js'
import { customTabViewTitle } from '../../../shared.js'
import { customTabViewComponentTitle } from '../../../shared.js'
export const CustomTabView2: React.FC<ServerSideEditViewProps> = ({ initPageResult }) => {
export const CustomTabComponentView: React.FC<ServerSideEditViewProps> = ({ initPageResult }) => {
if (!initPageResult) {
notFound()
}
@@ -27,7 +27,7 @@ export const CustomTabView2: React.FC<ServerSideEditViewProps> = ({ initPageResu
paddingRight: 'var(--gutter-h)',
}}
>
<h1 id="custom-view-title">{customTabViewTitle}</h1>
<h1 id="custom-view-title">{customTabViewComponentTitle}</h1>
<p>This custom view was added through the Payload config:</p>
<ul>
<li>

View File

@@ -4,9 +4,9 @@ import React, { Fragment } from 'react'
import type { ServerSideEditViewProps } from '../../../../../packages/payload/types.js'
import { customTabViewTitle } from '../../../shared.js'
import { customTabLabelViewTitle } from '../../../shared.js'
export const CustomTabView: React.FC<ServerSideEditViewProps> = ({ initPageResult }) => {
export const CustomTabLabelView: React.FC<ServerSideEditViewProps> = ({ initPageResult }) => {
if (!initPageResult) {
notFound()
}
@@ -27,7 +27,7 @@ export const CustomTabView: React.FC<ServerSideEditViewProps> = ({ initPageResul
paddingRight: 'var(--gutter-h)',
}}
>
<h1 id="custom-view-title">{customTabViewTitle}</h1>
<h1 id="custom-view-title">{customTabLabelViewTitle}</h1>
<p>This custom view was added through the Payload config:</p>
<ul>
<li>

View File

@@ -0,0 +1,24 @@
import type { AdminViewProps } from 'payload/types.js'
import React from 'react'
import { customParamViewTitle } from '../../../shared.js'
export const CustomTabWithParamView: React.FC<AdminViewProps> = ({ params }) => {
const paramValue = params?.segments?.[4]
return (
<div
style={{
marginTop: 'calc(var(--base) * 2)',
paddingLeft: 'var(--gutter-h)',
paddingRight: 'var(--gutter-h)',
}}
>
<h1 id="custom-view-title">{customParamViewTitle}</h1>
<p>
This custom collection view is using a dynamic URL parameter `slug: {paramValue || 'None'}`
</p>
</div>
)
}

View File

@@ -2,7 +2,8 @@ import type { GlobalConfig } from 'payload/types'
import { CustomTabComponent } from '../components/CustomTabComponent/index.js'
import { CustomDefaultEditView } from '../components/views/CustomEditDefault/index.js'
import { CustomTabView } from '../components/views/CustomTab/index.js'
import { CustomTabComponentView } from '../components/views/CustomTabComponent/index.js'
import { CustomTabLabelView } from '../components/views/CustomTabLabel/index.js'
import { CustomVersionsView } from '../components/views/CustomVersions/index.js'
import { customGlobalViews2GlobalSlug } from '../slugs.js'
@@ -14,7 +15,7 @@ export const CustomGlobalViews2: GlobalConfig = {
Edit: {
Default: CustomDefaultEditView,
MyCustomView: {
Component: CustomTabView,
Component: CustomTabLabelView,
Tab: {
href: '/custom-tab-view',
label: 'Custom',
@@ -22,7 +23,7 @@ export const CustomGlobalViews2: GlobalConfig = {
path: '/custom-tab-view',
},
MyCustomViewWithCustomTab: {
Component: CustomTabView,
Component: CustomTabComponentView,
Tab: CustomTabComponent,
path: '/custom-tab-component',
},

View File

@@ -22,8 +22,14 @@ export const customTabLabel = 'Custom Tab Label'
export const customTabViewPath = '/custom-tab-component'
export const customTabViewTitle = 'Custom View With Tab Component'
export const customTabLabelViewTitle = 'Custom Tab Label View'
export const customTabViewComponentTitle = 'Custom View With Tab Component'
export const customNestedTabViewPath = `${customTabViewPath}/nested-view`
export const customNestedTabViewTitle = 'Custom Nested Tab View'
export const customCollectionParamViewPathBase = '/custom-param'
export const customCollectionParamViewPath = `${customCollectionParamViewPathBase}/:slug`

View File

@@ -28,7 +28,7 @@
}
],
"paths": {
"@payload-config": ["./test/_community/config.ts"],
"@payload-config": ["./test/admin/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/ui/assets": ["./packages/ui/src/assets/index.ts"],