refactor(next): simplify document tab rendering logic (#12795)

Simplifies the rendering logic around document tabs in the following
ways:

- Merges default tabs with custom tabs in a more predictable way, now
there is only a single array of tabs to iterate over, no more concept of
"custom" tabs vs "default" tabs, there's now just "tabs"
- Deduplicates rendering conditions for all tabs, now any changes to
default tabs would also apply to custom tabs with half the code
- Removes unnecessary `getCustomViews` function, this is a relic of the
past
- Removes unnecessary `getViewConfig` function, this is a relic of the
past
- Removes unused `references`, `relationships`, and `version` key
placeholders, these are relics of the past
- Prevents tab conditions from running twice unnecessarily
- Other misc. cleanup like unnecessarily casting the tab conditions
result to a boolean, etc.
This commit is contained in:
Jacob Fletcher
2025-06-13 07:12:28 -04:00
committed by GitHub
parent 729b676e98
commit 53835f2620
7 changed files with 188 additions and 294 deletions

View File

@@ -15,7 +15,6 @@ export const DocumentTab: React.FC<
const {
apiURL,
collectionConfig,
condition,
globalConfig,
href: tabHref,
i18n,
@@ -49,48 +48,40 @@ export const DocumentTab: React.FC<
})
}
const meetsCondition =
!condition ||
(condition && Boolean(condition({ collectionConfig, config, globalConfig, permissions })))
const labelToRender =
typeof label === 'function'
? label({
t: i18n.t,
})
: label
if (meetsCondition) {
const labelToRender =
typeof label === 'function'
? label({
t: i18n.t,
})
: label
return (
<DocumentTabLink
adminRoute={routes.admin}
ariaLabel={labelToRender}
baseClass={baseClass}
href={href}
isActive={isActive}
newTab={newTab}
>
<span className={`${baseClass}__label`}>
{labelToRender}
{Pill || Pill_Component ? (
<Fragment>
&nbsp;
{RenderServerComponent({
Component: Pill,
Fallback: Pill_Component,
importMap: payload.importMap,
serverProps: {
i18n,
payload,
permissions,
} satisfies ServerProps,
})}
</Fragment>
) : null}
</span>
</DocumentTabLink>
)
}
return null
return (
<DocumentTabLink
adminRoute={routes.admin}
ariaLabel={labelToRender}
baseClass={baseClass}
href={href}
isActive={isActive}
newTab={newTab}
>
<span className={`${baseClass}__label`}>
{labelToRender}
{Pill || Pill_Component ? (
<Fragment>
&nbsp;
{RenderServerComponent({
Component: Pill,
Fallback: Pill_Component,
importMap: payload.importMap,
serverProps: {
i18n,
payload,
permissions,
} satisfies ServerProps,
})}
</Fragment>
) : null}
</span>
</DocumentTabLink>
)
}

View File

@@ -1,46 +0,0 @@
import type { DocumentViewConfig, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
import { documentViewKeys } from './tabs/index.js'
export const getCustomViews = (args: {
collectionConfig: SanitizedCollectionConfig
globalConfig: SanitizedGlobalConfig
}): DocumentViewConfig[] => {
const { collectionConfig, globalConfig } = args
let customViews: DocumentViewConfig[]
if (collectionConfig) {
const collectionViewsConfig =
typeof collectionConfig?.admin?.components?.views?.edit === 'object' &&
typeof collectionConfig?.admin?.components?.views?.edit !== 'function'
? collectionConfig?.admin?.components?.views?.edit
: undefined
customViews = Object.entries(collectionViewsConfig || {}).reduce((prev, [key, view]) => {
if (documentViewKeys.includes(key)) {
return prev
}
return [...prev, { ...view, key }]
}, [])
}
if (globalConfig) {
const globalViewsConfig =
typeof globalConfig?.admin?.components?.views?.edit === 'object' &&
typeof globalConfig?.admin?.components?.views?.edit !== 'function'
? globalConfig?.admin?.components?.views?.edit
: undefined
customViews = Object.entries(globalViewsConfig || {}).reduce((prev, [key, view]) => {
if (documentViewKeys.includes(key)) {
return prev
}
return [...prev, { ...view, key }]
}, [])
}
return customViews
}

View File

@@ -1,31 +0,0 @@
import type { DocumentViewConfig, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
export const getViewConfig = (args: {
collectionConfig: SanitizedCollectionConfig
globalConfig: SanitizedGlobalConfig
name: string
}): DocumentViewConfig => {
const { name, collectionConfig, globalConfig } = args
if (collectionConfig) {
const collectionConfigViewsConfig =
typeof collectionConfig?.admin?.components?.views?.edit === 'object' &&
typeof collectionConfig?.admin?.components?.views?.edit !== 'function'
? collectionConfig?.admin?.components?.views?.edit
: undefined
return collectionConfigViewsConfig?.[name]
}
if (globalConfig) {
const globalConfigViewsConfig =
typeof globalConfig?.admin?.components?.views?.edit === 'object' &&
typeof globalConfig?.admin?.components?.views?.edit !== 'function'
? globalConfig?.admin?.components?.views?.edit
: undefined
return globalConfigViewsConfig?.[name]
}
return null
}

View File

@@ -11,12 +11,10 @@ import type {
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import React from 'react'
import { getCustomViews } from './getCustomViews.js'
import { getViewConfig } from './getViewConfig.js'
import './index.scss'
import { ShouldRenderTabs } from './ShouldRenderTabs.js'
import { DocumentTab } from './Tab/index.js'
import { tabs as defaultTabs } from './tabs/index.js'
import { getTabs } from './tabs/index.js'
import './index.scss'
const baseClass = 'doc-tabs'
@@ -30,111 +28,54 @@ export const DocumentTabs: React.FC<{
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { config } = payload
const customViews = getCustomViews({ collectionConfig, globalConfig })
const tabs = getTabs({
collectionConfig,
globalConfig,
})
return (
<ShouldRenderTabs>
<div className={baseClass}>
<div className={`${baseClass}__tabs-container`}>
<ul className={`${baseClass}__tabs`}>
{Object.entries(defaultTabs)
// sort `defaultViews` based on `order` property from smallest to largest
// if no `order`, append the view to the end
// TODO: open `order` to the config and merge `defaultViews` with `customViews`
?.sort(([, a], [, b]) => {
if (a.order === undefined && b.order === undefined) {
return 0
} else if (a.order === undefined) {
return 1
} else if (b.order === undefined) {
return -1
}
return a.order - b.order
})
?.map(([name, tab], index) => {
const viewConfig = getViewConfig({ name, collectionConfig, globalConfig })
const tabFromConfig = viewConfig && 'tab' in viewConfig ? viewConfig.tab : undefined
{tabs?.map(({ tab, viewPath }, index) => {
const { condition } = tab || {}
const { condition } = tabFromConfig || {}
const meetsCondition =
!condition ||
(condition &&
Boolean(condition({ collectionConfig, config, globalConfig, permissions })))
const path = viewConfig && 'path' in viewConfig ? viewConfig.path : ''
if (meetsCondition) {
if (tabFromConfig?.Component) {
return RenderServerComponent({
clientProps: {
path,
} satisfies DocumentTabClientProps,
Component: tabFromConfig.Component,
importMap: payload.importMap,
key: `tab-${index}`,
serverProps: {
collectionConfig,
globalConfig,
i18n,
payload,
permissions,
} satisfies DocumentTabServerPropsOnly,
})
}
return (
<DocumentTab
key={`tab-${index}`}
path={path}
{...{
...props,
...(tab || {}),
...(tabFromConfig || {}),
}}
/>
)
}
const meetsCondition =
!condition || condition({ collectionConfig, config, globalConfig, permissions })
if (!meetsCondition) {
return null
})}
{customViews?.map((customViewConfig, index) => {
if ('tab' in customViewConfig) {
const { tab } = customViewConfig
const path = 'path' in customViewConfig ? customViewConfig.path : ''
if (tab.Component) {
return RenderServerComponent({
clientProps: {
path,
} satisfies DocumentTabClientProps,
Component: tab.Component,
importMap: payload.importMap,
key: `tab-custom-${index}`,
serverProps: {
collectionConfig,
globalConfig,
i18n,
payload,
permissions,
} satisfies DocumentTabServerPropsOnly,
})
}
return (
<DocumentTab
key={`tab-custom-${index}`}
path={path}
{...{
...props,
...tab,
}}
/>
)
}
return null
if (tab?.Component) {
return RenderServerComponent({
clientProps: {
path: viewPath,
} satisfies DocumentTabClientProps,
Component: tab.Component,
importMap: payload.importMap,
key: `tab-${index}`,
serverProps: {
collectionConfig,
globalConfig,
i18n,
payload,
permissions,
} satisfies DocumentTabServerPropsOnly,
})
}
return (
<DocumentTab
key={`tab-${index}`}
path={viewPath}
{...{
...props,
...tab,
}}
/>
)
})}
</ul>
</div>

View File

@@ -1,83 +1,97 @@
import type { DocumentTabConfig } from 'payload'
import type React from 'react'
import type { DocumentTabConfig, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
import { VersionsPill } from './VersionsPill/index.js'
export const documentViewKeys = [
'api',
'default',
'livePreview',
'references',
'relationships',
'version',
'versions',
]
export const documentViewKeys = ['api', 'default', 'livePreview', 'versions']
export type DocumentViewKey = (typeof documentViewKeys)[number]
export const tabs: Record<
DocumentViewKey,
{
order?: number // TODO: expose this to the globalConfig config
Pill_Component?: React.FC
} & DocumentTabConfig
> = {
api: {
condition: ({ collectionConfig, globalConfig }) =>
(collectionConfig && !collectionConfig?.admin?.hideAPIURL) ||
(globalConfig && !globalConfig?.admin?.hideAPIURL),
href: '/api',
label: 'API',
order: 1000,
},
default: {
href: '',
// isActive: ({ href, location }) =>
// location.pathname === href || location.pathname === `${href}/create`,
label: ({ t }) => t('general:edit'),
order: 0,
},
livePreview: {
condition: ({ collectionConfig, config, globalConfig }) => {
if (collectionConfig) {
return Boolean(
config?.admin?.livePreview?.collections?.includes(collectionConfig.slug) ||
collectionConfig?.admin?.livePreview,
)
}
export const getTabs = ({
collectionConfig,
globalConfig,
}: {
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
}): { tab: DocumentTabConfig; viewPath: string }[] => {
const customViews =
collectionConfig?.admin?.components?.views?.edit ||
globalConfig?.admin?.components?.views?.edit ||
{}
if (globalConfig) {
return Boolean(
config?.admin?.livePreview?.globals?.includes(globalConfig.slug) ||
globalConfig?.admin?.livePreview,
)
}
return false
return [
{
tab: {
href: '',
label: ({ t }) => t('general:edit'),
...(customViews?.['default']?.tab || {}),
},
viewPath: '/',
},
href: '/preview',
label: ({ t }) => t('general:livePreview'),
order: 100,
},
references: {
condition: () => false,
},
relationships: {
condition: () => false,
},
version: {
condition: () => false,
},
versions: {
condition: ({ collectionConfig, globalConfig, permissions }) =>
Boolean(
(collectionConfig?.versions &&
permissions?.collections?.[collectionConfig?.slug]?.readVersions) ||
(globalConfig?.versions && permissions?.globals?.[globalConfig?.slug]?.readVersions),
),
href: '/versions',
label: ({ t }) => t('version:versions'),
order: 200,
Pill_Component: VersionsPill,
},
{
tab: {
condition: ({ collectionConfig, config, globalConfig }) => {
if (collectionConfig) {
return Boolean(
config?.admin?.livePreview?.collections?.includes(collectionConfig.slug) ||
collectionConfig?.admin?.livePreview,
)
}
if (globalConfig) {
return Boolean(
config?.admin?.livePreview?.globals?.includes(globalConfig.slug) ||
globalConfig?.admin?.livePreview,
)
}
return false
},
href: '/preview',
label: ({ t }) => t('general:livePreview'),
...(customViews?.['livePreview']?.tab || {}),
},
viewPath: '/preview',
},
{
tab: {
condition: ({ collectionConfig, globalConfig, permissions }) =>
Boolean(
(collectionConfig?.versions &&
permissions?.collections?.[collectionConfig?.slug]?.readVersions) ||
(globalConfig?.versions && permissions?.globals?.[globalConfig?.slug]?.readVersions),
),
href: '/versions',
label: ({ t }) => t('version:versions'),
Pill_Component: VersionsPill,
...(customViews?.['versions']?.tab || {}),
},
viewPath: '/versions',
},
{
tab: {
condition: ({ collectionConfig, globalConfig }) =>
(collectionConfig && !collectionConfig?.admin?.hideAPIURL) ||
(globalConfig && !globalConfig?.admin?.hideAPIURL),
href: '/api',
label: 'API',
...(customViews?.['api']?.tab || {}),
},
viewPath: '/api',
},
].concat(
Object.entries(customViews).reduce((acc, [key, value]) => {
if (documentViewKeys.includes(key)) {
return acc
}
if (value?.tab) {
acc.push({
tab: value.tab,
viewPath: 'path' in value ? value.path : '',
})
}
return acc
}, []),
)
}

View File

@@ -9,8 +9,8 @@ import type {
import { Gutter, RenderTitle } from '@payloadcms/ui'
import React from 'react'
import './index.scss'
import { DocumentTabs } from './Tabs/index.js'
import './index.scss'
const baseClass = `doc-header`

View File

@@ -90,6 +90,7 @@ export interface Config {
with300documents: With300Document;
'with-list-drawer': WithListDrawer;
placeholder: Placeholder;
'use-as-title-group-field': UseAsTitleGroupField;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -119,6 +120,7 @@ export interface Config {
with300documents: With300DocumentsSelect<false> | With300DocumentsSelect<true>;
'with-list-drawer': WithListDrawerSelect<false> | WithListDrawerSelect<true>;
placeholder: PlaceholderSelect<false> | PlaceholderSelect<true>;
'use-as-title-group-field': UseAsTitleGroupFieldSelect<false> | UseAsTitleGroupFieldSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -523,6 +525,16 @@ export interface Placeholder {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "use-as-title-group-field".
*/
export interface UseAsTitleGroupField {
id: string;
name?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -621,6 +633,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'placeholder';
value: string | Placeholder;
} | null)
| ({
relationTo: 'use-as-title-group-field';
value: string | UseAsTitleGroupField;
} | null);
globalSlug?: string | null;
user: {
@@ -987,6 +1003,15 @@ export interface PlaceholderSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "use-as-title-group-field_select".
*/
export interface UseAsTitleGroupFieldSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".