From 53835f2620da4ee1bccb89bec36c5e15b11ce9c4 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 13 Jun 2025 07:12:28 -0400 Subject: [PATCH] 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. --- .../DocumentHeader/Tabs/Tab/index.tsx | 79 ++++----- .../DocumentHeader/Tabs/getCustomViews.ts | 46 ----- .../DocumentHeader/Tabs/getViewConfig.ts | 31 ---- .../elements/DocumentHeader/Tabs/index.tsx | 137 +++++---------- .../DocumentHeader/Tabs/tabs/index.tsx | 162 ++++++++++-------- .../src/elements/DocumentHeader/index.tsx | 2 +- test/admin/payload-types.ts | 25 +++ 7 files changed, 188 insertions(+), 294 deletions(-) delete mode 100644 packages/next/src/elements/DocumentHeader/Tabs/getCustomViews.ts delete mode 100644 packages/next/src/elements/DocumentHeader/Tabs/getViewConfig.ts diff --git a/packages/next/src/elements/DocumentHeader/Tabs/Tab/index.tsx b/packages/next/src/elements/DocumentHeader/Tabs/Tab/index.tsx index eaf3b9077..a19ee4c38 100644 --- a/packages/next/src/elements/DocumentHeader/Tabs/Tab/index.tsx +++ b/packages/next/src/elements/DocumentHeader/Tabs/Tab/index.tsx @@ -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 ( - - - {labelToRender} - {Pill || Pill_Component ? ( - -   - {RenderServerComponent({ - Component: Pill, - Fallback: Pill_Component, - importMap: payload.importMap, - serverProps: { - i18n, - payload, - permissions, - } satisfies ServerProps, - })} - - ) : null} - - - ) - } - - return null + return ( + + + {labelToRender} + {Pill || Pill_Component ? ( + +   + {RenderServerComponent({ + Component: Pill, + Fallback: Pill_Component, + importMap: payload.importMap, + serverProps: { + i18n, + payload, + permissions, + } satisfies ServerProps, + })} + + ) : null} + + + ) } diff --git a/packages/next/src/elements/DocumentHeader/Tabs/getCustomViews.ts b/packages/next/src/elements/DocumentHeader/Tabs/getCustomViews.ts deleted file mode 100644 index 49cb12e1f..000000000 --- a/packages/next/src/elements/DocumentHeader/Tabs/getCustomViews.ts +++ /dev/null @@ -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 -} diff --git a/packages/next/src/elements/DocumentHeader/Tabs/getViewConfig.ts b/packages/next/src/elements/DocumentHeader/Tabs/getViewConfig.ts deleted file mode 100644 index 8ae21b1df..000000000 --- a/packages/next/src/elements/DocumentHeader/Tabs/getViewConfig.ts +++ /dev/null @@ -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 -} diff --git a/packages/next/src/elements/DocumentHeader/Tabs/index.tsx b/packages/next/src/elements/DocumentHeader/Tabs/index.tsx index bfbce80fe..6efef9a5b 100644 --- a/packages/next/src/elements/DocumentHeader/Tabs/index.tsx +++ b/packages/next/src/elements/DocumentHeader/Tabs/index.tsx @@ -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 (
    - {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 ( - - ) - } + 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 ( - - ) } - 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 ( + + ) })}
diff --git a/packages/next/src/elements/DocumentHeader/Tabs/tabs/index.tsx b/packages/next/src/elements/DocumentHeader/Tabs/tabs/index.tsx index fbe9c33cb..9c9638204 100644 --- a/packages/next/src/elements/DocumentHeader/Tabs/tabs/index.tsx +++ b/packages/next/src/elements/DocumentHeader/Tabs/tabs/index.tsx @@ -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 + }, []), + ) } diff --git a/packages/next/src/elements/DocumentHeader/index.tsx b/packages/next/src/elements/DocumentHeader/index.tsx index 9cf68a2c8..399edf302 100644 --- a/packages/next/src/elements/DocumentHeader/index.tsx +++ b/packages/next/src/elements/DocumentHeader/index.tsx @@ -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` diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index e348ceebb..b42e50ee4 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -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 | With300DocumentsSelect; 'with-list-drawer': WithListDrawerSelect | WithListDrawerSelect; placeholder: PlaceholderSelect | PlaceholderSelect; + 'use-as-title-group-field': UseAsTitleGroupFieldSelect | UseAsTitleGroupFieldSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -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 { 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 { + name?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select".