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:
@@ -1,4 +1,4 @@
|
|||||||
import type { FieldWithSubFields, TabsField } from 'payload'
|
import type { FieldWithSubFields, Tab, TabsField } from 'payload'
|
||||||
|
|
||||||
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/shared'
|
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/shared'
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export const recursivelyBuildNestedPaths = ({ field, nestedFieldName2, parentNam
|
|||||||
if (field.type === 'tabs') {
|
if (field.type === 'tabs') {
|
||||||
// if the tab has a name, treat it as a group
|
// if the tab has a name, treat it as a group
|
||||||
// otherwise, treat it as a row
|
// otherwise, treat it as a row
|
||||||
return field.tabs.reduce((tabSchema, tab: any) => {
|
return (field.tabs as Tab[]).reduce((tabSchema, tab: any) => {
|
||||||
tabSchema.push(
|
tabSchema.push(
|
||||||
...recursivelyBuildNestedPaths({
|
...recursivelyBuildNestedPaths({
|
||||||
field: {
|
field: {
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ import type {
|
|||||||
} from '../types.js'
|
} from '../types.js'
|
||||||
|
|
||||||
export type ClientTab =
|
export type ClientTab =
|
||||||
| ({ fields: ClientField[]; readonly path?: string } & Omit<NamedTab, 'fields'>)
|
| ({ fields: ClientField[]; passesCondition?: boolean; readonly path?: string } & Omit<
|
||||||
| ({ fields: ClientField[] } & Omit<UnnamedTab, 'fields'>)
|
NamedTab,
|
||||||
|
'fields'
|
||||||
|
>)
|
||||||
|
| ({ fields: ClientField[]; passesCondition?: boolean } & Omit<UnnamedTab, 'fields'>)
|
||||||
|
|
||||||
type TabsFieldBaseClientProps = FieldPaths
|
type TabsFieldBaseClientProps = FieldPaths
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { deepMergeSimple } from '@payloadcms/translations/utilities'
|
import { deepMergeSimple } from '@payloadcms/translations/utilities'
|
||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CollectionConfig,
|
CollectionConfig,
|
||||||
@@ -288,6 +289,16 @@ export const sanitizeFields = async ({
|
|||||||
tab.label = toWords(tab.name)
|
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({
|
tab.fields = await sanitizeFields({
|
||||||
config,
|
config,
|
||||||
existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames,
|
existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames,
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ type TabBase = {
|
|||||||
*/
|
*/
|
||||||
description?: LabelFunction | StaticDescription
|
description?: LabelFunction | StaticDescription
|
||||||
fields: Field[]
|
fields: Field[]
|
||||||
|
id?: string
|
||||||
interfaceName?: string
|
interfaceName?: string
|
||||||
saveToJWT?: boolean | string
|
saveToJWT?: boolean | string
|
||||||
} & Omit<FieldBase, 'required' | 'validate'>
|
} & Omit<FieldBase, 'required' | 'validate'>
|
||||||
@@ -819,11 +820,11 @@ export type UnnamedTab = {
|
|||||||
} & Omit<TabBase, 'name' | 'virtual'>
|
} & Omit<TabBase, 'name' | 'virtual'>
|
||||||
|
|
||||||
export type Tab = NamedTab | UnnamedTab
|
export type Tab = NamedTab | UnnamedTab
|
||||||
|
|
||||||
export type TabsField = {
|
export type TabsField = {
|
||||||
admin?: Omit<Admin, 'description'>
|
admin?: Omit<Admin, 'description'>
|
||||||
tabs: Tab[]
|
|
||||||
type: 'tabs'
|
type: 'tabs'
|
||||||
|
} & {
|
||||||
|
tabs: Tab[]
|
||||||
} & Omit<FieldBase, 'admin' | 'localized' | 'name' | 'saveToJWT' | 'virtual'>
|
} & Omit<FieldBase, 'admin' | 'localized' | 'name' | 'saveToJWT' | 'virtual'>
|
||||||
|
|
||||||
export type TabsFieldClient = {
|
export type TabsFieldClient = {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,20 @@ import './index.scss'
|
|||||||
const baseClass = 'tabs-field__tab-button'
|
const baseClass = 'tabs-field__tab-button'
|
||||||
|
|
||||||
type TabProps = {
|
type TabProps = {
|
||||||
|
readonly hidden?: boolean
|
||||||
readonly isActive?: boolean
|
readonly isActive?: boolean
|
||||||
readonly parentPath: string
|
readonly parentPath: string
|
||||||
readonly setIsActive: () => void
|
readonly setIsActive: () => void
|
||||||
readonly tab: ClientTab
|
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 { i18n } = useTranslation()
|
||||||
const [errorCount, setErrorCount] = useState(undefined)
|
const [errorCount, setErrorCount] = useState(undefined)
|
||||||
|
|
||||||
@@ -40,6 +47,7 @@ export const TabComponent: React.FC<TabProps> = ({ isActive, parentPath, setIsAc
|
|||||||
baseClass,
|
baseClass,
|
||||||
fieldHasErrors && `${baseClass}--has-error`,
|
fieldHasErrors && `${baseClass}--has-error`,
|
||||||
isActive && `${baseClass}--active`,
|
isActive && `${baseClass}--active`,
|
||||||
|
hidden && `${baseClass}--hidden`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
margin-left: calc(var(--gutter-h) * -1);
|
margin-left: calc(var(--gutter-h) * -1);
|
||||||
margin-right: calc(var(--gutter-h) * -1);
|
margin-right: calc(var(--gutter-h) * -1);
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&__content-wrap {
|
&__content-wrap {
|
||||||
padding-left: var(--gutter-h);
|
padding-left: var(--gutter-h);
|
||||||
padding-right: var(--gutter-h);
|
padding-right: var(--gutter-h);
|
||||||
@@ -50,6 +54,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__tab--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&__description {
|
&__description {
|
||||||
margin-bottom: calc(var(--base) / 2);
|
margin-bottom: calc(var(--base) / 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useState } from 'react'
|
|||||||
|
|
||||||
import { useCollapsible } from '../../elements/Collapsible/provider.js'
|
import { useCollapsible } from '../../elements/Collapsible/provider.js'
|
||||||
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
|
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
|
||||||
|
import { useFormFields } from '../../forms/Form/index.js'
|
||||||
import { RenderFields } from '../../forms/RenderFields/index.js'
|
import { RenderFields } from '../../forms/RenderFields/index.js'
|
||||||
import { useField } from '../../forms/useField/index.js'
|
import { useField } from '../../forms/useField/index.js'
|
||||||
import { withCondition } from '../../forms/withCondition/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 { useTranslation } from '../../providers/Translation/index.js'
|
||||||
import { FieldDescription } from '../FieldDescription/index.js'
|
import { FieldDescription } from '../FieldDescription/index.js'
|
||||||
import { fieldBaseClass } from '../shared/index.js'
|
import { fieldBaseClass } from '../shared/index.js'
|
||||||
import './index.scss'
|
|
||||||
import { TabsProvider } from './provider.js'
|
import { TabsProvider } from './provider.js'
|
||||||
import { TabComponent } from './Tab/index.js'
|
import { TabComponent } from './Tab/index.js'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
const baseClass = 'tabs-field'
|
const baseClass = 'tabs-field'
|
||||||
|
|
||||||
@@ -60,45 +61,52 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
|
|||||||
const { preferencesKey } = useDocumentInfo()
|
const { preferencesKey } = useDocumentInfo()
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
const { isWithinCollapsible } = useCollapsible()
|
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 tabsPrefKey = `tabs-${indexPath}`
|
||||||
const [activeTabPath, setActiveTabPath] = useState<string>(() =>
|
const [activeTabPath, setActiveTabPath] = useState<string>(() =>
|
||||||
generateTabPath({ activeTabConfig: tabs[activeTabIndex], path: parentPath }),
|
generateTabPath({ activeTabConfig: tabs[activeTabIndex], path: parentPath }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const activePathChildrenPath = tabHasName(tabs[activeTabIndex]) ? activeTabPath : parentPath
|
|
||||||
|
|
||||||
const [activeTabSchemaPath, setActiveTabSchemaPath] = useState<string>(() =>
|
const [activeTabSchemaPath, setActiveTabSchemaPath] = useState<string>(() =>
|
||||||
generateTabPath({ activeTabConfig: tabs[0], path: parentSchemaPath }),
|
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])
|
const activePathSchemaChildrenPath = tabHasName(tabs[activeTabIndex])
|
||||||
? activeTabSchemaPath
|
? activeTabSchemaPath
|
||||||
: parentSchemaPath
|
: parentSchemaPath
|
||||||
|
|
||||||
useEffect(() => {
|
const activeTabDescription = activeTabConfig.admin?.description ?? activeTabConfig.description
|
||||||
if (preferencesKey) {
|
|
||||||
const getInitialPref = async () => {
|
|
||||||
const existingPreferences: DocumentPreferences = await getPreference(preferencesKey)
|
|
||||||
const initialIndex = path
|
|
||||||
? existingPreferences?.fields?.[path]?.tabIndex
|
|
||||||
: existingPreferences?.fields?.[tabsPrefKey]?.tabIndex
|
|
||||||
|
|
||||||
const newIndex = initialIndex || 0
|
const activeTabStaticDescription =
|
||||||
setActiveTabIndex(newIndex)
|
typeof activeTabDescription === 'function'
|
||||||
|
? activeTabDescription({ t: i18n.t })
|
||||||
|
: activeTabDescription
|
||||||
|
|
||||||
setActiveTabPath(generateTabPath({ activeTabConfig: tabs[newIndex], path: parentPath }))
|
const hasVisibleTabs = tabStates.some(({ passesCondition }) => passesCondition)
|
||||||
setActiveTabSchemaPath(
|
|
||||||
generateTabPath({ activeTabConfig: tabs[newIndex], path: parentSchemaPath }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
void getInitialPref()
|
|
||||||
}
|
|
||||||
}, [path, getPreference, preferencesKey, tabsPrefKey, tabs, parentPath, parentSchemaPath])
|
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
async (incomingTabIndex: number): Promise<void> => {
|
async (incomingTabIndex: number): Promise<void> => {
|
||||||
setActiveTabIndex(incomingTabIndex)
|
setActiveTabIndex(incomingTabIndex)
|
||||||
|
|
||||||
setActiveTabPath(
|
setActiveTabPath(
|
||||||
generateTabPath({ activeTabConfig: tabs[incomingTabIndex], path: parentPath }),
|
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 =
|
setActiveTabPath(generateTabPath({ activeTabConfig: tabs[newIndex], path: parentPath }))
|
||||||
typeof activeTabDescription === 'function'
|
setActiveTabSchemaPath(
|
||||||
? activeTabDescription({ t: i18n.t })
|
generateTabPath({ activeTabConfig: tabs[newIndex], path: parentSchemaPath }),
|
||||||
: activeTabDescription
|
)
|
||||||
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -161,6 +189,7 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
|
|||||||
className,
|
className,
|
||||||
baseClass,
|
baseClass,
|
||||||
isWithinCollapsible && `${baseClass}--within-collapsible`,
|
isWithinCollapsible && `${baseClass}--within-collapsible`,
|
||||||
|
!hasVisibleTabs && `${baseClass}--hidden`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
@@ -168,31 +197,31 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
|
|||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<div className={`${baseClass}__tabs-wrap`}>
|
<div className={`${baseClass}__tabs-wrap`}>
|
||||||
<div className={`${baseClass}__tabs`}>
|
<div className={`${baseClass}__tabs`}>
|
||||||
{tabs.map((tab, tabIndex) => {
|
{tabStates.map(({ index, passesCondition, tab }) => (
|
||||||
return (
|
<TabComponent
|
||||||
<TabComponent
|
hidden={!passesCondition}
|
||||||
isActive={activeTabIndex === tabIndex}
|
isActive={activeTabIndex === index}
|
||||||
key={tabIndex}
|
key={index}
|
||||||
parentPath={path}
|
parentPath={path}
|
||||||
setIsActive={() => {
|
setIsActive={() => {
|
||||||
void handleTabChange(tabIndex)
|
void handleTabChange(index)
|
||||||
}}
|
}}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${baseClass}__content-wrap`}>
|
<div className={`${baseClass}__content-wrap`}>
|
||||||
{activeTabConfig && (
|
{activeTabConfig && (
|
||||||
<ActiveTabContent
|
<TabContent
|
||||||
description={activeTabStaticDescription}
|
description={activeTabStaticDescription}
|
||||||
fields={activeTabConfig.fields}
|
fields={activeTabConfig.fields}
|
||||||
forceRender={forceRender}
|
forceRender={forceRender}
|
||||||
|
hidden={false}
|
||||||
parentIndexPath={
|
parentIndexPath={
|
||||||
tabHasName(activeTabConfig)
|
tabHasName(activeTabConfig)
|
||||||
? ''
|
? ''
|
||||||
: `${indexPath ? indexPath + '-' : ''}` + String(activeTabIndex)
|
: `${indexPath ? indexPath + '-' : ''}` + String(activeTabInfo.index)
|
||||||
}
|
}
|
||||||
parentPath={activePathChildrenPath}
|
parentPath={activePathChildrenPath}
|
||||||
parentSchemaPath={activePathSchemaChildrenPath}
|
parentSchemaPath={activePathSchemaChildrenPath}
|
||||||
@@ -218,21 +247,23 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
|
|||||||
export const TabsField = withCondition(TabsFieldComponent)
|
export const TabsField = withCondition(TabsFieldComponent)
|
||||||
|
|
||||||
type ActiveTabProps = {
|
type ActiveTabProps = {
|
||||||
description: StaticDescription
|
readonly description: StaticDescription
|
||||||
fields: ClientField[]
|
readonly fields: ClientField[]
|
||||||
forceRender?: boolean
|
readonly forceRender?: boolean
|
||||||
label?: string
|
readonly hidden: boolean
|
||||||
parentIndexPath: string
|
readonly label?: string
|
||||||
parentPath: string
|
readonly parentIndexPath: string
|
||||||
parentSchemaPath: string
|
readonly parentPath: string
|
||||||
path: string
|
readonly parentSchemaPath: string
|
||||||
permissions: SanitizedFieldPermissions
|
readonly path: string
|
||||||
readOnly: boolean
|
readonly permissions: SanitizedFieldPermissions
|
||||||
|
readonly readOnly: boolean
|
||||||
}
|
}
|
||||||
function ActiveTabContent({
|
function TabContent({
|
||||||
description,
|
description,
|
||||||
fields,
|
fields,
|
||||||
forceRender,
|
forceRender,
|
||||||
|
hidden,
|
||||||
label,
|
label,
|
||||||
parentIndexPath,
|
parentIndexPath,
|
||||||
parentPath,
|
parentPath,
|
||||||
@@ -253,6 +284,7 @@ function ActiveTabContent({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
|
hidden && `${baseClass}__tab--hidden`,
|
||||||
`${baseClass}__tab`,
|
`${baseClass}__tab`,
|
||||||
label && `${baseClass}__tabConfigLabel-${toKebabCase(getTranslation(label, i18n))}`,
|
label && `${baseClass}__tabConfigLabel-${toKebabCase(getTranslation(label, i18n))}`,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -750,6 +750,26 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
|||||||
childPermissions = parentPermissions
|
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({
|
return iterateFields({
|
||||||
id,
|
id,
|
||||||
addErrorPathToParent: addErrorPathToParentArg,
|
addErrorPathToParent: addErrorPathToParentArg,
|
||||||
@@ -767,7 +787,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
|||||||
omitParents,
|
omitParents,
|
||||||
operation,
|
operation,
|
||||||
parentIndexPath: isNamedTab ? '' : tabIndexPath,
|
parentIndexPath: isNamedTab ? '' : tabIndexPath,
|
||||||
parentPassesCondition: passesCondition,
|
parentPassesCondition: tabPassesCondition,
|
||||||
parentPath: isNamedTab ? tabPath : parentPath,
|
parentPath: isNamedTab ? tabPath : parentPath,
|
||||||
parentSchemaPath: isNamedTab ? tabSchemaPath : parentSchemaPath,
|
parentSchemaPath: isNamedTab ? tabSchemaPath : parentSchemaPath,
|
||||||
permissions: childPermissions,
|
permissions: childPermissions,
|
||||||
|
|||||||
@@ -126,12 +126,69 @@ describe('Tabs', () => {
|
|||||||
|
|
||||||
test('should render array data within named tabs', async () => {
|
test('should render array data within named tabs', async () => {
|
||||||
await navigateToDoc(page, url)
|
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(
|
await expect(page.locator('#field-tab__array__0__text')).toHaveValue(
|
||||||
"Hello, I'm the first row, in a named tab",
|
"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 () => {
|
test('should save preferences for tab order', async () => {
|
||||||
await page.goto(url.list)
|
await page.goto(url.list)
|
||||||
|
|
||||||
@@ -139,7 +196,7 @@ describe('Tabs', () => {
|
|||||||
const href = await firstItem.getAttribute('href')
|
const href = await firstItem.getAttribute('href')
|
||||||
await firstItem.click()
|
await firstItem.click()
|
||||||
|
|
||||||
const regex = new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
const regex = new RegExp(href!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||||
|
|
||||||
await page.waitForURL(regex)
|
await page.waitForURL(regex)
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,103 @@ const TabsFields: CollectionConfig = {
|
|||||||
'This should not collapse despite there being many tabs pushing the main fields open.',
|
'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',
|
type: 'tabs',
|
||||||
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',
|
label: 'Tab with Array',
|
||||||
description: 'This tab has an array.',
|
description: 'This tab has an array.',
|
||||||
|
|||||||
@@ -1831,6 +1831,19 @@ export interface TabsField {
|
|||||||
* This should not collapse despite there being many tabs pushing the main fields open.
|
* This should not collapse despite there being many tabs pushing the main fields open.
|
||||||
*/
|
*/
|
||||||
sidebarField?: string | null;
|
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: {
|
array: {
|
||||||
text: string;
|
text: string;
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
@@ -3452,6 +3465,15 @@ export interface TabsFields2Select<T extends boolean = true> {
|
|||||||
*/
|
*/
|
||||||
export interface TabsFieldsSelect<T extends boolean = true> {
|
export interface TabsFieldsSelect<T extends boolean = true> {
|
||||||
sidebarField?: T;
|
sidebarField?: T;
|
||||||
|
conditionalTabVisible?: T;
|
||||||
|
conditionalTab?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
conditionalTabField?: T;
|
||||||
|
nestedConditionalTabVisible?: T;
|
||||||
|
nestedUnconditionalTabInput?: T;
|
||||||
|
nestedConditionalTabInput?: T;
|
||||||
|
};
|
||||||
array?:
|
array?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|||||||
Reference in New Issue
Block a user