Skip to content

Commit aaddeac

Browse files
authored
fix(plugin-multi-tenant): hasMany tenant fields double-wrap arrays in filterOptions (#15709)
### What Fixes double-wrapped arrays in filterOptions queries when tenant field has `hasMany: true` ### Why When a collection uses `hasMany: true` on the tenant field, documents store tenant IDs as arrays (e.g., `tenant: [id1, id2]`). The `filterDocumentsByTenants` function was always wrapping the tenant value in an array without checking configuration, causing `{ tenant: { in: [[id1, id2]] } }` instead of `{ tenant: { in: [id1, id2] } }`. This breaks relationship field filtering in the admin UI. ### How Added explicit `hasMany` parameter threaded through `addFilterOptionsToFields` → `addRelationshipFilter` → `filterDocumentsByTenants`. When `hasMany` is true and `docTenantID` is provided, the value is used directly (already an array). Otherwise, it's wrapped in an array. Fixes #15690
1 parent 1b19d5f commit aaddeac

File tree

6 files changed

+115
-6
lines changed

6 files changed

+115
-6
lines changed

packages/plugin-multi-tenant/src/filters/filterDocumentsByTenants.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ type Args<ConfigType = unknown> = {
1111
/**
1212
* If the document this filter is run belongs to a tenant, the tenant ID should be passed here.
1313
* If set, this will be used instead of the tenant cookie
14+
* Can be an array when hasMany is true
1415
*/
15-
docTenantID?: number | string
16+
docTenantID?: number | number[] | string | string[]
1617
filterFieldName: string
1718
req: PayloadRequest
1819
tenantsArrayFieldName?: string
@@ -41,7 +42,7 @@ export const filterDocumentsByTenants = <ConfigType = unknown>({
4142
if (selectedTenant) {
4243
return {
4344
[filterFieldName]: {
44-
in: [selectedTenant],
45+
in: Array.isArray(selectedTenant) ? selectedTenant : [selectedTenant],
4546
},
4647
}
4748
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { multiTenantPostsSlug } from '../shared.js'
4+
5+
export const MultiTenantPosts: CollectionConfig = {
6+
slug: multiTenantPostsSlug,
7+
admin: {
8+
useAsTitle: 'title',
9+
},
10+
fields: [
11+
{
12+
name: 'title',
13+
type: 'text',
14+
required: true,
15+
},
16+
{
17+
name: 'parent',
18+
type: 'relationship',
19+
relationTo: multiTenantPostsSlug,
20+
},
21+
],
22+
}

test/plugin-multi-tenant/config.base.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@ import type { Config as ConfigType } from './payload-types.js'
1212
import { AutosaveGlobal } from './collections/AutosaveGlobal.js'
1313
import { Menu } from './collections/Menu.js'
1414
import { MenuItems } from './collections/MenuItems.js'
15+
import { MultiTenantPosts } from './collections/MultiTenantPosts.js'
1516
import { Relationships } from './collections/Relationships.js'
1617
import { Tenants } from './collections/Tenants.js'
1718
import { Users } from './collections/Users/index.js'
1819
import { seed } from './seed/index.js'
19-
import { autosaveGlobalSlug, menuItemsSlug, menuSlug, notTenantedSlug } from './shared.js'
20+
import {
21+
autosaveGlobalSlug,
22+
menuItemsSlug,
23+
menuSlug,
24+
multiTenantPostsSlug,
25+
notTenantedSlug,
26+
} from './shared.js'
2027

2128
export const baseConfig: Partial<Config> = {
2229
collections: [
@@ -26,6 +33,7 @@ export const baseConfig: Partial<Config> = {
2633
Menu,
2734
AutosaveGlobal,
2835
Relationships,
36+
MultiTenantPosts,
2937
{
3038
slug: notTenantedSlug,
3139
admin: {
@@ -76,8 +84,12 @@ export const baseConfig: Partial<Config> = {
7684
[autosaveGlobalSlug]: {
7785
isGlobal: true,
7886
},
79-
8087
['relationships']: {},
88+
[multiTenantPostsSlug]: {
89+
tenantFieldOverrides: {
90+
hasMany: true,
91+
},
92+
},
8193
},
8294
i18n: {
8395
translations: {

test/plugin-multi-tenant/int.spec.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { Relationship } from './payload-types.js'
99

1010
import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
1111
import { devUser } from '../credentials.js'
12-
import { relationshipsSlug, tenantsSlug, usersSlug } from './shared.js'
12+
import { multiTenantPostsSlug, relationshipsSlug, tenantsSlug, usersSlug } from './shared.js'
1313

1414
let payload: Payload
1515
let restClient: NextRESTClient
@@ -195,7 +195,7 @@ describe('@payloadcms/plugin-multi-tenant', () => {
195195
})
196196

197197
expect(result.docs).toHaveLength(1)
198-
expect(result.docs[0].id).toBe(noTenantUser.id)
198+
expect(result.docs[0]?.id).toBe(noTenantUser.id)
199199

200200
// Cleanup
201201
await payload.delete({ id: noTenantUser.id, collection: usersSlug })
@@ -292,4 +292,47 @@ describe('@payloadcms/plugin-multi-tenant', () => {
292292
await payload.delete({ id: tenantB.id, collection: tenantsSlug })
293293
})
294294
})
295+
296+
describe('hasMany tenant field filtering', () => {
297+
it('should not double-wrap tenant arrays in filterOptions', async () => {
298+
const tenant1 = await payload.create({
299+
collection: tenantsSlug,
300+
data: { name: 'Tenant 1', domain: 'tenant1.test' },
301+
})
302+
const tenant2 = await payload.create({
303+
collection: tenantsSlug,
304+
data: { name: 'Tenant 2', domain: 'tenant2.test' },
305+
})
306+
307+
// Create a post with multiple tenants (hasMany: true)
308+
const post = await payload.create({
309+
collection: multiTenantPostsSlug,
310+
data: {
311+
title: 'Multi-tenant post',
312+
tenant: [tenant1.id, tenant2.id],
313+
},
314+
})
315+
316+
// Get the parent relationship field
317+
const parentField = payload.collections[multiTenantPostsSlug].config.fields.find(
318+
(f) => 'name' in f && f.name === 'parent',
319+
) as any
320+
321+
// Call filterOptions - this internally calls filterDocumentsByTenants with the array
322+
const filter = await parentField.filterOptions({
323+
data: post,
324+
relationTo: multiTenantPostsSlug,
325+
req: { payload } as any,
326+
})
327+
328+
// Array should not be double-wrapped
329+
expect(Array.isArray(filter.tenant.in[0])).toBe(false)
330+
expect(Array.isArray(filter.tenant.in[1])).toBe(false)
331+
332+
// Cleanup
333+
await payload.delete({ id: post.id, collection: multiTenantPostsSlug })
334+
await payload.delete({ id: tenant1.id, collection: tenantsSlug })
335+
await payload.delete({ id: tenant2.id, collection: tenantsSlug })
336+
})
337+
})
295338
})

test/plugin-multi-tenant/payload-types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export interface Config {
7373
'food-menu': FoodMenu;
7474
'autosave-global': AutosaveGlobal;
7575
relationships: Relationship;
76+
'multi-tenant-posts': MultiTenantPost;
7677
notTenanted: NotTenanted;
7778
'payload-kv': PayloadKv;
7879
'payload-locked-documents': PayloadLockedDocument;
@@ -91,6 +92,7 @@ export interface Config {
9192
'food-menu': FoodMenuSelect<false> | FoodMenuSelect<true>;
9293
'autosave-global': AutosaveGlobalSelect<false> | AutosaveGlobalSelect<true>;
9394
relationships: RelationshipsSelect<false> | RelationshipsSelect<true>;
95+
'multi-tenant-posts': MultiTenantPostsSelect<false> | MultiTenantPostsSelect<true>;
9496
notTenanted: NotTenantedSelect<false> | NotTenantedSelect<true>;
9597
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
9698
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -275,6 +277,18 @@ export interface AutosaveGlobal {
275277
createdAt: string;
276278
_status?: ('draft' | 'published') | null;
277279
}
280+
/**
281+
* This interface was referenced by `Config`'s JSON-Schema
282+
* via the `definition` "multi-tenant-posts".
283+
*/
284+
export interface MultiTenantPost {
285+
id: string;
286+
tenant?: (string | Tenant)[] | null;
287+
title: string;
288+
parent?: (string | null) | MultiTenantPost;
289+
updatedAt: string;
290+
createdAt: string;
291+
}
278292
/**
279293
* This interface was referenced by `Config`'s JSON-Schema
280294
* via the `definition` "payload-kv".
@@ -323,6 +337,10 @@ export interface PayloadLockedDocument {
323337
relationTo: 'relationships';
324338
value: string | Relationship;
325339
} | null)
340+
| ({
341+
relationTo: 'multi-tenant-posts';
342+
value: string | MultiTenantPost;
343+
} | null)
326344
| ({
327345
relationTo: 'notTenanted';
328346
value: string | NotTenanted;
@@ -465,6 +483,17 @@ export interface RelationshipsSelect<T extends boolean = true> {
465483
updatedAt?: T;
466484
createdAt?: T;
467485
}
486+
/**
487+
* This interface was referenced by `Config`'s JSON-Schema
488+
* via the `definition` "multi-tenant-posts_select".
489+
*/
490+
export interface MultiTenantPostsSelect<T extends boolean = true> {
491+
tenant?: T;
492+
title?: T;
493+
parent?: T;
494+
updatedAt?: T;
495+
createdAt?: T;
496+
}
468497
/**
469498
* This interface was referenced by `Config`'s JSON-Schema
470499
* via the `definition` "notTenanted_select".

test/plugin-multi-tenant/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ export const autosaveGlobalSlug = 'autosave-global'
1111
export const relationshipsSlug = 'relationships'
1212

1313
export const notTenantedSlug = 'notTenanted'
14+
15+
export const multiTenantPostsSlug = 'multi-tenant-posts'

0 commit comments

Comments
 (0)