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 |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`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,
},
],
},
],
}
```

View File

@@ -372,36 +372,61 @@ const group: FieldSchemaGenerator<GroupField> = (
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<any> = {
...formattedBaseSchema,
type: buildSchema({
buildSchemaOptions: {
disableUnique: buildSchemaOptions.disableUnique,
draftsEnabled: buildSchemaOptions.draftsEnabled,
indexSortableFields,
options: {
_id: false,
id: false,
minimize: false,
const baseSchema: SchemaTypeOptions<any> = {
...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<JSONField> = (

View File

@@ -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()

View File

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

View File

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

View File

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

View File

@@ -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<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': {
if (tabHasName(field)) {

View File

@@ -719,7 +719,7 @@ export type DateFieldClient = {
} & FieldBaseClient &
Pick<DateField, 'timezone' | 'type'>
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<unknown, unknown, unknown, GroupField>
} & Omit<FieldBase, 'validate'>
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<unknown, unknown, unknown, GroupField>
} & Omit<FieldBase, 'required' | 'validate'>
} & GroupBase
export type GroupFieldClient = {
admin?: AdminClient & Pick<GroupField['admin'], 'hideGutter'>
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<GroupBase, 'name' | 'virtual'>
export type GroupField = NamedGroupField | UnnamedGroupField
export type NamedGroupFieldClient = {
admin?: AdminClient & Pick<NamedGroupField['admin'], 'hideGutter'>
fields: ClientField[]
} & 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 = {
admin?: Omit<Admin, 'description'>
@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
})

View File

@@ -375,9 +375,10 @@ export const promise = async <T>({
}
}
} 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,

View File

@@ -447,16 +447,23 @@ export const promise = async <T>({
}
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<string, unknown>
const groupDoc = siblingDoc[field.name] as Record<string, unknown>
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<string, unknown>
groupSiblingDoc = siblingDoc[field.name] as Record<string, unknown>
}
await traverseFields({
id,
@@ -469,13 +476,13 @@ export const promise = async <T>({
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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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<string, unknown>) {
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<string, unknown>) {
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
}

View File

@@ -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

View File

@@ -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) => {
</div>
)}
{BeforeInput}
<RenderFields
fields={fields}
margins="small"
parentIndexPath=""
parentPath={path}
parentSchemaPath={schemaPath}
permissions={permissions === true ? permissions : permissions?.fields}
readOnly={readOnly}
/>
{/* Render an unnamed group differently */}
{name ? (
<RenderFields
fields={fields}
margins="small"
parentIndexPath=""
parentPath={path}
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>
</GroupProvider>
{AfterInput}

View File

@@ -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,

View File

@@ -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] = {

View File

@@ -163,26 +163,40 @@ export const defaultValuePromise = async <T>({
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<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
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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
}

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 = {
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',
},
],
},
],
},
],
},
],
},
],
},
],
}

View File

@@ -1,6 +1,6 @@
import type { GroupField } from '../../payload-types.js'
export const groupDoc: Partial<GroupField> = {
export const namedGroupDoc: Partial<GroupField> = {
group: {
text: 'some text within a group',
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 { 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<GroupField>
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()

View File

@@ -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<T extends boolean = true> {
| {
email?: T;
};
insideUnnamedGroup?: T;
deeplyNestedGroup?:
| T
| {
insideNestedUnnamedGroup?: T;
};
updatedAt?: 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 { 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,
})

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

View File

@@ -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<T extends boolean = true> {
text?: T;
title?: T;
selectField?: T;
insideUnnamedGroup?: T;
namedGroup?:
| T
| {
insideNamedGroup?: T;
};
radioField?: T;
updatedAt?: T;
createdAt?: T;

View File

@@ -145,4 +145,17 @@ describe('Types testing', () => {
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) {
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') {

View File

@@ -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"],