diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts index 211837168..2609b45f0 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts +++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts @@ -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 diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index 0f6b2431b..30c599d8c 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -29,6 +29,7 @@ export { fieldIsVirtual, fieldShouldBeLocalized, fieldSupportsMany, + groupHasName, optionIsObject, optionIsValue, optionsAreObjects, diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 55642663f..5f2d393a5 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -770,7 +770,7 @@ export type NamedGroupFieldClient = { export type UnnamedGroupFieldClient = { admin?: AdminClient & Pick fields: ClientField[] -} & Omit & +} & Omit & Pick export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient @@ -1960,6 +1960,12 @@ export function tabHasName(tab: TField): tab is return 'name' in tab } +export function groupHasName( + group: Partial, +): 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`. diff --git a/packages/payload/src/utilities/flattenTopLevelFields.spec.ts b/packages/payload/src/utilities/flattenTopLevelFields.spec.ts new file mode 100644 index 000000000..d6e5711c8 --- /dev/null +++ b/packages/payload/src/utilities/flattenTopLevelFields.spec.ts @@ -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') + }) + }) +}) diff --git a/packages/payload/src/utilities/flattenTopLevelFields.ts b/packages/payload/src/utilities/flattenTopLevelFields.ts index c330125b5..f461600aa 100644 --- a/packages/payload/src/utilities/flattenTopLevelFields.ts +++ b/packages/payload/src/utilities/flattenTopLevelFields.ts @@ -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 extends ClientField - ? FieldAffectingDataClient | FieldPresentationalOnlyClient - : FieldAffectingData | FieldPresentationalOnly + ? { accessor?: string; labelWithPrefix?: string } & ( + | FieldAffectingDataClient + | FieldPresentationalOnlyClient + ) + : { accessor?: string; labelWithPrefix?: string } & (FieldAffectingData | FieldPresentationalOnly) type TabType = 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( fields: TField[], - keepPresentationalFields?: boolean, + options?: boolean | FlattenFieldsOptions, ): FlattenedField[] { + const normalizedOptions: FlattenFieldsOptions = + typeof options === 'boolean' ? { keepPresentationalFields: options } : (options ?? {}) + + const { + i18n, + keepPresentationalFields, + labelPrefix, + moveSubFieldsToTop = false, + pathPrefix, + } = normalizedOptions + return fields.reduce[]>((acc, field) => { - if (fieldAffectsData(field) || (keepPresentationalFields && fieldIsPresentationalOnly(field))) { - acc.push(field as FlattenedField) - } 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) + } + } 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) + } + } 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), + ...(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[]>((tabFields, tab: TabType) => { if (tabHasName(tab)) { - return [...tabFields, { ...tab, type: 'tab' } as unknown as FlattenedField] - } else { return [ ...tabFields, - ...flattenFields(tab.fields as TField[], keepPresentationalFields), + { + ...tab, + type: 'tab', + ...(moveSubFieldsToTop && { labelPrefix }), + } as unknown as FlattenedField, ] + } else { + return [...tabFields, ...flattenFields(tab.fields as TField[], options)] } }, []), ] diff --git a/packages/ui/src/elements/ColumnSelector/index.tsx b/packages/ui/src/elements/ColumnSelector/index.tsx index e03cf154c..d81fb99fb 100644 --- a/packages/ui/src/elements/ColumnSelector/index.tsx +++ b/packages/ui/src/elements/ColumnSelector/index.tsx @@ -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 = ({ 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 ( = ({ collectionSlug }) => { draggable icon={active ? : } 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 ?? ( - - )} + {col.CustomLabel ?? } ) })} diff --git a/packages/ui/src/elements/ListControls/getTextFieldsToBeSearched.ts b/packages/ui/src/elements/ListControls/getTextFieldsToBeSearched.ts index f24d0e1bd..1b1ec076d 100644 --- a/packages/ui/src/elements/ListControls/getTextFieldsToBeSearched.ts +++ b/packages/ui/src/elements/ListControls/getTextFieldsToBeSearched.ts @@ -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), diff --git a/packages/ui/src/elements/ListControls/index.tsx b/packages/ui/src/elements/ListControls/index.tsx index 3aded4ec7..9b42403a3 100644 --- a/packages/ui/src/elements/ListControls/index.tsx +++ b/packages/ui/src/elements/ListControls/index.tsx @@ -86,6 +86,7 @@ export const ListControls: React.FC = (props) => { const listSearchableFields = getTextFieldsToBeSearched( collectionConfig.admin.listSearchableFields, collectionConfig.fields, + i18n, ) const searchLabelTranslated = useRef( diff --git a/packages/ui/src/fields/Group/index.tsx b/packages/ui/src/fields/Group/index.tsx index 02511c3ba..95bc69abb 100644 --- a/packages/ui/src/fields/Group/index.tsx +++ b/packages/ui/src/fields/Group/index.tsx @@ -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) ? ( { - const flattenedFields = flattenTopLevelFields(fields) + const flattenedFields = flattenTopLevelFields(fields, { + keepPresentationalFields: true, + }) const path = segments[0] diff --git a/packages/ui/src/hooks/useUseAsTitle.ts b/packages/ui/src/hooks/useUseAsTitle.ts index 0449e7ead..345b2b9df 100644 --- a/packages/ui/src/hooks/useUseAsTitle.ts +++ b/packages/ui/src/hooks/useUseAsTitle.ts @@ -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) } diff --git a/packages/ui/src/providers/TableColumns/buildColumnState.tsx b/packages/ui/src/providers/TableColumns/buildColumnState.tsx index de1211278..16a42a092 100644 --- a/packages/ui/src/providers/TableColumns/buildColumnState.tsx +++ b/packages/ui/src/providers/TableColumns/buildColumnState.tsx @@ -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 = ( ) @@ -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, } diff --git a/packages/ui/src/providers/TableColumns/buildPolymorphicColumnState.tsx b/packages/ui/src/providers/TableColumns/buildPolymorphicColumnState.tsx index f122aa109..d1cbbe547 100644 --- a/packages/ui/src/providers/TableColumns/buildPolymorphicColumnState.tsx +++ b/packages/ui/src/providers/TableColumns/buildPolymorphicColumnState.tsx @@ -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 = ( ) 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, } diff --git a/packages/ui/src/providers/TableColumns/findValueInDoc.tsx b/packages/ui/src/providers/TableColumns/findValueInDoc.tsx new file mode 100644 index 000000000..c6da7f48c --- /dev/null +++ b/packages/ui/src/providers/TableColumns/findValueInDoc.tsx @@ -0,0 +1,20 @@ +export const findValueInDoc = (doc: Record, 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 +} diff --git a/packages/ui/src/utilities/renderTable.tsx b/packages/ui/src/utilities/renderTable.tsx index dc6d0fcac..4e60b55b0 100644 --- a/packages/ui/src/utilities/renderTable.tsx +++ b/packages/ui/src/utilities/renderTable.tsx @@ -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), diff --git a/test/admin/collections/Posts.ts b/test/admin/collections/Posts.ts index e9f9c98c7..6b51e6f74 100644 --- a/test/admin/collections/Posts.ts +++ b/test/admin/collections/Posts.ts @@ -143,6 +143,16 @@ export const Posts: CollectionConfig = { }, ], }, + { + name: 'group', + type: 'group', + fields: [ + { + name: 'nestedTitle', + type: 'text', + }, + ], + }, { name: 'relationship', type: 'relationship', diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 881aa670b..4eb2fa723 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -11,6 +11,7 @@ import { exactText, getRoutes, initPageConsoleErrorCatch, + openColumnControls, } from '../../../helpers.js' import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' @@ -954,6 +955,20 @@ describe('List View', () => { expect(page.url()).not.toMatch(/columns=/) }) + test('should render field in group as column', async () => { + await createPost({ group: { nestedTitle: 'nested group title 1' } }) + await page.goto(postsUrl.list) + await openColumnControls(page) + await page + .locator('.column-selector .column-selector__column', { + hasText: exactText('Group > Nested Title'), + }) + .click() + await expect(page.locator('.row-1 .cell-group-nestedTitle')).toHaveText( + 'nested group title 1', + ) + }) + test('should drag to reorder columns and save to preferences', async () => { await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' }) @@ -1261,7 +1276,7 @@ describe('List View', () => { beforeEach(async () => { // delete all posts created by the seed await deleteAllPosts() - await createPost({ number: 1 }) + await createPost({ number: 1, group: { nestedTitle: 'nested group title 1' } }) await createPost({ number: 2 }) }) @@ -1283,6 +1298,34 @@ describe('List View', () => { await expect(page.locator('.row-2 .cell-number')).toHaveText('1') }) + test('should allow sorting by nested field within group in separate column', async () => { + await page.goto(postsUrl.list) + await openColumnControls(page) + await page + .locator('.column-selector .column-selector__column', { + hasText: exactText('Group > Nested Title'), + }) + .click() + const upChevron = page.locator('#heading-group-nestedTitle .sort-column__asc') + const downChevron = page.locator('#heading-group-nestedTitle .sort-column__desc') + + await upChevron.click() + await page.waitForURL(/sort=group.nestedTitle/) + + await expect(page.locator('.row-1 .cell-group-nestedTitle')).toHaveText('') + await expect(page.locator('.row-2 .cell-group-nestedTitle')).toHaveText( + 'nested group title 1', + ) + + await downChevron.click() + await page.waitForURL(/sort=-group.nestedTitle/) + + await expect(page.locator('.row-1 .cell-group-nestedTitle')).toHaveText( + 'nested group title 1', + ) + await expect(page.locator('.row-2 .cell-group-nestedTitle')).toHaveText('') + }) + test('should sort with existing filters', async () => { await page.goto(postsUrl.list) diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index e29ed5f27..da92698de 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -237,6 +237,9 @@ export interface Post { [k: string]: unknown; }[] | null; + group?: { + nestedTitle?: string | null; + }; relationship?: (string | null) | Post; users?: (string | null) | User; customCell?: string | null; @@ -695,6 +698,11 @@ export interface PostsSelect { description?: T; number?: T; richText?: T; + group?: + | T + | { + nestedTitle?: T; + }; relationship?: T; users?: T; customCell?: T; diff --git a/test/helpers.ts b/test/helpers.ts index eb70eaffd..48f672893 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -380,6 +380,11 @@ export async function switchTab(page: Page, selector: string) { await expect(page.locator(`${selector}.tabs-field__tab-button--active`)).toBeVisible() } +export const openColumnControls = async (page: Page) => { + await page.locator('.list-controls__toggle-columns').click() + await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible() +} + /** * Throws an error when browser console error messages (with some exceptions) are thrown, thus resulting * in the e2e test failing.