From b4c4271305e3de36d3866a0d003a55ca649a6dfc Mon Sep 17 00:00:00 2001 From: OM MISHRA <152969928+howwohmm@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:09:10 +0530 Subject: [PATCH 1/3] fix(ui): relationship filter duplicate options when switching operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `reduceToIDs` helper in the relationship filter's optionsReducer read `option.id`, but options are created with `value` (not `id`). This meant deduplication never worked — `loadedIDs` was always an array of `undefined` values. When switching from `equals` to `is_in`, the second useEffect called `addOptionByID` for the already-loaded value, and because dedup was broken the same option was appended again. Fix: read `option.value` instead of `option.id` so existing options are correctly recognised and skipped. Fixes #15947 Co-Authored-By: Claude Opus 4.6 --- .../WhereBuilder/Condition/Relationship/optionsReducer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts index bcfddfe5fdc..8cb9f418e1c 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts +++ b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts @@ -9,7 +9,7 @@ const reduceToIDs = (options) => return [...ids, ...reduceToIDs(option.options)] } - return [...ids, option.id] + return [...ids, option.value] }, []) const optionsReducer = (state: Option[], action: Action): Option[] => { From 00ed65f1e1fc83d124212417d4d25ecdfe78e361 Mon Sep 17 00:00:00 2001 From: OM MISHRA <152969928+howwohmm@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:56:01 +0530 Subject: [PATCH 2/3] fix: add fallback for option.id in reduceToIDs Co-Authored-By: Claude Opus 4.6 --- .../WhereBuilder/Condition/Relationship/optionsReducer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts index 8cb9f418e1c..b373d4e75d5 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts +++ b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.ts @@ -9,7 +9,7 @@ const reduceToIDs = (options) => return [...ids, ...reduceToIDs(option.options)] } - return [...ids, option.value] + return [...ids, option.value ?? option.id] }, []) const optionsReducer = (state: Option[], action: Action): Option[] => { From ce95dc4e21afed15e4e7862a94280246d57bc443 Mon Sep 17 00:00:00 2001 From: OM MISHRA <152969928+howwohmm@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:56:40 +0530 Subject: [PATCH 3/3] test(ui): add unit tests for WhereBuilder optionsReducer deduplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify that ADD correctly deduplicates options by value — both for single-relation flat lists and multi-relation grouped options. The dedup test would fail with the original `option.id` read (always undefined on `{ label, value }` options) and passes with `option.value ?? option.id`. Co-Authored-By: Claude Sonnet 4.6 --- .../Relationship/optionsReducer.spec.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.spec.ts diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.spec.ts b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.spec.ts new file mode 100644 index 00000000000..30f156f40ec --- /dev/null +++ b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/optionsReducer.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' + +import optionsReducer from './optionsReducer.js' +import type { Option } from './types.js' + +const mockI18n = { + t: (key: string) => key, +} as any + +const mockCollection = { + admin: { useAsTitle: 'title' }, + labels: { plural: 'Posts' }, +} as any + +describe('optionsReducer', () => { + describe('ADD — single relation', () => { + it('deduplicates options when the same doc is loaded twice', () => { + const initial: Option[] = [{ label: 'Post A', value: 'id-1' }] + + const result = optionsReducer(initial, { + type: 'ADD', + collection: mockCollection, + data: { + docs: [ + { id: 'id-1', title: 'Post A' }, // already in state + { id: 'id-2', title: 'Post B' }, // new + ], + totalDocs: 2, + limit: 10, + totalPages: 1, + page: 1, + pagingCounter: 1, + hasPrevPage: false, + hasNextPage: false, + prevPage: null, + nextPage: null, + }, + hasMultipleRelations: false, + i18n: mockI18n, + relation: 'posts', + }) + + expect(result).toHaveLength(2) + expect(result.map((o) => o.value)).toEqual(['id-1', 'id-2']) + }) + + it('appends all docs when state is empty', () => { + const result = optionsReducer([], { + type: 'ADD', + collection: mockCollection, + data: { + docs: [ + { id: 'id-1', title: 'Post A' }, + { id: 'id-2', title: 'Post B' }, + ], + totalDocs: 2, + limit: 10, + totalPages: 1, + page: 1, + pagingCounter: 1, + hasPrevPage: false, + hasNextPage: false, + prevPage: null, + nextPage: null, + }, + hasMultipleRelations: false, + i18n: mockI18n, + relation: 'posts', + }) + + expect(result).toHaveLength(2) + }) + }) + + describe('ADD — multiple relations (grouped options)', () => { + it('deduplicates sub-options within a group when the same doc is loaded twice', () => { + const initial: Option[] = [ + { + label: 'Posts', + value: undefined as any, + options: [{ label: 'Post A', value: 'id-1', relationTo: 'posts' }], + }, + ] + + const result = optionsReducer(initial, { + type: 'ADD', + collection: { ...mockCollection, labels: { plural: 'Posts' } }, + data: { + docs: [ + { id: 'id-1', title: 'Post A' }, // duplicate + { id: 'id-2', title: 'Post B' }, // new + ], + totalDocs: 2, + limit: 10, + totalPages: 1, + page: 1, + pagingCounter: 1, + hasPrevPage: false, + hasNextPage: false, + prevPage: null, + nextPage: null, + }, + hasMultipleRelations: true, + i18n: { t: () => 'Posts' } as any, + relation: 'posts', + }) + + const group = result.find((o) => o.options) + expect(group?.options).toHaveLength(2) + expect(group?.options?.map((o) => o.value)).toEqual(['id-1', 'id-2']) + }) + }) + + describe('CLEAR', () => { + it('returns empty array when field is required', () => { + const result = optionsReducer( + [{ label: 'Post A', value: 'id-1' }], + { type: 'CLEAR', required: true, i18n: mockI18n }, + ) + expect(result).toEqual([]) + }) + + it('returns none option when field is not required', () => { + const result = optionsReducer( + [{ label: 'Post A', value: 'id-1' }], + { type: 'CLEAR', required: false, i18n: mockI18n }, + ) + expect(result).toHaveLength(1) + expect(result[0]?.value).toBe('null') + }) + }) +})