Skip to content

Commit f71ef61

Browse files
authored
fix(ui): monomorphic relationship fields don't support multi-select with in/not_in operators (#15886)
# Overview Fixes relationship filter UI to support multi-select when using `in` or `not_in` operators, regardless of the field's `hasMany` property. Previously, only `hasMany: true` fields supported multi-select, making it impossible to filter by multiple values on `hasMany: false` relationship fields. ## Key Changes - Changed multi-select logic from `hasMany` only to `hasMany || ['in', 'not_in'].includes(operator)` - Relationship fields with `hasMany: false` can now select multiple values with `in/not_in` operators - Matches the existing behavior of select fields which allow multi-select based on operator - Updated test helpers to support array values in filter inputs - Added e2e tests for multi-value filtering on `hasMany: false` relationship fields ## Design Decisions The `in` and `not_in` operators semantically expect arrays of values, so the UI should support multi-select regardless of whether the field itself stores multiple values. This aligns with how select fields already work - a `hasMany: false` select field still lets you pick multiple values when using "is in". Also clarified that polymorphic relationships (`relationTo: ['posts', 'users']`) don't support `in/not_in` operators since they store `{relationTo, value}` objects and these operators only match on value. Fixes #15882
1 parent 43d5596 commit f71ef61

File tree

5 files changed

+133
-36
lines changed

5 files changed

+133
-36
lines changed

packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
2727
field: { admin = {}, hasMany, relationTo },
2828
filterOptions,
2929
onChange,
30+
operator,
3031
value,
3132
} = props
3233

3334
const placeholder = 'placeholder' in admin ? admin?.placeholder : undefined
3435
const isSortable = admin?.isSortable
3536

37+
const isMultiValue = hasMany || ['in', 'not_in'].includes(operator)
38+
3639
const {
3740
config: {
3841
routes: { api },
@@ -184,7 +187,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
184187

185188
const findOptionsByValue = useCallback((): Option | Option[] => {
186189
if (value) {
187-
if (hasMany) {
190+
if (isMultiValue) {
188191
if (Array.isArray(value)) {
189192
return value.map((val) => {
190193
if (hasMultipleRelations) {
@@ -237,7 +240,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
237240
}
238241

239242
return undefined
240-
}, [hasMany, hasMultipleRelations, value, options])
243+
}, [isMultiValue, hasMultipleRelations, value, options])
241244

242245
const handleInputChange = useCallback(
243246
(input: string) => {
@@ -340,7 +343,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
340343
*/
341344
useEffect(() => {
342345
if (value && hasLoadedFirstOptions) {
343-
if (hasMany) {
346+
if (isMultiValue) {
344347
const matchedOptions = findOptionsByValue()
345348

346349
;((matchedOptions as Option[]) || []).forEach((option, i) => {
@@ -368,7 +371,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
368371
}, [
369372
addOptionByID,
370373
findOptionsByValue,
371-
hasMany,
374+
isMultiValue,
372375
hasMultipleRelations,
373376
relationTo,
374377
value,
@@ -388,15 +391,15 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
388391
) : (
389392
<ReactSelect
390393
disabled={disabled}
391-
isMulti={hasMany}
394+
isMulti={isMultiValue}
392395
isSortable={isSortable}
393396
onChange={(selected) => {
394397
if (!selected) {
395398
onChange(null)
396399
return
397400
}
398401

399-
if (hasMany && Array.isArray(selected)) {
402+
if (isMultiValue && Array.isArray(selected)) {
400403
onChange(
401404
selected
402405
? selected.map((option) => {

packages/ui/src/elements/WhereBuilder/field-types.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,8 @@ export const getValidFieldOperators = ({
167167
}[] = []
168168

169169
if (field.type === 'relationship' && Array.isArray(field.relationTo)) {
170-
if ('hasMany' in field && field.hasMany) {
171-
validOperators = [...equalsOperators, exists]
172-
} else {
173-
validOperators = [...base]
174-
}
170+
// Polymorphic relationships store {relationTo, value} - in/not_in only match value, not both properties
171+
validOperators = [...equalsOperators, exists]
175172
} else {
176173
validOperators = [...fieldTypeConditions[field.type].operators]
177174
}

test/__helpers/e2e/filters/addListFilter.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const addListFilter = async ({
1717
operatorLabel: string
1818
page: Page
1919
replaceExisting?: boolean
20-
value?: string
20+
value?: string | string[]
2121
}): Promise<{
2222
/**
2323
* A Locator pointing to the condition that was just added.
@@ -69,23 +69,43 @@ export const addListFilter = async ({
6969
response.url().includes(encodeURIComponent('where[or')) && response.status() === 200,
7070
)
7171
const valueLocator = condition.locator('.condition__value')
72-
const valueInput = valueLocator.locator('input')
73-
await valueInput.fill(value)
74-
await expect(valueInput).toHaveValue(value)
75-
76-
if ((await valueLocator.locator('input.rs__input').count()) > 0) {
77-
const valueOptions = condition.locator('.condition__value .rs__option')
78-
const createValue = valueOptions.locator(`text=Create "${value}"`)
79-
if ((await createValue.count()) > 0) {
80-
await createValue.click()
81-
} else {
72+
73+
// Check if this is a react-select input
74+
const isReactSelect = (await valueLocator.locator('input.rs__input').count()) > 0
75+
76+
if (isReactSelect) {
77+
if (Array.isArray(value)) {
78+
// For multi-select with array values
8279
await selectInput({
8380
selectLocator: valueLocator,
84-
multiSelect: multiSelect ? undefined : false,
85-
option: value,
81+
multiSelect: true,
82+
options: value,
8683
})
84+
} else {
85+
// For single select - fill input first to trigger search, then select option
86+
const valueInput = valueLocator.locator('input')
87+
await valueInput.fill(value)
88+
89+
const valueOptions = condition.locator('.condition__value .rs__option')
90+
const createValue = valueOptions.locator(`text=Create "${value}"`)
91+
if ((await createValue.count()) > 0) {
92+
await createValue.click()
93+
} else {
94+
await selectInput({
95+
selectLocator: valueLocator,
96+
multiSelect: multiSelect ? undefined : false,
97+
option: value,
98+
})
99+
}
87100
}
101+
} else {
102+
// For regular input fields (non react-select)
103+
const valueInput = valueLocator.locator('input')
104+
const stringValue = Array.isArray(value) ? value.join(',') : value
105+
await valueInput.fill(stringValue)
106+
await expect(valueInput).toHaveValue(stringValue)
88107
}
108+
89109
await networkPromise
90110
}
91111

test/fields-relationship/e2e.spec.ts

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,21 @@ import type {
1818
VersionedRelationshipField,
1919
} from './payload-types.js'
2020

21+
import { assertNetworkRequests } from '../__helpers/e2e/assertNetworkRequests.js'
22+
import { addArrayRow } from '../__helpers/e2e/fields/array/addArrayRow.js'
23+
import { openCreateDocDrawer } from '../__helpers/e2e/fields/relationship/openCreateDocDrawer.js'
24+
import { addListFilter } from '../__helpers/e2e/filters/index.js'
25+
import { goToNextPage } from '../__helpers/e2e/goToNextPage.js'
2126
import {
2227
ensureCompilationIsDone,
2328
initPageConsoleErrorCatch,
2429
saveDocAndAssert,
2530
// throttleTest,
2631
} from '../__helpers/e2e/helpers.js'
27-
import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
28-
import { assertToastErrors } from '../__helpers/shared/assertToastErrors.js'
29-
import { assertNetworkRequests } from '../__helpers/e2e/assertNetworkRequests.js'
30-
import { addArrayRow } from '../__helpers/e2e/fields/array/addArrayRow.js'
31-
import { openCreateDocDrawer } from '../__helpers/e2e/fields/relationship/openCreateDocDrawer.js'
32-
import { addListFilter } from '../__helpers/e2e/filters/index.js'
33-
import { goToNextPage } from '../__helpers/e2e/goToNextPage.js'
3432
import { openDocControls } from '../__helpers/e2e/openDocControls.js'
3533
import { openDocDrawer } from '../__helpers/e2e/toggleDocDrawer.js'
34+
import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
35+
import { assertToastErrors } from '../__helpers/shared/assertToastErrors.js'
3636
import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
3737
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
3838
import {
@@ -790,8 +790,6 @@ describe('Relationship Field', () => {
790790
await expect(options).toHaveCount(1) // None + 1 Unitled ID
791791
})
792792

793-
// test.todo('should paginate within the dropdown');
794-
795793
test('should search within the relationship field', async () => {
796794
await page.goto(url.edit(docWithExistingRelations.id))
797795
await wait(300)
@@ -1016,7 +1014,7 @@ describe('Relationship Field', () => {
10161014
}
10171015
}
10181016

1019-
test('should filter on polymorphic hasMany=true relationship field', async () => {
1017+
test('should filter on polymorphic hasMany=true relationship field - equals', async () => {
10201018
const { relatedDoc, cleanup } = await createRelatedDoc()
10211019
await page.goto(url.list)
10221020
await addListFilter({
@@ -1029,7 +1027,7 @@ describe('Relationship Field', () => {
10291027
await expect(tableRow).toHaveCount(1)
10301028
await cleanup()
10311029
})
1032-
test('should filter on polymorphic hasMany=false relationship field', async () => {
1030+
test('should filter on polymorphic hasMany=false relationship field - equals', async () => {
10331031
const { relatedDoc, cleanup } = await createRelatedDoc()
10341032
await page.goto(url.list)
10351033
await addListFilter({
@@ -1042,7 +1040,21 @@ describe('Relationship Field', () => {
10421040
await expect(tableRow).toHaveCount(1)
10431041
await cleanup()
10441042
})
1045-
test('should filter on monomorphic hasMany=true relationship field', async () => {
1043+
test('should filter on monomorphic hasMany=false relationship field - is in', async () => {
1044+
const { relatedDoc, cleanup } = await createRelatedDoc()
1045+
await page.goto(url.list)
1046+
await addListFilter({
1047+
page,
1048+
fieldLabel: 'Relationship',
1049+
operatorLabel: 'is in',
1050+
value: relatedDoc.id,
1051+
multiSelect: true,
1052+
})
1053+
const tableRow = page.locator(tableRowLocator)
1054+
await expect(tableRow).toHaveCount(1)
1055+
await cleanup()
1056+
})
1057+
test('should filter on monomorphic hasMany=true relationship field - is in', async () => {
10461058
const { relatedDoc, cleanup } = await createRelatedDoc()
10471059
await page.goto(url.list)
10481060
await addListFilter({
@@ -1056,6 +1068,58 @@ describe('Relationship Field', () => {
10561068
await expect(tableRow).toHaveCount(1)
10571069
await cleanup()
10581070
})
1071+
1072+
test('should filter on monomorphic hasMany=false relationship field - is in with multiple values', async () => {
1073+
// Create multiple related docs
1074+
const relatedDoc1 = await payload.create({
1075+
collection: relationOneSlug,
1076+
data: { name: 'Related Doc 1' },
1077+
})
1078+
const relatedDoc2 = await payload.create({
1079+
collection: relationOneSlug,
1080+
data: { name: 'Related Doc 2' },
1081+
})
1082+
const relatedDoc3 = await payload.create({
1083+
collection: relationOneSlug,
1084+
data: { name: 'Related Doc 3' },
1085+
})
1086+
1087+
// Create main docs that reference different related docs
1088+
const mainDoc1 = await payload.create({
1089+
collection: slug,
1090+
data: { relationship: relatedDoc1.id },
1091+
})
1092+
const mainDoc2 = await payload.create({
1093+
collection: slug,
1094+
data: { relationship: relatedDoc2.id },
1095+
})
1096+
const mainDoc3 = await payload.create({
1097+
collection: slug,
1098+
data: { relationship: relatedDoc3.id },
1099+
})
1100+
1101+
await page.goto(url.list)
1102+
1103+
// Filter by relatedDoc1 and relatedDoc2 (should match mainDoc1 and mainDoc2)
1104+
await addListFilter({
1105+
page,
1106+
fieldLabel: 'Relationship',
1107+
operatorLabel: 'is in',
1108+
value: [String(relatedDoc1.id), String(relatedDoc2.id)],
1109+
multiSelect: true,
1110+
})
1111+
1112+
const tableRow = page.locator(tableRowLocator)
1113+
await expect(tableRow).toHaveCount(2)
1114+
1115+
// Cleanup
1116+
await payload.delete({ collection: slug, id: String(mainDoc1.id) })
1117+
await payload.delete({ collection: slug, id: String(mainDoc2.id) })
1118+
await payload.delete({ collection: slug, id: String(mainDoc3.id) })
1119+
await payload.delete({ collection: relationOneSlug, id: String(relatedDoc1.id) })
1120+
await payload.delete({ collection: relationOneSlug, id: String(relatedDoc2.id) })
1121+
await payload.delete({ collection: relationOneSlug, id: String(relatedDoc3.id) })
1122+
})
10591123
})
10601124
})
10611125

test/fields-relationship/payload-types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ export interface Config {
116116
globals: {};
117117
globalsSelect: {};
118118
locale: 'en';
119+
widgets: {
120+
collections: CollectionsWidget;
121+
};
119122
user: User;
120123
jobs: {
121124
tasks: unknown;
@@ -752,6 +755,16 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
752755
updatedAt?: T;
753756
createdAt?: T;
754757
}
758+
/**
759+
* This interface was referenced by `Config`'s JSON-Schema
760+
* via the `definition` "collections_widget".
761+
*/
762+
export interface CollectionsWidget {
763+
data?: {
764+
[k: string]: unknown;
765+
};
766+
width: 'full';
767+
}
755768
/**
756769
* This interface was referenced by `Config`'s JSON-Schema
757770
* via the `definition` "auth".

0 commit comments

Comments
 (0)