From b8107ba424fb17f3eb28997505e97b8e9f14b62a Mon Sep 17 00:00:00 2001 From: taro-28 Date: Sat, 22 Feb 2025 11:48:09 +0900 Subject: [PATCH] Support column order --- README.md | 2 +- examples/lib/src/components/UserTable.tsx | 379 ++++++++++-------- .../src/app/custom-default-value/table.tsx | 1 + .../src/app/custom-encoder-decoder/table.tsx | 7 + .../src/app/custom-param-name/table.tsx | 1 + .../src/app/debounce/table.tsx | 1 + .../src/pages/custom-default-value.tsx | 1 + .../src/pages/custom-encoder-decoder.tsx | 5 + .../src/pages/custom-param-name.tsx | 1 + .../next-pages-router/src/pages/debounce.tsx | 1 + .../src/custom-default-value.tsx | 1 + .../src/custom-encoder-decoder.tsx | 4 + .../src/custom-param-name.tsx | 1 + examples/react-router-lib/src/debounce.tsx | 1 + .../src/routes/custom-default-value.tsx | 1 + .../src/routes/custom-encoder-decoder.tsx | 7 + .../src/routes/custom-param-name.tsx | 1 + .../tanstack-router/src/routes/debounce.tsx | 1 + .../tanstack-table-search-params/README.md | 2 +- .../src/encoder-decoder/columnOrder.test.ts | 159 ++++++++ .../src/encoder-decoder/columnOrder.ts | 36 ++ .../tanstack-table-search-params/src/index.ts | 17 +- .../src/tests/columnOrder.test.ts | 206 ++++++++++ .../next-pages-router/columnOrder.test.ts | 199 +++++++++ .../src/useColumnOrder.ts | 122 ++++++ 25 files changed, 977 insertions(+), 180 deletions(-) create mode 100644 packages/tanstack-table-search-params/src/encoder-decoder/columnOrder.test.ts create mode 100644 packages/tanstack-table-search-params/src/encoder-decoder/columnOrder.ts create mode 100644 packages/tanstack-table-search-params/src/tests/columnOrder.test.ts create mode 100644 packages/tanstack-table-search-params/src/tests/next-pages-router/columnOrder.test.ts create mode 100644 packages/tanstack-table-search-params/src/useColumnOrder.ts diff --git a/README.md b/README.md index cfad640..dcd61ef 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ List of supported TanStack table states - [x] sorting - [x] pagination - [x] columnFilters -- [ ] columnOrder +- [x] columnOrder - [ ] columnPinning - [ ] columnSizing - [ ] columnSizingInfo diff --git a/examples/lib/src/components/UserTable.tsx b/examples/lib/src/components/UserTable.tsx index fe1f5c7..10927bb 100644 --- a/examples/lib/src/components/UserTable.tsx +++ b/examples/lib/src/components/UserTable.tsx @@ -1,197 +1,226 @@ import { type Table, flexRender } from "@tanstack/react-table"; import { SearchInput } from "./SearchInput"; import type { User } from "./userData"; +import { useState } from "react"; type Props = { table: Table; }; -export const UserTable = ({ table }: Props) => ( -
-
- table.setGlobalFilter(value)} - defaultValue={table.getState().globalFilter ?? ""} - /> -
-
-
- {table.getFlatHeaders().map((header) => ( -
- +
+
+
+
+ {table.getFlatHeaders().map((header) => ( +
- {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - {(() => { - switch (header.column.getIsSorted()) { - case "asc": - return " ⬆️"; - case "desc": - return " ⬇️"; - default: - return " ↕️"; - } - })()} - -
- {header.column.id === "age" ? ( -
- - header.column.setFilterValue( - (old: [number, number]) => [ - e.target.value, - old?.[1], - ], - ) - } - placeholder="Min" - className="border w-16 rounded-md px-2" - /> - + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {(() => { + switch (header.column.getIsSorted()) { + case "asc": + return " ⬆️"; + case "desc": + return " ⬇️"; + default: + return " ↕️"; + } + })()} + +
+ {header.column.id === "age" ? ( +
+ + header.column.setFilterValue( + (old: [number, number]) => [ + e.target.value, + old?.[1], + ], + ) + } + placeholder="Min" + className="border w-16 rounded-md px-2" + /> + + header.column.setFilterValue( + (old: [number, number]) => [ + old?.[0], + e.target.value, + ], + ) + } + placeholder="Max" + className="border w-16 rounded-md px-2" + /> +
+ ) : ( + + header.column.setFilterValue(value) } - onChange={(e) => - header.column.setFilterValue( - (old: [number, number]) => [ - old?.[0], - e.target.value, - ], - ) + defaultValue={ + (header.column.getFilterValue() ?? "") as string } - placeholder="Max" - className="border w-16 rounded-md px-2" /> -
- ) : ( - header.column.setFilterValue(value)} - defaultValue={ - (header.column.getFilterValue() ?? "") as string - } - /> - )} + )} +
+ ))} +
+
+
+ {table.getRowModel().rows.map((row) => ( +
+ {row.getVisibleCells().map((cell) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))}
))}
-
- {table.getRowModel().rows.map((row) => ( -
- {row.getVisibleCells().map((cell) => ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ))} -
- ))} -
-
-
- - - - - -
Page
- - {table.getState().pagination.pageIndex + 1} of{" "} - {table.getPageCount().toLocaleString()} - -
- - | Go to page: - + + + + + +
Page
+ + {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount().toLocaleString()} + +
+ + | Go to page: + { + const page = e.target.value ? Number(e.target.value) - 1 : 0; + table.setPageIndex(page); + }} + className="border px-2 rounded w-16" + /> + + { - table.setPageSize(Number(e.target.value)); - }} - className="border px-2 rounded" - > - {[10, 20, 30, 40, 50].map((pageSize) => ( - - ))} - + className="border px-2 rounded" + > + {[10, 20, 30, 40, 50].map((pageSize) => ( + + ))} + +
+
+ Showing {table.getRowModel().rows.length.toLocaleString()} of{" "} + {table.getRowCount().toLocaleString()} Rows +
-
- Showing {table.getRowModel().rows.length.toLocaleString()} of{" "} - {table.getRowCount().toLocaleString()} Rows +
+
Table State
+
+          {JSON.stringify(
+            {
+              globalFilter: table.getState().globalFilter,
+              sorting: table.getState().sorting,
+              pagination: table.getState().pagination,
+              columnFilters: table.getState().columnFilters,
+              columnOrder: table.getState().columnOrder,
+            },
+            null,
+            2,
+          )}
+        
-
-
Table State
-
-        {JSON.stringify(
-          {
-            globalFilter: table.getState().globalFilter,
-            sorting: table.getState().sorting,
-            pagination: table.getState().pagination,
-            columnFilters: table.getState().columnFilters,
-          },
-          null,
-          2,
-        )}
-      
-
-
-); + ); +}; diff --git a/examples/next-app-router/src/app/custom-default-value/table.tsx b/examples/next-app-router/src/app/custom-default-value/table.tsx index 47f29da..2467e46 100644 --- a/examples/next-app-router/src/app/custom-default-value/table.tsx +++ b/examples/next-app-router/src/app/custom-default-value/table.tsx @@ -30,6 +30,7 @@ export const Table = () => { sorting: [{ id: "name", desc: true }], pagination: { pageIndex: 2, pageSize: 20 }, columnFilters: [{ id: "name", value: "b" }], + columnOrder: userColumns.reverse().map((c) => c.id as string), }, }, ); diff --git a/examples/next-app-router/src/app/custom-encoder-decoder/table.tsx b/examples/next-app-router/src/app/custom-encoder-decoder/table.tsx index e498029..dbc9c41 100644 --- a/examples/next-app-router/src/app/custom-encoder-decoder/table.tsx +++ b/examples/next-app-router/src/app/custom-encoder-decoder/table.tsx @@ -46,6 +46,9 @@ export const Table = () => { JSON.stringify(value), ]), ), + columnOrder: (columnOrder) => ({ + columnOrder: JSON.stringify(columnOrder), + }), }, decoders: { globalFilter: (query) => @@ -73,6 +76,10 @@ export const Table = () => { id: key.replace("columnFilters.", ""), value: JSON.parse(value as string), })), + columnOrder: (query) => + query["columnOrder"] + ? JSON.parse(query["columnOrder"] as string) + : [], }, }, ); diff --git a/examples/next-app-router/src/app/custom-param-name/table.tsx b/examples/next-app-router/src/app/custom-param-name/table.tsx index 46c516d..ca00e8a 100644 --- a/examples/next-app-router/src/app/custom-param-name/table.tsx +++ b/examples/next-app-router/src/app/custom-param-name/table.tsx @@ -33,6 +33,7 @@ export const Table = () => { pageSize: "userTable-pageSize", }, columnFilters: (defaultParamName) => `userTable-${defaultParamName}`, + columnOrder: (defaultParamName) => `userTable-${defaultParamName}`, }, }, ); diff --git a/examples/next-app-router/src/app/debounce/table.tsx b/examples/next-app-router/src/app/debounce/table.tsx index 368c9fc..db2ecf8 100644 --- a/examples/next-app-router/src/app/debounce/table.tsx +++ b/examples/next-app-router/src/app/debounce/table.tsx @@ -30,6 +30,7 @@ export const Table = () => { sorting: 1000, pagination: 1000, columnFilters: 1000, + columnOrder: 1000, }, }, ); diff --git a/examples/next-pages-router/src/pages/custom-default-value.tsx b/examples/next-pages-router/src/pages/custom-default-value.tsx index 57085c3..a91382d 100644 --- a/examples/next-pages-router/src/pages/custom-default-value.tsx +++ b/examples/next-pages-router/src/pages/custom-default-value.tsx @@ -23,6 +23,7 @@ export default function CustomParamNames() { sorting: [{ id: "name", desc: true }], pagination: { pageIndex: 2, pageSize: 20 }, columnFilters: [{ id: "name", value: "b" }], + columnOrder: userColumns.reverse().map((c) => c.id as string), }, }); diff --git a/examples/next-pages-router/src/pages/custom-encoder-decoder.tsx b/examples/next-pages-router/src/pages/custom-encoder-decoder.tsx index 0ef5314..558fb89 100644 --- a/examples/next-pages-router/src/pages/custom-encoder-decoder.tsx +++ b/examples/next-pages-router/src/pages/custom-encoder-decoder.tsx @@ -39,6 +39,9 @@ export default function CustomEncoderDecoder() { JSON.stringify(value), ]), ), + columnOrder: (columnOrder) => ({ + columnOrder: JSON.stringify(columnOrder), + }), }, decoders: { globalFilter: (query) => @@ -66,6 +69,8 @@ export default function CustomEncoderDecoder() { id: key.replace("columnFilters.", ""), value: JSON.parse(value as string), })), + columnOrder: (query) => + query["columnOrder"] ? JSON.parse(query["columnOrder"] as string) : [], }, }); diff --git a/examples/next-pages-router/src/pages/custom-param-name.tsx b/examples/next-pages-router/src/pages/custom-param-name.tsx index f7f08f7..45102f5 100644 --- a/examples/next-pages-router/src/pages/custom-param-name.tsx +++ b/examples/next-pages-router/src/pages/custom-param-name.tsx @@ -26,6 +26,7 @@ export default function CustomParamNames() { pageSize: "userTable-pageSize", }, columnFilters: (defaultParamName) => `userTable-${defaultParamName}`, + columnOrder: (defaultParamName) => `userTable-${defaultParamName}`, }, }); diff --git a/examples/next-pages-router/src/pages/debounce.tsx b/examples/next-pages-router/src/pages/debounce.tsx index 6524395..e3402d1 100644 --- a/examples/next-pages-router/src/pages/debounce.tsx +++ b/examples/next-pages-router/src/pages/debounce.tsx @@ -23,6 +23,7 @@ export default function Basic() { sorting: 1000, pagination: 1000, columnFilters: 1000, + columnOrder: 1000, }, }); diff --git a/examples/react-router-lib/src/custom-default-value.tsx b/examples/react-router-lib/src/custom-default-value.tsx index c42346d..c5dc6ef 100644 --- a/examples/react-router-lib/src/custom-default-value.tsx +++ b/examples/react-router-lib/src/custom-default-value.tsx @@ -27,6 +27,7 @@ export default function CustomDefaultValuePage() { sorting: [{ id: "name", desc: true }], pagination: { pageIndex: 2, pageSize: 20 }, columnFilters: [{ id: "name", value: "b" }], + columnOrder: userColumns.reverse().map((c) => c.id as string), }, }, ); diff --git a/examples/react-router-lib/src/custom-encoder-decoder.tsx b/examples/react-router-lib/src/custom-encoder-decoder.tsx index 0343b68..f28d822 100644 --- a/examples/react-router-lib/src/custom-encoder-decoder.tsx +++ b/examples/react-router-lib/src/custom-encoder-decoder.tsx @@ -70,6 +70,10 @@ export default function CustomEncoderDecoderPage() { id: key.replace("columnFilters.", ""), value: JSON.parse(value as string), })), + columnOrder: (query) => + query["columnOrder"] + ? JSON.parse(query["columnOrder"] as string) + : [], }, }, ); diff --git a/examples/react-router-lib/src/custom-param-name.tsx b/examples/react-router-lib/src/custom-param-name.tsx index c2549ab..d944a3b 100644 --- a/examples/react-router-lib/src/custom-param-name.tsx +++ b/examples/react-router-lib/src/custom-param-name.tsx @@ -30,6 +30,7 @@ export default function CustomParamNamePage() { pageSize: "userTable-pageSize", }, columnFilters: (defaultParamName) => `userTable-${defaultParamName}`, + columnOrder: (defaultParamName) => `userTable-${defaultParamName}`, }, }, ); diff --git a/examples/react-router-lib/src/debounce.tsx b/examples/react-router-lib/src/debounce.tsx index 1db6a87..212c880 100644 --- a/examples/react-router-lib/src/debounce.tsx +++ b/examples/react-router-lib/src/debounce.tsx @@ -27,6 +27,7 @@ export default function DebouncePage() { sorting: 1000, pagination: 1000, columnFilters: 1000, + columnOrder: 1000, }, }, ); diff --git a/examples/tanstack-router/src/routes/custom-default-value.tsx b/examples/tanstack-router/src/routes/custom-default-value.tsx index 3a29b52..13eb34a 100644 --- a/examples/tanstack-router/src/routes/custom-default-value.tsx +++ b/examples/tanstack-router/src/routes/custom-default-value.tsx @@ -41,6 +41,7 @@ function Page() { sorting: [{ id: "name", desc: true }], pagination: { pageIndex: 2, pageSize: 20 }, columnFilters: [{ id: "name", value: "b" }], + columnOrder: userColumns.reverse().map((c) => c.id as string), }, }, ); diff --git a/examples/tanstack-router/src/routes/custom-encoder-decoder.tsx b/examples/tanstack-router/src/routes/custom-encoder-decoder.tsx index 37b5617..a0a88c7 100644 --- a/examples/tanstack-router/src/routes/custom-encoder-decoder.tsx +++ b/examples/tanstack-router/src/routes/custom-encoder-decoder.tsx @@ -57,6 +57,9 @@ function Page() { JSON.stringify(value), ]), ), + columnOrder: (columnOrder) => ({ + columnOrder: JSON.stringify(columnOrder), + }), }, decoders: { globalFilter: (query) => @@ -84,6 +87,10 @@ function Page() { id: key.replace("columnFilters.", ""), value: JSON.parse(value as string), })), + columnOrder: (query) => + query["columnOrder"] + ? JSON.parse(query["columnOrder"] as string) + : [], }, }, ); diff --git a/examples/tanstack-router/src/routes/custom-param-name.tsx b/examples/tanstack-router/src/routes/custom-param-name.tsx index 68865a3..9ecd696 100644 --- a/examples/tanstack-router/src/routes/custom-param-name.tsx +++ b/examples/tanstack-router/src/routes/custom-param-name.tsx @@ -44,6 +44,7 @@ function Page() { pageSize: "userTable-pageSize", }, columnFilters: (defaultParamName) => `userTable-${defaultParamName}`, + columnOrder: (defaultParamName) => `userTable-${defaultParamName}`, }, }, ); diff --git a/examples/tanstack-router/src/routes/debounce.tsx b/examples/tanstack-router/src/routes/debounce.tsx index 26ba0ea..7fda6d1 100644 --- a/examples/tanstack-router/src/routes/debounce.tsx +++ b/examples/tanstack-router/src/routes/debounce.tsx @@ -41,6 +41,7 @@ function Page() { sorting: 1000, pagination: 1000, columnFilters: 1000, + columnOrder: 1000, }, }, ); diff --git a/packages/tanstack-table-search-params/README.md b/packages/tanstack-table-search-params/README.md index cfad640..dcd61ef 100644 --- a/packages/tanstack-table-search-params/README.md +++ b/packages/tanstack-table-search-params/README.md @@ -249,7 +249,7 @@ List of supported TanStack table states - [x] sorting - [x] pagination - [x] columnFilters -- [ ] columnOrder +- [x] columnOrder - [ ] columnPinning - [ ] columnSizing - [ ] columnSizingInfo diff --git a/packages/tanstack-table-search-params/src/encoder-decoder/columnOrder.test.ts b/packages/tanstack-table-search-params/src/encoder-decoder/columnOrder.test.ts new file mode 100644 index 0000000..d7bab30 --- /dev/null +++ b/packages/tanstack-table-search-params/src/encoder-decoder/columnOrder.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "vitest"; +import { decodeColumnOrder, encodeColumnOrder } from "./columnOrder"; +import { noneStringForCustomDefaultValue } from "./noneStringForCustomDefaultValue"; +import { defaultDefaultColumnOrder } from "../useColumnOrder"; + +const customDefaultValue = ["custom"]; + +describe("columnOrder", () => { + describe("encode", () => + describe.each[1]>([ + defaultDefaultColumnOrder, + customDefaultValue, + ])("default value: %defaultValue", (...defaultValue) => + test.each<{ + name: string; + stateValue: Parameters[0]; + want: ReturnType; + }>([ + { + name: "default value", + stateValue: defaultValue, + want: undefined, + }, + { + name: "empty array", + stateValue: [], + want: + JSON.stringify(defaultValue) === + JSON.stringify(defaultDefaultColumnOrder) + ? undefined + : noneStringForCustomDefaultValue, + }, + { + name: "one column", + stateValue: ["foo"], + want: "foo", + }, + { + name: "one column with comma", + stateValue: ["foo,bar"], + want: "foo%2Cbar", + }, + { + name: "two columns", + stateValue: ["foo", "bar"], + want: "foo,bar", + }, + { + name: "two columns with comma", + stateValue: ["foo", "bar,baz"], + want: "foo,bar%2Cbaz", + }, + ])("$name", ({ stateValue, want }) => + expect(encodeColumnOrder(stateValue, defaultValue)).toEqual(want), + ), + )); + + describe("decode", () => + describe.each([defaultDefaultColumnOrder, customDefaultValue])( + "default value: %defaultValue", + (...defaultValue) => + test.each<{ + name: string; + queryValue: Parameters[0]; + want: ReturnType; + }>([ + { + name: "default value", + queryValue: encodeColumnOrder(defaultValue, defaultValue), + want: defaultValue, + }, + { + name: "empty string", + queryValue: "", + want: defaultValue, + }, + { + name: "noneStringForCustomDefaultValue", + queryValue: noneStringForCustomDefaultValue, + want: [], + }, + { + name: "one column", + queryValue: "foo", + want: ["foo"], + }, + { + name: "one column with comma", + queryValue: "foo%2Cbar", + want: ["foo,bar"], + }, + { + name: "two columns", + queryValue: "foo,bar", + want: ["foo", "bar"], + }, + { + name: "two columns with comma", + queryValue: "foo,bar%2Cbaz", + want: ["foo", "bar,baz"], + }, + { + name: "invalid string array", + queryValue: ["invalid"], + want: defaultValue, + }, + { + name: "undefined", + queryValue: undefined, + want: defaultValue, + }, + ])("$name", ({ queryValue, want }) => + expect(decodeColumnOrder(queryValue, defaultValue)).toEqual(want), + ), + )); + + describe("encode and decode", () => + describe.each[1]>([ + defaultDefaultColumnOrder, + customDefaultValue, + ])("default value: $defaultValue", (...defaultValue) => + test.each<{ + name: string; + stateValue: Parameters[1]; + }>([ + { + name: "default value", + stateValue: defaultValue, + }, + { + name: "empty array", + stateValue: [], + }, + { + name: "one column", + stateValue: ["foo"], + }, + { + name: "one column with comma", + stateValue: ["foo,bar"], + }, + { + name: "two columns", + stateValue: ["foo", "bar"], + }, + { + name: "two columns with comma", + stateValue: ["foo", "bar,baz"], + }, + ])("$name", ({ stateValue }) => { + expect( + decodeColumnOrder( + encodeColumnOrder(stateValue, defaultValue), + defaultValue, + ), + ).toEqual(stateValue); + }), + )); +}); diff --git a/packages/tanstack-table-search-params/src/encoder-decoder/columnOrder.ts b/packages/tanstack-table-search-params/src/encoder-decoder/columnOrder.ts new file mode 100644 index 0000000..86d196f --- /dev/null +++ b/packages/tanstack-table-search-params/src/encoder-decoder/columnOrder.ts @@ -0,0 +1,36 @@ +import type { State } from ".."; +import type { Query } from "../types"; +import { noneStringForCustomDefaultValue } from "./noneStringForCustomDefaultValue"; + +export const encodeColumnOrder = ( + stateValue: State["columnOrder"], + defaultValue: State["columnOrder"], +): Query[string] => { + if (JSON.stringify(stateValue) === JSON.stringify(defaultValue)) { + return undefined; + } + + // return encoded empty string if stateValue is empty with custom default value + if (stateValue.length === 0) { + return noneStringForCustomDefaultValue; + } + + return stateValue + .map((v) => v.replaceAll(",", encodeURIComponent(","))) + .join(","); +}; + +export const decodeColumnOrder = ( + queryValue: Query[string], + defaultValue: State["columnOrder"], +): State["columnOrder"] => { + if (typeof queryValue !== "string") return defaultValue; + if (queryValue === "") return defaultValue; + if (queryValue === noneStringForCustomDefaultValue) { + return []; + } + + return queryValue + .split(",") + .map((v) => v.replaceAll(encodeURIComponent(","), ",")); +}; diff --git a/packages/tanstack-table-search-params/src/index.ts b/packages/tanstack-table-search-params/src/index.ts index 7d2cc76..dbcbbc2 100644 --- a/packages/tanstack-table-search-params/src/index.ts +++ b/packages/tanstack-table-search-params/src/index.ts @@ -6,10 +6,11 @@ import { useGlobalFilter } from "./useGlobalFilter"; import { usePagination } from "./usePagination"; import { useSorting } from "./useSorting"; import type { ExtractSpecificStateOptions } from "./utils"; +import { useColumnOrder } from "./useColumnOrder"; export type State = Pick< TableState, - "globalFilter" | "sorting" | "pagination" | "columnFilters" + "globalFilter" | "sorting" | "pagination" | "columnFilters" | "columnOrder" >; export const PARAM_NAMES = { @@ -18,6 +19,7 @@ export const PARAM_NAMES = { PAGE_INDEX: "pageIndex", PAGE_SIZE: "pageSize", COLUMN_FILTERS: "columnFilters", + COLUMN_ORDER: "columnOrder", } as const; export type Returns = { @@ -41,6 +43,10 @@ export type Returns = { * Tanstack Table's `onChangeColumnFilters` function */ onColumnFiltersChange: OnChangeFn; + /** + * Tanstack Table's `onChangeColumnOrder` function + */ + onColumnOrderChange: OnChangeFn; }; export type Options = { @@ -184,10 +190,14 @@ export const useTableSearchParams = ( router, options: extractSpecificStateOptions({ options, key: "columnFilters" }), }); + const { columnOrder, onColumnOrderChange } = useColumnOrder({ + router, + options: extractSpecificStateOptions({ options, key: "columnOrder" }), + }); const state = useMemo( - () => ({ sorting, pagination, globalFilter, columnFilters }), - [sorting, pagination, globalFilter, columnFilters], + () => ({ sorting, pagination, globalFilter, columnFilters, columnOrder }), + [sorting, pagination, globalFilter, columnFilters, columnOrder], ); return { @@ -196,5 +206,6 @@ export const useTableSearchParams = ( onSortingChange, onPaginationChange, onColumnFiltersChange, + onColumnOrderChange, }; }; diff --git a/packages/tanstack-table-search-params/src/tests/columnOrder.test.ts b/packages/tanstack-table-search-params/src/tests/columnOrder.test.ts new file mode 100644 index 0000000..9591dd1 --- /dev/null +++ b/packages/tanstack-table-search-params/src/tests/columnOrder.test.ts @@ -0,0 +1,206 @@ +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useTableSearchParams } from ".."; +import { defaultDefaultColumnOrder } from "../useColumnOrder"; +import { useTestRouter } from "./testRouter"; +import { noneStringForCustomDefaultValue } from "../encoder-decoder/noneStringForCustomDefaultValue"; + +describe("columnOrder", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + window.history.replaceState({}, "", "/"); + }); + describe.each<{ + name: string; + options?: Parameters[1]; + }>([ + { + name: "no options", + }, + { + name: "with options: string param name", + options: { + paramNames: { + columnOrder: "COLUMN_FILTERS", + }, + }, + }, + { + name: "with options: function param name", + options: { + paramNames: { + columnOrder: (key) => `userTable-${key}`, + }, + }, + }, + { + name: "with options: default param name encoder/decoder", + options: { + encoders: { + columnOrder: (columnOrder) => ({ + columnOrder: JSON.stringify(columnOrder), + }), + }, + decoders: { + columnOrder: (query) => + query["columnOrder"] + ? JSON.parse(query["columnOrder"] as string) + : query["columnOrder"], + }, + }, + }, + { + name: "with options: custom param name encoder/decoder", + options: { + encoders: { + columnOrder: (columnOrder) => ({ + "userTable-columnOrder": JSON.stringify(columnOrder), + }), + }, + decoders: { + columnOrder: (query) => + query["userTable-columnOrder"] + ? JSON.parse(query["userTable-columnOrder"] as string) + : query["userTable-columnOrder"], + }, + }, + }, + { + name: "with options: custom number of params encoder/decoder", + options: { + encoders: { + columnOrder: (columnOrder) => + Object.fromEntries( + columnOrder.map((value, i) => [ + `columnOrder.${i}`, + JSON.stringify(value), + ]), + ), + }, + decoders: { + columnOrder: (query) => + Object.entries(query) + .filter(([key]) => key.startsWith("columnOrder.")) + .map(([_, value]) => JSON.parse(value as string)), + }, + }, + }, + { + name: "with options: custom default value", + options: { + defaultValues: { + columnOrder: ["name", "id"], + }, + }, + }, + { + name: "with options: debounce milliseconds", + options: { + debounceMilliseconds: 1, + }, + }, + { + name: "with options: debounce milliseconds for columnOrder", + options: { + debounceMilliseconds: { + columnOrder: 1, + }, + }, + }, + { + name: "with options: custom param name, default value, debounce", + options: { + paramNames: { + columnOrder: "COLUMN_FILTERS", + }, + defaultValues: { + columnOrder: ["name", "id"], + }, + debounceMilliseconds: 1, + }, + }, + ])("%s", ({ options }) => { + const paramName = + typeof options?.paramNames?.columnOrder === "function" + ? options?.paramNames?.columnOrder("columnOrder") + : options?.paramNames?.columnOrder || "columnOrder"; + + const defaultColumnOrder = + options?.defaultValues?.columnOrder ?? defaultDefaultColumnOrder; + + const debounceMilliseconds = + options?.debounceMilliseconds !== undefined + ? typeof options.debounceMilliseconds === "object" + ? options.debounceMilliseconds.columnOrder + : options.debounceMilliseconds + : undefined; + + test("single column: string value", () => { + const { result: routerResult, rerender: routerRerender } = renderHook( + () => useTestRouter(), + ); + const { result, rerender: resultRerender } = renderHook(() => { + const stateAndOnChanges = useTableSearchParams( + routerResult.current, + options, + ); + return useReactTable({ + columns: [{ accessorKey: "id" }, { accessorKey: "name" }], + data: [ + { id: 0, name: "John" }, + { id: 1, name: "Mary" }, + ], + getCoreRowModel: getCoreRowModel(), + ...stateAndOnChanges, + }); + }); + const rerender = () => { + if (debounceMilliseconds !== undefined) { + vi.advanceTimersByTime(debounceMilliseconds); + } + routerRerender(); + resultRerender(); + }; + + // initial state + expect(result.current.getState().columnOrder).toEqual(defaultColumnOrder); + expect(routerResult.current.query).toEqual({}); + + const reversed = result.current + .getAllLeafColumns() + .reverse() + .map((c) => c.id); + // set column order + act(() => { + result.current.setColumnOrder(reversed); + }); + rerender(); + + expect(result.current.getState().columnOrder).toEqual(reversed); + expect(routerResult.current.query).toEqual( + options?.encoders?.columnOrder?.(reversed) ?? { + [paramName]: defaultColumnOrder.length === 0 ? "name,id" : "id,name", + }, + ); + + // reset + act(() => { + result.current.setColumnOrder([]); + }); + rerender(); + + expect(routerResult.current.query).toEqual( + options?.encoders?.columnOrder?.(defaultColumnOrder) ?? { + [paramName]: + defaultColumnOrder.length > 0 + ? noneStringForCustomDefaultValue + : undefined, + }, + ); + expect(result.current.getState().columnOrder).toEqual([]); + }); + }); +}); diff --git a/packages/tanstack-table-search-params/src/tests/next-pages-router/columnOrder.test.ts b/packages/tanstack-table-search-params/src/tests/next-pages-router/columnOrder.test.ts new file mode 100644 index 0000000..a57406e --- /dev/null +++ b/packages/tanstack-table-search-params/src/tests/next-pages-router/columnOrder.test.ts @@ -0,0 +1,199 @@ +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useTableSearchParams } from "../../.."; +import { defaultDefaultColumnOrder } from "../../useColumnOrder"; +import mockRouter from "next-router-mock"; +import { noneStringForCustomDefaultValue } from "../../encoder-decoder/noneStringForCustomDefaultValue"; + +describe("columnOrder", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + mockRouter.query = {}; + }); + describe.each<{ + name: string; + options?: Parameters[1]; + }>([ + { + name: "no options", + }, + { + name: "with options: string param name", + options: { + paramNames: { + columnOrder: "COLUMN_FILTERS", + }, + }, + }, + { + name: "with options: function param name", + options: { + paramNames: { + columnOrder: (key) => `userTable-${key}`, + }, + }, + }, + { + name: "with options: default param name encoder/decoder", + options: { + encoders: { + columnOrder: (columnOrder) => ({ + columnOrder: JSON.stringify(columnOrder), + }), + }, + decoders: { + columnOrder: (query) => + query["columnOrder"] + ? JSON.parse(query["columnOrder"] as string) + : query["columnOrder"], + }, + }, + }, + { + name: "with options: custom param name encoder/decoder", + options: { + encoders: { + columnOrder: (columnOrder) => ({ + "userTable-columnOrder": JSON.stringify(columnOrder), + }), + }, + decoders: { + columnOrder: (query) => + query["userTable-columnOrder"] + ? JSON.parse(query["userTable-columnOrder"] as string) + : query["userTable-columnOrder"], + }, + }, + }, + { + name: "with options: custom number of params encoder/decoder", + options: { + encoders: { + columnOrder: (columnOrder) => + Object.fromEntries( + columnOrder.map((value, i) => [ + `columnOrder.${i}`, + JSON.stringify(value), + ]), + ), + }, + decoders: { + columnOrder: (query) => + Object.entries(query) + .filter(([key]) => key.startsWith("columnOrder.")) + .map(([_, value]) => JSON.parse(value as string)), + }, + }, + }, + { + name: "with options: custom default value", + options: { + defaultValues: { + columnOrder: ["name", "id"], + }, + }, + }, + { + name: "with options: debounce milliseconds", + options: { + debounceMilliseconds: 1, + }, + }, + { + name: "with options: debounce milliseconds for columnOrder", + options: { + debounceMilliseconds: { + columnOrder: 1, + }, + }, + }, + { + name: "with options: custom param name, default value, debounce", + options: { + paramNames: { + columnOrder: "COLUMN_FILTERS", + }, + defaultValues: { + columnOrder: ["name", "id"], + }, + debounceMilliseconds: 1, + }, + }, + ])("%s", ({ options }) => { + const paramName = + typeof options?.paramNames?.columnOrder === "function" + ? options?.paramNames?.columnOrder("columnOrder") + : options?.paramNames?.columnOrder || "columnOrder"; + + const defaultColumnOrder = + options?.defaultValues?.columnOrder ?? defaultDefaultColumnOrder; + + const debounceMilliseconds = + options?.debounceMilliseconds !== undefined + ? typeof options.debounceMilliseconds === "object" + ? options.debounceMilliseconds.columnOrder + : options.debounceMilliseconds + : undefined; + + test("single column: string value", () => { + const { result, rerender: resultRerender } = renderHook(() => { + const stateAndOnChanges = useTableSearchParams(mockRouter, options); + return useReactTable({ + columns: [{ accessorKey: "id" }, { accessorKey: "name" }], + data: [ + { id: 0, name: "John" }, + { id: 1, name: "Mary" }, + ], + getCoreRowModel: getCoreRowModel(), + ...stateAndOnChanges, + }); + }); + const rerender = () => { + if (debounceMilliseconds !== undefined) { + vi.advanceTimersByTime(debounceMilliseconds); + } + resultRerender(); + }; + + // initial state + expect(result.current.getState().columnOrder).toEqual(defaultColumnOrder); + expect(mockRouter.query).toEqual({}); + + const reversed = result.current + .getAllLeafColumns() + .reverse() + .map((c) => c.id); + // set column order + act(() => { + result.current.setColumnOrder(reversed); + }); + rerender(); + + expect(result.current.getState().columnOrder).toEqual(reversed); + expect(mockRouter.query).toEqual( + options?.encoders?.columnOrder?.(reversed) ?? { + [paramName]: defaultColumnOrder.length === 0 ? "name,id" : "id,name", + }, + ); + + // reset + act(() => { + result.current.setColumnOrder([]); + }); + rerender(); + + expect(mockRouter.query).toEqual( + options?.encoders?.columnOrder?.(defaultColumnOrder) ?? { + [paramName]: + defaultColumnOrder.length > 0 + ? noneStringForCustomDefaultValue + : undefined, + }, + ); + expect(result.current.getState().columnOrder).toEqual([]); + }); + }); +}); diff --git a/packages/tanstack-table-search-params/src/useColumnOrder.ts b/packages/tanstack-table-search-params/src/useColumnOrder.ts new file mode 100644 index 0000000..3105f15 --- /dev/null +++ b/packages/tanstack-table-search-params/src/useColumnOrder.ts @@ -0,0 +1,122 @@ +import { type OnChangeFn, functionalUpdate } from "@tanstack/react-table"; +import { useCallback, useMemo } from "react"; +import { PARAM_NAMES, type State } from "."; +import { + decodeColumnOrder, + encodeColumnOrder, +} from "./encoder-decoder/columnOrder"; +import type { Router } from "./types"; +import { updateQuery } from "./updateQuery"; +import { useDebounce } from "./useDebounce"; +import type { ExtractSpecificStateOptions } from "./utils"; + +export const defaultDefaultColumnOrder = + [] as const satisfies State["columnOrder"]; + +type Props = { + router: Router; + options?: ExtractSpecificStateOptions<"columnOrder">; +}; + +type Returns = { + columnOrder: State["columnOrder"]; + onColumnOrderChange: OnChangeFn; +}; + +export const useColumnOrder = ({ router, options }: Props): Returns => { + const paramName = + (typeof options?.paramName === "function" + ? options?.paramName(PARAM_NAMES.COLUMN_ORDER) + : options?.paramName) || PARAM_NAMES.COLUMN_ORDER; + + const stringDefaultColumnOrder = JSON.stringify( + options?.defaultValue ?? defaultDefaultColumnOrder, + ); + + const uncustomisedColumnOrder = useMemo( + () => + decodeColumnOrder( + router.query[paramName], + JSON.parse(stringDefaultColumnOrder), + ), + [router.query[paramName], paramName, stringDefaultColumnOrder], + ); + + // If `router.query` is included in the dependency array, + // `columnOrder` will always be regenerated. + // To prevent this, use `JSON.stringify` and `JSON.parse` + // when utilizing a custom decoder. + const isCustomDecoder = !!options?.decoder; + const stringCustomColumnOrder = options?.decoder?.(router.query) + ? JSON.stringify(options.decoder(router.query)) + : ""; + const _columnOrder = useMemo( + () => + isCustomDecoder + ? stringCustomColumnOrder === "" + ? [] + : JSON.parse(stringCustomColumnOrder) + : uncustomisedColumnOrder, + [stringCustomColumnOrder, uncustomisedColumnOrder, isCustomDecoder], + ); + + const updateColumnOrderQuery = useCallback( + async (newColumnOrder: State["columnOrder"]) => { + const encoder = (columnOrder: State["columnOrder"]) => + options?.encoder + ? options.encoder(columnOrder) + : { + [paramName]: encodeColumnOrder( + columnOrder, + JSON.parse(stringDefaultColumnOrder), + ), + }; + await updateQuery({ + oldQuery: encoder(_columnOrder), + newQuery: encoder(newColumnOrder), + router, + }); + }, + [ + router, + paramName, + options?.encoder, + stringDefaultColumnOrder, + _columnOrder, + ], + ); + + const [debouncedColumnOrder, setDebouncedColumnOrder] = useDebounce({ + stateValue: _columnOrder, + updateQuery: updateColumnOrderQuery, + milliseconds: options?.debounceMilliseconds, + }); + + const columnOrder = useMemo( + () => + options?.debounceMilliseconds === undefined + ? _columnOrder + : debouncedColumnOrder, + [_columnOrder, debouncedColumnOrder, options?.debounceMilliseconds], + ); + + return { + columnOrder, + onColumnOrderChange: useCallback( + async (updater) => { + const newColumnOrder = functionalUpdate(updater, columnOrder); + if (options?.debounceMilliseconds !== undefined) { + setDebouncedColumnOrder(newColumnOrder); + return; + } + await updateColumnOrderQuery(newColumnOrder); + }, + [ + columnOrder, + updateColumnOrderQuery, + options?.debounceMilliseconds, + setDebouncedColumnOrder, + ], + ), + }; +};