From e258cd73efc9dc2d91249944950c6186731e7f66 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 14 May 2025 16:45:34 -0700 Subject: [PATCH] feat: allow group fields to have an optional name (#12318) Adds the ability to completely omit `name` from group fields now so that they're entirely presentational. New config: ```ts import type { CollectionConfig } from 'payload' export const ExampleCollection: CollectionConfig = { slug: 'posts', fields: [ { label: 'Page header', type: 'group', // required fields: [ { name: 'title', type: 'text', required: true, }, ], }, ], } ``` will create image but the data response will still be ``` { "createdAt": "2025-05-05T13:42:20.326Z", "updatedAt": "2025-05-05T13:42:20.326Z", "title": "example post", "id": "6818c03ce92b7f92be1540f0" } ``` Checklist: - [x] Added int tests - [x] Modify mongo, drizzle and graphql packages - [x] Add type tests - [x] Add e2e tests --- docs/fields/group.mdx | 41 +++++- packages/db-mongodb/src/models/buildSchema.ts | 77 +++++++---- .../src/queries/sanitizeQueryValue.ts | 1 + .../utilities/buildProjectionFromSelect.ts | 1 - .../src/schema/buildMutationInputType.ts | 48 ++++--- .../graphql/src/schema/fieldToSchemaMap.ts | 78 ++++++----- packages/payload/src/auth/getFieldsToSign.ts | 46 ++++--- packages/payload/src/fields/config/types.ts | 52 ++++++-- .../src/fields/hooks/afterChange/promise.ts | 60 ++++++--- .../src/fields/hooks/afterRead/promise.ts | 107 +++++++++------ .../src/fields/hooks/beforeChange/promise.ts | 58 ++++++--- .../fields/hooks/beforeDuplicate/promise.ts | 3 +- .../fields/hooks/beforeValidate/promise.ts | 31 +++-- packages/payload/src/index.ts | 4 + .../src/utilities/configToJSONSchema.ts | 60 ++++++--- .../src/utilities/fieldSchemaToJSON.ts | 17 ++- .../payload/src/utilities/flattenAllFields.ts | 10 +- .../payload/src/utilities/traverseFields.ts | 50 ++++--- .../elements/WhereBuilder/reduceFields.tsx | 43 +++++- packages/ui/src/fields/Group/index.tsx | 34 +++-- packages/ui/src/fields/Join/index.tsx | 4 +- .../addFieldStatePromise.ts | 2 +- .../calculateDefaultValues/promise.ts | 50 ++++--- .../traverseFields.ts | 35 ++++- .../buildFieldSchemaMap/traverseFields.ts | 27 +++- .../ui/src/utilities/copyDataFromLocale.ts | 8 +- test/fields/collections/Group/e2e.spec.ts | 123 ++++++++++++++++++ test/fields/collections/Group/index.ts | 48 +++++++ test/fields/collections/Group/shared.ts | 6 +- test/fields/int.spec.ts | 53 +++++++- test/fields/payload-types.ts | 10 ++ test/fields/seed.ts | 4 +- test/types/config.ts | 20 +++ test/types/payload-types.ts | 10 ++ test/types/types.spec.ts | 13 ++ .../src/generateTranslations/utils/index.ts | 2 +- tsconfig.base.json | 2 +- 37 files changed, 955 insertions(+), 283 deletions(-) create mode 100644 test/fields/collections/Group/e2e.spec.ts diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index 9062dc6f5d..7865e0fc75 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -35,9 +35,9 @@ export const MyGroupField: Field = { | Option | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | | **`fields`** \* | Array of field types to nest within this Group. | -| **`label`** | Used as a heading in the Admin Panel and to name the generated GraphQL type. | +| **`label`** | Used as a heading in the Admin Panel and to name the generated GraphQL type. Required when name is undefined, defaults to name converted to words. | | **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | @@ -86,7 +86,7 @@ export const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { - name: 'pageMeta', // required + name: 'pageMeta', type: 'group', // required interfaceName: 'Meta', // optional fields: [ @@ -110,3 +110,38 @@ export const ExampleCollection: CollectionConfig = { ], } ``` + +## Presentational group fields + +You can also use the Group field to create a presentational group of fields. This is useful when you want to group fields together visually without affecting the data structure. +The label will be required when a `name` is not provided. + +```ts +import type { CollectionConfig } from 'payload' + +export const ExampleCollection: CollectionConfig = { + slug: 'example-collection', + fields: [ + { + label: 'Page meta', + type: 'group', // required + fields: [ + { + name: 'title', + type: 'text', + required: true, + minLength: 20, + maxLength: 100, + }, + { + name: 'description', + type: 'textarea', + required: true, + minLength: 40, + maxLength: 160, + }, + ], + }, + ], +} +``` diff --git a/packages/db-mongodb/src/models/buildSchema.ts b/packages/db-mongodb/src/models/buildSchema.ts index 4cd833ccec..56e2cf1130 100644 --- a/packages/db-mongodb/src/models/buildSchema.ts +++ b/packages/db-mongodb/src/models/buildSchema.ts @@ -372,36 +372,61 @@ const group: FieldSchemaGenerator = ( buildSchemaOptions, parentIsLocalized, ): void => { - const formattedBaseSchema = formatBaseSchema({ buildSchemaOptions, field, parentIsLocalized }) + if (fieldAffectsData(field)) { + const formattedBaseSchema = formatBaseSchema({ buildSchemaOptions, field, parentIsLocalized }) - // carry indexSortableFields through to versions if drafts enabled - const indexSortableFields = - buildSchemaOptions.indexSortableFields && - field.name === 'version' && - buildSchemaOptions.draftsEnabled + // carry indexSortableFields through to versions if drafts enabled + const indexSortableFields = + buildSchemaOptions.indexSortableFields && + field.name === 'version' && + buildSchemaOptions.draftsEnabled - const baseSchema: SchemaTypeOptions = { - ...formattedBaseSchema, - type: buildSchema({ - buildSchemaOptions: { - disableUnique: buildSchemaOptions.disableUnique, - draftsEnabled: buildSchemaOptions.draftsEnabled, - indexSortableFields, - options: { - _id: false, - id: false, - minimize: false, + const baseSchema: SchemaTypeOptions = { + ...formattedBaseSchema, + type: buildSchema({ + buildSchemaOptions: { + disableUnique: buildSchemaOptions.disableUnique, + draftsEnabled: buildSchemaOptions.draftsEnabled, + indexSortableFields, + options: { + _id: false, + id: false, + minimize: false, + }, }, - }, - configFields: field.fields, - parentIsLocalized: parentIsLocalized || field.localized, - payload, - }), - } + configFields: field.fields, + parentIsLocalized: parentIsLocalized || field.localized, + payload, + }), + } - schema.add({ - [field.name]: localizeSchema(field, baseSchema, payload.config.localization, parentIsLocalized), - }) + schema.add({ + [field.name]: localizeSchema( + field, + baseSchema, + payload.config.localization, + parentIsLocalized, + ), + }) + } else { + field.fields.forEach((subField) => { + if (fieldIsVirtual(subField)) { + return + } + + const addFieldSchema = getSchemaGenerator(subField.type) + + if (addFieldSchema) { + addFieldSchema( + subField, + schema, + payload, + buildSchemaOptions, + (parentIsLocalized || field.localized) ?? false, + ) + } + }) + } } const json: FieldSchemaGenerator = ( diff --git a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts index c17c239b73..3de2aa6cba 100644 --- a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts +++ b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts @@ -105,6 +105,7 @@ export const sanitizeQueryValue = ({ | undefined => { let formattedValue = val let formattedOperator = operator + if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) { const segments = path.split('.') segments.shift() diff --git a/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts b/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts index 98a659643c..bd80cc99d7 100644 --- a/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts +++ b/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts @@ -128,7 +128,6 @@ const traverseFields = ({ break } - case 'blocks': { const blocksSelect = select[field.name] as SelectType diff --git a/packages/graphql/src/schema/buildMutationInputType.ts b/packages/graphql/src/schema/buildMutationInputType.ts index 44e627b8ab..448ac80570 100644 --- a/packages/graphql/src/schema/buildMutationInputType.ts +++ b/packages/graphql/src/schema/buildMutationInputType.ts @@ -145,27 +145,37 @@ export function buildMutationInputType({ }, }), group: (inputObjectTypeConfig: InputObjectTypeConfig, field: GroupField) => { - const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field) - const fullName = combineParentName(parentName, toWords(field.name, true)) - let type: GraphQLType = buildMutationInputType({ - name: fullName, - config, - fields: field.fields, - graphqlResult, - parentIsLocalized: parentIsLocalized || field.localized, - parentName: fullName, - }) + if (fieldAffectsData(field)) { + const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field) + const fullName = combineParentName(parentName, toWords(field.name, true)) + let type: GraphQLType = buildMutationInputType({ + name: fullName, + config, + fields: field.fields, + graphqlResult, + parentIsLocalized: parentIsLocalized || field.localized, + parentName: fullName, + }) - if (!type) { - return inputObjectTypeConfig - } + if (!type) { + return inputObjectTypeConfig + } - if (requiresAtLeastOneField) { - type = new GraphQLNonNull(type) - } - return { - ...inputObjectTypeConfig, - [formatName(field.name)]: { type }, + if (requiresAtLeastOneField) { + type = new GraphQLNonNull(type) + } + return { + ...inputObjectTypeConfig, + [formatName(field.name)]: { type }, + } + } else { + return field.fields.reduce((acc, subField: CollapsibleField) => { + const addSubField = fieldToSchemaMap[subField.type] + if (addSubField) { + return addSubField(acc, subField) + } + return acc + }, inputObjectTypeConfig) } }, json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({ diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts index fc5750add9..571bc61585 100644 --- a/packages/graphql/src/schema/fieldToSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -41,7 +41,7 @@ import { } from 'graphql' import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars' import { combineQueries, createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload' -import { tabHasName } from 'payload/shared' +import { fieldAffectsData, tabHasName } from 'payload/shared' import type { Context } from '../resolvers/types.js' @@ -302,44 +302,64 @@ export const fieldToSchemaMap: FieldToSchemaMap = { field, forceNullable, graphqlResult, + newlyCreatedBlockType, objectTypeConfig, parentIsLocalized, parentName, }) => { - const interfaceName = - field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) + if (fieldAffectsData(field)) { + const interfaceName = + field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) - if (!graphqlResult.types.groupTypes[interfaceName]) { - const objectType = buildObjectType({ - name: interfaceName, - config, - fields: field.fields, - forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), - graphqlResult, - parentIsLocalized: field.localized || parentIsLocalized, - parentName: interfaceName, - }) + if (!graphqlResult.types.groupTypes[interfaceName]) { + const objectType = buildObjectType({ + name: interfaceName, + config, + fields: field.fields, + forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), + graphqlResult, + parentIsLocalized: field.localized || parentIsLocalized, + parentName: interfaceName, + }) - if (Object.keys(objectType.getFields()).length) { - graphqlResult.types.groupTypes[interfaceName] = objectType + if (Object.keys(objectType.getFields()).length) { + graphqlResult.types.groupTypes[interfaceName] = objectType + } } - } - if (!graphqlResult.types.groupTypes[interfaceName]) { - return objectTypeConfig - } + if (!graphqlResult.types.groupTypes[interfaceName]) { + return objectTypeConfig + } - return { - ...objectTypeConfig, - [formatName(field.name)]: { - type: graphqlResult.types.groupTypes[interfaceName], - resolve: (parent, args, context: Context) => { - return { - ...parent[field.name], - _id: parent._id ?? parent.id, - } + return { + ...objectTypeConfig, + [formatName(field.name)]: { + type: graphqlResult.types.groupTypes[interfaceName], + resolve: (parent, args, context: Context) => { + return { + ...parent[field.name], + _id: parent._id ?? parent.id, + } + }, }, - }, + } + } else { + return field.fields.reduce((objectTypeConfigWithCollapsibleFields, subField) => { + const addSubField: GenericFieldToSchemaMap = fieldToSchemaMap[subField.type] + if (addSubField) { + return addSubField({ + config, + field: subField, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig: objectTypeConfigWithCollapsibleFields, + parentIsLocalized, + parentName, + }) + } + return objectTypeConfigWithCollapsibleFields + }, objectTypeConfig) } }, join: ({ collectionSlug, field, graphqlResult, objectTypeConfig, parentName }) => { diff --git a/packages/payload/src/auth/getFieldsToSign.ts b/packages/payload/src/auth/getFieldsToSign.ts index f6a2b774f9..c66bf40ca3 100644 --- a/packages/payload/src/auth/getFieldsToSign.ts +++ b/packages/payload/src/auth/getFieldsToSign.ts @@ -28,25 +28,35 @@ const traverseFields = ({ break } case 'group': { - let targetResult - if (typeof field.saveToJWT === 'string') { - targetResult = field.saveToJWT - result[field.saveToJWT] = data[field.name] - } else if (field.saveToJWT) { - targetResult = field.name - result[field.name] = data[field.name] + if (fieldAffectsData(field)) { + let targetResult + if (typeof field.saveToJWT === 'string') { + targetResult = field.saveToJWT + result[field.saveToJWT] = data[field.name] + } else if (field.saveToJWT) { + targetResult = field.name + result[field.name] = data[field.name] + } + const groupData: Record = data[field.name] as Record + const groupResult = (targetResult ? result[targetResult] : result) as Record< + string, + unknown + > + traverseFields({ + data: groupData, + fields: field.fields, + result: groupResult, + }) + break + } else { + traverseFields({ + data, + fields: field.fields, + result, + }) + + break } - const groupData: Record = data[field.name] as Record - const groupResult = (targetResult ? result[targetResult] : result) as Record< - string, - unknown - > - traverseFields({ - data: groupData, - fields: field.fields, - result: groupResult, - }) - break } case 'tab': { if (tabHasName(field)) { diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 159d9657b7..65fc86105c 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -719,7 +719,7 @@ export type DateFieldClient = { } & FieldBaseClient & Pick -export type GroupField = { +export type GroupBase = { admin?: { components?: { afterInput?: CustomComponent[] @@ -729,6 +729,11 @@ export type GroupField = { hideGutter?: boolean } & Admin fields: Field[] + type: 'group' + validate?: Validate +} & Omit + +export type NamedGroupField = { /** Customize generated GraphQL and Typescript schema names. * By default, it is bound to the collection. * @@ -736,15 +741,39 @@ export type GroupField = { * **Note**: Top level types can collide, ensure they are unique amongst collections, arrays, groups, blocks, tabs. */ interfaceName?: string - type: 'group' - validate?: Validate -} & Omit +} & GroupBase -export type GroupFieldClient = { - admin?: AdminClient & Pick +export type UnnamedGroupField = { + interfaceName?: never + /** + * Can be either: + * - A string, which will be used as the tab's label. + * - An object, where the key is the language code and the value is the label. + */ + label: + | { + [selectedLanguage: string]: string + } + | LabelFunction + | string + localized?: never +} & Omit + +export type GroupField = NamedGroupField | UnnamedGroupField + +export type NamedGroupFieldClient = { + admin?: AdminClient & Pick fields: ClientField[] } & Omit & - Pick + Pick + +export type UnnamedGroupFieldClient = { + admin?: AdminClient & Pick + fields: ClientField[] +} & Omit & + Pick + +export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient export type RowField = { admin?: Omit @@ -1611,6 +1640,7 @@ export type FlattenedBlocksField = { export type FlattenedGroupField = { flattenedFields: FlattenedField[] + name: string } & GroupField export type FlattenedArrayField = { @@ -1728,9 +1758,9 @@ export type FieldAffectingData = | CodeField | DateField | EmailField - | GroupField | JoinField | JSONField + | NamedGroupField | NumberField | PointField | RadioField @@ -1749,9 +1779,9 @@ export type FieldAffectingDataClient = | CodeFieldClient | DateFieldClient | EmailFieldClient - | GroupFieldClient | JoinFieldClient | JSONFieldClient + | NamedGroupFieldClient | NumberFieldClient | PointFieldClient | RadioFieldClient @@ -1771,8 +1801,8 @@ export type NonPresentationalField = | CollapsibleField | DateField | EmailField - | GroupField | JSONField + | NamedGroupField | NumberField | PointField | RadioField @@ -1793,8 +1823,8 @@ export type NonPresentationalFieldClient = | CollapsibleFieldClient | DateFieldClient | EmailFieldClient - | GroupFieldClient | JSONFieldClient + | NamedGroupFieldClient | NumberFieldClient | PointFieldClient | RadioFieldClient diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index dc18f307d0..8d348fe209 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -212,25 +212,47 @@ export const promise = async ({ } case 'group': { - await traverseFields({ - blockData, - collection, - context, - data, - doc, - fields: field.fields, - global, - operation, - parentIndexPath: '', - parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, - parentSchemaPath: schemaPath, - previousDoc, - previousSiblingDoc: previousDoc[field.name] as JsonObject, - req, - siblingData: (siblingData?.[field.name] as JsonObject) || {}, - siblingDoc: siblingDoc[field.name] as JsonObject, - }) + if (fieldAffectsData(field)) { + await traverseFields({ + blockData, + collection, + context, + data, + doc, + fields: field.fields, + global, + operation, + parentIndexPath: '', + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: path, + parentSchemaPath: schemaPath, + previousDoc, + previousSiblingDoc: previousDoc[field.name] as JsonObject, + req, + siblingData: (siblingData?.[field.name] as JsonObject) || {}, + siblingDoc: siblingDoc[field.name] as JsonObject, + }) + } else { + await traverseFields({ + blockData, + collection, + context, + data, + doc, + fields: field.fields, + global, + operation, + parentIndexPath: indexPath, + parentIsLocalized, + parentPath, + parentSchemaPath: schemaPath, + previousDoc, + previousSiblingDoc: { ...previousSiblingDoc }, + req, + siblingData: siblingData || {}, + siblingDoc: { ...siblingDoc }, + }) + } break } diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 20612d465c..a06c7ab706 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -186,7 +186,7 @@ export const promise = async ({ case 'group': { // Fill groups with empty objects so fields with hooks within groups can populate // themselves virtually as necessary - if (typeof siblingDoc[field.name] === 'undefined') { + if (fieldAffectsData(field) && typeof siblingDoc[field.name] === 'undefined') { siblingDoc[field.name] = {} } @@ -609,45 +609,78 @@ export const promise = async ({ } case 'group': { - let groupDoc = siblingDoc[field.name] as JsonObject + if (fieldAffectsData(field)) { + let groupDoc = siblingDoc[field.name] as JsonObject - if (typeof siblingDoc[field.name] !== 'object') { - groupDoc = {} + if (typeof siblingDoc[field.name] !== 'object') { + groupDoc = {} + } + + const groupSelect = select?.[field.name] + + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: '', + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: path, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select: typeof groupSelect === 'object' ? groupSelect : undefined, + selectMode, + showHiddenFields, + siblingDoc: groupDoc, + triggerAccessControl, + triggerHooks, + }) + } else { + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: indexPath, + parentIsLocalized, + parentPath, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select, + selectMode, + showHiddenFields, + siblingDoc, + triggerAccessControl, + triggerHooks, + }) } - const groupSelect = select?.[field.name] - - traverseFields({ - blockData, - collection, - context, - currentDepth, - depth, - doc, - draft, - fallbackLocale, - fieldPromises, - fields: field.fields, - findMany, - flattenLocales, - global, - locale, - overrideAccess, - parentIndexPath: '', - parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, - parentSchemaPath: schemaPath, - populate, - populationPromises, - req, - select: typeof groupSelect === 'object' ? groupSelect : undefined, - selectMode, - showHiddenFields, - siblingDoc: groupDoc, - triggerAccessControl, - triggerHooks, - }) - break } diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 89dee90827..27b5978c48 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -390,17 +390,42 @@ export const promise = async ({ } case 'group': { - if (typeof siblingData[field.name] !== 'object') { - siblingData[field.name] = {} + let groupSiblingData = siblingData + let groupSiblingDoc = siblingDoc + let groupSiblingDocWithLocales = siblingDocWithLocales + + const isNamedGroup = fieldAffectsData(field) + + if (isNamedGroup) { + if (typeof siblingData[field.name] !== 'object') { + siblingData[field.name] = {} + } + + if (typeof siblingDoc[field.name] !== 'object') { + siblingDoc[field.name] = {} + } + + if (typeof siblingDocWithLocales[field.name] !== 'object') { + siblingDocWithLocales[field.name] = {} + } + if (typeof siblingData[field.name] !== 'object') { + siblingData[field.name] = {} + } + + if (typeof siblingDoc[field.name] !== 'object') { + siblingDoc[field.name] = {} + } + + if (typeof siblingDocWithLocales[field.name] !== 'object') { + siblingDocWithLocales[field.name] = {} + } + + groupSiblingData = siblingData[field.name] as JsonObject + groupSiblingDoc = siblingDoc[field.name] as JsonObject + groupSiblingDocWithLocales = siblingDocWithLocales[field.name] as JsonObject } - if (typeof siblingDoc[field.name] !== 'object') { - siblingDoc[field.name] = {} - } - - if (typeof siblingDocWithLocales[field.name] !== 'object') { - siblingDocWithLocales[field.name] = {} - } + const fallbackLabel = field?.label || (isNamedGroup ? field.name : field?.type) await traverseFields({ id, @@ -414,23 +439,20 @@ export const promise = async ({ fieldLabelPath: field?.label === false ? fieldLabelPath - : buildFieldLabel( - fieldLabelPath, - getTranslatedLabel(field?.label || field?.name, req.i18n), - ), + : buildFieldLabel(fieldLabelPath, getTranslatedLabel(fallbackLabel, req.i18n)), fields: field.fields, global, mergeLocaleActions, operation, overrideAccess, - parentIndexPath: '', + parentIndexPath: isNamedGroup ? '' : indexPath, parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, + parentPath: isNamedGroup ? path : parentPath, parentSchemaPath: schemaPath, req, - siblingData: siblingData[field.name] as JsonObject, - siblingDoc: siblingDoc[field.name] as JsonObject, - siblingDocWithLocales: siblingDocWithLocales[field.name] as JsonObject, + siblingData: groupSiblingData, + siblingDoc: groupSiblingDoc, + siblingDocWithLocales: groupSiblingDocWithLocales, skipValidation: skipValidationFromHere, }) diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts index 2daa2a91b4..81e3e4481d 100644 --- a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts @@ -375,9 +375,10 @@ export const promise = async ({ } } } else { - // Finally, we traverse fields which do not affect data here + // Finally, we traverse fields which do not affect data here - collapsibles, rows, unnamed groups switch (field.type) { case 'collapsible': + case 'group': case 'row': { await traverseFields({ id, diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 2cda4e9b38..f07dbbe759 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -447,16 +447,23 @@ export const promise = async ({ } case 'group': { - if (typeof siblingData[field.name] !== 'object') { - siblingData[field.name] = {} - } + let groupSiblingData = siblingData + let groupSiblingDoc = siblingDoc - if (typeof siblingDoc[field.name] !== 'object') { - siblingDoc[field.name] = {} - } + const isNamedGroup = fieldAffectsData(field) - const groupData = siblingData[field.name] as Record - const groupDoc = siblingDoc[field.name] as Record + if (isNamedGroup) { + if (typeof siblingData[field.name] !== 'object') { + siblingData[field.name] = {} + } + + if (typeof siblingDoc[field.name] !== 'object') { + siblingDoc[field.name] = {} + } + + groupSiblingData = siblingData[field.name] as Record + groupSiblingDoc = siblingDoc[field.name] as Record + } await traverseFields({ id, @@ -469,13 +476,13 @@ export const promise = async ({ global, operation, overrideAccess, - parentIndexPath: '', + parentIndexPath: isNamedGroup ? '' : indexPath, parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, + parentPath: isNamedGroup ? path : parentPath, parentSchemaPath: schemaPath, req, - siblingData: groupData as JsonObject, - siblingDoc: groupDoc as JsonObject, + siblingData: groupSiblingData, + siblingDoc: groupSiblingDoc, }) break diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index f4d876f806..291d50e632 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1282,6 +1282,8 @@ export type { JSONFieldClient, Labels, LabelsClient, + NamedGroupField, + NamedGroupFieldClient, NamedTab, NonPresentationalField, NonPresentationalFieldClient, @@ -1318,6 +1320,8 @@ export type { TextFieldClient, UIField, UIFieldClient, + UnnamedGroupField, + UnnamedGroupFieldClient, UnnamedTab, UploadField, UploadFieldClient, diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index c00c95cbe6..daee3a0060 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -367,25 +367,26 @@ export function fieldsToJSONSchema( break } - case 'group': - case 'tab': { - fieldSchema = { - ...baseFieldSchema, - type: 'object', - additionalProperties: false, - ...fieldsToJSONSchema( - collectionIDFieldTypes, - field.flattenedFields, - interfaceNameDefinitions, - config, - i18n, - ), - } + case 'group': { + if (fieldAffectsData(field)) { + fieldSchema = { + ...baseFieldSchema, + type: 'object', + additionalProperties: false, + ...fieldsToJSONSchema( + collectionIDFieldTypes, + field.flattenedFields, + interfaceNameDefinitions, + config, + i18n, + ), + } - if (field.interfaceName) { - interfaceNameDefinitions.set(field.interfaceName, fieldSchema) + if (field.interfaceName) { + interfaceNameDefinitions.set(field.interfaceName, fieldSchema) - fieldSchema = { $ref: `#/definitions/${field.interfaceName}` } + fieldSchema = { $ref: `#/definitions/${field.interfaceName}` } + } } break } @@ -486,6 +487,7 @@ export function fieldsToJSONSchema( } break } + case 'radio': { fieldSchema = { ...baseFieldSchema, @@ -503,7 +505,6 @@ export function fieldsToJSONSchema( break } - case 'relationship': case 'upload': { if (Array.isArray(field.relationTo)) { @@ -595,7 +596,6 @@ export function fieldsToJSONSchema( break } - case 'richText': { if (!field?.editor) { throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor @@ -628,6 +628,7 @@ export function fieldsToJSONSchema( break } + case 'select': { const optionEnums = buildOptionEnums(field.options) // We get the previous field to check for a date in the case of a timezone select @@ -675,6 +676,27 @@ export function fieldsToJSONSchema( break } + case 'tab': { + fieldSchema = { + ...baseFieldSchema, + type: 'object', + additionalProperties: false, + ...fieldsToJSONSchema( + collectionIDFieldTypes, + field.flattenedFields, + interfaceNameDefinitions, + config, + i18n, + ), + } + + if (field.interfaceName) { + interfaceNameDefinitions.set(field.interfaceName, fieldSchema) + + fieldSchema = { $ref: `#/definitions/${field.interfaceName}` } + } + break + } case 'text': if (field.hasMany === true) { diff --git a/packages/payload/src/utilities/fieldSchemaToJSON.ts b/packages/payload/src/utilities/fieldSchemaToJSON.ts index 0b94e9f1eb..2f0e285197 100644 --- a/packages/payload/src/utilities/fieldSchemaToJSON.ts +++ b/packages/payload/src/utilities/fieldSchemaToJSON.ts @@ -1,7 +1,8 @@ import type { ClientConfig } from '../config/client.js' // @ts-strict-ignore import type { ClientField } from '../fields/config/client.js' -import type { FieldTypes } from '../fields/config/types.js' + +import { fieldAffectsData, type FieldTypes } from '../fields/config/types.js' export type FieldSchemaJSON = { blocks?: FieldSchemaJSON // TODO: conditionally add based on `type` @@ -67,11 +68,15 @@ export const fieldSchemaToJSON = (fields: ClientField[], config: ClientConfig): break case 'group': - acc.push({ - name: field.name, - type: field.type, - fields: fieldSchemaToJSON(field.fields, config), - }) + if (fieldAffectsData(field)) { + acc.push({ + name: field.name, + type: field.type, + fields: fieldSchemaToJSON(field.fields, config), + }) + } else { + result = result.concat(fieldSchemaToJSON(field.fields, config)) + } break diff --git a/packages/payload/src/utilities/flattenAllFields.ts b/packages/payload/src/utilities/flattenAllFields.ts index 97fa0b5dd3..9173e0885a 100644 --- a/packages/payload/src/utilities/flattenAllFields.ts +++ b/packages/payload/src/utilities/flattenAllFields.ts @@ -7,7 +7,7 @@ import type { FlattenedJoinField, } from '../fields/config/types.js' -import { tabHasName } from '../fields/config/types.js' +import { fieldAffectsData, tabHasName } from '../fields/config/types.js' export const flattenBlock = ({ block }: { block: Block }): FlattenedBlock => { return { @@ -44,7 +44,13 @@ export const flattenAllFields = ({ switch (field.type) { case 'array': case 'group': { - result.push({ ...field, flattenedFields: flattenAllFields({ fields: field.fields }) }) + if (fieldAffectsData(field)) { + result.push({ ...field, flattenedFields: flattenAllFields({ fields: field.fields }) }) + } else { + for (const nestedField of flattenAllFields({ fields: field.fields })) { + result.push(nestedField) + } + } break } diff --git a/packages/payload/src/utilities/traverseFields.ts b/packages/payload/src/utilities/traverseFields.ts index 63c01f7a55..7768b03eab 100644 --- a/packages/payload/src/utilities/traverseFields.ts +++ b/packages/payload/src/utilities/traverseFields.ts @@ -2,7 +2,11 @@ import type { Config, SanitizedConfig } from '../config/types.js' import type { ArrayField, Block, BlocksField, Field, TabAsField } from '../fields/config/types.js' -import { fieldHasSubFields, fieldShouldBeLocalized } from '../fields/config/types.js' +import { + fieldAffectsData, + fieldHasSubFields, + fieldShouldBeLocalized, +} from '../fields/config/types.js' const traverseArrayOrBlocksField = ({ callback, @@ -329,22 +333,38 @@ export const traverseFields = ({ currentRef && typeof currentRef === 'object' ) { - for (const key in currentRef as Record) { - if (currentRef[key]) { - traverseFields({ - callback, - callbackStack, - config, - fields: field.fields, - fillEmpty, - isTopLevel: false, - leavesFirst, - parentIsLocalized: true, - parentRef: currentParentRef, - ref: currentRef[key], - }) + if (fieldAffectsData(field)) { + for (const key in currentRef as Record) { + if (currentRef[key]) { + traverseFields({ + callback, + callbackStack, + config, + fields: field.fields, + fillEmpty, + isTopLevel: false, + leavesFirst, + parentIsLocalized: true, + parentRef: currentParentRef, + ref: currentRef[key], + }) + } } + } else { + traverseFields({ + callback, + callbackStack, + config, + fields: field.fields, + fillEmpty, + isTopLevel: false, + leavesFirst, + parentIsLocalized, + parentRef: currentParentRef, + ref: currentRef, + }) } + return } diff --git a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx index 76728b1c72..dadcba7c85 100644 --- a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx +++ b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx @@ -3,7 +3,7 @@ import type { ClientTranslationKeys, I18nClient } from '@payloadcms/translations import type { ClientField } from 'payload' import { getTranslation } from '@payloadcms/translations' -import { fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared' +import { fieldAffectsData, fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared' import { renderToStaticMarkup } from 'react-dom/server' import type { ReducedField } from './types.js' @@ -100,7 +100,46 @@ export const reduceFields = ({ return reduced } - if ((field.type === 'group' || field.type === 'array') && 'fields' in field) { + if (field.type === 'group' && 'fields' in field) { + const translatedLabel = getTranslation(field.label || '', i18n) + + const labelWithPrefix = labelPrefix + ? translatedLabel + ? labelPrefix + ' > ' + translatedLabel + : labelPrefix + : translatedLabel + + if (fieldAffectsData(field)) { + // Make sure we handle deeply nested groups + const pathWithPrefix = field.name + ? pathPrefix + ? pathPrefix + '.' + field.name + : field.name + : pathPrefix + + reduced.push( + ...reduceFields({ + fields: field.fields, + i18n, + labelPrefix: labelWithPrefix, + pathPrefix: pathWithPrefix, + }), + ) + } else { + reduced.push( + ...reduceFields({ + fields: field.fields, + i18n, + labelPrefix: labelWithPrefix, + pathPrefix, + }), + ) + } + + return reduced + } + + if (field.type === 'array' && 'fields' in field) { const translatedLabel = getTranslation(field.label || '', i18n) const labelWithPrefix = labelPrefix diff --git a/packages/ui/src/fields/Group/index.tsx b/packages/ui/src/fields/Group/index.tsx index d46c53d876..e11df20098 100644 --- a/packages/ui/src/fields/Group/index.tsx +++ b/packages/ui/src/fields/Group/index.tsx @@ -28,6 +28,9 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => { const { field, field: { name, admin: { className, description, hideGutter } = {}, fields, label }, + indexPath, + parentPath, + parentSchemaPath, path, permissions, readOnly, @@ -102,15 +105,28 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => { )} {BeforeInput} - + {/* Render an unnamed group differently */} + {name ? ( + + ) : ( + + )} {AfterInput} diff --git a/packages/ui/src/fields/Join/index.tsx b/packages/ui/src/fields/Join/index.tsx index 43610cb406..334f8f506f 100644 --- a/packages/ui/src/fields/Join/index.tsx +++ b/packages/ui/src/fields/Join/index.tsx @@ -10,7 +10,7 @@ import type { } from 'payload' import ObjectIdImport from 'bson-objectid' -import { flattenTopLevelFields } from 'payload/shared' +import { fieldAffectsData, flattenTopLevelFields } from 'payload/shared' import React, { useMemo } from 'react' import { RelationshipTable } from '../../elements/RelationshipTable/index.js' @@ -68,7 +68,7 @@ const getInitialDrawerData = ({ const nextSegments = segments.slice(1, segments.length) - if (field.type === 'tab' || field.type === 'group') { + if (field.type === 'tab' || (field.type === 'group' && fieldAffectsData(field))) { return { [field.name]: getInitialDrawerData({ collectionSlug, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 56035e4d1e..7032e2869c 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -734,7 +734,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom } } } else if (fieldHasSubFields(field) && !fieldAffectsData(field)) { - // Handle field types that do not use names (row, collapsible, etc) + // Handle field types that do not use names (row, collapsible, unnamed group etc) if (!filter || filter(args)) { state[path] = { diff --git a/packages/ui/src/forms/fieldSchemasToFormState/calculateDefaultValues/promise.ts b/packages/ui/src/forms/fieldSchemasToFormState/calculateDefaultValues/promise.ts index 25b37c6b3d..70a8dff242 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/calculateDefaultValues/promise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/calculateDefaultValues/promise.ts @@ -163,26 +163,40 @@ export const defaultValuePromise = async ({ break } case 'group': { - if (typeof siblingData[field.name] !== 'object') { - siblingData[field.name] = {} + if (fieldAffectsData(field)) { + if (typeof siblingData[field.name] !== 'object') { + siblingData[field.name] = {} + } + + const groupData = siblingData[field.name] as Record + + const groupSelect = select?.[field.name] + + await iterateFields({ + id, + data, + fields: field.fields, + locale, + req, + select: typeof groupSelect === 'object' ? groupSelect : undefined, + selectMode, + siblingData: groupData, + user, + }) + } else { + await iterateFields({ + id, + data, + fields: field.fields, + locale, + req, + select, + selectMode, + siblingData, + user, + }) } - const groupData = siblingData[field.name] as Record - - const groupSelect = select?.[field.name] - - await iterateFields({ - id, - data, - fields: field.fields, - locale, - req, - select: typeof groupSelect === 'object' ? groupSelect : undefined, - selectMode, - siblingData: groupData, - user, - }) - break } diff --git a/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts b/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts index f52a686def..a77c98756a 100644 --- a/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts +++ b/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts @@ -1,7 +1,6 @@ import type { I18n } from '@payloadcms/translations' import { - type ClientBlock, type ClientConfig, type ClientField, type ClientFieldSchemaMap, @@ -10,7 +9,7 @@ import { type FieldSchemaMap, type Payload, } from 'payload' -import { getFieldPaths, tabHasName } from 'payload/shared' +import { fieldAffectsData, getFieldPaths, tabHasName } from 'payload/shared' type Args = { clientSchemaMap: ClientFieldSchemaMap @@ -45,8 +44,7 @@ export const traverseFields = ({ clientSchemaMap.set(schemaPath, field) switch (field.type) { - case 'array': - case 'group': + case 'array': { traverseFields({ clientSchemaMap, config, @@ -59,6 +57,7 @@ export const traverseFields = ({ }) break + } case 'blocks': ;(field.blockReferences ?? field.blocks).map((_block) => { @@ -85,6 +84,7 @@ export const traverseFields = ({ }) break + case 'collapsible': case 'row': traverseFields({ @@ -99,6 +99,33 @@ export const traverseFields = ({ }) break + case 'group': { + if (fieldAffectsData(field)) { + traverseFields({ + clientSchemaMap, + config, + fields: field.fields, + i18n, + parentIndexPath: '', + parentSchemaPath: schemaPath, + payload, + schemaMap, + }) + } else { + traverseFields({ + clientSchemaMap, + config, + fields: field.fields, + i18n, + parentIndexPath: indexPath, + parentSchemaPath, + payload, + schemaMap, + }) + } + break + } + case 'richText': { // richText sub-fields are not part of the ClientConfig or the Config. // They only exist in the field schema map. diff --git a/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts b/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts index 805faa011d..a251b46e8f 100644 --- a/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts +++ b/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts @@ -2,7 +2,7 @@ import type { I18n } from '@payloadcms/translations' import type { Field, FieldSchemaMap, SanitizedConfig } from 'payload' import { MissingEditorProp } from 'payload' -import { getFieldPaths, tabHasName } from 'payload/shared' +import { fieldAffectsData, getFieldPaths, tabHasName } from 'payload/shared' type Args = { config: SanitizedConfig @@ -34,7 +34,6 @@ export const traverseFields = ({ switch (field.type) { case 'array': - case 'group': traverseFields({ config, fields: field.fields, @@ -66,6 +65,7 @@ export const traverseFields = ({ }) break + case 'collapsible': case 'row': traverseFields({ @@ -79,6 +79,29 @@ export const traverseFields = ({ break + case 'group': + if (fieldAffectsData(field)) { + traverseFields({ + config, + fields: field.fields, + i18n, + parentIndexPath: '', + parentSchemaPath: schemaPath, + schemaMap, + }) + } else { + traverseFields({ + config, + fields: field.fields, + i18n, + parentIndexPath: indexPath, + parentSchemaPath, + schemaMap, + }) + } + + break + case 'richText': if (!field?.editor) { throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor diff --git a/packages/ui/src/utilities/copyDataFromLocale.ts b/packages/ui/src/utilities/copyDataFromLocale.ts index 5bf9711c38..7d11a2e5cd 100644 --- a/packages/ui/src/utilities/copyDataFromLocale.ts +++ b/packages/ui/src/utilities/copyDataFromLocale.ts @@ -130,7 +130,11 @@ function iterateFields( break case 'group': { - if (field.name in toLocaleData && fromLocaleData?.[field.name] !== undefined) { + if ( + fieldAffectsData(field) && + field.name in toLocaleData && + fromLocaleData?.[field.name] !== undefined + ) { iterateFields( field.fields, fromLocaleData[field.name], @@ -138,6 +142,8 @@ function iterateFields( req, parentIsLocalized || field.localized, ) + } else { + iterateFields(field.fields, fromLocaleData, toLocaleData, req, parentIsLocalized) } break } diff --git a/test/fields/collections/Group/e2e.spec.ts b/test/fields/collections/Group/e2e.spec.ts new file mode 100644 index 0000000000..d49fbabf43 --- /dev/null +++ b/test/fields/collections/Group/e2e.spec.ts @@ -0,0 +1,123 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { addListFilter } from 'helpers/e2e/addListFilter.js' +import path from 'path' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureCompilationIsDone, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { assertToastErrors } from '../../../helpers/assertToastErrors.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../helpers/rest.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { groupFieldsSlug } from '../../slugs.js' +import { namedGroupDoc } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Group', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, groupFieldsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ page, serverURL }) + }) + + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + if (client) { + await client.logout() + } + client = new RESTClient({ defaultSlug: 'users', serverURL }) + await client.login() + await ensureCompilationIsDone({ page, serverURL }) + }) + + describe('Named', () => { + test('should display field in list view', async () => { + await page.goto(url.list) + + const textCell = page.locator('.row-1 .cell-group') + + await expect(textCell).toContainText(JSON.stringify(namedGroupDoc.group?.text), { + useInnerText: true, + }) + }) + }) + + describe('Unnamed', () => { + test('should display field in list view', async () => { + await page.goto(url.list) + + const textCell = page.locator('.row-1 .cell-insideUnnamedGroup') + + await expect(textCell).toContainText(namedGroupDoc?.insideUnnamedGroup ?? '', { + useInnerText: true, + }) + }) + + test('should display field in list view deeply nested', async () => { + await page.goto(url.list) + + const textCell = page.locator('.row-1 .cell-deeplyNestedGroup') + + await expect(textCell).toContainText(JSON.stringify(namedGroupDoc.deeplyNestedGroup), { + useInnerText: true, + }) + }) + + test('should display field visually within nested groups', async () => { + await page.goto(url.create) + + // Makes sure the fields are rendered + await page.mouse.wheel(0, 2000) + + const unnamedGroupSelector = `.field-type.group-field #field-insideUnnamedGroup` + const unnamedGroupField = page.locator(unnamedGroupSelector) + + await expect(unnamedGroupField).toBeVisible() + + // Makes sure the fields are rendered + await page.mouse.wheel(0, 2000) + + // A bit repetitive but this selector should fail if the group is not nested + const unnamedNestedGroupSelector = `.field-type.group-field .field-type.group-field .field-type.group-field .field-type.group-field .field-type.group-field #field-deeplyNestedGroup__insideNestedUnnamedGroup` + const unnamedNestedGroupField = page.locator(unnamedNestedGroupSelector) + await expect(unnamedNestedGroupField).toBeVisible() + }) + }) +}) diff --git a/test/fields/collections/Group/index.ts b/test/fields/collections/Group/index.ts index b09c356fc8..1323c92fef 100644 --- a/test/fields/collections/Group/index.ts +++ b/test/fields/collections/Group/index.ts @@ -8,6 +8,9 @@ export const groupDefaultChild = 'child takes priority' const GroupFields: CollectionConfig = { slug: groupFieldsSlug, versions: true, + admin: { + defaultColumns: ['id', 'group', 'insideUnnamedGroup', 'deeplyNestedGroup'], + }, fields: [ { label: 'Group Field', @@ -301,6 +304,51 @@ const GroupFields: CollectionConfig = { }, ], }, + { + type: 'group', + label: 'Unnamed group', + fields: [ + { + type: 'text', + name: 'insideUnnamedGroup', + }, + ], + }, + { + type: 'group', + label: 'Deeply nested group', + fields: [ + { + type: 'group', + label: 'Deeply nested group', + fields: [ + { + type: 'group', + name: 'deeplyNestedGroup', + label: 'Deeply nested group', + fields: [ + { + type: 'group', + label: 'Deeply nested group', + fields: [ + { + type: 'group', + label: 'Deeply nested group', + fields: [ + { + type: 'text', + name: 'insideNestedUnnamedGroup', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, ], } diff --git a/test/fields/collections/Group/shared.ts b/test/fields/collections/Group/shared.ts index 8fe425b69a..021f4b63fd 100644 --- a/test/fields/collections/Group/shared.ts +++ b/test/fields/collections/Group/shared.ts @@ -1,6 +1,6 @@ import type { GroupField } from '../../payload-types.js' -export const groupDoc: Partial = { +export const namedGroupDoc: Partial = { group: { text: 'some text within a group', subGroup: { @@ -12,4 +12,8 @@ export const groupDoc: Partial = { ], }, }, + insideUnnamedGroup: 'text in unnamed group', + deeplyNestedGroup: { + insideNestedUnnamedGroup: 'text in nested unnamed group', + }, } diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 5ab7350323..157ee33ec5 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -15,7 +15,7 @@ import { arrayDefaultValue } from './collections/Array/index.js' import { blocksDoc } from './collections/Blocks/shared.js' import { dateDoc } from './collections/Date/shared.js' import { groupDefaultChild, groupDefaultValue } from './collections/Group/index.js' -import { groupDoc } from './collections/Group/shared.js' +import { namedGroupDoc } from './collections/Group/shared.js' import { defaultNumber } from './collections/Number/index.js' import { numberDoc } from './collections/Number/shared.js' import { pointDoc } from './collections/Point/shared.js' @@ -1614,7 +1614,7 @@ describe('Fields', () => { it('should create with ids and nested ids', async () => { const docWithIDs = (await payload.create({ collection: groupFieldsSlug, - data: groupDoc, + data: namedGroupDoc, })) as Partial expect(docWithIDs.group.subGroup.arrayWithinGroup[0].id).toBeDefined() }) @@ -1913,6 +1913,53 @@ describe('Fields', () => { }) }) + it('should work with unnamed group', async () => { + const groupDoc = await payload.create({ + collection: groupFieldsSlug, + data: { + insideUnnamedGroup: 'Hello world', + deeplyNestedGroup: { insideNestedUnnamedGroup: 'Secondfield' }, + }, + }) + expect(groupDoc).toMatchObject({ + id: expect.anything(), + insideUnnamedGroup: 'Hello world', + deeplyNestedGroup: { + insideNestedUnnamedGroup: 'Secondfield', + }, + }) + }) + + it('should work with unnamed group - graphql', async () => { + const mutation = `mutation { + createGroupField( + data: { + insideUnnamedGroup: "Hello world", + deeplyNestedGroup: { insideNestedUnnamedGroup: "Secondfield" }, + group: {text: "hello"} + } + ) { + insideUnnamedGroup + deeplyNestedGroup { + insideNestedUnnamedGroup + } + } + }` + + const groupDoc = await restClient.GRAPHQL_POST({ + body: JSON.stringify({ query: mutation }), + }) + + const data = (await groupDoc.json()).data.createGroupField + + expect(data).toMatchObject({ + insideUnnamedGroup: 'Hello world', + deeplyNestedGroup: { + insideNestedUnnamedGroup: 'Secondfield', + }, + }) + }) + it('should query a subfield within a localized group', async () => { const text = 'find this' const hit = await payload.create({ @@ -2357,7 +2404,7 @@ describe('Fields', () => { it('should return empty object for groups when no data present', async () => { const doc = await payload.create({ collection: groupFieldsSlug, - data: groupDoc, + data: namedGroupDoc, }) expect(doc.potentiallyEmptyGroup).toBeDefined() diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index c303ea6b4c..dd8b11bc81 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -1080,6 +1080,10 @@ export interface GroupField { }[] | null; }; + insideUnnamedGroup?: string | null; + deeplyNestedGroup?: { + insideNestedUnnamedGroup?: string | null; + }; updatedAt: string; createdAt: string; } @@ -2676,6 +2680,12 @@ export interface GroupFieldsSelect { | { email?: T; }; + insideUnnamedGroup?: T; + deeplyNestedGroup?: + | T + | { + insideNestedUnnamedGroup?: T; + }; updatedAt?: T; createdAt?: T; } diff --git a/test/fields/seed.ts b/test/fields/seed.ts index 572a987f8d..ac71bad5d9 100644 --- a/test/fields/seed.ts +++ b/test/fields/seed.ts @@ -16,7 +16,7 @@ import { conditionalLogicDoc } from './collections/ConditionalLogic/shared.js' import { customRowID, customTabID, nonStandardID } from './collections/CustomID/shared.js' import { dateDoc } from './collections/Date/shared.js' import { anotherEmailDoc, emailDoc } from './collections/Email/shared.js' -import { groupDoc } from './collections/Group/shared.js' +import { namedGroupDoc } from './collections/Group/shared.js' import { jsonDoc } from './collections/JSON/shared.js' import { numberDoc } from './collections/Number/shared.js' import { pointDoc } from './collections/Point/shared.js' @@ -223,7 +223,7 @@ export const seed = async (_payload: Payload) => { await _payload.create({ collection: groupFieldsSlug, - data: groupDoc, + data: namedGroupDoc, depth: 0, overrideAccess: true, }) diff --git a/test/types/config.ts b/test/types/config.ts index a929272ff1..f33cf08169 100644 --- a/test/types/config.ts +++ b/test/types/config.ts @@ -38,6 +38,26 @@ export default buildConfigWithDefaults({ }, ], }, + { + type: 'group', + label: 'Unnamed Group', + fields: [ + { + type: 'text', + name: 'insideUnnamedGroup', + }, + ], + }, + { + type: 'group', + name: 'namedGroup', + fields: [ + { + type: 'text', + name: 'insideNamedGroup', + }, + ], + }, { name: 'radioField', type: 'radio', diff --git a/test/types/payload-types.ts b/test/types/payload-types.ts index 480f4509c6..543350e287 100644 --- a/test/types/payload-types.ts +++ b/test/types/payload-types.ts @@ -144,6 +144,10 @@ export interface Post { text?: string | null; title?: string | null; selectField: MySelectOptions; + insideUnnamedGroup?: string | null; + namedGroup?: { + insideNamedGroup?: string | null; + }; radioField: MyRadioOptions; updatedAt: string; createdAt: string; @@ -264,6 +268,12 @@ export interface PostsSelect { text?: T; title?: T; selectField?: T; + insideUnnamedGroup?: T; + namedGroup?: + | T + | { + insideNamedGroup?: T; + }; radioField?: T; updatedAt?: T; createdAt?: T; diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index fb315ec113..d7184cbd50 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -145,4 +145,17 @@ describe('Types testing', () => { expect(asType()).type.toBe() }) }) + + describe('fields', () => { + describe('Group', () => { + test('correctly ignores unnamed group', () => { + expect().type.toHaveProperty('insideUnnamedGroup') + }) + + test('generates nested group name', () => { + expect().type.toHaveProperty('namedGroup') + expect>().type.toHaveProperty('insideNamedGroup') + }) + }) + }) }) diff --git a/tools/scripts/src/generateTranslations/utils/index.ts b/tools/scripts/src/generateTranslations/utils/index.ts index b3cb8e8177..6a73e7fb6c 100644 --- a/tools/scripts/src/generateTranslations/utils/index.ts +++ b/tools/scripts/src/generateTranslations/utils/index.ts @@ -129,7 +129,7 @@ export async function translateObject(props: { for (const missingKey of missingKeys) { const keys: string[] = missingKey.split('.') const sourceText = keys.reduce( - (acc, key) => acc[key] as GenericTranslationsObject, + (acc, key) => acc[key], fromTranslationsObject, ) if (!sourceText || typeof sourceText !== 'string') { diff --git a/tsconfig.base.json b/tsconfig.base.json index b7c0ef5d5c..12877b1be6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/query-presets/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],