From fdf14aa13f9b3b33fce049526784b9c2bb39c481 Mon Sep 17 00:00:00 2001 From: Peter Young <20213436+petera2c@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:21:43 -0500 Subject: [PATCH 1/4] Add a TDGP helper so the table can load pages, sorts, and filters from a protocol server. Co-authored-by: Cursor --- apps/marketing/src/app/docs/tdgp/page.tsx | 37 +++ .../pages/docs-pages/TdgpContent.tsx | 206 +++++++++++++++ apps/marketing/src/constants/changelog.ts | 6 + .../marketing/src/constants/docsNavigation.ts | 7 + .../src/constants/docsSearchIndex.json | 31 +++ apps/marketing/src/constants/docsSeo.ts | 1 + apps/marketing/src/constants/docsSnippets.ts | 110 ++++++++ apps/marketing/src/constants/strings/seo.ts | 7 + .../core/src/__tests__/tdgpFilter.test.ts | 82 ++++++ .../src/__tests__/tdgpTableSource.test.ts | 217 ++++++++++++++++ packages/core/src/index.ts | 30 +++ .../core/src/tdgp/createTdgpTableSource.ts | 235 ++++++++++++++++++ packages/core/src/tdgp/index.ts | 29 +++ packages/core/src/tdgp/mapGroupResponse.ts | 37 +++ packages/core/src/tdgp/setNestedChildren.ts | 34 +++ .../core/src/tdgp/sortColumnToTdgpSort.ts | 8 + .../core/src/tdgp/tableFiltersToTdgpFilter.ts | 92 +++++++ packages/core/src/tdgp/types.ts | 145 +++++++++++ packages/react/src/index.ts | 17 ++ packages/react/src/tdgp/useTdgpTable.ts | 36 +++ packages/react/vitest.config.ts | 2 + 21 files changed, 1369 insertions(+) create mode 100644 apps/marketing/src/app/docs/tdgp/page.tsx create mode 100644 apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx create mode 100644 packages/core/src/__tests__/tdgpFilter.test.ts create mode 100644 packages/core/src/__tests__/tdgpTableSource.test.ts create mode 100644 packages/core/src/tdgp/createTdgpTableSource.ts create mode 100644 packages/core/src/tdgp/index.ts create mode 100644 packages/core/src/tdgp/mapGroupResponse.ts create mode 100644 packages/core/src/tdgp/setNestedChildren.ts create mode 100644 packages/core/src/tdgp/sortColumnToTdgpSort.ts create mode 100644 packages/core/src/tdgp/tableFiltersToTdgpFilter.ts create mode 100644 packages/core/src/tdgp/types.ts create mode 100644 packages/react/src/tdgp/useTdgpTable.ts diff --git a/apps/marketing/src/app/docs/tdgp/page.tsx b/apps/marketing/src/app/docs/tdgp/page.tsx new file mode 100644 index 000000000..fa4b281d4 --- /dev/null +++ b/apps/marketing/src/app/docs/tdgp/page.tsx @@ -0,0 +1,37 @@ +import { Metadata } from "next"; +import { SEO_STRINGS } from "@/constants/strings/seo"; +import TdgpContent from "@/components/pages/docs-pages/TdgpContent"; +import DocsDemoCode from "@/components/DocsDemoCode"; + +export const metadata: Metadata = { + title: SEO_STRINGS.tdgp.title, + description: SEO_STRINGS.tdgp.description, + keywords: SEO_STRINGS.tdgp.keywords, + openGraph: { + title: SEO_STRINGS.tdgp.title, + description: SEO_STRINGS.tdgp.description, + type: "article", + images: [SEO_STRINGS.site.ogImage], + siteName: SEO_STRINGS.site.name, + }, + twitter: { + card: "summary_large_image", + title: SEO_STRINGS.tdgp.title, + description: SEO_STRINGS.tdgp.description, + creator: SEO_STRINGS.site.creator, + images: SEO_STRINGS.site.ogImage.url, + }, + alternates: { + canonical: "/docs/tdgp", + }, +}; + +const TdgpPage = () => { + return ( + + + + ); +}; + +export default TdgpPage; diff --git a/apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx b/apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx new file mode 100644 index 000000000..89719e25a --- /dev/null +++ b/apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx @@ -0,0 +1,206 @@ +"use client"; + +import type { ReactNode } from "react"; +import Link from "next/link"; +import { motion } from "framer-motion"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlug } from "@fortawesome/free-solid-svg-icons"; +import DocNavigationButtons from "@/components/DocNavigationButtons"; +import PageWrapper from "@/components/PageWrapper"; +import CodeBlock from "@/components/CodeBlock"; +import PropTable, { type PropInfo } from "@/components/PropTable"; +import { tdgpSnippets, type CodeByFramework } from "@/constants/docsSnippets"; + +type TdgpPattern = { + title: string; + body: ReactNode; + codeByFramework: CodeByFramework; +}; + +const TDGP_PATTERNS: TdgpPattern[] = [ + { + title: "Connect to a TDGP server", + body: ( + <> + Install{" "} + @thedatagrid/client{" "} + next to Simple Table. In React, call{" "} + useTdgpTable and + spread{" "} + tableProps onto + the table. Other frameworks use{" "} + + createTdgpTableSource + {" "} + from{" "} + simple-table-core. + Page changes, sorts, and column filters become server requests. Pair with{" "} + + isLoading + {" "} + (already set on{" "} + tableProps). + + ), + codeByFramework: tdgpSnippets(), + }, +]; + +const TDGP_PROPS: PropInfo[] = [ + { + key: "client", + name: "client", + required: true, + description: + "A TDGP client with a query method. createTdgpClient() from @thedatagrid/client matches this.", + type: "TdgpQueryClient", + example: `client={createTdgpClient({ url: "https://data.thedatagrid.com" })}`, + }, + { + key: "dataset", + name: "dataset", + required: true, + description: "Dataset name on the TDGP server (the route segment, not a URL).", + type: "string", + example: `dataset="developers-10k"`, + }, + { + key: "columns", + name: "columns", + required: true, + description: "Column definitions for the table. Keep this array stable across renders.", + type: "ColumnDef[]", + example: `columns={columns}`, + }, + { + key: "pageSize", + name: "pageSize", + required: false, + description: "Rows per page sent to the server. Defaults to 50.", + type: "number", + example: `pageSize={50}`, + }, + { + key: "primaryKey", + name: "primaryKey", + required: false, + description: "Field used as the row id for leaf rows. Defaults to id.", + type: "string", + example: `primaryKey="id"`, + }, + { + key: "groupBy", + name: "groupBy", + required: false, + description: + "Group on the server by these fields. Expanding a group loads the next level (or leaf rows at the last level).", + type: "string[]", + example: `groupBy={["country", "stack"]}`, + }, + { + key: "aggregations", + name: "aggregations", + required: false, + description: + "Server aggregations for grouped rows (sum, avg, min, max, count). Values are copied onto the group row using each aggregation id.", + type: "TdgpAggregation[]", + example: `aggregations={[{ id: "salary", field: "salary", fn: "sum" }]}`, + }, +]; + +const TdgpContent = () => { + return ( + + +
+ +
+

Server Data (TDGP)

+
+ + + THE DataGrid Protocol (TDGP) is a shared JSON contract for asking a server for a page of + rows — filtered, sorted, and optionally grouped. Simple Table maps its existing{" "} + + server-side pagination + + , sort, and filter hooks onto that contract. The same public server that powers AG Grid and + Infinite Table demos works here:{" "} + + data.thedatagrid.com + + . + + + + {TDGP_PATTERNS.map((pattern) => ( +
+

+ {pattern.title} +

+

{pattern.body}

+ +
+ ))} +
+ + +

Grouping

+

+ Pass{" "} + groupBy to load + group rows first. Expanding a group fetches the next level from the server. Pivot stays + client-side — use{" "} + + Pivot Tables + {" "} + on rows you already have. +

+
+ + + Options + + + + + +
+ ); +}; + +export default TdgpContent; diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index 8cbb80282..0d618bdb3 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -41,6 +41,12 @@ export const v4_1_8: ChangelogEntry = { description: "Search boxes and other inputs inside the table now use the theme text color instead of staying black.", }, + { + type: "feature", + description: + "Connect the table to a THE DataGrid Protocol (TDGP) server. Page changes, sorts, and filters load from the server — and you can group on the server too.", + link: "/docs/tdgp", + }, ], }; diff --git a/apps/marketing/src/constants/docsNavigation.ts b/apps/marketing/src/constants/docsNavigation.ts index fb54594e6..492a8b8a4 100644 --- a/apps/marketing/src/constants/docsNavigation.ts +++ b/apps/marketing/src/constants/docsNavigation.ts @@ -39,6 +39,7 @@ import { faUpDown, faGear, faWandMagicSparkles, + faPlug, } from "@fortawesome/free-solid-svg-icons"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; @@ -235,6 +236,12 @@ export const docSections: DocSection[] = [ icon: faCode, }, { id: "pagination", label: "Pagination", path: "/docs/pagination", icon: faPager }, + { + id: "tdgp", + label: "Server Data (TDGP)", + path: "/docs/tdgp", + icon: faPlug, + }, { id: "loading-state", label: "Loading State", path: "/docs/loading-state", icon: faSpinner }, { id: "empty-state", label: "Empty State", path: "/docs/empty-state", icon: faInbox }, { id: "live-updates", label: "Live Updates", path: "/docs/live-updates", icon: faBolt }, diff --git a/apps/marketing/src/constants/docsSearchIndex.json b/apps/marketing/src/constants/docsSearchIndex.json index d6c1da500..5f4254124 100644 --- a/apps/marketing/src/constants/docsSearchIndex.json +++ b/apps/marketing/src/constants/docsSearchIndex.json @@ -1341,6 +1341,37 @@ "External / window scroll" ] }, + { + "id": "tdgp", + "path": "/docs/tdgp", + "title": "Connect Simple Table to a TDGP Server", + "description": "Load pages, sorts, and filters from a THE DataGrid Protocol (TDGP) server. Use useTdgpTable in React or createTdgpTableSource in other frameworks — no custom backend required.", + "keywords": [ + "client", + "dataset", + "columns", + "pageSize", + "primaryKey", + "groupBy", + "aggregations", + "Server Data (TDGP)", + "Grouping", + "Connect to a TDGP server", + "Server", + "Data", + "(TDGP)", + "Connect", + "TDGP", + "server" + ], + "content": "THE DataGrid Protocol (TDGP) is a shared JSON contract for asking a server for a page of rows — filtered, sorted, and optionally grouped. Simple Table maps its existing server-side pagination , sort, and filter hooks onto that contract. The same public server that powers AG Grid and Infinite Table demos works here: data.thedatagrid.com . Pass groupBy to load group rows first. Expanding a group fetches the next level from the server. Pivot stays client-side — use Pivot Tables on rows you already have. @thedatagrid/client useTdgpTable tableProps createTdgpTableSource simple-table-core groupBy Install and spread onto the table. Other frameworks use . Page changes, sorts, and column filters become server requests. Pair with isLoading Server Data (TDGP) THE DataGrid Protocol (TDGP) is a shared JSON contract for asking a server for a page of rows — filtered, sorted, and optionally grouped. Simple Table maps its existing server-side pagination , sort, and filter hooks onto that contract. The same public server that powers AG Grid and Infinite Table demos works here: data.thedatagrid.com {TDGP_PATTERNS.map((pattern) => ( Grouping Pass to load group rows first. Expanding a group fetches the next level from the server. Pivot stays client-side — use Pivot Tables Options client dataset columns pageSize primaryKey aggregations A TDGP client with a query method. createTdgpClient() from @thedatagrid/client matches this. Dataset name on the TDGP server (the route segment, not a URL). Column definitions for the table. Keep this array stable across renders. Rows per page sent to the server. Defaults to 50. Field used as the row id for leaf rows. Defaults to id. Group on the server by these fields. Expanding a group loads the next level (or leaf rows at the last level). Server aggregations for grouped rows (sum, avg, min, max, count). Values are copied onto the group row using each aggregation id. client={createTdgpClient({ url: \"https://data.thedatagrid.com\" })} dataset=\"developers-10k\" columns={columns} pageSize={50} primaryKey=\"id\" groupBy={[\"country\", \"stack\"]} aggregations={[{ id: \"salary\", field: \"salary\", fn: \"sum\" }]}", + "section": "Advanced Features", + "headings": [ + "Server Data (TDGP)", + "Grouping", + "Connect to a TDGP server" + ] + }, { "id": "themes", "path": "/docs/themes", diff --git a/apps/marketing/src/constants/docsSeo.ts b/apps/marketing/src/constants/docsSeo.ts index 90f698cba..7ab395498 100644 --- a/apps/marketing/src/constants/docsSeo.ts +++ b/apps/marketing/src/constants/docsSeo.ts @@ -43,6 +43,7 @@ export const DOC_SLUG_TO_SEO_KEY: Record = { "row-height": "rowHeight", "row-selection": "rowSelection", "table-height": "tableHeight", + tdgp: "tdgp", themes: "themes", tooltips: "tooltips", "value-formatter": "valueFormatter", diff --git a/apps/marketing/src/constants/docsSnippets.ts b/apps/marketing/src/constants/docsSnippets.ts index 575097c12..2db73f027 100644 --- a/apps/marketing/src/constants/docsSnippets.ts +++ b/apps/marketing/src/constants/docsSnippets.ts @@ -3078,3 +3078,113 @@ new SimpleTableVanilla(container, { });`, }; } + +export function tdgpSnippets(): Record { + return { + react: `import { SimpleTable, useTdgpTable } from "@simple-table/react"; +import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/react/styles.css"; + +const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); + +const columns = [ + { accessor: "firstName", label: "First name", width: 140, type: "string", filterable: true }, + { accessor: "country", label: "Country", width: 140, type: "string", filterable: true }, + { accessor: "salary", label: "Salary", width: 120, type: "number", filterable: true }, +]; + +function App() { + const { rows, tableProps } = useTdgpTable({ + client, + dataset: "developers-10k", + columns, + pageSize: 50, + }); + + return ; +}`, + vue: `import { SimpleTable } from "@simple-table/vue"; +import { createTdgpTableSource } from "simple-table-core"; +import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/vue/styles.css"; + +const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); +const source = createTdgpTableSource({ + client, + dataset: "developers-10k", + columns, + pageSize: 50, +}); +source.start(); + +// Subscribe and pass source.getSnapshot().rows / tableProps into SimpleTable.`, + angular: `import { createTdgpTableSource } from "simple-table-core"; +import { createTdgpClient } from "@thedatagrid/client"; + +const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); +const source = createTdgpTableSource({ + client, + dataset: "developers-10k", + columns, + pageSize: 50, +}); +source.start(); + +// Subscribe and pass source.getSnapshot().rows / tableProps into SimpleTable.`, + svelte: `import { SimpleTable } from "@simple-table/svelte"; +import { createTdgpTableSource } from "simple-table-core"; +import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/svelte/styles.css"; + +const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); +const source = createTdgpTableSource({ + client, + dataset: "developers-10k", + columns, + pageSize: 50, +}); +source.start(); + +// Subscribe and pass source.getSnapshot().rows / tableProps into SimpleTable.`, + solid: `import { SimpleTable } from "@simple-table/solid"; +import { createTdgpTableSource } from "simple-table-core"; +import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/solid/styles.css"; + +const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); +const source = createTdgpTableSource({ + client, + dataset: "developers-10k", + columns, + pageSize: 50, +}); +source.start(); + +// Subscribe and pass source.getSnapshot().rows / tableProps into SimpleTable.`, + vanilla: `import { SimpleTableVanilla, createTdgpTableSource } from "simple-table-core"; +import { createTdgpClient } from "@thedatagrid/client"; +import "simple-table-core/styles.css"; + +const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); +const source = createTdgpTableSource({ + client, + dataset: "developers-10k", + columns, + pageSize: 50, +}); + +const table = new SimpleTableVanilla(container, { + columns, + rows: [], + height: "480px", + ...source.getSnapshot().tableProps, +}); +table.mount(); + +source.subscribe(() => { + const { rows, tableProps } = source.getSnapshot(); + table.update({ rows, ...tableProps }); +}); +source.start();`, + }; +} diff --git a/apps/marketing/src/constants/strings/seo.ts b/apps/marketing/src/constants/strings/seo.ts index 86b49f36b..3476c063e 100644 --- a/apps/marketing/src/constants/strings/seo.ts +++ b/apps/marketing/src/constants/strings/seo.ts @@ -1292,6 +1292,13 @@ export const SEO_STRINGS = { "simple-table, data-grid, datagrid, data table, sports analytics, player statistics, sports dashboard, league table, stats table, responsive table, javascript data grid", }, }, + tdgp: { + title: "Connect Simple Table to a TDGP Server", + description: + "Load pages, sorts, and filters from a THE DataGrid Protocol (TDGP) server. Use useTdgpTable in React or createTdgpTableSource in other frameworks — no custom backend required.", + keywords: + "simple-table, data-grid, tdgp, the datagrid protocol, server-side pagination, server-side sorting, server-side filtering, @thedatagrid/client, javascript data grid", + }, liveUpdates: { title: "Live Updates in Simple Table Data Grid", description: diff --git a/packages/core/src/__tests__/tdgpFilter.test.ts b/packages/core/src/__tests__/tdgpFilter.test.ts new file mode 100644 index 000000000..09c1a2889 --- /dev/null +++ b/packages/core/src/__tests__/tdgpFilter.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { tableFiltersToTdgpFilter } from "../tdgp/tableFiltersToTdgpFilter"; +import type { TableFilterState } from "../types/FilterTypes"; + +describe("tableFiltersToTdgpFilter", () => { + it("returns undefined when there are no filters", () => { + expect(tableFiltersToTdgpFilter(undefined)).toBeUndefined(); + expect(tableFiltersToTdgpFilter({})).toBeUndefined(); + }); + + it("maps a number comparison to a single predicate", () => { + const filters: TableFilterState = { + age: { accessor: "age", operator: "greaterThan", value: 30 }, + }; + expect(tableFiltersToTdgpFilter(filters)).toEqual({ + kind: "predicate", + field: "age", + operator: "GT", + args: [30], + }); + }); + + it("wraps notContains in a not node", () => { + const filters: TableFilterState = { + name: { accessor: "name", operator: "notContains", value: "tmp" }, + }; + expect(tableFiltersToTdgpFilter(filters)).toEqual({ + kind: "not", + child: { + kind: "predicate", + field: "name", + operator: "CONTAINS", + args: ["tmp"], + }, + }); + }); + + it("maps between onto BETWEEN args", () => { + const filters: TableFilterState = { + salary: { accessor: "salary", operator: "between", values: [50000, 120000] }, + }; + expect(tableFiltersToTdgpFilter(filters)).toEqual({ + kind: "predicate", + field: "salary", + operator: "BETWEEN", + args: [50000, 120000], + }); + }); + + it("maps isEmpty without args", () => { + const filters: TableFilterState = { + city: { accessor: "city", operator: "isEmpty" }, + }; + expect(tableFiltersToTdgpFilter(filters)).toEqual({ + kind: "predicate", + field: "city", + operator: "IS_BLANK", + }); + }); + + it("skips a contains filter with no value", () => { + const filters: TableFilterState = { + name: { accessor: "name", operator: "contains", value: "" }, + }; + expect(tableFiltersToTdgpFilter(filters)).toBeUndefined(); + }); + + it("combines several column filters with AND", () => { + const filters: TableFilterState = { + country: { accessor: "country", operator: "equals", value: "France" }, + age: { accessor: "age", operator: "greaterThan", value: 30 }, + }; + expect(tableFiltersToTdgpFilter(filters)).toEqual({ + kind: "group", + combinator: "AND", + children: [ + { kind: "predicate", field: "country", operator: "EQ", args: ["France"] }, + { kind: "predicate", field: "age", operator: "GT", args: [30] }, + ], + }); + }); +}); diff --git a/packages/core/src/__tests__/tdgpTableSource.test.ts b/packages/core/src/__tests__/tdgpTableSource.test.ts new file mode 100644 index 000000000..de96cc874 --- /dev/null +++ b/packages/core/src/__tests__/tdgpTableSource.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTdgpTableSource } from "../tdgp/createTdgpTableSource"; +import { setNestedChildren } from "../tdgp/setNestedChildren"; +import { sortColumnToTdgpSort } from "../tdgp/sortColumnToTdgpSort"; +import { + TDGP_CHILDREN_ACCESSOR, + TDGP_GROUP_KEYS, + type TdgpQueryClient, + type TdgpQueryRequest, + type TdgpTableSource, +} from "../tdgp/types"; +import type { ColumnDef } from "../index"; +import type Row from "../types/Row"; +import type SortColumn from "../types/SortColumn"; + +const columns: ColumnDef[] = [ + { accessor: "country", label: "Country", width: 140, type: "string" }, + { accessor: "salary", label: "Salary", width: 120, type: "number" }, + { accessor: "id", label: "ID", width: 80, type: "number" }, +]; + +function waitFor( + source: TdgpTableSource, + predicate: () => boolean, + timeoutMs = 1000, +): Promise { + return new Promise((resolve, reject) => { + if (predicate()) { + resolve(); + return; + } + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out waiting for source update")); + }, timeoutMs); + const unsubscribe = source.subscribe(() => { + if (predicate()) { + clearTimeout(timeout); + unsubscribe(); + resolve(); + } + }); + }); +} + +function requestArgs(query: ReturnType): TdgpQueryRequest[] { + return (query.mock.calls as unknown as Array<[string, TdgpQueryRequest?]>).map( + (call) => call[1] ?? {}, + ); +} + +describe("sortColumnToTdgpSort", () => { + it("maps a column sort to field and direction", () => { + const sort: SortColumn = { key: columns[1], direction: "desc" }; + expect(sortColumnToTdgpSort(sort)).toEqual([{ field: "salary", dir: "desc" }]); + }); + + it("returns undefined when sort is cleared", () => { + expect(sortColumnToTdgpSort(null)).toBeUndefined(); + }); +}); + +describe("setNestedChildren", () => { + it("writes children onto the row at the given path", () => { + const rows = [ + { id: 1, country: "France", [TDGP_CHILDREN_ACCESSOR]: [] as { id: number }[] }, + { id: 2, country: "Spain", [TDGP_CHILDREN_ACCESSOR]: [] as { id: number }[] }, + ]; + const next = setNestedChildren(rows, [1], [TDGP_CHILDREN_ACCESSOR], [{ id: 9, name: "Ada" }]); + expect(next[0][TDGP_CHILDREN_ACCESSOR]).toEqual([]); + expect(next[1][TDGP_CHILDREN_ACCESSOR]).toEqual([{ id: 9, name: "Ada" }]); + expect(rows[1][TDGP_CHILDREN_ACCESSOR]).toEqual([]); + }); +}); + +describe("createTdgpTableSource", () => { + it("loads the first page and exposes server-side table props", async () => { + const query = vi.fn(async (_dataset: string, request?: { start?: number; limit?: number }) => ({ + protocol: "tdgp/1", + data: [ + { id: 1, country: "France", salary: 120000 }, + { id: 2, country: "Spain", salary: 110000 }, + ], + totalCount: 10000, + })); + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 50, + }); + source.start(); + + await waitFor(source, () => source.getSnapshot().isLoading === false); + + const snapshot = source.getSnapshot(); + expect(query).toHaveBeenCalledWith("developers-10k", expect.objectContaining({ start: 0, limit: 50 })); + expect(snapshot.rows).toHaveLength(2); + expect(snapshot.totalRowCount).toBe(10000); + expect(snapshot.tableProps.enablePagination).toBe(true); + expect(snapshot.tableProps.serverSidePagination).toBe(true); + expect(snapshot.tableProps.externalSortHandling).toBe(true); + expect(snapshot.tableProps.externalFilterHandling).toBe(true); + + source.getSnapshot().tableProps.onPageChange(2); + await waitFor(source, () => requestArgs(query).some((request) => request.start === 50)); + expect(query).toHaveBeenCalledWith("developers-10k", expect.objectContaining({ start: 50, limit: 50 })); + }); + + it("sends sort and filter on the next query and resets to page 1", async () => { + const query = vi.fn(async () => ({ + protocol: "tdgp/1", + data: [{ id: 1, country: "France", salary: 120000 }], + totalCount: 1, + })); + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 25, + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + + source.getSnapshot().tableProps.onSortChange({ key: columns[1], direction: "desc" }); + await waitFor(source, () => requestArgs(query).some((request) => request.sort?.[0]?.field === "salary")); + + source.getSnapshot().tableProps.onFilterChange({ + age: { accessor: "age", operator: "greaterThan", value: 30 }, + }); + await waitFor(source, () => + requestArgs(query).some( + (request) => request.filter && "operator" in request.filter && request.filter.operator === "GT", + ), + ); + + const lastCall = requestArgs(query).at(-1); + expect(lastCall?.start).toBe(0); + expect(lastCall?.sort).toEqual([{ field: "salary", dir: "desc" }]); + expect(lastCall?.filter).toEqual({ + kind: "predicate", + field: "age", + operator: "GT", + args: [30], + }); + }); + + it("loads group nodes, then children when a group expands", async () => { + const query = vi.fn(async (_dataset: string, request?: { groupKeys?: string[] }) => { + if (!request?.groupKeys?.length) { + return { + protocol: "tdgp/1", + data: [ + { + keys: ["France"], + data: { country: "France" }, + aggregations: { salary: 128000 }, + }, + ], + totalCount: 24, + }; + } + return { + protocol: "tdgp/1", + data: [{ id: 11, firstName: "Ada", country: "France", salary: 150000 }], + totalCount: 1, + }; + }); + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + groupBy: ["country"], + aggregations: [{ id: "salary", field: "salary", fn: "sum" }], + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + + const top = source.getSnapshot(); + expect(top.rows).toHaveLength(1); + expect(top.rows[0]).toMatchObject({ + country: "France", + salary: 128000, + [TDGP_GROUP_KEYS]: ["France"], + }); + expect(top.tableProps.rowGrouping).toEqual([TDGP_CHILDREN_ACCESSOR]); + expect(top.columns[0]?.expandable).toBe(true); + + const setLoading = vi.fn(); + const setError = vi.fn(); + const setEmpty = vi.fn(); + await top.tableProps.onRowGroupExpand?.({ + row: top.rows[0], + depth: 0, + event: new MouseEvent("click"), + groupingKey: TDGP_CHILDREN_ACCESSOR, + isExpanded: true, + rowIndexPath: [0], + groupingKeys: [TDGP_CHILDREN_ACCESSOR], + setLoading, + setError, + setEmpty, + }); + + const expanded = source.getSnapshot(); + expect(query).toHaveBeenCalledWith( + "developers-10k", + expect.objectContaining({ groupKeys: ["France"] }), + ); + expect(expanded.rows[0][TDGP_CHILDREN_ACCESSOR]).toEqual([ + { id: 11, firstName: "Ada", country: "France", salary: 150000 }, + ]); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 332687fbf..5c073b376 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,36 @@ export { PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL, }; +export { + createTdgpTableSource, + tableFiltersToTdgpFilter, + sortColumnToTdgpSort, + isTdgpGroupNode, + tdgpGroupNodeToRow, + tdgpGroupNodesToRows, + getTdgpGroupKeys, + setNestedChildren, + TDGP_CHILDREN_ACCESSOR, + TDGP_GROUP_KEYS, +} from "./tdgp"; +export type { + TdgpAggregation, + TdgpAggregationFn, + TdgpFilterGroup, + TdgpFilterModel, + TdgpFilterNot, + TdgpFilterOperator, + TdgpFilterPredicate, + TdgpGroupNode, + TdgpQueryClient, + TdgpQueryRequest, + TdgpQueryResponse, + TdgpSortModel, + TdgpTableProps, + TdgpTableSnapshot, + TdgpTableSource, + TdgpTableSourceOptions, +} from "./tdgp"; export { headersStructurallyEqual, collectHeaderAccessors, diff --git a/packages/core/src/tdgp/createTdgpTableSource.ts b/packages/core/src/tdgp/createTdgpTableSource.ts new file mode 100644 index 000000000..51ad5e028 --- /dev/null +++ b/packages/core/src/tdgp/createTdgpTableSource.ts @@ -0,0 +1,235 @@ +import type ColumnDef from "../types/ColumnDef"; +import type { TableFilterState } from "../types/FilterTypes"; +import type { GetRowIdParams } from "../types/GetRowId"; +import type OnRowGroupExpandProps from "../types/OnRowGroupExpandProps"; +import type Row from "../types/Row"; +import type { RowData } from "../types/Row"; +import type SortColumn from "../types/SortColumn"; +import { getTdgpGroupKeys, isTdgpGroupNode, tdgpGroupNodesToRows } from "./mapGroupResponse"; +import { setNestedChildren } from "./setNestedChildren"; +import { sortColumnToTdgpSort } from "./sortColumnToTdgpSort"; +import { tableFiltersToTdgpFilter } from "./tableFiltersToTdgpFilter"; +import { + TDGP_CHILDREN_ACCESSOR, + type TdgpQueryRequest, + type TdgpTableSnapshot, + type TdgpTableSource, + type TdgpTableSourceOptions, + type TdgpTableProps, +} from "./types"; + +function withExpandableGroupColumn( + columns: ColumnDef[], + groupBy: string[] | undefined, +): ColumnDef[] { + if (!groupBy?.length) return columns; + const target = groupBy[0]; + return columns.map((column) => + column.accessor === target || (column === columns[0] && !columns.some((c) => c.accessor === target)) + ? { ...column, expandable: true } + : column, + ); +} + +/** + * Loads pages, sorts, filters, and optional groups from a TDGP server + * and exposes the Simple Table props that keep that data in sync. + */ +export function createTdgpTableSource( + options: TdgpTableSourceOptions, +): TdgpTableSource { + const pageSize = options.pageSize ?? 50; + const primaryKey = options.primaryKey ?? "id"; + const groupBy = options.groupBy; + const aggregations = options.aggregations; + const childrenAccessor = TDGP_CHILDREN_ACCESSOR; + const groupingKeys = groupBy?.map(() => childrenAccessor); + const columns = withExpandableGroupColumn(options.columns, groupBy); + + let page = 1; + let sort: SortColumn | null = null; + let filters: TableFilterState = {}; + let rows: TData[] = []; + let totalRowCount = 0; + let isLoading = true; + let error: string | null = null; + let loadGeneration = 0; + let stopped = false; + + const listeners = new Set<() => void>(); + + const getRowId = (params: GetRowIdParams) => { + const row = params.row as Record | undefined; + const groupKeys = getTdgpGroupKeys(row); + if (groupKeys) return `group:${groupKeys.join("/")}`; + const value = row?.[primaryKey]; + return value == null ? undefined : String(value); + }; + + const handlePageChange = (nextPage: number) => { + page = nextPage; + void load(); + }; + + const handleSortChange = (nextSort: SortColumn | null) => { + sort = nextSort; + page = 1; + void load(); + }; + + const handleFilterChange = (nextFilters: TableFilterState) => { + filters = nextFilters; + page = 1; + void load(); + }; + + const handleRowGroupExpand = async (props: OnRowGroupExpandProps) => { + if (!groupBy?.length || !groupingKeys) return; + if (!props.isExpanded) return; + + const row = props.row as Record; + const field = props.groupingKey ? String(props.groupingKey) : childrenAccessor; + const existing = row[field]; + if (Array.isArray(existing) && existing.length > 0) return; + + const parentKeys = getTdgpGroupKeys(row); + if (!parentKeys) return; + + props.setLoading(true); + try { + const response = await options.client.query( + options.dataset, + buildRequest({ groupKeys: parentKeys, start: 0, limit: Math.max(pageSize, 500) }), + ); + const childRows = mapResponseRows(response.data, parentKeys.length) as TData[]; + if (childRows.length === 0) { + props.setEmpty(true, "No rows"); + return; + } + rows = setNestedChildren( + rows as Row[], + props.rowIndexPath, + groupingKeys.map(String), + childRows as Row[], + ) as TData[]; + emit(); + props.setLoading(false); + } catch (err) { + props.setError(err instanceof Error ? err.message : "Failed to load rows"); + } + }; + + function buildRequest(overrides: { groupKeys?: string[]; start?: number; limit?: number }): TdgpQueryRequest { + const request: TdgpQueryRequest = { + start: overrides.start ?? (page - 1) * pageSize, + limit: overrides.limit ?? pageSize, + sort: sortColumnToTdgpSort(sort), + filter: tableFiltersToTdgpFilter(filters), + process: { pagination: "server" }, + }; + + if (groupBy?.length) { + request.groupBy = groupBy.map((field) => ({ field })); + request.groupKeys = overrides.groupKeys ?? []; + request.process = { + ...request.process, + group: "server", + ...(aggregations?.length ? { aggregation: "server" } : {}), + }; + if (aggregations?.length) request.aggregations = aggregations; + } + + return request; + } + + function mapResponseRows(data: unknown[], groupKeyCount: number): Record[] { + if (groupBy?.length && groupKeyCount < groupBy.length && data.some(isTdgpGroupNode)) { + return tdgpGroupNodesToRows(data.filter(isTdgpGroupNode), childrenAccessor); + } + return data.filter((row) => row && typeof row === "object" && !isTdgpGroupNode(row)) as Record< + string, + unknown + >[]; + } + + function buildTableProps(): TdgpTableProps { + return { + enablePagination: true, + serverSidePagination: true, + rowsPerPage: pageSize, + totalRowCount, + isLoading, + externalSortHandling: true, + externalFilterHandling: true, + onPageChange: handlePageChange, + onSortChange: handleSortChange, + onFilterChange: handleFilterChange, + getRowId, + ...(groupingKeys + ? { rowGrouping: groupingKeys, onRowGroupExpand: handleRowGroupExpand } + : {}), + }; + } + + let snapshot: TdgpTableSnapshot = { + rows, + columns, + isLoading, + error, + totalRowCount, + tableProps: buildTableProps(), + }; + + function emit() { + snapshot = { + rows, + columns, + isLoading, + error, + totalRowCount, + tableProps: buildTableProps(), + }; + listeners.forEach((listener) => listener()); + } + + async function load() { + const generation = ++loadGeneration; + isLoading = true; + error = null; + emit(); + try { + const response = await options.client.query(options.dataset, buildRequest({})); + if (stopped || generation !== loadGeneration) return; + rows = mapResponseRows(response.data, 0) as TData[]; + totalRowCount = response.totalCount; + isLoading = false; + emit(); + } catch (err) { + if (stopped || generation !== loadGeneration) return; + error = err instanceof Error ? err.message : "Failed to load rows"; + isLoading = false; + emit(); + } + } + + return { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => snapshot, + start() { + stopped = false; + void load(); + }, + stop() { + stopped = true; + loadGeneration += 1; + }, + reload() { + void load(); + }, + }; +} diff --git a/packages/core/src/tdgp/index.ts b/packages/core/src/tdgp/index.ts new file mode 100644 index 000000000..fec8bc737 --- /dev/null +++ b/packages/core/src/tdgp/index.ts @@ -0,0 +1,29 @@ +export { createTdgpTableSource } from "./createTdgpTableSource"; +export { tableFiltersToTdgpFilter } from "./tableFiltersToTdgpFilter"; +export { sortColumnToTdgpSort } from "./sortColumnToTdgpSort"; +export { + isTdgpGroupNode, + tdgpGroupNodeToRow, + tdgpGroupNodesToRows, + getTdgpGroupKeys, +} from "./mapGroupResponse"; +export { setNestedChildren } from "./setNestedChildren"; +export { TDGP_CHILDREN_ACCESSOR, TDGP_GROUP_KEYS } from "./types"; +export type { + TdgpAggregation, + TdgpAggregationFn, + TdgpFilterGroup, + TdgpFilterModel, + TdgpFilterNot, + TdgpFilterOperator, + TdgpFilterPredicate, + TdgpGroupNode, + TdgpQueryClient, + TdgpQueryRequest, + TdgpQueryResponse, + TdgpSortModel, + TdgpTableProps, + TdgpTableSnapshot, + TdgpTableSource, + TdgpTableSourceOptions, +} from "./types"; diff --git a/packages/core/src/tdgp/mapGroupResponse.ts b/packages/core/src/tdgp/mapGroupResponse.ts new file mode 100644 index 000000000..c882c542e --- /dev/null +++ b/packages/core/src/tdgp/mapGroupResponse.ts @@ -0,0 +1,37 @@ +import { TDGP_CHILDREN_ACCESSOR, TDGP_GROUP_KEYS, type TdgpGroupNode } from "./types"; + +export function isTdgpGroupNode(value: unknown): value is TdgpGroupNode { + if (!value || typeof value !== "object") return false; + const node = value as TdgpGroupNode; + return Array.isArray(node.keys) && node.data != null && typeof node.data === "object"; +} + +/** Flatten a server group node into a Simple Table row with an empty children array. */ +export function tdgpGroupNodeToRow( + node: TdgpGroupNode, + childrenAccessor: string = TDGP_CHILDREN_ACCESSOR, +): Record { + const row: Record = { + ...node.data, + ...(node.aggregations ?? {}), + [TDGP_GROUP_KEYS]: node.keys, + [childrenAccessor]: [], + }; + if (row.id == null) { + row.id = `group:${node.keys.join("/")}`; + } + return row; +} + +export function tdgpGroupNodesToRows( + nodes: TdgpGroupNode[], + childrenAccessor: string = TDGP_CHILDREN_ACCESSOR, +): Record[] { + return nodes.map((node) => tdgpGroupNodeToRow(node, childrenAccessor)); +} + +export function getTdgpGroupKeys(row: Record | null | undefined): string[] | null { + const keys = row?.[TDGP_GROUP_KEYS]; + if (!Array.isArray(keys)) return null; + return keys.map((key) => String(key)); +} diff --git a/packages/core/src/tdgp/setNestedChildren.ts b/packages/core/src/tdgp/setNestedChildren.ts new file mode 100644 index 000000000..a69bb8a68 --- /dev/null +++ b/packages/core/src/tdgp/setNestedChildren.ts @@ -0,0 +1,34 @@ +import type Row from "../types/Row"; + +/** + * Set the children array on the row at `rowIndexPath`. + * Path `[0, 2]` writes `rows[0][groupingKeys[0]][2][groupingKeys[1]]`. + */ +export function setNestedChildren( + rows: Row[], + rowIndexPath: number[], + groupingKeys: string[], + children: Row[], +): Row[] { + if (rowIndexPath.length === 0) return rows; + return patchLevel(rows, 0); + + function patchLevel(list: Row[], depth: number): Row[] { + const index = rowIndexPath[depth]; + if (index == null || index < 0 || index >= list.length) return list; + + const next = list.slice(); + const row = { ...next[index] }; + const field = groupingKeys[depth]; + if (!field) return list; + + if (depth === rowIndexPath.length - 1) { + row[field] = children; + } else { + const nested = Array.isArray(row[field]) ? (row[field] as Row[]) : []; + row[field] = patchLevel(nested, depth + 1); + } + next[index] = row; + return next; + } +} diff --git a/packages/core/src/tdgp/sortColumnToTdgpSort.ts b/packages/core/src/tdgp/sortColumnToTdgpSort.ts new file mode 100644 index 000000000..9ebf3a843 --- /dev/null +++ b/packages/core/src/tdgp/sortColumnToTdgpSort.ts @@ -0,0 +1,8 @@ +import type SortColumn from "../types/SortColumn"; +import type { TdgpSortModel } from "./types"; + +/** Turn Simple Table's active sort into a TDGP sort list. */ +export function sortColumnToTdgpSort(sort: SortColumn | null | undefined): TdgpSortModel[] | undefined { + if (!sort?.key?.accessor) return undefined; + return [{ field: String(sort.key.accessor), dir: sort.direction }]; +} diff --git a/packages/core/src/tdgp/tableFiltersToTdgpFilter.ts b/packages/core/src/tdgp/tableFiltersToTdgpFilter.ts new file mode 100644 index 000000000..128666b5e --- /dev/null +++ b/packages/core/src/tdgp/tableFiltersToTdgpFilter.ts @@ -0,0 +1,92 @@ +import type { FilterCondition, FilterOperator, TableFilterState } from "../types/FilterTypes"; +import type { TdgpFilterModel, TdgpFilterOperator, TdgpFilterPredicate } from "./types"; + +type OperatorMapEntry = { + operator: TdgpFilterOperator; + negate?: boolean; +}; + +const OPERATOR_MAP: Record = { + equals: { operator: "EQ" }, + notEquals: { operator: "NEQ" }, + contains: { operator: "CONTAINS" }, + notContains: { operator: "CONTAINS", negate: true }, + startsWith: { operator: "STARTS_WITH" }, + endsWith: { operator: "ENDS_WITH" }, + isEmpty: { operator: "IS_BLANK" }, + isNotEmpty: { operator: "IS_NOT_BLANK" }, + greaterThan: { operator: "GT" }, + lessThan: { operator: "LT" }, + greaterThanOrEqual: { operator: "GTE" }, + lessThanOrEqual: { operator: "LTE" }, + between: { operator: "BETWEEN" }, + notBetween: { operator: "BETWEEN", negate: true }, + in: { operator: "IN" }, + notIn: { operator: "IN", negate: true }, + before: { operator: "LT" }, + after: { operator: "GT" }, +}; + +function isBlanklessOperator(operator: FilterOperator): boolean { + return operator === "isEmpty" || operator === "isNotEmpty"; +} + +function isListOperator(operator: FilterOperator): boolean { + return ( + operator === "between" || + operator === "notBetween" || + operator === "in" || + operator === "notIn" + ); +} + +function predicateArgs(condition: FilterCondition): Array | null { + if (isBlanklessOperator(condition.operator)) return []; + + if (isListOperator(condition.operator)) { + const values = (condition.values ?? []).filter( + (value) => value != null && value !== "", + ) as Array; + if (values.length === 0) return null; + return values; + } + + if (condition.value == null || condition.value === "") return null; + if (Array.isArray(condition.value)) return null; + return [condition.value as string | number | boolean]; +} + +function conditionToFilter(condition: FilterCondition): TdgpFilterModel | null { + const mapped = OPERATOR_MAP[condition.operator]; + if (!mapped) return null; + + const args = predicateArgs(condition); + if (args == null) return null; + + const predicate: TdgpFilterPredicate = { + kind: "predicate", + field: String(condition.accessor), + operator: mapped.operator, + ...(args.length > 0 ? { args } : {}), + }; + + if (mapped.negate) { + return { kind: "not", child: predicate }; + } + return predicate; +} + +/** Turn Simple Table column filters into a TDGP filter tree. */ +export function tableFiltersToTdgpFilter( + filters: TableFilterState | null | undefined, +): TdgpFilterModel | undefined { + if (!filters) return undefined; + + const children = Object.values(filters) + .map((condition) => conditionToFilter(condition)) + .filter((model): model is TdgpFilterModel => model != null); + + if (children.length === 0) return undefined; + if (children.length === 1) return children[0]; + return { kind: "group", combinator: "AND", children }; +} diff --git a/packages/core/src/tdgp/types.ts b/packages/core/src/tdgp/types.ts new file mode 100644 index 000000000..daf719f1d --- /dev/null +++ b/packages/core/src/tdgp/types.ts @@ -0,0 +1,145 @@ +import type { Accessor } from "../types/ColumnDef"; +import type ColumnDef from "../types/ColumnDef"; +import type { TableFilterState } from "../types/FilterTypes"; +import type { GetRowId } from "../types/GetRowId"; +import type OnRowGroupExpandProps from "../types/OnRowGroupExpandProps"; +import type Row from "../types/Row"; +import type { RowData } from "../types/Row"; +import type SortColumn from "../types/SortColumn"; + +/** Nested-children field used when the server returns grouped rows. */ +export const TDGP_CHILDREN_ACCESSOR = "__tdgpChildren"; + +/** Group key path stored on a grouped row (`["France", "backend"]`). */ +export const TDGP_GROUP_KEYS = "__tdgpKeys"; + +export type TdgpFilterOperator = + | "EQ" + | "NEQ" + | "GT" + | "GTE" + | "LT" + | "LTE" + | "BETWEEN" + | "IN" + | "CONTAINS" + | "STARTS_WITH" + | "ENDS_WITH" + | "IS_BLANK" + | "IS_NOT_BLANK"; + +export type TdgpFilterPredicate = { + kind: "predicate"; + field: string; + operator: TdgpFilterOperator; + args?: Array; +}; + +export type TdgpFilterGroup = { + kind: "group"; + combinator: "AND" | "OR"; + children: TdgpFilterModel[]; +}; + +export type TdgpFilterNot = { + kind: "not"; + child: TdgpFilterModel; +}; + +export type TdgpFilterModel = TdgpFilterGroup | TdgpFilterNot | TdgpFilterPredicate; + +export type TdgpSortModel = { + field: string; + dir: "asc" | "desc"; +}; + +export type TdgpAggregationFn = "sum" | "avg" | "min" | "max" | "count"; + +export type TdgpAggregation = { + id: string; + field: string; + fn: TdgpAggregationFn; +}; + +export type TdgpQueryRequest = { + start?: number; + limit?: number; + filter?: TdgpFilterModel; + sort?: TdgpSortModel[]; + groupBy?: { field: string }[]; + groupKeys?: string[]; + aggregations?: TdgpAggregation[]; + process?: { + group?: "server" | "client"; + pivot?: "server" | "client"; + aggregation?: "server" | "client"; + pagination?: "server" | "client"; + }; +}; + +export type TdgpGroupNode = { + keys: string[]; + data: Record; + aggregations?: Record; +}; + +export type TdgpQueryResponse = { + protocol?: string; + data: unknown[]; + totalCount: number; + totalCountUnfiltered?: number; +}; + +/** Minimal client shape. `createTdgpClient()` from `@thedatagrid/client` matches this. */ +export interface TdgpQueryClient { + query(dataset: string, request?: TdgpQueryRequest): Promise; +} + +export type TdgpTableProps = { + enablePagination: true; + serverSidePagination: true; + rowsPerPage: number; + totalRowCount: number; + isLoading: boolean; + externalSortHandling: true; + externalFilterHandling: true; + onPageChange: (page: number) => void | Promise; + onSortChange: (sort: SortColumn | null) => void; + onFilterChange: (filters: TableFilterState) => void; + getRowId: GetRowId; + rowGrouping?: Accessor[]; + onRowGroupExpand?: (props: OnRowGroupExpandProps) => void | Promise; +}; + +export type TdgpTableSnapshot = { + rows: TData[]; + columns: ColumnDef[]; + isLoading: boolean; + error: string | null; + totalRowCount: number; + tableProps: TdgpTableProps; +}; + +export type TdgpTableSourceOptions = { + client: TdgpQueryClient; + dataset: string; + columns: ColumnDef[]; + /** Rows per page. Default 50. */ + pageSize?: number; + /** Field used as the row id for leaf rows. Default `"id"`. */ + primaryKey?: string; + /** Group on the server by these fields, loading children when a group expands. */ + groupBy?: string[]; + /** Aggregations computed on the server for grouped rows. */ + aggregations?: TdgpAggregation[]; +}; + +export type TdgpTableSource = { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => TdgpTableSnapshot; + /** Load the current page. Called once from the React hook on mount. */ + start: () => void; + /** Ignore in-flight responses and stop later loads. */ + stop: () => void; + reload: () => void; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d4fd90b09..743a338ce 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -111,6 +111,16 @@ export type { TableFilterState, TableRow, Theme, + TdgpAggregation, + TdgpAggregationFn, + TdgpFilterModel, + TdgpQueryClient, + TdgpQueryRequest, + TdgpQueryResponse, + TdgpTableProps, + TdgpTableSnapshot, + TdgpTableSource, + TdgpTableSourceOptions, UpdateDataProps, ValueFormatter, ValueFormatterProps, @@ -125,4 +135,11 @@ export { PIVOT_IS_TOTAL_KEY, PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL, + createTdgpTableSource, + tableFiltersToTdgpFilter, + sortColumnToTdgpSort, + TDGP_CHILDREN_ACCESSOR, + TDGP_GROUP_KEYS, } from "simple-table-core"; + +export { useTdgpTable } from "./tdgp/useTdgpTable"; diff --git a/packages/react/src/tdgp/useTdgpTable.ts b/packages/react/src/tdgp/useTdgpTable.ts new file mode 100644 index 000000000..da142f6e2 --- /dev/null +++ b/packages/react/src/tdgp/useTdgpTable.ts @@ -0,0 +1,36 @@ +import { useEffect, useMemo } from "react"; +import { useSyncExternalStore } from "react"; +import { + createTdgpTableSource, + type TdgpTableSnapshot, + type TdgpTableSourceOptions, +} from "simple-table-core"; +import type { ReactDefaultRowData } from "../types"; + +/** + * Loads rows from a TDGP server and returns Simple Table props for + * server-side paging, sorting, filtering, and optional grouping. + */ +export function useTdgpTable( + options: TdgpTableSourceOptions, +): TdgpTableSnapshot { + const groupByKey = options.groupBy?.join("\0") ?? ""; + const aggregationsKey = + options.aggregations?.map((item) => `${item.id}:${item.field}:${item.fn}`).join("|") ?? ""; + + const source = useMemo( + () => createTdgpTableSource(options), + [options.client, options.dataset, options.pageSize, options.primaryKey, options.columns, groupByKey, aggregationsKey], + ); + + const snapshot = useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot); + + useEffect(() => { + source.start(); + return () => { + source.stop(); + }; + }, [source]); + + return snapshot; +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 65e049d98..8901aafb1 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -16,6 +16,8 @@ export default defineConfig({ "src/__tests__/**/*.{test,spec}.{ts,tsx}", "../core/src/__tests__/columnOwnership.test.ts", "../core/src/__tests__/parkAndStagger.test.ts", + "../core/src/__tests__/tdgpFilter.test.ts", + "../core/src/__tests__/tdgpTableSource.test.ts", ], // The vanilla core imports a CSS bundle on load. We assert on DOM classes, // not computed colors, so CSS processing is unnecessary here. From 3a44d48fcd403ab4e58704cd1dfee8b9703805be Mon Sep 17 00:00:00 2001 From: Peter Young <20213436+petera2c@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:43:46 -0500 Subject: [PATCH 2/4] Keep TDGP from refetching when the caller passes new client or columns objects. Type filter lists and query rows, and add a React example that loads from the public protocol server. Co-authored-by: Cursor --- .../__tests__/accessorTyping.types.test.ts | 16 +++ .../core/src/__tests__/tdgpFilter.test.ts | 12 ++- .../src/__tests__/tdgpTableSource.test.ts | 91 +++++++++++++++++ packages/core/src/index.ts | 1 + .../core/src/tdgp/createTdgpTableSource.ts | 98 +++++++++++++------ packages/core/src/tdgp/mapGroupResponse.ts | 32 +++--- .../core/src/tdgp/tableFiltersToTdgpFilter.ts | 25 +++-- packages/core/src/tdgp/types.ts | 15 ++- packages/core/src/types/FilterTypes.ts | 17 +++- packages/examples/react/package.json | 5 +- packages/examples/react/src/demo-list.ts | 1 + .../react/src/demos/tdgp/TdgpDemo.tsx | 55 +++++++++++ .../react/src/demos/tdgp/tdgp.demo-data.ts | 69 +++++++++++++ packages/examples/react/src/registry.ts | 1 + packages/examples/react/tsconfig.json | 6 +- .../react/src/__tests__/useTdgpTable.test.tsx | 81 +++++++++++++++ packages/react/src/index.ts | 12 +++ packages/react/src/tdgp/useTdgpTable.ts | 38 ++++--- pnpm-lock.yaml | 22 +++++ 19 files changed, 520 insertions(+), 77 deletions(-) create mode 100644 packages/examples/react/src/demos/tdgp/TdgpDemo.tsx create mode 100644 packages/examples/react/src/demos/tdgp/tdgp.demo-data.ts create mode 100644 packages/react/src/__tests__/useTdgpTable.test.tsx diff --git a/packages/core/src/__tests__/accessorTyping.types.test.ts b/packages/core/src/__tests__/accessorTyping.types.test.ts index ff3bfd2d8..908cb60de 100644 --- a/packages/core/src/__tests__/accessorTyping.types.test.ts +++ b/packages/core/src/__tests__/accessorTyping.types.test.ts @@ -13,8 +13,11 @@ import type { SimpleTableProps, TableAPI, TableFilterState, + TdgpGroupNode, + TdgpQueryClient, UpdateDataProps, } from "../index"; +import { tableFilterConditions } from "../index"; interface HREmployee { id: number; @@ -73,6 +76,19 @@ const filterState: TableFilterState = { }; void api.applyFilter(filterState.fullName); +const listedFilters: FilterCondition[] = tableFilterConditions(filterState); +listedFilters.map((condition) => condition.operator); + +declare const tdgpClient: TdgpQueryClient; +async function loadEmployees() { + const response = await tdgpClient.query("developers-10k"); + const data: Array> = response.data as Array< + HREmployee | TdgpGroupNode + >; + void data; +} +void loadEmployees; + const pivotOk: PivotConfig = { rows: ["firstName"], columns: ["lastName"], diff --git a/packages/core/src/__tests__/tdgpFilter.test.ts b/packages/core/src/__tests__/tdgpFilter.test.ts index 09c1a2889..6c0d9bb1d 100644 --- a/packages/core/src/__tests__/tdgpFilter.test.ts +++ b/packages/core/src/__tests__/tdgpFilter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { tableFiltersToTdgpFilter } from "../tdgp/tableFiltersToTdgpFilter"; -import type { TableFilterState } from "../types/FilterTypes"; +import { tableFilterConditions, type TableFilterState } from "../types/FilterTypes"; describe("tableFiltersToTdgpFilter", () => { it("returns undefined when there are no filters", () => { @@ -8,6 +8,16 @@ describe("tableFiltersToTdgpFilter", () => { expect(tableFiltersToTdgpFilter({})).toBeUndefined(); }); + it("lists each column filter as a FilterCondition", () => { + const filters: TableFilterState = { + age: { accessor: "age", operator: "greaterThan", value: 30 }, + }; + expect(tableFilterConditions(filters)).toEqual([ + { accessor: "age", operator: "greaterThan", value: 30 }, + ]); + expect(tableFilterConditions(undefined)).toEqual([]); + }); + it("maps a number comparison to a single predicate", () => { const filters: TableFilterState = { age: { accessor: "age", operator: "greaterThan", value: 30 }, diff --git a/packages/core/src/__tests__/tdgpTableSource.test.ts b/packages/core/src/__tests__/tdgpTableSource.test.ts index de96cc874..d6e270ec3 100644 --- a/packages/core/src/__tests__/tdgpTableSource.test.ts +++ b/packages/core/src/__tests__/tdgpTableSource.test.ts @@ -214,4 +214,95 @@ describe("createTdgpTableSource", () => { { id: 11, firstName: "Ada", country: "France", salary: 150000 }, ]); }); + + it("does not reload when client or columns are new objects with the same query", async () => { + const query = vi.fn(async () => ({ + protocol: "tdgp/1", + data: [{ id: 1, country: "France", salary: 120000 }], + totalCount: 1, + })); + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 50, + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + expect(query).toHaveBeenCalledTimes(1); + + source.applyOptions({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns: columns.map((column) => ({ ...column })), + pageSize: 50, + }); + + expect(query).toHaveBeenCalledTimes(1); + }); + + it("uses the latest client on the next request after applyOptions", async () => { + const firstQuery = vi.fn(async () => ({ + protocol: "tdgp/1", + data: [{ id: 1, country: "France", salary: 120000 }], + totalCount: 100, + })); + const secondQuery = vi.fn(async () => ({ + protocol: "tdgp/1", + data: [{ id: 2, country: "Spain", salary: 110000 }], + totalCount: 100, + })); + + const source = createTdgpTableSource({ + client: { query: firstQuery } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 50, + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + + source.applyOptions({ + client: { query: secondQuery } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 50, + }); + source.getSnapshot().tableProps.onPageChange(2); + await waitFor(source, () => secondQuery.mock.calls.length > 0); + + expect(firstQuery).toHaveBeenCalledTimes(1); + expect(secondQuery).toHaveBeenCalledWith( + "developers-10k", + expect.objectContaining({ start: 50, limit: 50 }), + ); + }); + + it("reloads when the dataset changes", async () => { + const query = vi.fn(async (dataset: string) => ({ + protocol: "tdgp/1", + data: [{ id: 1, dataset }], + totalCount: 1, + })); + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 50, + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + + source.applyOptions({ + client: { query } as TdgpQueryClient, + dataset: "developers-50k", + columns, + pageSize: 50, + }); + await waitFor(source, () => query.mock.calls.some((call) => call[0] === "developers-50k")); + + expect(query).toHaveBeenCalledWith("developers-50k", expect.objectContaining({ start: 0 })); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c073b376..25850ce22 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -125,6 +125,7 @@ export { TDGP_CHILDREN_ACCESSOR, TDGP_GROUP_KEYS, } from "./tdgp"; +export { tableFilterConditions } from "./types/FilterTypes"; export type { TdgpAggregation, TdgpAggregationFn, diff --git a/packages/core/src/tdgp/createTdgpTableSource.ts b/packages/core/src/tdgp/createTdgpTableSource.ts index 51ad5e028..5b7d1d45b 100644 --- a/packages/core/src/tdgp/createTdgpTableSource.ts +++ b/packages/core/src/tdgp/createTdgpTableSource.ts @@ -5,6 +5,7 @@ import type OnRowGroupExpandProps from "../types/OnRowGroupExpandProps"; import type Row from "../types/Row"; import type { RowData } from "../types/Row"; import type SortColumn from "../types/SortColumn"; +import { headersStructurallyEqual } from "../utils/propSyncEqual"; import { getTdgpGroupKeys, isTdgpGroupNode, tdgpGroupNodesToRows } from "./mapGroupResponse"; import { setNestedChildren } from "./setNestedChildren"; import { sortColumnToTdgpSort } from "./sortColumnToTdgpSort"; @@ -31,20 +32,23 @@ function withExpandableGroupColumn( ); } +/** Dataset, page size, key field, groups, and aggregations — not client or columns identity. */ +function queryShapeKey(options: TdgpTableSourceOptions): string { + const groupBy = options.groupBy?.join("\0") ?? ""; + const aggregations = + options.aggregations?.map((item) => `${item.id}:${item.field}:${item.fn}`).join("|") ?? ""; + return `${options.dataset}\n${options.pageSize ?? 50}\n${options.primaryKey ?? "id"}\n${groupBy}\n${aggregations}`; +} + /** * Loads pages, sorts, filters, and optional groups from a TDGP server * and exposes the Simple Table props that keep that data in sync. */ export function createTdgpTableSource( - options: TdgpTableSourceOptions, + initialOptions: TdgpTableSourceOptions, ): TdgpTableSource { - const pageSize = options.pageSize ?? 50; - const primaryKey = options.primaryKey ?? "id"; - const groupBy = options.groupBy; - const aggregations = options.aggregations; + let options = initialOptions; const childrenAccessor = TDGP_CHILDREN_ACCESSOR; - const groupingKeys = groupBy?.map(() => childrenAccessor); - const columns = withExpandableGroupColumn(options.columns, groupBy); let page = 1; let sort: SortColumn | null = null; @@ -55,14 +59,31 @@ export function createTdgpTableSource( let error: string | null = null; let loadGeneration = 0; let stopped = false; + let started = false; const listeners = new Set<() => void>(); + function pageSize(): number { + return options.pageSize ?? 50; + } + + function primaryKey(): string { + return options.primaryKey ?? "id"; + } + + function groupingKeys(): string[] | undefined { + return options.groupBy?.map(() => childrenAccessor); + } + + function resolvedColumns(): ColumnDef[] { + return withExpandableGroupColumn(options.columns, options.groupBy); + } + const getRowId = (params: GetRowIdParams) => { const row = params.row as Record | undefined; const groupKeys = getTdgpGroupKeys(row); if (groupKeys) return `group:${groupKeys.join("/")}`; - const value = row?.[primaryKey]; + const value = row?.[primaryKey()]; return value == null ? undefined : String(value); }; @@ -84,7 +105,9 @@ export function createTdgpTableSource( }; const handleRowGroupExpand = async (props: OnRowGroupExpandProps) => { - if (!groupBy?.length || !groupingKeys) return; + const groupBy = options.groupBy; + const keys = groupingKeys(); + if (!groupBy?.length || !keys) return; if (!props.isExpanded) return; const row = props.row as Record; @@ -99,19 +122,14 @@ export function createTdgpTableSource( try { const response = await options.client.query( options.dataset, - buildRequest({ groupKeys: parentKeys, start: 0, limit: Math.max(pageSize, 500) }), + buildRequest({ groupKeys: parentKeys, start: 0, limit: Math.max(pageSize(), 500) }), ); - const childRows = mapResponseRows(response.data, parentKeys.length) as TData[]; + const childRows = mapResponseRows(response.data, parentKeys.length); if (childRows.length === 0) { props.setEmpty(true, "No rows"); return; } - rows = setNestedChildren( - rows as Row[], - props.rowIndexPath, - groupingKeys.map(String), - childRows as Row[], - ) as TData[]; + rows = setNestedChildren(rows as Row[], props.rowIndexPath, keys, childRows as Row[]) as TData[]; emit(); props.setLoading(false); } catch (err) { @@ -120,9 +138,12 @@ export function createTdgpTableSource( }; function buildRequest(overrides: { groupKeys?: string[]; start?: number; limit?: number }): TdgpQueryRequest { + const size = pageSize(); + const groupBy = options.groupBy; + const aggregations = options.aggregations; const request: TdgpQueryRequest = { - start: overrides.start ?? (page - 1) * pageSize, - limit: overrides.limit ?? pageSize, + start: overrides.start ?? (page - 1) * size, + limit: overrides.limit ?? size, sort: sortColumnToTdgpSort(sort), filter: tableFiltersToTdgpFilter(filters), process: { pagination: "server" }, @@ -142,21 +163,20 @@ export function createTdgpTableSource( return request; } - function mapResponseRows(data: unknown[], groupKeyCount: number): Record[] { + function mapResponseRows(data: unknown[], groupKeyCount: number): TData[] { + const groupBy = options.groupBy; if (groupBy?.length && groupKeyCount < groupBy.length && data.some(isTdgpGroupNode)) { - return tdgpGroupNodesToRows(data.filter(isTdgpGroupNode), childrenAccessor); + return tdgpGroupNodesToRows(data, childrenAccessor) as TData[]; } - return data.filter((row) => row && typeof row === "object" && !isTdgpGroupNode(row)) as Record< - string, - unknown - >[]; + return data.filter((row) => row && typeof row === "object" && !isTdgpGroupNode(row)) as TData[]; } function buildTableProps(): TdgpTableProps { + const keys = groupingKeys(); return { enablePagination: true, serverSidePagination: true, - rowsPerPage: pageSize, + rowsPerPage: pageSize(), totalRowCount, isLoading, externalSortHandling: true, @@ -165,15 +185,13 @@ export function createTdgpTableSource( onSortChange: handleSortChange, onFilterChange: handleFilterChange, getRowId, - ...(groupingKeys - ? { rowGrouping: groupingKeys, onRowGroupExpand: handleRowGroupExpand } - : {}), + ...(keys ? { rowGrouping: keys, onRowGroupExpand: handleRowGroupExpand } : {}), }; } let snapshot: TdgpTableSnapshot = { rows, - columns, + columns: resolvedColumns(), isLoading, error, totalRowCount, @@ -183,7 +201,7 @@ export function createTdgpTableSource( function emit() { snapshot = { rows, - columns, + columns: resolvedColumns(), isLoading, error, totalRowCount, @@ -200,7 +218,7 @@ export function createTdgpTableSource( try { const response = await options.client.query(options.dataset, buildRequest({})); if (stopped || generation !== loadGeneration) return; - rows = mapResponseRows(response.data, 0) as TData[]; + rows = mapResponseRows(response.data, 0); totalRowCount = response.totalCount; isLoading = false; emit(); @@ -222,6 +240,7 @@ export function createTdgpTableSource( getSnapshot: () => snapshot, start() { stopped = false; + started = true; void load(); }, stop() { @@ -231,5 +250,20 @@ export function createTdgpTableSource( reload() { void load(); }, + applyOptions(next) { + const prev = options; + options = next; + if (!started || stopped) return; + + if (queryShapeKey(prev) !== queryShapeKey(next)) { + page = 1; + void load(); + return; + } + + if (!headersStructurallyEqual(prev.columns, next.columns)) { + emit(); + } + }, }; } diff --git a/packages/core/src/tdgp/mapGroupResponse.ts b/packages/core/src/tdgp/mapGroupResponse.ts index c882c542e..f45c849be 100644 --- a/packages/core/src/tdgp/mapGroupResponse.ts +++ b/packages/core/src/tdgp/mapGroupResponse.ts @@ -1,18 +1,23 @@ +import type Row from "../types/Row"; +import type { RowData } from "../types/Row"; import { TDGP_CHILDREN_ACCESSOR, TDGP_GROUP_KEYS, type TdgpGroupNode } from "./types"; -export function isTdgpGroupNode(value: unknown): value is TdgpGroupNode { +export function isTdgpGroupNode( + value: unknown, +): value is TdgpGroupNode { if (!value || typeof value !== "object") return false; - const node = value as TdgpGroupNode; + const node = value as TdgpGroupNode; return Array.isArray(node.keys) && node.data != null && typeof node.data === "object"; } /** Flatten a server group node into a Simple Table row with an empty children array. */ -export function tdgpGroupNodeToRow( - node: TdgpGroupNode, +export function tdgpGroupNodeToRow( + node: TdgpGroupNode, childrenAccessor: string = TDGP_CHILDREN_ACCESSOR, -): Record { +): TData { + const data = node.data as Record; const row: Record = { - ...node.data, + ...data, ...(node.aggregations ?? {}), [TDGP_GROUP_KEYS]: node.keys, [childrenAccessor]: [], @@ -20,18 +25,19 @@ export function tdgpGroupNodeToRow( if (row.id == null) { row.id = `group:${node.keys.join("/")}`; } - return row; + return row as TData; } -export function tdgpGroupNodesToRows( - nodes: TdgpGroupNode[], +export function tdgpGroupNodesToRows( + nodes: Array>, childrenAccessor: string = TDGP_CHILDREN_ACCESSOR, -): Record[] { - return nodes.map((node) => tdgpGroupNodeToRow(node, childrenAccessor)); +): TData[] { + return nodes.filter(isTdgpGroupNode).map((node) => tdgpGroupNodeToRow(node, childrenAccessor)); } -export function getTdgpGroupKeys(row: Record | null | undefined): string[] | null { - const keys = row?.[TDGP_GROUP_KEYS]; +export function getTdgpGroupKeys(row: object | null | undefined): string[] | null { + if (!row) return null; + const keys = (row as Record)[TDGP_GROUP_KEYS]; if (!Array.isArray(keys)) return null; return keys.map((key) => String(key)); } diff --git a/packages/core/src/tdgp/tableFiltersToTdgpFilter.ts b/packages/core/src/tdgp/tableFiltersToTdgpFilter.ts index 128666b5e..3ad377505 100644 --- a/packages/core/src/tdgp/tableFiltersToTdgpFilter.ts +++ b/packages/core/src/tdgp/tableFiltersToTdgpFilter.ts @@ -1,4 +1,11 @@ -import type { FilterCondition, FilterOperator, TableFilterState } from "../types/FilterTypes"; +import { + tableFilterConditions, + type FilterCondition, + type FilterOperator, + type TableFilterState, +} from "../types/FilterTypes"; +import type Row from "../types/Row"; +import type { RowData } from "../types/Row"; import type { TdgpFilterModel, TdgpFilterOperator, TdgpFilterPredicate } from "./types"; type OperatorMapEntry = { @@ -40,7 +47,9 @@ function isListOperator(operator: FilterOperator): boolean { ); } -function predicateArgs(condition: FilterCondition): Array | null { +function predicateArgs( + condition: FilterCondition, +): Array | null { if (isBlanklessOperator(condition.operator)) return []; if (isListOperator(condition.operator)) { @@ -56,7 +65,9 @@ function predicateArgs(condition: FilterCondition): Array( + condition: FilterCondition, +): TdgpFilterModel | null { const mapped = OPERATOR_MAP[condition.operator]; if (!mapped) return null; @@ -77,12 +88,10 @@ function conditionToFilter(condition: FilterCondition): TdgpFilterModel | null { } /** Turn Simple Table column filters into a TDGP filter tree. */ -export function tableFiltersToTdgpFilter( - filters: TableFilterState | null | undefined, +export function tableFiltersToTdgpFilter( + filters: TableFilterState | null | undefined, ): TdgpFilterModel | undefined { - if (!filters) return undefined; - - const children = Object.values(filters) + const children = tableFilterConditions(filters) .map((condition) => conditionToFilter(condition)) .filter((model): model is TdgpFilterModel => model != null); diff --git a/packages/core/src/tdgp/types.ts b/packages/core/src/tdgp/types.ts index daf719f1d..6b2cd9a6f 100644 --- a/packages/core/src/tdgp/types.ts +++ b/packages/core/src/tdgp/types.ts @@ -77,22 +77,22 @@ export type TdgpQueryRequest = { }; }; -export type TdgpGroupNode = { +export type TdgpGroupNode = { keys: string[]; - data: Record; + data: TData; aggregations?: Record; }; -export type TdgpQueryResponse = { +export type TdgpQueryResponse = { protocol?: string; - data: unknown[]; + data: Array>; totalCount: number; totalCountUnfiltered?: number; }; /** Minimal client shape. `createTdgpClient()` from `@thedatagrid/client` matches this. */ export interface TdgpQueryClient { - query(dataset: string, request?: TdgpQueryRequest): Promise; + query(dataset: string, request?: TdgpQueryRequest): Promise>; } export type TdgpTableProps = { @@ -142,4 +142,9 @@ export type TdgpTableSource = { /** Ignore in-flight responses and stop later loads. */ stop: () => void; reload: () => void; + /** + * Replace client, columns, and query options. Reloads when dataset, page + * size, primary key, group fields, or aggregations change. + */ + applyOptions: (next: TdgpTableSourceOptions) => void; }; diff --git a/packages/core/src/types/FilterTypes.ts b/packages/core/src/types/FilterTypes.ts index 668b40a10..832a7d671 100644 --- a/packages/core/src/types/FilterTypes.ts +++ b/packages/core/src/types/FilterTypes.ts @@ -56,10 +56,19 @@ export interface FilterCondition { values?: CellValue[]; // For operators like 'between', 'in', etc. } -// Filter state for the entire table -export type TableFilterState = { - [accessor: string]: FilterCondition; -}; +/** Active column filters, keyed by filter id. */ +export type TableFilterState = Record< + string, + FilterCondition +>; + +/** Column filters currently applied, as a list. */ +export function tableFilterConditions( + filters: TableFilterState | null | undefined, +): FilterCondition[] { + if (!filters) return []; + return Object.values(filters) as FilterCondition[]; +} // Human-readable labels for filter operators export const FILTER_OPERATOR_LABELS: Record = { diff --git a/packages/examples/react/package.json b/packages/examples/react/package.json index 1547a8aba..d0d61bda6 100644 --- a/packages/examples/react/package.json +++ b/packages/examples/react/package.json @@ -10,9 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@simple-table/react": "workspace:*", + "@thedatagrid/client": "^1.0.1", "react": "^18.0.0", - "react-dom": "^18.0.0", - "@simple-table/react": "workspace:*" + "react-dom": "^18.0.0" }, "devDependencies": { "@types/react": "^18.0.0", diff --git a/packages/examples/react/src/demo-list.ts b/packages/examples/react/src/demo-list.ts index 204fea1b4..b405155aa 100644 --- a/packages/examples/react/src/demo-list.ts +++ b/packages/examples/react/src/demo-list.ts @@ -20,6 +20,7 @@ export const DEMO_LIST = [ { id: "nested-headers", label: "Nested Headers" }, { id: "external-sort", label: "External Sort" }, { id: "external-filter", label: "External Filter" }, + { id: "tdgp", label: "Server Data (TDGP)" }, { id: "loading-state", label: "Loading State" }, { id: "infinite-scroll", label: "Infinite Scroll" }, { id: "window-infinite-scroll", label: "Window Infinite Scroll" }, diff --git a/packages/examples/react/src/demos/tdgp/TdgpDemo.tsx b/packages/examples/react/src/demos/tdgp/TdgpDemo.tsx new file mode 100644 index 000000000..1280ce541 --- /dev/null +++ b/packages/examples/react/src/demos/tdgp/TdgpDemo.tsx @@ -0,0 +1,55 @@ +import { SimpleTable, useTdgpTable } from "@simple-table/react"; +import type { Theme } from "@simple-table/react"; +import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/react/styles.css"; +import { + TDGP_DATASET, + TDGP_GROUP_BY, + TDGP_PAGE_SIZE, + TDGP_SERVER_URL, + tdgpAggregations, + tdgpHeaders, +} from "./tdgp.demo-data"; + +const client = createTdgpClient({ url: TDGP_SERVER_URL }); + +const TdgpDemo = ({ + height = "520px", + theme, +}: { + height?: string | number; + theme?: Theme; +}) => { + const { rows, tableProps, error, totalRowCount } = useTdgpTable({ + client, + dataset: TDGP_DATASET, + columns: tdgpHeaders, + pageSize: TDGP_PAGE_SIZE, + groupBy: TDGP_GROUP_BY, + aggregations: tdgpAggregations, + }); + + return ( +
+

+ Live rows from {TDGP_SERVER_URL} ({TDGP_DATASET}). Next page, sort, and + column filters ask the server for a new slice. Expand a country to load + stacks, then people. + {totalRowCount > 0 ? ` ${totalRowCount.toLocaleString()} rows on the server.` : ""} +

+ {error ? ( +

{error}

+ ) : null} + +
+ ); +}; + +export default TdgpDemo; diff --git a/packages/examples/react/src/demos/tdgp/tdgp.demo-data.ts b/packages/examples/react/src/demos/tdgp/tdgp.demo-data.ts new file mode 100644 index 000000000..ddc300ed0 --- /dev/null +++ b/packages/examples/react/src/demos/tdgp/tdgp.demo-data.ts @@ -0,0 +1,69 @@ +import type { ReactColumnDef } from "@simple-table/react"; + +export type TdgpDeveloper = { + id: number | string; + firstName?: string; + lastName?: string; + country?: string; + city?: string; + stack?: string; + preferredLanguage?: string; + age?: number; + salary?: number; + reposCount?: number; + __tdgpKeys?: string[]; + __tdgpChildren?: TdgpDeveloper[]; + [key: string]: unknown; +}; + +export const TDGP_DATASET = "developers-10k"; +export const TDGP_SERVER_URL = "https://data.thedatagrid.com"; +export const TDGP_PAGE_SIZE = 25; +export const TDGP_GROUP_BY = ["country", "stack"]; + +const currency = (value: number | string | null | undefined) => + value == null ? "" : `$${Number(value).toLocaleString()}`; + +export const tdgpHeaders: ReactColumnDef[] = [ + { + accessor: "country", + label: "Country", + width: 180, + type: "string", + filterable: true, + expandable: true, + }, + { accessor: "stack", label: "Stack", width: 140, type: "string", filterable: true }, + { accessor: "firstName", label: "First name", width: 130, type: "string", filterable: true }, + { accessor: "lastName", label: "Last name", width: 130, type: "string", filterable: true }, + { + accessor: "preferredLanguage", + label: "Language", + width: 130, + type: "string", + filterable: true, + }, + { accessor: "age", label: "Age", width: 90, type: "number", filterable: true }, + { + accessor: "salary", + label: "Salary", + width: 130, + type: "number", + filterable: true, + align: "right", + valueFormatter: ({ value }) => currency(value as number | string | null | undefined), + }, + { + accessor: "reposCount", + label: "Repos", + width: 100, + type: "number", + filterable: true, + }, +]; + +export const tdgpAggregations = [ + { id: "age", field: "age", fn: "avg" as const }, + { id: "salary", field: "salary", fn: "sum" as const }, + { id: "reposCount", field: "reposCount", fn: "sum" as const }, +]; diff --git a/packages/examples/react/src/registry.ts b/packages/examples/react/src/registry.ts index cc8632167..cc586a8ef 100644 --- a/packages/examples/react/src/registry.ts +++ b/packages/examples/react/src/registry.ts @@ -31,6 +31,7 @@ export const registry: DemoRegistry = { // Phase 2 "external-sort": () => import("./demos/external-sort/ExternalSortDemo"), "external-filter": () => import("./demos/external-filter/ExternalFilterDemo"), + tdgp: () => import("./demos/tdgp/TdgpDemo"), "loading-state": () => import("./demos/loading-state/LoadingStateDemo"), "infinite-scroll": () => import("./demos/infinite-scroll/InfiniteScrollDemo"), "window-infinite-scroll": () => import("./demos/window-infinite-scroll/WindowInfiniteScrollDemo"), diff --git a/packages/examples/react/tsconfig.json b/packages/examples/react/tsconfig.json index 776b2ae7d..5f31f0983 100644 --- a/packages/examples/react/tsconfig.json +++ b/packages/examples/react/tsconfig.json @@ -13,7 +13,11 @@ "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "paths": { + "@simple-table/react": ["../../react/src/index.ts"], + "simple-table-core": ["../../core/src/index.ts"] + } }, "include": ["src"] } diff --git a/packages/react/src/__tests__/useTdgpTable.test.tsx b/packages/react/src/__tests__/useTdgpTable.test.tsx new file mode 100644 index 000000000..4ae2b6db0 --- /dev/null +++ b/packages/react/src/__tests__/useTdgpTable.test.tsx @@ -0,0 +1,81 @@ +import { createElement, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useTdgpTable } from "../tdgp/useTdgpTable"; +import type { TdgpQueryClient } from "simple-table-core"; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + root?.unmount(); + root = null; + container?.remove(); + container = null; + vi.restoreAllMocks(); +}); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await wait(20); + } + throw new Error("Timed out waiting for condition"); +} + +function mount(node: React.ReactElement): HTMLDivElement { + const host = document.createElement("div"); + document.body.appendChild(host); + container = host; + root = createRoot(host); + root.render(node); + return host; +} + +describe("useTdgpTable", () => { + it("does not reload when the parent re-renders with new client and columns objects", async () => { + const query = vi.fn(async () => ({ + protocol: "tdgp/1", + data: [{ id: 1, name: "Ada" }], + totalCount: 1, + })); + + function Probe({ bump }: { bump: number }) { + const client = { query } as TdgpQueryClient; + const columns = [ + { accessor: "id", label: "ID", width: 80, type: "number" as const }, + { accessor: "name", label: "Name", width: 120, type: "string" as const }, + ]; + const snapshot = useTdgpTable({ + client, + dataset: "developers-10k", + columns, + pageSize: 10, + }); + return createElement("div", { "data-bump": bump, "data-loading": String(snapshot.isLoading) }); + } + + function Harness() { + const [bump, setBump] = useState(0); + return createElement( + "div", + null, + createElement("button", { className: "rerender", onClick: () => setBump((n) => n + 1) }, "again"), + createElement(Probe, { bump }), + ); + } + + const host = mount(createElement(Harness)); + await waitFor(() => query.mock.calls.length === 1 && host.querySelector("[data-loading='false']") != null); + expect(query).toHaveBeenCalledTimes(1); + + host.querySelector("button.rerender")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await waitFor(() => host.querySelector("[data-bump='1']") != null); + await wait(40); + + expect(query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 743a338ce..36daeff3a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -113,7 +113,12 @@ export type { Theme, TdgpAggregation, TdgpAggregationFn, + TdgpFilterGroup, TdgpFilterModel, + TdgpFilterNot, + TdgpFilterOperator, + TdgpFilterPredicate, + TdgpGroupNode, TdgpQueryClient, TdgpQueryRequest, TdgpQueryResponse, @@ -137,9 +142,16 @@ export { PIVOT_BLANK_LABEL, createTdgpTableSource, tableFiltersToTdgpFilter, + tableFilterConditions, sortColumnToTdgpSort, + isTdgpGroupNode, + tdgpGroupNodeToRow, + tdgpGroupNodesToRows, + getTdgpGroupKeys, + setNestedChildren, TDGP_CHILDREN_ACCESSOR, TDGP_GROUP_KEYS, } from "simple-table-core"; export { useTdgpTable } from "./tdgp/useTdgpTable"; +export type { UseTdgpTableOptions } from "./tdgp/useTdgpTable"; diff --git a/packages/react/src/tdgp/useTdgpTable.ts b/packages/react/src/tdgp/useTdgpTable.ts index da142f6e2..dd72151ab 100644 --- a/packages/react/src/tdgp/useTdgpTable.ts +++ b/packages/react/src/tdgp/useTdgpTable.ts @@ -1,30 +1,46 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; import { useSyncExternalStore } from "react"; import { createTdgpTableSource, type TdgpTableSnapshot, type TdgpTableSourceOptions, } from "simple-table-core"; -import type { ReactDefaultRowData } from "../types"; +import type { ReactColumnDef, ReactDefaultRowData } from "../types"; + +export type UseTdgpTableOptions = Omit< + TdgpTableSourceOptions, + "columns" +> & { + columns: ReactColumnDef[]; +}; + +function toSourceOptions( + options: UseTdgpTableOptions, +): TdgpTableSourceOptions { + return { + ...options, + columns: options.columns as TdgpTableSourceOptions["columns"], + }; +} /** * Loads rows from a TDGP server and returns Simple Table props for * server-side paging, sorting, filtering, and optional grouping. + * + * A new `client` or `columns` object each render does not reload. A change to + * dataset, page size, primary key, group fields, or aggregations does. */ export function useTdgpTable( - options: TdgpTableSourceOptions, + options: UseTdgpTableOptions, ): TdgpTableSnapshot { - const groupByKey = options.groupBy?.join("\0") ?? ""; - const aggregationsKey = - options.aggregations?.map((item) => `${item.id}:${item.field}:${item.fn}`).join("|") ?? ""; - - const source = useMemo( - () => createTdgpTableSource(options), - [options.client, options.dataset, options.pageSize, options.primaryKey, options.columns, groupByKey, aggregationsKey], - ); + const [source] = useState(() => createTdgpTableSource(toSourceOptions(options))); const snapshot = useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot); + useLayoutEffect(() => { + source.applyOptions(toSourceOptions(options)); + }); + useEffect(() => { source.start(); return () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff0dfa1f4..65e1fb565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -347,6 +347,9 @@ importers: '@simple-table/react': specifier: workspace:* version: link:../../react + '@thedatagrid/client': + specifier: ^1.0.1 + version: 1.0.1 react: specifier: ^18.0.0 version: 18.3.1 @@ -3466,6 +3469,12 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@thedatagrid/client@1.0.1': + resolution: {integrity: sha512-LP/spZmsvu6Y46LzsgOIoyVmHjBnN8NdfxUZQrAqgyHBCVzPa6accQ1//sr0TpmYoegXG1fOaOeKTBescwG+2g==} + + '@thedatagrid/protocol@1.0.1': + resolution: {integrity: sha512-cS5+RBAXa2Doh4H72EzkrQwOF1d7lUaulLhLpGrw2Mn0SVsdYye1MyTfYs6ZVZLp4SjkYyRZ9YKhcT58LJCA3g==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -9206,6 +9215,9 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zone.js@0.15.1: resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} @@ -11920,6 +11932,14 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 + '@thedatagrid/client@1.0.1': + dependencies: + '@thedatagrid/protocol': 1.0.1 + + '@thedatagrid/protocol@1.0.1': + dependencies: + zod: 4.4.3 + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -19103,6 +19123,8 @@ snapshots: zimmerframe@1.1.4: {} + zod@4.4.3: {} + zone.js@0.15.1: {} zwitch@2.0.4: {} From 92e2c3cacaed304de09f648c6175ce87b6fc5a7b Mon Sep 17 00:00:00 2001 From: Peter Young <20213436+petera2c@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:16:11 -0500 Subject: [PATCH 3/4] Fix TDGP paging and expand so next page replaces the current rows and opening a group shows children instead of a stuck loader. Co-authored-by: Cursor --- .../src/__tests__/tdgpTableSource.test.ts | 1 + .../src/core/rendering/RenderOrchestrator.ts | 33 ++++- .../core/src/tdgp/createTdgpTableSource.ts | 11 +- packages/core/src/tdgp/types.ts | 2 + packages/core/src/utils/rowFlattening.ts | 22 ++-- .../react/src/demos/tdgp/TdgpDemo.tsx | 7 +- .../react/src/demos/tdgp/tdgp.demo-data.ts | 2 +- packages/examples/react/vite.config.ts | 12 ++ .../rowGroupExpandLoadingCache.test.tsx | 120 ++++++++++++++++++ .../serverSidePaginationLoading.test.tsx | 83 ++++++++++++ 10 files changed, 269 insertions(+), 24 deletions(-) create mode 100644 packages/react/src/__tests__/rowGroupExpandLoadingCache.test.tsx create mode 100644 packages/react/src/__tests__/serverSidePaginationLoading.test.tsx diff --git a/packages/core/src/__tests__/tdgpTableSource.test.ts b/packages/core/src/__tests__/tdgpTableSource.test.ts index d6e270ec3..47dcf9468 100644 --- a/packages/core/src/__tests__/tdgpTableSource.test.ts +++ b/packages/core/src/__tests__/tdgpTableSource.test.ts @@ -187,6 +187,7 @@ describe("createTdgpTableSource", () => { [TDGP_GROUP_KEYS]: ["France"], }); expect(top.tableProps.rowGrouping).toEqual([TDGP_CHILDREN_ACCESSOR]); + expect(top.tableProps.expandAll).toBe(false); expect(top.columns[0]?.expandable).toBe(true); const setLoading = vi.fn(); diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index 542c9bfeb..2b8d406b1 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -33,6 +33,20 @@ import type { RenderContext, RenderState } from "./RenderContext"; export type { RenderContext, RenderState } from "./RenderContext"; +/** Per-row loading, error, and empty flags used when flattening expanded groups. */ +function rowStateFlagsKey( + map: Map, +): string { + if (map.size === 0) return ""; + const parts: string[] = []; + for (const [id, state] of map) { + parts.push( + `${String(id)}:${state.loading ? 1 : 0}:${state.error ? 1 : 0}:${state.isEmpty ? 1 : 0}`, + ); + } + return parts.join("|"); +} + interface FlattenedRowsCache { aggregatedRows: Row[]; quickFilteredRows: Row[]; @@ -46,7 +60,7 @@ interface FlattenedRowsCache { expandedRowsSize: number; collapsedRowsSize: number; expandedDepthsSize: number; - rowStateMapSize: number; + rowStateKey: string; sortKey: string; filterKey: string; }; @@ -270,6 +284,7 @@ export class RenderOrchestrator { const q = context.config.quickFilter; const quickFilterKey = q ? `${q.text ?? ""}|${q.mode ?? "simple"}` : ""; + const rowStateKey = rowStateFlagsKey(context.rowStateMap); const canUseCache = this.flattenedRowsCache && @@ -279,7 +294,7 @@ export class RenderOrchestrator { this.flattenedRowsCache.deps.expandedRowsSize === context.expandedRows.size && this.flattenedRowsCache.deps.collapsedRowsSize === context.collapsedRows.size && this.flattenedRowsCache.deps.expandedDepthsSize === context.expandedDepths.size && - this.flattenedRowsCache.deps.rowStateMapSize === context.rowStateMap.size && + this.flattenedRowsCache.deps.rowStateKey === rowStateKey && this.flattenedRowsCache.deps.sortKey === sortKey && this.flattenedRowsCache.deps.filterKey === filterKey; @@ -306,8 +321,9 @@ export class RenderOrchestrator { quickFilter: context.config.quickFilter, }); - // Append after aggregate/filter so placeholders keep WeakSet identity and - // are not dropped by quick filter. Empty → full skeleton page; else append. + // Skeleton rows are built after aggregate/filter so their identity survives + // those passes. No rows, or a server-side page fetch: show only skeletons. + // Otherwise append them under the current rows (load more / infinite scroll). let rowsToFlatten = quickFilteredRows; let hasLoadingPlaceholders = false; if (isLoading) { @@ -316,8 +332,11 @@ export class RenderOrchestrator { rowsToShow += 1; } const placeholders = createLoadingPlaceholderRows(rowsToShow); - rowsToFlatten = - rowsToFlatten.length === 0 ? placeholders : [...rowsToFlatten, ...placeholders]; + const replaceWithSkeletons = + rowsToFlatten.length === 0 || Boolean(context.config.serverSidePagination); + rowsToFlatten = replaceWithSkeletons + ? placeholders + : [...rowsToFlatten, ...placeholders]; hasLoadingPlaceholders = true; } @@ -351,7 +370,7 @@ export class RenderOrchestrator { expandedRowsSize: context.expandedRows.size, collapsedRowsSize: context.collapsedRows.size, expandedDepthsSize: context.expandedDepths.size, - rowStateMapSize: context.rowStateMap.size, + rowStateKey, sortKey, filterKey, }, diff --git a/packages/core/src/tdgp/createTdgpTableSource.ts b/packages/core/src/tdgp/createTdgpTableSource.ts index 5b7d1d45b..6c53c78e2 100644 --- a/packages/core/src/tdgp/createTdgpTableSource.ts +++ b/packages/core/src/tdgp/createTdgpTableSource.ts @@ -125,14 +125,15 @@ export function createTdgpTableSource( buildRequest({ groupKeys: parentKeys, start: 0, limit: Math.max(pageSize(), 500) }), ); const childRows = mapResponseRows(response.data, parentKeys.length); + props.setLoading(false); if (childRows.length === 0) { props.setEmpty(true, "No rows"); return; } rows = setNestedChildren(rows as Row[], props.rowIndexPath, keys, childRows as Row[]) as TData[]; emit(); - props.setLoading(false); } catch (err) { + props.setLoading(false); props.setError(err instanceof Error ? err.message : "Failed to load rows"); } }; @@ -185,7 +186,13 @@ export function createTdgpTableSource( onSortChange: handleSortChange, onFilterChange: handleFilterChange, getRowId, - ...(keys ? { rowGrouping: keys, onRowGroupExpand: handleRowGroupExpand } : {}), + ...(keys + ? { + rowGrouping: keys, + onRowGroupExpand: handleRowGroupExpand, + expandAll: false, + } + : {}), }; } diff --git a/packages/core/src/tdgp/types.ts b/packages/core/src/tdgp/types.ts index 6b2cd9a6f..89f6996ce 100644 --- a/packages/core/src/tdgp/types.ts +++ b/packages/core/src/tdgp/types.ts @@ -109,6 +109,8 @@ export type TdgpTableProps = { getRowId: GetRowId; rowGrouping?: Accessor[]; onRowGroupExpand?: (props: OnRowGroupExpandProps) => void | Promise; + /** Groups start collapsed. Children load when the user expands a row. */ + expandAll?: false; }; export type TdgpTableSnapshot = { diff --git a/packages/core/src/utils/rowFlattening.ts b/packages/core/src/utils/rowFlattening.ts index d97ce57d5..5f5760bac 100644 --- a/packages/core/src/utils/rowFlattening.ts +++ b/packages/core/src/utils/rowFlattening.ts @@ -235,6 +235,17 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { }, absoluteRowIndex: nestedGridPosition, }); + } else if (nestedRows.length > 0) { + const nestedIdPath = [...rowPath, currentGroupingKey]; + const nestedIndexPath = [...rowIndexPath]; + processRows( + nestedRows, + currentDepth + 1, + nestedIdPath, + nestedIndexPath, + [...parentIndices, currentRowIndex], + stableRowKey + ); } else if (rowState && (rowState.loading || rowState.error || rowState.isEmpty)) { const shouldShowState = (rowState.loading && hasLoadingRenderer) || @@ -281,17 +292,6 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { parentIndices: [...parentIndices, currentRowIndex], }); } - } else if (nestedRows.length > 0) { - const nestedIdPath = [...rowPath, currentGroupingKey]; - const nestedIndexPath = [...rowIndexPath]; - processRows( - nestedRows, - currentDepth + 1, - nestedIdPath, - nestedIndexPath, - [...parentIndices, currentRowIndex], - stableRowKey - ); } } diff --git a/packages/examples/react/src/demos/tdgp/TdgpDemo.tsx b/packages/examples/react/src/demos/tdgp/TdgpDemo.tsx index 1280ce541..9ecabc567 100644 --- a/packages/examples/react/src/demos/tdgp/TdgpDemo.tsx +++ b/packages/examples/react/src/demos/tdgp/TdgpDemo.tsx @@ -32,9 +32,10 @@ const TdgpDemo = ({ return (

- Live rows from {TDGP_SERVER_URL} ({TDGP_DATASET}). Next page, sort, and - column filters ask the server for a new slice. Expand a country to load - stacks, then people. + Live rows from {TDGP_SERVER_URL} ({TDGP_DATASET}). This query returns + countries (not the 10k people). Next page loads more countries. Click + the arrow in the Country column to load stacks, then people. Sort and + column filters ask the server for a new slice. {totalRowCount > 0 ? ` ${totalRowCount.toLocaleString()} rows on the server.` : ""}

{error ? ( diff --git a/packages/examples/react/src/demos/tdgp/tdgp.demo-data.ts b/packages/examples/react/src/demos/tdgp/tdgp.demo-data.ts index ddc300ed0..51e68927f 100644 --- a/packages/examples/react/src/demos/tdgp/tdgp.demo-data.ts +++ b/packages/examples/react/src/demos/tdgp/tdgp.demo-data.ts @@ -18,7 +18,7 @@ export type TdgpDeveloper = { export const TDGP_DATASET = "developers-10k"; export const TDGP_SERVER_URL = "https://data.thedatagrid.com"; -export const TDGP_PAGE_SIZE = 25; +export const TDGP_PAGE_SIZE = 5; export const TDGP_GROUP_BY = ["country", "stack"]; const currency = (value: number | string | null | undefined) => diff --git a/packages/examples/react/vite.config.ts b/packages/examples/react/vite.config.ts index 9bdb6dd02..bfc717775 100644 --- a/packages/examples/react/vite.config.ts +++ b/packages/examples/react/vite.config.ts @@ -4,15 +4,27 @@ import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const reactDir = path.resolve(__dirname, "node_modules/react"); +const reactDomDir = path.resolve(__dirname, "node_modules/react-dom"); export default defineConfig({ plugins: [react()], server: { port: 5200 }, resolve: { + // Resolve React from this app so the table package and the example share one copy. + dedupe: ["react", "react-dom"], alias: [ + { find: /^react$/, replacement: reactDir }, + { find: /^react\/jsx-runtime$/, replacement: path.join(reactDir, "jsx-runtime.js") }, + { find: /^react\/jsx-dev-runtime$/, replacement: path.join(reactDir, "jsx-dev-runtime.js") }, + { find: /^react-dom$/, replacement: reactDomDir }, + { find: /^react-dom\/client$/, replacement: path.join(reactDomDir, "client.js") }, { find: "@simple-table/react/styles.css", replacement: path.resolve(__dirname, "../../core/src/styles/base.css") }, { find: "@simple-table/react", replacement: path.resolve(__dirname, "../../react/src/index.ts") }, { find: "simple-table-core", replacement: path.resolve(__dirname, "../../core/src/index.ts") }, ], }, + optimizeDeps: { + include: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"], + }, }); diff --git a/packages/react/src/__tests__/rowGroupExpandLoadingCache.test.tsx b/packages/react/src/__tests__/rowGroupExpandLoadingCache.test.tsx new file mode 100644 index 000000000..87f101999 --- /dev/null +++ b/packages/react/src/__tests__/rowGroupExpandLoadingCache.test.tsx @@ -0,0 +1,120 @@ +import { createElement, useState, type Dispatch, type SetStateAction } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { SimpleTable } from "../index"; +import type { OnRowGroupExpandProps, ReactColumnDef } from "../index"; + +// Lazy expand: setLoading(true), write children onto the row, setLoading(false) +// without waiting. The flatten cache must not keep the loading skeleton after +// loading flips off on the same map size. + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + root?.unmount(); + root = null; + container?.remove(); + container = null; + setRowsRef.current = null; +}); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await wait(20); + } + throw new Error("Timed out waiting for condition"); +} + +async function waitForElement( + scope: HTMLElement, + selector: string, + timeoutMs = 4000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const el = scope.querySelector(selector); + if (el) return el; + await wait(20); + } + throw new Error(`Timed out waiting for element: ${selector}`); +} + +const headers: ReactColumnDef[] = [ + { accessor: "name", label: "Name", width: 200, type: "string", expandable: true }, + { accessor: "count", label: "Count", width: 80, type: "number" }, +]; + +interface ChildRow { + id: string; + name: string; + count: number; +} + +interface ParentRow { + id: string; + name: string; + count: number; + children?: ChildRow[]; +} + +const CHILD: ChildRow = { id: "child-1", name: "Backend", count: 3 }; + +const setRowsRef: { current: Dispatch> | null } = { current: null }; + +function findExpandIcon(host: HTMLElement, name: string): HTMLElement | null { + const nameCell = Array.from( + host.querySelectorAll('.st-cell[data-accessor="name"]'), + ).find((cell) => cell.textContent?.includes(name)); + if (!nameCell) return null; + const icon = nameCell.querySelector(".st-expand-icon-container"); + if (!icon || icon.getAttribute("aria-hidden") === "true") return null; + return icon as HTMLElement; +} + +describe("SimpleTable (React adapter) — lazy expand loading cache", () => { + it("shows loaded children after setLoading(true) then rows then setLoading(false)", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + container = host; + root = createRoot(host); + + const Harness = () => { + const [rows, setRows] = useState(() => [ + { id: "country-1", name: "Argentina", count: 10 }, + ]); + setRowsRef.current = setRows; + + return createElement(SimpleTable, { + columns: headers, + rows, + height: "280px", + theme: "light", + rowGrouping: ["children"], + expandAll: false, + getRowId: (p) => String((p.row as ParentRow).id), + onRowGroupExpand: async ({ isExpanded, setLoading }: OnRowGroupExpandProps) => { + if (!isExpanded) return; + setLoading(true); + await wait(40); + setRowsRef.current?.((prev) => [{ ...prev[0], children: [CHILD] }]); + setLoading(false); + }, + }); + }; + + root.render(createElement(Harness)); + await waitForElement(host, ".st-body-container .st-cell"); + + const icon = findExpandIcon(host, "Argentina"); + expect(icon).toBeTruthy(); + icon!.click(); + + await waitFor(() => host.textContent?.includes("Backend") ?? false); + expect(host.querySelectorAll('.st-cell[data-row-id*="loading-skeleton"]').length).toBe(0); + }); +}); diff --git a/packages/react/src/__tests__/serverSidePaginationLoading.test.tsx b/packages/react/src/__tests__/serverSidePaginationLoading.test.tsx new file mode 100644 index 000000000..3762d82e4 --- /dev/null +++ b/packages/react/src/__tests__/serverSidePaginationLoading.test.tsx @@ -0,0 +1,83 @@ +import { createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { SimpleTable } from "../index"; +import type { ReactColumnDef } from "../index"; + +// Server-side pagination: `rows` is the current page. While `isLoading` is +// true, the body shows a skeleton page instead of the old page plus skeletons +// underneath (that append path is for load-more / infinite scroll). + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + root?.unmount(); + root = null; + container?.remove(); + container = null; +}); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForElement( + scope: HTMLElement, + selector: string, + timeoutMs = 3000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const el = scope.querySelector(selector); + if (el) return el; + await wait(20); + } + throw new Error(`Timed out waiting for element: ${selector}`); +} + +const headers: ReactColumnDef[] = [ + { accessor: "name", label: "Name", width: 160, type: "string" }, + { accessor: "age", label: "Age", width: 80, type: "number" }, +]; + +const pageOne = [ + { id: "r1", name: "Alice", age: 30 }, + { id: "r2", name: "Bob", age: 40 }, +]; + +describe("SimpleTable (React adapter) — server-side pagination loading", () => { + it("replaces the current page with skeletons when isLoading is true", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + container = host; + root = createRoot(host); + + const renderWith = (isLoading: boolean) => + root!.render( + createElement(SimpleTable, { + columns: headers, + rows: pageOne, + isLoading, + enablePagination: true, + serverSidePagination: true, + rowsPerPage: 2, + totalRowCount: 10, + getRowId: (p) => String((p.row as { id?: unknown })?.id), + height: "250px", + theme: "light", + }), + ); + + renderWith(false); + await waitForElement(host, ".st-body-container .st-cell"); + await wait(80); + expect(host.textContent).toContain("Alice"); + expect(host.textContent).toContain("Bob"); + + renderWith(true); + await wait(150); + + expect(host.textContent).not.toContain("Alice"); + expect(host.textContent).not.toContain("Bob"); + expect(host.querySelectorAll(".st-loading-skeleton").length).toBeGreaterThan(0); + }); +}); From a12e80c8175a31e6417ac9b0bc27c1a25db843d0 Mon Sep 17 00:00:00 2001 From: Peter Young <20213436+petera2c@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:58:14 -0500 Subject: [PATCH 4/4] Add TDGP helpers for Vue, Solid, Svelte, Angular, and vanilla so each wrapper can load server pages without wiring the source by hand. Co-authored-by: Cursor --- .../scripts/generate-search-index.ts | 2 +- apps/marketing/src/app/docs/tdgp/page.tsx | 2 + apps/marketing/src/app/sitemap.ts | 1 + .../pages/docs-pages/TdgpContent.tsx | 68 +++-- apps/marketing/src/constants/changelog.ts | 22 +- .../marketing/src/constants/docsNavigation.ts | 7 - .../src/constants/docsSearchIndex.json | 31 --- apps/marketing/src/constants/docsSnippets.ts | 179 ++++++++----- apps/marketing/src/constants/strings/seo.ts | 2 +- packages/angular/package.json | 2 +- .../angular/src/__tests__/tableProps.test.ts | 89 +++++++ .../src/__tests__/useTdgpTable.test.ts | 84 ++++++ packages/angular/src/index.ts | 36 +++ .../angular/src/lib/SimpleTableComponent.ts | 6 + packages/angular/src/tdgp/useTdgpTable.ts | 71 ++++++ packages/core/package.json | 2 +- .../core/src/__tests__/mountTdgpTable.test.ts | 50 ++++ .../src/__tests__/tdgpTableSource.test.ts | 199 +++++++++++++++ packages/core/src/index.ts | 3 + packages/core/src/tdgp/index.ts | 2 + packages/core/src/tdgp/mountTdgpTable.ts | 60 +++++ packages/core/src/tdgp/types.ts | 2 +- packages/react/package.json | 2 +- .../src/__tests__/tdgpSimpleTable.test.tsx | 241 ++++++++++++++++++ packages/react/src/index.ts | 3 + packages/react/vitest.config.ts | 1 + packages/solid/package.json | 2 +- .../solid/src/__tests__/useTdgpTable.test.tsx | 112 ++++++++ packages/solid/src/index.ts | 36 +++ packages/solid/src/tdgp/useTdgpTable.ts | 71 ++++++ packages/svelte/package.json | 2 +- .../src/__tests__/createTdgpTable.test.ts | 83 ++++++ packages/svelte/src/index.ts | 36 +++ packages/svelte/src/tdgp/createTdgpTable.ts | 89 +++++++ packages/vue/package.json | 2 +- .../vue/src/__tests__/useTdgpTable.test.ts | 114 +++++++++ packages/vue/src/index.ts | 36 +++ packages/vue/src/tdgp/useTdgpTable.ts | 73 ++++++ 38 files changed, 1679 insertions(+), 144 deletions(-) create mode 100644 packages/angular/src/__tests__/tableProps.test.ts create mode 100644 packages/angular/src/__tests__/useTdgpTable.test.ts create mode 100644 packages/angular/src/tdgp/useTdgpTable.ts create mode 100644 packages/core/src/__tests__/mountTdgpTable.test.ts create mode 100644 packages/core/src/tdgp/mountTdgpTable.ts create mode 100644 packages/react/src/__tests__/tdgpSimpleTable.test.tsx create mode 100644 packages/solid/src/__tests__/useTdgpTable.test.tsx create mode 100644 packages/solid/src/tdgp/useTdgpTable.ts create mode 100644 packages/svelte/src/__tests__/createTdgpTable.test.ts create mode 100644 packages/svelte/src/tdgp/createTdgpTable.ts create mode 100644 packages/vue/src/__tests__/useTdgpTable.test.ts create mode 100644 packages/vue/src/tdgp/useTdgpTable.ts diff --git a/apps/marketing/scripts/generate-search-index.ts b/apps/marketing/scripts/generate-search-index.ts index 6f9656380..c2719beb3 100644 --- a/apps/marketing/scripts/generate-search-index.ts +++ b/apps/marketing/scripts/generate-search-index.ts @@ -20,7 +20,7 @@ const docsContentPath = path.join(__dirname, "../src/components/pages/docs-pages const outputPath = path.join(__dirname, "../src/constants/docsSearchIndex.json"); /** Temporarily hidden from nav/search. */ -const HIDDEN_DOC_IDS = new Set(["column-editing"]); +const HIDDEN_DOC_IDS = new Set(["column-editing", "tdgp"]); /** * Slug → Content filename when PascalCase conversion does not match the file on disk. diff --git a/apps/marketing/src/app/docs/tdgp/page.tsx b/apps/marketing/src/app/docs/tdgp/page.tsx index fa4b281d4..7eb9e0bd2 100644 --- a/apps/marketing/src/app/docs/tdgp/page.tsx +++ b/apps/marketing/src/app/docs/tdgp/page.tsx @@ -3,10 +3,12 @@ import { SEO_STRINGS } from "@/constants/strings/seo"; import TdgpContent from "@/components/pages/docs-pages/TdgpContent"; import DocsDemoCode from "@/components/DocsDemoCode"; +// Unreleased: reachable by URL, but not in nav, search, or the sitemap. export const metadata: Metadata = { title: SEO_STRINGS.tdgp.title, description: SEO_STRINGS.tdgp.description, keywords: SEO_STRINGS.tdgp.keywords, + robots: { index: false, follow: false }, openGraph: { title: SEO_STRINGS.tdgp.title, description: SEO_STRINGS.tdgp.description, diff --git a/apps/marketing/src/app/sitemap.ts b/apps/marketing/src/app/sitemap.ts index 3ee2239b7..91d60407f 100644 --- a/apps/marketing/src/app/sitemap.ts +++ b/apps/marketing/src/app/sitemap.ts @@ -112,6 +112,7 @@ const NON_INDEXABLE_SEGMENTS = new Set([ "/mobile-unsupported", // Temporarily hidden from sitemap; page route still exists but is unlinked. "/docs/column-editing", + "/docs/tdgp", // Internal reproduction page; reachable by direct URL only. "/sandbox/context-isolation", // Internal marketing ops checklist; noindex. diff --git a/apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx b/apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx index 89719e25a..441a85534 100644 --- a/apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx +++ b/apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx @@ -24,16 +24,16 @@ const TDGP_PATTERNS: TdgpPattern[] = [ <> Install{" "} @thedatagrid/client{" "} - next to Simple Table. In React, call{" "} - useTdgpTable and - spread{" "} - tableProps onto - the table. Other frameworks use{" "} - - createTdgpTableSource - {" "} - from{" "} - simple-table-core. + next to Simple Table. Call{" "} + useTdgpTable in + React, Vue, Solid, or Angular,{" "} + createTdgpTable in + Svelte, or{" "} + mountTdgpTable in + vanilla JavaScript. Pass{" "} + tableProps to the + table (Angular uses the{" "} + tableProps input). Page changes, sorts, and column filters become server requests. Pair with{" "} { > server-side pagination - , sort, and filter hooks onto that contract. The same public server that powers AG Grid and - Infinite Table demos works here:{" "} + , sort, and filter hooks onto that contract. The protocol is documented at{" "} + thedatagrid.com/protocol + + . The public demo server is{" "} + data.thedatagrid.com + , with a live API reference at{" "} + + /docs + . @@ -177,9 +193,13 @@ const TdgpContent = () => {

Grouping

Pass{" "} - groupBy to load - group rows first. Expanding a group fetches the next level from the server. Pivot stays - client-side — use{" "} + groupBy (field + names from the catalog) to load group rows first. Use the{" "} + columns from the + snapshot so the first group column can expand. Expanding a group fetches the next level + from the server.{" "} + totalCount at a + group level is the number of groups, not leaf rows. Pivot stays client-side — use{" "} Pivot Tables {" "} @@ -196,7 +216,7 @@ const TdgpContent = () => { Options - + diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index 0d618bdb3..3f940e403 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -11,6 +11,21 @@ export interface ChangelogEntry { }[]; } +export const v4_1_9: ChangelogEntry = { + version: "4.1.9", + date: "2026-08-22", + title: "Server data with TDGP", + description: + "Connect the table to a THE DataGrid Protocol server so pages, sorts, filters, and groups load from the server.", + changes: [ + { + type: "feature", + description: + "Connect the table to a THE DataGrid Protocol (TDGP) server. Page changes, sorts, and filters load from the server — and you can group on the server too.", + }, + ], +}; + export const v4_1_8: ChangelogEntry = { version: "4.1.8", date: "2026-08-22", @@ -41,12 +56,6 @@ export const v4_1_8: ChangelogEntry = { description: "Search boxes and other inputs inside the table now use the theme text color instead of staying black.", }, - { - type: "feature", - description: - "Connect the table to a THE DataGrid Protocol (TDGP) server. Page changes, sorts, and filters load from the server — and you can group on the server too.", - link: "/docs/tdgp", - }, ], }; @@ -2698,6 +2707,7 @@ export const v1_4_4: ChangelogEntry = { // Array of all changelog entries (newest first) export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + v4_1_9, v4_1_8, v4_1_7, v4_1_6, diff --git a/apps/marketing/src/constants/docsNavigation.ts b/apps/marketing/src/constants/docsNavigation.ts index 492a8b8a4..fb54594e6 100644 --- a/apps/marketing/src/constants/docsNavigation.ts +++ b/apps/marketing/src/constants/docsNavigation.ts @@ -39,7 +39,6 @@ import { faUpDown, faGear, faWandMagicSparkles, - faPlug, } from "@fortawesome/free-solid-svg-icons"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; @@ -236,12 +235,6 @@ export const docSections: DocSection[] = [ icon: faCode, }, { id: "pagination", label: "Pagination", path: "/docs/pagination", icon: faPager }, - { - id: "tdgp", - label: "Server Data (TDGP)", - path: "/docs/tdgp", - icon: faPlug, - }, { id: "loading-state", label: "Loading State", path: "/docs/loading-state", icon: faSpinner }, { id: "empty-state", label: "Empty State", path: "/docs/empty-state", icon: faInbox }, { id: "live-updates", label: "Live Updates", path: "/docs/live-updates", icon: faBolt }, diff --git a/apps/marketing/src/constants/docsSearchIndex.json b/apps/marketing/src/constants/docsSearchIndex.json index 5f4254124..d6c1da500 100644 --- a/apps/marketing/src/constants/docsSearchIndex.json +++ b/apps/marketing/src/constants/docsSearchIndex.json @@ -1341,37 +1341,6 @@ "External / window scroll" ] }, - { - "id": "tdgp", - "path": "/docs/tdgp", - "title": "Connect Simple Table to a TDGP Server", - "description": "Load pages, sorts, and filters from a THE DataGrid Protocol (TDGP) server. Use useTdgpTable in React or createTdgpTableSource in other frameworks — no custom backend required.", - "keywords": [ - "client", - "dataset", - "columns", - "pageSize", - "primaryKey", - "groupBy", - "aggregations", - "Server Data (TDGP)", - "Grouping", - "Connect to a TDGP server", - "Server", - "Data", - "(TDGP)", - "Connect", - "TDGP", - "server" - ], - "content": "THE DataGrid Protocol (TDGP) is a shared JSON contract for asking a server for a page of rows — filtered, sorted, and optionally grouped. Simple Table maps its existing server-side pagination , sort, and filter hooks onto that contract. The same public server that powers AG Grid and Infinite Table demos works here: data.thedatagrid.com . Pass groupBy to load group rows first. Expanding a group fetches the next level from the server. Pivot stays client-side — use Pivot Tables on rows you already have. @thedatagrid/client useTdgpTable tableProps createTdgpTableSource simple-table-core groupBy Install and spread onto the table. Other frameworks use . Page changes, sorts, and column filters become server requests. Pair with isLoading Server Data (TDGP) THE DataGrid Protocol (TDGP) is a shared JSON contract for asking a server for a page of rows — filtered, sorted, and optionally grouped. Simple Table maps its existing server-side pagination , sort, and filter hooks onto that contract. The same public server that powers AG Grid and Infinite Table demos works here: data.thedatagrid.com {TDGP_PATTERNS.map((pattern) => ( Grouping Pass to load group rows first. Expanding a group fetches the next level from the server. Pivot stays client-side — use Pivot Tables Options client dataset columns pageSize primaryKey aggregations A TDGP client with a query method. createTdgpClient() from @thedatagrid/client matches this. Dataset name on the TDGP server (the route segment, not a URL). Column definitions for the table. Keep this array stable across renders. Rows per page sent to the server. Defaults to 50. Field used as the row id for leaf rows. Defaults to id. Group on the server by these fields. Expanding a group loads the next level (or leaf rows at the last level). Server aggregations for grouped rows (sum, avg, min, max, count). Values are copied onto the group row using each aggregation id. client={createTdgpClient({ url: \"https://data.thedatagrid.com\" })} dataset=\"developers-10k\" columns={columns} pageSize={50} primaryKey=\"id\" groupBy={[\"country\", \"stack\"]} aggregations={[{ id: \"salary\", field: \"salary\", fn: \"sum\" }]}", - "section": "Advanced Features", - "headings": [ - "Server Data (TDGP)", - "Grouping", - "Connect to a TDGP server" - ] - }, { "id": "themes", "path": "/docs/themes", diff --git a/apps/marketing/src/constants/docsSnippets.ts b/apps/marketing/src/constants/docsSnippets.ts index 2db73f027..d1c14dc58 100644 --- a/apps/marketing/src/constants/docsSnippets.ts +++ b/apps/marketing/src/constants/docsSnippets.ts @@ -3079,6 +3079,13 @@ new SimpleTableVanilla(container, { }; } +/** Public TDGP demo server + catalog fields for developers-10k (primaryKey is id). */ +const TDGP_SNIPPET_COLUMNS = `const columns = [ + { accessor: "firstName", label: "First name", width: 140, type: "string", filterable: true }, + { accessor: "country", label: "Country", width: 140, type: "string", filterable: true }, + { accessor: "salary", label: "Salary", width: 120, type: "number", filterable: true }, +];`; + export function tdgpSnippets(): Record { return { react: `import { SimpleTable, useTdgpTable } from "@simple-table/react"; @@ -3087,104 +3094,142 @@ import "@simple-table/react/styles.css"; const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); -const columns = [ - { accessor: "firstName", label: "First name", width: 140, type: "string", filterable: true }, - { accessor: "country", label: "Country", width: 140, type: "string", filterable: true }, - { accessor: "salary", label: "Salary", width: 120, type: "number", filterable: true }, -]; +${TDGP_SNIPPET_COLUMNS} function App() { - const { rows, tableProps } = useTdgpTable({ + const { rows, columns: tableColumns, tableProps } = useTdgpTable({ client, dataset: "developers-10k", columns, pageSize: 50, + primaryKey: "id", }); - return ; + return ( + + ); }`, - vue: `import { SimpleTable } from "@simple-table/vue"; -import { createTdgpTableSource } from "simple-table-core"; + vue: ` -// Subscribe and pass source.getSnapshot().rows / tableProps into SimpleTable.`, - angular: `import { createTdgpTableSource } from "simple-table-core"; +`, + angular: `import { Component } from "@angular/core"; +import { SimpleTableComponent, useTdgpTable } from "@simple-table/angular"; import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/angular/styles.css"; -const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); -const source = createTdgpTableSource({ - client, - dataset: "developers-10k", - columns, - pageSize: 50, -}); -source.start(); +@Component({ + selector: "tdgp-demo", + standalone: true, + imports: [SimpleTableComponent], + template: \` + + \`, +}) +export class TdgpDemoComponent { + readonly columns = [ + { accessor: "firstName", label: "First name", width: 140, type: "string", filterable: true }, + { accessor: "country", label: "Country", width: 140, type: "string", filterable: true }, + { accessor: "salary", label: "Salary", width: 120, type: "number", filterable: true }, + ]; + + readonly tdgp = useTdgpTable(() => ({ + client: createTdgpClient({ url: "https://data.thedatagrid.com" }), + dataset: "developers-10k", + columns: this.columns, + pageSize: 50, + primaryKey: "id", + })); +}`, + svelte: ` -// Subscribe and pass source.getSnapshot().rows / tableProps into SimpleTable.`, - solid: `import { SimpleTable } from "@simple-table/solid"; -import { createTdgpTableSource } from "simple-table-core"; +`, + solid: `import { SimpleTable, useTdgpTable } from "@simple-table/solid"; import { createTdgpClient } from "@thedatagrid/client"; import "@simple-table/solid/styles.css"; -const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); -const source = createTdgpTableSource({ - client, - dataset: "developers-10k", - columns, - pageSize: 50, -}); -source.start(); +${TDGP_SNIPPET_COLUMNS} -// Subscribe and pass source.getSnapshot().rows / tableProps into SimpleTable.`, - vanilla: `import { SimpleTableVanilla, createTdgpTableSource } from "simple-table-core"; +function App() { + const tdgp = useTdgpTable({ + client: createTdgpClient({ url: "https://data.thedatagrid.com" }), + dataset: "developers-10k", + columns, + pageSize: 50, + primaryKey: "id", + }); + + return ( + + ); +}`, + vanilla: `import { mountTdgpTable } from "simple-table-core"; import { createTdgpClient } from "@thedatagrid/client"; import "simple-table-core/styles.css"; -const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); -const source = createTdgpTableSource({ - client, +const container = document.getElementById("table"); +if (!container) { + throw new Error('Add a

to the page'); +} + +${TDGP_SNIPPET_COLUMNS} + +const { destroy } = mountTdgpTable(container, { + client: createTdgpClient({ url: "https://data.thedatagrid.com" }), dataset: "developers-10k", columns, pageSize: 50, -}); - -const table = new SimpleTableVanilla(container, { - columns, - rows: [], - height: "480px", - ...source.getSnapshot().tableProps, -}); -table.mount(); - -source.subscribe(() => { - const { rows, tableProps } = source.getSnapshot(); - table.update({ rows, ...tableProps }); -}); -source.start();`, + primaryKey: "id", + tableConfig: { height: "480px" }, +});`, }; } diff --git a/apps/marketing/src/constants/strings/seo.ts b/apps/marketing/src/constants/strings/seo.ts index 3476c063e..89ed80b79 100644 --- a/apps/marketing/src/constants/strings/seo.ts +++ b/apps/marketing/src/constants/strings/seo.ts @@ -1295,7 +1295,7 @@ export const SEO_STRINGS = { tdgp: { title: "Connect Simple Table to a TDGP Server", description: - "Load pages, sorts, and filters from a THE DataGrid Protocol (TDGP) server. Use useTdgpTable in React or createTdgpTableSource in other frameworks — no custom backend required.", + "Load pages, sorts, and filters from a THE DataGrid Protocol (TDGP) server. Use useTdgpTable, createTdgpTable, or mountTdgpTable — no custom backend required.", keywords: "simple-table, data-grid, tdgp, the datagrid protocol, server-side pagination, server-side sorting, server-side filtering, @thedatagrid/client, javascript data grid", }, diff --git a/packages/angular/package.json b/packages/angular/package.json index 1616827c4..9e15b7a34 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "4.1.8", + "version": "4.1.9", "type": "module", "main": "./dist/fesm2022/simple-table-angular.mjs", "module": "./dist/fesm2022/simple-table-angular.mjs", diff --git a/packages/angular/src/__tests__/tableProps.test.ts b/packages/angular/src/__tests__/tableProps.test.ts new file mode 100644 index 000000000..5e9c07d67 --- /dev/null +++ b/packages/angular/src/__tests__/tableProps.test.ts @@ -0,0 +1,89 @@ +import { wait, waitFor } from "./testUtils"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ApplicationRef, + Component, + ComponentRef, + provideZoneChangeDetection, +} from "@angular/core"; +import { bootstrapApplication } from "@angular/platform-browser"; +import { SimpleTableComponent } from "../lib/SimpleTableComponent"; +import { provideSimpleTable } from "../lib/provideSimpleTable"; +import type { AngularColumnDef, SimpleTableAngularProps } from "../types"; + +type Row = { id: string; name: string; age: number }; + +const columns: AngularColumnDef[] = [ + { accessor: "name", label: "Name", width: 160, type: "string" }, + { accessor: "age", label: "Age", width: 80, type: "number" }, +]; + +const pageOne: Row[] = [ + { id: "r1", name: "Alice", age: 30 }, + { id: "r2", name: "Bob", age: 40 }, +]; + +@Component({ + standalone: true, + imports: [SimpleTableComponent], + selector: "st-table-props-host", + template: ` + + `, +}) +class TablePropsHost { + readonly columns = columns; + readonly rows = pageOne; + tableProps: Partial> = { + isLoading: false, + enablePagination: true, + serverSidePagination: true, + rowsPerPage: 2, + totalRowCount: 10, + }; + getRowId = ({ row }: { row: Row }) => row.id; +} + +let appRef: ApplicationRef | null = null; +let hostEl: HTMLElement | null = null; + +afterEach(() => { + appRef?.destroy(); + appRef = null; + hostEl?.remove(); + hostEl = null; +}); + +describe("SimpleTable (Angular) — tableProps input", () => { + it("applies paging and loading from the tableProps bag", async () => { + hostEl = document.createElement("st-table-props-host"); + document.body.appendChild(hostEl); + + appRef = await bootstrapApplication(TablePropsHost, { + providers: [provideZoneChangeDetection(), provideSimpleTable()], + }); + const hostRef = appRef.components[0] as ComponentRef; + + await waitFor(() => hostEl?.textContent?.includes("Alice") ?? false, 3000, "Alice"); + expect(hostEl?.textContent).toContain("Bob"); + + hostRef.instance.tableProps = { + ...hostRef.instance.tableProps, + isLoading: true, + }; + hostRef.changeDetectorRef.detectChanges(); + appRef.tick(); + await wait(150); + + expect(hostEl?.textContent).not.toContain("Alice"); + expect(hostEl?.textContent).not.toContain("Bob"); + expect(hostEl?.querySelectorAll(".st-loading-skeleton").length).toBeGreaterThan(0); + }); +}); diff --git a/packages/angular/src/__tests__/useTdgpTable.test.ts b/packages/angular/src/__tests__/useTdgpTable.test.ts new file mode 100644 index 000000000..9b8cb74ec --- /dev/null +++ b/packages/angular/src/__tests__/useTdgpTable.test.ts @@ -0,0 +1,84 @@ +import { wait, waitFor } from "./testUtils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ApplicationRef, + Component, + InjectionToken, + inject, + provideZoneChangeDetection, +} from "@angular/core"; +import { bootstrapApplication } from "@angular/platform-browser"; +import { SimpleTableComponent } from "../lib/SimpleTableComponent"; +import { provideSimpleTable } from "../lib/provideSimpleTable"; +import { useTdgpTable } from "../tdgp/useTdgpTable"; +import type { TdgpQueryClient } from "simple-table-core"; +import type { AngularColumnDef } from "../types"; + +const TDGP_CLIENT = new InjectionToken("tdgp-client"); + +const columns: AngularColumnDef[] = [ + { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "name", label: "Name", width: 120, type: "string" }, +]; + +@Component({ + standalone: true, + imports: [SimpleTableComponent], + selector: "st-tdgp-test-host", + template: ` + + `, +}) +class TdgpTestHost { + private readonly client = inject(TDGP_CLIENT); + readonly tdgp = useTdgpTable(() => ({ + client: this.client, + dataset: "developers-10k", + columns, + pageSize: 10, + primaryKey: "id", + })); +} + +let appRef: ApplicationRef | null = null; +let hostEl: HTMLElement | null = null; + +afterEach(() => { + appRef?.destroy(); + appRef = null; + hostEl?.remove(); + hostEl = null; + vi.restoreAllMocks(); +}); + +describe("useTdgpTable (Angular)", () => { + it("loads the first page and binds tableProps in one input", async () => { + const query = vi.fn(async (_dataset: string) => ({ + protocol: "tdgp/1", + data: [{ id: 1, name: "Ada" }], + totalCount: 1, + })); + + hostEl = document.createElement("st-tdgp-test-host"); + document.body.appendChild(hostEl); + + appRef = await bootstrapApplication(TdgpTestHost, { + providers: [ + provideZoneChangeDetection(), + provideSimpleTable(), + { provide: TDGP_CLIENT, useValue: { query } }, + ], + }); + + await waitFor(() => query.mock.calls.length === 1, 3000, "query"); + await wait(80); + await waitFor(() => hostEl?.textContent?.includes("Ada") ?? false, 3000, "Ada"); + expect(query.mock.calls[0]?.[0]).toBe("developers-10k"); + }); +}); diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index 3607bc6b1..b9a5fca1d 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -128,4 +128,40 @@ export { PIVOT_IS_TOTAL_KEY, PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL, + createTdgpTableSource, + mountTdgpTable, + tableFiltersToTdgpFilter, + tableFilterConditions, + sortColumnToTdgpSort, + isTdgpGroupNode, + tdgpGroupNodeToRow, + tdgpGroupNodesToRows, + getTdgpGroupKeys, + setNestedChildren, + TDGP_CHILDREN_ACCESSOR, + TDGP_GROUP_KEYS, } from "simple-table-core"; + +export type { + TdgpAggregation, + TdgpAggregationFn, + TdgpFilterGroup, + TdgpFilterModel, + TdgpFilterNot, + TdgpFilterOperator, + TdgpFilterPredicate, + TdgpGroupNode, + TdgpQueryClient, + TdgpQueryRequest, + TdgpQueryResponse, + TdgpSortModel, + TdgpTableProps, + TdgpTableSnapshot, + TdgpTableSource, + TdgpTableSourceOptions, + MountTdgpTableOptions, + MountedTdgpTable, +} from "simple-table-core"; + +export { useTdgpTable } from "./tdgp/useTdgpTable"; +export type { UseTdgpTableOptions } from "./tdgp/useTdgpTable"; diff --git a/packages/angular/src/lib/SimpleTableComponent.ts b/packages/angular/src/lib/SimpleTableComponent.ts index ad8a50637..36bf8bb4a 100644 --- a/packages/angular/src/lib/SimpleTableComponent.ts +++ b/packages/angular/src/lib/SimpleTableComponent.ts @@ -109,6 +109,11 @@ export class SimpleTableComponent< @Input() hoverRowBackground?: SimpleTableAngularProps["hoverRowBackground"]; @Input() oddColumnBackground?: SimpleTableAngularProps["oddColumnBackground"]; @Input() oddEvenRowBackground?: SimpleTableAngularProps["oddEvenRowBackground"]; + /** + * Server-driven paging, sort, filter, and grouping props (from useTdgpTable). + * Explicit inputs on this component override matching keys on the bag. + */ + @Input() tableProps?: Partial>; /** Emits the TableAPI once the table has mounted. */ @Output() tableReady = new EventEmitter>(); @@ -207,6 +212,7 @@ export class SimpleTableComponent< private getProps(): SimpleTableAngularProps { const props: SimpleTableAngularProps = { + ...(this.tableProps ?? {}), rows: this.rows, }; diff --git a/packages/angular/src/tdgp/useTdgpTable.ts b/packages/angular/src/tdgp/useTdgpTable.ts new file mode 100644 index 000000000..f9e948abd --- /dev/null +++ b/packages/angular/src/tdgp/useTdgpTable.ts @@ -0,0 +1,71 @@ +import { DestroyRef, computed, effect, inject, signal, untracked } from "@angular/core"; +import { + createTdgpTableSource, + type TdgpTableSourceOptions, +} from "simple-table-core"; +import type { AngularColumnDef, AngularDefaultRowData } from "../types"; + +export type UseTdgpTableOptions = Omit< + TdgpTableSourceOptions, + "columns" +> & { + columns: AngularColumnDef[]; +}; + +function resolveOptions( + options: UseTdgpTableOptions | (() => UseTdgpTableOptions), +): UseTdgpTableOptions { + return typeof options === "function" ? options() : options; +} + +function toSourceOptions( + options: UseTdgpTableOptions, +): TdgpTableSourceOptions { + return { + ...options, + columns: options.columns as TdgpTableSourceOptions["columns"], + }; +} + +/** + * Loads rows from a TDGP server and returns Simple Table props for + * server-side paging, sorting, filtering, and optional grouping. + * + * Call from an injection context (component field or constructor). Pass a + * getter if dataset, page size, or other query options should update. Bind + * `[tableProps]` on `` for paging, sort, and filter. + */ +export function useTdgpTable( + options: UseTdgpTableOptions | (() => UseTdgpTableOptions), +) { + const destroyRef = inject(DestroyRef); + const source = createTdgpTableSource(toSourceOptions(resolveOptions(options))); + const snapshot = signal(source.getSnapshot()); + + const unsubscribe = source.subscribe(() => { + snapshot.set(source.getSnapshot()); + }); + + effect(() => { + const next = resolveOptions(options); + untracked(() => { + source.applyOptions(toSourceOptions(next)); + }); + }); + + source.start(); + + destroyRef.onDestroy(() => { + source.stop(); + unsubscribe(); + }); + + return { + rows: computed(() => snapshot().rows), + columns: computed(() => snapshot().columns as AngularColumnDef[]), + tableProps: computed(() => snapshot().tableProps), + error: computed(() => snapshot().error), + isLoading: computed(() => snapshot().isLoading), + totalRowCount: computed(() => snapshot().totalRowCount), + }; +} diff --git a/packages/core/package.json b/packages/core/package.json index c736f397c..c6d17ec5e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "4.1.8", + "version": "4.1.9", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/index.d.ts", diff --git a/packages/core/src/__tests__/mountTdgpTable.test.ts b/packages/core/src/__tests__/mountTdgpTable.test.ts new file mode 100644 index 000000000..be293c3ea --- /dev/null +++ b/packages/core/src/__tests__/mountTdgpTable.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { mountTdgpTable } from "../tdgp/mountTdgpTable"; +import type { ColumnDef } from "../index"; +import type { TdgpQueryClient } from "../tdgp/types"; + +const columns: ColumnDef[] = [ + { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "name", label: "Name", width: 120, type: "string" }, +]; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await wait(20); + } + throw new Error("Timed out waiting for condition"); +} + +describe("mountTdgpTable", () => { + it("mounts the table and loads the first page", async () => { + const query = vi.fn(async (_dataset: string) => ({ + protocol: "tdgp/1", + data: [{ id: 1, name: "Ada" }], + totalCount: 1, + })); + + const host = document.createElement("div"); + document.body.appendChild(host); + + const mounted = mountTdgpTable(host, { + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 10, + primaryKey: "id", + tableConfig: { height: "250px" }, + }); + + try { + await waitFor(() => query.mock.calls.length === 1 && host.textContent?.includes("Ada") === true); + expect(query.mock.calls[0]?.[0]).toBe("developers-10k"); + } finally { + mounted.destroy(); + host.remove(); + } + }); +}); diff --git a/packages/core/src/__tests__/tdgpTableSource.test.ts b/packages/core/src/__tests__/tdgpTableSource.test.ts index 47dcf9468..f7c428736 100644 --- a/packages/core/src/__tests__/tdgpTableSource.test.ts +++ b/packages/core/src/__tests__/tdgpTableSource.test.ts @@ -306,4 +306,203 @@ describe("createTdgpTableSource", () => { expect(query).toHaveBeenCalledWith("developers-50k", expect.objectContaining({ start: 0 })); }); + + it("replaces the current page and sets isLoading while the next page loads", async () => { + let resolvePageTwo: ((value: { + protocol: string; + data: Array<{ id: number; country: string }>; + totalCount: number; + }) => void) | undefined; + + const query = vi.fn(async (_dataset: string, request?: { start?: number }) => { + if ((request?.start ?? 0) === 0) { + return { + protocol: "tdgp/1", + data: [{ id: 1, country: "France" }], + totalCount: 100, + }; + } + return new Promise((resolve) => { + resolvePageTwo = resolve; + }); + }); + + const pageTwo = { + protocol: "tdgp/1", + data: [{ id: 2, country: "Spain" }], + totalCount: 100, + }; + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 1, + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + expect(source.getSnapshot().rows).toEqual([{ id: 1, country: "France" }]); + + source.getSnapshot().tableProps.onPageChange(2); + await waitFor(source, () => source.getSnapshot().isLoading === true); + expect(source.getSnapshot().rows).toEqual([{ id: 1, country: "France" }]); + + resolvePageTwo?.(pageTwo); + await waitFor(source, () => source.getSnapshot().isLoading === false); + expect(source.getSnapshot().rows).toEqual([{ id: 2, country: "Spain" }]); + }); + + it("pages grouped rows with start and limit, not the leaf row count", async () => { + const query = vi.fn(async (_dataset: string, request?: { start?: number; limit?: number }) => ({ + protocol: "tdgp/1", + data: [ + { + keys: [request?.start === 5 ? "Spain" : "France"], + data: { country: request?.start === 5 ? "Spain" : "France" }, + }, + ], + totalCount: 10, + })); + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 5, + groupBy: ["country"], + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + + expect(source.getSnapshot().totalRowCount).toBe(10); + expect(source.getSnapshot().tableProps.rowsPerPage).toBe(5); + expect(source.getSnapshot().tableProps.expandAll).toBe(false); + + source.getSnapshot().tableProps.onPageChange(2); + await waitFor(source, () => requestArgs(query).some((request) => request.start === 5)); + expect(query).toHaveBeenCalledWith( + "developers-10k", + expect.objectContaining({ start: 5, limit: 5, groupBy: [{ field: "country" }] }), + ); + }); + + it("does not fetch children when a group is collapsed", async () => { + const query = vi.fn(async () => ({ + protocol: "tdgp/1", + data: [ + { + keys: ["France"], + data: { country: "France" }, + }, + ], + totalCount: 1, + })); + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + groupBy: ["country"], + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + expect(query).toHaveBeenCalledTimes(1); + + await source.getSnapshot().tableProps.onRowGroupExpand?.({ + row: source.getSnapshot().rows[0], + depth: 0, + event: new MouseEvent("click"), + groupingKey: TDGP_CHILDREN_ACCESSOR, + isExpanded: false, + rowIndexPath: [0], + groupingKeys: [TDGP_CHILDREN_ACCESSOR], + setLoading: vi.fn(), + setError: vi.fn(), + setEmpty: vi.fn(), + }); + + expect(query).toHaveBeenCalledTimes(1); + expect(source.getSnapshot().rows[0][TDGP_CHILDREN_ACCESSOR]).toEqual([]); + }); + + it("loads stacks then people for a two-level group", async () => { + const query = vi.fn(async (_dataset: string, request?: { groupKeys?: string[] }) => { + const keys = request?.groupKeys ?? []; + if (keys.length === 0) { + return { + protocol: "tdgp/1", + data: [{ keys: ["France"], data: { country: "France" } }], + totalCount: 1, + }; + } + if (keys.length === 1) { + return { + protocol: "tdgp/1", + data: [{ keys: ["France", "backend"], data: { country: "France", stack: "backend" } }], + totalCount: 1, + }; + } + return { + protocol: "tdgp/1", + data: [{ id: 11, firstName: "Ada", country: "France", stack: "backend" }], + totalCount: 1, + }; + }); + + const source = createTdgpTableSource({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + groupBy: ["country", "stack"], + }); + source.start(); + await waitFor(source, () => !source.getSnapshot().isLoading); + + const country = source.getSnapshot().rows[0]; + expect(country[TDGP_CHILDREN_ACCESSOR]).toEqual([]); + + await source.getSnapshot().tableProps.onRowGroupExpand?.({ + row: country, + depth: 0, + event: new MouseEvent("click"), + groupingKey: TDGP_CHILDREN_ACCESSOR, + isExpanded: true, + rowIndexPath: [0], + groupingKeys: [TDGP_CHILDREN_ACCESSOR, TDGP_CHILDREN_ACCESSOR], + setLoading: vi.fn(), + setError: vi.fn(), + setEmpty: vi.fn(), + }); + + const stacks = source.getSnapshot().rows[0][TDGP_CHILDREN_ACCESSOR] as Row[]; + expect(stacks).toHaveLength(1); + expect(stacks[0]).toMatchObject({ + stack: "backend", + [TDGP_GROUP_KEYS]: ["France", "backend"], + }); + + await source.getSnapshot().tableProps.onRowGroupExpand?.({ + row: stacks[0], + depth: 1, + event: new MouseEvent("click"), + groupingKey: TDGP_CHILDREN_ACCESSOR, + isExpanded: true, + rowIndexPath: [0, 0], + groupingKeys: [TDGP_CHILDREN_ACCESSOR, TDGP_CHILDREN_ACCESSOR], + setLoading: vi.fn(), + setError: vi.fn(), + setEmpty: vi.fn(), + }); + + const people = ( + source.getSnapshot().rows[0][TDGP_CHILDREN_ACCESSOR] as Row[] + )[0][TDGP_CHILDREN_ACCESSOR]; + expect(people).toEqual([ + { id: 11, firstName: "Ada", country: "France", stack: "backend" }, + ]); + expect(query).toHaveBeenCalledWith( + "developers-10k", + expect.objectContaining({ groupKeys: ["France", "backend"] }), + ); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25850ce22..e85f5eeed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -115,6 +115,7 @@ export { }; export { createTdgpTableSource, + mountTdgpTable, tableFiltersToTdgpFilter, sortColumnToTdgpSort, isTdgpGroupNode, @@ -143,6 +144,8 @@ export type { TdgpTableSnapshot, TdgpTableSource, TdgpTableSourceOptions, + MountTdgpTableOptions, + MountedTdgpTable, } from "./tdgp"; export { headersStructurallyEqual, diff --git a/packages/core/src/tdgp/index.ts b/packages/core/src/tdgp/index.ts index fec8bc737..8dde89c80 100644 --- a/packages/core/src/tdgp/index.ts +++ b/packages/core/src/tdgp/index.ts @@ -1,4 +1,5 @@ export { createTdgpTableSource } from "./createTdgpTableSource"; +export { mountTdgpTable } from "./mountTdgpTable"; export { tableFiltersToTdgpFilter } from "./tableFiltersToTdgpFilter"; export { sortColumnToTdgpSort } from "./sortColumnToTdgpSort"; export { @@ -27,3 +28,4 @@ export type { TdgpTableSource, TdgpTableSourceOptions, } from "./types"; +export type { MountTdgpTableOptions, MountedTdgpTable } from "./mountTdgpTable"; diff --git a/packages/core/src/tdgp/mountTdgpTable.ts b/packages/core/src/tdgp/mountTdgpTable.ts new file mode 100644 index 000000000..8c3879a85 --- /dev/null +++ b/packages/core/src/tdgp/mountTdgpTable.ts @@ -0,0 +1,60 @@ +import { SimpleTableVanilla } from "../core/SimpleTableVanilla"; +import type { SimpleTableConfigInput } from "../utils/normalizeConfig"; +import type { RowData } from "../types/Row"; +import type Row from "../types/Row"; +import { createTdgpTableSource } from "./createTdgpTableSource"; +import type { TdgpTableSource, TdgpTableSourceOptions } from "./types"; + +export type MountTdgpTableOptions = TdgpTableSourceOptions & { + /** Extra table config (height, theme, columnResizing). Applied after TDGP table props. */ + tableConfig?: Omit, "rows" | "columns">; +}; + +export type MountedTdgpTable = { + table: SimpleTableVanilla; + source: TdgpTableSource; + destroy: () => void; +}; + +/** + * Mounts a Simple Table that loads pages, sorts, filters, and optional groups + * from a TDGP server. Call destroy() to stop loads and remove the table. + */ +export function mountTdgpTable( + container: HTMLElement, + options: MountTdgpTableOptions, +): MountedTdgpTable { + const { tableConfig, ...sourceOptions } = options; + const source = createTdgpTableSource(sourceOptions); + const snapshot = source.getSnapshot(); + + const table = new SimpleTableVanilla(container, { + ...snapshot.tableProps, + ...tableConfig, + columns: snapshot.columns, + rows: snapshot.rows, + }); + table.mount(); + + const unsubscribe = source.subscribe(() => { + const next = source.getSnapshot(); + table.update({ + ...next.tableProps, + ...tableConfig, + columns: next.columns, + rows: next.rows, + }); + }); + + source.start(); + + return { + table, + source, + destroy() { + source.stop(); + unsubscribe(); + table.destroy(); + }, + }; +} diff --git a/packages/core/src/tdgp/types.ts b/packages/core/src/tdgp/types.ts index 89f6996ce..f4e0c5cf9 100644 --- a/packages/core/src/tdgp/types.ts +++ b/packages/core/src/tdgp/types.ts @@ -139,7 +139,7 @@ export type TdgpTableSourceOptions = { export type TdgpTableSource = { subscribe: (listener: () => void) => () => void; getSnapshot: () => TdgpTableSnapshot; - /** Load the current page. Called once from the React hook on mount. */ + /** Load the current page. Call once after subscribe. */ start: () => void; /** Ignore in-flight responses and stop later loads. */ stop: () => void; diff --git a/packages/react/package.json b/packages/react/package.json index 3004e79d2..30953345a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/react", - "version": "4.1.8", + "version": "4.1.9", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/react/src/__tests__/tdgpSimpleTable.test.tsx b/packages/react/src/__tests__/tdgpSimpleTable.test.tsx new file mode 100644 index 000000000..e3a6faab5 --- /dev/null +++ b/packages/react/src/__tests__/tdgpSimpleTable.test.tsx @@ -0,0 +1,241 @@ +import { createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SimpleTable, useTdgpTable } from "../index"; +import type { ReactColumnDef } from "../index"; +import type { TdgpQueryClient, TdgpQueryRequest } from "simple-table-core"; + +// Next/prev and expand go through the footer and chevron, the same path as the +// live TDGP demo — not by calling source methods directly. + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + root?.unmount(); + root = null; + container?.remove(); + container = null; + vi.restoreAllMocks(); +}); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await wait(20); + } + throw new Error("Timed out waiting for condition"); +} + +const columns: ReactColumnDef[] = [ + { accessor: "country", label: "Country", width: 160, type: "string", expandable: true }, + { accessor: "stack", label: "Stack", width: 140, type: "string" }, + { accessor: "firstName", label: "First name", width: 120, type: "string" }, +]; + +function requestOf(query: ReturnType, index = -1): TdgpQueryRequest { + const calls = query.mock.calls as unknown as Array<[string, TdgpQueryRequest?]>; + const call = index < 0 ? calls.at(index) : calls[index]; + return call?.[1] ?? {}; +} + +function nextButton(host: HTMLElement): HTMLButtonElement | null { + return host.querySelector('button[aria-label="Go to next page"]'); +} + +function prevButton(host: HTMLElement): HTMLButtonElement | null { + return host.querySelector('button[aria-label="Go to previous page"]'); +} + +function findExpandIcon(host: HTMLElement, accessor: string, value: string): HTMLElement | null { + const match = Array.from(host.querySelectorAll(`.st-cell[data-accessor="${accessor}"]`)).find( + (cell) => cell.textContent?.includes(value), + ); + if (!match) return null; + const rowIndex = match.getAttribute("data-row-index"); + if (rowIndex == null) return null; + const icon = host.querySelector( + `.st-cell[data-row-index="${rowIndex}"] .st-expand-icon-container:not(.placeholder)`, + ); + return icon instanceof HTMLElement ? icon : null; +} + +function TdgpHarness({ + client, + pageSize, + groupBy, +}: { + client: TdgpQueryClient; + pageSize: number; + groupBy?: string[]; +}) { + const { rows, tableProps } = useTdgpTable({ + client, + dataset: "developers-10k", + columns, + pageSize, + groupBy, + }); + return createElement(SimpleTable, { + columns, + rows, + height: "320px", + theme: "light", + ...tableProps, + }); +} + +function mountTdgp(props: { client: TdgpQueryClient; pageSize: number; groupBy?: string[] }): HTMLDivElement { + const host = document.createElement("div"); + document.body.appendChild(host); + container = host; + root = createRoot(host); + root.render(createElement(TdgpHarness, props)); + return host; +} + +describe("useTdgpTable + SimpleTable", () => { + it("enables next when the server has more than one page, and loads page 2 from the footer", async () => { + const query = vi.fn(async (_dataset: string, request?: TdgpQueryRequest) => { + if ((request?.start ?? 0) === 0) { + return { + protocol: "tdgp/1", + data: [{ id: 1, country: "France", firstName: "Ada" }], + totalCount: 10, + }; + } + return { + protocol: "tdgp/1", + data: [{ id: 2, country: "Spain", firstName: "Linus" }], + totalCount: 10, + }; + }); + + const host = mountTdgp({ client: { query } as TdgpQueryClient, pageSize: 5 }); + await waitFor(() => host.textContent?.includes("Ada") === true); + + const next = nextButton(host); + expect(next, "next should be enabled when 10 rows / 5 per page").not.toBeNull(); + expect(next!.disabled).toBe(false); + expect(prevButton(host)?.disabled).toBe(true); + + next!.click(); + await waitFor(() => host.textContent?.includes("Linus") === true); + + expect(host.textContent).not.toContain("Ada"); + expect(requestOf(query).start).toBe(5); + expect(prevButton(host)?.disabled).toBe(false); + }); + + it("disables next when the server count fits on one page", async () => { + const query = vi.fn(async () => ({ + protocol: "tdgp/1", + data: Array.from({ length: 10 }, (_, i) => ({ + id: i, + country: `Country ${i}`, + firstName: `Name ${i}`, + })), + totalCount: 10, + })); + + const host = mountTdgp({ client: { query } as TdgpQueryClient, pageSize: 25 }); + await waitFor(() => host.textContent?.includes("Country 0") === true); + + expect(nextButton(host)?.disabled).toBe(true); + expect(prevButton(host)?.disabled).toBe(true); + }); + + it("replaces the current page with skeletons while the next page is in flight", async () => { + let resolvePageTwo: ((value: { + protocol: string; + data: Array<{ id: number; country: string; firstName: string }>; + totalCount: number; + }) => void) | undefined; + + const query = vi.fn(async (_dataset: string, request?: TdgpQueryRequest) => { + if ((request?.start ?? 0) === 0) { + return { + protocol: "tdgp/1", + data: [{ id: 1, country: "France", firstName: "Ada" }], + totalCount: 10, + }; + } + return new Promise>[0]>>((resolve) => { + resolvePageTwo = resolve; + }); + }); + + const host = mountTdgp({ client: { query } as TdgpQueryClient, pageSize: 5 }); + await waitFor(() => host.textContent?.includes("Ada") === true); + + nextButton(host)!.click(); + await waitFor( + () => + host.querySelectorAll(".st-loading-skeleton").length > 0 && host.textContent?.includes("Ada") !== true, + ); + + resolvePageTwo?.({ + protocol: "tdgp/1", + data: [{ id: 2, country: "Spain", firstName: "Linus" }], + totalCount: 10, + }); + await waitFor(() => host.textContent?.includes("Linus") === true); + expect(host.querySelectorAll(".st-loading-skeleton").length).toBe(0); + }); + + it("starts groups collapsed and loads children from the first chevron click", async () => { + const query = vi.fn(async (_dataset: string, request?: TdgpQueryRequest) => { + const keys = request?.groupKeys ?? []; + if (keys.length === 0) { + return { + protocol: "tdgp/1", + data: [{ keys: ["France"], data: { country: "France" } }], + totalCount: 10, + }; + } + if (keys.length === 1) { + return { + protocol: "tdgp/1", + data: [{ keys: ["France", "backend"], data: { country: "France", stack: "backend" } }], + totalCount: 3, + }; + } + return { + protocol: "tdgp/1", + data: [{ id: 11, country: "France", stack: "backend", firstName: "Ada" }], + totalCount: 1, + }; + }); + + const host = mountTdgp({ + client: { query } as TdgpQueryClient, + pageSize: 5, + groupBy: ["country", "stack"], + }); + await waitFor(() => host.textContent?.includes("France") === true); + + expect(host.textContent).not.toContain("backend"); + expect(host.textContent).not.toContain("Ada"); + + const countryChevron = findExpandIcon(host, "country", "France"); + expect(countryChevron, "expand arrow on the country row").not.toBeNull(); + expect(countryChevron!.getAttribute("aria-expanded")).toBe("false"); + + countryChevron!.click(); + await waitFor(() => host.textContent?.includes("backend") === true); + + expect(query.mock.calls.some((call) => (call[1] as TdgpQueryRequest | undefined)?.groupKeys?.[0] === "France")).toBe( + true, + ); + expect(host.querySelectorAll('.st-cell[data-row-id*="loading-skeleton"]').length).toBe(0); + + const stackChevron = findExpandIcon(host, "stack", "backend"); + expect(stackChevron, "expand arrow on the stack row").not.toBeNull(); + stackChevron!.click(); + await waitFor(() => host.textContent?.includes("Ada") === true); + expect(host.querySelectorAll('.st-cell[data-row-id*="loading-skeleton"]').length).toBe(0); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 36daeff3a..a2b7e64d3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -126,6 +126,8 @@ export type { TdgpTableSnapshot, TdgpTableSource, TdgpTableSourceOptions, + MountTdgpTableOptions, + MountedTdgpTable, UpdateDataProps, ValueFormatter, ValueFormatterProps, @@ -141,6 +143,7 @@ export { PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL, createTdgpTableSource, + mountTdgpTable, tableFiltersToTdgpFilter, tableFilterConditions, sortColumnToTdgpSort, diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 8901aafb1..7955d4c16 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ "../core/src/__tests__/parkAndStagger.test.ts", "../core/src/__tests__/tdgpFilter.test.ts", "../core/src/__tests__/tdgpTableSource.test.ts", + "../core/src/__tests__/mountTdgpTable.test.ts", ], // The vanilla core imports a CSS bundle on load. We assert on DOM classes, // not computed colors, so CSS processing is unnecessary here. diff --git a/packages/solid/package.json b/packages/solid/package.json index 649bf0433..2e731a9ff 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/solid", - "version": "4.1.8", + "version": "4.1.9", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/solid/src/__tests__/useTdgpTable.test.tsx b/packages/solid/src/__tests__/useTdgpTable.test.tsx new file mode 100644 index 000000000..aa4c08e40 --- /dev/null +++ b/packages/solid/src/__tests__/useTdgpTable.test.tsx @@ -0,0 +1,112 @@ +import { createSignal } from "solid-js"; +import { render } from "solid-js/web"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SimpleTable, useTdgpTable } from "../index"; +import type { TdgpQueryClient } from "simple-table-core"; +import type { SolidColumnDef } from "../types"; + +let host: HTMLDivElement | null = null; +let dispose: (() => void) | null = null; + +afterEach(() => { + dispose?.(); + dispose = null; + host?.remove(); + host = null; + vi.restoreAllMocks(); +}); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await wait(20); + } + throw new Error("Timed out waiting for condition"); +} + +const columns: SolidColumnDef[] = [ + { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "name", label: "Name", width: 120, type: "string" }, +]; + +describe("useTdgpTable (Solid)", () => { + it("loads the first page into SimpleTable after mount", async () => { + const query = vi.fn(async (_dataset: string) => ({ + protocol: "tdgp/1", + data: [{ id: 1, name: "Ada" }], + totalCount: 1, + })); + + host = document.createElement("div"); + document.body.appendChild(host); + + dispose = render(() => { + const tdgp = useTdgpTable({ + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns, + pageSize: 10, + primaryKey: "id", + }); + return ( + + ); + }, host); + + await waitFor(() => query.mock.calls.length === 1 && (host?.textContent?.includes("Ada") ?? false)); + expect(query.mock.calls[0]?.[0]).toBe("developers-10k"); + }); + + it("does not reload when the getter returns new client and columns objects", async () => { + const query = vi.fn(async (_dataset: string) => ({ + protocol: "tdgp/1", + data: [{ id: 1, name: "Ada" }], + totalCount: 1, + })); + + host = document.createElement("div"); + document.body.appendChild(host); + + dispose = render(() => { + const [bump, setBump] = createSignal(0); + const tdgp = useTdgpTable(() => { + bump(); + return { + client: { query } as TdgpQueryClient, + dataset: "developers-10k", + columns: [ + { accessor: "id", label: "ID", width: 80, type: "number" as const }, + { accessor: "name", label: "Name", width: 120, type: "string" as const }, + ], + pageSize: 10, + primaryKey: "id", + }; + }); + return ( +
+