fix(next): pass req through document tab conditions and custom server components (#13302)

Custom document tab components (server components) do not receive the
`user` prop, as the types suggest. This makes it difficult to wire up
conditional rendering based on the user. This is because tab conditions
don't receive a user argument either, forcing you to render the default
tab component yourself—but a custom component should not be needed for
this in the first place.

Now they both receive `req` alongside `user`, which is more closely
aligned with custom field components.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210906078627357
This commit is contained in:
Jacob Fletcher
2025-07-28 23:35:38 -04:00
committed by GitHub
parent a888d5cc53
commit c5c8c13057
7 changed files with 65 additions and 53 deletions

View File

@@ -1,4 +1,11 @@
import type { DocumentTabConfig, DocumentTabServerProps, ServerProps } from 'payload'
import type {
DocumentTabConfig,
DocumentTabServerPropsOnly,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
} from 'payload'
import type React from 'react'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
@@ -9,27 +16,24 @@ import './index.scss'
export const baseClass = 'doc-tab'
export const DocumentTab: React.FC<
{ readonly Pill_Component?: React.FC } & DocumentTabConfig & DocumentTabServerProps
> = (props) => {
export const DefaultDocumentTab: React.FC<{
apiURL?: string
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
path?: string
permissions?: SanitizedPermissions
req: PayloadRequest
tabConfig: { readonly Pill_Component?: React.FC } & DocumentTabConfig
}> = (props) => {
const {
apiURL,
collectionConfig,
globalConfig,
href: tabHref,
i18n,
isActive: tabIsActive,
label,
newTab,
payload,
permissions,
Pill,
Pill_Component,
req,
tabConfig: { href: tabHref, isActive: tabIsActive, label, newTab, Pill, Pill_Component },
} = props
const { config } = payload
const { routes } = config
let href = typeof tabHref === 'string' ? tabHref : ''
let isActive = typeof tabIsActive === 'boolean' ? tabIsActive : false
@@ -38,7 +42,7 @@ export const DocumentTab: React.FC<
apiURL,
collection: collectionConfig,
global: globalConfig,
routes,
routes: req.payload.config.routes,
})
}
@@ -51,13 +55,13 @@ export const DocumentTab: React.FC<
const labelToRender =
typeof label === 'function'
? label({
t: i18n.t,
t: req.i18n.t,
})
: label
return (
<DocumentTabLink
adminRoute={routes.admin}
adminRoute={req.payload.config.routes.admin}
ariaLabel={labelToRender}
baseClass={baseClass}
href={href}
@@ -72,12 +76,14 @@ export const DocumentTab: React.FC<
{RenderServerComponent({
Component: Pill,
Fallback: Pill_Component,
importMap: payload.importMap,
importMap: req.payload.importMap,
serverProps: {
i18n,
payload,
i18n: req.i18n,
payload: req.payload,
permissions,
} satisfies ServerProps,
req,
user: req.user,
} satisfies DocumentTabServerPropsOnly,
})}
</Fragment>
) : null}

View File

@@ -1,8 +1,7 @@
import type { I18n } from '@payloadcms/translations'
import type {
DocumentTabClientProps,
DocumentTabServerPropsOnly,
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
@@ -12,7 +11,7 @@ import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerCompo
import React from 'react'
import { ShouldRenderTabs } from './ShouldRenderTabs.js'
import { DocumentTab } from './Tab/index.js'
import { DefaultDocumentTab } from './Tab/index.js'
import { getTabs } from './tabs/index.js'
import './index.scss'
@@ -21,12 +20,10 @@ const baseClass = 'doc-tabs'
export const DocumentTabs: React.FC<{
collectionConfig: SanitizedCollectionConfig
globalConfig: SanitizedGlobalConfig
i18n: I18n
payload: Payload
permissions: SanitizedPermissions
}> = (props) => {
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { config } = payload
req: PayloadRequest
}> = ({ collectionConfig, globalConfig, permissions, req }) => {
const { config } = req.payload
const tabs = getTabs({
collectionConfig,
@@ -38,42 +35,46 @@ export const DocumentTabs: React.FC<{
<div className={baseClass}>
<div className={`${baseClass}__tabs-container`}>
<ul className={`${baseClass}__tabs`}>
{tabs?.map(({ tab, viewPath }, index) => {
const { condition } = tab || {}
{tabs?.map(({ tab: tabConfig, viewPath }, index) => {
const { condition } = tabConfig || {}
const meetsCondition =
!condition || condition({ collectionConfig, config, globalConfig, permissions })
!condition ||
condition({ collectionConfig, config, globalConfig, permissions, req })
if (!meetsCondition) {
return null
}
if (tab?.Component) {
if (tabConfig?.Component) {
return RenderServerComponent({
clientProps: {
path: viewPath,
} satisfies DocumentTabClientProps,
Component: tab.Component,
importMap: payload.importMap,
Component: tabConfig.Component,
importMap: req.payload.importMap,
key: `tab-${index}`,
serverProps: {
collectionConfig,
globalConfig,
i18n,
payload,
i18n: req.i18n,
payload: req.payload,
permissions,
req,
user: req.user,
} satisfies DocumentTabServerPropsOnly,
})
}
return (
<DocumentTab
<DefaultDocumentTab
collectionConfig={collectionConfig}
globalConfig={globalConfig}
key={`tab-${index}`}
path={viewPath}
{...{
...props,
...tab,
}}
permissions={permissions}
req={req}
tabConfig={tabConfig}
/>
)
})}

View File

@@ -1,6 +1,6 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
SanitizedPermissions,
@@ -18,11 +18,10 @@ export const DocumentHeader: React.FC<{
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
hideTabs?: boolean
i18n: I18n
payload: Payload
permissions: SanitizedPermissions
req: PayloadRequest
}> = (props) => {
const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props
const { collectionConfig, globalConfig, hideTabs, permissions, req } = props
return (
<Gutter className={baseClass}>
@@ -31,9 +30,8 @@ export const DocumentHeader: React.FC<{
<DocumentTabs
collectionConfig={collectionConfig}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
)}
</Gutter>

View File

@@ -137,9 +137,8 @@ export async function Account({ initPageResult, params, searchParams }: AdminVie
<DocumentHeader
collectionConfig={collectionConfig}
hideTabs
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
<HydrateAuthProvider permissions={permissions} />
{RenderServerComponent({

View File

@@ -416,9 +416,8 @@ export const renderDocument = async ({
<DocumentHeader
collectionConfig={collectionConfig}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
req={req}
/>
)}
<HydrateAuthProvider permissions={permissions} />

View File

@@ -68,6 +68,9 @@ export type FieldPaths = {
path: string
}
/**
* TODO: This should be renamed to `FieldComponentServerProps` or similar
*/
export type ServerComponentProps = {
clientField: ClientFieldWithOptionalType
clientFieldSchemaMap: ClientFieldSchemaMap

View File

@@ -2,6 +2,7 @@ import type { SanitizedPermissions } from '../../auth/types.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { PayloadComponent, SanitizedConfig, ServerProps } from '../../config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type { PayloadRequest } from '../../types/index.js'
import type { Data, DocumentSlots, FormState } from '../types.js'
import type { InitPageResult, ViewTypes } from './index.js'
@@ -50,6 +51,7 @@ export type DocumentTabServerPropsOnly = {
readonly collectionConfig?: SanitizedCollectionConfig
readonly globalConfig?: SanitizedGlobalConfig
readonly permissions: SanitizedPermissions
readonly req: PayloadRequest
} & ServerProps
export type DocumentTabClientProps = {
@@ -60,9 +62,13 @@ export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerP
export type DocumentTabCondition = (args: {
collectionConfig: SanitizedCollectionConfig
/**
* @deprecated: Use `req.payload.config` instead. This will be removed in v4.
*/
config: SanitizedConfig
globalConfig: SanitizedGlobalConfig
permissions: SanitizedPermissions
req: PayloadRequest
}) => boolean
// Everything is optional because we merge in the defaults