feat: join field support relationships inside arrays (#9773)

### What?

Allow the join field to have a configuration `on` relationships inside
of an array, ie `on: 'myArray.myRelationship'`.

### Why?

This is a more powerful and expressive way to use the join field and not
be limited by usage of array data. For example, if you have a roles
array for multinant sites, you could add a join field on the sites to
show who the admins are.

### How?

This fixes the traverseFields function to allow the configuration to
pass sanitization. In addition, the function for querying the drizzle
tables needed to be ehanced.

Additional changes from https://github.com/payloadcms/payload/pull/9995:

- Significantly improves traverseFields and the 'join' case with a raw
query injection pattern, right now it's internal but we could expose it
at some point, for example for querying vectors.
- Fixes potential issues with not passed locale to traverseFields (it
was undefined always)
- Adds an empty array fallback for joins with localized relationships

Fixes #
https://github.com/payloadcms/payload/discussions/9643

---------

Co-authored-by: Because789 <thomas@because789.ch>
Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
This commit is contained in:
Dan Ribbens
2024-12-17 13:14:43 -05:00
committed by GitHub
parent eb037a0cc6
commit b0b2fc6c47
17 changed files with 290 additions and 141 deletions

View File

@@ -125,8 +125,8 @@ powerful Admin UI.
|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`collection`** \* | The `slug`s having the relationship field. |
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
| **`where`** \* | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
| **`where`** | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. |
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth). |
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |

View File

@@ -1,5 +1,3 @@
import type { QueryPromise } from 'drizzle-orm'
export type ChainedMethods = {
args: unknown[]
method: string
@@ -10,7 +8,7 @@ export type ChainedMethods = {
* @param methods
* @param query
*/
const chainMethods = <T>({ methods, query }): QueryPromise<T> => {
const chainMethods = <T>({ methods, query }: { methods: ChainedMethods; query: T }): T => {
return methods.reduce((query, { args, method }) => {
return query[method](...args)
}, query)

View File

@@ -73,6 +73,7 @@ export const findMany = async function find({
fields,
joinQuery,
joins,
locale,
select,
tableName,
versions,

View File

@@ -1,7 +1,7 @@
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { FlattenedField, JoinQuery, SelectMode, SelectType } from 'payload'
import type { FlattenedField, JoinQuery, SelectMode, SelectType, Where } from 'payload'
import { and, eq, sql } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { fieldIsVirtual } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
@@ -9,6 +9,8 @@ import type { BuildQueryJoinAliases, ChainedMethods, DrizzleAdapter } from '../t
import type { Result } from './buildFindManyArgs.js'
import buildQuery from '../queries/buildQuery.js'
import { jsonAggBuildObject } from '../utilities/json.js'
import { rawConstraint } from '../utilities/rawConstraint.js'
import { chainMethods } from './chainMethods.js'
type TraverseFieldArgs = {
@@ -145,6 +147,7 @@ export const traverseFields = ({
depth,
fields: field.flattenedFields,
joinQuery,
locale,
path: '',
select: typeof arraySelect === 'object' ? arraySelect : undefined,
selectMode,
@@ -254,6 +257,7 @@ export const traverseFields = ({
depth,
fields: block.flattenedFields,
joinQuery,
locale,
path: '',
select: typeof blockSelect === 'object' ? blockSelect : undefined,
selectMode: blockSelectMode,
@@ -294,6 +298,7 @@ export const traverseFields = ({
fields: field.flattenedFields,
joinQuery,
joins,
locale,
path: `${path}${field.name}_`,
select: typeof fieldSelect === 'object' ? fieldSelect : undefined,
selectAllOnCurrentLevel:
@@ -348,92 +353,37 @@ export const traverseFields = ({
const joins: BuildQueryJoinAliases = []
const buildQueryResult = buildQuery({
adapter,
fields,
joins,
locale,
sort,
tableName: joinCollectionTableName,
where,
})
let subQueryWhere = buildQueryResult.where
const orderBy = buildQueryResult.orderBy
let joinLocalesCollectionTableName: string | undefined
const currentIDColumn = versions
? adapter.tables[currentTableName].parent
: adapter.tables[currentTableName].id
// Handle hasMany _rels table
if (field.hasMany) {
const joinRelsCollectionTableName = `${joinCollectionTableName}${adapter.relationshipsSuffix}`
let joinQueryWhere: Where = {
[field.on]: {
equals: rawConstraint(currentIDColumn),
},
}
if (field.localized) {
joinLocalesCollectionTableName = joinRelsCollectionTableName
}
let columnReferenceToCurrentID: string
if (versions) {
columnReferenceToCurrentID = `${topLevelTableName
.replace('_', '')
.replace(new RegExp(`${adapter.versionsSuffix}$`), '')}_id`
} else {
columnReferenceToCurrentID = `${topLevelTableName}_id`
}
joins.push({
type: 'innerJoin',
condition: and(
eq(
adapter.tables[joinRelsCollectionTableName].parent,
adapter.tables[joinCollectionTableName].id,
),
eq(
sql.raw(`"${joinRelsCollectionTableName}"."${columnReferenceToCurrentID}"`),
currentIDColumn,
),
eq(adapter.tables[joinRelsCollectionTableName].path, field.on),
),
table: adapter.tables[joinRelsCollectionTableName],
})
} else {
// Handle localized without hasMany
const foreignColumn = field.on.replaceAll('.', '_')
if (field.localized) {
joinLocalesCollectionTableName = `${joinCollectionTableName}${adapter.localesSuffix}`
joins.push({
type: 'innerJoin',
condition: and(
eq(
adapter.tables[joinLocalesCollectionTableName]._parentID,
adapter.tables[joinCollectionTableName].id,
),
eq(adapter.tables[joinLocalesCollectionTableName][foreignColumn], currentIDColumn),
),
table: adapter.tables[joinLocalesCollectionTableName],
})
// Handle without localized and without hasMany, just a condition append to where. With localized the inner join handles eq.
} else {
const constraint = eq(
adapter.tables[joinCollectionTableName][foreignColumn],
currentIDColumn,
)
if (subQueryWhere) {
subQueryWhere = and(subQueryWhere, constraint)
} else {
subQueryWhere = constraint
}
if (where) {
joinQueryWhere = {
and: [joinQueryWhere, where],
}
}
const {
orderBy,
selectFields,
where: subQueryWhere,
} = buildQuery({
adapter,
fields,
joins,
locale,
selectLocale: true,
sort,
tableName: joinCollectionTableName,
where: joinQueryWhere,
})
const chainedMethods: ChainedMethods = []
joins.forEach(({ type, condition, table }) => {
@@ -452,49 +402,29 @@ export const traverseFields = ({
const db = adapter.drizzle as LibSQLDatabase
const columnName = `${path.replaceAll('.', '_')}${field.name}`
const subQueryAlias = `${columnName}_alias`
const subQuery = chainMethods({
methods: chainedMethods,
query: db
.select({
id: adapter.tables[joinCollectionTableName].id,
...(joinLocalesCollectionTableName && {
locale:
adapter.tables[joinLocalesCollectionTableName].locale ||
adapter.tables[joinLocalesCollectionTableName]._locale,
}),
})
.select(selectFields as any)
.from(adapter.tables[joinCollectionTableName])
.where(subQueryWhere)
.orderBy(() => orderBy.map(({ column, order }) => order(column))),
})
}).as(subQueryAlias)
const columnName = `${path.replaceAll('.', '_')}${field.name}`
const jsonObjectSelect = field.localized
? sql.raw(
`'_parentID', "id", '_locale', "${adapter.tables[joinLocalesCollectionTableName].locale ? 'locale' : '_locale'}"`,
)
: sql.raw(`'id', "id"`)
if (adapter.name === 'sqlite') {
currentArgs.extras[columnName] = sql`
COALESCE((
SELECT json_group_array(json_object(${jsonObjectSelect}))
FROM (
${subQuery}
) AS ${sql.raw(`${columnName}_sub`)}
), '[]')
`.as(columnName)
} else {
currentArgs.extras[columnName] = sql`
COALESCE((
SELECT json_agg(json_build_object(${jsonObjectSelect}))
FROM (
${subQuery}
) AS ${sql.raw(`${columnName}_sub`)}
), '[]'::json)
`.as(columnName)
}
currentArgs.extras[columnName] = sql`${db
.select({
result: jsonAggBuildObject(adapter, {
id: sql.raw(`"${subQueryAlias}".id`),
...(selectFields._locale && {
locale: sql.raw(`"${subQueryAlias}".${selectFields._locale.name}`),
}),
}),
})
.from(sql`${subQuery}`)}`.as(columnName)
break
}

View File

@@ -12,6 +12,7 @@ export function buildAndOrConditions({
joins,
locale,
selectFields,
selectLocale,
tableName,
where,
}: {
@@ -22,6 +23,7 @@ export function buildAndOrConditions({
joins: BuildQueryJoinAliases
locale?: string
selectFields: Record<string, GenericColumn>
selectLocale?: boolean
tableName: string
where: Where[]
}): SQL[] {
@@ -38,6 +40,7 @@ export function buildAndOrConditions({
joins,
locale,
selectFields,
selectLocale,
tableName,
where: condition,
})

View File

@@ -18,6 +18,7 @@ type BuildQueryArgs = {
fields: FlattenedField[]
joins?: BuildQueryJoinAliases
locale?: string
selectLocale?: boolean
sort?: Sort
tableName: string
where: Where
@@ -37,6 +38,7 @@ const buildQuery = function buildQuery({
fields,
joins = [],
locale,
selectLocale,
sort,
tableName,
where: incomingWhere,
@@ -64,6 +66,7 @@ const buildQuery = function buildQuery({
joins,
locale,
selectFields,
selectLocale,
tableName,
where: incomingWhere,
})

View File

@@ -49,6 +49,7 @@ type Args = {
pathSegments: string[]
rootTableName?: string
selectFields: Record<string, GenericColumn>
selectLocale?: boolean
tableName: string
/**
* If creating a new table name for arrays and blocks, this suffix should be appended to the table name
@@ -77,6 +78,7 @@ export const getTableColumnFromPath = ({
pathSegments: incomingSegments,
rootTableName: incomingRootTableName,
selectFields,
selectLocale,
tableName,
tableNameSuffix = '',
value,
@@ -130,6 +132,10 @@ export const getTableColumnFromPath = ({
if (locale && field.localized && adapter.payload.config.localization) {
const conditions = [eq(arrayParentTable.id, adapter.tables[newTableName]._parentID)]
if (selectLocale) {
selectFields._locale = adapter.tables[newTableName]._locale
}
if (locale !== 'all') {
conditions.push(eq(adapter.tables[newTableName]._locale, locale))
}
@@ -156,6 +162,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
selectLocale,
tableName: newTableName,
value,
})
@@ -213,6 +220,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields: blockSelectFields,
selectLocale,
tableName: newTableName,
value,
})
@@ -294,6 +302,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
selectLocale,
tableName: newTableName,
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
value,
@@ -347,6 +356,7 @@ export const getTableColumnFromPath = ({
case 'relationship':
case 'upload': {
const newCollectionPath = pathSegments.slice(1).join('.')
if (Array.isArray(field.relationTo) || field.hasMany) {
let relationshipFields
const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}`
@@ -355,6 +365,10 @@ export const getTableColumnFromPath = ({
newAliasTableName: aliasRelationshipTableName,
} = getTableAlias({ adapter, tableName: relationTableName })
if (selectLocale && field.localized && adapter.payload.config.localization) {
selectFields._locale = aliasRelationshipTable.locale
}
// Join in the relationships table
if (locale && field.localized && adapter.payload.config.localization) {
const conditions = [
@@ -365,6 +379,7 @@ export const getTableColumnFromPath = ({
if (locale !== 'all') {
conditions.push(eq(aliasRelationshipTable.locale, locale))
}
joins.push({
condition: and(...conditions),
table: aliasRelationshipTable,
@@ -523,6 +538,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1),
rootTableName: newTableName,
selectFields,
selectLocale,
tableName: newTableName,
value,
})
@@ -545,6 +561,10 @@ export const getTableColumnFromPath = ({
const condtions = [eq(aliasLocaleTable._parentID, adapter.tables[rootTableName].id)]
if (selectLocale) {
selectFields._locale = aliasLocaleTable._locale
}
if (locale !== 'all') {
condtions.push(eq(aliasLocaleTable._locale, locale))
}
@@ -643,6 +663,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
selectLocale,
tableName: newTableName,
tableNameSuffix: `${tableNameSuffix}${toSnakeCase(field.name)}_`,
value,
@@ -661,6 +682,7 @@ export const getTableColumnFromPath = ({
pathSegments: pathSegments.slice(1),
rootTableName,
selectFields,
selectLocale,
tableName: newTableName,
tableNameSuffix,
value,
@@ -689,6 +711,10 @@ export const getTableColumnFromPath = ({
condition = and(condition, eq(newTable._locale, locale))
}
if (selectLocale) {
selectFields._locale = newTable._locale
}
addJoinTable({
condition,
joins,

View File

@@ -19,6 +19,7 @@ type Args = {
joins: BuildQueryJoinAliases
locale: string
selectFields: Record<string, GenericColumn>
selectLocale?: boolean
tableName: string
where: Where
}
@@ -29,6 +30,7 @@ export function parseParams({
joins,
locale,
selectFields,
selectLocale,
tableName,
where,
}: Args): SQL {
@@ -53,6 +55,7 @@ export function parseParams({
joins,
locale,
selectFields,
selectLocale,
tableName,
where: condition,
})
@@ -86,6 +89,7 @@ export function parseParams({
locale,
pathSegments: relationOrPath.replace(/__/g, '.').split('.'),
selectFields,
selectLocale,
tableName,
value: val,
})

View File

@@ -8,6 +8,7 @@ import type { DrizzleAdapter } from '../types.js'
import { getCollectionIdType } from '../utilities/getCollectionIdType.js'
import { isPolymorphicRelationship } from '../utilities/isPolymorphicRelationship.js'
import { isRawConstraint } from '../utilities/rawConstraint.js'
type SanitizeQueryValueArgs = {
adapter: DrizzleAdapter
@@ -48,6 +49,9 @@ export const sanitizeQueryValue = ({
return { operator, value: formattedValue }
}
if (isRawConstraint(val)) {
return { operator, value: val.value }
}
if (
(field.type === 'relationship' || field.type === 'upload') &&
!relationOrPath.endsWith('relationTo') &&

View File

@@ -389,20 +389,25 @@ export const traverseFields = <T extends Record<string, unknown>>({
| { docs: unknown[]; hasNextPage: boolean }
| Record<string, { docs: unknown[]; hasNextPage: boolean }>
if (Array.isArray(fieldData)) {
if (field.localized) {
fieldResult = fieldData.reduce((joinResult, row) => {
if (typeof row._locale === 'string') {
if (!joinResult[row._locale]) {
joinResult[row._locale] = {
docs: [],
hasNextPage: false,
}
if (field.localized && adapter.payload.config.localization) {
fieldResult = fieldData.reduce(
(joinResult, row) => {
if (typeof row.locale === 'string') {
joinResult[row.locale].docs.push(row.id)
}
joinResult[row._locale].docs.push(row._parentID)
}
return joinResult
}, {})
return joinResult
},
// initialize with defaults so empty won't be undefined
adapter.payload.config.localization.localeCodes.reduce((acc, code) => {
acc[code] = {
docs: [],
hasNextPage: false,
}
return acc
}, {}),
)
Object.keys(fieldResult).forEach((locale) => {
fieldResult[locale].hasNextPage = fieldResult[locale].docs.length > limit
fieldResult[locale].docs = fieldResult[locale].docs.slice(0, limit)

View File

@@ -0,0 +1,44 @@
import type { Column, SQL } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import type { DrizzleAdapter } from '../types.js'
export function jsonAgg(adapter: DrizzleAdapter, expression: SQL) {
if (adapter.name === 'sqlite') {
return sql`coalesce(json_group_array(${expression}), '[]')`
}
return sql`coalesce(json_agg(${expression}), '[]'::json)`
}
/**
* @param shape Potential for SQL injections, so you shouldn't allow user-specified key names
*/
export function jsonBuildObject<T extends Record<string, Column | SQL>>(
adapter: DrizzleAdapter,
shape: T,
) {
const chunks: SQL[] = []
Object.entries(shape).forEach(([key, value]) => {
if (chunks.length > 0) {
chunks.push(sql.raw(','))
}
chunks.push(sql.raw(`'${key}',`))
chunks.push(sql`${value}`)
})
if (adapter.name === 'sqlite') {
return sql`json_object(${sql.join(chunks)})`
}
return sql`json_build_object(${sql.join(chunks)})`
}
export const jsonAggBuildObject = <T extends Record<string, Column | SQL>>(
adapter: DrizzleAdapter,
shape: T,
) => {
return jsonAgg(adapter, jsonBuildObject(adapter, shape))
}

View File

@@ -0,0 +1,13 @@
const RawConstraintSymbol = Symbol('RawConstraint')
/**
* You can use this to inject a raw query to where
*/
export const rawConstraint = (value: unknown) => ({
type: RawConstraintSymbol,
value,
})
export const isRawConstraint = (value: unknown): value is ReturnType<typeof rawConstraint> => {
return value && typeof value === 'object' && 'type' in value && value.type === RawConstraintSymbol
}

View File

@@ -6,13 +6,26 @@ const traverseArrayOrBlocksField = ({
callback,
data,
field,
fillEmpty,
parentRef,
}: {
callback: TraverseFieldsCallback
data: Record<string, unknown>[]
field: ArrayField | BlocksField
fillEmpty: boolean
parentRef?: unknown
}) => {
if (fillEmpty) {
if (field.type === 'array') {
traverseFields({ callback, fields: field.fields, parentRef })
}
if (field.type === 'blocks') {
field.blocks.forEach((block) => {
traverseFields({ callback, fields: block.fields, parentRef })
})
}
return
}
for (const ref of data) {
let fields: Field[]
if (field.type === 'blocks' && typeof ref?.blockType === 'string') {
@@ -23,7 +36,7 @@ const traverseArrayOrBlocksField = ({
}
if (fields) {
traverseFields({ callback, fields, parentRef, ref })
traverseFields({ callback, fields, fillEmpty, parentRef, ref })
}
}
}
@@ -50,7 +63,6 @@ export type TraverseFieldsCallback = (args: {
type TraverseFieldsArgs = {
callback: TraverseFieldsCallback
fields: (Field | TabAsField)[]
/** fill empty properties to use this without data */
fillEmpty?: boolean
parentRef?: Record<string, unknown> | unknown
ref?: Record<string, unknown> | unknown
@@ -61,8 +73,9 @@ type TraverseFieldsArgs = {
*
* @param fields
* @param callback callback called for each field, discontinue looping if callback returns truthy
* @param ref
* @param parentRef
* @param fillEmpty fill empty properties to use this without data
* @param ref the data or any artifacts assigned in the callback during field recursion
* @param parentRef the data or any artifacts assigned in the callback during field recursion one level up
*/
export const traverseFields = ({
callback,
@@ -113,6 +126,7 @@ export const traverseFields = ({
traverseFields({
callback,
fields: tab.fields,
fillEmpty,
parentRef: currentParentRef,
ref: tabRef[key],
})
@@ -137,6 +151,7 @@ export const traverseFields = ({
traverseFields({
callback,
fields: tab.fields,
fillEmpty,
parentRef: currentParentRef,
ref: tabRef,
})
@@ -177,6 +192,7 @@ export const traverseFields = ({
traverseFields({
callback,
fields: field.fields,
fillEmpty,
parentRef: currentParentRef,
ref: currentRef[key],
})
@@ -205,6 +221,7 @@ export const traverseFields = ({
callback,
data: localeData,
field,
fillEmpty,
parentRef: currentParentRef,
})
}
@@ -213,6 +230,7 @@ export const traverseFields = ({
callback,
data: currentRef as Record<string, unknown>[],
field,
fillEmpty,
parentRef: currentParentRef,
})
}
@@ -220,6 +238,7 @@ export const traverseFields = ({
traverseFields({
callback,
fields: field.fields,
fillEmpty,
parentRef: currentParentRef,
ref: currentRef,
})

View File

@@ -1,18 +1,78 @@
'use client'
import type { JoinFieldClient, JoinFieldClientComponent, PaginatedDocs, Where } from 'payload'
import type {
ClientField,
JoinFieldClient,
JoinFieldClientComponent,
PaginatedDocs,
Where,
} from 'payload'
import ObjectIdImport from 'bson-objectid'
import { 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 { 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 = ({
docID,
fields,
segments,
}: {
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.type === 'relationship' || field.type === 'upload') {
return {
// TODO: Handle polymorphic https://github.com/payloadcms/payload/pull/9990
[field.name]: field.hasMany ? [docID] : docID,
}
}
const nextSegments = segments.slice(1, segments.length)
if (field.type === 'tab' || field.type === 'group') {
return {
[field.name]: getInitialDrawerData({ docID, fields: field.fields, segments: nextSegments }),
}
}
if (field.type === 'array') {
const initialData = getInitialDrawerData({
docID,
fields: field.fields,
segments: nextSegments,
})
initialData.id = ObjectId().toHexString()
return {
[field.name]: [initialData],
}
}
}
const JoinFieldComponent: JoinFieldClientComponent = (props) => {
const {
field,
@@ -29,6 +89,10 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
const { id: docID } = useDocumentInfo()
const {
config: { collections },
} = useConfig()
const { customComponents: { AfterInput, BeforeInput, Description, Label } = {}, value } =
useField<PaginatedDocs>({
path,
@@ -54,6 +118,16 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
return where
}, [docID, on, field.where])
const initialDrawerData = useMemo(() => {
const relatedCollection = collections.find((collection) => collection.slug === field.collection)
return getInitialDrawerData({
docID,
fields: relatedCollection.fields,
segments: field.on.split('.'),
})
}, [collections, field.on, docID, field.collection])
return (
<div
className={[fieldBaseClass, 'join'].filter(Boolean).join(' ')}
@@ -67,9 +141,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
field={field as JoinFieldClient}
filterOptions={filterOptions}
initialData={docID && value ? value : ({ docs: [] } as PaginatedDocs)}
initialDrawerData={{
[on]: docID,
}}
initialDrawerData={initialDrawerData}
Label={
<h4 style={{ margin: 0 }}>
{Label || (

View File

@@ -103,6 +103,12 @@ export const Categories: CollectionConfig = {
},
],
},
{
name: 'arrayPosts',
type: 'join',
collection: 'posts',
on: 'array.category',
},
{
name: 'singulars',
type: 'join',

View File

@@ -69,5 +69,16 @@ export const Posts: CollectionConfig = {
},
],
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'category',
type: 'relationship',
relationTo: categoriesSlug,
},
],
},
],
}

View File

@@ -94,6 +94,7 @@ describe('Joins Field', () => {
category: category.id,
camelCaseCategory: category.id,
},
array: [{ category: category.id }],
})
}
})
@@ -183,6 +184,15 @@ describe('Joins Field', () => {
expect(docs[0].group.camelCaseCategory.group.camelCasePosts.docs).toHaveLength(10)
})
it('should populate joins with array relationships', async () => {
const categoryWithPosts = await payload.findByID({
id: category.id,
collection: categoriesSlug,
})
expect(categoryWithPosts.arrayPosts.docs).toBeDefined()
})
it('should populate uploads in joins', async () => {
const { docs } = await payload.find({
limit: 1,