feat: show nested fields in named tabs as separate columns in the list view (#12530)

### What

Continuation of #7355 by extending the functionality to named tabs.

Updates `flattenFields` to hoist nested fields inside named tabs to the
top-level field array when `moveSubFieldsToTop` is enabled.

Also fixes an issue where group fields with custom cells were being
flattened out.

Now, group fields with a custom cell components remain available as
top-level columns.

Fixes #12563
This commit is contained in:
Patrik
2025-05-27 17:15:47 -04:00
committed by GitHub
parent 0204f0dcbc
commit 20f7017758
7 changed files with 522 additions and 107 deletions

View File

@@ -49,10 +49,10 @@ describe('flattenFields', () => {
i18n,
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('slug')
expect(result[0].accessor).toBe('meta-slug')
expect(result[0].labelWithPrefix).toBe('Meta Info > Slug')
expect(result).toHaveLength(2)
expect(result[1].name).toBe('slug')
expect(result[1].accessor).toBe('meta-slug')
expect(result[1].labelWithPrefix).toBe('Meta Info > Slug')
})
it('should NOT flatten fields inside group without moveSubFieldsToTop', () => {
@@ -109,10 +109,10 @@ describe('flattenFields', () => {
i18n,
})
expect(hoisted).toHaveLength(1)
expect(hoisted[0].name).toBe('deep')
expect(hoisted[0].accessor).toBe('outer-inner-deep')
expect(hoisted[0].labelWithPrefix).toBe('Outer > Inner > Deep Field')
expect(hoisted).toHaveLength(3)
expect(hoisted[2].name).toBe('deep')
expect(hoisted[2].accessor).toBe('outer-inner-deep')
expect(hoisted[2].labelWithPrefix).toBe('Outer > Inner > Deep Field')
const nonHoisted = flattenFields(fields)
@@ -142,14 +142,15 @@ describe('flattenFields', () => {
i18n,
})
// Should keep the group as a single top-level field
expect(withExtract).toHaveLength(1)
expect(withExtract[0].type).toBe('text')
expect(withExtract[0].accessor).toBeUndefined()
expect(withExtract[0].labelWithPrefix).toBeUndefined()
// Should include top level group and its nested field as a top-level field
expect(withExtract).toHaveLength(2)
expect(withExtract[1].type).toBe('text')
expect(withExtract[1].accessor).toBeUndefined()
expect(withExtract[1].labelWithPrefix).toBeUndefined()
const withoutExtract = flattenFields(fields)
// Should return the group as a top-level item, not the inner field
expect(withoutExtract).toHaveLength(1)
expect(withoutExtract[0].type).toBe('group')
expect(withoutExtract[0].accessor).toBeUndefined()
@@ -195,10 +196,10 @@ describe('flattenFields', () => {
i18n,
})
expect(hoistedResult).toHaveLength(1)
expect(hoistedResult[0].name).toBe('nestedField')
expect(hoistedResult[0].accessor).toBe('namedGroup-nestedField')
expect(hoistedResult[0].labelWithPrefix).toBe('Named Group > Nested Field')
expect(hoistedResult).toHaveLength(5)
expect(hoistedResult[4].name).toBe('nestedField')
expect(hoistedResult[4].accessor).toBe('namedGroup-nestedField')
expect(hoistedResult[4].labelWithPrefix).toBe('Named Group > Nested Field')
const nonHoistedResult = flattenFields(fields)
@@ -432,9 +433,9 @@ describe('flattenFields', () => {
i18n,
})
expect(result).toHaveLength(1)
expect(result[0].accessor).toBe('groupInRow-nestedInRowGroup')
expect(result[0].labelWithPrefix).toBe('Group In Row > Nested In Row Group')
expect(result).toHaveLength(2)
expect(result[1].accessor).toBe('groupInRow-nestedInRowGroup')
expect(result[1].labelWithPrefix).toBe('Group In Row > Nested In Row Group')
})
it('should hoist named group fields inside collapsibles', () => {
@@ -464,15 +465,114 @@ describe('flattenFields', () => {
i18n,
})
expect(result).toHaveLength(1)
expect(result[0].accessor).toBe('groupInCollapsible-nestedInCollapsibleGroup')
expect(result[0].labelWithPrefix).toBe('Group In Collapsible > Nested In Collapsible Group')
expect(result).toHaveLength(2)
expect(result[1].accessor).toBe('groupInCollapsible-nestedInCollapsibleGroup')
expect(result[1].labelWithPrefix).toBe('Group In Collapsible > Nested In Collapsible Group')
})
})
describe('tab integration', () => {
it('should hoist named group fields inside tabs when moveSubFieldsToTop is true', () => {
const fields: ClientField[] = [
const namedTabFields: ClientField[] = [
{
type: 'tabs',
tabs: [
{
label: 'Tab One',
name: 'tabOne',
fields: [
{
type: 'array',
name: 'array',
fields: [
{
type: 'text',
name: 'text',
},
],
},
{
type: 'row',
fields: [
{
name: 'arrayInRow',
type: 'array',
fields: [
{
name: 'textInArrayInRow',
type: 'text',
},
],
},
],
},
{
type: 'text',
name: 'textInTab',
label: 'Text In Tab',
},
{
type: 'group',
name: 'groupInTab',
label: 'Group In Tab',
fields: [
{
type: 'text',
name: 'nestedTextInTabGroup',
label: 'Nested Text In Tab Group',
},
],
},
],
},
],
},
]
const unnamedTabFields: ClientField[] = [
{
type: 'tabs',
tabs: [
{
label: 'Tab One',
fields: [
{
type: 'array',
name: 'array',
fields: [
{
type: 'text',
name: 'text',
},
],
},
{
type: 'row',
fields: [
{
name: 'arrayInRow',
type: 'array',
fields: [
{
name: 'textInArrayInRow',
type: 'text',
},
],
},
],
},
{
type: 'text',
name: 'textInTab',
label: 'Text In Tab',
},
],
},
],
},
]
it('should hoist named group fields inside unamed tabs when moveSubFieldsToTop is true', () => {
const unnamedTabWithNamedGroup: ClientField[] = [
{
type: 'tabs',
tabs: [
@@ -497,14 +597,107 @@ describe('flattenFields', () => {
},
]
const result = flattenFields(fields, {
const result = flattenFields(unnamedTabWithNamedGroup, {
moveSubFieldsToTop: true,
i18n,
})
expect(result).toHaveLength(2)
expect(result[1].accessor).toBe('groupInTab-nestedInTabGroup')
expect(result[1].labelWithPrefix).toBe('Group In Tab > Nested In Tab Group')
})
it('should hoist fields inside unnamed groups inside unnamed tabs when moveSubFieldsToTop is true', () => {
const unnamedTabWithUnnamedGroup: ClientField[] = [
{
type: 'tabs',
tabs: [
{
label: 'Tab One',
fields: [
{
type: 'group',
label: 'Unnamed Group In Tab',
fields: [
{
type: 'text',
name: 'nestedInUnnamedGroup',
label: 'Nested In Unnamed Group',
},
],
},
],
},
],
},
]
const defaultResult = flattenFields(unnamedTabWithUnnamedGroup)
expect(defaultResult).toHaveLength(1)
expect(defaultResult[0].type).toBe('group')
expect(defaultResult[0].label).toBe('Unnamed Group In Tab')
expect('accessor' in defaultResult[0]).toBe(false)
expect('labelWithPrefix' in defaultResult[0]).toBe(false)
const hoistedResult = flattenFields(unnamedTabWithUnnamedGroup, {
moveSubFieldsToTop: true,
i18n,
})
expect(hoistedResult).toHaveLength(2)
const hoistedField = hoistedResult[1]
expect(hoistedField.name).toBe('nestedInUnnamedGroup')
expect(hoistedField.accessor).toBeUndefined()
expect(hoistedField.labelWithPrefix).toBeUndefined()
})
it('should properly hoist fields inside named tabs when moveSubFieldsToTop is true', () => {
const result = flattenFields(namedTabFields, {
moveSubFieldsToTop: true,
i18n,
})
expect(result).toHaveLength(5)
expect(result[0].accessor).toBe('tabOne-array')
expect(result[0].labelWithPrefix).toBe('Tab One > array')
expect(result[1].accessor).toBe('tabOne-arrayInRow')
expect(result[1].labelWithPrefix).toBe('Tab One > arrayInRow')
expect(result[2].accessor).toBe('tabOne-textInTab')
expect(result[2].labelWithPrefix).toBe('Tab One > Text In Tab')
expect(result[4].accessor).toBe('tabOne-groupInTab-nestedTextInTabGroup')
expect(result[4].labelWithPrefix).toBe('Tab One > Group In Tab > Nested Text In Tab Group')
})
it('should NOT hoist fields inside named tabs when moveSubFieldsToTop is false', () => {
const result = flattenFields(namedTabFields)
// We expect one top-level field: the tabs container itself is *not* hoisted
expect(result).toHaveLength(1)
expect(result[0].accessor).toBe('groupInTab-nestedInTabGroup')
expect(result[0].labelWithPrefix).toBe('Group In Tab > Nested In Tab Group')
const tabField = result[0]
expect(tabField.type).toBe('tab')
// Confirm nested fields are NOT hoisted: no accessors or labelWithPrefix at the top level
expect('accessor' in tabField).toBe(false)
expect('labelWithPrefix' in tabField).toBe(false)
})
it('should hoist fields inside unnamed tabs regardless of moveSubFieldsToTop', () => {
const resultDefault = flattenFields(unnamedTabFields)
const resultHoisted = flattenFields(unnamedTabFields, {
moveSubFieldsToTop: true,
i18n,
})
expect(resultDefault).toHaveLength(3)
expect(resultHoisted).toHaveLength(3)
expect(resultDefault).toEqual(resultHoisted)
for (const field of resultDefault) {
expect(field.accessor).toBeUndefined()
expect(field.labelWithPrefix).toBeUndefined()
}
})
})
})

View File

@@ -50,7 +50,7 @@ type FlattenFieldsOptions = {
*/
labelPrefix?: string
/**
* If true, nested fields inside `group` fields will be lifted to the top level
* If true, nested fields inside `group` & `tabs` fields will be lifted to the top level
* and given contextual `accessor` and `labelWithPrefix` values.
* Default: false.
*/
@@ -85,29 +85,33 @@ function flattenFields<TField extends ClientField | Field>(
} = normalizedOptions
return fields.reduce<FlattenedField<TField>[]>((acc, field) => {
if (fieldHasSubFields(field)) {
if (field.type === 'group') {
if (moveSubFieldsToTop && 'fields' in field) {
if (field.type === 'group' && 'fields' in field) {
if (moveSubFieldsToTop) {
const isNamedGroup = 'name' in field && typeof field.name === 'string' && !!field.name
const groupName = 'name' in field ? field.name : undefined
const translatedLabel =
'label' in field && field.label && i18n
? getTranslation(field.label as string, i18n)
: undefined
const labelWithPrefix =
isNamedGroup && labelPrefix && translatedLabel
? `${labelPrefix} > ${translatedLabel}`
: (labelPrefix ?? translatedLabel)
const labelWithPrefix = labelPrefix
? `${labelPrefix} > ${translatedLabel ?? groupName}`
: (translatedLabel ?? groupName)
const nameWithPrefix =
isNamedGroup && field.name
'name' in field && field.name
? pathPrefix
? `${pathPrefix}-${field.name as string}`
: (field.name as string)
? `${pathPrefix}-${field.name}`
: field.name
: pathPrefix
acc.push(
// Need to include the top-level group field when hoisting its subfields,
// so that `buildColumnState` can detect and render a column if the group
// has a custom admin Cell component defined in its configuration.
// See: packages/ui/src/providers/TableColumns/buildColumnState/index.tsx
field as FlattenedField<TField>,
...flattenFields(field.fields as TField[], {
i18n,
keepPresentationalFields,
@@ -117,16 +121,57 @@ function flattenFields<TField extends ClientField | Field>(
}),
)
} else {
// Just keep the group as-is
// Hoisting diabled - keep as top level field
acc.push(field as FlattenedField<TField>)
}
} else if (['collapsible', 'row'].includes(field.type)) {
} else if (field.type === 'tabs' && 'tabs' in field) {
return [
...acc,
...field.tabs.reduce<FlattenedField<TField>[]>((tabFields, tab: TabType<TField>) => {
if (tabHasName(tab)) {
if (moveSubFieldsToTop) {
const translatedLabel =
'label' in tab && tab.label && i18n ? getTranslation(tab.label, i18n) : undefined
const labelWithPrefixForTab = labelPrefix
? `${labelPrefix} > ${translatedLabel ?? tab.name}`
: (translatedLabel ?? tab.name)
const pathPrefixForTab = tab.name
? pathPrefix
? `${pathPrefix}-${tab.name}`
: tab.name
: pathPrefix
return [
...tabFields,
...flattenFields(tab.fields as TField[], {
i18n,
keepPresentationalFields,
labelPrefix: labelWithPrefixForTab,
moveSubFieldsToTop,
pathPrefix: pathPrefixForTab,
}),
]
} else {
// Named tab, hoisting disabled: keep as top-level field
return [
...tabFields,
{
...tab,
type: 'tab',
} as unknown as FlattenedField<TField>,
]
}
} else {
// Unnamed tab: always hoist its fields
return [...tabFields, ...flattenFields<TField>(tab.fields as TField[], options)]
}
}, []),
]
} else if (fieldHasSubFields(field) && ['collapsible', 'row'].includes(field.type)) {
// Recurse into row and collapsible
acc.push(...flattenFields(field.fields as TField[], options))
} else {
// Do not hoist fields from arrays & blocks
acc.push(field as FlattenedField<TField>)
}
} else if (
fieldAffectsData(field) ||
(keepPresentationalFields && fieldIsPresentationalOnly(field))
@@ -148,30 +193,11 @@ function flattenFields<TField extends ClientField | Field>(
...(moveSubFieldsToTop &&
isHoistingFromGroup && {
accessor: pathPrefix && name ? `${pathPrefix}-${name}` : (name ?? ''),
labelWithPrefix:
labelPrefix && translatedLabel
? `${labelPrefix} > ${translatedLabel}`
: (labelPrefix ?? translatedLabel),
labelWithPrefix: labelPrefix
? `${labelPrefix} > ${translatedLabel ?? name}`
: (translatedLabel ?? name),
}),
})
} else if (field.type === 'tabs' && 'tabs' in field) {
return [
...acc,
...field.tabs.reduce<FlattenedField<TField>[]>((tabFields, tab: TabType<TField>) => {
if (tabHasName(tab)) {
return [
...tabFields,
{
...tab,
type: 'tab',
...(moveSubFieldsToTop && { labelPrefix }),
} as unknown as FlattenedField<TField>,
]
} else {
return [...tabFields, ...flattenFields<TField>(tab.fields as TField[], options)]
}
}, []),
]
}
return acc

View File

@@ -139,6 +139,17 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
return fAccessor === accessor
})
const hasCustomCell =
serverField?.admin &&
'components' in serverField.admin &&
serverField.admin.components &&
'Cell' in serverField.admin.components &&
serverField.admin.components.Cell
if (serverField && serverField.type === 'group' && !hasCustomCell) {
return acc // skip any group without a custom cell
}
const columnPreference = columnPreferences?.find(
(preference) => clientField && 'name' in clientField && preference.accessor === accessor,
)

View File

@@ -133,7 +133,7 @@ export const Posts: CollectionConfig = {
type: 'text',
},
{
name: 'group',
name: 'namedGroup',
type: 'group',
fields: [
{
@@ -142,6 +142,54 @@ export const Posts: CollectionConfig = {
},
],
},
{
type: 'group',
label: 'Unnamed group',
fields: [
{
name: 'textFieldInUnnamedGroup',
type: 'text',
},
],
},
{
name: 'groupWithCustomCell',
type: 'group',
admin: {
components: {
Cell: '/components/CustomGroupCell/index.js#CustomGroupCell',
},
},
fields: [
{
name: 'nestedTextFieldInGroupWithCustomCell',
type: 'text',
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'namedTab',
fields: [
{
name: 'nestedTextFieldInNamedTab',
type: 'text',
},
],
},
{
label: 'unnamedTab',
fields: [
{
name: 'nestedTextFieldInUnnamedTab',
type: 'text',
},
],
},
],
},
{
name: 'relationship',
type: 'relationship',

View File

@@ -0,0 +1,9 @@
'use client'
import type { DefaultCellComponentProps } from 'payload'
import React from 'react'
export const CustomGroupCell: React.FC<DefaultCellComponentProps> = (props) => {
return <div>{`Custom group cell: ${props?.rowData?.title || 'No data'}`}</div>
}

View File

@@ -955,24 +955,64 @@ describe('List View', () => {
expect(page.url()).not.toMatch(/columns=/)
})
test('should render field in group as column', async () => {
await createPost({ group: { someTextField: 'nested group text field' } })
test('should render nested field in named group as separate column', async () => {
await createPost({ namedGroup: { someTextField: 'nested group text field' } })
await page.goto(postsUrl.list)
await openColumnControls(page)
await page
.locator('.column-selector .column-selector__column', {
hasText: exactText('Group > Some Text Field'),
hasText: exactText('Named Group > Some Text Field'),
})
.click()
await expect(page.locator('.row-1 .cell-group-someTextField')).toHaveText(
await expect(page.locator('.row-1 .cell-namedGroup-someTextField')).toHaveText(
'nested group text field',
)
})
test('should render top-level and group field with same name in separate columns', async () => {
test('should render nested field in unnamed group as separate column', async () => {
await createPost({ textFieldInUnnamedGroup: 'nested text in unnamed group' })
await page.goto(postsUrl.list)
await openColumnControls(page)
await page
.locator('.column-selector .column-selector__column', {
hasText: exactText('Text Field In Unnamed Group'),
})
.click()
await expect(page.locator('.row-1 .cell-textFieldInUnnamedGroup')).toHaveText(
'nested text in unnamed group',
)
})
test('should not render group field as top level column when custom cell is not defined', async () => {
await createPost({ namedGroup: { someTextField: 'nested group text field' } })
await page.goto(postsUrl.list)
await openColumnControls(page)
await expect(
page.locator('.column-selector .column-selector__column', {
hasText: exactText('Named Group'),
}),
).toBeHidden()
})
test('should render group field as top level column when custom cell is defined', async () => {
await createPost({
groupWithCustomCell: {
nestedTextFieldInGroupWithCustomCell: 'nested group text field in group with custom cell',
},
})
await page.goto(postsUrl.list)
await openColumnControls(page)
await expect(
page.locator('.column-selector .column-selector__column', {
hasText: exactText('Group With Custom Cell'),
}),
).toBeVisible()
})
test('should render top-level field and group field with same name in separate columns', async () => {
await createPost({
someTextField: 'top-level text field',
group: { someTextField: 'nested group text field' },
namedGroup: { someTextField: 'nested group text field' },
})
await page.goto(postsUrl.list)
@@ -988,7 +1028,7 @@ describe('List View', () => {
// Enable group column
await page
.locator('.column-selector .column-selector__column', {
hasText: exactText('Group > Some Text Field'),
hasText: exactText('Named Group > Some Text Field'),
})
.click()
@@ -996,11 +1036,39 @@ describe('List View', () => {
await expect(page.locator('.row-1 .cell-someTextField')).toHaveText('top-level text field')
// Expect nested group cell
await expect(page.locator('.row-1 .cell-group-someTextField')).toHaveText(
await expect(page.locator('.row-1 .cell-namedGroup-someTextField')).toHaveText(
'nested group text field',
)
})
test('should render nested field in named tab as separate column', async () => {
await createPost({ namedTab: { nestedTextFieldInNamedTab: 'nested text in named tab' } })
await page.goto(postsUrl.list)
await openColumnControls(page)
await page
.locator('.column-selector .column-selector__column', {
hasText: exactText('Named Tab > Nested Text Field In Named Tab'),
})
.click()
await expect(page.locator('.row-1 .cell-namedTab-nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
})
test('should render nested field in unnamed tab as separate column', async () => {
await createPost({ nestedTextFieldInUnnamedTab: 'nested text in unnamed tab' })
await page.goto(postsUrl.list)
await openColumnControls(page)
await page
.locator('.column-selector .column-selector__column', {
hasText: exactText('Nested Text Field In Unnamed Tab'),
})
.click()
await expect(page.locator('.row-1 .cell-nestedTextFieldInUnnamedTab')).toHaveText(
'nested text in unnamed tab',
)
})
test('should drag to reorder columns and save to preferences', async () => {
await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' })
@@ -1308,7 +1376,11 @@ describe('List View', () => {
beforeEach(async () => {
// delete all posts created by the seed
await deleteAllPosts()
await createPost({ number: 1, group: { someTextField: 'nested group text field' } })
await createPost({
number: 1,
namedGroup: { someTextField: 'nested group text field' },
namedTab: { nestedTextFieldInNamedTab: 'nested text in named tab' },
})
await createPost({ number: 2 })
})
@@ -1335,33 +1407,69 @@ describe('List View', () => {
await openColumnControls(page)
await page
.locator('.column-selector .column-selector__column', {
hasText: exactText('Group > Some Text Field'),
hasText: exactText('Named Group > Some Text Field'),
})
.click()
const upChevron = page.locator('#heading-group-someTextField .sort-column__asc')
const downChevron = page.locator('#heading-group-someTextField .sort-column__desc')
const upChevron = page.locator('#heading-namedGroup-someTextField .sort-column__asc')
const downChevron = page.locator('#heading-namedGroup-someTextField .sort-column__desc')
await upChevron.click()
await page.waitForURL(/sort=group.someTextField/)
await page.waitForURL(/sort=namedGroup.someTextField/)
await expect(page.locator('.row-1 .cell-group-someTextField')).toHaveText(
await expect(page.locator('.row-1 .cell-namedGroup-someTextField')).toHaveText(
'<No Some Text Field>',
)
await expect(page.locator('.row-2 .cell-group-someTextField')).toHaveText(
await expect(page.locator('.row-2 .cell-namedGroup-someTextField')).toHaveText(
'nested group text field',
)
await downChevron.click()
await page.waitForURL(/sort=-group.someTextField/)
await page.waitForURL(/sort=-namedGroup.someTextField/)
await expect(page.locator('.row-1 .cell-group-someTextField')).toHaveText(
await expect(page.locator('.row-1 .cell-namedGroup-someTextField')).toHaveText(
'nested group text field',
)
await expect(page.locator('.row-2 .cell-group-someTextField')).toHaveText(
await expect(page.locator('.row-2 .cell-namedGroup-someTextField')).toHaveText(
'<No Some Text Field>',
)
})
test('should allow sorting by nested field within tab in separate column', async () => {
await page.goto(postsUrl.list)
await openColumnControls(page)
await page
.locator('.column-selector .column-selector__column', {
hasText: exactText('Named Tab > Nested Text Field In Named Tab'),
})
.click()
const upChevron = page.locator(
'#heading-namedTab-nestedTextFieldInNamedTab .sort-column__asc',
)
const downChevron = page.locator(
'#heading-namedTab-nestedTextFieldInNamedTab .sort-column__desc',
)
await upChevron.click()
await page.waitForURL(/sort=namedTab.nestedTextFieldInNamedTab/)
await expect(page.locator('.row-1 .cell-namedTab-nestedTextFieldInNamedTab')).toHaveText(
'<No Nested Text Field In Named Tab>',
)
await expect(page.locator('.row-2 .cell-namedTab-nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
await downChevron.click()
await page.waitForURL(/sort=-namedTab.nestedTextFieldInNamedTab/)
await expect(page.locator('.row-1 .cell-namedTab-nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
await expect(page.locator('.row-2 .cell-namedTab-nestedTextFieldInNamedTab')).toHaveText(
'<No Nested Text Field In Named Tab>',
)
})
test('should sort with existing filters', async () => {
await page.goto(postsUrl.list)

View File

@@ -238,9 +238,17 @@ export interface Post {
}[]
| null;
someTextField?: string | null;
group?: {
namedGroup?: {
someTextField?: string | null;
};
textFieldInUnnamedGroup?: string | null;
groupWithCustomCell?: {
nestedTextFieldInGroupWithCustomCell?: string | null;
};
namedTab?: {
nestedTextFieldInNamedTab?: string | null;
};
nestedTextFieldInUnnamedTab?: string | null;
relationship?: (string | null) | Post;
users?: (string | null) | User;
customCell?: string | null;
@@ -700,11 +708,23 @@ export interface PostsSelect<T extends boolean = true> {
number?: T;
richText?: T;
someTextField?: T;
group?:
namedGroup?:
| T
| {
someTextField?: T;
};
textFieldInUnnamedGroup?: T;
groupWithCustomCell?:
| T
| {
nestedTextFieldInGroupWithCustomCell?: T;
};
namedTab?:
| T
| {
nestedTextFieldInNamedTab?: T;
};
nestedTextFieldInUnnamedTab?: T;
relationship?: T;
users?: T;
customCell?: T;