Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: conditional tabs #8720

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Block, Field } from 'payload'
import type { Block, Field, Tab } from 'payload'

Check warning on line 1 in packages/drizzle/src/utilities/validateExistingBlockIsIdentical.ts

View workflow job for this annotation

GitHub Actions / lint

'Tab' is defined but never used. Allowed unused vars must match /^_/u

import { InvalidConfiguration } from 'payload'
import { fieldAffectsData, fieldHasSubFields, tabHasName } from 'payload/shared'
Expand Down Expand Up @@ -33,7 +33,7 @@
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,
Expand Down
3 changes: 2 additions & 1 deletion packages/graphql/src/schema/buildMutationInputType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
SanitizedCollectionConfig,
SanitizedConfig,
SelectField,
Tab,

Check warning on line 23 in packages/graphql/src/schema/buildMutationInputType.ts

View workflow job for this annotation

GitHub Actions / lint

'Tab' is defined but never used. Allowed unused vars must match /^_/u
TabsField,
TextareaField,
TextField,
Expand Down Expand Up @@ -272,7 +273,7 @@
}
},
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)
Expand Down
3 changes: 2 additions & 1 deletion packages/graphql/src/schema/buildObjectType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
RowField,
SanitizedConfig,
SelectField,
Tab,

Check warning on line 24 in packages/graphql/src/schema/buildObjectType.ts

View workflow job for this annotation

GitHub Actions / lint

'Tab' is defined but never used. Allowed unused vars must match /^_/u
TabsField,
TextareaField,
TextField,
Expand Down Expand Up @@ -52,7 +53,7 @@
import { withNullableType } from './withNullableType.js'

export type ObjectTypeConfig = {
[path: string]: GraphQLFieldConfig<any, any>

Check warning on line 56 in packages/graphql/src/schema/buildObjectType.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 56 in packages/graphql/src/schema/buildObjectType.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
}

type Args = {
Expand Down Expand Up @@ -108,7 +109,7 @@
}
},
blocks: (objectTypeConfig: ObjectTypeConfig, field: BlocksField) => {
const blockTypes: GraphQLObjectType<any, any>[] = field.blocks.reduce((acc, block) => {

Check warning on line 112 in packages/graphql/src/schema/buildObjectType.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 112 in packages/graphql/src/schema/buildObjectType.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
if (!graphqlResult.types.blockTypes[block.slug]) {
const interfaceName =
block?.interfaceName || block?.graphQL?.singularName || toWords(block.slug, true)
Expand Down Expand Up @@ -212,7 +213,7 @@
...objectTypeConfig,
[field.name]: {
type: graphqlResult.types.groupTypes[interfaceName],
resolve: (parent, args, context: Context) => {

Check warning on line 216 in packages/graphql/src/schema/buildObjectType.ts

View workflow job for this annotation

GitHub Actions / lint

'args' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 216 in packages/graphql/src/schema/buildObjectType.ts

View workflow job for this annotation

GitHub Actions / lint

'context' is defined but never used. Allowed unused args must match /^_/u
return {
...parent[field.name],
_id: parent._id ?? parent.id,
Expand Down Expand Up @@ -617,7 +618,7 @@
}
},
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))
Expand Down Expand Up @@ -645,7 +646,7 @@
...tabSchema,
[tab.name]: {
type: graphqlResult.types.groupTypes[interfaceName],
resolve(parent, args, context: Context) {

Check warning on line 649 in packages/graphql/src/schema/buildObjectType.ts

View workflow job for this annotation

GitHub Actions / lint

'args' is defined but never used. Allowed unused args must match /^_/u
return {
...parent[tab.name],
_id: parent._id ?? parent.id,
Expand Down
4 changes: 2 additions & 2 deletions packages/graphql/src/schema/recursivelyBuildNestedPaths.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FieldWithSubFields, TabsField } from 'payload'
import type { FieldWithSubFields, Tab, TabsField } from 'payload'

import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/shared'

Expand All @@ -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: {
Expand Down
7 changes: 5 additions & 2 deletions packages/payload/src/admin/fields/Tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,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 & Pick<ServerFieldBase, 'permissions'>

Expand Down
7 changes: 7 additions & 0 deletions packages/payload/src/errors/DuplicateTabsIds.ts
Original file line number Diff line number Diff line change
@@ -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(', ')}"`)
}
}
10 changes: 10 additions & 0 deletions packages/payload/src/fields/config/sanitize.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions packages/payload/src/fields/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,11 +692,15 @@ export type CollapsibleFieldClient = {
Pick<CollapsibleField, 'type'>

type TabBase = {
description?: LabelFunction | StaticDescription
admin?: {
condition?: Condition
}
description?: Description
fields: Field[]
id?: string
interfaceName?: string
saveToJWT?: boolean | string
} & Omit<FieldBase, 'required' | 'validate'>
} & Omit<FieldBase, 'admin' | 'required' | 'validate'>

export type NamedTab = {
/** Customize generated GraphQL and Typescript schema names.
Expand Down Expand Up @@ -725,11 +729,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 = {
Expand Down
1 change: 1 addition & 0 deletions packages/payload/src/fields/hooks/beforeChange/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
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'
Expand Down Expand Up @@ -287,7 +288,7 @@

case 'collapsible':

case 'row': {

Check failure on line 291 in packages/payload/src/fields/hooks/beforeChange/promise.ts

View workflow job for this annotation

GitHub Actions / lint

Expected a 'break' statement before 'case'
await traverseFields({
id,
collection,
Expand Down
4 changes: 2 additions & 2 deletions packages/payload/src/utilities/getEntityPolicies.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -207,7 +207,7 @@ export async function getEntityPolicies<T extends Args>(args: T): Promise<Return
})
} else if (field.type === 'tabs') {
await Promise.all(
field.tabs.map(async (tab) => {
field.tabs.map(async (tab: Tab) => {
if (tabHasName(tab)) {
if (!mutablePolicies[tab.name]) {
mutablePolicies[tab.name] = {
Expand Down
43 changes: 34 additions & 9 deletions packages/ui/src/fields/Tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
} from 'payload'

import { getTranslation } from '@payloadcms/translations'
import { useFormFields } from '@payloadcms/ui'

Check failure on line 12 in packages/ui/src/fields/Tabs/index.tsx

View workflow job for this annotation

GitHub Actions / lint

Package "@payloadcms/ui" should not import from itself. Use relative instead
import { tabHasName, toKebabCase } from 'payload/shared'
import React, { useCallback, useEffect, useState } from 'react'

Expand All @@ -21,8 +22,8 @@
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'

Expand Down Expand Up @@ -60,14 +61,31 @@
const { preferencesKey } = useDocumentInfo()
const { i18n } = useTranslation()
const { isWithinCollapsible } = useCollapsible()
const [activeTabIndex, setActiveTabIndex] = useState<number>(0)

const tabInfos = useFormFields(([fields]) => {
return tabs.map((tab, index) => {
return {
index,
passesCondition: fields?.[tab?.id]?.passesCondition ?? true,
tab,
}
})
})

const [activeTabIndex, setActiveTabIndex] = useState<number>(() => {
return tabInfos.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 activeTabInfo = tabInfos[activeTabIndex]
const activeTabConfig = activeTabInfo?.tab

const [activeTabSchemaPath, setActiveTabSchemaPath] = useState<string>(() =>
generateTabPath({ activeTabConfig: tabs[0], path: parentSchemaPath }),
)
Expand Down Expand Up @@ -145,7 +163,14 @@
],
)

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

Expand All @@ -168,18 +193,18 @@
<TabsProvider>
<div className={`${baseClass}__tabs-wrap`}>
<div className={`${baseClass}__tabs`}>
{tabs.map((tab, tabIndex) => {
return (
{tabInfos.map(({ index, passesCondition, tab }) => {
return passesCondition ? (
<TabComponent
isActive={activeTabIndex === tabIndex}
key={tabIndex}
isActive={activeTabIndex === index}
key={index}
parentPath={path}
setIsActive={() => {
void handleTabChange(tabIndex)
void handleTabChange(index)
}}
tab={tab}
/>
)
) : null
})}
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/forms/RenderFields/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const RenderFields: React.FC<RenderFieldsProps> = (props) => {
const {
className,
fields,
filter,
forceRender,
margins,
parentIndexPath,
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/forms/RenderFields/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
38 changes: 38 additions & 0 deletions test/fields/collections/Tabs/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
69 changes: 69 additions & 0 deletions test/fields/collections/Tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
Loading
Loading