feat: show fields inside groups as separate columns in the list view (#7355)

## Description

Group fields are shown as one column, this PR changes this so that the
individual field is now shown separately.

Before change:
<img width="1227" alt="before change"
src="https://github.com/user-attachments/assets/dfae58fd-8ad2-4329-84fd-ed1d4eb20854">

After change:
<img width="1229" alt="after change"
src="https://github.com/user-attachments/assets/d4fd78bb-c474-436e-a0f5-cac4638b91a4">

- [X] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [X] New feature (non-breaking change which adds functionality)

## Checklist:

- [X] I have added tests that prove my fix is effective or that my
feature works
- [X] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
This commit is contained in:
Anders Semb Hermansen
2025-05-21 22:25:34 +02:00
committed by GitHub
parent c772a3207c
commit 2a41d3fbb1
19 changed files with 884 additions and 78 deletions

View File

@@ -1,6 +1,6 @@
import type { ArrayFieldClient, BlocksFieldClient, ClientConfig, ClientField } from 'payload'
import { fieldShouldBeLocalized } from 'payload/shared'
import { fieldShouldBeLocalized, groupHasName } from 'payload/shared'
import { fieldHasChanges } from './fieldHasChanges.js'
import { getFieldsForRowComparison } from './getFieldsForRowComparison.js'
@@ -114,25 +114,37 @@ export function countChangedFields({
// Fields that have nested fields and nest their fields' data.
case 'group': {
if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) {
locales.forEach((locale) => {
if (groupHasName(field)) {
if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) {
locales.forEach((locale) => {
count += countChangedFields({
comparison: comparison?.[field.name]?.[locale],
config,
fields: field.fields,
locales,
parentIsLocalized: parentIsLocalized || field.localized,
version: version?.[field.name]?.[locale],
})
})
} else {
count += countChangedFields({
comparison: comparison?.[field.name]?.[locale],
comparison: comparison?.[field.name],
config,
fields: field.fields,
locales,
parentIsLocalized: parentIsLocalized || field.localized,
version: version?.[field.name]?.[locale],
version: version?.[field.name],
})
})
}
} else {
// Unnamed group field: data is NOT nested under `field.name`
count += countChangedFields({
comparison: comparison?.[field.name],
comparison,
config,
fields: field.fields,
locales,
parentIsLocalized: parentIsLocalized || field.localized,
version: version?.[field.name],
version,
})
}
break

View File

@@ -29,6 +29,7 @@ export {
fieldIsVirtual,
fieldShouldBeLocalized,
fieldSupportsMany,
groupHasName,
optionIsObject,
optionIsValue,
optionsAreObjects,

View File

@@ -770,7 +770,7 @@ export type NamedGroupFieldClient = {
export type UnnamedGroupFieldClient = {
admin?: AdminClient & Pick<UnnamedGroupField['admin'], 'hideGutter'>
fields: ClientField[]
} & Omit<FieldBaseClient, 'required'> &
} & Omit<FieldBaseClient, 'name' | 'required'> &
Pick<UnnamedGroupField, 'label' | 'type'>
export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient
@@ -1960,6 +1960,12 @@ export function tabHasName<TField extends ClientTab | Tab>(tab: TField): tab is
return 'name' in tab
}
export function groupHasName(
group: Partial<NamedGroupFieldClient>,
): group is NamedGroupFieldClient {
return 'name' in group
}
/**
* Check if a field has localized: true set. This does not check if a field *should*
* be localized. To check if a field should be localized, use `fieldShouldBeLocalized`.

View File

@@ -0,0 +1,510 @@
import { I18nClient } from '@payloadcms/translations'
import { ClientField } from '../fields/config/client.js'
import flattenFields from './flattenTopLevelFields.js'
describe('flattenFields', () => {
const i18n: I18nClient = {
t: (value: string) => value,
language: 'en',
dateFNS: {} as any,
dateFNSKey: 'en-US',
fallbackLanguage: 'en',
translations: {},
}
const baseField: ClientField = {
type: 'text',
name: 'title',
label: 'Title',
}
describe('basic flattening', () => {
it('should return flat list for top-level fields', () => {
const fields = [baseField]
const result = flattenFields(fields)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('title')
})
})
describe('group flattening', () => {
it('should flatten fields inside group with accessor and labelWithPrefix with moveSubFieldsToTop', () => {
const fields: ClientField[] = [
{
type: 'group',
name: 'meta',
label: 'Meta Info',
fields: [
{
type: 'text',
name: 'slug',
label: 'Slug',
},
],
},
]
const result = flattenFields(fields, {
moveSubFieldsToTop: true,
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')
})
it('should NOT flatten fields inside group without moveSubFieldsToTop', () => {
const fields: ClientField[] = [
{
type: 'group',
name: 'meta',
label: 'Meta Info',
fields: [
{
type: 'text',
name: 'slug',
label: 'Slug',
},
],
},
]
const result = flattenFields(fields)
// Should return the group as a top-level item, not the inner field
expect(result).toHaveLength(1)
expect(result[0].name).toBe('meta')
expect('fields' in result[0]).toBe(true)
expect('accessor' in result[0]).toBe(false)
expect('labelWithPrefix' in result[0]).toBe(false)
})
it('should correctly handle deeply nested group fields with and without moveSubFieldsToTop', () => {
const fields: ClientField[] = [
{
type: 'group',
name: 'outer',
label: 'Outer',
fields: [
{
type: 'group',
name: 'inner',
label: 'Inner',
fields: [
{
type: 'text',
name: 'deep',
label: 'Deep Field',
},
],
},
],
},
]
const hoisted = flattenFields(fields, {
moveSubFieldsToTop: true,
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')
const nonHoisted = flattenFields(fields)
expect(nonHoisted).toHaveLength(1)
expect(nonHoisted[0].name).toBe('outer')
expect('fields' in nonHoisted[0]).toBe(true)
expect('accessor' in nonHoisted[0]).toBe(false)
expect('labelWithPrefix' in nonHoisted[0]).toBe(false)
})
it('should hoist fields from unnamed group if moveSubFieldsToTop is true', () => {
const fields: ClientField[] = [
{
type: 'group',
label: 'Unnamed group',
fields: [
{
type: 'text',
name: 'insideUnnamedGroup',
},
],
},
]
const withExtract = flattenFields(fields, {
moveSubFieldsToTop: true,
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()
const withoutExtract = flattenFields(fields)
expect(withoutExtract).toHaveLength(1)
expect(withoutExtract[0].type).toBe('group')
expect(withoutExtract[0].accessor).toBeUndefined()
expect(withoutExtract[0].labelWithPrefix).toBeUndefined()
})
it('should hoist using deepest named group only if parents are unnamed', () => {
const fields: ClientField[] = [
{
type: 'group',
label: 'Outer',
fields: [
{
type: 'group',
label: 'Middle',
fields: [
{
type: 'group',
name: 'namedGroup',
label: 'Named Group',
fields: [
{
type: 'group',
label: 'Inner',
fields: [
{
type: 'text',
name: 'nestedField',
label: 'Nested Field',
},
],
},
],
},
],
},
],
},
]
const hoistedResult = flattenFields(fields, {
moveSubFieldsToTop: true,
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')
const nonHoistedResult = flattenFields(fields)
expect(nonHoistedResult).toHaveLength(1)
expect(nonHoistedResult[0].type).toBe('group')
expect('fields' in nonHoistedResult[0]).toBe(true)
expect('accessor' in nonHoistedResult[0]).toBe(false)
expect('labelWithPrefix' in nonHoistedResult[0]).toBe(false)
})
})
describe('array and block edge cases', () => {
it('should NOT flatten fields in arrays or blocks with moveSubFieldsToTop', () => {
const fields: ClientField[] = [
{
type: 'array',
name: 'items',
label: 'Items',
fields: [
{
type: 'text',
name: 'label',
label: 'Label',
},
],
},
{
type: 'blocks',
name: 'layout',
blocks: [
{
slug: 'block',
fields: [
{
type: 'text',
name: 'content',
label: 'Content',
},
],
},
],
},
]
const result = flattenFields(fields, { moveSubFieldsToTop: true })
expect(result).toHaveLength(2)
expect(result[0].name).toBe('items')
expect(result[1].name).toBe('layout')
})
it('should NOT flatten fields in arrays or blocks without moveSubFieldsToTop', () => {
const fields: ClientField[] = [
{
type: 'array',
name: 'things',
label: 'Things',
fields: [
{
type: 'text',
name: 'thingLabel',
label: 'Thing Label',
},
],
},
{
type: 'blocks',
name: 'contentBlocks',
blocks: [
{
slug: 'content',
fields: [
{
type: 'text',
name: 'body',
label: 'Body',
},
],
},
],
},
]
const result = flattenFields(fields)
expect(result).toHaveLength(2)
expect(result[0].name).toBe('things')
expect(result[1].name).toBe('contentBlocks')
})
it('should not hoist group fields nested inside arrays', () => {
const fields: ClientField[] = [
{
type: 'array',
name: 'arrayField',
label: 'Array Field',
fields: [
{
type: 'group',
name: 'groupInArray',
label: 'Group In Array',
fields: [
{
type: 'text',
name: 'nestedInArrayGroup',
label: 'Nested In Array Group',
},
],
},
],
},
]
const result = flattenFields(fields, { moveSubFieldsToTop: true })
expect(result).toHaveLength(1)
expect(result[0].name).toBe('arrayField')
})
it('should not hoist group fields nested inside blocks', () => {
const fields: ClientField[] = [
{
type: 'blocks',
name: 'blockField',
blocks: [
{
slug: 'exampleBlock',
fields: [
{
type: 'group',
name: 'groupInBlock',
label: 'Group In Block',
fields: [
{
type: 'text',
name: 'nestedInBlockGroup',
label: 'Nested In Block Group',
},
],
},
],
},
],
},
]
const result = flattenFields(fields, { moveSubFieldsToTop: true })
expect(result).toHaveLength(1)
expect(result[0].name).toBe('blockField')
})
})
describe('row and collapsible behavior', () => {
it('should recursively flatten collapsible fields regardless of moveSubFieldsToTop', () => {
const fields: ClientField[] = [
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
type: 'text',
name: 'nickname',
label: 'Nickname',
},
],
},
]
const defaultResult = flattenFields(fields)
const hoistedResult = flattenFields(fields, { moveSubFieldsToTop: true })
for (const result of [defaultResult, hoistedResult]) {
expect(result).toHaveLength(1)
expect(result[0].name).toBe('nickname')
expect('accessor' in result[0]).toBe(false)
expect('labelWithPrefix' in result[0]).toBe(false)
}
})
it('should recursively flatten row fields regardless of moveSubFieldsToTop', () => {
const fields: ClientField[] = [
{
type: 'row',
fields: [
{
type: 'text',
name: 'firstName',
label: 'First Name',
},
{
type: 'text',
name: 'lastName',
label: 'Last Name',
},
],
},
]
const defaultResult = flattenFields(fields)
const hoistedResult = flattenFields(fields, { moveSubFieldsToTop: true })
for (const result of [defaultResult, hoistedResult]) {
expect(result).toHaveLength(2)
expect(result[0].name).toBe('firstName')
expect(result[1].name).toBe('lastName')
expect('accessor' in result[0]).toBe(false)
expect('labelWithPrefix' in result[0]).toBe(false)
}
})
it('should hoist named group fields inside rows', () => {
const fields: ClientField[] = [
{
type: 'row',
fields: [
{
type: 'group',
name: 'groupInRow',
label: 'Group In Row',
fields: [
{
type: 'text',
name: 'nestedInRowGroup',
label: 'Nested In Row Group',
},
],
},
],
},
]
const result = flattenFields(fields, {
moveSubFieldsToTop: true,
i18n,
})
expect(result).toHaveLength(1)
expect(result[0].accessor).toBe('groupInRow-nestedInRowGroup')
expect(result[0].labelWithPrefix).toBe('Group In Row > Nested In Row Group')
})
it('should hoist named group fields inside collapsibles', () => {
const fields: ClientField[] = [
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
type: 'group',
name: 'groupInCollapsible',
label: 'Group In Collapsible',
fields: [
{
type: 'text',
name: 'nestedInCollapsibleGroup',
label: 'Nested In Collapsible Group',
},
],
},
],
},
]
const result = flattenFields(fields, {
moveSubFieldsToTop: true,
i18n,
})
expect(result).toHaveLength(1)
expect(result[0].accessor).toBe('groupInCollapsible-nestedInCollapsibleGroup')
expect(result[0].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[] = [
{
type: 'tabs',
tabs: [
{
label: 'Tab One',
fields: [
{
type: 'group',
name: 'groupInTab',
label: 'Group In Tab',
fields: [
{
type: 'text',
name: 'nestedInTabGroup',
label: 'Nested In Tab Group',
},
],
},
],
},
],
},
]
const result = flattenFields(fields, {
moveSubFieldsToTop: true,
i18n,
})
expect(result).toHaveLength(1)
expect(result[0].accessor).toBe('groupInTab-nestedInTabGroup')
expect(result[0].labelWithPrefix).toBe('Group In Tab > Nested In Tab Group')
})
})
})

View File

@@ -1,4 +1,8 @@
// @ts-strict-ignore
import type { I18nClient } from '@payloadcms/translations'
import { getTranslation } from '@payloadcms/translations'
import type { ClientTab } from '../admin/fields/Tabs.js'
import type { ClientField } from '../fields/config/client.js'
import type {
@@ -18,38 +22,153 @@ import {
} from '../fields/config/types.js'
type FlattenedField<TField> = TField extends ClientField
? FieldAffectingDataClient | FieldPresentationalOnlyClient
: FieldAffectingData | FieldPresentationalOnly
? { accessor?: string; labelWithPrefix?: string } & (
| FieldAffectingDataClient
| FieldPresentationalOnlyClient
)
: { accessor?: string; labelWithPrefix?: string } & (FieldAffectingData | FieldPresentationalOnly)
type TabType<TField> = TField extends ClientField ? ClientTab : Tab
/**
* Flattens a collection's fields into a single array of fields, as long
* as the fields do not affect data.
* Options to control how fields are flattened.
*/
type FlattenFieldsOptions = {
/**
* i18n context used for translating `label` values via `getTranslation`.
*/
i18n?: I18nClient
/**
* If true, presentational-only fields (like UI fields) will be included
* in the output. Otherwise, they will be skipped.
* Default: false.
*/
keepPresentationalFields?: boolean
/**
* A label prefix to prepend to translated labels when building `labelWithPrefix`.
* Used recursively when flattening nested fields.
*/
labelPrefix?: string
/**
* If true, nested fields inside `group` fields will be lifted to the top level
* and given contextual `accessor` and `labelWithPrefix` values.
* Default: false.
*/
moveSubFieldsToTop?: boolean
/**
* A path prefix to prepend to field names when building the `accessor`.
* Used recursively when flattening nested fields.
*/
pathPrefix?: string
}
/**
* Flattens a collection's fields into a single array of fields, optionally
* extracting nested fields in group fields.
*
* @param fields
* @param keepPresentationalFields if true, will skip flattening fields that are presentational only
* @param fields - Array of fields to flatten
* @param options - Options to control the flattening behavior
*/
function flattenFields<TField extends ClientField | Field>(
fields: TField[],
keepPresentationalFields?: boolean,
options?: boolean | FlattenFieldsOptions,
): FlattenedField<TField>[] {
const normalizedOptions: FlattenFieldsOptions =
typeof options === 'boolean' ? { keepPresentationalFields: options } : (options ?? {})
const {
i18n,
keepPresentationalFields,
labelPrefix,
moveSubFieldsToTop = false,
pathPrefix,
} = normalizedOptions
return fields.reduce<FlattenedField<TField>[]>((acc, field) => {
if (fieldAffectsData(field) || (keepPresentationalFields && fieldIsPresentationalOnly(field))) {
acc.push(field as FlattenedField<TField>)
} else if (fieldHasSubFields(field)) {
acc.push(...flattenFields(field.fields as TField[], keepPresentationalFields))
if (fieldHasSubFields(field)) {
if (field.type === 'group') {
if (moveSubFieldsToTop && 'fields' in field) {
const isNamedGroup = 'name' in field && typeof field.name === 'string' && !!field.name
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 nameWithPrefix =
isNamedGroup && field.name
? pathPrefix
? `${pathPrefix}-${field.name as string}`
: (field.name as string)
: pathPrefix
acc.push(
...flattenFields(field.fields as TField[], {
i18n,
keepPresentationalFields,
labelPrefix: isNamedGroup ? labelWithPrefix : labelPrefix,
moveSubFieldsToTop,
pathPrefix: isNamedGroup ? nameWithPrefix : pathPrefix,
}),
)
} else {
// Just keep the group as-is
acc.push(field as FlattenedField<TField>)
}
} else if (['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))
) {
// Ignore nested `id` fields when inside nested structure
if (field.name === 'id' && labelPrefix !== undefined) {
return acc
}
const translatedLabel =
'label' in field && field.label && i18n ? getTranslation(field.label, i18n) : undefined
const name = 'name' in field ? field.name : undefined
const isHoistingFromGroup = pathPrefix !== undefined || labelPrefix !== undefined
acc.push({
...(field as FlattenedField<TField>),
...(moveSubFieldsToTop &&
isHoistingFromGroup && {
accessor: pathPrefix && name ? `${pathPrefix}-${name}` : (name ?? ''),
labelWithPrefix:
labelPrefix && translatedLabel
? `${labelPrefix} > ${translatedLabel}`
: (labelPrefix ?? translatedLabel),
}),
})
} 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' } as unknown as FlattenedField<TField>]
} else {
return [
...tabFields,
...flattenFields(tab.fields as TField[], keepPresentationalFields),
{
...tab,
type: 'tab',
...(moveSubFieldsToTop && { labelPrefix }),
} as unknown as FlattenedField<TField>,
]
} else {
return [...tabFields, ...flattenFields<TField>(tab.fields as TField[], options)]
}
}, []),
]

View File

@@ -1,5 +1,5 @@
'use client'
import type { SanitizedCollectionConfig } from 'payload'
import type { SanitizedCollectionConfig, StaticLabel } from 'payload'
import { fieldIsHiddenOrDisabled, fieldIsID } from 'payload/shared'
import React, { useId, useMemo } from 'react'
@@ -53,6 +53,15 @@ export const ColumnSelector: React.FC<Props> = ({ collectionSlug }) => {
{filteredColumns.map((col, i) => {
const { accessor, active, field } = col
const label =
'labelWithPrefix' in field && field.labelWithPrefix !== undefined
? field.labelWithPrefix
: 'label' in field && field.label !== undefined
? field.label
: 'name' in field && field.name !== undefined
? field.name
: undefined
return (
<Pill
alignIcon="left"
@@ -63,14 +72,12 @@ export const ColumnSelector: React.FC<Props> = ({ collectionSlug }) => {
draggable
icon={active ? <XIcon /> : <PlusIcon />}
id={accessor}
key={`${collectionSlug}-${field && 'name' in field ? field?.name : i}${editDepth ? `-${editDepth}-` : ''}${uuid}`}
key={`${collectionSlug}-${accessor}-${i}${editDepth ? `-${editDepth}-` : ''}${uuid}`}
onClick={() => {
void toggleColumn(accessor)
}}
>
{col.CustomLabel ?? (
<FieldLabel label={field && 'label' in field && field.label} unstyled />
)}
{col.CustomLabel ?? <FieldLabel label={label as StaticLabel} unstyled />}
</Pill>
)
})}

View File

@@ -1,4 +1,5 @@
'use client'
import type { I18nClient } from '@payloadcms/translations'
import type { ClientField } from 'payload'
import { fieldAffectsData, flattenTopLevelFields } from 'payload/shared'
@@ -6,9 +7,13 @@ import { fieldAffectsData, flattenTopLevelFields } from 'payload/shared'
export const getTextFieldsToBeSearched = (
listSearchableFields: string[],
fields: ClientField[],
i18n: I18nClient,
): ClientField[] => {
if (listSearchableFields) {
const flattenedFields = flattenTopLevelFields(fields) as ClientField[]
const flattenedFields = flattenTopLevelFields(fields, {
i18n,
moveSubFieldsToTop: true,
}) as ClientField[]
return flattenedFields.filter(
(field) => fieldAffectsData(field) && listSearchableFields.includes(field.name),

View File

@@ -86,6 +86,7 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
const listSearchableFields = getTextFieldsToBeSearched(
collectionConfig.admin.listSearchableFields,
collectionConfig.fields,
i18n,
)
const searchLabelTranslated = useRef(

View File

@@ -3,6 +3,7 @@
import type { GroupFieldClientComponent } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { groupHasName } from 'payload/shared'
import React, { useMemo } from 'react'
import { useCollapsible } from '../../elements/Collapsible/provider.js'
@@ -16,9 +17,9 @@ import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js'
import './index.scss'
import { useRow } from '../Row/provider.js'
import { fieldBaseClass } from '../shared/index.js'
import './index.scss'
import { useTabs } from '../Tabs/provider.js'
import { GroupProvider, useGroup } from './provider.js'
@@ -27,7 +28,7 @@ const baseClass = 'group-field'
export const GroupFieldComponent: GroupFieldClientComponent = (props) => {
const {
field,
field: { name, admin: { className, description, hideGutter } = {}, fields, label },
field: { admin: { className, description, hideGutter } = {}, fields, label },
indexPath,
parentPath,
parentSchemaPath,
@@ -37,7 +38,8 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => {
schemaPath: schemaPathFromProps,
} = props
const schemaPath = schemaPathFromProps ?? name
const schemaPath =
schemaPathFromProps ?? (field.type === 'group' && groupHasName(field) ? field.name : path)
const { i18n } = useTranslation()
const { isWithinCollapsible } = useCollapsible()
@@ -106,7 +108,7 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => {
)}
{BeforeInput}
{/* Render an unnamed group differently */}
{name ? (
{groupHasName(field) ? (
<RenderFields
fields={fields}
margins="small"

View File

@@ -43,7 +43,9 @@ const getInitialDrawerData = ({
fields: ClientField[]
segments: string[]
}) => {
const flattenedFields = flattenTopLevelFields(fields)
const flattenedFields = flattenTopLevelFields(fields, {
keepPresentationalFields: true,
})
const path = segments[0]

View File

@@ -3,13 +3,20 @@ import type { ClientCollectionConfig, ClientField } from 'payload'
import { flattenTopLevelFields } from 'payload/shared'
import { useTranslation } from '../providers/Translation/index.js'
export const useUseTitleField = (collection: ClientCollectionConfig): ClientField => {
const {
admin: { useAsTitle },
fields,
} = collection
const topLevelFields = flattenTopLevelFields(fields) as ClientField[]
const { i18n } = useTranslation()
const topLevelFields = flattenTopLevelFields(fields, {
i18n,
moveSubFieldsToTop: true,
}) as ClientField[]
return topLevelFields?.find((field) => 'name' in field && field.name === useAsTitle)
}

View File

@@ -36,6 +36,7 @@ import {
} from '../../exports/client/index.js'
import { hasOptionLabelJSXElement } from '../../utilities/hasOptionLabelJSXElement.js'
import { filterFields } from './filterFields.js'
import { findValueInDoc } from './findValueInDoc.js'
type Args = {
beforeRows?: Column[]
@@ -70,15 +71,17 @@ export const buildColumnState = (args: Args): Column[] => {
} = args
// clientFields contains the fake `id` column
let sortedFieldMap = flattenTopLevelFields(
filterFields(clientCollectionConfig.fields),
true,
) as ClientField[]
let sortedFieldMap = flattenTopLevelFields(filterFields(clientCollectionConfig.fields), {
i18n,
keepPresentationalFields: true,
moveSubFieldsToTop: true,
}) as ClientField[]
let _sortedFieldMap = flattenTopLevelFields(
filterFields(collectionConfig.fields),
true,
) as Field[] // TODO: think of a way to avoid this additional flatten
let _sortedFieldMap = flattenTopLevelFields(filterFields(collectionConfig.fields), {
i18n,
keepPresentationalFields: true,
moveSubFieldsToTop: true,
}) as Field[] // TODO: think of a way to avoid this additional flatten
// place the `ID` field first, if it exists
// do the same for the `useAsTitle` field with precedence over the `ID` field
@@ -103,8 +106,9 @@ export const buildColumnState = (args: Args): Column[] => {
const sortFieldMap = (fieldMap, sortTo) =>
fieldMap?.sort((a, b) => {
const aIndex = sortTo.findIndex((column) => 'name' in a && column.accessor === a.name)
const bIndex = sortTo.findIndex((column) => 'name' in b && column.accessor === b.name)
const getAccessor = (field) => field.accessor ?? ('name' in field ? field.name : undefined)
const aIndex = sortTo.findIndex((column) => 'name' in a && column.accessor === getAccessor(a))
const bIndex = sortTo.findIndex((column) => 'name' in b && column.accessor === getAccessor(b))
if (aIndex === -1 && bIndex === -1) {
return 0
@@ -134,12 +138,15 @@ export const buildColumnState = (args: Args): Column[] => {
return acc
}
const _field = _sortedFieldMap.find(
(f) => 'name' in field && 'name' in f && f.name === field.name,
)
const accessor = (field as any).accessor ?? ('name' in field ? field.name : undefined)
const _field = _sortedFieldMap.find((f) => {
const fAccessor = (f as any).accessor ?? ('name' in f ? f.name : undefined)
return fAccessor === accessor
})
const columnPreference = columnPreferences?.find(
(preference) => field && 'name' in field && preference.accessor === field.name,
(preference) => field && 'name' in field && preference.accessor === accessor,
)
let active = false
@@ -147,9 +154,7 @@ export const buildColumnState = (args: Args): Column[] => {
if (columnPreference) {
active = columnPreference.active
} else if (columns && Array.isArray(columns) && columns.length > 0) {
active = columns.find(
(column) => field && 'name' in field && column.accessor === field.name,
)?.active
active = columns.find((column) => column.accessor === accessor)?.active
} else if (activeColumnsIndices.length < 4) {
active = true
}
@@ -197,12 +202,22 @@ export const buildColumnState = (args: Args): Column[] => {
field.type &&
(field.type === 'array' || field.type === 'group' || field.type === 'blocks')
const label =
field && 'labelWithPrefix' in field && field.labelWithPrefix !== undefined
? field.labelWithPrefix
: 'label' in field
? field.label
: undefined
// Convert accessor to dot notation specifically for SortColumn sorting behavior
const dotAccessor = accessor?.replace(/-/g, '.')
const Heading = (
<SortColumn
disable={fieldAffectsDataSubFields || fieldIsPresentationalOnly(field) || undefined}
Label={CustomLabel}
label={field && 'label' in field ? (field.label as StaticLabel) : undefined}
name={'name' in field ? field.name : undefined}
label={label as StaticLabel}
name={dotAccessor}
{...(sortColumnProps || {})}
/>
)
@@ -216,7 +231,7 @@ export const buildColumnState = (args: Args): Column[] => {
}
const column: Column = {
accessor: 'name' in field ? field.name : undefined,
accessor,
active,
CustomLabel,
field,
@@ -227,7 +242,7 @@ export const buildColumnState = (args: Args): Column[] => {
const cellClientProps: DefaultCellComponentProps = {
...baseCellClientProps,
cellData: 'name' in field ? doc[field.name] : undefined,
cellData: 'name' in field ? findValueInDoc(doc, field.name) : undefined,
link: isLinkedColumn,
rowData: doc,
}

View File

@@ -33,6 +33,7 @@ import {
// eslint-disable-next-line payload/no-imports-from-exports-dir
} from '../../exports/client/index.js'
import { filterFields } from './filterFields.js'
import { findValueInDoc } from './findValueInDoc.js'
type Args = {
beforeRows?: Column[]
@@ -65,9 +66,17 @@ export const buildPolymorphicColumnState = (args: Args): Column[] => {
} = args
// clientFields contains the fake `id` column
let sortedFieldMap = flattenTopLevelFields(filterFields(fields), true) as ClientField[]
let sortedFieldMap = flattenTopLevelFields(filterFields(fields), {
i18n,
keepPresentationalFields: true,
moveSubFieldsToTop: true,
}) as ClientField[]
let _sortedFieldMap = flattenTopLevelFields(filterFields(fields), true) as Field[] // TODO: think of a way to avoid this additional flatten
let _sortedFieldMap = flattenTopLevelFields(filterFields(fields), {
i18n,
keepPresentationalFields: true,
moveSubFieldsToTop: true,
}) as Field[] // TODO: think of a way to avoid this additional flatten
// place the `ID` field first, if it exists
// do the same for the `useAsTitle` field with precedence over the `ID` field
@@ -92,8 +101,9 @@ export const buildPolymorphicColumnState = (args: Args): Column[] => {
const sortFieldMap = (fieldMap, sortTo) =>
fieldMap?.sort((a, b) => {
const aIndex = sortTo.findIndex((column) => 'name' in a && column.accessor === a.name)
const bIndex = sortTo.findIndex((column) => 'name' in b && column.accessor === b.name)
const getAccessor = (field) => field.accessor ?? ('name' in field ? field.name : undefined)
const aIndex = sortTo.findIndex((column) => 'name' in a && column.accessor === getAccessor(a))
const bIndex = sortTo.findIndex((column) => 'name' in b && column.accessor === getAccessor(b))
if (aIndex === -1 && bIndex === -1) {
return 0
@@ -123,12 +133,15 @@ export const buildPolymorphicColumnState = (args: Args): Column[] => {
return acc
}
const _field = _sortedFieldMap.find(
(f) => 'name' in field && 'name' in f && f.name === field.name,
)
const accessor = (field as any).accessor ?? ('name' in field ? field.name : undefined)
const _field = _sortedFieldMap.find((f) => {
const fAccessor = (f as any).accessor ?? ('name' in f ? f.name : undefined)
return fAccessor === accessor
})
const columnPreference = columnPreferences?.find(
(preference) => field && 'name' in field && preference.accessor === field.name,
(preference) => field && 'name' in field && preference.accessor === accessor,
)
let active = false
@@ -136,9 +149,7 @@ export const buildPolymorphicColumnState = (args: Args): Column[] => {
if (columnPreference) {
active = columnPreference.active
} else if (columns && Array.isArray(columns) && columns.length > 0) {
active = columns.find(
(column) => field && 'name' in field && column.accessor === field.name,
)?.active
active = columns.find((column) => column.accessor === accessor)?.active
} else if (activeColumnsIndices.length < 4) {
active = true
}
@@ -179,18 +190,28 @@ export const buildPolymorphicColumnState = (args: Args): Column[] => {
field.type &&
(field.type === 'array' || field.type === 'group' || field.type === 'blocks')
const label =
'labelWithPrefix' in field && field.labelWithPrefix !== undefined
? field.labelWithPrefix
: 'label' in field
? field.label
: undefined
// Convert accessor to dot notation specifically for SortColumn sorting behavior
const dotAccessor = accessor?.replace(/-/g, '.')
const Heading = (
<SortColumn
disable={fieldAffectsDataSubFields || fieldIsPresentationalOnly(field) || undefined}
Label={CustomLabel}
label={field && 'label' in field ? (field.label as StaticLabel) : undefined}
name={'name' in field ? field.name : undefined}
label={label as StaticLabel}
name={dotAccessor}
{...(sortColumnProps || {})}
/>
)
const column: Column = {
accessor: 'name' in field ? field.name : undefined,
accessor,
active,
CustomLabel,
field,
@@ -212,7 +233,7 @@ export const buildPolymorphicColumnState = (args: Args): Column[] => {
const cellClientProps: DefaultCellComponentProps = {
...baseCellClientProps,
cellData: 'name' in field ? doc[field.name] : undefined,
cellData: 'name' in field ? findValueInDoc(doc, field.name) : undefined,
link: isLinkedColumn,
rowData: doc,
}

View File

@@ -0,0 +1,20 @@
export const findValueInDoc = (doc: Record<string, any>, targetName: string): any => {
if (!doc || typeof doc !== 'object') {
return undefined
}
if (targetName in doc) {
return doc[targetName]
}
for (const key in doc) {
if (typeof doc[key] === 'object' && doc[key] !== null) {
const result = findValueInDoc(doc[key], targetName)
if (result !== undefined) {
return result
}
}
}
return undefined
}

View File

@@ -118,9 +118,15 @@ export const renderTable = ({
const columns = columnsFromArgs
? columnsFromArgs?.filter((column) =>
flattenTopLevelFields(fields, true)?.some(
(field) => 'name' in field && field.name === column.accessor,
),
flattenTopLevelFields(fields, {
i18n,
keepPresentationalFields: true,
moveSubFieldsToTop: true,
})?.some((field) => {
const accessor =
'accessor' in field ? field.accessor : 'name' in field ? field.name : undefined
return accessor === column.accessor
}),
)
: getInitialColumns(fields, useAsTitle, [])
@@ -139,9 +145,15 @@ export const renderTable = ({
} else {
const columns = columnsFromArgs
? columnsFromArgs?.filter((column) =>
flattenTopLevelFields(clientCollectionConfig.fields, true)?.some(
(field) => 'name' in field && field.name === column.accessor,
),
flattenTopLevelFields(clientCollectionConfig.fields, {
i18n,
keepPresentationalFields: true,
moveSubFieldsToTop: true,
})?.some((field) => {
const accessor =
'accessor' in field ? field.accessor : 'name' in field ? field.name : undefined
return accessor === column.accessor
}),
)
: getInitialColumns(
filterFields(clientCollectionConfig.fields),