diff --git a/.changeset/perfect-knives-call.md b/.changeset/perfect-knives-call.md new file mode 100644 index 0000000000000..2a9aa117569d4 --- /dev/null +++ b/.changeset/perfect-knives-call.md @@ -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`. diff --git a/packages/core/src/definitions/table/index.spec.ts b/packages/core/src/definitions/table/index.spec.ts index 1cbca849989f1..0cb21da960fcf 100644 --- a/packages/core/src/definitions/table/index.spec.ts +++ b/packages/core/src/definitions/table/index.spec.ts @@ -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]"); + }); }); diff --git a/packages/core/src/definitions/table/index.ts b/packages/core/src/definitions/table/index.ts index 53508118e8eff..eb294fd2cf6ec 100644 --- a/packages/core/src/definitions/table/index.ts +++ b/packages/core/src/definitions/table/index.ts @@ -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 { @@ -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", diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index f3bcc10d7c8ee..9e1ec77fa0147 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -9,6 +9,7 @@ export { getDefaultSortOrder, parseTableParams, parseTableParamsFromQuery, + QS_PARSE_DEPTH, setInitialFilters, setInitialSorters, stringifyTableParams, diff --git a/packages/nextjs-router/src/app/bindings.tsx b/packages/nextjs-router/src/app/bindings.tsx index b5cadf34d9e56..6273845e7e2cc 100644 --- a/packages/nextjs-router/src/app/bindings.tsx +++ b/packages/nextjs-router/src/app/bindings.tsx @@ -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"; @@ -48,6 +49,7 @@ export const routerProvider: RouterProvider = { ...(keepQuery ? qs.parse(searchParamsObj.toString(), { ignoreQueryPrefix: true, + depth: QS_PARSE_DEPTH, }) : {}), ...query, @@ -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(() => { diff --git a/packages/nextjs-router/src/common/parse-table-params.ts b/packages/nextjs-router/src/common/parse-table-params.ts index 2c0a3ee0c12c1..ec5ddf3ec9896 100644 --- a/packages/nextjs-router/src/common/parse-table-params.ts +++ b/packages/nextjs-router/src/common/parse-table-params.ts @@ -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, diff --git a/packages/nextjs-router/src/pages/bindings.tsx b/packages/nextjs-router/src/pages/bindings.tsx index 3b1526188ea8e..e854fa0dc24f0 100644 --- a/packages/nextjs-router/src/pages/bindings.tsx +++ b/packages/nextjs-router/src/pages/bindings.tsx @@ -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"; @@ -46,6 +47,7 @@ export const routerProvider: RouterProvider = { ...(keepQuery ? qs.parse(pathname.split("?")[1], { ignoreQueryPrefix: true, + depth: QS_PARSE_DEPTH, }) : {}), ...query, @@ -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, { ignoreQueryPrefix: true, + depth: QS_PARSE_DEPTH, }); const combinedParams = { ...inferredParams, diff --git a/packages/react-router/src/bindings.tsx b/packages/react-router/src/bindings.tsx index 5e65ff6dd2e37..5750eb9602ed8 100644 --- a/packages/react-router/src/bindings.tsx +++ b/packages/react-router/src/bindings.tsx @@ -5,6 +5,7 @@ import { type RouterProvider, matchResourceFromRoute, ResourceContext, + QS_PARSE_DEPTH, } from "@refinedev/core"; import { useCallback, useContext } from "react"; import qs from "qs"; @@ -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, }; @@ -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, diff --git a/packages/remix-router/src/bindings.tsx b/packages/remix-router/src/bindings.tsx index b42ab1fb5264a..d010b15212a39 100644 --- a/packages/remix-router/src/bindings.tsx +++ b/packages/remix-router/src/bindings.tsx @@ -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"; @@ -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, }; @@ -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, diff --git a/packages/remix-router/src/parse-table-params.ts b/packages/remix-router/src/parse-table-params.ts index 27ba8b36761b9..9326786473066 100644 --- a/packages/remix-router/src/parse-table-params.ts +++ b/packages/remix-router/src/parse-table-params.ts @@ -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,