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/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) => diff --git a/packages/ui/src/fields/Relationship/Input.tsx b/packages/ui/src/fields/Relationship/Input.tsx index ea3b0b5b431..c299b3b2da7 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,16 @@ export const RelationshipInput: React.FC = (props) => { }, {}) ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relation) => { + const existingFilter = + filterOptions?.[relation] && typeof filterOptions[relation] === 'object' + ? filterOptions[relation] + : {} + newFilterOptions = { ...(newFilterOptions || {}), - [relation]: { - ...(typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {}), - ...(valuesByRelation[relation] - ? { - id: { - not_in: valuesByRelation[relation], - }, - } - : {}), - }, + [relation]: valuesByRelation[relation] + ? hoistQueryParamsToAnd(existingFilter, { id: { not_in: valuesByRelation[relation] } }) + : existingFilter, } }) } diff --git a/test/fields/collections/Relationship/e2e.spec.ts b/test/fields/collections/Relationship/e2e.spec.ts index c60561f179b..c2b18b5e558 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,36 @@ 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') + + // 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(listDrawerContent.getByText(doc1.text)).toBeHidden() + 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 + // 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(listDrawerContent.getByText(doc1.text)).toBeHidden() + }) + 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; }