diff --git a/packages/drizzle/src/utilities/validateExistingBlockIsIdentical.ts b/packages/drizzle/src/utilities/validateExistingBlockIsIdentical.ts index 8a92876774c..7b0f7dd632f 100644 --- a/packages/drizzle/src/utilities/validateExistingBlockIsIdentical.ts +++ b/packages/drizzle/src/utilities/validateExistingBlockIsIdentical.ts @@ -1,4 +1,4 @@ -import type { Block, Field } from 'payload' +import type { Block, Field, Tab } from 'payload' import { InvalidConfiguration } from 'payload' import { fieldAffectsData, fieldHasSubFields, tabHasName } from 'payload/shared' @@ -33,7 +33,7 @@ const getFlattenedFieldNames = ( if (field.type === 'tabs') { return [ ...fieldsToUse, - ...field.tabs.reduce((tabFields, tab) => { + ...(field.tabs as Tab[]).reduce((tabFields, tab) => { fieldPrefix = 'name' in tab ? `${prefix}_${tab.name}` : prefix return [ ...tabFields, diff --git a/packages/graphql/src/schema/buildMutationInputType.ts b/packages/graphql/src/schema/buildMutationInputType.ts index f4243485f08..c4a224d169c 100644 --- a/packages/graphql/src/schema/buildMutationInputType.ts +++ b/packages/graphql/src/schema/buildMutationInputType.ts @@ -20,6 +20,7 @@ import type { SanitizedCollectionConfig, SanitizedConfig, SelectField, + Tab, TabsField, TextareaField, TextField, @@ -272,7 +273,7 @@ export function buildMutationInputType({ } }, tabs: (inputObjectTypeConfig: InputObjectTypeConfig, field: TabsField) => { - return field.tabs.reduce((acc, tab) => { + return (field.tabs as Tab[]).reduce((acc, tab) => { if (tabHasName(tab)) { const fullName = combineParentName(parentName, toWords(tab.name, true)) const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field) diff --git a/packages/graphql/src/schema/buildObjectType.ts b/packages/graphql/src/schema/buildObjectType.ts index 37cc866e447..409eb43f914 100644 --- a/packages/graphql/src/schema/buildObjectType.ts +++ b/packages/graphql/src/schema/buildObjectType.ts @@ -21,6 +21,7 @@ import type { RowField, SanitizedConfig, SelectField, + Tab, TabsField, TextareaField, TextField, @@ -617,7 +618,7 @@ export function buildObjectType({ } }, tabs: (objectTypeConfig: ObjectTypeConfig, field: TabsField) => - field.tabs.reduce((tabSchema, tab) => { + (field.tabs as Tab[]).reduce((tabSchema, tab) => { if (tabHasName(tab)) { const interfaceName = tab?.interfaceName || combineParentName(parentName, toWords(tab.name, true)) diff --git a/packages/graphql/src/schema/recursivelyBuildNestedPaths.ts b/packages/graphql/src/schema/recursivelyBuildNestedPaths.ts index 199b4ee5609..bcb8bd7b3be 100644 --- a/packages/graphql/src/schema/recursivelyBuildNestedPaths.ts +++ b/packages/graphql/src/schema/recursivelyBuildNestedPaths.ts @@ -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: { diff --git a/packages/payload/src/admin/fields/Tabs.ts b/packages/payload/src/admin/fields/Tabs.ts index 2cbb4bbd97c..c19d837fd72 100644 --- a/packages/payload/src/admin/fields/Tabs.ts +++ b/packages/payload/src/admin/fields/Tabs.ts @@ -23,8 +23,11 @@ import type { } from '../types.js' export type ClientTab = - | ({ fields: ClientField[]; readonly path?: string } & Omit) - | ({ fields: ClientField[] } & Omit) + | ({ fields: ClientField[]; passesCondition?: boolean; readonly path?: string } & Omit< + NamedTab, + 'fields' + >) + | ({ fields: ClientField[]; passesCondition?: boolean } & Omit) type TabsFieldBaseClientProps = FieldPaths & Pick diff --git a/packages/payload/src/errors/DuplicateTabsIds.ts b/packages/payload/src/errors/DuplicateTabsIds.ts new file mode 100644 index 00000000000..78bf7ff9a78 --- /dev/null +++ b/packages/payload/src/errors/DuplicateTabsIds.ts @@ -0,0 +1,7 @@ +import { APIError } from './APIError.js' + +export class DuplicateTabsIds extends APIError { + constructor(duplicates: string[]) { + super(`Collection tabs ids already in use: "${duplicates.join(', ')}"`) + } +} diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 6f03d734525..0c2065b9a20 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -1,4 +1,5 @@ import { deepMergeSimple } from '@payloadcms/translations/utilities' +import { v4 as uuid } from 'uuid' import type { CollectionConfig, SanitizedJoins } from '../../collections/config/types.js' import type { Config, SanitizedConfig } from '../../config/types.js' @@ -263,6 +264,15 @@ export const sanitizeFields = async ({ tab.label = toWords(tab.name) } + if ( + 'admin' in tab && + tab.admin?.condition && + typeof tab.admin.condition === 'function' && + !tab.id + ) { + tab.id = tabHasName(tab) ? tab.name : uuid() + } + tab.fields = await sanitizeFields({ config, existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames, diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 2f6b36ac53c..b2841228d06 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -692,11 +692,15 @@ export type CollapsibleFieldClient = { Pick type TabBase = { - description?: LabelFunction | StaticDescription + admin?: { + condition?: Condition + } + description?: Description fields: Field[] + id?: string interfaceName?: string saveToJWT?: boolean | string -} & Omit +} & Omit export type NamedTab = { /** Customize generated GraphQL and Typescript schema names. @@ -725,11 +729,11 @@ export type UnnamedTab = { } & Omit export type Tab = NamedTab | UnnamedTab - export type TabsField = { admin?: Omit - tabs: Tab[] type: 'tabs' +} & { + tabs: Tab[] } & Omit export type TabsFieldClient = { diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 3d38bb86041..24c5ef7b5d9 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -77,6 +77,7 @@ export const promise = async ({ const passesCondition = field.admin?.condition ? Boolean(field.admin.condition(data, siblingData, { user: req.user })) : true + let skipValidationFromHere = skipValidation || !passesCondition const { localization } = req.payload.config const defaultLocale = localization ? localization?.defaultLocale : 'en' diff --git a/packages/payload/src/utilities/getEntityPolicies.ts b/packages/payload/src/utilities/getEntityPolicies.ts index 1f77a970272..6d47a2802cf 100644 --- a/packages/payload/src/utilities/getEntityPolicies.ts +++ b/packages/payload/src/utilities/getEntityPolicies.ts @@ -1,7 +1,7 @@ import type { CollectionPermission, GlobalPermission } from '../auth/types.js' import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js' import type { Access } from '../config/types.js' -import type { Field, FieldAccess } from '../fields/config/types.js' +import type { Field, FieldAccess, Tab } from '../fields/config/types.js' import type { SanitizedGlobalConfig } from '../globals/config/types.js' import type { AllOperations, Document, PayloadRequest, Where } from '../types/index.js' @@ -207,7 +207,7 @@ export async function getEntityPolicies(args: T): Promise { + field.tabs.map(async (tab: Tab) => { if (tabHasName(tab)) { if (!mutablePolicies[tab.name]) { mutablePolicies[tab.name] = { diff --git a/packages/ui/src/fields/Tabs/index.tsx b/packages/ui/src/fields/Tabs/index.tsx index 9a6ee3cd97d..eac4c532bee 100644 --- a/packages/ui/src/fields/Tabs/index.tsx +++ b/packages/ui/src/fields/Tabs/index.tsx @@ -9,6 +9,7 @@ import type { } from 'payload' import { getTranslation } from '@payloadcms/translations' +import { useFormFields } from '@payloadcms/ui' import { tabHasName, toKebabCase } from 'payload/shared' import React, { useCallback, useEffect, useState } from 'react' @@ -21,8 +22,8 @@ import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' 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 { fieldBaseClass } from '../shared/index.js' import { TabsProvider } from './provider.js' import { TabComponent } from './Tab/index.js' @@ -60,7 +61,21 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => { const { preferencesKey } = useDocumentInfo() const { i18n } = useTranslation() const { isWithinCollapsible } = useCollapsible() - const [activeTabIndex, setActiveTabIndex] = useState(0) + + const tabInfos = useFormFields(([fields]) => { + return tabs.map((tab, index) => { + return { + index, + passesCondition: fields?.[tab?.id]?.passesCondition ?? true, + tab, + } + }) + }) + + const [activeTabIndex, setActiveTabIndex] = useState(() => { + return tabInfos.filter(({ passesCondition }) => passesCondition)?.[0]?.index ?? 0 + }) + const tabsPrefKey = `tabs-${indexPath}` const [activeTabPath, setActiveTabPath] = useState(() => generateTabPath({ activeTabConfig: tabs[activeTabIndex], path: parentPath }), @@ -68,6 +83,9 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => { const activePathChildrenPath = tabHasName(tabs[activeTabIndex]) ? activeTabPath : parentPath + const activeTabInfo = tabInfos[activeTabIndex] + const activeTabConfig = activeTabInfo?.tab + const [activeTabSchemaPath, setActiveTabSchemaPath] = useState(() => generateTabPath({ activeTabConfig: tabs[0], path: parentSchemaPath }), ) @@ -145,7 +163,14 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => { ], ) - const activeTabConfig = tabs[activeTabIndex] + useEffect(() => { + if (activeTabInfo?.passesCondition === false) { + const nextTab = tabInfos.find(({ passesCondition }) => passesCondition) + if (nextTab) { + void handleTabChange(nextTab.index) + } + } + }, [activeTabInfo, tabInfos, handleTabChange]) const activeTabDescription = activeTabConfig.description @@ -168,18 +193,18 @@ const TabsFieldComponent: TabsFieldClientComponent = (props) => {
- {tabs.map((tab, tabIndex) => { - return ( + {tabInfos.map(({ index, passesCondition, tab }) => { + return passesCondition ? ( { - void handleTabChange(tabIndex) + void handleTabChange(index) }} tab={tab} /> - ) + ) : null })}
diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index 09bb11a7705..ccd05482202 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -18,6 +18,7 @@ export const RenderFields: React.FC = (props) => { const { className, fields, + filter, forceRender, margins, parentIndexPath, diff --git a/packages/ui/src/forms/RenderFields/types.ts b/packages/ui/src/forms/RenderFields/types.ts index 743c9aedb95..c66d975827d 100644 --- a/packages/ui/src/forms/RenderFields/types.ts +++ b/packages/ui/src/forms/RenderFields/types.ts @@ -3,6 +3,7 @@ import type { ClientField, SanitizedFieldPermissions } from 'payload' export type RenderFieldsProps = { readonly className?: string readonly fields: ClientField[] + readonly filter?: (field: ClientField) => boolean /** * Controls the rendering behavior of the fields, i.e. defers rendering until they intersect with the viewport using the Intersection Observer API. * diff --git a/test/fields/collections/Tabs/e2e.spec.ts b/test/fields/collections/Tabs/e2e.spec.ts index 9670d297272..41e64e43e8d 100644 --- a/test/fields/collections/Tabs/e2e.spec.ts +++ b/test/fields/collections/Tabs/e2e.spec.ts @@ -132,4 +132,42 @@ describe('Tabs', () => { "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")' + await expect(page.locator(conditionalTabSelector)).toHaveCount(0) + + const checkboxSelector = `input#field-conditionalTabVisible` + await page.locator(checkboxSelector).check() + await expect(page.locator(checkboxSelector)).toBeChecked() + + await wait(300) + + await expect(page.locator(conditionalTabSelector)).toHaveCount(1) + 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(page.locator(nestedConditionalTabSelector)).toHaveCount(1) + + const nestedCheckboxSelector = `input#field-conditionalTab__nestedConditionalTabVisible` + await page.locator(nestedCheckboxSelector).uncheck() + + await expect(page.locator(nestedConditionalTabSelector)).toHaveCount(0) + }) }) diff --git a/test/fields/collections/Tabs/index.ts b/test/fields/collections/Tabs/index.ts index 8a7121c8bb7..a2740a82b79 100644 --- a/test/fields/collections/Tabs/index.ts +++ b/test/fields/collections/Tabs/index.ts @@ -21,9 +21,78 @@ 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: '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.', diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 0fa6d0407df..37671ded313 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -1539,6 +1539,13 @@ export interface TabsFields2 { export interface TabsField { id: string; sidebarField?: string | null; + conditionalTabVisible?: boolean | null; + conditionalTab?: { + conditionalTabField?: string | null; + nestedConditionalTabVisible?: boolean | null; + nestedUnconditionalTabInput?: string | null; + nestedConditionalTabInput?: string | null; + }; array: { text: string; id?: string | null;