Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/perfect-knives-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@refinedev/core": patch
"@refinedev/nextjs-router": patch
"@refinedev/remix-router": patch
"@refinedev/react-router": patch
---

fix: correctly parse deeply nested conditional filters from URL with syncWithLocation

Increased `qs.parse` depth from default 5 to 10 to support deeply nested conditional filters (e.g., `or -> and -> {field, operator, value}`). Previously, nested filter properties were incorrectly parsed as bracket notation keys (`[field]`, `[operator]`, `[value]`) after page reload when using `syncWithLocation: true`.
55 changes: 55 additions & 0 deletions packages/core/src/definitions/table/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,4 +636,59 @@ describe("definitions/table", () => {
}),
).toEqual("");
});

it("parseTableParams should handle deeply nested conditional filters", () => {
const filters: CrudFilter[] = [
{
operator: "or",
value: [
{
operator: "and",
value: [
{ field: "isDismissed", operator: "eq", value: true },
{ field: "participation.id", operator: "nnull", value: "" },
],
},
{
operator: "and",
value: [{ field: "isDismissed", operator: "eq", value: false }],
},
],
},
];

const url = stringifyTableParams({
pagination: { currentPage: 1, pageSize: 10 },
sorters: [],
filters,
});

const { parsedFilters } = parseTableParams(`?${url}`);

expect(parsedFilters).toHaveLength(1);
expect(parsedFilters[0]).toHaveProperty("operator", "or");
expect(parsedFilters[0]).toHaveProperty("value");

const orValue = (parsedFilters[0] as any).value;
expect(orValue).toHaveLength(2);

expect(orValue[0]).toHaveProperty("operator", "and");
expect(orValue[0].value).toHaveLength(2);
expect(orValue[0].value[0]).toHaveProperty("field", "isDismissed");
expect(orValue[0].value[0]).toHaveProperty("operator", "eq");
expect(orValue[0].value[0]).toHaveProperty("value", "true");
expect(orValue[0].value[1]).toHaveProperty("field", "participation.id");
expect(orValue[0].value[1]).toHaveProperty("operator", "nnull");

expect(orValue[1]).toHaveProperty("operator", "and");
expect(orValue[1].value).toHaveLength(1);
expect(orValue[1].value[0]).toHaveProperty("field", "isDismissed");
expect(orValue[1].value[0]).toHaveProperty("operator", "eq");
expect(orValue[1].value[0]).toHaveProperty("value", "false");

const stringified = JSON.stringify(parsedFilters);
expect(stringified).not.toContain("[field]");
expect(stringified).not.toContain("[operator]");
expect(stringified).not.toContain("[value]");
});
});
11 changes: 11 additions & 0 deletions packages/core/src/definitions/table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ import type {
SortOrder,
} from "../../contexts/data/types";

/**
* Depth limit for `qs.parse`. Deeply nested conditional filters
* (e.g. `or -> and -> { field, operator, value }`) require at least 7 levels.
* We use 10 to leave comfortable headroom.
*/
export const QS_PARSE_DEPTH = 10;

export const parseTableParams = (url: string) => {
const { currentPage, pageSize, sorters, sorter, filters } = qs.parse(
url.substring(1), // remove first ? character
{ depth: QS_PARSE_DEPTH },
);

return {
Expand Down Expand Up @@ -44,6 +52,9 @@ export const stringifyTableParams = (params: {
filters: CrudFilter[];
[key: string]: any;
}): string => {
// Note: qs.stringify has no depth limit by default, so it correctly
// serialises deeply nested filters without extra configuration.
// The matching qs.parse call uses QS_PARSE_DEPTH to deserialise them.
const options: IStringifyOptions = {
skipNulls: true,
arrayFormat: "indices",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
getDefaultSortOrder,
parseTableParams,
parseTableParamsFromQuery,
QS_PARSE_DEPTH,
setInitialFilters,
setInitialSorters,
stringifyTableParams,
Expand Down
7 changes: 6 additions & 1 deletion packages/nextjs-router/src/app/bindings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ResourceContext,
matchResourceFromRoute,
type ParseResponse,
QS_PARSE_DEPTH,
} from "@refinedev/core";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import NextLink from "next/link";
Expand Down Expand Up @@ -48,6 +49,7 @@ export const routerProvider: RouterProvider = {
...(keepQuery
? qs.parse(searchParamsObj.toString(), {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
})
: {}),
...query,
Expand Down Expand Up @@ -109,7 +111,10 @@ export const routerProvider: RouterProvider = {

const parsedParams = React.useMemo(() => {
const searchParams = searchParamsObj.toString();
return qs.parse(searchParams, { ignoreQueryPrefix: true });
return qs.parse(searchParams, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
});
}, [searchParamsObj]);

const fn = React.useCallback(() => {
Expand Down
7 changes: 5 additions & 2 deletions packages/nextjs-router/src/common/parse-table-params.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import qs from "qs";
import type { ParsedParams } from "@refinedev/core";
import { type ParsedParams, QS_PARSE_DEPTH } from "@refinedev/core";

const parseTableParams = (search: string) => {
const parsed: ParsedParams = qs.parse(search, { ignoreQueryPrefix: true });
const parsed: ParsedParams = qs.parse(search, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
});

const tableReady = {
...parsed,
Expand Down
8 changes: 7 additions & 1 deletion packages/nextjs-router/src/pages/bindings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ResourceContext,
matchResourceFromRoute,
type ParseResponse,
QS_PARSE_DEPTH,
} from "@refinedev/core";
import { useRouter } from "next/router";
import NextLink from "next/link";
Expand Down Expand Up @@ -46,6 +47,7 @@ export const routerProvider: RouterProvider = {
...(keepQuery
? qs.parse(pathname.split("?")[1], {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
})
: {}),
...query,
Expand Down Expand Up @@ -110,12 +112,16 @@ export const routerProvider: RouterProvider = {

const parsedParams = React.useMemo(() => {
const searchParams = pathname.split("?")[1];
return qs.parse(searchParams, { ignoreQueryPrefix: true });
return qs.parse(searchParams, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
});
}, [pathname]);

const fn = React.useCallback(() => {
const parsedQuery = qs.parse(query as Record<string, string>, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
});
const combinedParams = {
...inferredParams,
Expand Down
12 changes: 10 additions & 2 deletions packages/react-router/src/bindings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type RouterProvider,
matchResourceFromRoute,
ResourceContext,
QS_PARSE_DEPTH,
} from "@refinedev/core";
import { useCallback, useContext } from "react";
import qs from "qs";
Expand Down Expand Up @@ -42,7 +43,11 @@ export const routerProvider: RouterProvider = {
const urlQuery = {
...(keepQuery &&
existingSearch &&
qs.parse(existingSearch, { ignoreQueryPrefix: true })),
qs.parse(existingSearch, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
})),

...query,
};

Expand Down Expand Up @@ -106,7 +111,10 @@ export const routerProvider: RouterProvider = {
}

const fn = useCallback(() => {
const parsedSearch = qs.parse(search, { ignoreQueryPrefix: true });
const parsedSearch = qs.parse(search, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
});

const combinedParams = {
...params,
Expand Down
11 changes: 9 additions & 2 deletions packages/remix-router/src/bindings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ResourceContext,
matchResourceFromRoute,
type ParseResponse,
QS_PARSE_DEPTH,
} from "@refinedev/core";
import { useParams, useLocation, useNavigate, Link } from "@remix-run/react";
import qs from "qs";
Expand Down Expand Up @@ -36,7 +37,10 @@ export const routerProvider: RouterProvider = {
const urlQuery = {
...(keepQuery &&
existingSearch &&
qs.parse(existingSearch, { ignoreQueryPrefix: true })),
qs.parse(existingSearch, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
})),
...query,
};

Expand Down Expand Up @@ -98,7 +102,10 @@ export const routerProvider: RouterProvider = {
const inferredId = inferredParams.id;

const fn = useCallback(() => {
const parsedSearch = qs.parse(search, { ignoreQueryPrefix: true });
const parsedSearch = qs.parse(search, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
});

const combinedParams = {
...inferredParams,
Expand Down
7 changes: 5 additions & 2 deletions packages/remix-router/src/parse-table-params.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import qs from "qs";
import type { ParsedParams } from "@refinedev/core";
import { type ParsedParams, QS_PARSE_DEPTH } from "@refinedev/core";

export const parseTableParams = (search: string) => {
const parsed: ParsedParams = qs.parse(search, { ignoreQueryPrefix: true });
const parsed: ParsedParams = qs.parse(search, {
ignoreQueryPrefix: true,
depth: QS_PARSE_DEPTH,
});

const tableReady = {
...parsed,
Expand Down
Loading