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
<img width="332" alt="image"
src="https://github.com/user-attachments/assets/10b4315e-92d6-439e-82dd-7c815a844035"
/>


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
This commit is contained in:
Paul
2025-05-14 16:45:34 -07:00
committed by GitHub
parent d63c8baea5
commit e258cd73ef
37 changed files with 955 additions and 283 deletions

View File

@@ -35,9 +35,9 @@ export const MyGroupField: Field = {
| Option | Description | | 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. | | **`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) | | **`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. | | **`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). | | **`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', slug: 'example-collection',
fields: [ fields: [
{ {
name: 'pageMeta', // required name: 'pageMeta',
type: 'group', // required type: 'group', // required
interfaceName: 'Meta', // optional interfaceName: 'Meta', // optional
fields: [ 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,
},
],
},
],
}
```

View File

@@ -372,36 +372,61 @@ const group: FieldSchemaGenerator<GroupField> = (
buildSchemaOptions, buildSchemaOptions,
parentIsLocalized, parentIsLocalized,
): void => { ): void => {
const formattedBaseSchema = formatBaseSchema({ buildSchemaOptions, field, parentIsLocalized }) if (fieldAffectsData(field)) {
const formattedBaseSchema = formatBaseSchema({ buildSchemaOptions, field, parentIsLocalized })
// carry indexSortableFields through to versions if drafts enabled // carry indexSortableFields through to versions if drafts enabled
const indexSortableFields = const indexSortableFields =
buildSchemaOptions.indexSortableFields && buildSchemaOptions.indexSortableFields &&
field.name === 'version' && field.name === 'version' &&
buildSchemaOptions.draftsEnabled buildSchemaOptions.draftsEnabled
const baseSchema: SchemaTypeOptions<any> = { const baseSchema: SchemaTypeOptions<any> = {
...formattedBaseSchema, ...formattedBaseSchema,
type: buildSchema({ type: buildSchema({
buildSchemaOptions: { buildSchemaOptions: {
disableUnique: buildSchemaOptions.disableUnique, disableUnique: buildSchemaOptions.disableUnique,
draftsEnabled: buildSchemaOptions.draftsEnabled, draftsEnabled: buildSchemaOptions.draftsEnabled,
indexSortableFields, indexSortableFields,
options: { options: {
_id: false, _id: false,
id: false, id: false,
minimize: false, minimize: false,
},
}, },
}, configFields: field.fields,
configFields: field.fields, parentIsLocalized: parentIsLocalized || field.localized,
parentIsLocalized: parentIsLocalized || field.localized, payload,
payload, }),
}), }
}
schema.add({ schema.add({
[field.name]: localizeSchema(field, baseSchema, payload.config.localization, parentIsLocalized), [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<JSONField> = ( const json: FieldSchemaGenerator<JSONField> = (

View File

@@ -105,6 +105,7 @@ export const sanitizeQueryValue = ({
| undefined => { | undefined => {
let formattedValue = val let formattedValue = val
let formattedOperator = operator let formattedOperator = operator
if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) { if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) {
const segments = path.split('.') const segments = path.split('.')
segments.shift() segments.shift()

View File

@@ -128,7 +128,6 @@ const traverseFields = ({
break break
} }
case 'blocks': { case 'blocks': {
const blocksSelect = select[field.name] as SelectType const blocksSelect = select[field.name] as SelectType

View File

@@ -145,27 +145,37 @@ export function buildMutationInputType({
}, },
}), }),
group: (inputObjectTypeConfig: InputObjectTypeConfig, field: GroupField) => { group: (inputObjectTypeConfig: InputObjectTypeConfig, field: GroupField) => {
const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field) if (fieldAffectsData(field)) {
const fullName = combineParentName(parentName, toWords(field.name, true)) const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field)
let type: GraphQLType = buildMutationInputType({ const fullName = combineParentName(parentName, toWords(field.name, true))
name: fullName, let type: GraphQLType = buildMutationInputType({
config, name: fullName,
fields: field.fields, config,
graphqlResult, fields: field.fields,
parentIsLocalized: parentIsLocalized || field.localized, graphqlResult,
parentName: fullName, parentIsLocalized: parentIsLocalized || field.localized,
}) parentName: fullName,
})
if (!type) { if (!type) {
return inputObjectTypeConfig return inputObjectTypeConfig
} }
if (requiresAtLeastOneField) { if (requiresAtLeastOneField) {
type = new GraphQLNonNull(type) type = new GraphQLNonNull(type)
} }
return { return {
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[formatName(field.name)]: { type }, [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) => ({ json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({

View File

@@ -41,7 +41,7 @@ import {
} from 'graphql' } from 'graphql'
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars' import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'
import { combineQueries, createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload' 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' import type { Context } from '../resolvers/types.js'
@@ -302,44 +302,64 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
field, field,
forceNullable, forceNullable,
graphqlResult, graphqlResult,
newlyCreatedBlockType,
objectTypeConfig, objectTypeConfig,
parentIsLocalized, parentIsLocalized,
parentName, parentName,
}) => { }) => {
const interfaceName = if (fieldAffectsData(field)) {
field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) const interfaceName =
field?.interfaceName || combineParentName(parentName, toWords(field.name, true))
if (!graphqlResult.types.groupTypes[interfaceName]) { if (!graphqlResult.types.groupTypes[interfaceName]) {
const objectType = buildObjectType({ const objectType = buildObjectType({
name: interfaceName, name: interfaceName,
config, config,
fields: field.fields, fields: field.fields,
forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }),
graphqlResult, graphqlResult,
parentIsLocalized: field.localized || parentIsLocalized, parentIsLocalized: field.localized || parentIsLocalized,
parentName: interfaceName, parentName: interfaceName,
}) })
if (Object.keys(objectType.getFields()).length) { if (Object.keys(objectType.getFields()).length) {
graphqlResult.types.groupTypes[interfaceName] = objectType graphqlResult.types.groupTypes[interfaceName] = objectType
}
} }
}
if (!graphqlResult.types.groupTypes[interfaceName]) { if (!graphqlResult.types.groupTypes[interfaceName]) {
return objectTypeConfig return objectTypeConfig
} }
return { return {
...objectTypeConfig, ...objectTypeConfig,
[formatName(field.name)]: { [formatName(field.name)]: {
type: graphqlResult.types.groupTypes[interfaceName], type: graphqlResult.types.groupTypes[interfaceName],
resolve: (parent, args, context: Context) => { resolve: (parent, args, context: Context) => {
return { return {
...parent[field.name], ...parent[field.name],
_id: parent._id ?? parent.id, _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 }) => { join: ({ collectionSlug, field, graphqlResult, objectTypeConfig, parentName }) => {

View File

@@ -28,25 +28,35 @@ const traverseFields = ({
break break
} }
case 'group': { case 'group': {
let targetResult if (fieldAffectsData(field)) {
if (typeof field.saveToJWT === 'string') { let targetResult
targetResult = field.saveToJWT if (typeof field.saveToJWT === 'string') {
result[field.saveToJWT] = data[field.name] targetResult = field.saveToJWT
} else if (field.saveToJWT) { result[field.saveToJWT] = data[field.name]
targetResult = field.name } else if (field.saveToJWT) {
result[field.name] = data[field.name] targetResult = field.name
result[field.name] = data[field.name]
}
const groupData: Record<string, unknown> = data[field.name] as Record<string, unknown>
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<string, unknown> = data[field.name] as Record<string, unknown>
const groupResult = (targetResult ? result[targetResult] : result) as Record<
string,
unknown
>
traverseFields({
data: groupData,
fields: field.fields,
result: groupResult,
})
break
} }
case 'tab': { case 'tab': {
if (tabHasName(field)) { if (tabHasName(field)) {

View File

@@ -719,7 +719,7 @@ export type DateFieldClient = {
} & FieldBaseClient & } & FieldBaseClient &
Pick<DateField, 'timezone' | 'type'> Pick<DateField, 'timezone' | 'type'>
export type GroupField = { export type GroupBase = {
admin?: { admin?: {
components?: { components?: {
afterInput?: CustomComponent[] afterInput?: CustomComponent[]
@@ -729,6 +729,11 @@ export type GroupField = {
hideGutter?: boolean hideGutter?: boolean
} & Admin } & Admin
fields: Field[] fields: Field[]
type: 'group'
validate?: Validate<unknown, unknown, unknown, GroupField>
} & Omit<FieldBase, 'validate'>
export type NamedGroupField = {
/** Customize generated GraphQL and Typescript schema names. /** Customize generated GraphQL and Typescript schema names.
* By default, it is bound to the collection. * 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. * **Note**: Top level types can collide, ensure they are unique amongst collections, arrays, groups, blocks, tabs.
*/ */
interfaceName?: string interfaceName?: string
type: 'group' } & GroupBase
validate?: Validate<unknown, unknown, unknown, GroupField>
} & Omit<FieldBase, 'required' | 'validate'>
export type GroupFieldClient = { export type UnnamedGroupField = {
admin?: AdminClient & Pick<GroupField['admin'], 'hideGutter'> 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<GroupBase, 'name' | 'virtual'>
export type GroupField = NamedGroupField | UnnamedGroupField
export type NamedGroupFieldClient = {
admin?: AdminClient & Pick<NamedGroupField['admin'], 'hideGutter'>
fields: ClientField[] fields: ClientField[]
} & Omit<FieldBaseClient, 'required'> & } & Omit<FieldBaseClient, 'required'> &
Pick<GroupField, 'interfaceName' | 'type'> Pick<NamedGroupField, 'interfaceName' | 'type'>
export type UnnamedGroupFieldClient = {
admin?: AdminClient & Pick<UnnamedGroupField['admin'], 'hideGutter'>
fields: ClientField[]
} & Omit<FieldBaseClient, 'required'> &
Pick<UnnamedGroupField, 'label' | 'type'>
export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient
export type RowField = { export type RowField = {
admin?: Omit<Admin, 'description'> admin?: Omit<Admin, 'description'>
@@ -1611,6 +1640,7 @@ export type FlattenedBlocksField = {
export type FlattenedGroupField = { export type FlattenedGroupField = {
flattenedFields: FlattenedField[] flattenedFields: FlattenedField[]
name: string
} & GroupField } & GroupField
export type FlattenedArrayField = { export type FlattenedArrayField = {
@@ -1728,9 +1758,9 @@ export type FieldAffectingData =
| CodeField | CodeField
| DateField | DateField
| EmailField | EmailField
| GroupField
| JoinField | JoinField
| JSONField | JSONField
| NamedGroupField
| NumberField | NumberField
| PointField | PointField
| RadioField | RadioField
@@ -1749,9 +1779,9 @@ export type FieldAffectingDataClient =
| CodeFieldClient | CodeFieldClient
| DateFieldClient | DateFieldClient
| EmailFieldClient | EmailFieldClient
| GroupFieldClient
| JoinFieldClient | JoinFieldClient
| JSONFieldClient | JSONFieldClient
| NamedGroupFieldClient
| NumberFieldClient | NumberFieldClient
| PointFieldClient | PointFieldClient
| RadioFieldClient | RadioFieldClient
@@ -1771,8 +1801,8 @@ export type NonPresentationalField =
| CollapsibleField | CollapsibleField
| DateField | DateField
| EmailField | EmailField
| GroupField
| JSONField | JSONField
| NamedGroupField
| NumberField | NumberField
| PointField | PointField
| RadioField | RadioField
@@ -1793,8 +1823,8 @@ export type NonPresentationalFieldClient =
| CollapsibleFieldClient | CollapsibleFieldClient
| DateFieldClient | DateFieldClient
| EmailFieldClient | EmailFieldClient
| GroupFieldClient
| JSONFieldClient | JSONFieldClient
| NamedGroupFieldClient
| NumberFieldClient | NumberFieldClient
| PointFieldClient | PointFieldClient
| RadioFieldClient | RadioFieldClient

View File

@@ -212,25 +212,47 @@ export const promise = async ({
} }
case 'group': { case 'group': {
await traverseFields({ if (fieldAffectsData(field)) {
blockData, await traverseFields({
collection, blockData,
context, collection,
data, context,
doc, data,
fields: field.fields, doc,
global, fields: field.fields,
operation, global,
parentIndexPath: '', operation,
parentIsLocalized: parentIsLocalized || field.localized, parentIndexPath: '',
parentPath: path, parentIsLocalized: parentIsLocalized || field.localized,
parentSchemaPath: schemaPath, parentPath: path,
previousDoc, parentSchemaPath: schemaPath,
previousSiblingDoc: previousDoc[field.name] as JsonObject, previousDoc,
req, previousSiblingDoc: previousDoc[field.name] as JsonObject,
siblingData: (siblingData?.[field.name] as JsonObject) || {}, req,
siblingDoc: siblingDoc[field.name] as JsonObject, 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 break
} }

View File

@@ -186,7 +186,7 @@ export const promise = async ({
case 'group': { case 'group': {
// Fill groups with empty objects so fields with hooks within groups can populate // Fill groups with empty objects so fields with hooks within groups can populate
// themselves virtually as necessary // themselves virtually as necessary
if (typeof siblingDoc[field.name] === 'undefined') { if (fieldAffectsData(field) && typeof siblingDoc[field.name] === 'undefined') {
siblingDoc[field.name] = {} siblingDoc[field.name] = {}
} }
@@ -609,45 +609,78 @@ export const promise = async ({
} }
case 'group': { 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') { if (typeof siblingDoc[field.name] !== 'object') {
groupDoc = {} 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 break
} }

View File

@@ -390,17 +390,42 @@ export const promise = async ({
} }
case 'group': { case 'group': {
if (typeof siblingData[field.name] !== 'object') { let groupSiblingData = siblingData
siblingData[field.name] = {} 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') { const fallbackLabel = field?.label || (isNamedGroup ? field.name : field?.type)
siblingDoc[field.name] = {}
}
if (typeof siblingDocWithLocales[field.name] !== 'object') {
siblingDocWithLocales[field.name] = {}
}
await traverseFields({ await traverseFields({
id, id,
@@ -414,23 +439,20 @@ export const promise = async ({
fieldLabelPath: fieldLabelPath:
field?.label === false field?.label === false
? fieldLabelPath ? fieldLabelPath
: buildFieldLabel( : buildFieldLabel(fieldLabelPath, getTranslatedLabel(fallbackLabel, req.i18n)),
fieldLabelPath,
getTranslatedLabel(field?.label || field?.name, req.i18n),
),
fields: field.fields, fields: field.fields,
global, global,
mergeLocaleActions, mergeLocaleActions,
operation, operation,
overrideAccess, overrideAccess,
parentIndexPath: '', parentIndexPath: isNamedGroup ? '' : indexPath,
parentIsLocalized: parentIsLocalized || field.localized, parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path, parentPath: isNamedGroup ? path : parentPath,
parentSchemaPath: schemaPath, parentSchemaPath: schemaPath,
req, req,
siblingData: siblingData[field.name] as JsonObject, siblingData: groupSiblingData,
siblingDoc: siblingDoc[field.name] as JsonObject, siblingDoc: groupSiblingDoc,
siblingDocWithLocales: siblingDocWithLocales[field.name] as JsonObject, siblingDocWithLocales: groupSiblingDocWithLocales,
skipValidation: skipValidationFromHere, skipValidation: skipValidationFromHere,
}) })

View File

@@ -375,9 +375,10 @@ export const promise = async <T>({
} }
} }
} else { } 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) { switch (field.type) {
case 'collapsible': case 'collapsible':
case 'group':
case 'row': { case 'row': {
await traverseFields({ await traverseFields({
id, id,

View File

@@ -447,16 +447,23 @@ export const promise = async <T>({
} }
case 'group': { case 'group': {
if (typeof siblingData[field.name] !== 'object') { let groupSiblingData = siblingData
siblingData[field.name] = {} let groupSiblingDoc = siblingDoc
}
if (typeof siblingDoc[field.name] !== 'object') { const isNamedGroup = fieldAffectsData(field)
siblingDoc[field.name] = {}
}
const groupData = siblingData[field.name] as Record<string, unknown> if (isNamedGroup) {
const groupDoc = siblingDoc[field.name] as Record<string, unknown> if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
groupSiblingData = siblingData[field.name] as Record<string, unknown>
groupSiblingDoc = siblingDoc[field.name] as Record<string, unknown>
}
await traverseFields({ await traverseFields({
id, id,
@@ -469,13 +476,13 @@ export const promise = async <T>({
global, global,
operation, operation,
overrideAccess, overrideAccess,
parentIndexPath: '', parentIndexPath: isNamedGroup ? '' : indexPath,
parentIsLocalized: parentIsLocalized || field.localized, parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path, parentPath: isNamedGroup ? path : parentPath,
parentSchemaPath: schemaPath, parentSchemaPath: schemaPath,
req, req,
siblingData: groupData as JsonObject, siblingData: groupSiblingData,
siblingDoc: groupDoc as JsonObject, siblingDoc: groupSiblingDoc,
}) })
break break

View File

@@ -1282,6 +1282,8 @@ export type {
JSONFieldClient, JSONFieldClient,
Labels, Labels,
LabelsClient, LabelsClient,
NamedGroupField,
NamedGroupFieldClient,
NamedTab, NamedTab,
NonPresentationalField, NonPresentationalField,
NonPresentationalFieldClient, NonPresentationalFieldClient,
@@ -1318,6 +1320,8 @@ export type {
TextFieldClient, TextFieldClient,
UIField, UIField,
UIFieldClient, UIFieldClient,
UnnamedGroupField,
UnnamedGroupFieldClient,
UnnamedTab, UnnamedTab,
UploadField, UploadField,
UploadFieldClient, UploadFieldClient,

View File

@@ -367,25 +367,26 @@ export function fieldsToJSONSchema(
break break
} }
case 'group': case 'group': {
case 'tab': { if (fieldAffectsData(field)) {
fieldSchema = { fieldSchema = {
...baseFieldSchema, ...baseFieldSchema,
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
...fieldsToJSONSchema( ...fieldsToJSONSchema(
collectionIDFieldTypes, collectionIDFieldTypes,
field.flattenedFields, field.flattenedFields,
interfaceNameDefinitions, interfaceNameDefinitions,
config, config,
i18n, i18n,
), ),
} }
if (field.interfaceName) { if (field.interfaceName) {
interfaceNameDefinitions.set(field.interfaceName, fieldSchema) interfaceNameDefinitions.set(field.interfaceName, fieldSchema)
fieldSchema = { $ref: `#/definitions/${field.interfaceName}` } fieldSchema = { $ref: `#/definitions/${field.interfaceName}` }
}
} }
break break
} }
@@ -486,6 +487,7 @@ export function fieldsToJSONSchema(
} }
break break
} }
case 'radio': { case 'radio': {
fieldSchema = { fieldSchema = {
...baseFieldSchema, ...baseFieldSchema,
@@ -503,7 +505,6 @@ export function fieldsToJSONSchema(
break break
} }
case 'relationship': case 'relationship':
case 'upload': { case 'upload': {
if (Array.isArray(field.relationTo)) { if (Array.isArray(field.relationTo)) {
@@ -595,7 +596,6 @@ export function fieldsToJSONSchema(
break break
} }
case 'richText': { case 'richText': {
if (!field?.editor) { 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 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 break
} }
case 'select': { case 'select': {
const optionEnums = buildOptionEnums(field.options) const optionEnums = buildOptionEnums(field.options)
// We get the previous field to check for a date in the case of a timezone select // 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 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': case 'text':
if (field.hasMany === true) { if (field.hasMany === true) {

View File

@@ -1,7 +1,8 @@
import type { ClientConfig } from '../config/client.js' import type { ClientConfig } from '../config/client.js'
// @ts-strict-ignore // @ts-strict-ignore
import type { ClientField } from '../fields/config/client.js' 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 = { export type FieldSchemaJSON = {
blocks?: FieldSchemaJSON // TODO: conditionally add based on `type` blocks?: FieldSchemaJSON // TODO: conditionally add based on `type`
@@ -67,11 +68,15 @@ export const fieldSchemaToJSON = (fields: ClientField[], config: ClientConfig):
break break
case 'group': case 'group':
acc.push({ if (fieldAffectsData(field)) {
name: field.name, acc.push({
type: field.type, name: field.name,
fields: fieldSchemaToJSON(field.fields, config), type: field.type,
}) fields: fieldSchemaToJSON(field.fields, config),
})
} else {
result = result.concat(fieldSchemaToJSON(field.fields, config))
}
break break

View File

@@ -7,7 +7,7 @@ import type {
FlattenedJoinField, FlattenedJoinField,
} from '../fields/config/types.js' } 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 => { export const flattenBlock = ({ block }: { block: Block }): FlattenedBlock => {
return { return {
@@ -44,7 +44,13 @@ export const flattenAllFields = ({
switch (field.type) { switch (field.type) {
case 'array': case 'array':
case 'group': { 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 break
} }

View File

@@ -2,7 +2,11 @@
import type { Config, SanitizedConfig } from '../config/types.js' import type { Config, SanitizedConfig } from '../config/types.js'
import type { ArrayField, Block, BlocksField, Field, TabAsField } from '../fields/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 = ({ const traverseArrayOrBlocksField = ({
callback, callback,
@@ -329,22 +333,38 @@ export const traverseFields = ({
currentRef && currentRef &&
typeof currentRef === 'object' typeof currentRef === 'object'
) { ) {
for (const key in currentRef as Record<string, unknown>) { if (fieldAffectsData(field)) {
if (currentRef[key]) { for (const key in currentRef as Record<string, unknown>) {
traverseFields({ if (currentRef[key]) {
callback, traverseFields({
callbackStack, callback,
config, callbackStack,
fields: field.fields, config,
fillEmpty, fields: field.fields,
isTopLevel: false, fillEmpty,
leavesFirst, isTopLevel: false,
parentIsLocalized: true, leavesFirst,
parentRef: currentParentRef, parentIsLocalized: true,
ref: currentRef[key], parentRef: currentParentRef,
}) ref: currentRef[key],
})
}
} }
} else {
traverseFields({
callback,
callbackStack,
config,
fields: field.fields,
fillEmpty,
isTopLevel: false,
leavesFirst,
parentIsLocalized,
parentRef: currentParentRef,
ref: currentRef,
})
} }
return return
} }

View File

@@ -3,7 +3,7 @@ import type { ClientTranslationKeys, I18nClient } from '@payloadcms/translations
import type { ClientField } from 'payload' import type { ClientField } from 'payload'
import { getTranslation } from '@payloadcms/translations' 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 { renderToStaticMarkup } from 'react-dom/server'
import type { ReducedField } from './types.js' import type { ReducedField } from './types.js'
@@ -100,7 +100,46 @@ export const reduceFields = ({
return reduced 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 translatedLabel = getTranslation(field.label || '', i18n)
const labelWithPrefix = labelPrefix const labelWithPrefix = labelPrefix

View File

@@ -28,6 +28,9 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => {
const { const {
field, field,
field: { name, admin: { className, description, hideGutter } = {}, fields, label }, field: { name, admin: { className, description, hideGutter } = {}, fields, label },
indexPath,
parentPath,
parentSchemaPath,
path, path,
permissions, permissions,
readOnly, readOnly,
@@ -102,15 +105,28 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => {
</div> </div>
)} )}
{BeforeInput} {BeforeInput}
<RenderFields {/* Render an unnamed group differently */}
fields={fields} {name ? (
margins="small" <RenderFields
parentIndexPath="" fields={fields}
parentPath={path} margins="small"
parentSchemaPath={schemaPath} parentIndexPath=""
permissions={permissions === true ? permissions : permissions?.fields} parentPath={path}
readOnly={readOnly} parentSchemaPath={schemaPath}
/> permissions={permissions === true ? permissions : permissions?.fields}
readOnly={readOnly}
/>
) : (
<RenderFields
fields={fields}
margins="small"
parentIndexPath={indexPath}
parentPath={parentPath}
parentSchemaPath={parentSchemaPath}
permissions={permissions === true ? permissions : permissions?.fields}
readOnly={readOnly}
/>
)}
</div> </div>
</GroupProvider> </GroupProvider>
{AfterInput} {AfterInput}

View File

@@ -10,7 +10,7 @@ import type {
} from 'payload' } from 'payload'
import ObjectIdImport from 'bson-objectid' import ObjectIdImport from 'bson-objectid'
import { flattenTopLevelFields } from 'payload/shared' import { fieldAffectsData, flattenTopLevelFields } from 'payload/shared'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { RelationshipTable } from '../../elements/RelationshipTable/index.js' import { RelationshipTable } from '../../elements/RelationshipTable/index.js'
@@ -68,7 +68,7 @@ const getInitialDrawerData = ({
const nextSegments = segments.slice(1, segments.length) const nextSegments = segments.slice(1, segments.length)
if (field.type === 'tab' || field.type === 'group') { if (field.type === 'tab' || (field.type === 'group' && fieldAffectsData(field))) {
return { return {
[field.name]: getInitialDrawerData({ [field.name]: getInitialDrawerData({
collectionSlug, collectionSlug,

View File

@@ -734,7 +734,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
} }
} }
} else if (fieldHasSubFields(field) && !fieldAffectsData(field)) { } 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)) { if (!filter || filter(args)) {
state[path] = { state[path] = {

View File

@@ -163,26 +163,40 @@ export const defaultValuePromise = async <T>({
break break
} }
case 'group': { case 'group': {
if (typeof siblingData[field.name] !== 'object') { if (fieldAffectsData(field)) {
siblingData[field.name] = {} if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
const groupData = siblingData[field.name] as Record<string, unknown>
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<string, unknown>
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 break
} }

View File

@@ -1,7 +1,6 @@
import type { I18n } from '@payloadcms/translations' import type { I18n } from '@payloadcms/translations'
import { import {
type ClientBlock,
type ClientConfig, type ClientConfig,
type ClientField, type ClientField,
type ClientFieldSchemaMap, type ClientFieldSchemaMap,
@@ -10,7 +9,7 @@ import {
type FieldSchemaMap, type FieldSchemaMap,
type Payload, type Payload,
} from 'payload' } from 'payload'
import { getFieldPaths, tabHasName } from 'payload/shared' import { fieldAffectsData, getFieldPaths, tabHasName } from 'payload/shared'
type Args = { type Args = {
clientSchemaMap: ClientFieldSchemaMap clientSchemaMap: ClientFieldSchemaMap
@@ -45,8 +44,7 @@ export const traverseFields = ({
clientSchemaMap.set(schemaPath, field) clientSchemaMap.set(schemaPath, field)
switch (field.type) { switch (field.type) {
case 'array': case 'array': {
case 'group':
traverseFields({ traverseFields({
clientSchemaMap, clientSchemaMap,
config, config,
@@ -59,6 +57,7 @@ export const traverseFields = ({
}) })
break break
}
case 'blocks': case 'blocks':
;(field.blockReferences ?? field.blocks).map((_block) => { ;(field.blockReferences ?? field.blocks).map((_block) => {
@@ -85,6 +84,7 @@ export const traverseFields = ({
}) })
break break
case 'collapsible': case 'collapsible':
case 'row': case 'row':
traverseFields({ traverseFields({
@@ -99,6 +99,33 @@ export const traverseFields = ({
}) })
break 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': { case 'richText': {
// richText sub-fields are not part of the ClientConfig or the Config. // richText sub-fields are not part of the ClientConfig or the Config.
// They only exist in the field schema map. // They only exist in the field schema map.

View File

@@ -2,7 +2,7 @@ import type { I18n } from '@payloadcms/translations'
import type { Field, FieldSchemaMap, SanitizedConfig } from 'payload' import type { Field, FieldSchemaMap, SanitizedConfig } from 'payload'
import { MissingEditorProp } from 'payload' import { MissingEditorProp } from 'payload'
import { getFieldPaths, tabHasName } from 'payload/shared' import { fieldAffectsData, getFieldPaths, tabHasName } from 'payload/shared'
type Args = { type Args = {
config: SanitizedConfig config: SanitizedConfig
@@ -34,7 +34,6 @@ export const traverseFields = ({
switch (field.type) { switch (field.type) {
case 'array': case 'array':
case 'group':
traverseFields({ traverseFields({
config, config,
fields: field.fields, fields: field.fields,
@@ -66,6 +65,7 @@ export const traverseFields = ({
}) })
break break
case 'collapsible': case 'collapsible':
case 'row': case 'row':
traverseFields({ traverseFields({
@@ -79,6 +79,29 @@ export const traverseFields = ({
break 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': case 'richText':
if (!field?.editor) { 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 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

View File

@@ -130,7 +130,11 @@ function iterateFields(
break break
case 'group': { case 'group': {
if (field.name in toLocaleData && fromLocaleData?.[field.name] !== undefined) { if (
fieldAffectsData(field) &&
field.name in toLocaleData &&
fromLocaleData?.[field.name] !== undefined
) {
iterateFields( iterateFields(
field.fields, field.fields,
fromLocaleData[field.name], fromLocaleData[field.name],
@@ -138,6 +142,8 @@ function iterateFields(
req, req,
parentIsLocalized || field.localized, parentIsLocalized || field.localized,
) )
} else {
iterateFields(field.fields, fromLocaleData, toLocaleData, req, parentIsLocalized)
} }
break break
} }

View File

@@ -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<Config>
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<Config>({
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()
})
})
})

View File

@@ -8,6 +8,9 @@ export const groupDefaultChild = 'child takes priority'
const GroupFields: CollectionConfig = { const GroupFields: CollectionConfig = {
slug: groupFieldsSlug, slug: groupFieldsSlug,
versions: true, versions: true,
admin: {
defaultColumns: ['id', 'group', 'insideUnnamedGroup', 'deeplyNestedGroup'],
},
fields: [ fields: [
{ {
label: 'Group Field', 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',
},
],
},
],
},
],
},
],
},
],
},
], ],
} }

View File

@@ -1,6 +1,6 @@
import type { GroupField } from '../../payload-types.js' import type { GroupField } from '../../payload-types.js'
export const groupDoc: Partial<GroupField> = { export const namedGroupDoc: Partial<GroupField> = {
group: { group: {
text: 'some text within a group', text: 'some text within a group',
subGroup: { subGroup: {
@@ -12,4 +12,8 @@ export const groupDoc: Partial<GroupField> = {
], ],
}, },
}, },
insideUnnamedGroup: 'text in unnamed group',
deeplyNestedGroup: {
insideNestedUnnamedGroup: 'text in nested unnamed group',
},
} }

View File

@@ -15,7 +15,7 @@ import { arrayDefaultValue } from './collections/Array/index.js'
import { blocksDoc } from './collections/Blocks/shared.js' import { blocksDoc } from './collections/Blocks/shared.js'
import { dateDoc } from './collections/Date/shared.js' import { dateDoc } from './collections/Date/shared.js'
import { groupDefaultChild, groupDefaultValue } from './collections/Group/index.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 { defaultNumber } from './collections/Number/index.js'
import { numberDoc } from './collections/Number/shared.js' import { numberDoc } from './collections/Number/shared.js'
import { pointDoc } from './collections/Point/shared.js' import { pointDoc } from './collections/Point/shared.js'
@@ -1614,7 +1614,7 @@ describe('Fields', () => {
it('should create with ids and nested ids', async () => { it('should create with ids and nested ids', async () => {
const docWithIDs = (await payload.create({ const docWithIDs = (await payload.create({
collection: groupFieldsSlug, collection: groupFieldsSlug,
data: groupDoc, data: namedGroupDoc,
})) as Partial<GroupField> })) as Partial<GroupField>
expect(docWithIDs.group.subGroup.arrayWithinGroup[0].id).toBeDefined() 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 () => { it('should query a subfield within a localized group', async () => {
const text = 'find this' const text = 'find this'
const hit = await payload.create({ const hit = await payload.create({
@@ -2357,7 +2404,7 @@ describe('Fields', () => {
it('should return empty object for groups when no data present', async () => { it('should return empty object for groups when no data present', async () => {
const doc = await payload.create({ const doc = await payload.create({
collection: groupFieldsSlug, collection: groupFieldsSlug,
data: groupDoc, data: namedGroupDoc,
}) })
expect(doc.potentiallyEmptyGroup).toBeDefined() expect(doc.potentiallyEmptyGroup).toBeDefined()

View File

@@ -1080,6 +1080,10 @@ export interface GroupField {
}[] }[]
| null; | null;
}; };
insideUnnamedGroup?: string | null;
deeplyNestedGroup?: {
insideNestedUnnamedGroup?: string | null;
};
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -2676,6 +2680,12 @@ export interface GroupFieldsSelect<T extends boolean = true> {
| { | {
email?: T; email?: T;
}; };
insideUnnamedGroup?: T;
deeplyNestedGroup?:
| T
| {
insideNestedUnnamedGroup?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }

View File

@@ -16,7 +16,7 @@ import { conditionalLogicDoc } from './collections/ConditionalLogic/shared.js'
import { customRowID, customTabID, nonStandardID } from './collections/CustomID/shared.js' import { customRowID, customTabID, nonStandardID } from './collections/CustomID/shared.js'
import { dateDoc } from './collections/Date/shared.js' import { dateDoc } from './collections/Date/shared.js'
import { anotherEmailDoc, emailDoc } from './collections/Email/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 { jsonDoc } from './collections/JSON/shared.js'
import { numberDoc } from './collections/Number/shared.js' import { numberDoc } from './collections/Number/shared.js'
import { pointDoc } from './collections/Point/shared.js' import { pointDoc } from './collections/Point/shared.js'
@@ -223,7 +223,7 @@ export const seed = async (_payload: Payload) => {
await _payload.create({ await _payload.create({
collection: groupFieldsSlug, collection: groupFieldsSlug,
data: groupDoc, data: namedGroupDoc,
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,
}) })

View File

@@ -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', name: 'radioField',
type: 'radio', type: 'radio',

View File

@@ -144,6 +144,10 @@ export interface Post {
text?: string | null; text?: string | null;
title?: string | null; title?: string | null;
selectField: MySelectOptions; selectField: MySelectOptions;
insideUnnamedGroup?: string | null;
namedGroup?: {
insideNamedGroup?: string | null;
};
radioField: MyRadioOptions; radioField: MyRadioOptions;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -264,6 +268,12 @@ export interface PostsSelect<T extends boolean = true> {
text?: T; text?: T;
title?: T; title?: T;
selectField?: T; selectField?: T;
insideUnnamedGroup?: T;
namedGroup?:
| T
| {
insideNamedGroup?: T;
};
radioField?: T; radioField?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;

View File

@@ -145,4 +145,17 @@ describe('Types testing', () => {
expect(asType<Post['radioField']>()).type.toBe<MyRadioOptions>() expect(asType<Post['radioField']>()).type.toBe<MyRadioOptions>()
}) })
}) })
describe('fields', () => {
describe('Group', () => {
test('correctly ignores unnamed group', () => {
expect<Post>().type.toHaveProperty('insideUnnamedGroup')
})
test('generates nested group name', () => {
expect<Post>().type.toHaveProperty('namedGroup')
expect<NonNullable<Post['namedGroup']>>().type.toHaveProperty('insideNamedGroup')
})
})
})
}) })

View File

@@ -129,7 +129,7 @@ export async function translateObject(props: {
for (const missingKey of missingKeys) { for (const missingKey of missingKeys) {
const keys: string[] = missingKey.split('.') const keys: string[] = missingKey.split('.')
const sourceText = keys.reduce( const sourceText = keys.reduce(
(acc, key) => acc[key] as GenericTranslationsObject, (acc, key) => acc[key],
fromTranslationsObject, fromTranslationsObject,
) )
if (!sourceText || typeof sourceText !== 'string') { if (!sourceText || typeof sourceText !== 'string') {

View File

@@ -31,7 +31,7 @@
} }
], ],
"paths": { "paths": {
"@payload-config": ["./test/query-presets/config.ts"], "@payload-config": ["./test/_community/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],