Skip to content

Commit 3a1f31a

Browse files
JarrodMFleschhowwohmmclaude
authored
fix(ui): relationship filter duplicate options when switching operators (#16204)
Fixes #15947 ## Summary - Fixes duplicate options appearing in relationship filter dropdowns when switching operators - The `reduceToIDs` function was incorrectly using `option.id` (which doesn't exist on the Option type) instead of `option.value` for deduplication ## Test plan - [x] Unit tests added for optionsReducer deduplication logic - [x] E2E test added that verifies no duplicate options when switching operators Continuation from #16029 which was from a fork of payload that was archived. Co-authored-by: https://github.com/howwohmm --------- Co-authored-by: OM MISHRA <152969928+howwohmm@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1ddeb60 commit 3a1f31a

3 files changed

Lines changed: 188 additions & 3 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import optionsReducer from './optionsReducer.js'
4+
import type { Option } from './types.js'
5+
6+
const mockI18n = {
7+
t: (key: string) => key,
8+
} as any
9+
10+
const mockCollection = {
11+
admin: { useAsTitle: 'title' },
12+
labels: { plural: 'Posts' },
13+
} as any
14+
15+
describe('optionsReducer', () => {
16+
describe('ADD — single relation', () => {
17+
it('deduplicates options when the same doc is loaded twice', () => {
18+
const initial: Option[] = [{ label: 'Post A', value: 'id-1' }]
19+
20+
const result = optionsReducer(initial, {
21+
type: 'ADD',
22+
collection: mockCollection,
23+
data: {
24+
docs: [
25+
{ id: 'id-1', title: 'Post A' }, // already in state
26+
{ id: 'id-2', title: 'Post B' }, // new
27+
],
28+
totalDocs: 2,
29+
limit: 10,
30+
totalPages: 1,
31+
page: 1,
32+
pagingCounter: 1,
33+
hasPrevPage: false,
34+
hasNextPage: false,
35+
prevPage: null,
36+
nextPage: null,
37+
},
38+
hasMultipleRelations: false,
39+
i18n: mockI18n,
40+
relation: 'posts',
41+
})
42+
43+
expect(result).toHaveLength(2)
44+
expect(result.map((o) => o.value)).toEqual(['id-1', 'id-2'])
45+
})
46+
47+
it('appends all docs when state is empty', () => {
48+
const result = optionsReducer([], {
49+
type: 'ADD',
50+
collection: mockCollection,
51+
data: {
52+
docs: [
53+
{ id: 'id-1', title: 'Post A' },
54+
{ id: 'id-2', title: 'Post B' },
55+
],
56+
totalDocs: 2,
57+
limit: 10,
58+
totalPages: 1,
59+
page: 1,
60+
pagingCounter: 1,
61+
hasPrevPage: false,
62+
hasNextPage: false,
63+
prevPage: null,
64+
nextPage: null,
65+
},
66+
hasMultipleRelations: false,
67+
i18n: mockI18n,
68+
relation: 'posts',
69+
})
70+
71+
expect(result).toHaveLength(2)
72+
})
73+
})
74+
75+
describe('ADD — multiple relations (grouped options)', () => {
76+
it('deduplicates sub-options within a group when the same doc is loaded twice', () => {
77+
const initial: Option[] = [
78+
{
79+
label: 'Posts',
80+
value: undefined as any,
81+
options: [{ label: 'Post A', value: 'id-1', relationTo: 'posts' }],
82+
},
83+
]
84+
85+
const result = optionsReducer(initial, {
86+
type: 'ADD',
87+
collection: { ...mockCollection, labels: { plural: 'Posts' } },
88+
data: {
89+
docs: [
90+
{ id: 'id-1', title: 'Post A' }, // duplicate
91+
{ id: 'id-2', title: 'Post B' }, // new
92+
],
93+
totalDocs: 2,
94+
limit: 10,
95+
totalPages: 1,
96+
page: 1,
97+
pagingCounter: 1,
98+
hasPrevPage: false,
99+
hasNextPage: false,
100+
prevPage: null,
101+
nextPage: null,
102+
},
103+
hasMultipleRelations: true,
104+
i18n: { t: () => 'Posts' } as any,
105+
relation: 'posts',
106+
})
107+
108+
const group = result.find((o) => o.options)
109+
expect(group?.options).toHaveLength(2)
110+
expect(group?.options?.map((o) => o.value)).toEqual(['id-1', 'id-2'])
111+
})
112+
})
113+
114+
describe('CLEAR', () => {
115+
it('returns empty array when field is required', () => {
116+
const result = optionsReducer(
117+
[{ label: 'Post A', value: 'id-1' }],
118+
{ type: 'CLEAR', required: true, i18n: mockI18n },
119+
)
120+
expect(result).toEqual([])
121+
})
122+
123+
it('returns none option when field is not required', () => {
124+
const result = optionsReducer(
125+
[{ label: 'Post A', value: 'id-1' }],
126+
{ type: 'CLEAR', required: false, i18n: mockI18n },
127+
)
128+
expect(result).toHaveLength(1)
129+
expect(result[0]?.value).toBe('null')
130+
})
131+
})
132+
})

packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const reduceToIDs = (options) =>
99
return [...ids, ...reduceToIDs(option.options)]
1010
}
1111

12-
return [...ids, option.id]
12+
return [...ids, option.value]
1313
}, [])
1414

1515
const optionsReducer = (state: Option[], action: Action): Option[] => {

test/fields/collections/Relationship/e2e.spec.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import type { Page } from '@playwright/test'
33
import { expect, test } from '@playwright/test'
44
import { checkFocusIndicators } from '__helpers/e2e/checkFocusIndicators.js'
55
import { openCreateDocDrawer } from '__helpers/e2e/fields/relationship/openCreateDocDrawer.js'
6-
import { addListFilter } from '__helpers/e2e/filters/index.js'
6+
import { addListFilter, openListFilters } from '__helpers/e2e/filters/index.js'
77
import { navigateToDoc } from '__helpers/e2e/navigateToDoc.js'
88
import { openDocControls } from '__helpers/e2e/openDocControls.js'
99
import { runAxeScan } from '__helpers/e2e/runAxeScan.js'
10+
import { getSelectInputOptions, selectInput } from '__helpers/e2e/selectInput.js'
1011
import { openDocDrawer } from '__helpers/e2e/toggleDocDrawer.js'
1112
import path from 'path'
1213
import { wait } from 'payload/shared'
@@ -24,8 +25,8 @@ import {
2425
} from '../../../__helpers/e2e/helpers.js'
2526
import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js'
2627
import { assertToastErrors } from '../../../__helpers/shared/assertToastErrors.js'
27-
import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js'
2828
import { reInitializeDB } from '../../../__helpers/shared/clearAndSeed/reInitializeDB.js'
29+
import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js'
2930
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
3031
import { relationshipFieldsSlug, textFieldsSlug } from '../../slugs.js'
3132

@@ -795,6 +796,58 @@ describe('relationship', () => {
795796
await expect(page.locator(tableRowLocator)).toHaveCount(1)
796797
})
797798

799+
test('should not duplicate relationship filter options when switching operators', async () => {
800+
await page.goto(url.list)
801+
await wait(1000)
802+
803+
await openListFilters(page, {})
804+
const whereBuilder = page.locator('.where-builder')
805+
806+
// Add first filter
807+
await whereBuilder.locator('.where-builder__add-first-filter').click()
808+
const condition = whereBuilder.locator('.where-builder__or-filters > li').last()
809+
810+
// Select relationship field
811+
await selectInput({
812+
selectLocator: condition.locator('.condition__field'),
813+
multiSelect: false,
814+
option: 'Relationship',
815+
})
816+
817+
// Select equals operator (default)
818+
await selectInput({
819+
selectLocator: condition.locator('.condition__operator'),
820+
multiSelect: false,
821+
option: 'equals',
822+
})
823+
824+
// Select a value
825+
const valueLocator = condition.locator('.condition__value')
826+
await selectInput({
827+
selectLocator: valueLocator,
828+
multiSelect: false,
829+
option: 'Seeded text document',
830+
selectType: 'relationship',
831+
})
832+
833+
// Switch to "is not equal to" operator
834+
await selectInput({
835+
selectLocator: condition.locator('.condition__operator'),
836+
multiSelect: false,
837+
option: 'is not equal to',
838+
})
839+
840+
// Wait for options to reload
841+
await wait(500)
842+
843+
// Get all options in the value dropdown
844+
const options = await getSelectInputOptions({ selectLocator: valueLocator })
845+
846+
// Verify no duplicates - each option should appear only once
847+
const uniqueOptions = [...new Set(options)]
848+
expect(options.length).toBe(uniqueOptions.length)
849+
})
850+
798851
test('should be able to select relationship with drawer appearance', async () => {
799852
await loadCreatePage()
800853

0 commit comments

Comments
 (0)