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
244 lines
6.1 KiB
TypeScript
244 lines
6.1 KiB
TypeScript
'use client'
|
|
|
|
import type {
|
|
ClientConfig,
|
|
ClientField,
|
|
JoinFieldClient,
|
|
JoinFieldClientComponent,
|
|
PaginatedDocs,
|
|
Where,
|
|
} from 'payload'
|
|
|
|
import ObjectIdImport from 'bson-objectid'
|
|
import { fieldAffectsData, flattenTopLevelFields } from 'payload/shared'
|
|
import React, { useMemo } from 'react'
|
|
|
|
import { RelationshipTable } from '../../elements/RelationshipTable/index.js'
|
|
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
|
|
import { useField } from '../../forms/useField/index.js'
|
|
import { withCondition } from '../../forms/withCondition/index.js'
|
|
import { useConfig } from '../../providers/Config/index.js'
|
|
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
|
|
import { FieldDescription } from '../FieldDescription/index.js'
|
|
import { FieldError } from '../FieldError/index.js'
|
|
import { FieldLabel } from '../FieldLabel/index.js'
|
|
import { fieldBaseClass } from '../index.js'
|
|
|
|
const ObjectId = (ObjectIdImport.default ||
|
|
ObjectIdImport) as unknown as typeof ObjectIdImport.default
|
|
|
|
/**
|
|
* Recursively builds the default data for joined collection
|
|
*/
|
|
const getInitialDrawerData = ({
|
|
collectionSlug,
|
|
config,
|
|
docID,
|
|
fields,
|
|
segments,
|
|
}: {
|
|
collectionSlug: string
|
|
config: ClientConfig
|
|
docID: number | string
|
|
fields: ClientField[]
|
|
segments: string[]
|
|
}) => {
|
|
const flattenedFields = flattenTopLevelFields(fields)
|
|
|
|
const path = segments[0]
|
|
|
|
const field = flattenedFields.find((field) => field.name === path)
|
|
|
|
if (!field) {
|
|
return null
|
|
}
|
|
|
|
if (field.type === 'relationship' || field.type === 'upload') {
|
|
let value: { relationTo: string; value: number | string } | number | string = docID
|
|
if (Array.isArray(field.relationTo)) {
|
|
value = {
|
|
relationTo: collectionSlug,
|
|
value: docID,
|
|
}
|
|
}
|
|
return {
|
|
[field.name]: field.hasMany ? [value] : value,
|
|
}
|
|
}
|
|
|
|
const nextSegments = segments.slice(1, segments.length)
|
|
|
|
if (field.type === 'tab' || (field.type === 'group' && fieldAffectsData(field))) {
|
|
return {
|
|
[field.name]: getInitialDrawerData({
|
|
collectionSlug,
|
|
config,
|
|
docID,
|
|
fields: field.fields,
|
|
segments: nextSegments,
|
|
}),
|
|
}
|
|
}
|
|
|
|
if (field.type === 'array') {
|
|
const initialData = getInitialDrawerData({
|
|
collectionSlug,
|
|
config,
|
|
docID,
|
|
fields: field.fields,
|
|
segments: nextSegments,
|
|
})
|
|
|
|
initialData.id = ObjectId().toHexString()
|
|
|
|
return {
|
|
[field.name]: [initialData],
|
|
}
|
|
}
|
|
|
|
if (field.type === 'blocks') {
|
|
for (const _block of field.blockReferences ?? field.blocks) {
|
|
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
|
|
|
|
const blockInitialData = getInitialDrawerData({
|
|
collectionSlug,
|
|
config,
|
|
docID,
|
|
fields: block.fields,
|
|
segments: nextSegments,
|
|
})
|
|
|
|
if (blockInitialData) {
|
|
blockInitialData.id = ObjectId().toHexString()
|
|
blockInitialData.blockType = block.slug
|
|
|
|
return {
|
|
[field.name]: [blockInitialData],
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const JoinFieldComponent: JoinFieldClientComponent = (props) => {
|
|
const {
|
|
field,
|
|
field: {
|
|
admin: { allowCreate, description },
|
|
collection,
|
|
label,
|
|
localized,
|
|
on,
|
|
required,
|
|
},
|
|
path: pathFromProps,
|
|
} = props
|
|
|
|
const { id: docID, docConfig } = useDocumentInfo()
|
|
|
|
const { config, getEntityConfig } = useConfig()
|
|
|
|
const {
|
|
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
|
|
path,
|
|
showError,
|
|
value,
|
|
} = useField<PaginatedDocs>({
|
|
potentiallyStalePath: pathFromProps,
|
|
})
|
|
|
|
const filterOptions: null | Where = useMemo(() => {
|
|
if (!docID) {
|
|
return null
|
|
}
|
|
|
|
let value: { relationTo: string; value: number | string } | number | string = docID
|
|
|
|
if (Array.isArray(field.targetField.relationTo)) {
|
|
value = {
|
|
relationTo: docConfig.slug,
|
|
value,
|
|
}
|
|
}
|
|
|
|
const where = Array.isArray(collection)
|
|
? {}
|
|
: {
|
|
[on]: {
|
|
equals: value,
|
|
},
|
|
}
|
|
|
|
if (field.where) {
|
|
return {
|
|
and: [where, field.where],
|
|
}
|
|
}
|
|
|
|
return where
|
|
}, [docID, collection, field.targetField.relationTo, field.where, on, docConfig?.slug])
|
|
|
|
const initialDrawerData = useMemo(() => {
|
|
const relatedCollection = getEntityConfig({
|
|
collectionSlug: Array.isArray(field.collection) ? field.collection[0] : field.collection,
|
|
})
|
|
|
|
return getInitialDrawerData({
|
|
collectionSlug: docConfig?.slug,
|
|
config,
|
|
docID,
|
|
fields: relatedCollection.fields,
|
|
segments: field.on.split('.'),
|
|
})
|
|
}, [getEntityConfig, field.collection, field.on, docConfig?.slug, docID, config])
|
|
|
|
if (!docConfig) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={[fieldBaseClass, showError && 'error', 'join'].filter(Boolean).join(' ')}
|
|
id={`field-${path?.replace(/\./g, '__')}`}
|
|
>
|
|
<RenderCustomComponent
|
|
CustomComponent={Error}
|
|
Fallback={<FieldError path={path} showError={showError} />}
|
|
/>
|
|
<RelationshipTable
|
|
AfterInput={AfterInput}
|
|
allowCreate={typeof docID !== 'undefined' && allowCreate}
|
|
BeforeInput={BeforeInput}
|
|
disableTable={filterOptions === null}
|
|
field={field as JoinFieldClient}
|
|
filterOptions={filterOptions}
|
|
initialData={docID && value ? value : ({ docs: [] } as PaginatedDocs)}
|
|
initialDrawerData={initialDrawerData}
|
|
Label={
|
|
<h4 style={{ margin: 0 }}>
|
|
{Label || (
|
|
<FieldLabel label={label} localized={localized} path={path} required={required} />
|
|
)}
|
|
</h4>
|
|
}
|
|
parent={
|
|
Array.isArray(collection)
|
|
? {
|
|
id: docID,
|
|
collectionSlug: docConfig.slug,
|
|
joinPath: path,
|
|
}
|
|
: undefined
|
|
}
|
|
relationTo={collection}
|
|
/>
|
|
<RenderCustomComponent
|
|
CustomComponent={Description}
|
|
Fallback={<FieldDescription description={description} path={path} />}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const JoinField = withCondition(JoinFieldComponent)
|