From 8dcd93dabdcc461f19bf528e45352d12e164dfaa Mon Sep 17 00:00:00 2001 From: Jan-Willem Gmelig Meyling Date: Fri, 15 May 2026 10:08:09 +0200 Subject: [PATCH] fix(mui): clear stale filter value on DataGrid filter row field swap When a user changed the column on an existing filter row in the MUI X DataGrid filter panel, MUI carried over the previous value to the new field. `useDataGrid` forwarded that value to the data provider as a CrudFilter, producing nonsensical queries for fields whose value space differs (enums, foreign-key references, booleans, etc.). `onFilterModelChange` now keeps a ref to the previous filter model and, when the item count is unchanged, compares items position-for-position; any row whose `field` changed gets its `value` cleared before the model is translated to CrudFilters. The caller's model is not mutated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../silent-mui-datagrid-filter-field-swap.md | 9 ++ .../mui/src/hooks/useDataGrid/index.spec.ts | 152 ++++++++++++++++++ packages/mui/src/hooks/useDataGrid/index.ts | 30 +++- 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 .changeset/silent-mui-datagrid-filter-field-swap.md diff --git a/.changeset/silent-mui-datagrid-filter-field-swap.md b/.changeset/silent-mui-datagrid-filter-field-swap.md new file mode 100644 index 0000000000000..edc9ae25f6148 --- /dev/null +++ b/.changeset/silent-mui-datagrid-filter-field-swap.md @@ -0,0 +1,9 @@ +--- +"@refinedev/mui": patch +--- + +fix(mui): clear stale filter value when a DataGrid filter row's field is swapped + +In `useDataGrid`, when the user changes the column on an existing row in the MUI X DataGrid filter panel, MUI keeps the previously entered `value`. That value is meaningless under the new field (enums, foreign-key references, booleans, etc.) and was forwarded to the data provider as a `CrudFilter`, producing invalid queries. + +`onFilterModelChange` now compares the incoming filter model against the previous one position-for-position when the item count is unchanged. If a row's `field` changed but its `value` was carried over, the value is cleared before the model is translated to a `CrudFilter`. Add/remove row paths are untouched. diff --git a/packages/mui/src/hooks/useDataGrid/index.spec.ts b/packages/mui/src/hooks/useDataGrid/index.spec.ts index e3eafa5f2831f..f383ab37404fa 100644 --- a/packages/mui/src/hooks/useDataGrid/index.spec.ts +++ b/packages/mui/src/hooks/useDataGrid/index.spec.ts @@ -308,6 +308,158 @@ describe("useDataGrid Hook", () => { }); }); + describe("onFilterModelChange — field swap value clearing", () => { + it("clears the carried-over value when only the field changes at the same position", async () => { + const { result } = renderHook( + () => + useDataGrid({ + resource: "posts", + filters: { mode: "off" }, + }), + { wrapper: TestWrapper({}) }, + ); + + await waitFor(() => { + expect(!result.current.tableQuery?.isLoading).toBeTruthy(); + }); + + // User picks field "title" with value "X". + await act(async () => { + result.current.dataGridProps.onFilterModelChange( + { + items: [ + { id: 1, field: "title", operator: "contains", value: "X" }, + ], + }, + {} as any, + ); + }); + + expect(result.current.filters).toEqual([ + { field: "title", operator: "contains", value: "X" }, + ]); + + // User changes the field to "status" — MUI carries over the old value "X". + await act(async () => { + result.current.dataGridProps.onFilterModelChange( + { + items: [ + { id: 1, field: "status", operator: "contains", value: "X" }, + ], + }, + {} as any, + ); + }); + + // The stale value must NOT leak into the data provider as a filter on + // the new field. + expect(result.current.filters).toEqual([]); + }); + + it("preserves the value when only the value changes (same field)", async () => { + const { result } = renderHook( + () => + useDataGrid({ + resource: "posts", + filters: { mode: "off" }, + }), + { wrapper: TestWrapper({}) }, + ); + + await waitFor(() => { + expect(!result.current.tableQuery?.isLoading).toBeTruthy(); + }); + + await act(async () => { + result.current.dataGridProps.onFilterModelChange( + { + items: [ + { id: 1, field: "title", operator: "contains", value: "X" }, + ], + }, + {} as any, + ); + }); + + await act(async () => { + result.current.dataGridProps.onFilterModelChange( + { + items: [ + { id: 1, field: "title", operator: "contains", value: "Y" }, + ], + }, + {} as any, + ); + }); + + expect(result.current.filters).toEqual([ + { field: "title", operator: "contains", value: "Y" }, + ]); + }); + + it("does not clear values when a row is added or removed", async () => { + const { result } = renderHook( + () => + useDataGrid({ + resource: "posts", + filters: { mode: "off" }, + }), + { wrapper: TestWrapper({}) }, + ); + + await waitFor(() => { + expect(!result.current.tableQuery?.isLoading).toBeTruthy(); + }); + + await act(async () => { + result.current.dataGridProps.onFilterModelChange( + { + items: [ + { id: 1, field: "title", operator: "contains", value: "X" }, + ], + }, + {} as any, + ); + }); + + // Adding a second row must not nuke the first row's value. + await act(async () => { + result.current.dataGridProps.onFilterModelChange( + { + items: [ + { id: 1, field: "title", operator: "contains", value: "X" }, + { id: 2, field: "status", operator: "contains", value: "draft" }, + ], + }, + {} as any, + ); + }); + + expect(result.current.filters).toEqual([ + { field: "title", operator: "contains", value: "X" }, + { field: "status", operator: "contains", value: "draft" }, + ]); + + // Removing the first row must not nuke the remaining row's value, even + // though a naive position-by-position diff would see the field change at + // index 0. + await act(async () => { + result.current.dataGridProps.onFilterModelChange( + { + items: [ + { id: 2, field: "status", operator: "contains", value: "draft" }, + ], + }, + {} as any, + ); + }); + + expect(result.current.filters).toEqual([ + { field: "status", operator: "contains", value: "draft" }, + ]); + }); + }); + it("should not change sortModel when page changes", async () => { const { result } = renderHook( () => diff --git a/packages/mui/src/hooks/useDataGrid/index.ts b/packages/mui/src/hooks/useDataGrid/index.ts index 2a89c85468bee..9209ebc00a636 100644 --- a/packages/mui/src/hooks/useDataGrid/index.ts +++ b/packages/mui/src/hooks/useDataGrid/index.ts @@ -161,6 +161,9 @@ export function useDataGrid< const columnsTypes = useRef>({}); // Debounce server-side filter fetches so UI input stays responsive. const filterDebounceRef = useRef | null>(null); + // Tracks the last filter model seen by `onFilterModelChange` so we can detect + // when the user swapped a row's `field` and clear the now-stale `value`. + const previousFilterModelRef = useRef(null); const { identifier } = useResourceParams({ resource: resourceFromProp }); @@ -260,7 +263,32 @@ export function useDataGrid< }; const handleFilterModelChange = (filterModel: GridFilterModel) => { - const crudFilters = transformFilterModelToCrudFilters(filterModel); + // When the user changes a row's `field` in the filter panel, MUI keeps the + // previous `value` — which is meaningless under the new field (enums, + // foreign-key refs, booleans, etc). Clear it before translating to a + // CrudFilter so we don't ship an invalid query to the data provider. + const previousModel = previousFilterModelRef.current; + let normalizedModel = filterModel; + if ( + previousModel && + previousModel.items.length === filterModel.items.length + ) { + let mutated = false; + const items = filterModel.items.map((newItem, i) => { + const oldItem = previousModel.items[i]; + if (oldItem && oldItem.field !== newItem.field) { + mutated = true; + return { ...newItem, value: null }; + } + return newItem; + }); + if (mutated) { + normalizedModel = { ...filterModel, items }; + } + } + previousFilterModelRef.current = normalizedModel; + + const crudFilters = transformFilterModelToCrudFilters(normalizedModel); setMuiCrudFilters(crudFilters); if (isServerSideFilteringEnabled) { // Let the input update immediately; debounce only the server query.