feat: added support for conditional tabs (#8720)

Adds support for conditional tabs.

You can now add a `condition` function like other fields to each
individual tab's admin config like so:

```ts
{
  name: 'contentTab',
  admin: {
    condition: (data) => Boolean(data?.enableTab)
  }
}
```

This will toggle the individual tab's visibility in the document listing

### Example


https://github.com/user-attachments/assets/45cf9cfd-eaed-4dfe-8a32-1992385fd05c

This is an updated PR from
https://github.com/payloadcms/payload/pull/8406 thanks to @willviles

---------

Co-authored-by: Will Viles <will@willviles.com>
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
This commit is contained in:
Paul
2025-03-13 13:32:53 +00:00
committed by GitHub
parent e8064a3a0c
commit 878dc54579
12 changed files with 323 additions and 63 deletions

View File

@@ -1,4 +1,4 @@
import type { FieldWithSubFields, TabsField } from 'payload'
import type { FieldWithSubFields, Tab, TabsField } from 'payload'
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/shared'
@@ -17,7 +17,7 @@ export const recursivelyBuildNestedPaths = ({ field, nestedFieldName2, parentNam
if (field.type === 'tabs') {
// if the tab has a name, treat it as a group
// otherwise, treat it as a row
return field.tabs.reduce((tabSchema, tab: any) => {
return (field.tabs as Tab[]).reduce((tabSchema, tab: any) => {
tabSchema.push(
...recursivelyBuildNestedPaths({
field: {

View File

@@ -25,8 +25,11 @@ import type {
} from '../types.js'
export type ClientTab =
| ({ fields: ClientField[]; readonly path?: string } & Omit<NamedTab, 'fields'>)
| ({ fields: ClientField[] } & Omit<UnnamedTab, 'fields'>)
| ({ fields: ClientField[]; passesCondition?: boolean; readonly path?: string } & Omit<
NamedTab,
'fields'
>)
| ({ fields: ClientField[]; passesCondition?: boolean } & Omit<UnnamedTab, 'fields'>)
type TabsFieldBaseClientProps = FieldPaths

View File

@@ -1,5 +1,6 @@
// @ts-strict-ignore
import { deepMergeSimple } from '@payloadcms/translations/utilities'
import { v4 as uuid } from 'uuid'
import type {
CollectionConfig,
@@ -288,6 +289,16 @@ export const sanitizeFields = async ({
tab.label = toWords(tab.name)
}
if (
'admin' in tab &&
tab.admin?.condition &&
typeof tab.admin.condition === 'function' &&
!tab.id
) {
// Always attach a UUID to tabs with a condition so there's no conflicts even if there are duplicate nested names
tab.id = tabHasName(tab) ? `${tab.name}_${uuid()}` : uuid()
}
tab.fields = await sanitizeFields({
config,
existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames,

View File

@@ -788,6 +788,7 @@ type TabBase = {
*/
description?: LabelFunction | StaticDescription
fields: Field[]
id?: string
interfaceName?: string
saveToJWT?: boolean | string
} & Omit<FieldBase, 'required' | 'validate'>
@@ -819,11 +820,11 @@ export type UnnamedTab = {
} & Omit<TabBase, 'name' | 'virtual'>
export type Tab = NamedTab | UnnamedTab
export type TabsField = {
admin?: Omit<Admin, 'description'>
tabs: Tab[]
type: 'tabs'
} & {
tabs: Tab[]
} & Omit<FieldBase, 'admin' | 'localized' | 'name' | 'saveToJWT' | 'virtual'>
export type TabsFieldClient = {

View File

@@ -38,6 +38,10 @@
}
}
&--hidden {
display: none;
}
&--active {
opacity: 1 !important;

View File

@@ -14,13 +14,20 @@ import './index.scss'
const baseClass = 'tabs-field__tab-button'
type TabProps = {
readonly hidden?: boolean
readonly isActive?: boolean
readonly parentPath: string
readonly setIsActive: () => void
readonly tab: ClientTab
}
export const TabComponent: React.FC<TabProps> = ({ isActive, parentPath, setIsActive, tab }) => {
export const TabComponent: React.FC<TabProps> = ({
hidden,
isActive,
parentPath,
setIsActive,
tab,
}) => {
const { i18n } = useTranslation()
const [errorCount, setErrorCount] = useState(undefined)
@@ -40,6 +47,7 @@ export const TabComponent: React.FC<TabProps> = ({ isActive, parentPath, setIsAc
baseClass,
fieldHasErrors && `${baseClass}--has-error`,
isActive && `${baseClass}--active`,
hidden && `${baseClass}--hidden`,
]
.filter(Boolean)
.join(' ')}

View File

@@ -6,6 +6,10 @@
margin-left: calc(var(--gutter-h) * -1);
margin-right: calc(var(--gutter-h) * -1);
&--hidden {
display: none;
}
&__content-wrap {
padding-left: var(--gutter-h);
padding-right: var(--gutter-h);
@@ -50,6 +54,10 @@
}
}
&__tab--hidden {
display: none;
}
&__description {
margin-bottom: calc(var(--base) / 2);
}

View File

@@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import { useCollapsible } from '../../elements/Collapsible/provider.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { useFormFields } from '../../forms/Form/index.js'
import { RenderFields } from '../../forms/RenderFields/index.js'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
@@ -22,9 +23,9 @@ import { usePreferences } from '../../providers/Preferences/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { FieldDescription } from '../FieldDescription/index.js'
import { fieldBaseClass } from '../shared/index.js'
import './index.scss'
import { TabsProvider } from './provider.js'
import { TabComponent } from './Tab/index.js'
import './index.scss'
const baseClass = 'tabs-field'
@@ -60,45 +61,52 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
const { preferencesKey } = useDocumentInfo()
const { i18n } = useTranslation()
const { isWithinCollapsible } = useCollapsible()
const [activeTabIndex, setActiveTabIndex] = useState<number>(0)
const tabStates = useFormFields(([fields]) => {
return tabs.map((tab, index) => {
const id = tab?.id
return {
index,
passesCondition: fields?.[id]?.passesCondition ?? true,
tab,
}
})
})
const [activeTabIndex, setActiveTabIndex] = useState<number>(
() => tabStates.filter(({ passesCondition }) => passesCondition)?.[0]?.index ?? 0,
)
const tabsPrefKey = `tabs-${indexPath}`
const [activeTabPath, setActiveTabPath] = useState<string>(() =>
generateTabPath({ activeTabConfig: tabs[activeTabIndex], path: parentPath }),
)
const activePathChildrenPath = tabHasName(tabs[activeTabIndex]) ? activeTabPath : parentPath
const [activeTabSchemaPath, setActiveTabSchemaPath] = useState<string>(() =>
generateTabPath({ activeTabConfig: tabs[0], path: parentSchemaPath }),
)
const activePathChildrenPath = tabHasName(tabs[activeTabIndex]) ? activeTabPath : parentPath
const activeTabInfo = tabStates[activeTabIndex]
const activeTabConfig = activeTabInfo?.tab
const activePathSchemaChildrenPath = tabHasName(tabs[activeTabIndex])
? activeTabSchemaPath
: parentSchemaPath
useEffect(() => {
if (preferencesKey) {
const getInitialPref = async () => {
const existingPreferences: DocumentPreferences = await getPreference(preferencesKey)
const initialIndex = path
? existingPreferences?.fields?.[path]?.tabIndex
: existingPreferences?.fields?.[tabsPrefKey]?.tabIndex
const activeTabDescription = activeTabConfig.admin?.description ?? activeTabConfig.description
const newIndex = initialIndex || 0
setActiveTabIndex(newIndex)
const activeTabStaticDescription =
typeof activeTabDescription === 'function'
? activeTabDescription({ t: i18n.t })
: activeTabDescription
setActiveTabPath(generateTabPath({ activeTabConfig: tabs[newIndex], path: parentPath }))
setActiveTabSchemaPath(
generateTabPath({ activeTabConfig: tabs[newIndex], path: parentSchemaPath }),
)
}
void getInitialPref()
}
}, [path, getPreference, preferencesKey, tabsPrefKey, tabs, parentPath, parentSchemaPath])
const hasVisibleTabs = tabStates.some(({ passesCondition }) => passesCondition)
const handleTabChange = useCallback(
async (incomingTabIndex: number): Promise<void> => {
setActiveTabIndex(incomingTabIndex)
setActiveTabPath(
generateTabPath({ activeTabConfig: tabs[incomingTabIndex], path: parentPath }),
)
@@ -145,14 +153,34 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
],
)
const activeTabConfig = tabs[activeTabIndex]
useEffect(() => {
if (preferencesKey) {
const getInitialPref = async () => {
const existingPreferences: DocumentPreferences = await getPreference(preferencesKey)
const initialIndex = path
? existingPreferences?.fields?.[path]?.tabIndex
: existingPreferences?.fields?.[tabsPrefKey]?.tabIndex
const activeTabDescription = activeTabConfig.admin?.description ?? activeTabConfig.description
const newIndex = initialIndex || 0
setActiveTabIndex(newIndex)
const activeTabStaticDescription =
typeof activeTabDescription === 'function'
? activeTabDescription({ t: i18n.t })
: activeTabDescription
setActiveTabPath(generateTabPath({ activeTabConfig: tabs[newIndex], path: parentPath }))
setActiveTabSchemaPath(
generateTabPath({ activeTabConfig: tabs[newIndex], path: parentSchemaPath }),
)
}
void getInitialPref()
}
}, [path, getPreference, preferencesKey, tabsPrefKey, tabs, parentPath, parentSchemaPath])
useEffect(() => {
if (activeTabInfo?.passesCondition === false) {
const nextTab = tabStates.find(({ passesCondition }) => passesCondition)
if (nextTab) {
void handleTabChange(nextTab.index)
}
}
}, [activeTabInfo, tabStates, handleTabChange])
return (
<div
@@ -161,6 +189,7 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
className,
baseClass,
isWithinCollapsible && `${baseClass}--within-collapsible`,
!hasVisibleTabs && `${baseClass}--hidden`,
]
.filter(Boolean)
.join(' ')}
@@ -168,31 +197,31 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
<TabsProvider>
<div className={`${baseClass}__tabs-wrap`}>
<div className={`${baseClass}__tabs`}>
{tabs.map((tab, tabIndex) => {
return (
<TabComponent
isActive={activeTabIndex === tabIndex}
key={tabIndex}
parentPath={path}
setIsActive={() => {
void handleTabChange(tabIndex)
}}
tab={tab}
/>
)
})}
{tabStates.map(({ index, passesCondition, tab }) => (
<TabComponent
hidden={!passesCondition}
isActive={activeTabIndex === index}
key={index}
parentPath={path}
setIsActive={() => {
void handleTabChange(index)
}}
tab={tab}
/>
))}
</div>
</div>
<div className={`${baseClass}__content-wrap`}>
{activeTabConfig && (
<ActiveTabContent
<TabContent
description={activeTabStaticDescription}
fields={activeTabConfig.fields}
forceRender={forceRender}
hidden={false}
parentIndexPath={
tabHasName(activeTabConfig)
? ''
: `${indexPath ? indexPath + '-' : ''}` + String(activeTabIndex)
: `${indexPath ? indexPath + '-' : ''}` + String(activeTabInfo.index)
}
parentPath={activePathChildrenPath}
parentSchemaPath={activePathSchemaChildrenPath}
@@ -218,21 +247,23 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
export const TabsField = withCondition(TabsFieldComponent)
type ActiveTabProps = {
description: StaticDescription
fields: ClientField[]
forceRender?: boolean
label?: string
parentIndexPath: string
parentPath: string
parentSchemaPath: string
path: string
permissions: SanitizedFieldPermissions
readOnly: boolean
readonly description: StaticDescription
readonly fields: ClientField[]
readonly forceRender?: boolean
readonly hidden: boolean
readonly label?: string
readonly parentIndexPath: string
readonly parentPath: string
readonly parentSchemaPath: string
readonly path: string
readonly permissions: SanitizedFieldPermissions
readonly readOnly: boolean
}
function ActiveTabContent({
function TabContent({
description,
fields,
forceRender,
hidden,
label,
parentIndexPath,
parentPath,
@@ -253,6 +284,7 @@ function ActiveTabContent({
return (
<div
className={[
hidden && `${baseClass}__tab--hidden`,
`${baseClass}__tab`,
label && `${baseClass}__tabConfigLabel-${toKebabCase(getTranslation(label, i18n))}`,
]

View File

@@ -750,6 +750,26 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
childPermissions = parentPermissions
}
const pathSegments = path ? path.split('.') : []
// If passesCondition is false then this should always result to false
// If the tab has no admin.condition provided then fallback to passesCondition and let that decide the result
let tabPassesCondition = passesCondition
if (passesCondition && typeof tab.admin?.condition === 'function') {
tabPassesCondition = tab.admin.condition(fullData, data, {
blockData,
path: pathSegments,
user: req.user,
})
}
if (tab?.id) {
state[tab.id] = {
passesCondition: tabPassesCondition,
}
}
return iterateFields({
id,
addErrorPathToParent: addErrorPathToParentArg,
@@ -767,7 +787,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
omitParents,
operation,
parentIndexPath: isNamedTab ? '' : tabIndexPath,
parentPassesCondition: passesCondition,
parentPassesCondition: tabPassesCondition,
parentPath: isNamedTab ? tabPath : parentPath,
parentSchemaPath: isNamedTab ? tabSchemaPath : parentSchemaPath,
permissions: childPermissions,

View File

@@ -126,12 +126,69 @@ describe('Tabs', () => {
test('should render array data within named tabs', async () => {
await navigateToDoc(page, url)
await switchTab(page, '.tabs-field__tab-button:nth-child(5)')
await switchTab(page, '.tabs-field__tab-button:has-text("Tab with Name")')
await expect(page.locator('#field-tab__array__0__text')).toHaveValue(
"Hello, I'm the first row, in a named tab",
)
})
test('should render conditional tab when checkbox is toggled', async () => {
await navigateToDoc(page, url)
const conditionalTabSelector = '.tabs-field__tab-button:text-is("Conditional Tab")'
const button = page.locator(conditionalTabSelector)
await expect(
async () => await expect(page.locator(conditionalTabSelector)).toHaveClass(/--hidden/),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
const checkboxSelector = `input#field-conditionalTabVisible`
await page.locator(checkboxSelector).check()
await expect(page.locator(checkboxSelector)).toBeChecked()
await expect(
async () => await expect(page.locator(conditionalTabSelector)).not.toHaveClass(/--hidden/),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await switchTab(page, conditionalTabSelector)
await expect(
page.locator('label[for="field-conditionalTab__conditionalTabField"]'),
).toHaveCount(1)
})
test('should hide nested conditional tab when checkbox is toggled', async () => {
await navigateToDoc(page, url)
// Show the conditional tab
const conditionalTabSelector = '.tabs-field__tab-button:text-is("Conditional Tab")'
const checkboxSelector = `input#field-conditionalTabVisible`
await page.locator(checkboxSelector).check()
await switchTab(page, conditionalTabSelector)
// Now assert on the nested conditional tab
const nestedConditionalTabSelector = '.tabs-field__tab-button:text-is("Nested Conditional Tab")'
await expect(
async () =>
await expect(page.locator(nestedConditionalTabSelector)).not.toHaveClass(/--hidden/),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
const nestedCheckboxSelector = `input#field-conditionalTab__nestedConditionalTabVisible`
await page.locator(nestedCheckboxSelector).uncheck()
await expect(
async () => await expect(page.locator(nestedConditionalTabSelector)).toHaveClass(/--hidden/),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('should save preferences for tab order', async () => {
await page.goto(url.list)
@@ -139,7 +196,7 @@ describe('Tabs', () => {
const href = await firstItem.getAttribute('href')
await firstItem.click()
const regex = new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const regex = new RegExp(href!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
await page.waitForURL(regex)

View File

@@ -21,9 +21,103 @@ const TabsFields: CollectionConfig = {
'This should not collapse despite there being many tabs pushing the main fields open.',
},
},
{
name: 'conditionalTabVisible',
type: 'checkbox',
label: 'Toggle Conditional Tab',
admin: {
position: 'sidebar',
description:
'When active, the conditional tab should be visible. When inactive, it should be hidden.',
},
},
{
type: 'tabs',
tabs: [
{
name: 'conditionalTab',
label: 'Conditional Tab',
description: 'This tab should only be visible when the conditional field is checked.',
fields: [
{
name: 'conditionalTabField',
type: 'text',
label: 'Conditional Tab Field',
defaultValue:
'This field should only be visible when the conditional tab is visible.',
},
{
name: 'nestedConditionalTabVisible',
type: 'checkbox',
label: 'Toggle Nested Conditional Tab',
defaultValue: true,
admin: {
description:
'When active, the nested conditional tab should be visible. When inactive, it should be hidden.',
},
},
{
type: 'group',
name: 'conditionalTabGroup',
fields: [
{
type: 'text',
name: 'conditionalTabGroupTitle',
},
{
type: 'tabs',
tabs: [
{
// duplicate name as above, should not conflict with tab IDs in form-state, if it does then tests will fail
name: 'conditionalTab',
label: 'Duplicate conditional tab',
fields: [],
admin: {
condition: ({ conditionalTab }) =>
!!conditionalTab?.nestedConditionalTabVisible,
},
},
],
},
],
},
{
type: 'tabs',
tabs: [
{
label: 'Nested Unconditional Tab',
description: 'Description for a nested unconditional tab',
fields: [
{
name: 'nestedUnconditionalTabInput',
type: 'text',
},
],
},
{
label: 'Nested Conditional Tab',
description: 'Here is a description for a nested conditional tab',
fields: [
{
name: 'nestedConditionalTabInput',
type: 'textarea',
defaultValue:
'This field should only be visible when the nested conditional tab is visible.',
},
],
admin: {
condition: ({ conditionalTab }) =>
!!conditionalTab?.nestedConditionalTabVisible,
},
},
],
},
],
admin: {
condition: ({ conditionalTabVisible }) => !!conditionalTabVisible,
},
},
{
label: 'Tab with Array',
description: 'This tab has an array.',

View File

@@ -1831,6 +1831,19 @@ export interface TabsField {
* This should not collapse despite there being many tabs pushing the main fields open.
*/
sidebarField?: string | null;
/**
* When active, the conditional tab should be visible. When inactive, it should be hidden.
*/
conditionalTabVisible?: boolean | null;
conditionalTab?: {
conditionalTabField?: string | null;
/**
* When active, the nested conditional tab should be visible. When inactive, it should be hidden.
*/
nestedConditionalTabVisible?: boolean | null;
nestedUnconditionalTabInput?: string | null;
nestedConditionalTabInput?: string | null;
};
array: {
text: string;
id?: string | null;
@@ -3452,6 +3465,15 @@ export interface TabsFields2Select<T extends boolean = true> {
*/
export interface TabsFieldsSelect<T extends boolean = true> {
sidebarField?: T;
conditionalTabVisible?: T;
conditionalTab?:
| T
| {
conditionalTabField?: T;
nestedConditionalTabVisible?: T;
nestedUnconditionalTabInput?: T;
nestedConditionalTabInput?: T;
};
array?:
| T
| {