From cccf5942780558289ebd3e74bca8588dae44a04c Mon Sep 17 00:00:00 2001 From: lcnogueira Date: Wed, 1 Apr 2026 15:25:36 -0300 Subject: [PATCH 1/5] fix(ui): re-evaluate filterOptions on subsequent drawer opens for relationship fields --- .../src/elements/ListDrawer/DrawerContent.tsx | 11 ++++-- packages/ui/src/fields/Relationship/Input.tsx | 22 ++++++------ .../collections/Relationship/e2e.spec.ts | 34 ++++++++++++++++++- test/fields/collections/Relationship/index.ts | 21 ++++++++++++ test/fields/payload-types.ts | 4 +++ 5 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx index 6c62a5f8bd8..fd01e1510cb 100644 --- a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx @@ -136,10 +136,17 @@ export const ListDrawerContent: React.FC = ({ ) useEffect(() => { - if (!ListView) { + if (!ListView && isOpen) { void refresh({ slug: selectedOption?.value }) } - }, [refresh, ListView, selectedOption.value]) + }, [refresh, ListView, selectedOption.value, isOpen]) + + useEffect(() => { + if (!isOpen) { + setListView(undefined) + setIsLoading(true) + } + }, [isOpen]) const onCreateNew = useCallback( ({ doc }) => { diff --git a/packages/ui/src/fields/Relationship/Input.tsx b/packages/ui/src/fields/Relationship/Input.tsx index ea3b0b5b431..8ebf7bd00f7 100644 --- a/packages/ui/src/fields/Relationship/Input.tsx +++ b/packages/ui/src/fields/Relationship/Input.tsx @@ -8,7 +8,7 @@ import type { } from 'payload' import { dequal } from 'dequal/lite' -import { formatAdminURL, wordBoundariesRegex } from 'payload/shared' +import { formatAdminURL, hoistQueryParamsToAnd, wordBoundariesRegex } from 'payload/shared' import * as qs from 'qs-esm' import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' @@ -128,18 +128,18 @@ export const RelationshipInput: React.FC = (props) => { }, {}) ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relation) => { + if (!valuesByRelation[relation]) { + return + } + + const existingFilter = + typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {} + newFilterOptions = { ...(newFilterOptions || {}), - [relation]: { - ...(typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {}), - ...(valuesByRelation[relation] - ? { - id: { - not_in: valuesByRelation[relation], - }, - } - : {}), - }, + [relation]: hoistQueryParamsToAnd(existingFilter, { + id: { not_in: valuesByRelation[relation] }, + }), } }) } diff --git a/test/fields/collections/Relationship/e2e.spec.ts b/test/fields/collections/Relationship/e2e.spec.ts index c60561f179b..9bb9d22cf11 100644 --- a/test/fields/collections/Relationship/e2e.spec.ts +++ b/test/fields/collections/Relationship/e2e.spec.ts @@ -24,8 +24,8 @@ import { } from '../../../__helpers/e2e/helpers.js' import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js' import { assertToastErrors } from '../../../__helpers/shared/assertToastErrors.js' -import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js' import { reInitializeDB } from '../../../__helpers/shared/clearAndSeed/reInitializeDB.js' +import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js' import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js' import { relationshipFieldsSlug, textFieldsSlug } from '../../slugs.js' @@ -1058,6 +1058,38 @@ describe('relationship', () => { ).toHaveText('new text') }) + test('should re-evaluate `filterOptions` on subsequent drawer opens when a value has already been set', async () => { + const doc1 = await createTextFieldDoc({ text: 'filterBySibling doc 1' }) + const doc2 = await createTextFieldDoc({ text: 'filterBySibling doc 2' }) + await loadCreatePage() + + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + const rows = listDrawerContent.locator('table tbody tr') + + // Select doc1 in sibling1 drawer + await page.locator('#field-relationshipDrawerFilterBySibling1').click() + await expect(listDrawerContent).toBeVisible() + await listDrawerContent.getByText(doc1.text, { exact: true }).click() + await expect(listDrawerContent).toBeHidden() + + // First open of sibling2 drawer: doc1 should be filtered out by filterOptions + await page.locator('#field-relationshipDrawerFilterBySibling2').click() + await expect(listDrawerContent).toBeVisible() + await expect(rows).toHaveCount(1) + await expect(listDrawerContent.getByText(doc2.text)).toBeVisible() + + // Select doc2 and close drawer + await listDrawerContent.getByText(doc2.text, { exact: true }).click() + await expect(listDrawerContent).toBeHidden() + + // Second open: filterOptions must be re-evaluated + // doc1 is excluded by filterOptions (sibling1 = doc1) + // doc2 is excluded because it is already selected + await page.locator('#field-relationshipDrawerFilterBySibling2').click() + await expect(listDrawerContent).toBeVisible() + await expect(rows).toHaveCount(0) + }) + describe('A11y', () => { test.fixme('Create view should have no accessibility violations', async ({}, testInfo) => { await page.goto(url.create) diff --git a/test/fields/collections/Relationship/index.ts b/test/fields/collections/Relationship/index.ts index ec09f8cbcd0..f343e084cff 100644 --- a/test/fields/collections/Relationship/index.ts +++ b/test/fields/collections/Relationship/index.ts @@ -191,6 +191,27 @@ const RelationshipFields: CollectionConfig = { } }, }, + { + name: 'relationshipDrawerFilterBySibling1', + admin: { appearance: 'drawer' }, + type: 'relationship', + relationTo: 'text-fields', + }, + { + name: 'relationshipDrawerFilterBySibling2', + admin: { appearance: 'drawer' }, + type: 'relationship', + relationTo: 'text-fields', + filterOptions: ({ siblingData }) => { + const sibling1 = siblingData?.relationshipDrawerFilterBySibling1 as string | undefined + + if (sibling1) { + return { id: { not_equals: sibling1 } } + } + + return true + }, + }, ], slug: relationshipFieldsSlug, } diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index f29e79cf1d0..6e2ecd080b3 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -1557,6 +1557,8 @@ export interface RelationshipField { relationTo: 'text-fields'; value: string | TextField; } | null; + relationshipDrawerFilterBySibling1?: (string | null) | TextField; + relationshipDrawerFilterBySibling2?: (string | null) | TextField; updatedAt: string; createdAt: string; } @@ -3327,6 +3329,8 @@ export interface RelationshipFieldsSelect { relationshipDrawerHasManyPolymorphic?: T; relationshipDrawerWithAllowCreateFalse?: T; relationshipDrawerWithFilterOptions?: T; + relationshipDrawerFilterBySibling1?: T; + relationshipDrawerFilterBySibling2?: T; updatedAt?: T; createdAt?: T; } From 135ac4ac0a6d56847f76e3f1d3061aaea523bcdc Mon Sep 17 00:00:00 2001 From: lcnogueira Date: Wed, 1 Apr 2026 16:21:07 -0300 Subject: [PATCH 2/5] chore: fix e2e test to use presence assertions instead of row counts --- test/fields/collections/Relationship/e2e.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/fields/collections/Relationship/e2e.spec.ts b/test/fields/collections/Relationship/e2e.spec.ts index 9bb9d22cf11..c2b18b5e558 100644 --- a/test/fields/collections/Relationship/e2e.spec.ts +++ b/test/fields/collections/Relationship/e2e.spec.ts @@ -1064,7 +1064,6 @@ describe('relationship', () => { await loadCreatePage() const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') - const rows = listDrawerContent.locator('table tbody tr') // Select doc1 in sibling1 drawer await page.locator('#field-relationshipDrawerFilterBySibling1').click() @@ -1075,7 +1074,7 @@ describe('relationship', () => { // First open of sibling2 drawer: doc1 should be filtered out by filterOptions await page.locator('#field-relationshipDrawerFilterBySibling2').click() await expect(listDrawerContent).toBeVisible() - await expect(rows).toHaveCount(1) + await expect(listDrawerContent.getByText(doc1.text)).toBeHidden() await expect(listDrawerContent.getByText(doc2.text)).toBeVisible() // Select doc2 and close drawer @@ -1083,11 +1082,10 @@ describe('relationship', () => { await expect(listDrawerContent).toBeHidden() // Second open: filterOptions must be re-evaluated - // doc1 is excluded by filterOptions (sibling1 = doc1) - // doc2 is excluded because it is already selected + // Without the fix, the cached ListView reappears and doc1 would be visible again await page.locator('#field-relationshipDrawerFilterBySibling2').click() await expect(listDrawerContent).toBeVisible() - await expect(rows).toHaveCount(0) + await expect(listDrawerContent.getByText(doc1.text)).toBeHidden() }) describe('A11y', () => { From 7bd659afc0919c3fe99afeaa4dda91a164b56b50 Mon Sep 17 00:00:00 2001 From: lcnogueira Date: Wed, 1 Apr 2026 21:33:18 -0300 Subject: [PATCH 3/5] chore: guard against null filterOptions value in hoistQueryParamsToAnd call --- packages/ui/src/fields/Relationship/Input.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/fields/Relationship/Input.tsx b/packages/ui/src/fields/Relationship/Input.tsx index 8ebf7bd00f7..ca3be9a0f92 100644 --- a/packages/ui/src/fields/Relationship/Input.tsx +++ b/packages/ui/src/fields/Relationship/Input.tsx @@ -133,7 +133,9 @@ export const RelationshipInput: React.FC = (props) => { } const existingFilter = - typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {} + filterOptions?.[relation] && typeof filterOptions[relation] === 'object' + ? filterOptions[relation] + : {} newFilterOptions = { ...(newFilterOptions || {}), From c17e96e3702ebe8ac656c77b44bf76e00970cff0 Mon Sep 17 00:00:00 2001 From: lcnogueira Date: Wed, 1 Apr 2026 22:22:50 -0300 Subject: [PATCH 4/5] chore: always set filterOptions for all relations to avoid undefined lookup in DrawerContent --- packages/ui/src/fields/Relationship/Input.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/fields/Relationship/Input.tsx b/packages/ui/src/fields/Relationship/Input.tsx index ca3be9a0f92..c299b3b2da7 100644 --- a/packages/ui/src/fields/Relationship/Input.tsx +++ b/packages/ui/src/fields/Relationship/Input.tsx @@ -128,10 +128,6 @@ export const RelationshipInput: React.FC = (props) => { }, {}) ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relation) => { - if (!valuesByRelation[relation]) { - return - } - const existingFilter = filterOptions?.[relation] && typeof filterOptions[relation] === 'object' ? filterOptions[relation] @@ -139,9 +135,9 @@ export const RelationshipInput: React.FC = (props) => { newFilterOptions = { ...(newFilterOptions || {}), - [relation]: hoistQueryParamsToAnd(existingFilter, { - id: { not_in: valuesByRelation[relation] }, - }), + [relation]: valuesByRelation[relation] + ? hoistQueryParamsToAnd(existingFilter, { id: { not_in: valuesByRelation[relation] } }) + : existingFilter, } }) } From 0f06c3007c2633c8f40ae0e789d414a68bacaa92 Mon Sep 17 00:00:00 2001 From: lcnogueira Date: Wed, 1 Apr 2026 23:16:07 -0300 Subject: [PATCH 5/5] chore: prevent MemoizedDrawer remount when filterOptions reference changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a ref to hold the latest filterOptions in useListDrawer so that updates to filterOptions (e.g. after selecting a value) do not recreate MemoizedDrawer. A new function reference causes React to unmount and remount ListDrawerContent, resetting selectedOption state — which broke the polymorphic relationship list drawer filter. --- packages/ui/src/elements/ListDrawer/index.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/elements/ListDrawer/index.tsx b/packages/ui/src/elements/ListDrawer/index.tsx index 342bdb56005..8acbddba994 100644 --- a/packages/ui/src/elements/ListDrawer/index.tsx +++ b/packages/ui/src/elements/ListDrawer/index.tsx @@ -1,6 +1,6 @@ 'use client' import { useModal } from '@faceless-ui/modal' -import React, { useCallback, useEffect, useId, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import type { ListDrawerProps, ListTogglerProps, UseListDrawer } from './types.js' @@ -119,6 +119,12 @@ export const useListDrawer: UseListDrawer = ({ openModal(drawerSlug) }, [drawerSlug, openModal]) + // Use a ref so that filterOptions updates don't recreate MemoizedDrawer. + // A new MemoizedDrawer reference causes React to unmount/remount ListDrawerContent, + // which resets its internal state (e.g. selectedOption) — breaking polymorphic drawers. + const filterOptionsRef = useRef(filterOptions) + filterOptionsRef.current = filterOptions + const MemoizedDrawer = useMemo(() => { return (props) => ( ) - }, [drawerSlug, collectionSlugs, uploads, closeDrawer, selectedCollection, filterOptions]) + }, [drawerSlug, collectionSlugs, uploads, closeDrawer, selectedCollection]) const MemoizedDrawerToggler = useMemo(() => { return (props) =>