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 new file mode 100644 index 000000000..7eb9e0bd2 --- /dev/null +++ b/apps/marketing/src/app/docs/tdgp/page.tsx @@ -0,0 +1,39 @@ +import { Metadata } from "next"; +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, + 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/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 new file mode 100644 index 000000000..441a85534 --- /dev/null +++ b/apps/marketing/src/components/pages/docs-pages/TdgpContent.tsx @@ -0,0 +1,226 @@ +"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. 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{" "} + + isLoading + {" "} + (already set on{" "} + tableProps). + + ), + codeByFramework: tdgpSnippets(), + }, +]; + +const TDGP_PROPS: PropInfo[] = [ + { + key: "client", + name: "client", + required: true, + description: + "A TDGP client with a query(dataset, request) method. createTdgpClient({ url }) 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. This is the route segment (POST /{dataset}/query), not a URL. The public catalog lists developers-10k.", + 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 as limit (start is (page - 1) × pageSize). Defaults to 50.", + type: "number", + example: `pageSize={50}`, + }, + { + key: "primaryKey", + name: "primaryKey", + required: false, + description: + "Field used as the row id for leaf rows. Read this from the dataset catalog (GET /datasets). The public developers-10k dataset uses id. If you omit it, the helper falls back to id.", + type: "string", + example: `primaryKey="id"`, + }, + { + key: "groupBy", + name: "groupBy", + required: false, + description: + "Group on the server by these catalog field names. Expanding a group loads the next level, or the leaf rows when you have expanded every group field.", + type: "string[]", + example: `groupBy={["country", "stack"]}`, + }, + { + key: "aggregations", + name: "aggregations", + required: false, + description: + "Server aggregations for grouped rows (sum, avg, min, max, count). Each id is copied onto the group row, so use a name that will not collide with a field (the protocol examples use avgSalary).", + type: "TdgpAggregation[]", + example: `aggregations={[{ id: "avgSalary", field: "salary", fn: "avg" }]}`, + }, +]; + +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 protocol is documented at{" "} + + thedatagrid.com/protocol + + . The public demo server is{" "} + + data.thedatagrid.com + + , with a live API reference at{" "} + + /docs + + . + + + + {TDGP_PATTERNS.map((pattern) => ( +
+

+ {pattern.title} +

+

{pattern.body}

+ +
+ ))} +
+ + +

Grouping

+

+ Pass{" "} + 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 + {" "} + 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..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", @@ -2692,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/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..d1c14dc58 100644 --- a/apps/marketing/src/constants/docsSnippets.ts +++ b/apps/marketing/src/constants/docsSnippets.ts @@ -3078,3 +3078,158 @@ 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"; +import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/react/styles.css"; + +const client = createTdgpClient({ url: "https://data.thedatagrid.com" }); + +${TDGP_SNIPPET_COLUMNS} + +function App() { + const { rows, columns: tableColumns, tableProps } = useTdgpTable({ + client, + dataset: "developers-10k", + columns, + pageSize: 50, + primaryKey: "id", + }); + + return ( + + ); +}`, + vue: ` + +`, + angular: `import { Component } from "@angular/core"; +import { SimpleTableComponent, useTdgpTable } from "@simple-table/angular"; +import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/angular/styles.css"; + +@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: ` + +`, + solid: `import { SimpleTable, useTdgpTable } from "@simple-table/solid"; +import { createTdgpClient } from "@thedatagrid/client"; +import "@simple-table/solid/styles.css"; + +${TDGP_SNIPPET_COLUMNS} + +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 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, + primaryKey: "id", + tableConfig: { height: "480px" }, +});`, + }; +} diff --git a/apps/marketing/src/constants/strings/seo.ts b/apps/marketing/src/constants/strings/seo.ts index 86b49f36b..89ed80b79 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, 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", + }, liveUpdates: { title: "Live Updates in Simple Table Data Grid", description: 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__/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__/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__/tdgpFilter.test.ts b/packages/core/src/__tests__/tdgpFilter.test.ts new file mode 100644 index 000000000..6c0d9bb1d --- /dev/null +++ b/packages/core/src/__tests__/tdgpFilter.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { tableFiltersToTdgpFilter } from "../tdgp/tableFiltersToTdgpFilter"; +import { tableFilterConditions, type TableFilterState } from "../types/FilterTypes"; + +describe("tableFiltersToTdgpFilter", () => { + it("returns undefined when there are no filters", () => { + expect(tableFiltersToTdgpFilter(undefined)).toBeUndefined(); + 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 }, + }; + 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..f7c428736 --- /dev/null +++ b/packages/core/src/__tests__/tdgpTableSource.test.ts @@ -0,0 +1,508 @@ +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.tableProps.expandAll).toBe(false); + 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 }, + ]); + }); + + 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 })); + }); + + 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/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/index.ts b/packages/core/src/index.ts index 332687fbf..e85f5eeed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,40 @@ export { PIVOT_ACCESSOR_PREFIX, PIVOT_BLANK_LABEL, }; +export { + createTdgpTableSource, + mountTdgpTable, + tableFiltersToTdgpFilter, + sortColumnToTdgpSort, + isTdgpGroupNode, + tdgpGroupNodeToRow, + tdgpGroupNodesToRows, + getTdgpGroupKeys, + setNestedChildren, + TDGP_CHILDREN_ACCESSOR, + TDGP_GROUP_KEYS, +} from "./tdgp"; +export { tableFilterConditions } from "./types/FilterTypes"; +export type { + TdgpAggregation, + TdgpAggregationFn, + TdgpFilterGroup, + TdgpFilterModel, + TdgpFilterNot, + TdgpFilterOperator, + TdgpFilterPredicate, + TdgpGroupNode, + TdgpQueryClient, + TdgpQueryRequest, + TdgpQueryResponse, + TdgpSortModel, + TdgpTableProps, + TdgpTableSnapshot, + TdgpTableSource, + TdgpTableSourceOptions, + MountTdgpTableOptions, + MountedTdgpTable, +} 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..6c53c78e2 --- /dev/null +++ b/packages/core/src/tdgp/createTdgpTableSource.ts @@ -0,0 +1,276 @@ +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 { headersStructurallyEqual } from "../utils/propSyncEqual"; +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, + ); +} + +/** 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( + initialOptions: TdgpTableSourceOptions, +): TdgpTableSource { + let options = initialOptions; + const childrenAccessor = TDGP_CHILDREN_ACCESSOR; + + 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; + 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()]; + 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) => { + const groupBy = options.groupBy; + const keys = groupingKeys(); + if (!groupBy?.length || !keys) 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); + 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(); + } catch (err) { + props.setLoading(false); + props.setError(err instanceof Error ? err.message : "Failed to load rows"); + } + }; + + 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) * size, + limit: overrides.limit ?? size, + 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): TData[] { + const groupBy = options.groupBy; + if (groupBy?.length && groupKeyCount < groupBy.length && data.some(isTdgpGroupNode)) { + return tdgpGroupNodesToRows(data, childrenAccessor) as TData[]; + } + 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(), + totalRowCount, + isLoading, + externalSortHandling: true, + externalFilterHandling: true, + onPageChange: handlePageChange, + onSortChange: handleSortChange, + onFilterChange: handleFilterChange, + getRowId, + ...(keys + ? { + rowGrouping: keys, + onRowGroupExpand: handleRowGroupExpand, + expandAll: false, + } + : {}), + }; + } + + let snapshot: TdgpTableSnapshot = { + rows, + columns: resolvedColumns(), + isLoading, + error, + totalRowCount, + tableProps: buildTableProps(), + }; + + function emit() { + snapshot = { + rows, + columns: resolvedColumns(), + 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); + 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; + started = true; + void load(); + }, + stop() { + stopped = true; + loadGeneration += 1; + }, + 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/index.ts b/packages/core/src/tdgp/index.ts new file mode 100644 index 000000000..8dde89c80 --- /dev/null +++ b/packages/core/src/tdgp/index.ts @@ -0,0 +1,31 @@ +export { createTdgpTableSource } from "./createTdgpTableSource"; +export { mountTdgpTable } from "./mountTdgpTable"; +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"; +export type { MountTdgpTableOptions, MountedTdgpTable } from "./mountTdgpTable"; diff --git a/packages/core/src/tdgp/mapGroupResponse.ts b/packages/core/src/tdgp/mapGroupResponse.ts new file mode 100644 index 000000000..f45c849be --- /dev/null +++ b/packages/core/src/tdgp/mapGroupResponse.ts @@ -0,0 +1,43 @@ +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 { + 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, +): TData { + const data = node.data as Record; + const row: Record = { + ...data, + ...(node.aggregations ?? {}), + [TDGP_GROUP_KEYS]: node.keys, + [childrenAccessor]: [], + }; + if (row.id == null) { + row.id = `group:${node.keys.join("/")}`; + } + return row as TData; +} + +export function tdgpGroupNodesToRows( + nodes: Array>, + childrenAccessor: string = TDGP_CHILDREN_ACCESSOR, +): TData[] { + return nodes.filter(isTdgpGroupNode).map((node) => tdgpGroupNodeToRow(node, childrenAccessor)); +} + +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/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/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..3ad377505 --- /dev/null +++ b/packages/core/src/tdgp/tableFiltersToTdgpFilter.ts @@ -0,0 +1,101 @@ +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 = { + 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 { + const children = tableFilterConditions(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..f4e0c5cf9 --- /dev/null +++ b/packages/core/src/tdgp/types.ts @@ -0,0 +1,152 @@ +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: TData; + aggregations?: Record; +}; + +export type TdgpQueryResponse = { + protocol?: string; + data: Array>; + 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; + /** Groups start collapsed. Children load when the user expands a row. */ + expandAll?: false; +}; + +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. Call once after subscribe. */ + start: () => void; + /** 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/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/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..9ecabc567 --- /dev/null +++ b/packages/examples/react/src/demos/tdgp/TdgpDemo.tsx @@ -0,0 +1,56 @@ +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}). 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 ? ( +

{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..51e68927f --- /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 = 5; +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/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/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__/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); + }); +}); 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/__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 d4fd90b09..a2b7e64d3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -111,6 +111,23 @@ export type { TableFilterState, TableRow, Theme, + TdgpAggregation, + TdgpAggregationFn, + TdgpFilterGroup, + TdgpFilterModel, + TdgpFilterNot, + TdgpFilterOperator, + TdgpFilterPredicate, + TdgpGroupNode, + TdgpQueryClient, + TdgpQueryRequest, + TdgpQueryResponse, + TdgpTableProps, + TdgpTableSnapshot, + TdgpTableSource, + TdgpTableSourceOptions, + MountTdgpTableOptions, + MountedTdgpTable, UpdateDataProps, ValueFormatter, ValueFormatterProps, @@ -125,4 +142,19 @@ 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 { 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 new file mode 100644 index 000000000..dd72151ab --- /dev/null +++ b/packages/react/src/tdgp/useTdgpTable.ts @@ -0,0 +1,52 @@ +import { useEffect, useLayoutEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; +import { + createTdgpTableSource, + type TdgpTableSnapshot, + type TdgpTableSourceOptions, +} from "simple-table-core"; +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: UseTdgpTableOptions, +): TdgpTableSnapshot { + const [source] = useState(() => createTdgpTableSource(toSourceOptions(options))); + + const snapshot = useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot); + + useLayoutEffect(() => { + source.applyOptions(toSourceOptions(options)); + }); + + 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..7955d4c16 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -16,6 +16,9 @@ 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", + "../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 ( +
+