Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/silent-mui-datagrid-filter-field-swap.md
Original file line number Diff line number Diff line change
@@ -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.
152 changes: 152 additions & 0 deletions packages/mui/src/hooks/useDataGrid/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand Down
30 changes: 29 additions & 1 deletion packages/mui/src/hooks/useDataGrid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export function useDataGrid<
const columnsTypes = useRef<Record<string, string>>({});
// Debounce server-side filter fetches so UI input stays responsive.
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | 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<GridFilterModel | null>(null);

const { identifier } = useResourceParams({ resource: resourceFromProp });

Expand Down Expand Up @@ -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.
Expand Down
Loading