From 32d41bf4f9d3708c4205bea8444f7f2811fcfe50 Mon Sep 17 00:00:00 2001 From: sadman <68962712+sadmann7@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:47:56 +0600 Subject: [PATCH 1/4] test: add unit tests --- src/app/lib/test/filter-columns.test.ts | 481 ++++++++++++++++++++++ src/hooks/test/use-data-table.test.tsx | 150 ++++++- src/lib/test/data-table-filters.test.ts | 133 ++++++ src/lib/test/data-table-table.test.tsx | 525 ++++++++++++++++++++++++ src/lib/test/data-table-utils.test.ts | 304 ++++++++++++++ src/lib/test/data-table.test.ts | 101 ++++- 6 files changed, 1687 insertions(+), 7 deletions(-) create mode 100644 src/app/lib/test/filter-columns.test.ts create mode 100644 src/lib/test/data-table-filters.test.ts create mode 100644 src/lib/test/data-table-table.test.tsx create mode 100644 src/lib/test/data-table-utils.test.ts diff --git a/src/app/lib/test/filter-columns.test.ts b/src/app/lib/test/filter-columns.test.ts new file mode 100644 index 00000000..627a6eb4 --- /dev/null +++ b/src/app/lib/test/filter-columns.test.ts @@ -0,0 +1,481 @@ +import { addDays, endOfDay, startOfDay } from "date-fns"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import type { ColumnFilterItem, JoinOperator } from "@/lib/data-table-types"; + +import { filterColumns, getColumn } from "@/app/lib/filter-columns"; +import { type Task, tasks } from "@/db/schema"; + +const dialect = new PgDialect(); + +type TaskColumnId = keyof Task; + +function columnFilter( + filter: Omit, "filterId"> & { + filterId?: string; + }, +): ColumnFilterItem { + return { filterId: `${filter.id}-0`, ...filter }; +} + +function compile( + filters: ColumnFilterItem[], + joinOperator: JoinOperator = "and", +) { + const statement = filterColumns({ table: tasks, filters, joinOperator }); + if (!statement) return undefined; + return dialect.sqlToQuery(statement); +} + +describe("filterColumns", () => { + it("returns the column from the table", () => { + expect(getColumn(tasks, "title")).toBe(tasks.title); + }); + + it("returns nothing when no filter produces a condition", () => { + expect(compile([])).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "number", + operator: "iLike", + value: "2", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "eq", + value: "2026-02-30", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "status", + variant: "multiSelect", + operator: "inArray", + value: "todo", + }), + ]), + ).toBeUndefined(); + }); + + it("compares text, booleans, numbers, and lists", () => { + expect( + compile([ + columnFilter({ + id: "title", + variant: "text", + operator: "iLike", + value: "the", + }), + ])?.params, + ).toEqual(["%the%"]); + expect( + compile([ + columnFilter({ + id: "title", + variant: "text", + operator: "notILike", + value: "the", + }), + ])?.sql.toLowerCase(), + ).toContain("not"); + expect( + compile([ + columnFilter({ + id: "title", + variant: "text", + operator: "eq", + value: "bug", + }), + ])?.params, + ).toEqual(["bug"]); + expect( + compile([ + columnFilter({ + id: "archived", + variant: "boolean", + operator: "eq", + value: "true", + }), + ])?.params, + ).toEqual([true]); + expect( + compile([ + columnFilter({ + id: "archived", + variant: "boolean", + operator: "ne", + value: "false", + }), + ])?.params, + ).toEqual([false]); + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "number", + operator: "lt", + value: "3", + }), + ])?.params, + ).toEqual(["3"]); + expect( + compile([ + columnFilter({ + id: "status", + variant: "multiSelect", + operator: "inArray", + value: ["todo", "done"], + }), + ])?.params, + ).toEqual(["todo", "done"]); + expect( + compile([ + columnFilter({ + id: "status", + variant: "multiSelect", + operator: "notInArray", + value: ["todo"], + }), + ])?.sql.toLowerCase(), + ).toContain("not"); + }); + + it("bounds a calendar day for date comparisons", () => { + const day = "2026-10-03"; + const start = startOfDay(new Date(2026, 9, 3)); + const end = endOfDay(start); + + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "eq", + value: day, + }), + ])?.params, + ).toEqual([start.toISOString(), end.toISOString()]); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "dateRange", + operator: "ne", + value: day, + }), + ])?.params, + ).toEqual([start.toISOString(), end.toISOString()]); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "lt", + value: day, + }), + ])?.params, + ).toEqual([start.toISOString()]); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "lte", + value: day, + }), + ])?.params, + ).toEqual([end.toISOString()]); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "gt", + value: day, + }), + ])?.params, + ).toEqual([end.toISOString()]); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "gte", + value: day, + }), + ])?.params, + ).toEqual([start.toISOString()]); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "dateRange", + operator: "lt", + value: day, + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "lt", + value: ["2026-10-03"], + }), + ]), + ).toBeUndefined(); + }); + + it("leaves an open number or date bound out of between", () => { + const start = startOfDay(new Date(2026, 9, 1)); + const end = endOfDay(new Date(2026, 9, 7)); + + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "range", + operator: "isBetween", + value: ["2", "8"], + }), + ])?.params, + ).toEqual([2, 8]); + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "number", + operator: "isBetween", + value: ["", "3"], + }), + ])?.params, + ).toEqual([3]); + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "range", + operator: "isBetween", + value: ["2", ""], + }), + ])?.params, + ).toEqual([2]); + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "range", + operator: "isBetween", + value: ["", ""], + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "range", + operator: "isBetween", + value: "2", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "dateRange", + operator: "isBetween", + value: ["2026-10-01", "2026-10-07"], + }), + ])?.params, + ).toEqual([start.toISOString(), end.toISOString()]); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isBetween", + value: ["", "2026-10-07"], + }), + ])?.params, + ).toEqual([end.toISOString()]); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isBetween", + value: ["nope", "nope"], + }), + ]), + ).toBeUndefined(); + }); + + it("counts days, weeks, and months from today", () => { + const today = new Date(); + + const tomorrow = startOfDay(addDays(today, 1)); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isRelativeToToday", + value: "1 days", + }), + ])?.params, + ).toEqual([tomorrow.toISOString(), endOfDay(tomorrow).toISOString()]); + + const nextWeek = startOfDay(addDays(today, 7)); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "dateRange", + operator: "isRelativeToToday", + value: "1 weeks", + }), + ])?.params, + ).toEqual([ + nextWeek.toISOString(), + endOfDay(addDays(nextWeek, 6)).toISOString(), + ]); + + const nextMonth = startOfDay(addDays(today, 30)); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isRelativeToToday", + value: "1 months", + }), + ])?.params, + ).toEqual([ + nextMonth.toISOString(), + endOfDay(addDays(nextMonth, 29)).toISOString(), + ]); + + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isRelativeToToday", + value: "1", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isRelativeToToday", + value: "1 years", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "number", + operator: "isRelativeToToday", + value: "1 days", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "number", + operator: "isRelativeToToday", + value: ["1 days"], + }), + ]), + ).toBeUndefined(); + }); + + it("matches empty and non-empty values", () => { + const title = compile([ + columnFilter({ + id: "title", + variant: "text", + operator: "isEmpty", + value: "", + }), + ])?.sql; + expect(title).toContain("is null"); + expect(title).toContain("''"); + + const archived = compile([ + columnFilter({ + id: "archived", + variant: "boolean", + operator: "isEmpty", + value: "", + }), + ])?.sql; + expect(archived).toContain("is null"); + expect(archived).not.toContain("''"); + + expect( + compile([ + columnFilter({ + id: "title", + variant: "text", + operator: "isNotEmpty", + value: "", + }), + ])?.sql.toLowerCase(), + ).toContain("not"); + }); + + it("joins conditions with and or or", () => { + const filters = [ + columnFilter({ + id: "title", + variant: "text", + operator: "eq", + value: "bug", + }), + columnFilter({ + id: "status", + variant: "multiSelect", + operator: "inArray", + value: ["todo"], + filterId: "status-0", + }), + ]; + + expect(compile(filters, "and")?.sql.toLowerCase()).toContain("and"); + expect(compile(filters, "or")?.sql.toLowerCase()).toContain("or"); + }); + + it("rejects an operator it cannot compile", () => { + expect(() => + compile([ + columnFilter({ + id: "title", + variant: "text", + operator: "nope" as ColumnFilterItem["operator"], + value: "bug", + }), + ]), + ).toThrow("Unsupported operator"); + }); +}); diff --git a/src/hooks/test/use-data-table.test.tsx b/src/hooks/test/use-data-table.test.tsx index c6f5d047..6401eab7 100644 --- a/src/hooks/test/use-data-table.test.tsx +++ b/src/hooks/test/use-data-table.test.tsx @@ -2,7 +2,7 @@ import { createColumnHelper } from "@tanstack/react-table"; import { act, renderHook, waitFor } from "@testing-library/react"; import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing"; import * as React from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import type { DataTableFeatures } from "@/lib/data-table-features"; @@ -41,7 +41,7 @@ function renderDataTable( search: string, props: Partial> = {}, ) { - const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + const updates: UrlUpdateEvent[] = []; let currentSearch = search; window.history.replaceState(null, "", `/${search}`); @@ -50,7 +50,9 @@ function renderDataTable( { + updates.push(event); + }} > {children} @@ -76,7 +78,7 @@ function renderDataTable( } function getLastSearch() { - return onUrlUpdate.mock.lastCall?.[0].searchParams; + return updates.at(-1)?.searchParams; } return { ...hook, navigate, getLastSearch }; @@ -139,11 +141,11 @@ describe("useDataTable", () => { }); it("lets an outside URL change replace the filters being edited", async () => { - const { result, navigate } = renderDataTable("?title=bug"); + const { result, navigate, getLastSearch } = renderDataTable("?title=bug"); act(() => { result.current.table.setColumnFilters([ - { id: "title", value: "bug" }, + { id: "title", value: "zzz" }, { id: "status", value: [] }, ]); }); @@ -158,6 +160,14 @@ describe("useDataTable", () => { getFilterSummary(result.current.table.state.columnFilters), ).toEqual([["status", ["done"]]]), ); + + // The URL write is debounced by 10ms, so wait until that timer has run. + await act(() => new Promise((resolve) => setTimeout(resolve, 30))); + + expect(getLastSearch()?.get("title")).not.toBe("zzz"); + expect(getFilterSummary(result.current.table.state.columnFilters)).toEqual([ + ["status", ["done"]], + ]); }); it("only reads sorting for sortable columns", () => { @@ -183,5 +193,133 @@ describe("useDataTable", () => { }); await waitFor(() => expect(getLastSearch()?.get("page")).toBe("3")); expect(result.current.table.state.pagination.pageIndex).toBe(2); + + act(() => { + result.current.table.setPageSize(25); + result.current.table.setJoinOperator("or"); + }); + await waitFor(() => expect(getLastSearch()?.get("perPage")).toBe("25")); + expect(getLastSearch()?.get("joinOperator")).toBe("or"); + }); + + it("reads page, page size, sorting, and the join from the URL", () => { + const { result } = renderDataTable( + "?page=3&perPage=25&sort=title.desc&joinOperator=or", + ); + + expect(result.current.table.state.pagination).toEqual({ + pageIndex: 2, + pageSize: 25, + }); + expect(result.current.table.state.sorting).toEqual([ + { id: "title", desc: true }, + ]); + expect(result.current.table.getJoinOperator()).toBe("or"); + }); + + it("uses the initial page size when the URL does not set one", () => { + const { result } = renderDataTable("", { + initialState: { pagination: { pageIndex: 0, pageSize: 25 } }, + }); + + expect(result.current.table.state.pagination.pageSize).toBe(25); + }); + + it("reads and writes the configured query keys", async () => { + const { result, getLastSearch } = renderDataTable( + "?p=2&s=title.asc&join=or", + { + queryKeys: { + page: "p", + perPage: "size", + sort: "s", + joinOperator: "join", + }, + }, + ); + + expect(result.current.table.state.pagination.pageIndex).toBe(1); + expect(result.current.table.state.sorting).toEqual([ + { id: "title", desc: false }, + ]); + expect(result.current.table.getJoinOperator()).toBe("or"); + + act(() => result.current.table.setPageIndex(3)); + await waitFor(() => expect(getLastSearch()?.get("p")).toBe("4")); + expect(getLastSearch()?.has("page")).toBe(false); + }); + + it("writes every filter on a column, including one with no value", async () => { + const { result, getLastSearch } = renderDataTable(""); + + act(() => { + result.current.table.setColumnFilters([ + { + id: "title", + value: "bug", + operator: "iLike", + variant: "text", + filterId: "title-0", + }, + { + id: "title", + value: "feat", + operator: "iLike", + variant: "text", + filterId: "title-1", + }, + { + id: "status", + value: "", + operator: "isEmpty", + variant: "multiSelect", + filterId: "status-0", + }, + ]); + }); + + await waitFor(() => + expect(getLastSearch()?.getAll("title")).toEqual(["bug", "feat"]), + ); + expect(getLastSearch()?.get("status")).toBe("is.empty"); + }); + + it("leaves a column that cannot be filtered out of the URL", async () => { + const { result, getLastSearch } = renderDataTable(""); + + act(() => { + result.current.table.setColumnFilters([ + { id: "code", value: "T-1" }, + { id: "title", value: "bug" }, + ]); + }); + + await waitFor(() => expect(getLastSearch()?.get("title")).toBe("bug")); + expect(getLastSearch()?.has("code")).toBe(false); + }); + + it("filters rows in client mode and leaves them to the server otherwise", async () => { + const tasks: Task[] = [ + { id: "1", code: "T-1", title: "bug", status: "todo" }, + { id: "2", code: "T-2", title: "feat", status: "done" }, + ]; + + const server = renderDataTable("?title=bug", { data: tasks }); + await waitFor(() => + expect(server.result.current.table.state.columnFilters).toHaveLength(1), + ); + expect(server.result.current.table.getRowModel().rows).toHaveLength(2); + + const client = renderDataTable("?title=bug", { + mode: "client", + data: tasks, + }); + await waitFor(() => + expect( + client.result.current.table + .getFilteredRowModel() + .rows.map((row) => row.original.title), + ).toEqual(["bug"]), + ); }); }); diff --git a/src/lib/test/data-table-filters.test.ts b/src/lib/test/data-table-filters.test.ts new file mode 100644 index 00000000..7d05821e --- /dev/null +++ b/src/lib/test/data-table-filters.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import type { FilterOperator, FilterVariant } from "@/lib/data-table-types"; + +import { matchesFilter } from "@/lib/data-table-filters"; + +function match( + cell: unknown, + operator: FilterOperator, + variant: FilterVariant, + value: string | string[], +) { + return matchesFilter(cell, { operator, variant, value }); +} + +describe("matchesFilter", () => { + it("matches text by inclusion, and skips a filter with no text", () => { + expect(match("Alpha", "iLike", "text", "alp")).toBe(true); + expect(match("Alpha", "iLike", "text", "bet")).toBe(false); + expect(match("Alpha", "notILike", "text", "bet")).toBe(true); + expect(match("Alpha", "notILike", "text", "alp")).toBe(false); + expect(match("Alpha", "iLike", "text", ["alp"])).toBe(true); + expect(match("Alpha", "notILike", "text", ["alp"])).toBe(true); + }); + + it("compares booleans, numbers, and text for equality", () => { + expect(match(true, "eq", "boolean", "true")).toBe(true); + expect(match("true", "eq", "boolean", "true")).toBe(true); + expect(match(false, "eq", "boolean", "true")).toBe(false); + expect(match("false", "ne", "boolean", "true")).toBe(true); + expect(match(false, "ne", "boolean", "false")).toBe(false); + + expect(match(2, "eq", "number", "2")).toBe(true); + expect(match("2", "ne", "range", "3")).toBe(true); + expect(match("", "eq", "number", "")).toBe(false); + + expect(match("todo", "eq", "select", "todo")).toBe(true); + expect(match("todo", "ne", "select", "done")).toBe(true); + expect(match(null, "eq", "text", "")).toBe(true); + }); + + it("matches list membership when the filter value is a list", () => { + expect(match("todo", "inArray", "multiSelect", ["todo", "done"])).toBe( + true, + ); + expect(match("canceled", "inArray", "multiSelect", ["todo"])).toBe(false); + expect(match("todo", "inArray", "multiSelect", "todo")).toBe(true); + expect(match("todo", "notInArray", "multiSelect", ["done"])).toBe(true); + expect(match("done", "notInArray", "multiSelect", ["done"])).toBe(false); + expect(match("done", "notInArray", "multiSelect", "done")).toBe(true); + }); + + it("compares numbers and rejects a value that is not numeric", () => { + expect(match(2, "lt", "number", "3")).toBe(true); + expect(match(3, "lt", "number", "3")).toBe(false); + expect(match(3, "lte", "range", "3")).toBe(true); + expect(match(4, "gt", "number", "3")).toBe(true); + expect(match(3, "gte", "number", "3")).toBe(true); + expect(match("nope", "gt", "number", "3")).toBe(false); + expect(match(1, "lt", "number", ["1", "2"])).toBe(true); + }); + + it("treats a between filter with a missing side as open", () => { + const day = new Date(2026, 9, 3, 12).getTime(); + const later = new Date(2026, 9, 8, 12).getTime(); + + expect(match(2, "isBetween", "range", "2")).toBe(true); + expect(match(2, "isBetween", "range", ["", ""])).toBe(true); + expect(match(4, "isBetween", "range", ["2", "8"])).toBe(true); + expect(match(1, "isBetween", "range", ["2", "8"])).toBe(false); + expect(match("nope", "isBetween", "number", ["2", "8"])).toBe(false); + expect(match(day, "isBetween", "date", ["2026-10-03", ""])).toBe(true); + expect( + match(later, "isBetween", "dateRange", ["2026-10-01", "2026-10-07"]), + ).toBe(false); + expect(match(day, "isBetween", "date", ["", "2026-10-03"])).toBe(true); + }); + + it("matches a day, week, or month counted from today", () => { + const dayMs = 24 * 60 * 60 * 1000; + const now = Date.now(); + + function atNoon(dayOffset: number) { + const date = new Date(now + dayOffset * dayMs); + date.setHours(12, 0, 0, 0); + return date.getTime(); + } + + expect(match(atNoon(0), "isRelativeToToday", "date", "0 days")).toBe(true); + expect(match(atNoon(1), "isRelativeToToday", "date", "0 days")).toBe(false); + expect(match(atNoon(-1), "isRelativeToToday", "date", "-1 days")).toBe( + true, + ); + expect(match(atNoon(7), "isRelativeToToday", "date", "1 weeks")).toBe(true); + expect(match(atNoon(6), "isRelativeToToday", "date", "1 weeks")).toBe( + false, + ); + expect(match(atNoon(13), "isRelativeToToday", "date", "1 weeks")).toBe( + true, + ); + expect(match(atNoon(14), "isRelativeToToday", "date", "1 weeks")).toBe( + false, + ); + expect( + match(atNoon(30), "isRelativeToToday", "dateRange", "1 months"), + ).toBe(true); + expect(match(atNoon(29), "isRelativeToToday", "date", "1 months")).toBe( + false, + ); + expect(match(atNoon(59), "isRelativeToToday", "date", "1 months")).toBe( + true, + ); + expect(match(atNoon(60), "isRelativeToToday", "date", "1 months")).toBe( + false, + ); + expect(match("nope", "isRelativeToToday", "date", "0 days")).toBe(false); + expect(match(atNoon(0), "isRelativeToToday", "date", ["0 days"])).toBe( + true, + ); + expect(match(atNoon(0), "isRelativeToToday", "date", "days")).toBe(true); + expect(match(atNoon(0), "isRelativeToToday", "date", "1 years")).toBe(true); + }); + + it("treats null, an empty string, and an empty list as empty", () => { + for (const value of [null, undefined, "", []]) { + expect(match(value, "isEmpty", "text", "")).toBe(true); + expect(match(value, "isNotEmpty", "text", "")).toBe(false); + } + expect(match(0, "isEmpty", "number", "")).toBe(false); + expect(match(false, "isNotEmpty", "boolean", "")).toBe(true); + expect(match("todo", "nope" as FilterOperator, "text", "todo")).toBe(true); + }); +}); diff --git a/src/lib/test/data-table-table.test.tsx b/src/lib/test/data-table-table.test.tsx new file mode 100644 index 00000000..c8899144 --- /dev/null +++ b/src/lib/test/data-table-table.test.tsx @@ -0,0 +1,525 @@ +import { + createColumnHelper, + type FilterFn, + useTable, +} from "@tanstack/react-table"; +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { DataTableFeatures } from "@/lib/data-table-features"; +import type { FilterVariant } from "@/lib/data-table-types"; + +import { dataTableFeatures } from "@/lib/data-table-features"; +import { dataTableFilterFn } from "@/lib/data-table-filters"; +import { + getColumnPinningStyle, + getColumnSizingStyle, + getDefaultFilter, +} from "@/lib/data-table-utils"; + +interface Task { + id: string; + title: string; + status: string; + hours: number; + createdAt: number; + archived: boolean; + children?: Task[]; +} + +const columnHelper = createColumnHelper(); + +const exactTitle: FilterFn = (row, columnId, value) => + row.getValue(columnId) === value; +exactTitle.resolveFilterValue = (value) => + typeof value === "string" ? value.trim() : value; +exactTitle.autoRemove = (value) => value === ""; + +const columns = columnHelper.columns([ + columnHelper.accessor("title", { + id: "title", + meta: { variant: "text" }, + }), + columnHelper.accessor("title", { + id: "exact", + filterFn: exactTitle, + }), + columnHelper.accessor("title", { + id: "weird", + meta: { variant: "bogus" as FilterVariant }, + }), + columnHelper.accessor("status", { + id: "status", + meta: { variant: "multiSelect" }, + }), + columnHelper.accessor("hours", { + id: "hours", + meta: { variant: "range" }, + }), + columnHelper.accessor("createdAt", { + id: "createdAt", + meta: { variant: "date" }, + }), + columnHelper.accessor("archived", { + id: "archived", + meta: { variant: "boolean" }, + }), + columnHelper.accessor("title", { + id: "a b", + }), +]); + +const tasks: Task[] = [ + { + id: "alpha", + title: "Alpha", + status: "todo", + hours: 2, + createdAt: new Date(2026, 9, 3, 9).getTime(), + archived: false, + }, + { + id: "beta", + title: "Beta", + status: "done", + hours: 8, + createdAt: new Date(2026, 9, 4, 9).getTime(), + archived: true, + children: [ + { + id: "child", + title: "Nested", + status: "todo", + hours: 1, + createdAt: new Date(2026, 9, 3, 12).getTime(), + archived: false, + }, + ], + }, + { + id: "gamma", + title: "Gamma", + status: "todo", + hours: 4, + createdAt: new Date(2026, 9, 5, 9).getTime(), + archived: false, + }, +]; + +function renderTaskTable({ + data = tasks, + filterFromLeafRows, + maxLeafRowFilterDepth, + joinOperator, +}: { + data?: Task[]; + filterFromLeafRows?: boolean; + maxLeafRowFilterDepth?: number; + joinOperator?: "and" | "or"; +} = {}) { + return renderHook(() => + useTable({ + features: dataTableFeatures, + columns, + data, + getRowId: (row) => row.id, + getSubRows: (row) => row.children, + filterFromLeafRows, + maxLeafRowFilterDepth, + initialState: joinOperator ? { joinOperator } : undefined, + }), + ); +} + +function rowIds( + table: ReturnType["result"]["current"], +) { + return table.getFilteredRowModel().rows.map((row) => row.id); +} + +function facetIds( + table: ReturnType["result"]["current"], + columnId: string, +) { + return ( + table + .getColumn(columnId) + ?.getFacetedRowModel() + .rows.map((row) => row.id) ?? [] + ); +} + +describe("filtered rows", () => { + it("keeps every row when nothing narrows them", () => { + const { result } = renderTaskTable(); + const table = result.current; + + expect(rowIds(table)).toEqual(["alpha", "beta", "gamma"]); + expect(table.getFilteredRowModel()).toBe(table.getPreFilteredRowModel()); + + act(() => { + table.setColumnFilters([{ id: "missing", value: "x" }]); + }); + expect(table.getFilteredRowModel()).toBe(table.getPreFilteredRowModel()); + + act(() => { + table.setColumnFilters([ + { id: "title", operator: "iLike", variant: "text", value: "" }, + ]); + }); + expect(table.getFilteredRowModel()).toBe(table.getPreFilteredRowModel()); + }); + + it("returns the empty model unchanged", () => { + const { result } = renderTaskTable({ data: [] }); + act(() => { + result.current.setColumnFilters([{ id: "title", value: "Alpha" }]); + }); + expect(result.current.getFilteredRowModel().rows).toEqual([]); + expect(result.current.getFilteredRowModel()).toBe( + result.current.getPreFilteredRowModel(), + ); + }); + + it("joins filters on one column and across columns", () => { + const { result } = renderTaskTable(); + const table = result.current; + + act(() => { + table.setColumnFilters([ + { id: "title", operator: "iLike", variant: "text", value: "a" }, + { id: "title", operator: "eq", variant: "text", value: "Alpha" }, + ]); + }); + expect(rowIds(table)).toEqual(["alpha"]); + + act(() => table.setJoinOperator("or")); + act(() => { + table.setColumnFilters([ + { + id: "status", + operator: "inArray", + variant: "multiSelect", + value: ["done"], + }, + { id: "title", operator: "eq", variant: "text", value: "Alpha" }, + ]); + }); + expect(rowIds(table)).toEqual(["alpha", "beta"]); + + const alpha = table + .getPreFilteredRowModel() + .flatRows.find((row) => row.id === "alpha"); + expect( + alpha && "columnFilters" in alpha && alpha.columnFilters, + ).toMatchObject({ status: false, title: true }); + }); + + it("uses a column filter function until the filter has an operator", () => { + const { result } = renderTaskTable(); + const table = result.current; + const row = table.getRowModel().rows[0]; + + expect(row && dataTableFilterFn(row, "title", "")).toBe(true); + expect(row && dataTableFilterFn(row, "title", "Alpha")).toBe(true); + expect(dataTableFilterFn.autoRemove?.("")).toBe(false); + expect(dataTableFilterFn.autoRemove?.(undefined)).toBe(true); + + act(() => table.setColumnFilters([{ id: "exact", value: " Alpha" }])); + expect(rowIds(table)).toEqual(["alpha"]); + + act(() => { + table.setColumnFilters([ + { id: "exact", operator: "iLike", variant: "text", value: "alp" }, + ]); + }); + expect(rowIds(table)).toEqual(["alpha"]); + + act(() => { + table.setColumnFilters([ + { id: "weird", operator: "iLike", variant: "text", value: "alpha" }, + ]); + }); + expect(rowIds(table)).toEqual(["alpha"]); + }); + + it("keeps a parent whose child matches only when filtering from the leaves", () => { + function filterNested(filterFromLeafRows?: boolean, maxDepth?: number) { + const { result } = renderTaskTable({ + filterFromLeafRows, + maxLeafRowFilterDepth: maxDepth, + }); + act(() => { + result.current.setColumnFilters([ + { id: "title", operator: "eq", variant: "text", value: "Nested" }, + ]); + }); + return result.current.getFilteredRowModel(); + } + + expect(filterNested(false).rows).toEqual([]); + + const fromLeaves = filterNested(true); + expect(fromLeaves.rows.map((row) => row.id)).toEqual(["beta"]); + expect(fromLeaves.flatRows.map((row) => row.id)).toEqual(["beta", "child"]); + + const shallow = filterNested(true, 0); + expect(shallow.rows).toEqual([]); + }); + + it("keeps unfiltered children once the depth limit is reached", () => { + const { result } = renderTaskTable({ + filterFromLeafRows: true, + maxLeafRowFilterDepth: 0, + }); + act(() => { + result.current.setColumnFilters([ + { id: "title", operator: "eq", variant: "text", value: "Beta" }, + ]); + }); + const model = result.current.getFilteredRowModel(); + expect(model.rows.map((row) => row.id)).toEqual(["beta"]); + expect(model.flatRows.map((row) => row.id)).toEqual(["beta", "child"]); + }); + + it("does not keep a child that fails when filtering from the root", () => { + const { result } = renderTaskTable(); + act(() => { + result.current.setColumnFilters([ + { id: "title", operator: "eq", variant: "text", value: "Beta" }, + ]); + }); + const model = result.current.getFilteredRowModel(); + expect(model.rows.map((row) => row.id)).toEqual(["beta"]); + expect(model.rows[0]?.subRows).toEqual([]); + }); + + it("keeps every child once the depth limit is reached from the root", () => { + const { result } = renderTaskTable({ maxLeafRowFilterDepth: 0 }); + act(() => { + result.current.setColumnFilters([ + { id: "title", operator: "eq", variant: "text", value: "Beta" }, + ]); + }); + expect( + result.current.getFilteredRowModel().flatRows.map((row) => row.id), + ).toEqual(["beta", "child"]); + }); +}); + +describe("faceted rows", () => { + it("ignores a column's own filter when building its facets", () => { + const { result } = renderTaskTable(); + const table = result.current; + + act(() => { + table.setColumnFilters([ + { + id: "status", + operator: "inArray", + variant: "multiSelect", + value: ["done"], + }, + ]); + }); + expect(table.getColumn("status")?.getFacetedRowModel()).toBe( + table.getPreFilteredRowModel(), + ); + + act(() => { + table.setColumnFilters([ + { id: "title", operator: "eq", variant: "text", value: "Alpha" }, + { + id: "status", + operator: "inArray", + variant: "multiSelect", + value: ["done"], + }, + ]); + }); + expect(rowIds(table)).toEqual([]); + expect(facetIds(table, "status")).toEqual(["alpha"]); + expect(facetIds(table, "title")).toEqual(["beta"]); + }); + + it("facets an empty table without rebuilding it", () => { + const { result } = renderTaskTable({ data: [] }); + act(() => { + result.current.setColumnFilters([ + { id: "title", value: "Alpha" }, + { id: "status", value: ["done"] }, + ]); + }); + expect(result.current.getColumn("status")?.getFacetedRowModel()).toBe( + result.current.getPreFilteredRowModel(), + ); + }); +}); + +describe("column filter items", () => { + it("adds, updates, and removes filters by id", () => { + const { result } = renderTaskTable(); + const table = result.current; + const filter = { + id: "title", + variant: "text" as const, + operator: "iLike" as const, + value: "Alpha", + filterId: "title-1", + }; + + act(() => table.addColumnFilter(filter)); + expect(table.getColumnFilterItems()).toEqual([ + expect.objectContaining({ filterId: "title-1", value: "Alpha" }), + ]); + + act(() => table.updateColumnFilter("title-1", { value: "Beta" })); + expect(table.getColumnFilterItems()[0]?.value).toBe("Beta"); + + act(() => table.removeColumnFilter("title-1")); + expect(table.getColumnFilterItems()).toEqual([]); + }); + + it("removes cleared filters and keeps operator filters and unknown columns", () => { + const { result } = renderTaskTable(); + const table = result.current; + + act(() => table.setColumnFilters([{ id: "title", value: undefined }])); + expect(table.getColumnFilterItems()).toEqual([]); + + act(() => table.setColumnFilters([{ id: "title", value: "" }])); + expect(table.getColumnFilterItems()).toEqual([ + expect.objectContaining({ id: "title", operator: "iLike", value: "" }), + ]); + + act(() => table.setColumnFilters([{ id: "exact", value: "" }])); + expect(table.getColumnFilterItems()).toEqual([]); + + act(() => { + table.setColumnFilters([ + { id: "title", operator: "iLike", value: "" }, + { id: "missing", value: "" }, + ]); + }); + expect(table.getColumnFilterItems().map((filter) => filter.id)).toEqual([ + "title", + "missing", + ]); + }); + + it("reads and writes the plain filter for a column", () => { + const { result } = renderTaskTable(); + const table = result.current; + const title = table.getColumn("title"); + const hours = table.getColumn("hours"); + const createdAt = table.getColumn("createdAt"); + + expect(title && getDefaultFilter(title)).toEqual({ + id: "title", + variant: "text", + operator: "iLike", + value: "", + }); + + act(() => title?.setFilterValue("Alpha")); + expect(title?.getFilterValue()).toBe("Alpha"); + act(() => title?.setFilterValue("Beta")); + expect(table.getColumnFilterItems()).toHaveLength(1); + expect(title?.getFilterValue()).toBe("Beta"); + + act(() => { + table.setColumnFilters([ + { + id: "title", + operator: "eq", + variant: "text", + value: "Alpha", + filterId: "adv", + }, + ]); + }); + expect(title?.getFilterValue()).toBeUndefined(); + act(() => title?.setFilterValue("Beta")); + expect(table.getColumnFilterItems()).toHaveLength(2); + + act(() => { + table.setColumnFilters([ + { id: "title", value: "Alpha", filterId: "keep" }, + ]); + }); + act(() => title?.setFilterValue("Beta")); + expect(table.getColumnFilterItems()[0]).toMatchObject({ + value: "Beta", + filterId: "keep", + }); + + act(() => hours?.setFilterValue(["2", ""])); + expect(hours?.getFilterValue()).toEqual([2, undefined]); + + act(() => createdAt?.setFilterValue(new Date(2026, 9, 3, 9).getTime())); + expect(createdAt?.getFilterValue()).toBe(new Date(2026, 9, 3).getTime()); + }); + + it("resets the join operator to its initial value or to and", () => { + const { result } = renderTaskTable({ joinOperator: "or" }); + const table = result.current; + + expect(table.getJoinOperator()).toBe("or"); + act(() => table.setJoinOperator("and")); + expect(table.getJoinOperator()).toBe("and"); + act(() => table.resetJoinOperator()); + expect(table.getJoinOperator()).toBe("or"); + act(() => table.resetJoinOperator(true)); + expect(table.getJoinOperator()).toBe("and"); + }); +}); + +describe("column layout styles", () => { + it("pins columns with sticky offsets and sizes every column", () => { + const { result } = renderTaskTable(); + const table = result.current; + const title = table.getColumn("title"); + const archived = table.getColumn("archived"); + const spaced = table.getColumn("a b"); + + act(() => { + table.setColumnPinning({ start: ["title", "a b"], end: ["archived"] }); + }); + + expect(title && getColumnPinningStyle(title)).toMatchObject({ + position: "sticky", + insetInlineStart: "var(--column-title-offset)", + opacity: 0.97, + zIndex: 1, + width: "var(--column-title-size)", + }); + expect(archived && getColumnPinningStyle(archived)).toMatchObject({ + insetInlineEnd: "var(--column-archived-offset)", + insetInlineStart: undefined, + }); + expect( + table.getColumn("status") && + getColumnPinningStyle(table.getColumn("status")!), + ).toMatchObject({ + position: "relative", + opacity: 1, + zIndex: undefined, + }); + expect(spaced && getColumnPinningStyle(spaced).width).toBe( + "var(--column-a_20_b-size)", + ); + + const style = Object.fromEntries( + Object.entries(getColumnSizingStyle(table)), + ); + expect(style.minWidth).toBe(`${table.getTotalSize()}px`); + expect(style["--column-title-size"]).toBe(`${title?.getSize()}px`); + expect(style["--column-a_20_b-offset"]).toBe( + `${spaced?.getStart("start")}px`, + ); + expect(style["--column-archived-offset"]).toBe( + `${archived?.getAfter("end")}px`, + ); + expect(style["--column-status-offset"]).toBeUndefined(); + }); +}); diff --git a/src/lib/test/data-table-utils.test.ts b/src/lib/test/data-table-utils.test.ts new file mode 100644 index 00000000..dc24d8ef --- /dev/null +++ b/src/lib/test/data-table-utils.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; + +import { + coerceFilterValue, + createPlainFilter, + formatFilterDate, + getActiveFilters, + getDateFilterLabel, + getDefaultFilterOperator, + getFilterDates, + getFilterDateValue, + getFilterOperators, + getIsActiveFilter, + getIsDateVariant, + getIsEditableTarget, + getIsMultiValueVariant, + getIsPlainFilter, + getPlainFilterId, + getPlainFilterOperator, + getPlainFilterValue, + getSelectFilterValue, + normalizeColumnFilter, + parseFilterDate, + stringifyFilterValue, +} from "@/lib/data-table-utils"; + +describe("filter operators", () => { + it("gives every variant its own operator list and default", () => { + expect(getFilterOperators("text").map((item) => item.value)).toContain( + "iLike", + ); + expect(getFilterOperators("multiSelect")[0]?.value).toBe("inArray"); + expect(getDefaultFilterOperator("text")).toBe("iLike"); + expect(getDefaultFilterOperator("number")).toBe("eq"); + expect(getDefaultFilterOperator("boolean")).toBe("eq"); + expect(getDefaultFilterOperator("date")).toBe("eq"); + }); + + it("uses the plain operator a toolbar filter writes", () => { + expect(getPlainFilterOperator("select")).toBe("inArray"); + expect(getPlainFilterOperator("multiSelect")).toBe("inArray"); + expect(getPlainFilterOperator("range")).toBe("isBetween"); + expect(getPlainFilterOperator("dateRange")).toBe("isBetween"); + expect(getPlainFilterOperator("text")).toBe("iLike"); + expect(getPlainFilterOperator("number")).toBe("eq"); + expect(getIsMultiValueVariant("range")).toBe(true); + expect(getIsMultiValueVariant("text")).toBe(false); + expect(getIsDateVariant("dateRange")).toBe(true); + expect(getIsDateVariant("number")).toBe(false); + }); +}); + +describe("coerceFilterValue", () => { + it("reshapes a value to match the operator", () => { + expect(coerceFilterValue("isEmpty", "kept")).toBe(""); + expect(coerceFilterValue("isNotEmpty", ["kept"])).toBe(""); + expect(coerceFilterValue("inArray", "todo")).toEqual(["todo"]); + expect(coerceFilterValue("inArray", "")).toEqual([]); + expect(coerceFilterValue("notInArray", ["todo"])).toEqual(["todo"]); + expect(coerceFilterValue("isBetween", "2")).toEqual(["2", ""]); + expect(coerceFilterValue("isBetween", ["2", "8"])).toEqual(["2", "8"]); + expect(coerceFilterValue("eq", ["", "todo"])).toBe("todo"); + expect(coerceFilterValue("eq", ["", ""])).toBe(""); + expect(coerceFilterValue("eq", "todo")).toBe("todo"); + }); +}); + +describe("editable targets", () => { + it("recognizes fields and content a user can type into", () => { + expect(getIsEditableTarget(null)).toBe(false); + expect(getIsEditableTarget(document.createElement("div"))).toBe(false); + + const editable = document.createElement("div"); + editable.contentEditable = "true"; + expect(getIsEditableTarget(editable)).toBe(true); + expect(getIsEditableTarget(document.createElement("input"))).toBe(true); + expect(getIsEditableTarget(document.createElement("textarea"))).toBe(true); + expect(getIsEditableTarget(document.createElement("select"))).toBe(true); + }); +}); + +describe("filter dates", () => { + it("parses dates, timestamps, and rejects values that are not dates", () => { + const date = new Date(2026, 9, 3, 9, 30); + expect(parseFilterDate(date)).toBe(date); + expect(parseFilterDate(new Date(Number.NaN))).toBeUndefined(); + expect(parseFilterDate(date.getTime())?.getTime()).toBe(date.getTime()); + expect(parseFilterDate("")).toBeUndefined(); + expect(parseFilterDate(" ")).toBeUndefined(); + expect(parseFilterDate(String(date.getTime()))?.getTime()).toBe( + date.getTime(), + ); + expect(parseFilterDate("not-a-date")).toBeUndefined(); + expect(parseFilterDate("2026-10-03T15:00:00.000Z")?.toISOString()).toBe( + "2026-10-03T15:00:00.000Z", + ); + }); + + it("formats a calendar day and labels a range", () => { + const start = new Date(2026, 9, 3); + const end = new Date(2026, 9, 7); + expect(formatFilterDate(start)).toBe("2026-10-03"); + expect(getFilterDateValue(start)).toBe("2026-10-03"); + expect(getFilterDateValue(undefined)).toBe(""); + expect(getFilterDates(["2026-10-03", "nope", "2026-10-07"])).toEqual([ + start, + end, + ]); + + const between = { + id: "createdAt", + filterId: "createdAt-0", + variant: "dateRange" as const, + operator: "isBetween" as const, + value: ["2026-10-03", "2026-10-07"], + }; + expect(getDateFilterLabel(between)).toBe("Oct 3, 2026 - Oct 7, 2026"); + expect( + getDateFilterLabel({ ...between, value: ["2026-10-03", "2026-10-03"] }), + ).toBe("Oct 3, 2026"); + expect( + getDateFilterLabel({ ...between, operator: "gte", value: "2026-10-03" }), + ).toBe("Oct 3, 2026"); + expect(getDateFilterLabel({ ...between, value: "nope" })).toBeUndefined(); + }); +}); + +describe("stringifyFilterValue", () => { + it("stringifies empty, date, object, and primitive values", () => { + const date = new Date(2026, 9, 3, 9, 30); + expect(stringifyFilterValue(null)).toBe(""); + expect(stringifyFilterValue(undefined)).toBe(""); + expect(stringifyFilterValue(date)).toBe(date.toISOString()); + expect(stringifyFilterValue({ id: 1 })).toBe('{"id":1}'); + expect(stringifyFilterValue(false)).toBe("false"); + }); +}); + +describe("plain filters", () => { + it("builds a plain filter and drops an empty one", () => { + expect(createPlainFilter("title", "text", undefined)).toBeNull(); + expect(createPlainFilter("title", "text", "")).toBeNull(); + expect(createPlainFilter("title", "text", ["Alpha"])).toBeNull(); + expect(createPlainFilter("status", "multiSelect", ["", ""])).toBeNull(); + expect(createPlainFilter("title", "text", "Alpha")).toEqual({ + id: "title", + variant: "text", + operator: "iLike", + value: "Alpha", + filterId: "title-filter", + }); + expect(createPlainFilter("status", "multiSelect", "todo")).toMatchObject({ + operator: "inArray", + value: ["todo"], + filterId: "status-filter", + }); + expect( + createPlainFilter("createdAt", "date", new Date(2026, 9, 3, 9).getTime()), + ).toMatchObject({ operator: "eq", value: "2026-10-03" }); + }); + + it("normalizes a stored filter into a column filter item", () => { + expect( + normalizeColumnFilter( + { + id: "createdAt", + operator: "isRelativeToToday", + value: "1 days", + filterId: "createdAt-0", + }, + "date", + ), + ).toMatchObject({ operator: "isRelativeToToday", value: "1 days" }); + expect( + normalizeColumnFilter( + { id: "createdAt", operator: "eq", value: new Date(2026, 9, 3) }, + "date", + ), + ).toMatchObject({ value: "2026-10-03", filterId: "createdAt-filter" }); + expect( + normalizeColumnFilter({ id: "title", value: "" }, "text"), + ).toMatchObject({ operator: "iLike", value: "", filterId: "title-filter" }); + expect( + normalizeColumnFilter({ id: "status", value: undefined }, "multiSelect"), + ).toMatchObject({ operator: "inArray", value: [] }); + }); + + it("reads a plain filter back into the toolbar value", () => { + const base = { + id: "status", + filterId: "status-0", + operator: "inArray" as const, + }; + expect( + getPlainFilterValue({ ...base, variant: "multiSelect", value: ["todo"] }), + ).toEqual(["todo"]); + expect( + getPlainFilterValue({ ...base, variant: "select", value: "todo" }), + ).toEqual(["todo"]); + expect( + getPlainFilterValue({ + ...base, + id: "hours", + variant: "range", + operator: "isBetween", + value: ["2", ""], + }), + ).toEqual([2, undefined]); + expect( + getPlainFilterValue({ + id: "createdAt", + filterId: "createdAt-0", + variant: "date", + operator: "eq", + value: "2026-10-03", + }), + ).toBe(new Date(2026, 9, 3).getTime()); + expect( + getPlainFilterValue({ + id: "createdAt", + filterId: "createdAt-0", + variant: "dateRange", + operator: "isBetween", + value: ["2026-10-03", "nope"], + }), + ).toEqual([new Date(2026, 9, 3).getTime(), undefined]); + expect( + getPlainFilterValue({ + id: "title", + filterId: "title-0", + variant: "text", + operator: "iLike", + value: "Alpha", + }), + ).toBe("Alpha"); + }); + + it("knows which filters are plain and which still narrow the rows", () => { + const plain = createPlainFilter("title", "text", "Alpha"); + expect(plain && getIsPlainFilter(plain)).toBe(true); + expect( + getIsPlainFilter({ + id: "title", + filterId: getPlainFilterId("title"), + variant: "text", + operator: "eq", + value: "Alpha", + }), + ).toBe(false); + expect( + getIsPlainFilter({ + id: "status", + filterId: "status-0", + variant: "multiSelect", + operator: "inArray", + value: "todo", + }), + ).toBe(false); + + const empty = { + id: "title", + filterId: "title-0", + variant: "text" as const, + operator: "iLike" as const, + value: "", + }; + expect(getIsActiveFilter(empty)).toBe(false); + expect(getIsActiveFilter({ ...empty, operator: "isEmpty" })).toBe(true); + expect(getIsActiveFilter({ ...empty, value: ["", "2"] })).toBe(true); + expect(getIsActiveFilter({ ...empty, value: ["", ""] })).toBe(false); + expect(getActiveFilters([empty, { ...empty, value: "Alpha" }])).toEqual([ + { ...empty, value: "Alpha" }, + ]); + }); +}); + +describe("select filter values", () => { + it("returns a list for multi-select and a string for select", () => { + const filter = { + id: "status", + filterId: "status-0", + operator: "eq" as const, + value: "todo", + }; + expect( + getSelectFilterValue({ + ...filter, + variant: "multiSelect", + value: "todo", + }), + ).toEqual([]); + expect( + getSelectFilterValue({ + ...filter, + variant: "multiSelect", + value: ["todo"], + }), + ).toEqual(["todo"]); + expect(getSelectFilterValue({ ...filter, variant: "select" })).toBe("todo"); + expect( + getSelectFilterValue({ ...filter, variant: "select", value: ["todo"] }), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/test/data-table.test.ts b/src/lib/test/data-table.test.ts index eec280b4..007ba08a 100644 --- a/src/lib/test/data-table.test.ts +++ b/src/lib/test/data-table.test.ts @@ -5,8 +5,10 @@ import { tasksColumnConfigs, tasksDefaultSorting } from "@/app/lib/validations"; import { matchesFilter } from "@/lib/data-table-filters"; import { parseFilterDate } from "@/lib/data-table-utils"; import { - getColumnOptions, + getColumnFilters, + getColumnFiltersKey, getColumnFilterParser, + getColumnOptions, getDataTableQuery, getDataTableSearchParams, getFilterableColumns, @@ -156,6 +158,16 @@ describe("serializeColumnFilter", () => { parseColumnFilter("status", "multiSelect", '"to,do",done'), ), ).toBe('"to,do",done'); + expect( + serializeColumnFilter( + parseColumnFilter("title", "text", "ilike.eq.hello"), + ), + ).toBe("ilike.eq.hello"); + expect( + serializeColumnFilter( + parseColumnFilter("status", "multiSelect", '"say ""hi""",done'), + ), + ).toBe('"say ""hi""",done'); }); it("serializes a column parser as one param per filter", () => { @@ -169,6 +181,18 @@ describe("serializeColumnFilter", () => { ).toEqual(["gte.2", "lte.8"]); }); + it("parses quoted quotes, empty list items, and a single between bound", () => { + expect( + parseColumnFilter("status", "multiSelect", '"say ""hi""",done'), + ).toMatchObject({ value: ['say "hi"', "done"] }); + expect( + parseColumnFilter("status", "multiSelect", "todo,,done"), + ).toMatchObject({ value: ["todo", "done"] }); + expect( + parseColumnFilter("estimatedHours", "range", "between.5"), + ).toMatchObject({ operator: "isBetween", value: ["5", ""] }); + }); + it("drops filters without a value when parsing", () => { const parser = getColumnFilterParser("title", "text"); @@ -210,6 +234,45 @@ describe("getDataTableQuery", () => { ); expect(query.joinOperator).toBe("and"); + expect(loadSearch("?joinOperator=xor").joinOperator).toBe("and"); + }); + + it("defaults page, page size, and sorting", () => { + const search = loadSearch("?page=no"); + + expect(search.page).toBe(1); + expect(search.perPage).toBe(10); + expect(search.sort).toEqual(tasksDefaultSorting); + + const custom = createLoader( + getDataTableSearchParams({ + columnConfigs: tasksColumnConfigs, + defaultPerPage: 25, + defaultSorting: [], + }), + )("?page=2&perPage=5"); + + expect(custom.page).toBe(2); + expect(custom.perPage).toBe(5); + expect(custom.sort).toEqual([]); + }); +}); + +describe("getColumnFilters", () => { + it("numbers filters per column and builds a stable key", () => { + const title = parseColumnFilter("title", "text", "the"); + const status = parseColumnFilter("status", "multiSelect", "todo"); + const filters = getColumnFilters(["title", "status"] as const, (id) => + id === "title" ? [title, title] : [status], + ); + + expect(filters.map((filter) => filter.filterId)).toEqual([ + "title-0", + "title-1", + "status-0", + ]); + expect(getColumnFiltersKey([status, title])).toBe("status=todo&title=the"); + expect(getColumnFiltersKey([])).toBe(""); }); }); @@ -227,6 +290,12 @@ describe("sortColumnFiltersBySearch", () => { "?createdAt=2026-10-01,2026-10-07&status=todo&title=the", ).map((filter) => filter.id), ).toEqual(["createdAt", "status", "title"]); + expect( + sortColumnFiltersBySearch( + filters, + new URLSearchParams("status=todo"), + ).map((filter) => filter.id), + ).toEqual(["status", "title", "createdAt"]); }); }); @@ -252,6 +321,36 @@ describe("getSortingStateParser", () => { expect(commaParser.serialize([{ id: "a,b", desc: true }])).toBe( JSON.stringify([{ id: "a,b", desc: true }]), ); + expect( + getSortingStateParser().serialize([{ id: "[title]", desc: false }]), + ).toBe(JSON.stringify([{ id: "[title]", desc: false }])); + expect(parser.serialize([])).toBe("[]"); + }); + + it("reads JSON sorting and rejects a broken list", () => { + expect(parser.parse('[{"id":"title","desc":true}]')).toEqual([ + { id: "title", desc: true }, + ]); + expect(parser.parse("[]")).toEqual([]); + expect(parser.parse("[")).toBeNull(); + expect(parser.parse("title")).toBeNull(); + expect(parser.parse(".desc")).toBeNull(); + expect(getSortingStateParser().parse("missing.desc")).toEqual([ + { id: "missing", desc: true }, + ]); + expect( + getSortingStateParser(new Set(["title"])).parse("title.asc"), + ).toEqual([{ id: "title", desc: false }]); + expect(getSortingStateParser(new Set(["title"])).parse("missing.asc")).toBe( + null, + ); + }); + + it("treats the same sort list as equal", () => { + const sorting = [{ id: "title" as const, desc: true }]; + expect(parser.eq(sorting, [{ id: "title", desc: true }])).toBe(true); + expect(parser.eq(sorting, [{ id: "title", desc: false }])).toBe(false); + expect(parser.eq(sorting, [])).toBe(false); }); }); From c4939ad075154981747539390115ccfb91ac435a Mon Sep 17 00:00:00 2001 From: sadman <68962712+sadmann7@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:02:43 +0600 Subject: [PATCH 2/4] chore: clean stuffs --- src/app/lib/test/filter-columns.test.ts | 170 +++++++++++++----------- src/lib/test/data-table-filters.test.ts | 98 ++++++++------ 2 files changed, 146 insertions(+), 122 deletions(-) diff --git a/src/app/lib/test/filter-columns.test.ts b/src/app/lib/test/filter-columns.test.ts index 627a6eb4..a0ef3d4f 100644 --- a/src/app/lib/test/filter-columns.test.ts +++ b/src/app/lib/test/filter-columns.test.ts @@ -1,6 +1,6 @@ import { addDays, endOfDay, startOfDay } from "date-fns"; import { PgDialect } from "drizzle-orm/pg-core"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ColumnFilterItem, JoinOperator } from "@/lib/data-table-types"; @@ -324,90 +324,98 @@ describe("filterColumns", () => { }); it("counts days, weeks, and months from today", () => { - const today = new Date(); + // One frozen instant, so this date and the Date inside filterColumns + // stay on the same day. + vi.useFakeTimers({ now: new Date(2026, 5, 15, 12), toFake: ["Date"] }); - const tomorrow = startOfDay(addDays(today, 1)); - expect( - compile([ - columnFilter({ - id: "createdAt", - variant: "date", - operator: "isRelativeToToday", - value: "1 days", - }), - ])?.params, - ).toEqual([tomorrow.toISOString(), endOfDay(tomorrow).toISOString()]); + try { + const today = new Date(); - const nextWeek = startOfDay(addDays(today, 7)); - expect( - compile([ - columnFilter({ - id: "createdAt", - variant: "dateRange", - operator: "isRelativeToToday", - value: "1 weeks", - }), - ])?.params, - ).toEqual([ - nextWeek.toISOString(), - endOfDay(addDays(nextWeek, 6)).toISOString(), - ]); + const tomorrow = startOfDay(addDays(today, 1)); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isRelativeToToday", + value: "1 days", + }), + ])?.params, + ).toEqual([tomorrow.toISOString(), endOfDay(tomorrow).toISOString()]); - const nextMonth = startOfDay(addDays(today, 30)); - expect( - compile([ - columnFilter({ - id: "createdAt", - variant: "date", - operator: "isRelativeToToday", - value: "1 months", - }), - ])?.params, - ).toEqual([ - nextMonth.toISOString(), - endOfDay(addDays(nextMonth, 29)).toISOString(), - ]); + const nextWeek = startOfDay(addDays(today, 7)); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "dateRange", + operator: "isRelativeToToday", + value: "1 weeks", + }), + ])?.params, + ).toEqual([ + nextWeek.toISOString(), + endOfDay(addDays(nextWeek, 6)).toISOString(), + ]); - expect( - compile([ - columnFilter({ - id: "createdAt", - variant: "date", - operator: "isRelativeToToday", - value: "1", - }), - ]), - ).toBeUndefined(); - expect( - compile([ - columnFilter({ - id: "createdAt", - variant: "date", - operator: "isRelativeToToday", - value: "1 years", - }), - ]), - ).toBeUndefined(); - expect( - compile([ - columnFilter({ - id: "createdAt", - variant: "number", - operator: "isRelativeToToday", - value: "1 days", - }), - ]), - ).toBeUndefined(); - expect( - compile([ - columnFilter({ - id: "estimatedHours", - variant: "number", - operator: "isRelativeToToday", - value: ["1 days"], - }), - ]), - ).toBeUndefined(); + const nextMonth = startOfDay(addDays(today, 30)); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isRelativeToToday", + value: "1 months", + }), + ])?.params, + ).toEqual([ + nextMonth.toISOString(), + endOfDay(addDays(nextMonth, 29)).toISOString(), + ]); + + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isRelativeToToday", + value: "1", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "date", + operator: "isRelativeToToday", + value: "1 years", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "createdAt", + variant: "number", + operator: "isRelativeToToday", + value: "1 days", + }), + ]), + ).toBeUndefined(); + expect( + compile([ + columnFilter({ + id: "estimatedHours", + variant: "number", + operator: "isRelativeToToday", + value: ["1 days"], + }), + ]), + ).toBeUndefined(); + } finally { + vi.useRealTimers(); + } }); it("matches empty and non-empty values", () => { diff --git a/src/lib/test/data-table-filters.test.ts b/src/lib/test/data-table-filters.test.ts index 7d05821e..8b589a56 100644 --- a/src/lib/test/data-table-filters.test.ts +++ b/src/lib/test/data-table-filters.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { FilterOperator, FilterVariant } from "@/lib/data-table-types"; @@ -77,48 +77,64 @@ describe("matchesFilter", () => { }); it("matches a day, week, or month counted from today", () => { - const dayMs = 24 * 60 * 60 * 1000; - const now = Date.now(); + // June 15 sits clear of daylight-saving transitions, so each 24h + // step is a different calendar day. + vi.useFakeTimers({ now: new Date(2026, 5, 15, 12), toFake: ["Date"] }); - function atNoon(dayOffset: number) { - const date = new Date(now + dayOffset * dayMs); - date.setHours(12, 0, 0, 0); - return date.getTime(); - } + try { + const dayMs = 24 * 60 * 60 * 1000; + const now = Date.now(); - expect(match(atNoon(0), "isRelativeToToday", "date", "0 days")).toBe(true); - expect(match(atNoon(1), "isRelativeToToday", "date", "0 days")).toBe(false); - expect(match(atNoon(-1), "isRelativeToToday", "date", "-1 days")).toBe( - true, - ); - expect(match(atNoon(7), "isRelativeToToday", "date", "1 weeks")).toBe(true); - expect(match(atNoon(6), "isRelativeToToday", "date", "1 weeks")).toBe( - false, - ); - expect(match(atNoon(13), "isRelativeToToday", "date", "1 weeks")).toBe( - true, - ); - expect(match(atNoon(14), "isRelativeToToday", "date", "1 weeks")).toBe( - false, - ); - expect( - match(atNoon(30), "isRelativeToToday", "dateRange", "1 months"), - ).toBe(true); - expect(match(atNoon(29), "isRelativeToToday", "date", "1 months")).toBe( - false, - ); - expect(match(atNoon(59), "isRelativeToToday", "date", "1 months")).toBe( - true, - ); - expect(match(atNoon(60), "isRelativeToToday", "date", "1 months")).toBe( - false, - ); - expect(match("nope", "isRelativeToToday", "date", "0 days")).toBe(false); - expect(match(atNoon(0), "isRelativeToToday", "date", ["0 days"])).toBe( - true, - ); - expect(match(atNoon(0), "isRelativeToToday", "date", "days")).toBe(true); - expect(match(atNoon(0), "isRelativeToToday", "date", "1 years")).toBe(true); + function atNoon(dayOffset: number) { + const date = new Date(now + dayOffset * dayMs); + date.setHours(12, 0, 0, 0); + return date.getTime(); + } + + expect(match(atNoon(0), "isRelativeToToday", "date", "0 days")).toBe( + true, + ); + expect(match(atNoon(1), "isRelativeToToday", "date", "0 days")).toBe( + false, + ); + expect(match(atNoon(-1), "isRelativeToToday", "date", "-1 days")).toBe( + true, + ); + expect(match(atNoon(7), "isRelativeToToday", "date", "1 weeks")).toBe( + true, + ); + expect(match(atNoon(6), "isRelativeToToday", "date", "1 weeks")).toBe( + false, + ); + expect(match(atNoon(13), "isRelativeToToday", "date", "1 weeks")).toBe( + true, + ); + expect(match(atNoon(14), "isRelativeToToday", "date", "1 weeks")).toBe( + false, + ); + expect( + match(atNoon(30), "isRelativeToToday", "dateRange", "1 months"), + ).toBe(true); + expect(match(atNoon(29), "isRelativeToToday", "date", "1 months")).toBe( + false, + ); + expect(match(atNoon(59), "isRelativeToToday", "date", "1 months")).toBe( + true, + ); + expect(match(atNoon(60), "isRelativeToToday", "date", "1 months")).toBe( + false, + ); + expect(match("nope", "isRelativeToToday", "date", "0 days")).toBe(false); + expect(match(atNoon(0), "isRelativeToToday", "date", ["0 days"])).toBe( + true, + ); + expect(match(atNoon(0), "isRelativeToToday", "date", "days")).toBe(true); + expect(match(atNoon(0), "isRelativeToToday", "date", "1 years")).toBe( + true, + ); + } finally { + vi.useRealTimers(); + } }); it("treats null, an empty string, and an empty list as empty", () => { From 756af3781c6326b7e31a15ea35b7c0fdf6cd9fef Mon Sep 17 00:00:00 2001 From: sadman <68962712+sadmann7@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:12:14 +0600 Subject: [PATCH 3/4] refactor: refactor tests --- src/lib/test/data-table-filters.test.ts | 29 ++++++ src/lib/test/data-table-utils.test.ts | 6 ++ src/lib/test/data-table.test.ts | 122 ++++++------------------ 3 files changed, 62 insertions(+), 95 deletions(-) diff --git a/src/lib/test/data-table-filters.test.ts b/src/lib/test/data-table-filters.test.ts index 8b589a56..5bfd5494 100644 --- a/src/lib/test/data-table-filters.test.ts +++ b/src/lib/test/data-table-filters.test.ts @@ -74,6 +74,35 @@ describe("matchesFilter", () => { match(later, "isBetween", "dateRange", ["2026-10-01", "2026-10-07"]), ).toBe(false); expect(match(day, "isBetween", "date", ["", "2026-10-03"])).toBe(true); + expect(match(3, "isBetween", "range", ["", "3"])).toBe(true); + expect(match(4, "isBetween", "range", ["", "3"])).toBe(false); + expect(match(2, "isBetween", "range", ["2", ""])).toBe(true); + expect(match(1, "isBetween", "range", ["2", ""])).toBe(false); + }); + + it("treats a calendar date as the local day", () => { + const morning = new Date(2026, 9, 3, 9, 30).getTime(); + const nextDay = new Date(2026, 9, 4, 0, 30).getTime(); + + expect(match(morning, "eq", "date", "2026-10-03")).toBe(true); + expect(match(nextDay, "eq", "date", "2026-10-03")).toBe(false); + }); + + it("excludes the day itself from strict date comparisons", () => { + const dayBefore = new Date(2026, 9, 2, 12).getTime(); + const sameDay = new Date(2026, 9, 3, 12).getTime(); + const dayAfter = new Date(2026, 9, 4, 12).getTime(); + + function getMatches(operator: "lt" | "lte" | "gt" | "gte") { + return [dayBefore, sameDay, dayAfter].map((time) => + match(time, operator, "date", "2026-10-03"), + ); + } + + expect(getMatches("lt")).toEqual([true, false, false]); + expect(getMatches("lte")).toEqual([true, true, false]); + expect(getMatches("gt")).toEqual([false, false, true]); + expect(getMatches("gte")).toEqual([false, true, true]); }); it("matches a day, week, or month counted from today", () => { diff --git a/src/lib/test/data-table-utils.test.ts b/src/lib/test/data-table-utils.test.ts index dc24d8ef..d85e063d 100644 --- a/src/lib/test/data-table-utils.test.ts +++ b/src/lib/test/data-table-utils.test.ts @@ -94,6 +94,12 @@ describe("filter dates", () => { expect(parseFilterDate("2026-10-03T15:00:00.000Z")?.toISOString()).toBe( "2026-10-03T15:00:00.000Z", ); + expect(parseFilterDate("2026-10-03")?.toDateString()).toBe( + new Date(2026, 9, 3).toDateString(), + ); + expect(parseFilterDate("2026-13-01")).toBeUndefined(); + expect(parseFilterDate("2026-00-31")).toBeUndefined(); + expect(parseFilterDate("2026-02-30")).toBeUndefined(); }); it("formats a calendar day and labels a range", () => { diff --git a/src/lib/test/data-table.test.ts b/src/lib/test/data-table.test.ts index 007ba08a..e8a16faf 100644 --- a/src/lib/test/data-table.test.ts +++ b/src/lib/test/data-table.test.ts @@ -2,8 +2,6 @@ import { createLoader } from "nuqs/server"; import { describe, expect, it } from "vitest"; import { tasksColumnConfigs, tasksDefaultSorting } from "@/app/lib/validations"; -import { matchesFilter } from "@/lib/data-table-filters"; -import { parseFilterDate } from "@/lib/data-table-utils"; import { getColumnFilters, getColumnFiltersKey, @@ -131,6 +129,31 @@ describe("parseColumnFilter", () => { parseColumnFilter("createdAt", "dateRange", "lte.2026-06-30"), ).toMatchObject({ operator: "lte", value: "2026-06-30" }); }); + + it("parses quoted quotes, empty list items, and a single between bound", () => { + expect( + parseColumnFilter("status", "multiSelect", '"say ""hi""",done'), + ).toMatchObject({ value: ['say "hi"', "done"] }); + expect( + parseColumnFilter("status", "multiSelect", "todo,,done"), + ).toMatchObject({ value: ["todo", "done"] }); + expect( + parseColumnFilter("estimatedHours", "range", "between.5"), + ).toMatchObject({ operator: "isBetween", value: ["5", ""] }); + }); + + it("drops filters without a value when parsing", () => { + const parser = getColumnFilterParser("title", "text"); + + expect( + parser + .parse(["ilike.", "the", "is.empty"]) + ?.map((filter) => [filter.operator, filter.value]), + ).toEqual([ + ["iLike", "the"], + ["isEmpty", ""], + ]); + }); }); describe("serializeColumnFilter", () => { @@ -180,31 +203,6 @@ describe("serializeColumnFilter", () => { ]), ).toEqual(["gte.2", "lte.8"]); }); - - it("parses quoted quotes, empty list items, and a single between bound", () => { - expect( - parseColumnFilter("status", "multiSelect", '"say ""hi""",done'), - ).toMatchObject({ value: ['say "hi"', "done"] }); - expect( - parseColumnFilter("status", "multiSelect", "todo,,done"), - ).toMatchObject({ value: ["todo", "done"] }); - expect( - parseColumnFilter("estimatedHours", "range", "between.5"), - ).toMatchObject({ operator: "isBetween", value: ["5", ""] }); - }); - - it("drops filters without a value when parsing", () => { - const parser = getColumnFilterParser("title", "text"); - - expect( - parser - .parse(["ilike.", "the", "is.empty"]) - ?.map((filter) => [filter.operator, filter.value]), - ).toEqual([ - ["iLike", "the"], - ["isEmpty", ""], - ]); - }); }); describe("getDataTableQuery", () => { @@ -236,7 +234,9 @@ describe("getDataTableQuery", () => { expect(query.joinOperator).toBe("and"); expect(loadSearch("?joinOperator=xor").joinOperator).toBe("and"); }); +}); +describe("getDataTableSearchParams", () => { it("defaults page, page size, and sorting", () => { const search = loadSearch("?page=no"); @@ -353,71 +353,3 @@ describe("getSortingStateParser", () => { expect(parser.eq(sorting, [])).toBe(false); }); }); - -describe("matchesFilter", () => { - it("treats a calendar date as the local day", () => { - const morning = new Date(2026, 9, 3, 9, 30).getTime(); - const nextDay = new Date(2026, 9, 4, 0, 30).getTime(); - - expect( - matchesFilter(morning, { - operator: "eq", - variant: "date", - value: "2026-10-03", - }), - ).toBe(true); - expect( - matchesFilter(nextDay, { - operator: "eq", - variant: "date", - value: "2026-10-03", - }), - ).toBe(false); - }); - - it("excludes the day itself from strict date comparisons", () => { - const dayBefore = new Date(2026, 9, 2, 12).getTime(); - const sameDay = new Date(2026, 9, 3, 12).getTime(); - const dayAfter = new Date(2026, 9, 4, 12).getTime(); - - function getMatches(operator: "lt" | "lte" | "gt" | "gte") { - return [dayBefore, sameDay, dayAfter].map((time) => - matchesFilter(time, { operator, variant: "date", value: "2026-10-03" }), - ); - } - - expect(getMatches("lt")).toEqual([true, false, false]); - expect(getMatches("lte")).toEqual([true, true, false]); - expect(getMatches("gt")).toEqual([false, false, true]); - expect(getMatches("gte")).toEqual([false, true, true]); - }); - - it("treats a missing number bound as open", () => { - const atMostThree = { - operator: "isBetween" as const, - variant: "range" as const, - value: ["", "3"], - }; - const atLeastTwo = { - operator: "isBetween" as const, - variant: "range" as const, - value: ["2", ""], - }; - - expect(matchesFilter(3, atMostThree)).toBe(true); - expect(matchesFilter(4, atMostThree)).toBe(false); - expect(matchesFilter(2, atLeastTwo)).toBe(true); - expect(matchesFilter(1, atLeastTwo)).toBe(false); - }); -}); - -describe("parseFilterDate", () => { - it("rejects calendar dates that roll over", () => { - expect(parseFilterDate("2026-10-03")?.toDateString()).toBe( - new Date(2026, 9, 3).toDateString(), - ); - expect(parseFilterDate("2026-13-01")).toBeUndefined(); - expect(parseFilterDate("2026-00-31")).toBeUndefined(); - expect(parseFilterDate("2026-02-30")).toBeUndefined(); - }); -}); From 52ff666487914c048fdf7429d3cf43f425eec8f0 Mon Sep 17 00:00:00 2001 From: sadman <68962712+sadmann7@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:14:01 +0600 Subject: [PATCH 4/4] refactor: refactor parsers --- registry.json | 2 +- src/app/components/tasks-table-columns.tsx | 2 +- src/app/components/test/tasks-table-columns.test.ts | 5 ++++- src/app/lib/validations.ts | 2 +- src/app/page.tsx | 2 +- src/lib/{parsers.ts => data-table-parsers.ts} | 0 ...data-table.test.ts => data-table-parsers.test.ts} | 2 +- src/registry/bases/base/hooks/use-data-table.ts | 12 ++++++------ src/registry/bases/radix/hooks/use-data-table.ts | 12 ++++++------ 9 files changed, 21 insertions(+), 18 deletions(-) rename src/lib/{parsers.ts => data-table-parsers.ts} (100%) rename src/lib/test/{data-table.test.ts => data-table-parsers.test.ts} (99%) diff --git a/registry.json b/registry.json index 6332edaa..73d54eba 100644 --- a/registry.json +++ b/registry.json @@ -92,7 +92,7 @@ "type": "registry:lib" }, { - "path": "src/lib/parsers.ts", + "path": "src/lib/data-table-parsers.ts", "type": "registry:lib" }, { diff --git a/src/app/components/tasks-table-columns.tsx b/src/app/components/tasks-table-columns.tsx index dac7ee52..92672deb 100644 --- a/src/app/components/tasks-table-columns.tsx +++ b/src/app/components/tasks-table-columns.tsx @@ -16,9 +16,9 @@ import type { DataTableFeatures } from "@/lib/data-table-features"; import type { DataTableRowAction } from "@/lib/data-table-types"; import { type Task, tasks } from "@/db/schema"; +import { getColumnOptions } from "@/lib/data-table-parsers"; import { getErrorMessage } from "@/lib/error"; import { formatDate } from "@/lib/format"; -import { getColumnOptions } from "@/lib/parsers"; import { DataTableColumnHeader } from "@/registry/bases/radix/components/data-table/data-table-column-header"; import { getDataTableSelectColumn } from "@/registry/bases/radix/components/data-table/data-table-select-column"; import { Badge } from "@/registry/bases/radix/ui/badge"; diff --git a/src/app/components/test/tasks-table-columns.test.ts b/src/app/components/test/tasks-table-columns.test.ts index 851e862d..b26d3812 100644 --- a/src/app/components/test/tasks-table-columns.test.ts +++ b/src/app/components/test/tasks-table-columns.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { getTasksTableColumns } from "@/app/components/tasks-table-columns"; import { tasksColumnConfigs } from "@/app/lib/validations"; -import { getFilterableColumns, getSortableColumns } from "@/lib/parsers"; +import { + getFilterableColumns, + getSortableColumns, +} from "@/lib/data-table-parsers"; vi.mock("@/app/lib/actions", () => ({ updateTask: vi.fn() })); diff --git a/src/app/lib/validations.ts b/src/app/lib/validations.ts index 3b83962e..6286f022 100644 --- a/src/app/lib/validations.ts +++ b/src/app/lib/validations.ts @@ -8,8 +8,8 @@ import type { } from "@/lib/data-table-types"; import { type Task, tasks } from "@/db/schema"; +import { getDataTableSearchParams } from "@/lib/data-table-parsers"; import { DATA_MODES, FILTER_MODES } from "@/lib/flag"; -import { getDataTableSearchParams } from "@/lib/parsers"; export const tasksColumnConfigs = { code: { isSortable: false }, diff --git a/src/app/page.tsx b/src/app/page.tsx index 013dd93d..211a5041 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import type { SearchParams } from "@/types"; -import { getDataTableQuery } from "@/lib/parsers"; +import { getDataTableQuery } from "@/lib/data-table-parsers"; import { DataTableSkeleton } from "@/registry/bases/radix/components/data-table/data-table-skeleton"; import { TasksTable } from "./components/tasks-table"; diff --git a/src/lib/parsers.ts b/src/lib/data-table-parsers.ts similarity index 100% rename from src/lib/parsers.ts rename to src/lib/data-table-parsers.ts diff --git a/src/lib/test/data-table.test.ts b/src/lib/test/data-table-parsers.test.ts similarity index 99% rename from src/lib/test/data-table.test.ts rename to src/lib/test/data-table-parsers.test.ts index e8a16faf..82723290 100644 --- a/src/lib/test/data-table.test.ts +++ b/src/lib/test/data-table-parsers.test.ts @@ -15,7 +15,7 @@ import { parseColumnFilter, serializeColumnFilter, sortColumnFiltersBySearch, -} from "@/lib/parsers"; +} from "@/lib/data-table-parsers"; const loadSearch = createLoader( getDataTableSearchParams({ diff --git a/src/registry/bases/base/hooks/use-data-table.ts b/src/registry/bases/base/hooks/use-data-table.ts index 5b093986..154405cb 100644 --- a/src/registry/bases/base/hooks/use-data-table.ts +++ b/src/registry/bases/base/hooks/use-data-table.ts @@ -29,18 +29,18 @@ import { type DataTableFeatures, dataTableFeatures, } from "@/lib/data-table-features"; -import { - getActiveFilters, - JOIN_OPERATORS, - normalizeColumnFilter, -} from "@/lib/data-table-utils"; import { getColumnFilterParser, getColumnFilters, getColumnFiltersKey, getSortingStateParser, sortColumnFiltersBySearch, -} from "@/lib/parsers"; +} from "@/lib/data-table-parsers"; +import { + getActiveFilters, + JOIN_OPERATORS, + normalizeColumnFilter, +} from "@/lib/data-table-utils"; const PAGE_KEY = "page"; const PER_PAGE_KEY = "perPage"; diff --git a/src/registry/bases/radix/hooks/use-data-table.ts b/src/registry/bases/radix/hooks/use-data-table.ts index 5b093986..154405cb 100644 --- a/src/registry/bases/radix/hooks/use-data-table.ts +++ b/src/registry/bases/radix/hooks/use-data-table.ts @@ -29,18 +29,18 @@ import { type DataTableFeatures, dataTableFeatures, } from "@/lib/data-table-features"; -import { - getActiveFilters, - JOIN_OPERATORS, - normalizeColumnFilter, -} from "@/lib/data-table-utils"; import { getColumnFilterParser, getColumnFilters, getColumnFiltersKey, getSortingStateParser, sortColumnFiltersBySearch, -} from "@/lib/parsers"; +} from "@/lib/data-table-parsers"; +import { + getActiveFilters, + JOIN_OPERATORS, + normalizeColumnFilter, +} from "@/lib/data-table-utils"; const PAGE_KEY = "page"; const PER_PAGE_KEY = "perPage";