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:
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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> = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -128,7 +128,6 @@ const traverseFields = ({
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'blocks': {
|
||||
const blocksSelect = select[field.name] as SelectType
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
123
test/fields/collections/Group/e2e.spec.ts
Normal file
123
test/fields/collections/Group/e2e.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user