From 4d9a28aeb8a938cd47b9f22ed61e1cc59fc4e859 Mon Sep 17 00:00:00 2001 From: Jackson Kasi <109948871+jacksonkasi1@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:59:00 +0530 Subject: [PATCH 1/8] fix(security): security audit (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(table): add row grouping feature (#32) - Add RowGroupingAggregation, RowGroupingConfig, OnRowGroupExpandInfo types - Add rowGrouping, rowGroupingConfig, onRowGroupExpand props to DataTableProps - Add enableRowGroupingControls to TableConfig (default: true) - Add expandAllGroups/collapseAllGroups/isRowGroupingActive to TableContext - Wire getGroupedRowModel + GroupingState into DataTable - Render collapsible group-header rows with chevron + label + badge - Support multi-level grouping with depth-based indentation on child rows - Support per-column aggregation functions (sum/mean/min/max etc.) - Support custom group cell renderer via renderGroupCell - Add Expand All / Collapse All toolbar buttons (gated by enableRowGroupingControls) - Add GroupRowChevron + GroupRowBadge components to expand-icon.tsx - Add row grouping CSS classes to styles.css (depth-accent borders, badge, chevron anim) - Fix: getRowCanExpand now always returns true for group rows (was blocking TanStack expand) - Fix: expanded state init now correctly respects rowGroupingConfig.defaultExpanded - Fix: renderGroupCell returning null now falls back to default label (was rendering nothing) - Fix: placeholder cells in group rows render empty (was null, broke column grid) - Fix: Fragment keys namespaced to prevent group/leaf row ID collision - Fix: keyboard handler (Space/Enter) works on group rows + unmasked implicit role=row bug - Fix: isRowGroupingActive reads grouping state (not prop) to stay accurate after setGrouping - Fix: group label uses meta.label ?? id.replace(/_/g,' ') instead of raw column ID - Fix: Object.keys(true) guard in onRowGroupExpand effect when expanded=true (expand-all) - Fix: row model pipeline order documented (Core->Filter->Group->Sort->Page) - Fix: manualPagination+grouping dev warning to prevent silent group-splitting across pages - Add row grouping aggregationFns performance note to RowGroupingConfig JSDoc - Fix: export-utils createElementNS fix (prevents spy infinite recursion in tests) - Add employees-grouping demo page (3 examples: dept, dept+team, custom renderer) - Re-export RowGroupingAggregation, RowGroupingConfig, OnRowGroupExpandInfo from index.ts BREAKING CHANGE: none — all new props are optional, all existing props/methods intact Tests: 81 passed / 0 failed (5 suites) * feat(table): add imperative TableGroupingAPI via groupingRef (#32) Adds advanced depth-level grouping control matching simple-table's v2.1 API surface, built on top of TanStack's row model. - Add TableGroupingAPI interface to types.ts (expandDepth, collapseDepth, toggleDepth, etc.) - Add groupingRef prop to DataTableProps to expose imperative handle - Add depth-level helpers (expandDepth, collapseDepth, toggleDepth, getExpandedDepths, setExpandedDepths) to data-table.tsx - Add property/depth lookup helpers (getGroupingProperty, getGroupingDepth) - Expose all new helpers on TableContext for declarative usage - Update TableCraft employees-grouping demo with ProgrammaticDemo showcasing the new API - Fix: remove process.env check in use-table-data effect (resolves TS2591 process not found) - Docs: add TableGroupingAPI to index.ts exports * fix(table): row grouping review fixes (#32) - Swap toolbar Expand/Collapse icons (ChevronsUpDown for expand, ChevronsDownUp for collapse) — they were inverted - Gate the rowGrouping+pagination warning on NODE_ENV via globalThis so it stays out of production logs (and stops requiring @types/node) - Stop expandDepth from collapsing other depths when expanded === true: return prev unchanged in the boolean case instead of resetting to {} Co-Authored-By: Claude Sonnet 4.6 * fix(table): stabilize groupingRef handle across renders (#32) The imperative handle effect ran without a dependency array and cleaned up by nulling the ref. With the empty deps the effect/cleanup pair fired every render, so any consumer reading `ref.current` between renders could see `null` for a tick. - Add a real dep array listing every closure the handle captures - Drop the cleanup that nulls the ref; parent owns the ref's lifecycle Co-Authored-By: Claude Sonnet 4.6 * fix(table): mitigate CSV formula injection and drop aria-selected on group rows CSV injection: cell values whose first character is `=`, `+`, `-`, `@`, `\t`, or `\r` are interpreted as formulas by Excel, Google Sheets, and LibreOffice when an exported file is opened. Prefix such values with a single quote so spreadsheet apps treat them as plain text. Headers come from column metadata and are not user-controlled, so they are left unchanged. aria-selected: row-grouping rows are never selectable, so reporting `aria-selected="false"` on them was misleading to assistive tech. Omit the attribute on group rows. Tests: 82 passing (added one for the CSV sanitizer). Co-Authored-By: Claude Sonnet 4.6 * feat(table): add endToolbarContent/endToolbarPlacement for right-side injection Mirrors the existing startToolbarContent/startToolbarPlacement API so consumers can inject custom content anywhere in the right toolbar cluster without shifting any built-in controls. Slots (EndToolbarPlacement): before-grouping → first on the right (after legacy customToolbarContent) after-grouping / before-export → between grouping controls and export after-export / before-view → between export and view-options after-view / before-settings → between view-options and settings (default) after-settings → last (rightmost) Like startToolbarContent, accepts a ReactNode or a function that receives ToolbarContext (selection, search, dateRange state). Co-Authored-By: Claude Sonnet 4.6 * feat(demo): add Toolbar Placement demo page in vite-web-example Adds /toolbar-placement route showcasing all endToolbarContent slots: - before-export: context-aware Archive/Restore bulk-action buttons that appear only when rows are selected (reads ctx.totalSelected + selectedIds) - after-settings: static Import CSV button in the last/rightmost slot - before-view: custom category filter chips driving a customFilters on the adapter — no search input or URL state - after-view (default): Delete button disabled when nothing is selected All four examples use the existing products endpoint from hono-example — no new seed data or API routes required. Also includes a slot-map reference strip at the top of the page so developers can quickly see the full right-cluster order. Co-Authored-By: Claude Sonnet 4.6 * feat(demo): split toolbar demos into Start Slots / End Slots pages - Add /toolbar-start page: 3 compact examples for startToolbarContent - before-search: New Product button - after-search: category chips that drive customFilters - after-date: Sync button with spinner state - Rewrite /toolbar-placement (end slots): same 4 examples, drastically less description text — one-liner label + badge per section - Nav: change flat "Toolbar" link to a group dropdown with "Start Slots" and "End Slots" No new API routes or seed data needed. Co-Authored-By: Claude Sonnet 4.6 * feat(demo): add Archived toggle next to search on Products page Uses startToolbarPlacement="after-search" + startToolbarContent to place an Archive toggle button immediately after the search input. When active it drives customFilters={isArchived: eq true} on the adapter so only archived products are fetched. Co-Authored-By: Claude Sonnet 4.6 * feat(table): add lazy-tree support — getSubRows, onRowExpand, keepPreviousData - Add `getSubRows` prop so TanStack renders hierarchical data with depth indentation and a chevron on the first data cell of expandable rows - Add `onRowExpand` callback (fires on expand/collapse of non-grouped rows) for lazy-loading children on first expand - Add `keepPreviousData` config flag to skip skeleton flash when the adapter is recreated to push fresh tree data - Add `flattenTree` helper so export and selection count traverse all loaded child nodes, not just top-level adapter rows - Stop propagation on system-column (select/expand/actions) clicks so checking a checkbox no longer toggles row expansion - Clear `rowSelection` on page change, page-size change, and search change to prevent stale cross-page selection accumulation Co-Authored-By: Claude Sonnet 4.6 * feat(hono): add retail tree DB schema, REST API, and seed script - Add retailRegions / retailStores / retailProducts tables with Drizzle ORM - GET /api/manual/retail/tree — paginated regions (tree mode) or full-text deep search across all three levels with breadcrumb field (search mode) - GET /api/manual/retail/tree/:id/children — lazy-loads stores for a region or products for a store, both with sort support - seed-retail.ts seeds 60 regions, 139 stores, 417 products (6+ pages at default page size of 10) - Add seed:retail script to package.json Co-Authored-By: Claude Sonnet 4.6 * feat(web): add server-side lazy tree example (Regions → Stores → Products) - New /row-grouping/server page using DataTable with server-side search, sort, pagination, and on-demand child loading via onRowExpand - mergeChildren recursively reattaches cached children so multi-level expansion works correctly after adapter version bumps - Breadcrumb subtitle shown in Name cell for flat deep-search results - Restructure /row-grouping into a layout with Basic (static) and Server tabs - Add explicit column definitions to EmployeesGroupingPage so group rows render the value directly without the stale column-label prefix Co-Authored-By: Claude Sonnet 4.6 * chore: ignore graphify-out dir and update bun lockfile Co-Authored-By: Claude Sonnet 4.6 * feat(table): add useTreeAdapter hook — lazy server-side tree in one call Encapsulates all lazy-tree boilerplate (childrenRef, version re-memo, mergeChildren walk, loading placeholder, expand handler, per-node abort, dedupe, depth-cap cycle guard, unmount cleanup) into a single reusable hook so pages only declare what's unique to their data. server.tsx drops from 226 → 154 lines with no functional change. Co-Authored-By: Claude Sonnet 4.6 * refactor(hono): simplify retail tree route with engine utilities - Use engine's parseRequest for validated page/pageSize/search parsing - Consolidate 3 sort maps into a single SORT namespace - Extract order() helper (sort-column lookup + direction in one call) - Extract pageMeta() to eliminate repeated meta object construction - Extract parseParams() to centralise URL extraction across both endpoints Co-Authored-By: Claude Sonnet 4.6 * fix(hono): remove as const from SORT map to avoid IDE type inference issue Co-Authored-By: Claude Sonnet 4.6 * Update README.md * chore: update lockfile with redis optional peers * fix(web): satisfy eslint react-hooks and react-refresh rules - Move `cn` helper from LiquidCard.tsx into lib/utils.ts (shadcn convention) so LiquidCard only exports a component, satisfying react-refresh. - Drop the synchronous handleMouseMove() call from the Presentation mount effect — the controls already default to visible, so just schedule the auto-hide timer to avoid cascading renders (react-hooks/set-state-in-effect). --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: jacksonnkasi --- .gitignore | 4 +- README.md | 1 + apps/hono-example/package.json | 1 + apps/hono-example/src/db/schema.ts | 44 +- apps/hono-example/src/db/seed-retail.ts | 212 +++++ apps/hono-example/src/routes/manual/index.ts | 2 + apps/hono-example/src/routes/manual/retail.ts | 136 ++++ apps/vite-web-example/eslint.config.js | 3 + apps/vite-web-example/src/App.tsx | 179 ++++- .../src/data/employees-grouping.ts | 28 + apps/vite-web-example/src/data/retail-tree.ts | 145 ++++ .../src/pages/employees-grouping-page.tsx | 201 +++++ .../src/pages/products-grouping-page.tsx | 114 +++ .../src/pages/products-page.tsx | 27 +- .../src/pages/row-grouping/basic.tsx | 187 +++++ .../src/pages/row-grouping/layout.tsx | 41 + .../src/pages/row-grouping/server.tsx | 157 ++++ .../src/pages/row-grouping/tree.tsx | 124 +++ .../src/pages/toolbar-placement-page.tsx | 198 +++++ .../src/pages/toolbar-start-page.tsx | 174 +++++ apps/web/src/Presentation.tsx | 10 +- apps/web/src/components/LiquidCard.tsx | 7 +- apps/web/src/lib/utils.ts | 6 + build_log.txt | 138 ---- bun.lock | 12 +- packages/table/src/auto/use-tree-adapter.ts | 308 ++++++++ packages/table/src/core/table-config.ts | 4 +- packages/table/src/core/use-table-data.ts | 6 +- packages/table/src/data-table.tsx | 722 +++++++++++++++--- packages/table/src/expand-icon.tsx | 40 +- packages/table/src/index.ts | 20 +- packages/table/src/styles.css | 51 ++ packages/table/src/toolbar.tsx | 71 +- packages/table/src/types.ts | 261 ++++++- packages/table/src/utils/export-utils.ts | 37 +- packages/table/test/export-utils.test.ts | 26 + 36 files changed, 3399 insertions(+), 298 deletions(-) create mode 100644 apps/hono-example/src/db/seed-retail.ts create mode 100644 apps/hono-example/src/routes/manual/retail.ts create mode 100644 apps/vite-web-example/src/data/employees-grouping.ts create mode 100644 apps/vite-web-example/src/data/retail-tree.ts create mode 100644 apps/vite-web-example/src/pages/employees-grouping-page.tsx create mode 100644 apps/vite-web-example/src/pages/products-grouping-page.tsx create mode 100644 apps/vite-web-example/src/pages/row-grouping/basic.tsx create mode 100644 apps/vite-web-example/src/pages/row-grouping/layout.tsx create mode 100644 apps/vite-web-example/src/pages/row-grouping/server.tsx create mode 100644 apps/vite-web-example/src/pages/row-grouping/tree.tsx create mode 100644 apps/vite-web-example/src/pages/toolbar-placement-page.tsx create mode 100644 apps/vite-web-example/src/pages/toolbar-start-page.tsx create mode 100644 apps/web/src/lib/utils.ts delete mode 100644 build_log.txt create mode 100644 packages/table/src/auto/use-tree-adapter.ts diff --git a/.gitignore b/.gitignore index e38dda1..1913db6 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,6 @@ request.json .explore apps-explore/tnks-data-table -studio/ \ No newline at end of file +studio/ + +graphify-out diff --git a/README.md b/README.md index 7ef912f..8581ee3 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,4 @@ For full guides, API reference, and examples: ## 📄 License MIT + diff --git a/apps/hono-example/package.json b/apps/hono-example/package.json index 5bf7cb3..a0d3b47 100644 --- a/apps/hono-example/package.json +++ b/apps/hono-example/package.json @@ -7,6 +7,7 @@ "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "seed": "bun run src/db/seed.ts", + "seed:retail": "bun run src/db/seed-retail.ts", "test": "bun test", "typecheck": "bun run --bun tsc --noEmit" }, diff --git a/apps/hono-example/src/db/schema.ts b/apps/hono-example/src/db/schema.ts index e3851f8..195e0d4 100644 --- a/apps/hono-example/src/db/schema.ts +++ b/apps/hono-example/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, text, integer, timestamp, boolean, jsonb, decimal, uuid } from 'drizzle-orm/pg-core'; +import { pgTable, serial, text, integer, timestamp, boolean, jsonb, decimal, uuid, real } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; // 1. Tenants (SaaS context) @@ -53,6 +53,48 @@ export const orderItems = pgTable('order_items', { unitPrice: decimal('unit_price', { precision: 10, scale: 2 }).notNull(), }); +// ─── Retail tree (Regions → Stores → Products) ──────────────────────────── + +export const retailRegions = pgTable('retail_regions', { + id: text('id').primaryKey(), + name: text('name').notNull(), + totalSales: integer('total_sales').notNull().default(0), + revenue: integer('revenue').notNull().default(0), + stores: integer('stores').notNull().default(0), + avgRating: real('avg_rating'), +}); + +export const retailStores = pgTable('retail_stores', { + id: text('id').primaryKey(), + regionId: text('region_id').notNull().references(() => retailRegions.id), + name: text('name').notNull(), + totalSales: integer('total_sales').notNull().default(0), + revenue: integer('revenue').notNull().default(0), + avgRating: real('avg_rating'), +}); + +export const retailProducts = pgTable('retail_products', { + id: text('id').primaryKey(), + storeId: text('store_id').notNull().references(() => retailStores.id), + name: text('name').notNull(), + totalSales: integer('total_sales').notNull().default(0), + revenue: integer('revenue').notNull().default(0), + avgRating: real('avg_rating'), +}); + +export const retailRegionRelations = relations(retailRegions, ({ many }) => ({ + stores: many(retailStores), +})); + +export const retailStoreRelations = relations(retailStores, ({ one, many }) => ({ + region: one(retailRegions, { fields: [retailStores.regionId], references: [retailRegions.id] }), + products: many(retailProducts), +})); + +export const retailProductRelations = relations(retailProducts, ({ one }) => ({ + store: one(retailStores, { fields: [retailProducts.storeId], references: [retailStores.id] }), +})); + // --- Relations --- export const tenantRelations = relations(tenants, ({ many }) => ({ diff --git a/apps/hono-example/src/db/seed-retail.ts b/apps/hono-example/src/db/seed-retail.ts new file mode 100644 index 0000000..1607af2 --- /dev/null +++ b/apps/hono-example/src/db/seed-retail.ts @@ -0,0 +1,212 @@ +import { db } from './index'; +import * as schema from './schema'; +import { sql } from 'drizzle-orm'; + +// ── Regions (60) ───────────────────────────────────────────────────────────── +const regions = [ + { id: 'r01', name: 'Northeast US', totalSales: 52400, revenue: 1380000, stores: 14, avgRating: 4.5 }, + { id: 'r02', name: 'Southeast US', totalSales: 44200, revenue: 1140000, stores: 11, avgRating: 4.3 }, + { id: 'r03', name: 'Midwest US', totalSales: 38600, revenue: 980000, stores: 10, avgRating: 4.2 }, + { id: 'r04', name: 'Southwest US', totalSales: 41800, revenue: 1060000, stores: 11, avgRating: 4.4 }, + { id: 'r05', name: 'West Coast US', totalSales: 57300, revenue: 1520000, stores: 15, avgRating: 4.6 }, + { id: 'r06', name: 'Pacific Northwest', totalSales: 33200, revenue: 860000, stores: 8, avgRating: 4.5 }, + { id: 'r07', name: 'Mountain West', totalSales: 29800, revenue: 740000, stores: 7, avgRating: 4.3 }, + { id: 'r08', name: 'Great Plains', totalSales: 24100, revenue: 610000, stores: 6, avgRating: 4.1 }, + { id: 'r09', name: 'New England', totalSales: 31400, revenue: 820000, stores: 8, avgRating: 4.4 }, + { id: 'r10', name: 'Mid-Atlantic', totalSales: 48900, revenue: 1260000, stores: 13, avgRating: 4.5 }, + { id: 'r11', name: 'Western Canada', totalSales: 27600, revenue: 710000, stores: 7, avgRating: 4.3 }, + { id: 'r12', name: 'Eastern Canada', totalSales: 22400, revenue: 570000, stores: 6, avgRating: 4.2 }, + { id: 'r13', name: 'Central Canada', totalSales: 18900, revenue: 470000, stores: 5, avgRating: 4.0 }, + { id: 'r14', name: 'Mexico', totalSales: 21300, revenue: 530000, stores: 6, avgRating: 4.1 }, + { id: 'r15', name: 'Central America', totalSales: 14700, revenue: 360000, stores: 4, avgRating: 3.9 }, + { id: 'r16', name: 'UK & Ireland', totalSales: 45800, revenue: 1190000, stores: 12, avgRating: 4.4 }, + { id: 'r17', name: 'France', totalSales: 38200, revenue: 980000, stores: 10, avgRating: 4.3 }, + { id: 'r18', name: 'Germany', totalSales: 43600, revenue: 1120000, stores: 11, avgRating: 4.4 }, + { id: 'r19', name: 'Benelux', totalSales: 29100, revenue: 740000, stores: 7, avgRating: 4.3 }, + { id: 'r20', name: 'Nordics', totalSales: 33700, revenue: 870000, stores: 9, avgRating: 4.5 }, + { id: 'r21', name: 'Iberia', totalSales: 26400, revenue: 670000, stores: 7, avgRating: 4.2 }, + { id: 'r22', name: 'Italy', totalSales: 31800, revenue: 810000, stores: 8, avgRating: 4.3 }, + { id: 'r23', name: 'Eastern Europe', totalSales: 22600, revenue: 560000, stores: 6, avgRating: 4.0 }, + { id: 'r24', name: 'DACH Extended', totalSales: 19400, revenue: 490000, stores: 5, avgRating: 4.1 }, + { id: 'r25', name: 'Baltics', totalSales: 11200, revenue: 280000, stores: 3, avgRating: 3.9 }, + { id: 'r26', name: 'Japan', totalSales: 49200, revenue: 1280000, stores: 13, avgRating: 4.7 }, + { id: 'r27', name: 'South Korea', totalSales: 38700, revenue: 1000000, stores: 10, avgRating: 4.5 }, + { id: 'r28', name: 'Greater China', totalSales: 62100, revenue: 1640000, stores: 16, avgRating: 4.4 }, + { id: 'r29', name: 'Southeast Asia', totalSales: 44300, revenue: 1130000, stores: 11, avgRating: 4.3 }, + { id: 'r30', name: 'India', totalSales: 36800, revenue: 940000, stores: 9, avgRating: 4.2 }, + { id: 'r31', name: 'Australia', totalSales: 31200, revenue: 810000, stores: 8, avgRating: 4.4 }, + { id: 'r32', name: 'New Zealand', totalSales: 14800, revenue: 380000, stores: 4, avgRating: 4.3 }, + { id: 'r33', name: 'Middle East', totalSales: 28400, revenue: 730000, stores: 7, avgRating: 4.2 }, + { id: 'r34', name: 'North Africa', totalSales: 16200, revenue: 410000, stores: 4, avgRating: 3.9 }, + { id: 'r35', name: 'Sub-Saharan Africa', totalSales: 12900, revenue: 320000, stores: 3, avgRating: 3.8 }, + { id: 'r36', name: 'Brazil', totalSales: 33600, revenue: 870000, stores: 9, avgRating: 4.1 }, + { id: 'r37', name: 'Argentina', totalSales: 19200, revenue: 490000, stores: 5, avgRating: 4.0 }, + { id: 'r38', name: 'Colombia', totalSales: 16400, revenue: 410000, stores: 4, avgRating: 3.9 }, + { id: 'r39', name: 'Chile', totalSales: 14100, revenue: 360000, stores: 4, avgRating: 4.0 }, + { id: 'r40', name: 'Peru', totalSales: 11800, revenue: 290000, stores: 3, avgRating: 3.8 }, + { id: 'r41', name: 'Taiwan', totalSales: 22600, revenue: 580000, stores: 6, avgRating: 4.4 }, + { id: 'r42', name: 'Hong Kong', totalSales: 24100, revenue: 630000, stores: 6, avgRating: 4.5 }, + { id: 'r43', name: 'Philippines', totalSales: 18300, revenue: 460000, stores: 5, avgRating: 4.1 }, + { id: 'r44', name: 'Indonesia', totalSales: 21700, revenue: 550000, stores: 6, avgRating: 4.0 }, + { id: 'r45', name: 'Malaysia', totalSales: 17400, revenue: 440000, stores: 5, avgRating: 4.2 }, + { id: 'r46', name: 'Thailand', totalSales: 19800, revenue: 500000, stores: 5, avgRating: 4.1 }, + { id: 'r47', name: 'Vietnam', totalSales: 15200, revenue: 380000, stores: 4, avgRating: 4.0 }, + { id: 'r48', name: 'Pakistan', totalSales: 11600, revenue: 290000, stores: 3, avgRating: 3.8 }, + { id: 'r49', name: 'Bangladesh', totalSales: 9800, revenue: 240000, stores: 3, avgRating: 3.7 }, + { id: 'r50', name: 'Sri Lanka', totalSales: 8400, revenue: 210000, stores: 2, avgRating: 3.9 }, + { id: 'r51', name: 'UAE', totalSales: 26800, revenue: 690000, stores: 7, avgRating: 4.4 }, + { id: 'r52', name: 'Saudi Arabia', totalSales: 23400, revenue: 600000, stores: 6, avgRating: 4.1 }, + { id: 'r53', name: 'Israel', totalSales: 17200, revenue: 440000, stores: 5, avgRating: 4.3 }, + { id: 'r54', name: 'Turkey', totalSales: 19600, revenue: 500000, stores: 5, avgRating: 4.0 }, + { id: 'r55', name: 'Poland', totalSales: 18100, revenue: 460000, stores: 5, avgRating: 4.1 }, + { id: 'r56', name: 'Czech Republic', totalSales: 14600, revenue: 370000, stores: 4, avgRating: 4.0 }, + { id: 'r57', name: 'Romania', totalSales: 12300, revenue: 310000, stores: 3, avgRating: 3.9 }, + { id: 'r58', name: 'Greece', totalSales: 11800, revenue: 300000, stores: 3, avgRating: 4.0 }, + { id: 'r59', name: 'Portugal', totalSales: 13200, revenue: 340000, stores: 4, avgRating: 4.1 }, + { id: 'r60', name: 'Hungary', totalSales: 10900, revenue: 270000, stores: 3, avgRating: 3.9 }, +]; + +// ── Stores (3 per region for r01-r10 to demonstrate children, 2 per region for rest) ────── +function makeStores() { + const stores: { id: string; regionId: string; name: string; totalSales: number; revenue: number; avgRating: number }[] = []; + let idx = 1; + const storeNames: Record = { + r01: ['NYC Flagship', 'Boston Commons', 'Philadelphia Center'], + r02: ['Miami Beach', 'Atlanta Peachtree', 'Charlotte Uptown'], + r03: ['Chicago Michigan Ave', 'Detroit Metro', 'Minneapolis Loop'], + r04: ['Dallas Downtown', 'Phoenix Scottsdale', 'Las Vegas Strip'], + r05: ['LA Beverly', 'San Francisco Union Sq', 'San Diego Gaslamp'], + r06: ['Seattle Pike Place', 'Portland Pearl'], + r07: ['Denver Cherry Creek', 'Salt Lake City'], + r08: ['Kansas City Plaza', 'Omaha Old Market'], + r09: ['Boston Newbury', 'Providence Downtown'], + r10: ['NYC Times Square', 'Washington DC Georgetown', 'Baltimore Inner Harbor'], + r11: ['Vancouver Pacific Centre', 'Calgary Stephen Ave'], + r12: ['Toronto Eaton Centre', 'Montreal Sainte-Catherine'], + r13: ['Winnipeg Portage', 'Regina Downtown'], + r14: ['Mexico City Polanco', 'Guadalajara Centro'], + r15: ['San José Downtown', 'Guatemala City'], + r16: ['London Oxford St', 'Manchester Arndale', 'Dublin Grafton St'], + r17: ['Paris Champs-Élysées', 'Lyon Bellecour', 'Marseille Canebière'], + r18: ['Berlin Mitte', 'Munich Maximilianstr', 'Hamburg Jungfernstieg'], + r19: ['Amsterdam Kalverstr', 'Brussels Grand Place'], + r20: ['Stockholm Drottningg', 'Oslo Karl Johans', 'Helsinki Esplanadi'], + r21: ['Madrid Gran Via', 'Barcelona Passeig de Gràcia'], + r22: ['Milan Corso Buenos Aires', 'Rome Via Condotti', 'Florence Via Tornabuoni'], + r23: ['Warsaw Nowy Świat', 'Prague Wenceslas Sq'], + r24: ['Zurich Bahnhofstr', 'Vienna Mariahilfer'], + r25: ['Tallinn Old Town', 'Riga Center'], + r26: ['Tokyo Shibuya', 'Osaka Shinsaibashi', 'Kyoto Kawaramachi'], + r27: ['Seoul Gangnam', 'Busan Seomyeon'], + r28: ['Shanghai Nanjing Rd', 'Beijing Wangfujing', 'Guangzhou Beijing Rd', 'Shenzhen Luohu'], + r29: ['Singapore CBD', 'Bangkok Siam', 'Kuala Lumpur KLCC'], + r30: ['Mumbai Colaba', 'Delhi Connaught Pl', 'Bangalore MG Road'], + r31: ['Sydney CBD', 'Melbourne Bourke St', 'Brisbane Queen St'], + r32: ['Auckland Queen St', 'Wellington Lambton Quay'], + r33: ['Dubai Mall', 'Riyadh Tahlia St'], + r34: ['Cairo Zamalek', 'Casablanca Maarif'], + r35: ['Lagos Victoria Island', 'Nairobi Westlands'], + r36: ['São Paulo Paulista', 'Rio Ipanema', 'Brasília Asa Norte'], + r37: ['Buenos Aires Palermo', 'Córdoba Nueva Córdoba'], + r38: ['Bogotá Zona Rosa', 'Medellín El Poblado'], + r39: ['Santiago Las Condes', 'Viña del Mar'], + r40: ['Lima Miraflores', 'Arequipa Centro'], + r41: ['Taipei Xinyi', 'Kaohsiung Zuoying'], + r42: ['Hong Kong Central', 'Kowloon Mong Kok'], + r43: ['Manila BGC', 'Cebu IT Park'], + r44: ['Jakarta Sudirman', 'Bali Seminyak'], + r45: ['Kuala Lumpur Pavilion', 'Penang Georgetown'], + r46: ['Bangkok Asok', 'Chiang Mai Nimman'], + r47: ['Ho Chi Minh City D1', 'Hanoi Hoan Kiem'], + r48: ['Karachi Clifton', 'Lahore MM Alam Rd'], + r49: ['Dhaka Gulshan', 'Chittagong Agrabad'], + r50: ['Colombo Fort', 'Kandy City Centre'], + r51: ['Dubai Marina', 'Abu Dhabi Corniche', 'Sharjah City Centre'], + r52: ['Riyadh Kingdom Tower', 'Jeddah Corniche'], + r53: ['Tel Aviv Dizengoff', 'Jerusalem Mamilla'], + r54: ['Istanbul Istiklal', 'Ankara Tunalı'], + r55: ['Warsaw Arkadia', 'Kraków Galeria'], + r56: ['Prague Palladium', 'Brno Olympia'], + r57: ['Bucharest AFI Palace', 'Cluj-Napoca'], + r58: ['Athens Kolonaki', 'Thessaloniki Tsimiski'], + r59: ['Lisbon Chiado', 'Porto Boavista'], + r60: ['Budapest Váci Str', 'Debrecen Forum'], + }; + + for (const region of regions) { + const names = storeNames[region.id] ?? [`${region.name} Store 1`, `${region.name} Store 2`]; + for (let i = 0; i < names.length; i++) { + const sid = `s${String(idx).padStart(3, '0')}`; + const share = 1 / names.length; + stores.push({ + id: sid, + regionId: region.id, + name: names[i], + totalSales: Math.round(region.totalSales * share * (0.85 + Math.random() * 0.3)), + revenue: Math.round(region.revenue * share * (0.85 + Math.random() * 0.3)), + avgRating: Math.round((region.avgRating + (Math.random() * 0.4 - 0.2)) * 10) / 10, + }); + idx++; + } + } + return stores; +} + +const PRODUCT_POOL = [ + 'Wireless Headphones', 'Laptop Stand', 'USB-C Hub', 'Mechanical Keyboard', + 'Webcam 4K', 'Desk Mat XL', 'Monitor 27"', 'Smart Speaker', 'Cable Organizer', + 'Noise-Cancel Earbuds', 'Portable Charger', 'Phone Stand', 'LED Desk Lamp', + 'Ergonomic Mouse', 'Monitor Light Bar', 'Smart Watch', 'Laptop Sleeve', + 'Mini Projector', 'Wireless Charger', 'Cable Clip Pack', 'Screen Protector', + 'Drawing Tablet', 'Streaming Mic', 'RGB Keyboard Wrist Rest', 'USB Docking Station', + 'Laptop Cooling Pad', 'Mechanical Numpad', 'Smart Ring', 'AR Glasses', + 'Portable SSD 1TB', +]; + +function makeProducts(stores: ReturnType) { + const products: { id: string; storeId: string; name: string; totalSales: number; revenue: number; avgRating: number }[] = []; + let idx = 1; + for (const store of stores) { + // 3 products per store + const usedNames = new Set(); + for (let i = 0; i < 3; i++) { + let name: string; + do { name = PRODUCT_POOL[Math.floor(Math.random() * PRODUCT_POOL.length)]; } while (usedNames.has(name)); + usedNames.add(name); + const pid = `p${String(idx).padStart(4, '0')}`; + const sales = Math.round(store.totalSales * 0.33 * (0.8 + Math.random() * 0.4)); + products.push({ + id: pid, + storeId: store.id, + name, + totalSales: sales, + revenue: Math.round(sales * (20 + Math.random() * 30)), + avgRating: Math.round((store.avgRating + (Math.random() * 0.4 - 0.2)) * 10) / 10, + }); + idx++; + } + } + return products; +} + +async function seed() { + console.log('Seeding retail tree…'); + + await db.execute(sql`TRUNCATE retail_products, retail_stores, retail_regions RESTART IDENTITY CASCADE`); + + await db.insert(schema.retailRegions).values(regions); + console.log(` ✓ ${regions.length} regions`); + + const stores = makeStores(); + await db.insert(schema.retailStores).values(stores); + console.log(` ✓ ${stores.length} stores`); + + const products = makeProducts(stores); + await db.insert(schema.retailProducts).values(products); + console.log(` ✓ ${products.length} products`); + + console.log('Done.'); + process.exit(0); +} + +seed().catch((err) => { console.error(err); process.exit(1); }); diff --git a/apps/hono-example/src/routes/manual/index.ts b/apps/hono-example/src/routes/manual/index.ts index e308fe1..3070d11 100644 --- a/apps/hono-example/src/routes/manual/index.ts +++ b/apps/hono-example/src/routes/manual/index.ts @@ -3,6 +3,7 @@ import productsApp from './products'; import ordersApp from './orders'; import usersApp from './users'; import tenantsApp from './tenants'; +import retailApp from './retail'; const app = new Hono(); @@ -10,5 +11,6 @@ app.route('/products', productsApp); app.route('/orders', ordersApp); app.route('/users', usersApp); app.route('/tenants', tenantsApp); +app.route('/retail', retailApp); export default app; diff --git a/apps/hono-example/src/routes/manual/retail.ts b/apps/hono-example/src/routes/manual/retail.ts new file mode 100644 index 0000000..d7615f1 --- /dev/null +++ b/apps/hono-example/src/routes/manual/retail.ts @@ -0,0 +1,136 @@ +import { Hono } from 'hono'; +import { db } from '@/db'; +import * as schema from '@/db/schema'; +import { eq, ilike, sql } from 'drizzle-orm'; +import { asc, desc } from 'drizzle-orm'; +import { parseRequest } from '@tablecraft/engine'; + +const app = new Hono(); + +// ─── Sort whitelists ────────────────────────────────────────────────────────── + +const SORT = { + region: { name: schema.retailRegions.name, totalSales: schema.retailRegions.totalSales, revenue: schema.retailRegions.revenue, stores: schema.retailRegions.stores, avgRating: schema.retailRegions.avgRating }, + store: { name: schema.retailStores.name, totalSales: schema.retailStores.totalSales, revenue: schema.retailStores.revenue, avgRating: schema.retailStores.avgRating }, + product: { name: schema.retailProducts.name, totalSales: schema.retailProducts.totalSales, revenue: schema.retailProducts.revenue, avgRating: schema.retailProducts.avgRating }, +}; + +type Dir = 'asc' | 'desc'; + +/** Resolve a sort column from a whitelist + direction into a Drizzle ORDER BY clause. */ +function order>(map: T, key: string, fallback: T[keyof T], dir: Dir) { + const col = (key in map ? map[key] : fallback) as Parameters[0]; + return dir === 'asc' ? asc(col) : desc(col); +} + +/** Build the standard pagination meta object. */ +function pageMeta(page: number, pageSize: number, total: number) { + return { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) }; +} + +/** + * Parse the common query params for both tree endpoints. + * Uses the engine's `parseRequest` for validated page/pageSize/search, + * then reads sort/sortOrder separately (frontend uses two-param format). + */ +function parseParams(searchParams: URLSearchParams) { + const p = parseRequest(searchParams); + const page = Math.max(1, p.page ?? 1); + const pageSize = Math.min(100, Math.max(1, p.pageSize ?? 10)); + return { + page, + pageSize, + offset: (page - 1) * pageSize, + search: p.search?.trim() ?? '', + sortKey: searchParams.get('sort') ?? 'name', + sortDir: (searchParams.get('sortOrder') === 'asc' ? 'asc' : 'desc') as Dir, + }; +} + +// ─── GET /retail/tree ───────────────────────────────────────────────────────── +// No search → paginated top-level regions (lazy tree root). +// With search → flat results across all three levels with breadcrumbs. + +app.get('/tree', async (c) => { + const { page, pageSize, offset, search, sortKey, sortDir } = + parseParams(new URL(c.req.url).searchParams); + + // ── Deep search mode ──────────────────────────────────────────────────── + if (search) { + const term = `%${search}%`; + + const [rRows, sRows, pRows] = await Promise.all([ + db.select().from(schema.retailRegions) + .where(ilike(schema.retailRegions.name, term)), + + db.select({ + id: schema.retailStores.id, name: schema.retailStores.name, + totalSales: schema.retailStores.totalSales, revenue: schema.retailStores.revenue, + avgRating: schema.retailStores.avgRating, regionName: schema.retailRegions.name, + }).from(schema.retailStores) + .leftJoin(schema.retailRegions, eq(schema.retailStores.regionId, schema.retailRegions.id)) + .where(ilike(schema.retailStores.name, term)), + + db.select({ + id: schema.retailProducts.id, name: schema.retailProducts.name, + totalSales: schema.retailProducts.totalSales, revenue: schema.retailProducts.revenue, + avgRating: schema.retailProducts.avgRating, + storeName: schema.retailStores.name, regionName: schema.retailRegions.name, + }).from(schema.retailProducts) + .leftJoin(schema.retailStores, eq(schema.retailProducts.storeId, schema.retailStores.id)) + .leftJoin(schema.retailRegions, eq(schema.retailStores.regionId, schema.retailRegions.id)) + .where(ilike(schema.retailProducts.name, term)), + ]); + + const all = [ + ...rRows.map(r => ({ ...r, type: 'Region' as const, breadcrumb: null as string | null, children: [] as never[] })), + ...sRows.map(s => ({ id: s.id, name: s.name, type: 'Store' as const, totalSales: s.totalSales, revenue: s.revenue, stores: null, avgRating: s.avgRating, breadcrumb: s.regionName ?? null, children: [] as never[] })), + ...pRows.map(p => ({ id: p.id, name: p.name, type: 'Product' as const, totalSales: p.totalSales, revenue: p.revenue, stores: null, avgRating: p.avgRating, breadcrumb: [p.regionName, p.storeName].filter(Boolean).join(' › ') || null, children: [] as never[] })), + ]; + + return c.json({ data: all.slice(offset, offset + pageSize), meta: pageMeta(page, pageSize, all.length) }); + } + + // ── Tree mode: paginated top-level regions ────────────────────────────── + const [rows, [{ total }]] = await Promise.all([ + db.select().from(schema.retailRegions) + .orderBy(order(SORT.region, sortKey, schema.retailRegions.name, sortDir)) + .limit(pageSize).offset(offset), + db.select({ total: sql`count(*)` }).from(schema.retailRegions), + ]); + + return c.json({ + data: rows.map(r => ({ ...r, type: 'Region' as const, breadcrumb: null, children: undefined })), + meta: pageMeta(page, pageSize, Number(total)), + }); +}); + +// ─── GET /retail/tree/:id/children ─────────────────────────────────────────── + +app.get('/tree/:id/children', async (c) => { + const id = c.req.param('id'); + const sortKey = c.req.query('sort') ?? 'name'; + const sortDir = (c.req.query('sortOrder') === 'asc' ? 'asc' : 'desc') as Dir; + + // Region → stores + const region = await db.query.retailRegions.findFirst({ where: eq(schema.retailRegions.id, id) }); + if (region) { + const rows = await db.select().from(schema.retailStores) + .where(eq(schema.retailStores.regionId, id)) + .orderBy(order(SORT.store, sortKey, schema.retailStores.name, sortDir)); + return c.json(rows.map(s => ({ ...s, type: 'Store' as const, stores: null, breadcrumb: null, children: undefined }))); + } + + // Store → products (leaf) + const store = await db.query.retailStores.findFirst({ where: eq(schema.retailStores.id, id) }); + if (store) { + const rows = await db.select().from(schema.retailProducts) + .where(eq(schema.retailProducts.storeId, id)) + .orderBy(order(SORT.product, sortKey, schema.retailProducts.name, sortDir)); + return c.json(rows.map(p => ({ ...p, type: 'Product' as const, stores: null, breadcrumb: null, children: [] }))); + } + + return c.json([]); +}); + +export default app; diff --git a/apps/vite-web-example/eslint.config.js b/apps/vite-web-example/eslint.config.js index 5e6b472..97fee71 100644 --- a/apps/vite-web-example/eslint.config.js +++ b/apps/vite-web-example/eslint.config.js @@ -18,6 +18,9 @@ export default defineConfig([ languageOptions: { ecmaVersion: 2020, globals: globals.browser, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, }, }, ]) diff --git a/apps/vite-web-example/src/App.tsx b/apps/vite-web-example/src/App.tsx index 4ff105f..095b634 100644 --- a/apps/vite-web-example/src/App.tsx +++ b/apps/vite-web-example/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, Link, useLocation } from "react-router-dom"; +import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom"; import { ProductsPage } from "@/pages/products-page"; import { ProductsAxiosPage } from "@/pages/products-axios-page"; import { OrdersPage } from "@/pages/orders-page"; @@ -9,10 +9,33 @@ import { DashboardPage } from "@/pages/dashboard-page"; import { OrdersRestPage } from "@/pages/orders-rest-page"; import { OrdersSubRowPage } from "@/pages/orders-subrow-page"; import { EmployeesStaticPage } from "@/pages/employees-static-page"; -import { EmployeesGroupingPage } from "@/pages/employees-grouping-page"; +import { ToolbarPlacementPage } from "@/pages/toolbar-placement-page"; +import { ToolbarStartPage } from "@/pages/toolbar-start-page"; +import { RowGroupingLayout } from "@/pages/row-grouping/layout"; +import { RowGroupingBasicPage } from "@/pages/row-grouping/basic"; +import { RowGroupingServerPage } from "@/pages/row-grouping/server"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { Package, ShoppingCart, Users, LayoutDashboard, Cable, Filter, Plug, Database } from "lucide-react"; +import { + Package, + ShoppingCart, + Users, + LayoutDashboard, + Cable, + Filter, + Plug, + Database, + SlidersHorizontal, + ChevronDown, +} from "lucide-react"; const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
{/* Header */}
-
- +
+

TableCraft

-
-
+
@@ -99,7 +200,13 @@ function App() { } /> } /> } /> - } /> + }> + } /> + } /> + } /> + + } /> + } />
diff --git a/apps/vite-web-example/src/data/employees-grouping.ts b/apps/vite-web-example/src/data/employees-grouping.ts new file mode 100644 index 0000000..b14f881 --- /dev/null +++ b/apps/vite-web-example/src/data/employees-grouping.ts @@ -0,0 +1,28 @@ +export interface EmployeeRow extends Record { + id: string; + name: string; + department: string; + team: string; + role: string; + salary: number; + location: string; + startDate: string; +} + +export const EMPLOYEES_DATA: EmployeeRow[] = [ + { id: "1", name: "Alice Chen", department: "Engineering", team: "Frontend", role: "Senior Engineer", salary: 130000, location: "San Francisco", startDate: "2021-03-01" }, + { id: "2", name: "Bob Martinez", department: "Engineering", team: "Frontend", role: "Junior Engineer", salary: 85000, location: "New York", startDate: "2023-06-15" }, + { id: "3", name: "Carol Kim", department: "Engineering", team: "Backend", role: "Staff Engineer", salary: 155000, location: "Remote", startDate: "2019-11-20" }, + { id: "4", name: "David Park", department: "Engineering", team: "Backend", role: "Senior Engineer", salary: 135000, location: "San Francisco", startDate: "2020-07-10" }, + { id: "5", name: "Eva Rossi", department: "Design", team: "UX", role: "Lead Designer", salary: 120000, location: "New York", startDate: "2020-02-14" }, + { id: "6", name: "Frank Nguyen", department: "Design", team: "UX", role: "Designer", salary: 95000, location: "San Francisco", startDate: "2022-09-01" }, + { id: "7", name: "Grace Liu", department: "Design", team: "Brand", role: "Senior Designer", salary: 110000, location: "Remote", startDate: "2021-05-17" }, + { id: "8", name: "Hiro Tanaka", department: "Product", team: "Growth", role: "PM", salary: 140000, location: "San Francisco", startDate: "2020-01-06" }, + { id: "9", name: "Isla Scott", department: "Product", team: "Core", role: "Senior PM", salary: 160000, location: "New York", startDate: "2018-08-22" }, + { id: "10", name: "James O'Brien", department: "Product", team: "Growth", role: "APM", salary: 90000, location: "Remote", startDate: "2023-01-10" }, + { id: "11", name: "Karen White", department: "Engineering", team: "Platform", role: "Principal Engineer", salary: 180000, location: "San Francisco", startDate: "2017-04-03" }, + { id: "12", name: "Leo Santos", department: "Engineering", team: "Platform", role: "Senior Engineer", salary: 145000, location: "Remote", startDate: "2021-08-19" }, + { id: "13", name: "Maya Patel", department: "Design", team: "Brand", role: "Designer", salary: 90000, location: "New York", startDate: "2022-11-07" }, + { id: "14", name: "Nate Johnson", department: "Product", team: "Core", role: "PM", salary: 135000, location: "San Francisco", startDate: "2019-09-14" }, + { id: "15", name: "Olivia Brown", department: "Engineering", team: "Frontend", role: "Senior Engineer", salary: 140000, location: "New York", startDate: "2020-12-01" }, +]; diff --git a/apps/vite-web-example/src/data/retail-tree.ts b/apps/vite-web-example/src/data/retail-tree.ts new file mode 100644 index 0000000..bc28c38 --- /dev/null +++ b/apps/vite-web-example/src/data/retail-tree.ts @@ -0,0 +1,145 @@ +export interface RetailNode extends Record { + id: string; + name: string; + type: "Region" | "Store" | "Product"; + totalSales: number; + revenue: number; + stores: number | null; + avgRating: number | null; + breadcrumb?: string | null; + children?: RetailNode[]; +} + +export const RETAIL_TREE: RetailNode[] = [ + { + id: "r1", + name: "North America", + type: "Region", + totalSales: 48200, + revenue: 1240000, + stores: 12, + avgRating: 4.5, + children: [ + { + id: "s1", + name: "NYC Flagship", + type: "Store", + totalSales: 18500, + revenue: 520000, + stores: null, + avgRating: 4.7, + children: [ + { id: "p1", name: "Wireless Headphones", type: "Product", totalSales: 6400, revenue: 192000, stores: null, avgRating: 4.8 }, + { id: "p2", name: "Laptop Stand", type: "Product", totalSales: 4200, revenue: 126000, stores: null, avgRating: 4.6 }, + { id: "p3", name: "USB-C Hub", type: "Product", totalSales: 7900, revenue: 202000, stores: null, avgRating: 4.7 }, + ], + }, + { + id: "s2", + name: "Chicago Downtown", + type: "Store", + totalSales: 14800, + revenue: 360000, + stores: null, + avgRating: 4.4, + children: [ + { id: "p4", name: "Mechanical Keyboard", type: "Product", totalSales: 5100, revenue: 153000, stores: null, avgRating: 4.5 }, + { id: "p5", name: "Webcam 4K", type: "Product", totalSales: 3400, revenue: 102000, stores: null, avgRating: 4.3 }, + { id: "p6", name: "Desk Mat XL", type: "Product", totalSales: 6300, revenue: 105000, stores: null, avgRating: 4.4 }, + ], + }, + { + id: "s3", + name: "LA Beverly", + type: "Store", + totalSales: 14900, + revenue: 360000, + stores: null, + avgRating: 4.4, + children: [ + { id: "p7", name: "Monitor 27\"", type: "Product", totalSales: 4800, revenue: 192000, stores: null, avgRating: 4.6 }, + { id: "p8", name: "Smart Speaker", type: "Product", totalSales: 5200, revenue: 104000, stores: null, avgRating: 4.2 }, + { id: "p9", name: "Cable Organizer", type: "Product", totalSales: 4900, revenue: 64000, stores: null, avgRating: 4.4 }, + ], + }, + ], + }, + { + id: "r2", + name: "Europe", + type: "Region", + totalSales: 32400, + revenue: 890000, + stores: 8, + avgRating: 4.3, + children: [ + { + id: "s4", + name: "London Oxford St.", + type: "Store", + totalSales: 17200, + revenue: 510000, + stores: null, + avgRating: 4.5, + children: [ + { id: "p10", name: "Noise-Cancel Earbuds", type: "Product", totalSales: 7800, revenue: 234000, stores: null, avgRating: 4.6 }, + { id: "p11", name: "Portable Charger", type: "Product", totalSales: 5400, revenue: 162000, stores: null, avgRating: 4.4 }, + { id: "p12", name: "Phone Stand", type: "Product", totalSales: 4000, revenue: 114000, stores: null, avgRating: 4.5 }, + ], + }, + { + id: "s5", + name: "Berlin Mitte", + type: "Store", + totalSales: 15200, + revenue: 380000, + stores: null, + avgRating: 4.1, + children: [ + { id: "p13", name: "LED Desk Lamp", type: "Product", totalSales: 5600, revenue: 140000, stores: null, avgRating: 4.2 }, + { id: "p14", name: "Ergonomic Mouse", type: "Product", totalSales: 4900, revenue: 122500, stores: null, avgRating: 4.0 }, + { id: "p15", name: "Monitor Light Bar", type: "Product", totalSales: 4700, revenue: 117500, stores: null, avgRating: 4.1 }, + ], + }, + ], + }, + { + id: "r3", + name: "Asia Pacific", + type: "Region", + totalSales: 29600, + revenue: 760000, + stores: 7, + avgRating: 4.4, + children: [ + { + id: "s6", + name: "Tokyo Shibuya", + type: "Store", + totalSales: 16400, + revenue: 430000, + stores: null, + avgRating: 4.6, + children: [ + { id: "p16", name: "Smart Watch", type: "Product", totalSales: 6200, revenue: 186000, stores: null, avgRating: 4.7 }, + { id: "p17", name: "Laptop Sleeve", type: "Product", totalSales: 5300, revenue: 106000, stores: null, avgRating: 4.5 }, + { id: "p18", name: "Mini Projector", type: "Product", totalSales: 4900, revenue: 138000, stores: null, avgRating: 4.6 }, + ], + }, + { + id: "s7", + name: "Singapore CBD", + type: "Store", + totalSales: 13200, + revenue: 330000, + stores: null, + avgRating: 4.2, + children: [ + { id: "p19", name: "Wireless Charger", type: "Product", totalSales: 4800, revenue: 120000, stores: null, avgRating: 4.3 }, + { id: "p20", name: "Cable Clip Pack", type: "Product", totalSales: 4200, revenue: 84000, stores: null, avgRating: 4.1 }, + { id: "p21", name: "Screen Protector", type: "Product", totalSales: 4200, revenue: 126000, stores: null, avgRating: 4.2 }, + ], + }, + ], + }, +]; diff --git a/apps/vite-web-example/src/pages/employees-grouping-page.tsx b/apps/vite-web-example/src/pages/employees-grouping-page.tsx new file mode 100644 index 0000000..5c657ca --- /dev/null +++ b/apps/vite-web-example/src/pages/employees-grouping-page.tsx @@ -0,0 +1,201 @@ +import { DataTable, createStaticAdapter, DataTableColumnHeader } from "@tablecraft/table"; +import { useRef } from "react"; +import type { TableGroupingAPI } from "@tablecraft/table"; +import type { ColumnDef } from "@tanstack/react-table"; +import { EMPLOYEES_DATA, type EmployeeRow } from "@/data/employees-grouping"; + +const adapter = createStaticAdapter(EMPLOYEES_DATA); + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ getValue }) => {String(getValue())}, + }, + { + accessorKey: "department", + header: ({ column }) => , + }, + { + accessorKey: "team", + header: ({ column }) => , + }, + { + accessorKey: "role", + header: ({ column }) => , + cell: ({ getValue }) => {String(getValue())}, + }, + { + accessorKey: "salary", + header: ({ column }) => , + cell: ({ getValue }) => ( + + ${(getValue() as number).toLocaleString()} + + ), + aggregationFn: "sum", + aggregatedCell: ({ getValue }) => ( + + ${(getValue() as number).toLocaleString()} + + ), + }, + { + accessorKey: "location", + header: ({ column }) => , + }, + { + accessorKey: "startDate", + header: ({ column }) => , + cell: ({ getValue }) => {String(getValue())}, + }, +]; + +function ProgrammaticDemo() { + const groupRef = useRef(null); + + return ( +
+
+ + + + + + +
+ + adapter={adapter} + columns={columns} + rowGrouping={["department", "team"]} + groupingRef={groupRef} + rowGroupingConfig={{ defaultExpanded: false }} + config={{ enableUrlState: false, enablePagination: false, defaultPageSize: 100 }} + /> +
+ ); +} + +export function EmployeesGroupingPage() { + return ( +
+
+

Row Grouping

+

+ Flat employee data grouped into collapsible hierarchies — no changes to the backend needed. +

+
+ + {/* Example 1: Group by Department */} +
+

Group by Department (salary sum)

+ + adapter={adapter} + columns={columns} + rowGrouping={["department"]} + rowGroupingConfig={{ + defaultExpanded: true, + aggregations: { salary: "sum" }, + }} + config={{ + enableUrlState: false, + enablePagination: false, + defaultPageSize: 100, + }} + /> +
+ + {/* Example 2: Group by Department then Team */} +
+

Group by Department → Team (salary mean)

+ + adapter={adapter} + columns={columns} + rowGrouping={["department", "team"]} + rowGroupingConfig={{ + defaultExpanded: false, + aggregations: { salary: "mean" }, + }} + config={{ + enableUrlState: false, + enablePagination: false, + defaultPageSize: 100, + }} + /> +
+ + {/* Example 3: Custom group cell renderer */} +
+

Custom Group Header (group by location)

+ + adapter={adapter} + columns={columns} + rowGrouping={["location"]} + rowGroupingConfig={{ + defaultExpanded: true, + renderGroupCell: ({ value, leafRowCount }) => ( + + 📍 + {String(value)} + + ({leafRowCount} {leafRowCount === 1 ? "employee" : "employees"}) + + + ), + }} + config={{ + enableUrlState: false, + enablePagination: false, + defaultPageSize: 100, + }} + /> +
+ + {/* Example 4: Programmatic depth control */} +
+

Programmatic Control (groupingRef)

+

+ Attach a groupingRef for imperative access: + expandDepth, collapseDepth, toggleDepth, setExpandedDepths and more. +

+ +
+
+ ); +} diff --git a/apps/vite-web-example/src/pages/products-grouping-page.tsx b/apps/vite-web-example/src/pages/products-grouping-page.tsx new file mode 100644 index 0000000..15a13d0 --- /dev/null +++ b/apps/vite-web-example/src/pages/products-grouping-page.tsx @@ -0,0 +1,114 @@ +import { useMemo } from "react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { + DataTable, + DataTableColumnHeader, + createRestAdapter, +} from "@tablecraft/table"; +import type { QueryParams, QueryResult } from "@tablecraft/table"; +import { API_BASE_URL } from "@/api"; + +// ─── Row type ──────────────────────────────────────────────────────────────── + +interface ProductRow extends Record { + id: number; + name: string; + description: string | null; + price: string; + category: string; + isArchived: boolean; +} + +// ─── Columns ────────────────────────────────────────────────────────────────── + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ getValue }) => ( + {String(getValue())} + ), + }, + { + accessorKey: "category", + header: ({ column }) => , + }, + { + accessorKey: "description", + header: ({ column }) => , + cell: ({ getValue }) => ( + {String(getValue() ?? "—")} + ), + }, + { + accessorKey: "price", + header: ({ column }) => , + cell: ({ getValue }) => ( + + ${Number(getValue()).toFixed(2)} + + ), + aggregationFn: "mean", + aggregatedCell: ({ getValue }) => ( + + avg ${Number(getValue()).toFixed(2)} + + ), + }, +]; + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export function ProductsGroupingPage() { + // Fetch all products in one request (large pageSize) so client-side grouping + // sees the full dataset. For very large APIs, move grouping to the server. + const adapter = useMemo( + () => + createRestAdapter({ + queryFn: async (params: QueryParams): Promise> => { + const url = new URL(`${API_BASE_URL.replace("/engine", "/manual")}/products`, window.location.origin); + url.searchParams.set("page", String(params.page)); + url.searchParams.set("pageSize", String(params.pageSize)); + if (params.search) url.searchParams.set("search", params.search); + if (params.sort) url.searchParams.set("sort", params.sortOrder === "desc" ? `-${params.sort}` : params.sort); + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }, + }), + [] + ); + + return ( +
+
+

Products — Server-side Row Grouping

+

+ Data fetched from /api/manual/products and + grouped client-side by category. Use a large{" "} + defaultPageSize so all rows are + available for grouping. +

+
+ +
+

Group by Category (avg price)

+ + adapter={adapter} + columns={columns} + rowGrouping={["category"]} + rowGroupingConfig={{ + defaultExpanded: true, + aggregations: { price: "mean" }, + }} + config={{ + enableUrlState: false, + enablePagination: false, + defaultPageSize: 100, + }} + /> +
+
+ ); +} diff --git a/apps/vite-web-example/src/pages/products-page.tsx b/apps/vite-web-example/src/pages/products-page.tsx index b347827..3980d99 100644 --- a/apps/vite-web-example/src/pages/products-page.tsx +++ b/apps/vite-web-example/src/pages/products-page.tsx @@ -1,5 +1,6 @@ -import { useMemo } from 'react'; -import { DataTable, createTableCraftAdapter, hiddenColumns, defineColumnOverrides } from '@tablecraft/table'; +import { useMemo, useState } from 'react'; +import { DataTable, hiddenColumns, defineColumnOverrides } from '@tablecraft/table'; +import { createProductsAdapter } from '../generated/products'; import type { ProductsRow, ProductsColumn } from '../generated'; import { DropdownMenu, @@ -10,16 +11,18 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; -import { MoreHorizontal } from 'lucide-react'; +import { MoreHorizontal, Archive } from 'lucide-react'; // ** import apis import { API_BASE_URL } from '../api'; export function ProductsPage() { - const adapter = useMemo(() => createTableCraftAdapter({ + const [showArchived, setShowArchived] = useState(false); + + const adapter = useMemo(() => createProductsAdapter({ baseUrl: API_BASE_URL, - table: 'products', - }), []); + customFilters: showArchived ? { isArchived: { operator: 'eq', value: true } } : undefined, + }), [showArchived]); return (
@@ -28,6 +31,18 @@ export function ProductsPage() { adapter={adapter} hiddenColumns={hiddenColumns(['id', 'tenantId', 'metadata'])} + startToolbarPlacement="after-search" + startToolbarContent={ + + } config={{ enableSearch: true, enableExport: true, diff --git a/apps/vite-web-example/src/pages/row-grouping/basic.tsx b/apps/vite-web-example/src/pages/row-grouping/basic.tsx new file mode 100644 index 0000000..4d6123b --- /dev/null +++ b/apps/vite-web-example/src/pages/row-grouping/basic.tsx @@ -0,0 +1,187 @@ +import { useRef } from "react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { DataTable, DataTableColumnHeader, createStaticAdapter } from "@tablecraft/table"; +import type { TableGroupingAPI } from "@tablecraft/table"; +import { EMPLOYEES_DATA, type EmployeeRow } from "@/data/employees-grouping"; + +const adapter = createStaticAdapter(EMPLOYEES_DATA); + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ getValue }) => {String(getValue())}, + }, + { + accessorKey: "department", + header: ({ column }) => , + }, + { + accessorKey: "team", + header: ({ column }) => , + }, + { + accessorKey: "role", + header: ({ column }) => , + cell: ({ getValue }) => {String(getValue())}, + }, + { + accessorKey: "salary", + header: ({ column }) => , + cell: ({ getValue }) => ( + + ${(getValue() as number).toLocaleString()} + + ), + aggregationFn: "sum", + aggregatedCell: ({ getValue }) => ( + + ${(getValue() as number).toLocaleString()} + + ), + }, + { + accessorKey: "location", + header: ({ column }) => , + }, + { + accessorKey: "startDate", + header: ({ column }) => , + cell: ({ getValue }) => {String(getValue())}, + }, +]; + +function ProgrammaticDemo() { + const groupRef = useRef(null); + + return ( +
+
+ + + + + + +
+ + adapter={adapter} + columns={columns} + rowGrouping={["department", "team"]} + groupingRef={groupRef} + rowGroupingConfig={{ defaultExpanded: false }} + config={{ enableUrlState: false, enablePagination: false, defaultPageSize: 100 }} + /> +
+ ); +} + +export function RowGroupingBasicPage() { + return ( +
+
+

Group by Department (salary sum)

+

+ 15 employees grouped into departments with aggregated salary totals. +

+ + adapter={adapter} + columns={columns} + rowGrouping={["department"]} + rowGroupingConfig={{ + defaultExpanded: true, + aggregations: { salary: "sum" }, + }} + config={{ enableUrlState: false, enablePagination: false, defaultPageSize: 100 }} + /> +
+ +
+

Group by Department → Team (salary mean)

+

+ Two-level hierarchy: departments expand into teams, each showing average salary. +

+ + adapter={adapter} + columns={columns} + rowGrouping={["department", "team"]} + rowGroupingConfig={{ + defaultExpanded: false, + aggregations: { salary: "mean" }, + }} + config={{ enableUrlState: false, enablePagination: false, defaultPageSize: 100 }} + /> +
+ +
+

Custom Group Header (group by location)

+

+ Use renderGroupCell for a fully custom group row renderer. +

+ + adapter={adapter} + columns={columns} + rowGrouping={["location"]} + rowGroupingConfig={{ + defaultExpanded: true, + renderGroupCell: ({ value, leafRowCount }) => ( + + 📍 + {String(value)} + + ({leafRowCount} {leafRowCount === 1 ? "employee" : "employees"}) + + + ), + }} + config={{ enableUrlState: false, enablePagination: false, defaultPageSize: 100 }} + /> +
+ +
+

Programmatic Control (groupingRef)

+

+ Attach a groupingRef for imperative access: + expandDepth, collapseDepth, toggleDepth, setExpandedDepths and more. +

+ +
+
+ ); +} diff --git a/apps/vite-web-example/src/pages/row-grouping/layout.tsx b/apps/vite-web-example/src/pages/row-grouping/layout.tsx new file mode 100644 index 0000000..0f5a82b --- /dev/null +++ b/apps/vite-web-example/src/pages/row-grouping/layout.tsx @@ -0,0 +1,41 @@ +import { NavLink, Outlet } from "react-router-dom"; +import { cn } from "@/lib/utils"; + +const tabs = [ + { to: "/row-grouping/basic", label: "Basic (Static)" }, + { to: "/row-grouping/server", label: "Server-side" }, +]; + +export function RowGroupingLayout() { + return ( +
+
+

Row Grouping

+

+ Flat data grouped into collapsible hierarchies, and server-side data with client-side grouping. +

+
+ + + + +
+ ); +} diff --git a/apps/vite-web-example/src/pages/row-grouping/server.tsx b/apps/vite-web-example/src/pages/row-grouping/server.tsx new file mode 100644 index 0000000..9d2a488 --- /dev/null +++ b/apps/vite-web-example/src/pages/row-grouping/server.tsx @@ -0,0 +1,157 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { + DataTable, + DataTableColumnHeader, + isTreeLoadingRow, + useTreeAdapter, +} from "@tablecraft/table"; +import { ChevronRight, Loader2 } from "lucide-react"; +import type { RetailNode } from "@/data/retail-tree"; + +const RETAIL_API = `${import.meta.env.VITE_API_BASE_URL ?? ""}/api/manual/retail`; + +// ─── Columns ──────────────────────────────────────────────────────────────── + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + size: 320, + header: ({ column }) => , + cell: ({ row, getValue }) => { + if (isTreeLoadingRow(row.original)) { + return ( + + + Loading… + + ); + } + const breadcrumb = row.original.breadcrumb; + return ( + + + {String(getValue())} + + {breadcrumb && ( + {breadcrumb} + )} + + ); + }, + }, + { + accessorKey: "type", + size: 90, + header: ({ column }) => , + cell: ({ row, getValue }) => + isTreeLoadingRow(row.original) ? null : ( + {String(getValue())} + ), + }, + { + accessorKey: "totalSales", + header: ({ column }) => , + cell: ({ row, getValue }) => + isTreeLoadingRow(row.original) ? null : ( + {(getValue() as number).toLocaleString()} + ), + }, + { + accessorKey: "revenue", + header: ({ column }) => , + cell: ({ row, getValue }) => + isTreeLoadingRow(row.original) ? null : ( + + ${(getValue() as number).toLocaleString()} + + ), + }, + { + accessorKey: "stores", + header: ({ column }) => , + cell: ({ row, getValue }) => { + if (isTreeLoadingRow(row.original)) return null; + const v = getValue() as number | null; + return v != null ? ( + {v} + ) : ( + — + ); + }, + }, + { + accessorKey: "avgRating", + header: ({ column }) => , + cell: ({ row, getValue }) => { + if (isTreeLoadingRow(row.original)) return null; + const v = getValue() as number | null; + return v != null ? ( + + ★ + {v.toFixed(1)} + + ) : ( + — + ); + }, + }, +]; + +// ─── Page ─────────────────────────────────────────────────────────────────── + +export function RowGroupingServerPage() { + const { adapter, treeProps, isLoadingRow } = useTreeAdapter({ + list: { url: `${RETAIL_API}/tree` }, + children: { url: (id) => `${RETAIL_API}/tree/${id}/children` }, + loadingRow: (_parentId, loadingId) => ({ + id: loadingId, + name: "", + type: "Product", + totalSales: 0, + revenue: 0, + stores: null, + avgRating: null, + children: [], + }), + }); + + return ( +
+
+

+ Server-side Lazy Tree — Regions → Stores → Products +

+

+ Top-level regions are fetched from the DB with server-side search & + sort. Click{" "} + to + expand — children are fetched on demand from the API, each level + independently. +

+
+ + + adapter={adapter} + columns={columns} + {...treeProps} + getRowCanExpand={(row) => + !isLoadingRow(row) && + row.type !== "Product" && + row.children?.length !== 0 + } + config={{ + enableUrlState: true, + enablePagination: true, + enableDateFilter: false, + keepPreviousData: true, + defaultPageSize: 10, + searchPlaceholder: "Search regions, stores, products…", + }} + /> +
+ ); +} diff --git a/apps/vite-web-example/src/pages/row-grouping/tree.tsx b/apps/vite-web-example/src/pages/row-grouping/tree.tsx new file mode 100644 index 0000000..899e6ad --- /dev/null +++ b/apps/vite-web-example/src/pages/row-grouping/tree.tsx @@ -0,0 +1,124 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { DataTable, DataTableColumnHeader, createStaticAdapter } from "@tablecraft/table"; +import { RETAIL_TREE, type RetailNode } from "@/data/retail-tree"; + +const adapter = createStaticAdapter(RETAIL_TREE); + +const TYPE_BADGE: Record = { + Region: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + Store: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300", + Product: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", +}; + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ getValue }) => {String(getValue())}, + }, + { + accessorKey: "type", + header: ({ column }) => , + cell: ({ getValue }) => { + const t = getValue() as RetailNode["type"]; + return ( + + {t} + + ); + }, + }, + { + accessorKey: "totalSales", + header: ({ column }) => , + cell: ({ getValue }) => ( + {(getValue() as number).toLocaleString()} + ), + }, + { + accessorKey: "revenue", + header: ({ column }) => , + cell: ({ getValue }) => ( + + ${(getValue() as number).toLocaleString()} + + ), + }, + { + accessorKey: "stores", + header: ({ column }) => , + cell: ({ getValue }) => { + const v = getValue() as number | null; + return v != null ? ( + {v} + ) : ( + — + ); + }, + }, + { + accessorKey: "avgRating", + header: ({ column }) => , + cell: ({ getValue }) => { + const v = getValue() as number | null; + return v != null ? ( + + ★ + {v.toFixed(1)} + + ) : ( + — + ); + }, + }, +]; + +export function RowGroupingTreePage() { + return ( +
+
+

Client-side Tree (Regions → Stores → Products)

+

+ Pass getSubRows to enable + hierarchical rendering. The data already contains nested{" "} + children arrays — no grouping + config needed. +

+ + adapter={adapter} + columns={columns} + getSubRows={(row) => row.children as RetailNode[] | undefined} + config={{ + enableUrlState: false, + enablePagination: false, + defaultPageSize: 200, + }} + /> +
+ +
+

Server-side Pattern

+

+ For server-side tree data, use createRestAdapter with a{" "} + queryFn that returns the full nested JSON from your API. + Pass getSubRows the same way — the adapter only fetches data, + tree rendering is handled by the table. +

+
+

{"const adapter = createRestAdapter({"}

+

{"queryFn: async (params) => {"}

+

{"const res = await fetch(`/api/tree?page=${params.page}`);"}

+

{"return res.json(); // { data: [...nested], total, page, pageSize }"}

+

{"}"}

+

{"});"}

+

 

+

{" +

{"adapter={adapter}"}

+

{"columns={columns}"}

+

{"getSubRows={(row) => row.children}"}

+

{"/>"}

+
+
+
+ ); +} diff --git a/apps/vite-web-example/src/pages/toolbar-placement-page.tsx b/apps/vite-web-example/src/pages/toolbar-placement-page.tsx new file mode 100644 index 0000000..8b9df0b --- /dev/null +++ b/apps/vite-web-example/src/pages/toolbar-placement-page.tsx @@ -0,0 +1,198 @@ +import { useMemo, useState } from "react"; +import { DataTable, hiddenColumns, defineColumnOverrides } from "@tablecraft/table"; +import { createProductsAdapter } from "@/generated/products"; +import type { ProductsRow, ProductsColumn } from "@/generated/products"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { API_BASE_URL } from "../api"; +import { Upload, Trash2, Archive, ArchiveRestore, SlidersHorizontal } from "lucide-react"; + +const columnOverrides = defineColumnOverrides()({ + price: ({ value }) => ( + + ${(value as number).toFixed(2)} + + ), + isArchived: ({ value }) => ( + + {value ? "Archived" : "Active"} + + ), +}); + +const hidden = hiddenColumns(["id", "tenantId", "metadata", "tags", "description"]); + +function Demo({ + label, + placement, + children, +}: { + label: string; + placement: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} + {placement} +
+ {children} +
+ ); +} + +// ─── before-export: bulk actions ────────────────────────────────────────────── + +function BulkActionsDemo() { + const adapter = useMemo(() => createProductsAdapter({ baseUrl: API_BASE_URL }), []); + const [log, setLog] = useState(null); + + return ( + + {log &&

{log}

} + + adapter={adapter} + hiddenColumns={hidden} + columnOverrides={columnOverrides} + endToolbarPlacement="before-export" + endToolbarContent={(ctx) => + ctx.totalSelected > 0 ? ( +
+ + +
+ ) : null + } + config={{ enableRowSelection: true, enableSearch: true, enableExport: true, defaultPageSize: 5, pageSizeOptions: [5, 10], columnResizingTableId: "tp-bulk", enableUrlState: false }} + /> +
+ ); +} + +// ─── after-settings: import (rightmost) ────────────────────────────────────── + +function ImportDemo() { + const adapter = useMemo(() => createProductsAdapter({ baseUrl: API_BASE_URL }), []); + return ( + + + adapter={adapter} + hiddenColumns={hidden} + columnOverrides={columnOverrides} + endToolbarPlacement="after-settings" + endToolbarContent={ + + } + config={{ enableSearch: true, enableExport: true, defaultPageSize: 5, pageSizeOptions: [5], columnResizingTableId: "tp-import", enableUrlState: false }} + /> + + ); +} + +// ─── before-view: category filter chips ────────────────────────────────────── + +const CATS = ["electronics", "clothing", "home"] as const; +type Cat = (typeof CATS)[number]; + +function CategoryFilterDemo() { + const [activeCat, setActiveCat] = useState(null); + const adapter = useMemo( + () => createProductsAdapter({ + baseUrl: API_BASE_URL, + customFilters: activeCat ? { category: { operator: "eq", value: activeCat } } : undefined, + }), + [activeCat], + ); + + return ( + + + adapter={adapter} + hiddenColumns={hidden} + columnOverrides={columnOverrides} + endToolbarPlacement="before-view" + endToolbarContent={ +
+ + {CATS.map((c) => ( + + ))} +
+ } + config={{ enableSearch: false, enableExport: true, defaultPageSize: 5, pageSizeOptions: [5], columnResizingTableId: "tp-cats", enableUrlState: false }} + /> +
+ ); +} + +// ─── after-view (default): delete with count ────────────────────────────────── + +function DeleteDemo() { + const adapter = useMemo(() => createProductsAdapter({ baseUrl: API_BASE_URL }), []); + return ( + + + adapter={adapter} + hiddenColumns={hidden} + columnOverrides={columnOverrides} + endToolbarContent={(ctx) => ( + + )} + config={{ enableRowSelection: true, enableSearch: true, enableExport: true, enableColumnVisibility: true, defaultPageSize: 5, pageSizeOptions: [5], columnResizingTableId: "tp-delete", enableUrlState: false }} + /> + + ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export function ToolbarPlacementPage() { + return ( +
+
+
+

End Slots

+ endToolbarContent +
+

+ Inject into the right toolbar cluster. Order:{" "} + + before-grouping → before-export → after-export / before-view → after-view / before-settings → after-settings + +

+
+ + + + + +
+ ); +} diff --git a/apps/vite-web-example/src/pages/toolbar-start-page.tsx b/apps/vite-web-example/src/pages/toolbar-start-page.tsx new file mode 100644 index 0000000..c207f50 --- /dev/null +++ b/apps/vite-web-example/src/pages/toolbar-start-page.tsx @@ -0,0 +1,174 @@ +import { useMemo, useState } from "react"; +import { DataTable, hiddenColumns, defineColumnOverrides } from "@tablecraft/table"; +import { createProductsAdapter } from "@/generated/products"; +import type { ProductsRow, ProductsColumn } from "@/generated/products"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { API_BASE_URL } from "../api"; +import { Plus, RefreshCw, Tag } from "lucide-react"; + +const columnOverrides = defineColumnOverrides()({ + price: ({ value }) => ( + + ${(value as number).toFixed(2)} + + ), + isArchived: ({ value }) => ( + + {value ? "Archived" : "Active"} + + ), +}); + +const hidden = hiddenColumns(["id", "tenantId", "metadata", "tags", "description"]); + +function Demo({ + label, + placement, + children, +}: { + label: string; + placement: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} + {placement} +
+ {children} +
+ ); +} + +// ─── before-search ──────────────────────────────────────────────────────────── + +function BeforeSearch() { + const adapter = useMemo(() => createProductsAdapter({ baseUrl: API_BASE_URL }), []); + return ( + + + adapter={adapter} + hiddenColumns={hidden} + columnOverrides={columnOverrides} + startToolbarPlacement="before-search" + startToolbarContent={ + + } + config={{ enableSearch: true, defaultPageSize: 5, pageSizeOptions: [5], enableUrlState: false, columnResizingTableId: "ts-before" }} + /> + + ); +} + +// ─── after-search ───────────────────────────────────────────────────────────── + +const CATS = ["electronics", "clothing", "home"] as const; +type Cat = (typeof CATS)[number]; + +function AfterSearch() { + const [cat, setCat] = useState(null); + const adapter = useMemo( + () => createProductsAdapter({ + baseUrl: API_BASE_URL, + customFilters: cat ? { category: { operator: "eq", value: cat } } : undefined, + }), + [cat], + ); + + return ( + + + adapter={adapter} + hiddenColumns={hidden} + columnOverrides={columnOverrides} + startToolbarPlacement="after-search" + startToolbarContent={ +
+ + {CATS.map((c) => ( + + ))} +
+ } + config={{ enableSearch: true, defaultPageSize: 5, pageSizeOptions: [5], enableUrlState: false, columnResizingTableId: "ts-after-search" }} + /> +
+ ); +} + +// ─── after-date (default) ───────────────────────────────────────────────────── + +function AfterDate() { + const adapter = useMemo(() => createProductsAdapter({ baseUrl: API_BASE_URL }), []); + const [syncing, setSyncing] = useState(false); + + return ( + + + adapter={adapter} + hiddenColumns={hidden} + columnOverrides={columnOverrides} + startToolbarPlacement="after-date" + startToolbarContent={ + + } + config={{ enableSearch: true, enableDateFilter: true, defaultPageSize: 5, pageSizeOptions: [5], enableUrlState: false, columnResizingTableId: "ts-after-date" }} + /> + + ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export function ToolbarStartPage() { + return ( +
+
+
+

Start Slots

+ startToolbarContent +
+

+ Inject into the left toolbar cluster. + Order: before-search → [search] → after-search → [date] → after-date +

+
+ + + + +
+ ); +} diff --git a/apps/web/src/Presentation.tsx b/apps/web/src/Presentation.tsx index a705556..ce649ba 100644 --- a/apps/web/src/Presentation.tsx +++ b/apps/web/src/Presentation.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { ChevronLeft, ChevronRight, Maximize, Minimize, Play, Pause } from 'lucide-react'; -import { cn } from './components/LiquidCard'; +import { cn } from './lib/utils'; interface PresentationProps { slides: React.ReactNode[]; @@ -73,11 +73,15 @@ export const Presentation: React.FC = ({ slides }) => { }, []); useEffect(() => { - handleMouseMove(); + // Schedule the initial auto-hide of the controls. + // `controlsVisible` already defaults to true; no need to setState synchronously. + controlsTimeoutRef.current = setTimeout(() => { + setControlsVisible(false); + }, 3000); return () => { if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current); }; - }, [handleMouseMove]); + }, []); useEffect(() => { if (!isPlaying) return; diff --git a/apps/web/src/components/LiquidCard.tsx b/apps/web/src/components/LiquidCard.tsx index b68ac85..962385a 100644 --- a/apps/web/src/components/LiquidCard.tsx +++ b/apps/web/src/components/LiquidCard.tsx @@ -1,10 +1,5 @@ import React from 'react'; -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} +import { cn } from '../lib/utils'; export const LiquidCard: React.FC> = ({ className, children, ...props }) => { return ( diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..77f4d80 --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} \ No newline at end of file diff --git a/build_log.txt b/build_log.txt deleted file mode 100644 index ddbcb6b..0000000 --- a/build_log.txt +++ /dev/null @@ -1,138 +0,0 @@ -@tablecraft/codegen build: CLI Building entry: src/cli.ts, src/index.ts -@tablecraft/codegen build: CLI Using tsconfig: tsconfig.json -@tablecraft/codegen build: CLI tsup v8.5.1 -@tablecraft/codegen build: CLI Target: es2022 -@tablecraft/codegen build: ESM Build start -@tablecraft/codegen build: ESM dist/chunk-6NAIM5XD.js 12.06 KB -@tablecraft/codegen build: ESM dist/cli.js 1.73 KB -@tablecraft/codegen build: ESM dist/index.js 347.00 B -@tablecraft/codegen build: ESM ⚡️ Build success in 633ms -@tablecraft/codegen build: DTS Build start -sveltekit-example build: vite v6.4.1 building SSR bundle for production... -sveltekit-example build: transforming... -vite-web-example build: @tablecraft/client build: Exited with code 0 -@tablecraft/client build: Exited with code 0 -@tablecraft/adapter-next build: Exited with code 0 -@tablecraft/plugin-cache build: Exited with code 0 -@tablecraft/adapter-express build: Exited with code 0 -@tablecraft/adapter-elysia build: Exited with code 0 -@tablecraft/adapter-sveltekit build: Exited with code 0 -@tablecraft/adapter-hono build: Exited with code 0 -sveltekit-example build: "uuid" is imported from external module "drizzle-orm/pg-core" but never used in "src/lib/server/db/schema.ts". -sveltekit-example build: ✓ 181 modules transformed. -sveltekit-example build: rendering chunks... -web build: vite v7.3.1 building client environment for production... -web build: transforming... -sveltekit-example build: vite v6.4.1 building for production... -sveltekit-example build: transforming... -@tablecraft/codegen build: DTS ⚡️ Build success in 37018ms -@tablecraft/codegen build: DTS dist/cli.d.ts 20.00 B -@tablecraft/codegen build: DTS dist/index.d.ts 2.12 KB -@tablecraft/codegen build: Exited with code 0 -sveltekit-example build: ✓ 152 modules transformed. -@tablecraft/engine build: Exited with code 0 -sveltekit-example build: rendering chunks... -sveltekit-example build: computing gzip size... -sveltekit-example build: .svelte-kit/output/client/_app/version.json 0.03 kB │ gzip: 0.05 kB -sveltekit-example build: .svelte-kit/output/client/.vite/manifest.json 2.99 kB │ gzip: 0.59 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/assets/2.CSgvuz3b.css 0.20 kB │ gzip: 0.16 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/entry/start.BvsoBo5T.js 0.08 kB │ gzip: 0.09 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/chunks/CtNDDFDu.js 0.32 kB │ gzip: 0.25 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/nodes/0.BzxYdpYi.js 0.33 kB │ gzip: 0.24 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/chunks/Dw67_Kem.js 0.53 kB │ gzip: 0.34 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/nodes/1.eDWMeucs.js 0.59 kB │ gzip: 0.37 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/chunks/Cj_Eri5U.js 1.31 kB │ gzip: 0.72 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/chunks/FZozcCav.js 2.23 kB │ gzip: 0.98 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/nodes/2.DcvXaOyQ.js 4.20 kB │ gzip: 1.99 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/entry/app.CH2MKbUD.js 7.82 kB │ gzip: 3.58 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/chunks/CvVN0Wrl.js 7.97 kB │ gzip: 3.41 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/chunks/C1AQKOho.js 24.87 kB │ gzip: 9.70 kB -sveltekit-example build: .svelte-kit/output/client/_app/immutable/chunks/CCkj0V-E.js 28.20 kB │ gzip: 10.90 kB -sveltekit-example build: ✓ built in 6.63s -sveltekit-example build: .svelte-kit/output/server/.vite/manifest.json 3.40 kB -sveltekit-example build: .svelte-kit/output/server/_app/immutable/assets/_page.CSgvuz3b.css 0.20 kB -sveltekit-example build: .svelte-kit/output/server/chunks/false.js 0.05 kB -sveltekit-example build: .svelte-kit/output/server/entries/fallbacks/layout.svelte.js 0.17 kB -sveltekit-example build: .svelte-kit/output/server/chunks/environment.js 0.19 kB -sveltekit-example build: .svelte-kit/output/server/internal.js 0.40 kB -sveltekit-example build: .svelte-kit/output/server/chunks/server.js 0.45 kB -sveltekit-example build: .svelte-kit/output/server/chunks/utils.js 1.15 kB -sveltekit-example build: .svelte-kit/output/server/entries/pages/_page.svelte.js 1.32 kB -sveltekit-example build: .svelte-kit/output/server/entries/fallbacks/error.svelte.js 1.42 kB -sveltekit-example build: .svelte-kit/output/server/chunks/url.js 3.16 kB -sveltekit-example build: .svelte-kit/output/server/chunks/internal.js 3.40 kB -sveltekit-example build: .svelte-kit/output/server/chunks/exports.js 3.83 kB -sveltekit-example build: .svelte-kit/output/server/chunks/attributes.js 5.98 kB -sveltekit-example build: .svelte-kit/output/server/remote-entry.js 19.07 kB -sveltekit-example build: .svelte-kit/output/server/chunks/shared.js 27.07 kB -sveltekit-example build: .svelte-kit/output/server/chunks/root.js 108.62 kB -sveltekit-example build: .svelte-kit/output/server/index.js 125.78 kB -sveltekit-example build: .svelte-kit/output/server/entries/hooks.server.js 134.43 kB -sveltekit-example build: ✓ built in 40.93s -sveltekit-example build: -sveltekit-example build: Run npm run preview to preview your production build locally. -sveltekit-example build: -sveltekit-example build: > Using @sveltejs/adapter-auto -sveltekit-example build: Could not detect a supported production environment. See https://svelte.dev/docs/kit/adapters to learn how to configure your app to run on the platform of your choosing -sveltekit-example build: ✔ done -sveltekit-example build: Exited with code 0 -@tablecraft/table build: Exited with code 0 -vite-web-example build: @tablecraft/table build: Exited with code 0 -web build: ✓ 1738 modules transformed. -web build: rendering chunks... -web build: computing gzip size... -web build: dist/index.html 1.66 kB │ gzip: 0.57 kB -web build: dist/assets/index-Duvx9g5X.css 19.44 kB │ gzip: 4.56 kB -web build: dist/assets/index-bEuEcFw-.js 764.13 kB │ gzip: 237.65 kB -web build: -web build: (!) Some chunks are larger than 500 kB after minification. Consider: -web build: - Using dynamic import() to code-split the application -web build: - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks -web build: - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. -web build: ✓ built in 28.98s -web build: Exited with code 0 -vite-web-example build: vite.config.ts(8,13): error TS2769: No overload matches this call. -vite-web-example build: The last overload gave the following error. -vite-web-example build: Type 'Plugin[]' is not assignable to type 'PluginOption'. -vite-web-example build: Type 'Plugin[]' is not assignable to type 'PluginOption[]'. -vite-web-example build: Type 'Plugin' is not assignable to type 'PluginOption'. -vite-web-example build: Type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/node_modules/vite/dist/node/index").Plugin' is not assignable to type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/apps/vite-web-example/node_modules/vite/dist/node/index").Plugin'. -vite-web-example build: Types of property 'hotUpdate' are incompatible. -vite-web-example build: Type 'ObjectHook<(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise> | undefined' is not assignable to type 'ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>> | undefined'. -vite-web-example build: Type '(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise' is not assignable to type 'ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>> | undefined'. -vite-web-example build: Type '(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise' is not assignable to type '(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>'. -vite-web-example build: Types of parameters 'options' and 'options' are incompatible. -vite-web-example build: Property 'environment' is missing in type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/apps/vite-web-example/node_modules/vite/dist/node/index").HotUpdateOptions' but required in type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/node_modules/vite/dist/node/index").HotUpdateOptions'. -vite-web-example build: Type 'Plugin[]' is not assignable to type 'PluginOption'. -vite-web-example build: Type 'Plugin[]' is not assignable to type 'PluginOption[]'. -vite-web-example build: Type 'Plugin' is not assignable to type 'PluginOption'. -vite-web-example build: Type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/node_modules/vite/dist/node/index").Plugin' is not assignable to type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/apps/vite-web-example/node_modules/vite/dist/node/index").Plugin'. -vite-web-example build: Types of property 'hotUpdate' are incompatible. -vite-web-example build: Type 'ObjectHook<(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise> | undefined' is not assignable to type 'ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>> | undefined'. -vite-web-example build: Type '(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise' is not assignable to type 'ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>> | undefined'. -vite-web-example build: Type '(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise' is not assignable to type '(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>'. -vite-web-example build: Types of parameters 'options' and 'options' are incompatible. -vite-web-example build: Property 'environment' is missing in type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/apps/vite-web-example/node_modules/vite/dist/node/index").HotUpdateOptions' but required in type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/node_modules/vite/dist/node/index").HotUpdateOptions'. -vite-web-example build: vite.config.ts(8,22): error TS2769: No overload matches this call. -vite-web-example build: The last overload gave the following error. -vite-web-example build: Type 'Plugin[]' is not assignable to type 'PluginOption'. -vite-web-example build: Type 'Plugin[]' is not assignable to type 'PluginOption[]'. -vite-web-example build: Type 'Plugin' is not assignable to type 'PluginOption'. -vite-web-example build: Type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/node_modules/vite/dist/node/index").Plugin' is not assignable to type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/apps/vite-web-example/node_modules/vite/dist/node/index").Plugin'. -vite-web-example build: Types of property 'hotUpdate' are incompatible. -vite-web-example build: Type 'ObjectHook<(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise> | undefined' is not assignable to type 'ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>> | undefined'. -vite-web-example build: Type '(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise' is not assignable to type 'ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>> | undefined'. -vite-web-example build: Type '(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise' is not assignable to type '(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>'. -vite-web-example build: Types of parameters 'options' and 'options' are incompatible. -vite-web-example build: Property 'environment' is missing in type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/apps/vite-web-example/node_modules/vite/dist/node/index").HotUpdateOptions' but required in type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/node_modules/vite/dist/node/index").HotUpdateOptions'. -vite-web-example build: Type 'Plugin[]' is not assignable to type 'PluginOption'. -vite-web-example build: Type 'Plugin[]' is not assignable to type 'PluginOption[]'. -vite-web-example build: Type 'Plugin' is not assignable to type 'PluginOption'. -vite-web-example build: Type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/node_modules/vite/dist/node/index").Plugin' is not assignable to type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/apps/vite-web-example/node_modules/vite/dist/node/index").Plugin'. -vite-web-example build: Types of property 'hotUpdate' are incompatible. -vite-web-example build: Type 'ObjectHook<(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise> | undefined' is not assignable to type 'ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>> | undefined'. -vite-web-example build: Type '(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise' is not assignable to type 'ObjectHook<(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>> | undefined'. -vite-web-example build: Type '(this: HotUpdatePluginContext, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise' is not assignable to type '(this: MinimalPluginContext & { environment: DevEnvironment; }, options: HotUpdateOptions) => void | EnvironmentModuleNode[] | Promise<...>'. -vite-web-example build: Types of parameters 'options' and 'options' are incompatible. -vite-web-example build: Property 'environment' is missing in type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/apps/vite-web-example/node_modules/vite/dist/node/index").HotUpdateOptions' but required in type 'import("/Users/mahy/Desktop/u/opensource/TableCraft/node_modules/vite/dist/node/index").HotUpdateOptions'. -vite-web-example build: Exited with code 2 diff --git a/bun.lock b/bun.lock index 93c31b5..94c44d7 100644 --- a/bun.lock +++ b/bun.lock @@ -175,8 +175,8 @@ "vitest": "^4.1.5", }, "peerDependencies": { - "@tablecraft/engine": ">=0.1.0", - "next": ">=14.0.0 <16 || >=16.2.3", + "@tablecraft/engine": ">=0.1.0 <1", + "next": ">=14.0.0 <16 || >=16.1.6", }, }, "packages/adapter-sveltekit": { @@ -250,8 +250,14 @@ "vitest": "^4.1.5", }, "peerDependencies": { - "@tablecraft/engine": ">=0.1.0", + "@tablecraft/engine": ">=0.1.0 <1", + "@upstash/redis": "*", + "ioredis": "*", }, + "optionalPeers": [ + "@upstash/redis", + "ioredis", + ], }, "packages/table": { "name": "@tablecraft/table", diff --git a/packages/table/src/auto/use-tree-adapter.ts b/packages/table/src/auto/use-tree-adapter.ts new file mode 100644 index 0000000..bab15e3 --- /dev/null +++ b/packages/table/src/auto/use-tree-adapter.ts @@ -0,0 +1,308 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { DataAdapter, QueryParams, QueryResult } from "../types"; + +const LOADING_PREFIX = "__tc_tree_loading__"; + +/** Build the synthetic id for a tree-loading placeholder row. */ +export function makeTreeLoadingId(parentId: string): string { + return `${LOADING_PREFIX}${parentId}`; +} + +/** + * True when a row is the synthetic loading placeholder injected by + * `useTreeAdapter` while a parent's children are being fetched. + * + * Use this in column renderers and `getRowCanExpand` to skip work on + * the placeholder row. + */ +export function isTreeLoadingRow( + row: T, + getRowId: (row: T) => string = (r) => + (r as unknown as { id: string }).id, +): boolean { + const id = getRowId(row); + return typeof id === "string" && id.startsWith(LOADING_PREFIX); +} + +export interface UseTreeAdapterListSource { + /** + * REST URL for top-level rows. Standard table params are appended as + * query string: `page`, `pageSize`, `search`, `sort`, `sortOrder`. + * Response must be a `QueryResult`. + */ + url?: string; + /** + * Custom fetch override. If set, `url` is ignored. Receives the same + * `AbortSignal` the table uses to cancel stale requests. + */ + fetch?: (params: QueryParams, signal: AbortSignal) => Promise>; +} + +export interface UseTreeAdapterChildrenSource { + /** + * REST URL builder for a node's children. Receives the parent row id and + * returns the absolute URL to fetch. Response must be a `T[]`. + */ + url?: (parentId: string) => string; + /** + * Custom fetch override. If set, `url` is ignored. Receives an + * `AbortSignal` that fires when the parent re-expands or unmounts. + */ + fetch?: (parentId: string, signal: AbortSignal) => Promise; +} + +export interface UseTreeAdapterOptions { + list: UseTreeAdapterListSource; + children: UseTreeAdapterChildrenSource; + /** Returns the row's id. Defaults to `row.id`. */ + getRowId?: (row: T) => string; + /** + * Factory for the placeholder row shown while children are loading. + * If omitted, the parent simply renders an empty children array — no + * loading row is injected. + * + * The factory is called with `(parentId, loadingId)` where `loadingId` + * is the synthetic id `useTreeAdapter` will look for. You should set + * `id: loadingId` on the returned row so `isTreeLoadingRow` recognises it. + */ + loadingRow?: (parentId: string, loadingId: string) => T; + /** + * Hard cap on tree depth merged into the rendered tree. Cycle guard. + * @default 50 + */ + maxDepth?: number; + /** + * Called when fetching children fails (non-abort errors only). The + * cache entry is cleared so the user can retry by re-expanding. + */ + onChildrenError?: (error: Error, parentId: string) => void; +} + +export interface UseTreeAdapterReturn { + adapter: DataAdapter; + /** + * Spread onto `` to wire up tree behaviour: + * `getSubRows` and `onRowExpand`. + */ + treeProps: { + getSubRows: (row: T) => T[] | undefined; + onRowExpand: (info: { + row: T; + rowId: string; + depth: number; + isExpanded: boolean; + }) => void; + }; + /** True when a row is the synthetic loading placeholder. */ + isLoadingRow: (row: T) => boolean; + /** + * Drop cached children for one parent (or all parents when called + * with no argument). Triggers a re-merge and a top-level refetch. + */ + invalidateChildren: (parentId?: string) => void; +} + +type RowWithChildren = T & { children?: T[] }; + +/** + * Lazy server-side tree adapter. + * + * Encapsulates the children-cache, expand-handler, loading-row + * placeholder, mergeChildren walk, and abort/dedupe logic that every + * lazy tree needs. Pair with ``. + * + * Children are merged into rows at render time via a `children` field — + * this is the only structural assumption. If your data uses a different + * field, map it before returning from `list.fetch`. + */ +export function useTreeAdapter>( + options: UseTreeAdapterOptions, +): UseTreeAdapterReturn { + const { + list, + children, + loadingRow, + maxDepth = 50, + onChildrenError, + } = options; + + const getRowId = useMemo( + () => + options.getRowId ?? ((row: T) => (row as unknown as { id: string }).id), + [options.getRowId], + ); + + // Children cache: parentId → children array (loaded), null (loading), or absent (untouched). + const cacheRef = useRef>({}); + // Per-parent abort controllers so rapid re-expands cancel in-flight fetches. + const abortRef = useRef>({}); + // Bumped whenever the cache mutates. Forces the memoised adapter to be + // recreated, which makes useTableData re-run its query effect and re-merge. + const [version, setVersion] = useState(0); + const bumpVersion = useCallback(() => setVersion((v) => v + 1), []); + + const isLoadingRow = useCallback( + (row: T) => isTreeLoadingRow(row, getRowId), + [getRowId], + ); + + // Recursive merge of cached children into the row tree, depth-bounded. + const mergeChildren = useCallback( + (node: T, depth: number): T => { + if (depth >= maxDepth) return node; + const id = getRowId(node); + const cache = cacheRef.current; + if (!(id in cache)) return node; + const cached = cache[id]; + if (cached === null) { + const placeholder = loadingRow + ? [loadingRow(id, makeTreeLoadingId(id))] + : []; + return { ...(node as RowWithChildren), children: placeholder } as T; + } + return { + ...(node as RowWithChildren), + children: cached.map((child) => mergeChildren(child, depth + 1)), + } as T; + }, + [getRowId, loadingRow, maxDepth], + ); + + const adapter = useMemo>( + () => ({ + async query(params) { + let result: QueryResult; + if (list.fetch) { + // Caller-owned abort: we pass a fresh controller so they can + // honour it. The outer useTableData also aborts via its own. + const ctrl = new AbortController(); + result = await list.fetch(params, ctrl.signal); + } else { + if (!list.url) { + throw new Error( + "useTreeAdapter: `list.url` or `list.fetch` is required", + ); + } + const qs = new URLSearchParams({ + page: String(params.page), + pageSize: String(params.pageSize), + }); + if (params.search) qs.set("search", params.search); + if (params.sort) qs.set("sort", params.sort); + if (params.sortOrder) qs.set("sortOrder", params.sortOrder); + const res = await fetch(`${list.url}?${qs.toString()}`); + if (!res.ok) { + throw new Error(`useTreeAdapter list: HTTP ${res.status}`); + } + result = (await res.json()) as QueryResult; + } + return { + ...result, + data: result.data.map((row) => mergeChildren(row, 0)), + }; + }, + async queryByIds() { + return []; + }, + }), + // mergeChildren closes over cacheRef.current at call time, so version + // is what we actually depend on for re-runs. + // eslint-disable-next-line react-hooks/exhaustive-deps + [version, list.url, list.fetch, mergeChildren], + ); + + const onRowExpand = useCallback< + UseTreeAdapterReturn["treeProps"]["onRowExpand"] + >( + ({ row, isExpanded }) => { + if (!isExpanded) return; + if (isLoadingRow(row)) return; + const id = getRowId(row); + // Already loaded or in flight — let the existing fetch resolve. + if (cacheRef.current[id] !== undefined) return; + + const ctrl = new AbortController(); + abortRef.current[id]?.abort(); + abortRef.current[id] = ctrl; + + cacheRef.current[id] = null; + bumpVersion(); + + const fetchPromise: Promise = children.fetch + ? children.fetch(id, ctrl.signal) + : (async () => { + if (!children.url) { + throw new Error( + "useTreeAdapter: `children.url` or `children.fetch` is required", + ); + } + const res = await fetch(children.url(id), { signal: ctrl.signal }); + if (!res.ok) { + throw new Error(`useTreeAdapter children: HTTP ${res.status}`); + } + return (await res.json()) as T[]; + })(); + + fetchPromise + .then((kids) => { + if (ctrl.signal.aborted) return; + cacheRef.current[id] = kids; + bumpVersion(); + }) + .catch((err: unknown) => { + if ( + (err as { name?: string } | null)?.name === "AbortError" || + ctrl.signal.aborted + ) { + return; + } + delete cacheRef.current[id]; + bumpVersion(); + onChildrenError?.( + err instanceof Error ? err : new Error(String(err)), + id, + ); + }) + .finally(() => { + if (abortRef.current[id] === ctrl) delete abortRef.current[id]; + }); + }, + [bumpVersion, children, getRowId, isLoadingRow, onChildrenError], + ); + + const getSubRows = useCallback( + (row: T) => (row as RowWithChildren).children, + [], + ); + + const invalidateChildren = useCallback( + (parentId?: string) => { + if (parentId === undefined) { + cacheRef.current = {}; + for (const ctrl of Object.values(abortRef.current)) ctrl.abort(); + abortRef.current = {}; + } else { + delete cacheRef.current[parentId]; + abortRef.current[parentId]?.abort(); + delete abortRef.current[parentId]; + } + bumpVersion(); + }, + [bumpVersion], + ); + + // Abort any pending children fetches when the consumer unmounts. + useEffect(() => { + const controllers = abortRef.current; + return () => { + for (const ctrl of Object.values(controllers)) ctrl.abort(); + }; + }, []); + + return { + adapter, + treeProps: { getSubRows, onRowExpand }, + isLoadingRow, + invalidateChildren, + }; +} diff --git a/packages/table/src/core/table-config.ts b/packages/table/src/core/table-config.ts index 132404c..d4c7b05 100644 --- a/packages/table/src/core/table-config.ts +++ b/packages/table/src/core/table-config.ts @@ -25,6 +25,8 @@ const defaultConfig: TableConfig = { defaultPageSize: 10, pageSizeOptions: [10, 20, 30, 40, 50], allowExportNewColumns: true, + enableRowGroupingControls: true, + keepPreviousData: false, }; /** @@ -37,4 +39,4 @@ export function useTableConfig( return { ...defaultConfig, ...overrideConfig }; } -export { defaultConfig }; \ No newline at end of file +export { defaultConfig }; diff --git a/packages/table/src/core/use-table-data.ts b/packages/table/src/core/use-table-data.ts index 71192c3..3b5f558 100644 --- a/packages/table/src/core/use-table-data.ts +++ b/packages/table/src/core/use-table-data.ts @@ -121,7 +121,11 @@ export function useTableData>( const fetchData = async () => { try { - setIsLoading(true); + // When keepPreviousData is on and we already have rows, skip the + // skeleton flash — render the prior data until the new query resolves. + if (!(config.keepPreviousData && result)) { + setIsLoading(true); + } const data = await adapter.query(queryParams); setResult(data); setIsError(false); diff --git a/packages/table/src/data-table.tsx b/packages/table/src/data-table.tsx index 6ebae8d..63c3588 100644 --- a/packages/table/src/data-table.tsx +++ b/packages/table/src/data-table.tsx @@ -7,6 +7,7 @@ import type { ColumnFiltersState, VisibilityState, ExpandedState, + GroupingState, } from "@tanstack/react-table"; import { flexRender, @@ -17,12 +18,13 @@ import { getFacetedRowModel, getFacetedUniqueValues, getExpandedRowModel, + getGroupedRowModel, useReactTable, } from "@tanstack/react-table"; import React, { Fragment, useEffect, useCallback, useMemo, useRef, useState } from "react"; import { AlertCircle } from "lucide-react"; import { Checkbox } from "./components/checkbox"; -import { ExpandIcon } from "./expand-icon"; +import { ExpandIcon, GroupRowChevron, GroupRowBadge } from "./expand-icon"; import { cn } from "./utils/cn"; import type { DataTableProps, ExportableData, TableContext, ExportConfig } from "./types"; @@ -51,6 +53,8 @@ export function DataTable>({ defaultColumnOrder, startToolbarContent, startToolbarPlacement, + endToolbarContent, + endToolbarPlacement, toolbarContent, renderToolbar, className, @@ -59,6 +63,12 @@ export function DataTable>({ actions, renderSubRow, getRowCanExpand, + getSubRows, + rowGrouping, + rowGroupingConfig, + onRowGroupExpand, + onRowExpand, + groupingRef, }: DataTableProps) { const tableConfig = useTableConfig(configOverrides); @@ -121,6 +131,12 @@ export function DataTable>({ allData: [], }); + // ─── Row grouping aggregation map ─── + const aggregationFns = useMemo(() => { + if (!rowGrouping?.length || !rowGroupingConfig?.aggregations) return {} as Record; + return rowGroupingConfig.aggregations as Record; + }, [rowGrouping, rowGroupingConfig]); + // ─── Apply column overrides & inject action column ─── const resolvedColumns = useMemo(() => { // Start with auto-generated or manual columns @@ -176,6 +192,18 @@ export function DataTable>({ cols = [expandColumn, ...cols]; } + // Inject aggregation functions for row grouping + const aggKeys = Object.keys(aggregationFns); + if (aggKeys.length > 0) { + cols = cols.map((col) => { + const accessorKeyForAgg = (col as { accessorKey?: string }).accessorKey; + if (accessorKeyForAgg && aggregationFns[accessorKeyForAgg]) { + col = { ...col, aggregationFn: aggregationFns[accessorKeyForAgg] } as typeof col; + } + return col; + }); + } + // Apply columnOverrides: replace cell renderer for matching columns if (columnOverrides) { cols = cols.map((col) => { @@ -217,7 +245,7 @@ export function DataTable>({ } return cols; - }, [autoColumns, tableConfig.enableRowSelection, columnOverrides, actions, renderSubRow]); + }, [autoColumns, tableConfig.enableRowSelection, columnOverrides, actions, renderSubRow, aggregationFns]); // ─── Column resize ─── const { columnSizing, setColumnSizing, resetColumnSizing } = @@ -227,12 +255,111 @@ export function DataTable>({ const [rowSelection, setRowSelection] = useState>({}); // ─── Expanded state ─── - const [expanded, setExpanded] = useState(() => - tableConfig.defaultExpanded === false - ? {} - : (tableConfig.defaultExpanded ?? {}) + const [expanded, setExpanded] = useState(() => { + // Only honour config.defaultExpanded when the consumer explicitly provided it. + // defaultConfig.defaultExpanded is `false`, so checking the merged + // tableConfig value would always shadow the rowGroupingConfig path below. + if (configOverrides?.defaultExpanded !== undefined) { + if (configOverrides.defaultExpanded === false) return {}; + return configOverrides.defaultExpanded as ExpandedState; + } + // Row grouping default + if (rowGroupingConfig?.defaultExpanded) return true; + return {}; + }); + + // ─── Row grouping state ─── + const [grouping, setGrouping] = useState( + () => rowGrouping ?? [] ); + // Sync grouping state if rowGrouping prop changes + useEffect(() => { + setGrouping(rowGrouping ?? []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(rowGrouping)]); + + // ─── Dev warning: manualPagination + rowGrouping conflict ─── + // The table internally sets manualPagination:true, so the adapter returns + // one page of rows at a time. Client-side grouping (getGroupedRowModel) + // then groups ONLY those rows, so any group whose members span multiple + // pages will appear as separate partial groups with wrong leaf counts on + // each page. + useEffect(() => { + const NODE_ENV = (globalThis as { process?: { env?: { NODE_ENV?: string } } }) + .process?.env?.NODE_ENV; + if ( + NODE_ENV !== "production" && + rowGrouping?.length && + tableConfig.enablePagination + ) { + console.warn( + "[TableCraft] Row grouping + pagination: groups may be split across pages. " + + "Consider setting config={{ enablePagination: false }} or using a large pageSize when rowGrouping is active." + ); + } + }, [rowGrouping, tableConfig.enablePagination]); + + // ─── Fire onRowGroupExpand / onRowExpand callbacks ─── + const prevExpandedRef = useRef({}); + useEffect(() => { + const wantsGroup = !!onRowGroupExpand && !!rowGrouping?.length; + const wantsTree = !!onRowExpand; + if ((!wantsGroup && !wantsTree) || !tableRef.current) { + prevExpandedRef.current = expanded; + return; + } + const prev = prevExpandedRef.current; + const curr = expanded; + + // `expanded` (ExpandedState) can be the boolean `true` when all rows are + // expanded via table.toggleAllRowsExpanded(true). Object.keys(true) + // returns [] in modern JS — silently swallowing every expand event and + // leaving prevExpandedRef permanently stuck at `true`, which then + // corrupts the diff on the next individual-row toggle. Skip per-row + // diffing for bulk toggle operations entirely. + if (typeof curr !== "object" || typeof prev !== "object") { + prevExpandedRef.current = expanded; + return; + } + + const prevRecord = prev as Record; + const currRecord = curr as Record; + + const fire = (rowId: string, isExpanded: boolean) => { + try { + const row = tableRef.current!.getRow(rowId); + if (!row) return; + if (row.getIsGrouped()) { + if (wantsGroup) { + onRowGroupExpand!({ + columnId: row.groupingColumnId ?? "", + value: row.groupingValue, + isExpanded, + depth: row.depth, + }); + } + } else if (wantsTree) { + onRowExpand!({ + row: row.original, + rowId: row.id, + depth: row.depth, + isExpanded, + }); + } + } catch { /* row may not exist */ } + }; + + for (const k of Object.keys(currRecord)) { + if (currRecord[k] && !prevRecord[k]) fire(k, true); + } + for (const k of Object.keys(prevRecord)) { + if (prevRecord[k] && !currRecord[k]) fire(k, false); + } + prevExpandedRef.current = curr; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expanded]); + // ─── Column order ─── const [columnOrder, setColumnOrder] = useState([]); const columnOrderRef = useRef(columnOrder); @@ -306,36 +433,54 @@ export function DataTable>({ setRowSelection({}); }, []); + const handleSetSearch = useCallback( + (v: string | ((prev: string) => string)) => { + setRowSelection({}); + setSearch(v); + }, + [setSearch] + ); + + // When getSubRows is provided, flatten the tree so export and selection + // can reach every node, not just the top-level rows the adapter returned. + const flattenTree = useCallback((rows: T[]): T[] => { + if (!getSubRows) return rows; + const result: T[] = []; + const walk = (nodes: T[]) => { + for (const node of nodes) { + result.push(node); + const children = getSubRows(node); + if (children?.length) walk(children as T[]); + } + }; + walk(rows); + return result; + }, [getSubRows]); + const getSelectedItems = useCallback(async () => { if (totalSelectedItems === 0) return []; - const selectedIds = Object.keys(rowSelection); - const itemsOnPage = data.filter( - (item) => rowSelection[String(item[idField])] - ); + const selectedIds = new Set(Object.keys(rowSelection)); + const allRows = flattenTree(data); + const itemsOnPage = allRows.filter((item) => selectedIds.has(String(item[idField]))); const idsOnPage = new Set(itemsOnPage.map((item) => String(item[idField]))); - const idsToFetch = selectedIds.filter((id) => !idsOnPage.has(id)); + const idsToFetch = [...selectedIds].filter((id) => !idsOnPage.has(id)); - // If everything selected is already exactly on the current page, - // their order is perfect (they are sorted exactly as they appear). - if (idsToFetch.length === 0 || !adapter.queryByIds) { + // Tree mode: all items are already in the flattened data — no server call needed. + if (getSubRows || idsToFetch.length === 0 || !adapter.queryByIds) { return itemsOnPage; } try { - // Cross-page export: To guarantee that rows are exported in the exact same sorted - // order as the table, we tell the backend to fetch ALL selected IDs (not just missing ones) - // and sort them according to the current table state. - // This perfectly avoids the problem of page 2 items appearing before page 1 items. - const fetchedAllSorted = await adapter.queryByIds(selectedIds, { sortBy, sortOrder }); + // Cross-page export: fetch ALL selected IDs sorted via the backend. + const fetchedAllSorted = await adapter.queryByIds([...selectedIds], { sortBy, sortOrder }); return fetchedAllSorted; } catch { - // Fallback: mostly useless if cross-page, but prevents crashing return itemsOnPage; } - }, [data, rowSelection, totalSelectedItems, adapter, idField, sortBy, sortOrder]); + }, [data, rowSelection, totalSelectedItems, adapter, idField, sortBy, sortOrder, flattenTree, getSubRows]); - const getAllItems = useCallback((): T[] => data, [data]); + const getAllItems = useCallback((): T[] => flattenTree(data), [data, flattenTree]); // ─── Pagination state for TanStack ─── const pagination = useMemo( @@ -376,11 +521,13 @@ export function DataTable>({ if (newPagination.pageSize !== pageSize) { setPageSize(newPagination.pageSize); setPage(1); + setRowSelection({}); return; } if (newPagination.pageIndex + 1 !== page) { setPage(newPagination.pageIndex + 1); + setRowSelection({}); } }, [page, pageSize, setPage, setPageSize] @@ -477,15 +624,20 @@ export function DataTable>({ e.preventDefault(); const focusedElement = document.activeElement; - if ( - focusedElement && - (focusedElement.getAttribute("role") === "row" || - focusedElement.getAttribute("role") === "gridcell") - ) { - const rowElement = + if (focusedElement) { + // has *implicit* role="row" — getAttribute returns null for it, + // so we match by tagName as well as an explicit role attribute. + let rowElement: Element | null = null; + if ( + focusedElement.tagName === "TR" || focusedElement.getAttribute("role") === "row" - ? focusedElement - : focusedElement.closest('[role="row"]'); + ) { + rowElement = focusedElement; + } else if (focusedElement.getAttribute("role") === "gridcell") { + rowElement = + focusedElement.closest("tr") ?? + focusedElement.closest('[role="row"]'); + } if (rowElement) { const rowId = @@ -494,7 +646,10 @@ export function DataTable>({ const rowIndex = Number.parseInt(rowId.replace(/^row-/, ""), 10); const row = table.getRowModel().rows[rowIndex]; if (row) { - if (e.key === " ") { + if (row.getIsGrouped()) { + // Group rows toggle expansion; they are never selectable. + row.toggleExpanded(); + } else if (e.key === " ") { row.toggleSelected(); } else if (e.key === "Enter" && onRowClick) { onRowClick(row.original as T, rowIndex); @@ -522,6 +677,7 @@ export function DataTable>({ columnSizing, columnOrder, expanded, + grouping, }, meta: { isLoadingColumns: isLoadingMeta, @@ -536,19 +692,33 @@ export function DataTable>({ manualPagination: true, manualSorting: true, manualFiltering: true, + groupedColumnMode: false as const, + getSubRows: getSubRows ? (row: T) => getSubRows(row) : undefined, onRowSelectionChange: setRowSelection, onSortingChange: handleSortingChange, onColumnVisibilityChange: setUrlColumnVisibility as (updater: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => void, onPaginationChange: handlePaginationChange, + // Row model pipeline — must follow TanStack v8 order: + // Core → Filtered → Grouped → Sorted → Expanded → Pagination getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), + getGroupedRowModel: rowGrouping?.length ? getGroupedRowModel() : undefined, getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), onExpandedChange: setExpanded, getExpandedRowModel: getExpandedRowModel(), - getRowCanExpand: (row: Row) => getRowCanExpand ? getRowCanExpand(row.original) : !!renderSubRow, + getPaginationRowModel: getPaginationRowModel(), + onGroupingChange: setGrouping, + getRowCanExpand: (row: Row) => { + // Group rows created by getGroupedRowModel MUST always be expandable, + // regardless of the host's getRowCanExpand prop or the presence of + // renderSubRow. Without this guard, getCanExpand() returns false for + // every group row which breaks getToggleExpandedHandler() and any + // other caller that respects canExpand (keyboard nav, expand-all, etc). + if (row.getIsGrouped()) return true; + return getRowCanExpand ? getRowCanExpand(row.original) : !!renderSubRow; + }, }), [ data, @@ -572,6 +742,9 @@ export function DataTable>({ isLoadingMeta, getRowCanExpand, renderSubRow, + getSubRows, + grouping, + setGrouping, ] ); @@ -608,8 +781,8 @@ export function DataTable>({ // ─── Render toolbar content ─── const selectedRows = useMemo( - () => data.filter((item) => rowSelection[String(item[idField])]), - [data, rowSelection, idField] + () => flattenTree(data).filter((item) => rowSelection[String(item[idField])]), + [data, rowSelection, idField, flattenTree] ); const selectedIds = useMemo( () => Object.keys(rowSelection), @@ -622,11 +795,153 @@ export function DataTable>({ totalSelected: totalSelectedItems, clearSelection, search, - setSearch, + setSearch: handleSetSearch, dateRange, setDateRange, }; + // ─── Row grouping depth helpers ─── + const collectGroupRowsAtDepth = useCallback( + (rows: Row[], targetDepth: number, acc: Row[] = []): Row[] => { + for (const row of rows) { + if (!row.getIsGrouped()) continue; + if (row.depth === targetDepth) { + acc.push(row); + } else if (row.depth < targetDepth && row.subRows?.length) { + collectGroupRowsAtDepth(row.subRows, targetDepth, acc); + } + } + return acc; + }, + [] // no deps — pure function over its arguments + ); + + const expandDepth = useCallback( + (depth: number) => { + if (!rowGrouping?.length) return; + const groupedRows = table.getGroupedRowModel().rows; + const targets = collectGroupRowsAtDepth(groupedRows, depth); + if (!targets.length) return; + setExpanded((prev) => { + // prev === true means "all rows expanded" already includes our targets. + // Returning a fresh object would collapse every other depth — preserve + // the boolean state instead. + if (prev === true) return prev; + const base = + typeof prev === "boolean" ? {} : { ...(prev as Record) }; + for (const row of targets) base[row.id] = true; + return base; + }); + }, + [rowGrouping, table, collectGroupRowsAtDepth] + ); + + const collapseDepth = useCallback( + (depth: number) => { + if (!rowGrouping?.length) return; + const groupedRows = table.getGroupedRowModel().rows; + const targets = collectGroupRowsAtDepth(groupedRows, depth); + if (!targets.length) return; + const targetIds = new Set(targets.map((r) => r.id)); + setExpanded((prev) => { + if (typeof prev === "boolean") { + const allGroupIds = new Set(); + const collectAll = (rows: Row[]) => { + for (const r of rows) { + if (r.getIsGrouped()) { + allGroupIds.add(r.id); + if (r.subRows?.length) collectAll(r.subRows); + } + } + }; + collectAll(table.getGroupedRowModel().rows); + const map: Record = {}; + for (const id of allGroupIds) map[id] = !targetIds.has(id); + return map; + } + const next = { ...(prev as Record) }; + for (const id of targetIds) delete next[id]; + return next; + }); + }, + [rowGrouping, table, collectGroupRowsAtDepth] + ); + + const toggleDepth = useCallback( + (depth: number) => { + if (!rowGrouping?.length) return; + const groupedRows = table.getGroupedRowModel().rows; + const targets = collectGroupRowsAtDepth(groupedRows, depth); + if (!targets.length) return; + const expandedState = + typeof expanded === "boolean" + ? targets.map(() => expanded) + : targets.map((r) => !!(expanded as Record)[r.id]); + const allExpanded = expandedState.every(Boolean); + if (allExpanded) { + collapseDepth(depth); + } else { + expandDepth(depth); + } + }, + [rowGrouping, table, collectGroupRowsAtDepth, expanded, expandDepth, collapseDepth] + ); + + const setExpandedDepths = useCallback( + (depths: Set) => { + if (!rowGrouping?.length) return; + const newExpanded: Record = {}; + const visit = (rows: Row[]) => { + for (const row of rows) { + if (!row.getIsGrouped()) continue; + newExpanded[row.id] = depths.has(row.depth); + if (row.subRows?.length) visit(row.subRows); + } + }; + visit(table.getGroupedRowModel().rows); + setExpanded(newExpanded); + }, + [rowGrouping, table] + ); + + const getExpandedDepths = useCallback((): Set => { + if (!rowGrouping?.length) return new Set(); + const depths = new Set(); + if (typeof expanded === "boolean") { + if (expanded) { + const visit = (rows: Row[]) => { + for (const row of rows) { + if (row.getIsGrouped()) { + depths.add(row.depth); + if (row.subRows?.length) visit(row.subRows); + } + } + }; + visit(table.getGroupedRowModel().rows); + } + return depths; + } + const expandedMap = expanded as Record; + for (const [id, isOpen] of Object.entries(expandedMap)) { + if (!isOpen) continue; + try { + const row = table.getRow(id); + if (row?.getIsGrouped()) depths.add(row.depth); + } catch { /* row may not exist in current model */ } + } + return depths; + }, [rowGrouping, expanded, table]); + + const getGroupingProperty = useCallback( + (depth: number): string | undefined => rowGrouping?.[depth], + [rowGrouping] + ); + + const getGroupingDepth = useCallback( + (property: string): number => rowGrouping?.indexOf(property) ?? -1, + [rowGrouping] + ); + // ─── Keep tableContextRef in sync every render ─── // This runs synchronously before render, so column cells always see fresh values. tableContextRef.current = { @@ -636,8 +951,50 @@ export function DataTable>({ search, dateRange, allData: data, + expandAllGroups: () => table.toggleAllRowsExpanded(true), + collapseAllGroups: () => table.toggleAllRowsExpanded(false), + expandDepth, + collapseDepth, + toggleDepth, + getGroupingProperty, + getGroupingDepth, + isRowGroupingActive: !!(grouping.length), }; + // ─── Expose grouping API via groupingRef (imperative handle) ─── + // Re-publish the handle whenever any of the captured helpers change identity + // (each helper is a useCallback that updates when its own deps change, so + // this effect runs only when the closure's behaviour actually changes — + // not on every render, which previously nulled the ref between renders). + useEffect(() => { + if (!groupingRef) return; + const handle: import("./types").TableGroupingAPI = { + expandAll: () => table.toggleAllRowsExpanded(true), + collapseAll: () => table.toggleAllRowsExpanded(false), + expandDepth, + collapseDepth, + toggleDepth, + setExpandedDepths, + getExpandedDepths, + getGroupingProperty, + getGroupingDepth, + }; + (groupingRef as React.MutableRefObject).current = handle; + // No cleanup: the parent owns the ref's lifecycle. Nulling on every + // dep change opened a brief null window that broke consumers reading + // `ref.current` synchronously across renders. + }, [ + groupingRef, + table, + expandDepth, + collapseDepth, + toggleDepth, + setExpandedDepths, + getExpandedDepths, + getGroupingProperty, + getGroupingDepth, + ]); + const customToolbar = renderToolbar ? renderToolbar(toolbarContext) : toolbarContent; @@ -647,6 +1004,11 @@ export function DataTable>({ ? startToolbarContent(toolbarContext) : startToolbarContent; + const resolvedEndToolbarContent = + typeof endToolbarContent === "function" + ? endToolbarContent(toolbarContext) + : endToolbarContent; + // ─── Error state ─── if (isError) { return ( @@ -697,7 +1059,7 @@ export function DataTable>({ table={table as unknown as ReturnType>} search={search} dateRange={{ from: dateRange.from, to: dateRange.to }} - setSearch={setSearch} + setSearch={handleSetSearch} setDateRange={setDateRange} totalSelectedItems={totalSelectedItems} clearSelection={clearSelection} @@ -713,7 +1075,12 @@ export function DataTable>({ customToolbarContent={customToolbar} startToolbarContent={resolvedStartToolbarContent} startToolbarPlacement={startToolbarPlacement} + endToolbarContent={resolvedEndToolbarContent} + endToolbarPlacement={endToolbarPlacement} hiddenColumns={hiddenColumns as string[]} + onExpandAllGroups={tableContextRef.current.expandAllGroups} + onCollapseAllGroups={tableContextRef.current.collapseAllGroups} + isRowGroupingActive={tableContextRef.current.isRowGroupingActive} /> )} @@ -805,62 +1172,235 @@ export function DataTable>({ )) ) : table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row, rowIndex) => ( - - { - if (tableConfig.enableClickRowSelect) { - row.toggleSelected(); - } - if (onRowClick) { - handleRowClick(event, row.original, rowIndex); - } - }} - style={{ - cursor: onRowClick ? "pointer" : undefined, - }} - > - {row.getVisibleCells().map((cell) => ( - { + const isGroupRow = row.getIsGrouped(); + const visibleCells = row.getVisibleCells(); + + // Group label always renders in the first non-system column so the + // chevron + value appear at column 0 regardless of grouping key. + const firstDataCellId = isGroupRow + ? (visibleCells.find( + c => c.column.id !== "select" && c.column.id !== "__expand" && c.column.id !== "__actions" + )?.id ?? null) + : null; + + // First data cell of leaf rows — only this cell gets depth indentation. + const firstLeafDataCellId = !isGroupRow + ? (visibleCells.find( + c => c.column.id !== "select" && c.column.id !== "__expand" && c.column.id !== "__actions" + )?.id ?? null) + : null; + + return ( + + - {flexRender( - cell.column.columnDef.cell, - cell.getContext() + "border-b transition-colors", + isGroupRow || row.getCanExpand() + ? "hover:bg-muted/60 cursor-pointer" + : "hover:bg-muted/50 data-[state=selected]:bg-muted", + onRowClick && !isGroupRow && !row.getCanExpand() ? "cursor-pointer" : undefined )} - - ))} - {renderResizePlaceholderCell("td")} - - {row.getIsExpanded() && renderSubRow && ( - - { + if (isGroupRow || row.getCanExpand()) { + row.toggleExpanded(); + return; + } + if (tableConfig.enableClickRowSelect) { + row.toggleSelected(); + } + if (onRowClick) { + handleRowClick(event, row.original, rowIndex); + } + }} + style={{ + cursor: isGroupRow || row.getCanExpand() ? "pointer" : onRowClick ? "pointer" : undefined, + }} > - {renderSubRow({ row: row.original, table: tableContextRef.current })} - - - )} - - )) + {visibleCells.map((cell) => { + const isSystemCol = + cell.column.id === "select" || + cell.column.id === "__expand" || + cell.column.id === "__actions"; + + const isFirstDataCell = + cell.id === firstDataCellId || cell.id === firstLeafDataCellId; + + // \u2500\u2500 Group rows \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + if (isGroupRow) { + // Placeholder cells that are not the target first-data cell \u2192 empty + if (cell.getIsPlaceholder() && !isFirstDataCell) { + return ( + + ); + } + + // First data cell \u2192 render the group label here, always + if (isFirstDataCell) { + const leafCount = row.getLeafRows().length; + const groupValue = row.groupingValue; + const defaultGroupContent = ( + <> + + {String(groupValue ?? "\u2014")} + + + + ); + const customGroupContent = rowGroupingConfig?.renderGroupCell + ? rowGroupingConfig.renderGroupCell({ + columnId: row.groupingColumnId ?? "", + value: groupValue, + leafRowCount: leafCount, + }) + : null; + + return ( + + + + {customGroupContent != null ? customGroupContent : defaultGroupContent} + + + ); + } + + // Aggregated value cells (e.g. salary sum/mean) + if (cell.getIsAggregated()) { + return ( + + {flexRender( + cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell, + cell.getContext() + )} + + ); + } + + // System columns (select checkbox etc.) and remaining cells \u2192 render normally + return ( + e.stopPropagation() : undefined} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + } + + // \u2500\u2500 Leaf rows \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + // Placeholder cells (grouped columns become empty in leaf rows) + if (cell.getIsPlaceholder()) { + return ( + + ); + } + + // Tree-expandable rows: first data cell gets chevron + depth indent + if (isFirstDataCell && row.getCanExpand()) { + return ( + + + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + ); + } + + return ( + 0 + ? `${16 + row.depth * 20}px` + : undefined, + }} + onClick={isSystemCol ? (e) => e.stopPropagation() : undefined} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + {renderResizePlaceholderCell("td")} + + + {/* Master-Detail sub-row (existing feature) — only for non-group leaf rows */} + {!isGroupRow && row.getIsExpanded() && renderSubRow && ( + + + {renderSubRow({ row: row.original, table: tableContextRef.current })} + + + )} + + ); + }) ) : ( >({ )}
); -} \ No newline at end of file +} diff --git a/packages/table/src/expand-icon.tsx b/packages/table/src/expand-icon.tsx index d93626f..6ad52a2 100644 --- a/packages/table/src/expand-icon.tsx +++ b/packages/table/src/expand-icon.tsx @@ -43,4 +43,42 @@ export function ExpandIcon({ /> ); -} \ No newline at end of file +} + +// ───────────────────────────────────────────── +// Row Grouping Components +// ───────────────────────────────────────────── + +interface GroupRowChevronProps { + isExpanded: boolean; + className?: string; +} + +/** + * Animated chevron for row-grouping group-header rows. + * Points right when collapsed, rotates 90° when expanded. + * Uses CSS class tc-group-chevron + data-expanded for the animation. + */ +export function GroupRowChevron({ isExpanded, className }: GroupRowChevronProps) { + return ( + + + + ); +} + +interface GroupRowBadgeProps { + count: number; + className?: string; +} + +/** + * Pill badge showing the number of rows in a group. + * Uses tc-group-badge CSS class. + */ +export function GroupRowBadge({ count, className }: GroupRowBadgeProps) { + return {count}; +} diff --git a/packages/table/src/index.ts b/packages/table/src/index.ts index 8be4aee..61599b9 100644 --- a/packages/table/src/index.ts +++ b/packages/table/src/index.ts @@ -8,7 +8,7 @@ export { DataTableToolbar } from "./toolbar"; export { DataTableViewOptions } from "./view-options"; export { DataTableExport } from "./export"; export { DataTableResizer } from "./resizer"; -export { ExpandIcon } from "./expand-icon"; +export { ExpandIcon, GroupRowChevron, GroupRowBadge } from "./expand-icon"; // ─── Adapters ─── export { createTableCraftAdapter } from "./auto/tablecraft-adapter"; @@ -17,6 +17,17 @@ export { createRestAdapter } from "./auto/rest-adapter"; export type { RestAdapterOptions } from "./auto/rest-adapter"; export { createStaticAdapter } from "./auto/static-adapter"; export type { StaticAdapterOptions } from "./auto/static-adapter"; +export { + useTreeAdapter, + isTreeLoadingRow, + makeTreeLoadingId, +} from "./auto/use-tree-adapter"; +export type { + UseTreeAdapterOptions, + UseTreeAdapterReturn, + UseTreeAdapterListSource, + UseTreeAdapterChildrenSource, +} from "./auto/use-tree-adapter"; // ─── Auto-generation ─── export { generateColumns } from "./auto/auto-columns"; @@ -84,8 +95,13 @@ export type { ToolbarContext, TableContext, StartToolbarPlacement, + EndToolbarPlacement, ColumnOverrides, ActionsRender, + RowGroupingAggregation, + RowGroupingConfig, + OnRowGroupExpandInfo, + TableGroupingAPI, } from "./types"; -export { defineColumnOverrides, defineExportConfig } from "./types"; \ No newline at end of file +export { defineColumnOverrides, defineExportConfig } from "./types"; diff --git a/packages/table/src/styles.css b/packages/table/src/styles.css index 3ec9b04..37c4d29 100644 --- a/packages/table/src/styles.css +++ b/packages/table/src/styles.css @@ -210,3 +210,54 @@ input[type="week"] { color: var(--primary) !important; } } + +/* ──────────────────────────────────────────────────────────────────────────── + 4. Row Grouping + ──────────────────────────────────────────────────────────────────────────── */ + +/* Group-header row base */ +[data-group-row="true"] { + background-color: hsl(var(--muted) / 0.4); + font-weight: 500; +} +[data-group-row="true"]:hover { + background-color: hsl(var(--muted) / 0.6); +} + +/* Depth-based left-border accent */ +[data-group-row="true"][data-depth="0"] > td:first-child { + border-left: 3px solid hsl(var(--primary) / 0.6); +} +[data-group-row="true"][data-depth="1"] > td:first-child { + border-left: 3px solid hsl(var(--primary) / 0.35); +} +[data-group-row="true"][data-depth="2"] > td:first-child { + border-left: 3px solid hsl(var(--primary) / 0.2); +} + +/* Row count badge */ +.tc-group-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + line-height: 1; + padding: 2px 7px; + border-radius: 9999px; + background-color: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + font-weight: 400; + min-width: 1.5rem; +} + +/* Chevron transition */ +.tc-group-chevron { + display: inline-flex; + transition: transform 150ms ease; +} +.tc-group-chevron[data-expanded="true"] { + transform: rotate(90deg); +} +.tc-group-chevron[data-expanded="false"] { + transform: rotate(0deg); +} diff --git a/packages/table/src/toolbar.tsx b/packages/table/src/toolbar.tsx index e3369d8..4f66197 100644 --- a/packages/table/src/toolbar.tsx +++ b/packages/table/src/toolbar.tsx @@ -1,7 +1,7 @@ import type { Table } from "@tanstack/react-table"; import { useEffect, useState, useRef } from "react"; -import { X, Settings, Undo2, CheckSquare, MoveHorizontal, EyeOff, Search } from "lucide-react"; -import type { TableConfig, ExportConfig, ExportableData, StartToolbarPlacement } from "./types"; +import { X, Settings, Undo2, CheckSquare, MoveHorizontal, EyeOff, Search, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; +import type { TableConfig, ExportConfig, ExportableData, StartToolbarPlacement, EndToolbarPlacement } from "./types"; import { DataTableViewOptions } from "./view-options"; import { DataTableExport } from "./export"; import { @@ -60,13 +60,23 @@ interface DataTableToolbarProps { /** * Controls where `startToolbarContent` is rendered in the left toolbar area. * - `'before-search'` — before the search input - * - `'after-search'` — after search, before the date filter. + * - `'after-search'` — after search, before the date filter. * NOTE: If `enableSearch` is false, this renders in the same visual position as `'before-search'`. * - `'after-date'` — after the date filter (default) * @default 'after-date' */ startToolbarPlacement?: StartToolbarPlacement; + /** Right-side injectable content (resolved ReactNode — function form is resolved by parent) */ + endToolbarContent?: React.ReactNode; + /** Anchor for `endToolbarContent` */ + endToolbarPlacement?: EndToolbarPlacement; hiddenColumns?: string[]; + /** Expand all row groups */ + onExpandAllGroups?: () => void; + /** Collapse all row groups */ + onCollapseAllGroups?: () => void; + /** Whether row grouping is currently active */ + isRowGroupingActive?: boolean; } export function DataTableToolbar({ @@ -87,7 +97,12 @@ export function DataTableToolbar({ customToolbarContent, startToolbarContent, startToolbarPlacement = 'after-date', + endToolbarContent, + endToolbarPlacement = 'after-view', hiddenColumns, + onExpandAllGroups, + onCollapseAllGroups, + isRowGroupingActive, }: DataTableToolbarProps) { const entityName = exportConfig?.entityName || "items"; @@ -232,6 +247,48 @@ export function DataTableToolbar({
{customToolbarContent} + {endToolbarPlacement === 'before-grouping' && endToolbarContent} + + {isRowGroupingActive && (config.enableRowGroupingControls ?? true) && ( + <> + + + + )} + + {(endToolbarPlacement === 'after-grouping' || + endToolbarPlacement === 'before-export') && endToolbarContent} + {config.enableExport && ( ({ /> )} + {(endToolbarPlacement === 'after-export' || + endToolbarPlacement === 'before-view') && endToolbarContent} + {config.enableColumnVisibility && ( ({ /> )} + {(endToolbarPlacement === 'after-view' || + endToolbarPlacement === 'before-settings') && endToolbarContent} +
+ + {endToolbarPlacement === 'after-settings' && endToolbarContent}
); diff --git a/packages/table/src/types.ts b/packages/table/src/types.ts index 1a42f6b..f684133 100644 --- a/packages/table/src/types.ts +++ b/packages/table/src/types.ts @@ -41,6 +41,135 @@ export function defaultColumnOrder(columns: C[]): C[] { return columns; } +// ───────────────────────────────────────────── +// Row Grouping Types +// ───────────────────────────────────────────── + +export type RowGroupingAggregation = + | "sum" + | "min" + | "max" + | "mean" + | "median" + | "unique" + | "uniqueCount" + | "count" + | "extent"; + +/** + * Configuration for row grouping behaviour. + * + * **Performance note**: this object is used as a `useMemo` dependency inside + * `DataTable`. If you pass it as an inline object literal the reference + * changes on every parent render, which busts the `aggregationFns` memo and, + * transitively, the `resolvedColumns` memo — causing all columns to + * re-resolve on every render. Stabilise the value with `useMemo` or define + * it outside the component: + * + * ```ts + * // Outside component or in useMemo: + * const groupingConfig = useMemo( + * () => ({ defaultExpanded: true, aggregations: { salary: 'sum' } }), + * [] + * ); + * + * ``` + */ +export interface RowGroupingConfig> { + /** + * Start all groups expanded when the table mounts. + * @default false + */ + defaultExpanded?: boolean; + /** + * Per-column aggregate functions to display in the group-header row. + * @example { salary: "sum", headcount: "count" } + */ + aggregations?: Partial>; + /** + * Optional custom renderer for the group-header cell content. + * Return null or undefined to fall back to the default "ColumnLabel: value (n rows)" format. + */ + renderGroupCell?: (props: { + columnId: string; + value: unknown; + leafRowCount: number; + }) => ReactNode; +} + +export interface OnRowGroupExpandInfo { + /** The column ID that was grouped */ + columnId: string; + /** The grouped value (e.g. "Engineering") */ + value: unknown; + /** Whether the group was just expanded (true) or collapsed (false) */ + isExpanded: boolean; + /** Zero-based depth level in a multi-level grouping */ + depth: number; +} + +// ───────────────────────────────────────────── +// Grouping Imperative API (exposed via groupingRef prop) +// ───────────────────────────────────────────── + +/** + * Imperative handle exposed when you pass a `groupingRef` to ``. + * Mirrors the programmatic control API from simple-table v2.1.0. + * + * @example + * const ref = useRef(null); + * + * + * // In an event handler: + * ref.current?.expandAll(); + * ref.current?.expandDepth(0); // expand top-level groups only + * ref.current?.collapseDepth(1); // collapse second-level groups + */ +export interface TableGroupingAPI { + /** Expand all groups at all depths */ + expandAll(): void; + /** Collapse all groups at all depths */ + collapseAll(): void; + /** + * Expand all group rows at a specific depth (0-indexed). + * depth 0 = top-level groups, depth 1 = second-level groups, etc. + */ + expandDepth(depth: number): void; + /** + * Collapse all group rows at a specific depth (0-indexed). + */ + collapseDepth(depth: number): void; + /** + * Toggle expansion for all group rows at a specific depth. + * If ALL groups at that depth are expanded, collapses them. + * Otherwise, expands them all. + */ + toggleDepth(depth: number): void; + /** + * Explicitly set which depths are expanded. + * Replaces the current expansion state for group rows. + * @example ref.current?.setExpandedDepths(new Set([0, 2])); // only depths 0 and 2 + */ + setExpandedDepths(depths: Set): void; + /** + * Get the set of depths that currently have at least one expanded group row. + */ + getExpandedDepths(): Set; + /** + * Get the column accessor key that defines the grouping at a given depth. + * @example getGroupingProperty(0) // "department" + * @example getGroupingProperty(1) // "team" + */ + getGroupingProperty(depth: number): string | undefined; + /** + * Get the depth index for a given grouping property name. + * Returns -1 if the property is not in the rowGrouping array. + * @example getGroupingDepth("department") // 0 + * @example getGroupingDepth("team") // 1 + */ + getGroupingDepth(property: string): number; +} + // ───────────────────────────────────────────── // Table Configuration // ───────────────────────────────────────────── @@ -88,10 +217,48 @@ export interface TableConfig { pageSizeOptions?: number[]; /** Allow exporting new columns created by transform function */ allowExportNewColumns: boolean; + /** + * Show "Expand All / Collapse All" buttons in the toolbar when rowGrouping is active. + * @default true + */ + enableRowGroupingControls?: boolean; + /** + * When true, the table keeps showing previous rows during a refetch instead + * of swapping to the loading skeleton. Useful when the host swaps the + * adapter to push fresh data (e.g. lazy-tree expansion) and a skeleton flash + * would be jarring. + * @default false + */ + keepPreviousData?: boolean; } export type StartToolbarPlacement = 'before-search' | 'after-search' | 'after-date'; +/** + * Anchor positions for `endToolbarContent` (right-side toolbar cluster). + * The anchor is a *position*, not a dependency — it still renders even if the + * referenced built-in is disabled. Order of built-ins on the right (when + * enabled): grouping-controls → export → view-options → settings. + * + * - `'before-grouping'` — first thing on the right (after `customToolbarContent`) + * - `'after-grouping'` — between grouping controls and export + * - `'before-export'` — same visual slot as `'after-grouping'` when grouping is hidden + * - `'after-export'` — between export and view-options + * - `'before-view'` — same as `'after-export'` when export is hidden + * - `'after-view'` — between view-options and settings *(default)* + * - `'before-settings'` — alias of `'after-view'` + * - `'after-settings'` — last (rightmost) — after the settings popover + */ +export type EndToolbarPlacement = + | 'before-grouping' + | 'after-grouping' + | 'before-export' + | 'after-export' + | 'before-view' + | 'after-view' + | 'before-settings' + | 'after-settings'; + // ───────────────────────────────────────────── // Data Fetching // ───────────────────────────────────────────── @@ -230,6 +397,22 @@ export interface TableContext { dateRange: { from: string; to: string }; /** All rows on the current page */ allData: T[]; + /** Expand all row groups (no-op when rowGrouping is not active) */ + expandAllGroups?: () => void; + /** Collapse all row groups (no-op when rowGrouping is not active) */ + collapseAllGroups?: () => void; + /** Expand all group rows at a specific depth index (0 = top-level) */ + expandDepth?: (depth: number) => void; + /** Collapse all group rows at a specific depth index */ + collapseDepth?: (depth: number) => void; + /** Toggle expansion for all group rows at a specific depth */ + toggleDepth?: (depth: number) => void; + /** Get the column key at a given grouping depth index */ + getGroupingProperty?: (depth: number) => string | undefined; + /** Get the depth index of a given column key (-1 if not grouped by it) */ + getGroupingDepth?: (property: string) => number; + /** True when rowGrouping prop is non-empty */ + isRowGroupingActive?: boolean; } // ───────────────────────────────────────────── @@ -415,6 +598,12 @@ export interface DataTableProps> { renderSubRow?: (props: { row: T; table: TableContext }) => React.ReactNode; /** Allow developers to control exactly WHICH rows can expand (Optional, defaults to all if renderSubRow is provided) */ getRowCanExpand?: (row: T) => boolean; + /** + * Returns child rows for a given row — enables tree/hierarchical data. + * The table renders children inline with depth-based indentation. + * @example getSubRows={(row) => row.children as T[]} + */ + getSubRows?: (row: T) => T[] | undefined; /** Data adapter — the bridge to your backend */ adapter: DataAdapter; /** Manual column definitions (skip auto-generation from metadata) */ @@ -463,12 +652,37 @@ export interface DataTableProps> { /** * Controls where `startToolbarContent` is rendered in the left toolbar area. * - `'before-search'` — before the search input - * - `'after-search'` — after search, before the date filter. + * - `'after-search'` — after search, before the date filter. * NOTE: If `enableSearch` is false, this renders in the same visual position as `'before-search'`. * - `'after-date'` — after the date filter (default) * @default 'after-date' */ startToolbarPlacement?: StartToolbarPlacement; + /** + * Custom toolbar content — injected into the right toolbar area without + * disturbing the position of any built-in control. + * Use `endToolbarPlacement` to choose which slot to render into. + * + * Receives the same `ToolbarContext` as `startToolbarContent` when passed + * as a function, so you can read selection / search / date-range state. + * + * @example + * ( + * + * )} + * endToolbarPlacement="before-export" + * /> + */ + endToolbarContent?: React.ReactNode | ((ctx: ToolbarContext) => React.ReactNode); + /** + * Where to inject `endToolbarContent` on the right side. See + * {@link EndToolbarPlacement} for available slots. + * @default 'after-view' + */ + endToolbarPlacement?: EndToolbarPlacement; /** Custom toolbar content (rendered after built-in controls) */ toolbarContent?: React.ReactNode; /** Render custom toolbar with selection context */ @@ -507,6 +721,49 @@ export interface DataTableProps> { * )} */ actions?: ActionsRender; + /** + * Column IDs to group flat data by — creates collapsible group-header rows. + * First element = outermost group, last element = innermost group. + * @example rowGrouping={["department", "team"]} + */ + rowGrouping?: (keyof T & string)[]; + + /** + * Fine-grained configuration for row grouping behaviour. + * Only relevant when `rowGrouping` is set. + */ + rowGroupingConfig?: RowGroupingConfig; + + /** + * Callback fired when a group row is expanded or collapsed. + */ + onRowGroupExpand?: (info: OnRowGroupExpandInfo) => void; + /** + * Callback fired when a tree (non-grouped) sub-row is expanded or collapsed. + * Use this to lazy-load children when a row is first expanded. + * + * @example + * onRowExpand={({ row, isExpanded }) => { + * if (isExpanded && row.children === undefined) fetchChildren(row.id); + * }} + */ + onRowExpand?: (info: { + row: T; + rowId: string; + depth: number; + isExpanded: boolean; + }) => void; + /** + * Imperative ref that exposes programmatic grouping controls. + * Attach this to a `useRef()` to call methods like + * `expandAll()`, `expandDepth(0)`, `collapseDepth(1)`, `toggleDepth(n)`, etc. + * + * @example + * const groupRef = useRef(null); + * + * // Then: groupRef.current?.expandDepth(0); + */ + groupingRef?: React.RefObject; } export interface ToolbarContext { @@ -528,4 +785,4 @@ export interface ToolbarContext { // Exportable data type for export utils // ───────────────────────────────────────────── -export type ExportableData = Record; \ No newline at end of file +export type ExportableData = Record; diff --git a/packages/table/src/utils/export-utils.ts b/packages/table/src/utils/export-utils.ts index 5563bcc..e112289 100644 --- a/packages/table/src/utils/export-utils.ts +++ b/packages/table/src/utils/export-utils.ts @@ -1,5 +1,30 @@ import type { ExportableData, DataTransformFunction } from "../types"; +/** + * Neutralize CSV-formula-injection vectors. Spreadsheet apps (Excel, Sheets, + * LibreOffice) evaluate any cell whose first character is `=`, `+`, `-`, `@`, + * `\t` or `\r` as a formula — letting an attacker who controls cell values + * exfiltrate data, fetch URLs, or run DDE commands when a victim opens the + * exported file. Prefixing such values with a single quote forces the + * spreadsheet to treat them as plain text. (OWASP CSV Injection guidance.) + */ +function sanitizeCsvCell(value: string): string { + if (value.length === 0) return value; + const first = value.charCodeAt(0); + // 0x3D '=' 0x2B '+' 0x2D '-' 0x40 '@' 0x09 TAB 0x0D CR + if ( + first === 0x3d || + first === 0x2b || + first === 0x2d || + first === 0x40 || + first === 0x09 || + first === 0x0d + ) { + return `'${value}`; + } + return value; +} + /** * Convert array of objects to CSV string. */ @@ -29,7 +54,8 @@ function convertToCSV( for (const item of data) { const row = headers.map((header) => { const value = item[header]; - const cellValue = value === null || value === undefined ? "" : String(value); + const rawCell = value === null || value === undefined ? "" : String(value); + const cellValue = sanitizeCsvCell(rawCell); const escapedValue = cellValue.includes(",") || cellValue.includes('"') ? `"${cellValue.replace(/"/g, '""')}"` @@ -46,8 +72,13 @@ function convertToCSV( * Download blob as file. */ function downloadFile(blob: Blob, filename: string) { - const link = document.createElement("a"); const url = URL.createObjectURL(blob); + // Use createElementNS to avoid conflicts with vi.spyOn(document, "createElement") + // in test environments where nested spies on the same property cause infinite recursion. + const link = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "a" + ) as HTMLAnchorElement; link.setAttribute("href", url); link.setAttribute("download", filename); @@ -233,4 +264,4 @@ export async function exportData( } finally { if (onLoadingEnd) onLoadingEnd(); } -} \ No newline at end of file +} diff --git a/packages/table/test/export-utils.test.ts b/packages/table/test/export-utils.test.ts index e2012f8..8ffe80e 100644 --- a/packages/table/test/export-utils.test.ts +++ b/packages/table/test/export-utils.test.ts @@ -174,6 +174,32 @@ describe("exportToCSV — basic", () => { cap6.restore(); }); + it("neutralizes CSV-formula-injection prefixes (=, +, -, @, tab, CR)", async () => { + const cap = captureDownload(); + const data = [ + { a: "=cmd|' /C calc'!A1", b: "+SUM(1)", c: "-2+3", d: "@SUM(1)", e: "\thidden", f: "\rsneaky" }, + ]; + exportToCSV(data, "injection", ["a", "b", "c", "d", "e", "f"]); + const text = await cap.blobs[0].text(); + // Each dangerous value must be prefixed with a single quote so + // spreadsheet apps treat it as plain text rather than a formula. + expect(text).toContain("'=cmd|' /C calc'!A1"); + expect(text).toContain("'+SUM(1)"); + expect(text).toContain("'-2+3"); + expect(text).toContain("'@SUM(1)"); + expect(text).toContain("'\thidden"); + expect(text).toContain("'\rsneaky"); + // And benign values are *not* prefixed: + const benign = [{ a: "hello", b: "world" }]; + const cap2 = captureDownload(); + exportToCSV(benign, "benign", ["a", "b"]); + const text2 = await cap2.blobs[0].text(); + expect(text2).toContain("hello,world"); + expect(text2).not.toContain("'hello"); + cap.restore(); + cap2.restore(); + }); + it("renders null/undefined cells as empty strings", async () => { const cap7 = captureDownload(); const data = [{ id: 1, name: null, extra: undefined }] as unknown as Array< From 071306560b9cd161b05f99e9d0b9d50157a97879 Mon Sep 17 00:00:00 2001 From: jacksonnkasi Date: Thu, 9 Jul 2026 22:43:03 +0530 Subject: [PATCH 2/8] fix(security): follow-up fixes for peer deps, CSV escaping, cancellation, dep CVEs - plugin-cache: declare ioredis and @upstash/redis as real optional peer dependencies (peerDependenciesMeta was inert without matching peer entries, leaving bun.lock out of sync with package.json). - table: thread AbortSignal through DataAdapter.query so useTableData can cancel stale requests before they overwrite newer table data; add a post-await abort guard in both useTableData and useTreeAdapter so adapters that ignore the signal still cannot race newer requests. - table/export-utils: quote cells containing CR or LF (RFC 4180); trim leading whitespace in sanitizeCsvCell so values like ' =SUM(1)' cannot bypass the formula-injection prefix check. - Bump high+critical dep versions: axios >=1.16.0 (ReDoS/MITM/Proxy-Auth leak), next >=16.2.5 (DoS/SSRF in peer of @tablecraft/adapter-next), hono >=4.12.18 (serveStatic file read), react-router-dom >=7.14.2 (RCE via turbo-stream in vite-web-example), vite >=6.4.3 (fs.deny bypass). vitest already on ^4.1.5 which is above the affected <3.2.6 range; jsdom already on ^29.1.1 which bundles undici >=7.28.0. Co-Authored-By: Claude --- apps/hono-example/package.json | 2 +- apps/sveltekit-example/package.json | 6 +- apps/vite-web-example/package.json | 6 +- apps/web/package.json | 2 +- packages/adapter-hono/package.json | 4 +- packages/adapter-next/package.json | 2 +- packages/client/package.json | 4 +- packages/plugin-cache/package.json | 4 +- packages/table/src/auto/rest-adapter.ts | 13 ++- packages/table/src/auto/static-adapter.ts | 6 +- packages/table/src/auto/tablecraft-adapter.ts | 10 ++- packages/table/src/auto/use-tree-adapter.ts | 20 +++-- packages/table/src/core/use-table-data.ts | 20 ++++- packages/table/src/types.ts | 15 +++- packages/table/src/utils/export-utils.ts | 23 ++++- packages/table/test/export-utils.test.ts | 89 +++++++++++++++++++ 16 files changed, 190 insertions(+), 36 deletions(-) diff --git a/apps/hono-example/package.json b/apps/hono-example/package.json index a0d3b47..64b812c 100644 --- a/apps/hono-example/package.json +++ b/apps/hono-example/package.json @@ -12,7 +12,7 @@ "typecheck": "bun run --bun tsc --noEmit" }, "dependencies": { - "hono": "^4.12.14", + "hono": "^4.12.18", "drizzle-orm": "^0.45.2", "postgres": "^3.4.9", "dotenv": "^17.4.2", diff --git a/apps/sveltekit-example/package.json b/apps/sveltekit-example/package.json index 9c4f1e4..7c882c9 100644 --- a/apps/sveltekit-example/package.json +++ b/apps/sveltekit-example/package.json @@ -16,12 +16,12 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", - "@sveltejs/kit": "^2.58.0", + "@sveltejs/kit": "^2.60.1", "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", + "svelte": "^5.55.7", "svelte-check": "^4.1.4", "typescript": "^5.9.3", - "vite": "^6.4.2", + "vite": "^6.4.3", "drizzle-kit": "^0.31.10", "vitest": "^4.1.5" }, diff --git a/apps/vite-web-example/package.json b/apps/vite-web-example/package.json index 0b977f3..3aafd8b 100644 --- a/apps/vite-web-example/package.json +++ b/apps/vite-web-example/package.json @@ -17,7 +17,7 @@ "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "^5.100.6", "@tanstack/react-table": "^8.21.3", - "axios": "^1.15.2", + "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -28,7 +28,7 @@ "react": "^19.2.0", "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.2.1", + "react-router-dom": "^7.14.2", "shadcn": "^3.8.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -48,6 +48,6 @@ "globals": "^16.5.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^6.4.2" + "vite": "^6.4.3" } } diff --git a/apps/web/package.json b/apps/web/package.json index 984aca5..53d00ad 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,6 @@ "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^6.4.2" + "vite": "^6.4.3" } } diff --git a/packages/adapter-hono/package.json b/packages/adapter-hono/package.json index 13cce6b..63a0dce 100644 --- a/packages/adapter-hono/package.json +++ b/packages/adapter-hono/package.json @@ -24,11 +24,11 @@ }, "peerDependencies": { "@tablecraft/engine": ">=0.1.0", - "hono": ">=4.12.14" + "hono": ">=4.12.18" }, "devDependencies": { "@tablecraft/engine": "workspace:*", - "hono": "^4.12.14", + "hono": "^4.12.18", "typescript": "^5.9.3", "vitest": "^4.1.5" }, diff --git a/packages/adapter-next/package.json b/packages/adapter-next/package.json index 41c8167..3d055a6 100644 --- a/packages/adapter-next/package.json +++ b/packages/adapter-next/package.json @@ -24,7 +24,7 @@ }, "peerDependencies": { "@tablecraft/engine": ">=0.1.0 <1", - "next": ">=14.0.0 <16 || >=16.1.6" + "next": ">=14.2.0 <16 || >=16.2.5" }, "devDependencies": { "@tablecraft/engine": "workspace:*", diff --git a/packages/client/package.json b/packages/client/package.json index 3489f56..08f2804 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -23,7 +23,7 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "axios": "^1.15.2", + "axios": "^1.16.0", "react": ">=18.0.0" }, "peerDependenciesMeta": { @@ -36,7 +36,7 @@ }, "devDependencies": { "@types/react": "^19.2.13", - "axios": "^1.15.2", + "axios": "^1.16.0", "typescript": "^5.9.3", "vitest": "^2.1.8" }, diff --git a/packages/plugin-cache/package.json b/packages/plugin-cache/package.json index aaa726b..b41ee46 100644 --- a/packages/plugin-cache/package.json +++ b/packages/plugin-cache/package.json @@ -31,7 +31,9 @@ "typecheck": "bun run --bun tsc --noEmit" }, "peerDependencies": { - "@tablecraft/engine": ">=0.1.0 <1" + "@tablecraft/engine": ">=0.1.0 <1", + "@upstash/redis": "*", + "ioredis": "*" }, "peerDependenciesMeta": { "ioredis": { "optional": true }, diff --git a/packages/table/src/auto/rest-adapter.ts b/packages/table/src/auto/rest-adapter.ts index b3b4f47..126755b 100644 --- a/packages/table/src/auto/rest-adapter.ts +++ b/packages/table/src/auto/rest-adapter.ts @@ -1,8 +1,17 @@ import type { DataAdapter, QueryParams, QueryResult, TableMetadata } from "../types"; export interface RestAdapterOptions { - /** Function that fetches data given table params */ - queryFn: (params: QueryParams) => Promise>; + /** + * Function that fetches data given table params. + * + * Receives an optional second argument carrying the table's + * `AbortSignal`; implementations SHOULD pass it through to + * `fetch` so stale requests are cancelled when params change. + */ + queryFn: ( + params: QueryParams, + options?: { signal?: AbortSignal }, + ) => Promise>; /** Function to fetch specific items by IDs (for cross-page export) */ queryByIdsFn?: (ids: (string | number)[], options?: { sortBy?: string; sortOrder?: "asc" | "desc" }) => Promise; /** Function to fetch table metadata (enables auto-columns) */ diff --git a/packages/table/src/auto/static-adapter.ts b/packages/table/src/auto/static-adapter.ts index 296b0d4..56b7b9d 100644 --- a/packages/table/src/auto/static-adapter.ts +++ b/packages/table/src/auto/static-adapter.ts @@ -24,7 +24,11 @@ export function createStaticAdapter>( options?: StaticAdapterOptions ): DataAdapter { return { - async query(params: QueryParams): Promise> { + async query( + params: QueryParams, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _options?: { signal?: AbortSignal }, + ): Promise> { let filtered = [...data]; // Search diff --git a/packages/table/src/auto/tablecraft-adapter.ts b/packages/table/src/auto/tablecraft-adapter.ts index 201d8bc..52d1578 100644 --- a/packages/table/src/auto/tablecraft-adapter.ts +++ b/packages/table/src/auto/tablecraft-adapter.ts @@ -151,7 +151,7 @@ export function createTableCraftAdapter, TFilters = return options.headers; } - async function request(url: string): Promise { + async function request(url: string, signal?: AbortSignal): Promise { const headers = await resolveHeaders(); const response = await customFetch(url, { method: "GET", @@ -159,6 +159,7 @@ export function createTableCraftAdapter, TFilters = Accept: "application/json", ...headers, }, + signal, }); if (!response.ok) { @@ -270,7 +271,10 @@ export function createTableCraftAdapter, TFilters = } return { - async query(params: QueryParams): Promise> { + async query( + params: QueryParams, + options?: { signal?: AbortSignal }, + ): Promise> { const { dateRangeCol } = await getMetadataWithFallback(); const url = buildQueryUrl(applyCustomFilters(params), dateRangeCol); @@ -282,7 +286,7 @@ export function createTableCraftAdapter, TFilters = pageSize: number; totalPages: number | null; }; - }>(url); + }>(url, options?.signal); return { data: result.data, diff --git a/packages/table/src/auto/use-tree-adapter.ts b/packages/table/src/auto/use-tree-adapter.ts index bab15e3..368417d 100644 --- a/packages/table/src/auto/use-tree-adapter.ts +++ b/packages/table/src/auto/use-tree-adapter.ts @@ -170,13 +170,15 @@ export function useTreeAdapter>( const adapter = useMemo>( () => ({ - async query(params) { + async query(params, options) { + // Prefer the upstream signal from useTableData so a stale list + // response can be cancelled by the table's own AbortController + // (param change, unmount). Fall back to a never-aborted signal + // so adapters that pass it straight through still typecheck. + const signal = options?.signal ?? new AbortController().signal; let result: QueryResult; if (list.fetch) { - // Caller-owned abort: we pass a fresh controller so they can - // honour it. The outer useTableData also aborts via its own. - const ctrl = new AbortController(); - result = await list.fetch(params, ctrl.signal); + result = await list.fetch(params, signal); } else { if (!list.url) { throw new Error( @@ -190,12 +192,18 @@ export function useTreeAdapter>( if (params.search) qs.set("search", params.search); if (params.sort) qs.set("sort", params.sort); if (params.sortOrder) qs.set("sortOrder", params.sortOrder); - const res = await fetch(`${list.url}?${qs.toString()}`); + const res = await fetch(`${list.url}?${qs.toString()}`, { signal }); if (!res.ok) { throw new Error(`useTreeAdapter list: HTTP ${res.status}`); } result = (await res.json()) as QueryResult; } + // Stale-response guard: if the table aborted us while we were + // awaiting, don't merge + return a fresh result that would race + // with a newer query. + if (signal.aborted) { + throw Object.assign(new Error("Aborted"), { name: "AbortError" }); + } return { ...result, data: result.data.map((row) => mergeChildren(row, 0)), diff --git a/packages/table/src/core/use-table-data.ts b/packages/table/src/core/use-table-data.ts index 3b5f558..898b0ff 100644 --- a/packages/table/src/core/use-table-data.ts +++ b/packages/table/src/core/use-table-data.ts @@ -117,7 +117,8 @@ export function useTableData>( // ─── Fetch data on param change ─── useEffect(() => { abortRef.current?.abort(); - abortRef.current = new AbortController(); + const controller = new AbortController(); + abortRef.current = controller; const fetchData = async () => { try { @@ -126,23 +127,34 @@ export function useTableData>( if (!(config.keepPreviousData && result)) { setIsLoading(true); } - const data = await adapter.query(queryParams); + const data = await adapter.query(queryParams, { signal: controller.signal }); + // Stale-response guard: if this effect was superseded (param changed, + // unmount, etc.) while the await was pending, drop the result so it + // can't overwrite newer table state. Adapters that honour the signal + // will already have thrown AbortError; this catches the rest. + if (controller.signal.aborted) return; setResult(data); setIsError(false); setError(null); } catch (err) { if (err instanceof Error && err.name === "AbortError") return; + if (controller.signal.aborted) return; setIsError(true); setError(err instanceof Error ? err : new Error("Unknown error")); } finally { - setIsLoading(false); + if (!controller.signal.aborted) { + setIsLoading(false); + } } }; fetchData(); return () => { - abortRef.current?.abort(); + controller.abort(); + if (abortRef.current === controller) { + abortRef.current = null; + } }; }, [adapter, queryParams]); diff --git a/packages/table/src/types.ts b/packages/table/src/types.ts index f684133..a8c8c94 100644 --- a/packages/table/src/types.ts +++ b/packages/table/src/types.ts @@ -289,8 +289,19 @@ export interface QueryResult> { // ───────────────────────────────────────────── export interface DataAdapter> { - /** Fetch data given current table params */ - query(params: QueryParams): Promise>; + /** + * Fetch data given current table params. + * + * The optional `options.signal` is the table's `AbortSignal` and is fired + * whenever query params change (or the consumer unmounts). Implementations + * SHOULD pass it through to any underlying `fetch` so stale requests are + * cancelled. Adapters that resolve synchronously (e.g. in-memory data) + * MAY ignore it, but must still accept the argument for type compatibility. + */ + query( + params: QueryParams, + options?: { signal?: AbortSignal }, + ): Promise>; /** Fetch items by IDs (for cross-page selection/export) */ queryByIds?(ids: (string | number)[], options?: { sortBy?: string; sortOrder?: "asc" | "desc" }): Promise; /** Fetch table metadata (enables auto-column generation) */ diff --git a/packages/table/src/utils/export-utils.ts b/packages/table/src/utils/export-utils.ts index e112289..ba2b7cd 100644 --- a/packages/table/src/utils/export-utils.ts +++ b/packages/table/src/utils/export-utils.ts @@ -7,10 +7,19 @@ import type { ExportableData, DataTransformFunction } from "../types"; * exfiltrate data, fetch URLs, or run DDE commands when a victim opens the * exported file. Prefixing such values with a single quote forces the * spreadsheet to treat them as plain text. (OWASP CSV Injection guidance.) + * + * Leading whitespace is trimmed before the prefix check, so a value like + * `" =SUM(1)"` (with a leading space) cannot bypass the check — some + * spreadsheet apps ignore leading whitespace when evaluating formulas. + * The original whitespace is preserved in the returned value. */ function sanitizeCsvCell(value: string): string { if (value.length === 0) return value; - const first = value.charCodeAt(0); + // Trim leading spaces (not tabs/CRs — those are themselves formula triggers + // and must remain in the value so the prefix check can detect them). + const trimmed = value.replace(/^ +/, ""); + if (trimmed.length === 0) return value; + const first = trimmed.charCodeAt(0); // 0x3D '=' 0x2B '+' 0x2D '-' 0x40 '@' 0x09 TAB 0x0D CR if ( first === 0x3d || @@ -20,7 +29,7 @@ function sanitizeCsvCell(value: string): string { first === 0x09 || first === 0x0d ) { - return `'${value}`; + return `'${value}`; // preserve the original (including leading whitespace) } return value; } @@ -42,7 +51,10 @@ function convertToCSV( if (columnMapping) { const headerRow = headers.map((header) => { const mappedHeader = columnMapping[header] || header; - return mappedHeader.includes(",") || mappedHeader.includes('"') + return mappedHeader.includes(",") || + mappedHeader.includes('"') || + mappedHeader.includes("\n") || + mappedHeader.includes("\r") ? `"${mappedHeader.replace(/"/g, '""')}"` : mappedHeader; }); @@ -57,7 +69,10 @@ function convertToCSV( const rawCell = value === null || value === undefined ? "" : String(value); const cellValue = sanitizeCsvCell(rawCell); const escapedValue = - cellValue.includes(",") || cellValue.includes('"') + cellValue.includes(",") || + cellValue.includes('"') || + cellValue.includes("\n") || + cellValue.includes("\r") ? `"${cellValue.replace(/"/g, '""')}"` : cellValue; return escapedValue; diff --git a/packages/table/test/export-utils.test.ts b/packages/table/test/export-utils.test.ts index 8ffe80e..d12aa76 100644 --- a/packages/table/test/export-utils.test.ts +++ b/packages/table/test/export-utils.test.ts @@ -213,6 +213,95 @@ describe("exportToCSV — basic", () => { expect(text).toContain("1,,"); cap7.restore(); }); + + it("escapes LF (\\n) in cell values with double-quotes", async () => { + const cap = captureDownload(); + const data = [{ id: 1, note: "line1\nline2" }]; + exportToCSV(data, "lf", ["id", "note"]); + + const text = await cap.blobs[0].text(); + expect(text).toContain('"line1\nline2"'); + cap.restore(); + }); + + it("escapes CR (\\r) in cell values with double-quotes", async () => { + const cap = captureDownload(); + const data = [{ id: 1, note: "line1\rline2" }]; + exportToCSV(data, "cr", ["id", "note"]); + + const text = await cap.blobs[0].text(); + expect(text).toContain('"line1\rline2"'); + cap.restore(); + }); + + it("escapes CRLF (\\r\\n) in cell values with double-quotes", async () => { + const cap = captureDownload(); + const data = [{ id: 1, note: "line1\r\nline2" }]; + exportToCSV(data, "crlf", ["id", "note"]); + + const text = await cap.blobs[0].text(); + expect(text).toContain('"line1\r\nline2"'); + cap.restore(); + }); + + it("escapes newlines in column-mapping header names", async () => { + const cap = captureDownload(); + const data = [{ id: 1, label: "x" }]; + exportToCSV( + data, + "header-newline", + ["id", "label"], + { id: "id", label: "label\nwith\nnewline" }, + ); + + const text = await cap.blobs[0].text(); + expect(text.startsWith("id,\"label\nwith\nnewline\"\n")).toBe(true); + cap.restore(); + }); + + it("neutralizes formula-injection prefix that bypasses via leading space", async () => { + const cap = captureDownload(); + const data = [{ formula: " =SUM(1)" }]; + exportToCSV(data, "leading-space", ["formula"]); + + const text = await cap.blobs[0].text(); + // After sanitization, the value must start with a single quote so + // spreadsheet apps treat it as plain text, not a formula. The original + // leading whitespace is preserved in the written cell. + expect(text).toContain("' =SUM(1)"); + cap.restore(); + }); + + it("preserves tab characters within cell values", async () => { + const cap = captureDownload(); + const data = [{ id: 1, note: "hello\tworld" }]; + exportToCSV(data, "tab", ["id", "note"]); + + const text = await cap.blobs[0].text(); + // The first char is 'h' (not a formula trigger), and tab is not in + // the RFC 4180 escape rule (only CR/LF/quote/comma are). The tab is + // preserved verbatim. Sanitize only acts on the leading char. + expect(text).toContain("hello\tworld"); + // And the row is written as-is (no double-quote wrap): + expect(text).toContain("1,hello\tworld\n"); + cap.restore(); + }); + + it("keeps a multi-line cell as one logical CSV row", async () => { + const cap = captureDownload(); + const data = [{ id: 1, body: "para one\n\npara two" }]; + exportToCSV(data, "multiline", ["id", "body"]); + + const text = await cap.blobs[0].text(); + // The cell contains embedded \n, so the escape rule wraps the cell + // in double-quotes. The embedded newlines are preserved verbatim + // inside the quoted cell — the parser sees ONE logical row. + expect(text).toContain('"para one\n\npara two"'); + // The header row is followed by a single data row, terminated by a + // single trailing \n (the only unquoted row separator at the end). + expect(text).toBe('id,body\n1,"para one\n\npara two"\n'); + cap.restore(); + }); }); describe("exportData — csv wrapper", () => { From b43b7c3f9a7ff4ca377281c160bd86ffb630aa27 Mon Sep 17 00:00:00 2001 From: jacksonnkasi Date: Sat, 11 Jul 2026 22:10:34 +0530 Subject: [PATCH 3/8] fix(table): harden grouping and tree behavior --- apps/hono-example/src/db/seed-retail.ts | 40 +++- apps/hono-example/src/routes/manual/retail.ts | 53 ++++- .../src/pages/employees-grouping-page.tsx | 201 ------------------ .../src/pages/products-grouping-page.tsx | 114 ---------- .../src/pages/row-grouping/tree.tsx | 124 ----------- bun.lock | 64 +++--- docs/api-reference.md | 38 ++++ packages/table/src/auto/use-tree-adapter.ts | 61 ++++-- packages/table/src/data-table.tsx | 78 ++++--- packages/table/src/utils/export-utils.ts | 36 ++-- packages/table/test/export-utils.test.ts | 18 ++ packages/table/test/use-tree-adapter.test.tsx | 101 +++++++++ 12 files changed, 376 insertions(+), 552 deletions(-) delete mode 100644 apps/vite-web-example/src/pages/employees-grouping-page.tsx delete mode 100644 apps/vite-web-example/src/pages/products-grouping-page.tsx delete mode 100644 apps/vite-web-example/src/pages/row-grouping/tree.tsx create mode 100644 packages/table/test/use-tree-adapter.test.tsx diff --git a/apps/hono-example/src/db/seed-retail.ts b/apps/hono-example/src/db/seed-retail.ts index 1607af2..e038b5f 100644 --- a/apps/hono-example/src/db/seed-retail.ts +++ b/apps/hono-example/src/db/seed-retail.ts @@ -2,6 +2,14 @@ import { db } from './index'; import * as schema from './schema'; import { sql } from 'drizzle-orm'; +let randomState = 0x5eed1234; + +/** Deterministic PRNG so repeated destructive seeds produce identical data. */ +function random(): number { + randomState = (1664525 * randomState + 1013904223) >>> 0; + return randomState / 0x100000000; +} + // ── Regions (60) ───────────────────────────────────────────────────────────── const regions = [ { id: 'r01', name: 'Northeast US', totalSales: 52400, revenue: 1380000, stores: 14, avgRating: 4.5 }, @@ -142,9 +150,9 @@ function makeStores() { id: sid, regionId: region.id, name: names[i], - totalSales: Math.round(region.totalSales * share * (0.85 + Math.random() * 0.3)), - revenue: Math.round(region.revenue * share * (0.85 + Math.random() * 0.3)), - avgRating: Math.round((region.avgRating + (Math.random() * 0.4 - 0.2)) * 10) / 10, + totalSales: Math.round(region.totalSales * share * (0.85 + random() * 0.3)), + revenue: Math.round(region.revenue * share * (0.85 + random() * 0.3)), + avgRating: Math.round((region.avgRating + (random() * 0.4 - 0.2)) * 10) / 10, }); idx++; } @@ -171,17 +179,17 @@ function makeProducts(stores: ReturnType) { const usedNames = new Set(); for (let i = 0; i < 3; i++) { let name: string; - do { name = PRODUCT_POOL[Math.floor(Math.random() * PRODUCT_POOL.length)]; } while (usedNames.has(name)); + do { name = PRODUCT_POOL[Math.floor(random() * PRODUCT_POOL.length)]; } while (usedNames.has(name)); usedNames.add(name); const pid = `p${String(idx).padStart(4, '0')}`; - const sales = Math.round(store.totalSales * 0.33 * (0.8 + Math.random() * 0.4)); + const sales = Math.round(store.totalSales * 0.33 * (0.8 + random() * 0.4)); products.push({ id: pid, storeId: store.id, name, totalSales: sales, - revenue: Math.round(sales * (20 + Math.random() * 30)), - avgRating: Math.round((store.avgRating + (Math.random() * 0.4 - 0.2)) * 10) / 10, + revenue: Math.round(sales * (20 + random() * 30)), + avgRating: Math.round((store.avgRating + (random() * 0.4 - 0.2)) * 10) / 10, }); idx++; } @@ -190,14 +198,24 @@ function makeProducts(stores: ReturnType) { } async function seed() { - console.log('Seeding retail tree…'); + console.warn('DESTRUCTIVE: truncating and reseeding all retail demo tables.'); + + randomState = 0x5eed1234; + const stores = makeStores(); + const storeCounts = new Map(); + for (const store of stores) { + storeCounts.set(store.regionId, (storeCounts.get(store.regionId) ?? 0) + 1); + } + const coherentRegions = regions.map((region) => ({ + ...region, + stores: storeCounts.get(region.id) ?? 0, + })); await db.execute(sql`TRUNCATE retail_products, retail_stores, retail_regions RESTART IDENTITY CASCADE`); - await db.insert(schema.retailRegions).values(regions); - console.log(` ✓ ${regions.length} regions`); + await db.insert(schema.retailRegions).values(coherentRegions); + console.log(` ✓ ${coherentRegions.length} regions`); - const stores = makeStores(); await db.insert(schema.retailStores).values(stores); console.log(` ✓ ${stores.length} stores`); diff --git a/apps/hono-example/src/routes/manual/retail.ts b/apps/hono-example/src/routes/manual/retail.ts index d7615f1..286da45 100644 --- a/apps/hono-example/src/routes/manual/retail.ts +++ b/apps/hono-example/src/routes/manual/retail.ts @@ -25,7 +25,7 @@ function order>(map: T, key: string, fallback: /** Build the standard pagination meta object. */ function pageMeta(page: number, pageSize: number, total: number) { - return { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) }; + return { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }; } /** @@ -58,10 +58,17 @@ app.get('/tree', async (c) => { // ── Deep search mode ──────────────────────────────────────────────────── if (search) { const term = `%${search}%`; + const safeSortKey = sortKey in SORT.region ? sortKey : 'name'; + const windowSize = offset + pageSize; + const direction = sortDir === 'asc' ? 1 : -1; - const [rRows, sRows, pRows] = await Promise.all([ + const [rRows, sRows, pRows, [rCount], [sCount], [pCount]] = await Promise.all([ db.select().from(schema.retailRegions) - .where(ilike(schema.retailRegions.name, term)), + .where(ilike(schema.retailRegions.name, term)) + .orderBy( + order(SORT.region, safeSortKey, schema.retailRegions.name, sortDir), + asc(schema.retailRegions.id), + ).limit(windowSize), db.select({ id: schema.retailStores.id, name: schema.retailStores.name, @@ -69,7 +76,11 @@ app.get('/tree', async (c) => { avgRating: schema.retailStores.avgRating, regionName: schema.retailRegions.name, }).from(schema.retailStores) .leftJoin(schema.retailRegions, eq(schema.retailStores.regionId, schema.retailRegions.id)) - .where(ilike(schema.retailStores.name, term)), + .where(ilike(schema.retailStores.name, term)) + .orderBy( + order(SORT.store, safeSortKey, schema.retailStores.name, sortDir), + asc(schema.retailStores.id), + ).limit(windowSize), db.select({ id: schema.retailProducts.id, name: schema.retailProducts.name, @@ -79,6 +90,16 @@ app.get('/tree', async (c) => { }).from(schema.retailProducts) .leftJoin(schema.retailStores, eq(schema.retailProducts.storeId, schema.retailStores.id)) .leftJoin(schema.retailRegions, eq(schema.retailStores.regionId, schema.retailRegions.id)) + .where(ilike(schema.retailProducts.name, term)) + .orderBy( + order(SORT.product, safeSortKey, schema.retailProducts.name, sortDir), + asc(schema.retailProducts.id), + ).limit(windowSize), + db.select({ total: sql`count(*)` }).from(schema.retailRegions) + .where(ilike(schema.retailRegions.name, term)), + db.select({ total: sql`count(*)` }).from(schema.retailStores) + .where(ilike(schema.retailStores.name, term)), + db.select({ total: sql`count(*)` }).from(schema.retailProducts) .where(ilike(schema.retailProducts.name, term)), ]); @@ -88,13 +109,33 @@ app.get('/tree', async (c) => { ...pRows.map(p => ({ id: p.id, name: p.name, type: 'Product' as const, totalSales: p.totalSales, revenue: p.revenue, stores: null, avgRating: p.avgRating, breadcrumb: [p.regionName, p.storeName].filter(Boolean).join(' › ') || null, children: [] as never[] })), ]; - return c.json({ data: all.slice(offset, offset + pageSize), meta: pageMeta(page, pageSize, all.length) }); + // Mixed entity results share the requested field ordering. Entity type and + // ID are deterministic tie-breakers; invalid fields fall back to name. + all.sort((left, right) => { + const leftValue = left[safeSortKey as keyof typeof left]; + const rightValue = right[safeSortKey as keyof typeof right]; + const primary = typeof leftValue === 'number' && typeof rightValue === 'number' + ? leftValue - rightValue + : String(leftValue ?? '').localeCompare(String(rightValue ?? '')); + if (primary !== 0) return primary * direction; + const typeOrder = left.type.localeCompare(right.type); + return typeOrder || left.id.localeCompare(right.id); + }); + const total = Number(rCount.total) + Number(sCount.total) + Number(pCount.total); + + return c.json({ + data: all.slice(offset, offset + pageSize), + meta: pageMeta(page, pageSize, total), + }); } // ── Tree mode: paginated top-level regions ────────────────────────────── const [rows, [{ total }]] = await Promise.all([ db.select().from(schema.retailRegions) - .orderBy(order(SORT.region, sortKey, schema.retailRegions.name, sortDir)) + .orderBy( + order(SORT.region, sortKey, schema.retailRegions.name, sortDir), + asc(schema.retailRegions.id), + ) .limit(pageSize).offset(offset), db.select({ total: sql`count(*)` }).from(schema.retailRegions), ]); diff --git a/apps/vite-web-example/src/pages/employees-grouping-page.tsx b/apps/vite-web-example/src/pages/employees-grouping-page.tsx deleted file mode 100644 index 5c657ca..0000000 --- a/apps/vite-web-example/src/pages/employees-grouping-page.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { DataTable, createStaticAdapter, DataTableColumnHeader } from "@tablecraft/table"; -import { useRef } from "react"; -import type { TableGroupingAPI } from "@tablecraft/table"; -import type { ColumnDef } from "@tanstack/react-table"; -import { EMPLOYEES_DATA, type EmployeeRow } from "@/data/employees-grouping"; - -const adapter = createStaticAdapter(EMPLOYEES_DATA); - -const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => , - cell: ({ getValue }) => {String(getValue())}, - }, - { - accessorKey: "department", - header: ({ column }) => , - }, - { - accessorKey: "team", - header: ({ column }) => , - }, - { - accessorKey: "role", - header: ({ column }) => , - cell: ({ getValue }) => {String(getValue())}, - }, - { - accessorKey: "salary", - header: ({ column }) => , - cell: ({ getValue }) => ( - - ${(getValue() as number).toLocaleString()} - - ), - aggregationFn: "sum", - aggregatedCell: ({ getValue }) => ( - - ${(getValue() as number).toLocaleString()} - - ), - }, - { - accessorKey: "location", - header: ({ column }) => , - }, - { - accessorKey: "startDate", - header: ({ column }) => , - cell: ({ getValue }) => {String(getValue())}, - }, -]; - -function ProgrammaticDemo() { - const groupRef = useRef(null); - - return ( -
-
- - - - - - -
- - adapter={adapter} - columns={columns} - rowGrouping={["department", "team"]} - groupingRef={groupRef} - rowGroupingConfig={{ defaultExpanded: false }} - config={{ enableUrlState: false, enablePagination: false, defaultPageSize: 100 }} - /> -
- ); -} - -export function EmployeesGroupingPage() { - return ( -
-
-

Row Grouping

-

- Flat employee data grouped into collapsible hierarchies — no changes to the backend needed. -

-
- - {/* Example 1: Group by Department */} -
-

Group by Department (salary sum)

- - adapter={adapter} - columns={columns} - rowGrouping={["department"]} - rowGroupingConfig={{ - defaultExpanded: true, - aggregations: { salary: "sum" }, - }} - config={{ - enableUrlState: false, - enablePagination: false, - defaultPageSize: 100, - }} - /> -
- - {/* Example 2: Group by Department then Team */} -
-

Group by Department → Team (salary mean)

- - adapter={adapter} - columns={columns} - rowGrouping={["department", "team"]} - rowGroupingConfig={{ - defaultExpanded: false, - aggregations: { salary: "mean" }, - }} - config={{ - enableUrlState: false, - enablePagination: false, - defaultPageSize: 100, - }} - /> -
- - {/* Example 3: Custom group cell renderer */} -
-

Custom Group Header (group by location)

- - adapter={adapter} - columns={columns} - rowGrouping={["location"]} - rowGroupingConfig={{ - defaultExpanded: true, - renderGroupCell: ({ value, leafRowCount }) => ( - - 📍 - {String(value)} - - ({leafRowCount} {leafRowCount === 1 ? "employee" : "employees"}) - - - ), - }} - config={{ - enableUrlState: false, - enablePagination: false, - defaultPageSize: 100, - }} - /> -
- - {/* Example 4: Programmatic depth control */} -
-

Programmatic Control (groupingRef)

-

- Attach a groupingRef for imperative access: - expandDepth, collapseDepth, toggleDepth, setExpandedDepths and more. -

- -
-
- ); -} diff --git a/apps/vite-web-example/src/pages/products-grouping-page.tsx b/apps/vite-web-example/src/pages/products-grouping-page.tsx deleted file mode 100644 index 15a13d0..0000000 --- a/apps/vite-web-example/src/pages/products-grouping-page.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useMemo } from "react"; -import type { ColumnDef } from "@tanstack/react-table"; -import { - DataTable, - DataTableColumnHeader, - createRestAdapter, -} from "@tablecraft/table"; -import type { QueryParams, QueryResult } from "@tablecraft/table"; -import { API_BASE_URL } from "@/api"; - -// ─── Row type ──────────────────────────────────────────────────────────────── - -interface ProductRow extends Record { - id: number; - name: string; - description: string | null; - price: string; - category: string; - isArchived: boolean; -} - -// ─── Columns ────────────────────────────────────────────────────────────────── - -const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => , - cell: ({ getValue }) => ( - {String(getValue())} - ), - }, - { - accessorKey: "category", - header: ({ column }) => , - }, - { - accessorKey: "description", - header: ({ column }) => , - cell: ({ getValue }) => ( - {String(getValue() ?? "—")} - ), - }, - { - accessorKey: "price", - header: ({ column }) => , - cell: ({ getValue }) => ( - - ${Number(getValue()).toFixed(2)} - - ), - aggregationFn: "mean", - aggregatedCell: ({ getValue }) => ( - - avg ${Number(getValue()).toFixed(2)} - - ), - }, -]; - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export function ProductsGroupingPage() { - // Fetch all products in one request (large pageSize) so client-side grouping - // sees the full dataset. For very large APIs, move grouping to the server. - const adapter = useMemo( - () => - createRestAdapter({ - queryFn: async (params: QueryParams): Promise> => { - const url = new URL(`${API_BASE_URL.replace("/engine", "/manual")}/products`, window.location.origin); - url.searchParams.set("page", String(params.page)); - url.searchParams.set("pageSize", String(params.pageSize)); - if (params.search) url.searchParams.set("search", params.search); - if (params.sort) url.searchParams.set("sort", params.sortOrder === "desc" ? `-${params.sort}` : params.sort); - - const res = await fetch(url.toString()); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); - }, - }), - [] - ); - - return ( -
-
-

Products — Server-side Row Grouping

-

- Data fetched from /api/manual/products and - grouped client-side by category. Use a large{" "} - defaultPageSize so all rows are - available for grouping. -

-
- -
-

Group by Category (avg price)

- - adapter={adapter} - columns={columns} - rowGrouping={["category"]} - rowGroupingConfig={{ - defaultExpanded: true, - aggregations: { price: "mean" }, - }} - config={{ - enableUrlState: false, - enablePagination: false, - defaultPageSize: 100, - }} - /> -
-
- ); -} diff --git a/apps/vite-web-example/src/pages/row-grouping/tree.tsx b/apps/vite-web-example/src/pages/row-grouping/tree.tsx deleted file mode 100644 index 899e6ad..0000000 --- a/apps/vite-web-example/src/pages/row-grouping/tree.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type { ColumnDef } from "@tanstack/react-table"; -import { DataTable, DataTableColumnHeader, createStaticAdapter } from "@tablecraft/table"; -import { RETAIL_TREE, type RetailNode } from "@/data/retail-tree"; - -const adapter = createStaticAdapter(RETAIL_TREE); - -const TYPE_BADGE: Record = { - Region: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", - Store: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300", - Product: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", -}; - -const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => , - cell: ({ getValue }) => {String(getValue())}, - }, - { - accessorKey: "type", - header: ({ column }) => , - cell: ({ getValue }) => { - const t = getValue() as RetailNode["type"]; - return ( - - {t} - - ); - }, - }, - { - accessorKey: "totalSales", - header: ({ column }) => , - cell: ({ getValue }) => ( - {(getValue() as number).toLocaleString()} - ), - }, - { - accessorKey: "revenue", - header: ({ column }) => , - cell: ({ getValue }) => ( - - ${(getValue() as number).toLocaleString()} - - ), - }, - { - accessorKey: "stores", - header: ({ column }) => , - cell: ({ getValue }) => { - const v = getValue() as number | null; - return v != null ? ( - {v} - ) : ( - — - ); - }, - }, - { - accessorKey: "avgRating", - header: ({ column }) => , - cell: ({ getValue }) => { - const v = getValue() as number | null; - return v != null ? ( - - ★ - {v.toFixed(1)} - - ) : ( - — - ); - }, - }, -]; - -export function RowGroupingTreePage() { - return ( -
-
-

Client-side Tree (Regions → Stores → Products)

-

- Pass getSubRows to enable - hierarchical rendering. The data already contains nested{" "} - children arrays — no grouping - config needed. -

- - adapter={adapter} - columns={columns} - getSubRows={(row) => row.children as RetailNode[] | undefined} - config={{ - enableUrlState: false, - enablePagination: false, - defaultPageSize: 200, - }} - /> -
- -
-

Server-side Pattern

-

- For server-side tree data, use createRestAdapter with a{" "} - queryFn that returns the full nested JSON from your API. - Pass getSubRows the same way — the adapter only fetches data, - tree rendering is handled by the table. -

-
-

{"const adapter = createRestAdapter({"}

-

{"queryFn: async (params) => {"}

-

{"const res = await fetch(`/api/tree?page=${params.page}`);"}

-

{"return res.json(); // { data: [...nested], total, page, pageSize }"}

-

{"}"}

-

{"});"}

-

 

-

{" -

{"adapter={adapter}"}

-

{"columns={columns}"}

-

{"getSubRows={(row) => row.children}"}

-

{"/>"}

-
-
-
- ); -} diff --git a/bun.lock b/bun.lock index 94c44d7..71897e7 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ "@tablecraft/plugin-cache": "workspace:*", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", - "hono": "^4.12.14", + "hono": "^4.12.18", "postgres": "^3.4.9", }, "devDependencies": { @@ -37,13 +37,13 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", - "@sveltejs/kit": "^2.58.0", + "@sveltejs/kit": "^2.60.1", "@sveltejs/vite-plugin-svelte": "^5.0.0", "drizzle-kit": "^0.31.10", - "svelte": "^5.0.0", + "svelte": "^5.55.7", "svelte-check": "^4.1.4", "typescript": "^5.9.3", - "vite": "^6.4.2", + "vite": "^6.4.3", "vitest": "^4.1.5", }, }, @@ -58,7 +58,7 @@ "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "^5.100.6", "@tanstack/react-table": "^8.21.3", - "axios": "^1.15.2", + "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -69,7 +69,7 @@ "react": "^19.2.0", "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.2.1", + "react-router-dom": "^7.14.2", "shadcn": "^3.8.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -89,7 +89,7 @@ "globals": "^16.5.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^6.4.2", + "vite": "^6.4.3", }, }, "apps/web": { @@ -119,7 +119,7 @@ "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", - "vite": "^6.4.2", + "vite": "^6.4.3", }, }, "packages/adapter-elysia": { @@ -156,13 +156,13 @@ "version": "0.1.4", "devDependencies": { "@tablecraft/engine": "workspace:*", - "hono": "^4.12.14", + "hono": "^4.12.18", "typescript": "^5.9.3", "vitest": "^4.1.5", }, "peerDependencies": { "@tablecraft/engine": ">=0.1.0", - "hono": ">=4.12.14", + "hono": ">=4.12.18", }, }, "packages/adapter-next": { @@ -176,7 +176,7 @@ }, "peerDependencies": { "@tablecraft/engine": ">=0.1.0 <1", - "next": ">=14.0.0 <16 || >=16.1.6", + "next": ">=14.2.0 <16 || >=16.2.5", }, }, "packages/adapter-sveltekit": { @@ -198,12 +198,12 @@ "version": "0.1.12", "devDependencies": { "@types/react": "^19.2.13", - "axios": "^1.15.2", + "axios": "^1.16.0", "typescript": "^5.9.3", "vitest": "^2.1.8", }, "peerDependencies": { - "axios": "^1.15.2", + "axios": "^1.16.0", "react": ">=18.0.0", }, "optionalPeers": [ @@ -813,7 +813,7 @@ "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="], - "@sveltejs/kit": ["@sveltejs/kit@2.58.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA=="], + "@sveltejs/kit": ["@sveltejs/kit@2.69.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], @@ -983,7 +983,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], @@ -1017,7 +1017,7 @@ "autoprefixer": ["autoprefixer@10.4.24", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="], - "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], + "axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -1055,7 +1055,7 @@ "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1183,7 +1183,7 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -1255,7 +1255,7 @@ "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="], + "esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], @@ -1321,7 +1321,7 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -1395,7 +1395,7 @@ "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="], - "hono": ["hono@4.12.16", "", {}, "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg=="], + "hono": ["hono@4.12.29", "", {}, "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw=="], "hono-example": ["hono-example@workspace:apps/hono-example"], @@ -1403,7 +1403,7 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -1579,7 +1579,7 @@ "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], - "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], + "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1759,9 +1759,9 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="], + "react-router": ["react-router@7.18.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg=="], - "react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="], + "react-router-dom": ["react-router-dom@7.18.1", "", { "dependencies": { "react-router": "7.18.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], @@ -1885,7 +1885,7 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "svelte": ["svelte@5.53.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA=="], + "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="], @@ -2007,7 +2007,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], @@ -2207,6 +2207,8 @@ "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "shadcn/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "shadcn/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2215,6 +2217,8 @@ "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "svelte/@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], + "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -2229,14 +2233,10 @@ "vite-web-example/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], - "vite-web-example/lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], - "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "web/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], - "web/lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], - "whatwg-url/@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -2409,6 +2409,8 @@ "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "shadcn/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "vite-node/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], diff --git a/docs/api-reference.md b/docs/api-reference.md index 5caa71a..e94b710 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -374,6 +374,9 @@ import type { OrdersRow, OrdersColumn } from './generated'; TableCraft supports rendering nested sub-tables or any React component inside an expandable row. When you provide the `renderSubRow` prop, TableCraft automatically injects an `__expand` column with an arrow toggle button. +Expansion remains explicit: ordinary row clicks still invoke `onRowClick` and +`enableClickRowSelect`; only the expand button opens master-detail content. + ```tsx ``` +### Tree Rows and Grouping + +Static trees only need `getSubRows`. A row is expandable when that function +returns a non-empty child array; an empty array is a leaf. Supply +`getRowCanExpand` only when the data needs an explicit override. `onRowExpand` +receives individual tree expansion changes. + +For lazy trees, `useTreeAdapter` returns an adapter plus `treeProps`. It preserves +existing query parameters in list URLs, cancels child requests on collapse, +invalidation, and unmount, rejects stale responses, and permits retry after an +error. `invalidateChildren(id)` clears one branch; calling it without an ID +clears every branch. Lazy adapters intentionally omit `queryByIds` because they +cannot guarantee cross-page lookup. + +Use `rowGrouping` for client-side grouping, `rowGroupingConfig` for aggregates, +custom group labels, and initial expansion, and `groupingRef` for imperative +expand/collapse operations. Changing `rowGrouping` clears obsolete group +expansion state. Group headers are synthetic: they are never selectable or +passed to `actions`; selection, toolbar counts, and exports contain data rows +only. With server pagination, grouping applies to the current page, so groups +split across pages are partial rather than global. + +Selection persists across page and page-size changes. When the adapter supports +`queryByIds`, selected rows from multiple pages are fetched together for export. +Changing search text clears selection to avoid carrying IDs into a different +result set. + +### Toolbar Slots + +`startToolbarContent` supports `before-search`, `after-search`, and `after-date`. +`endToolbarContent` supports positions around grouping, export, view, and +settings. `before-export` aliases `after-grouping`, `before-view` aliases +`after-export`, and `before-settings` aliases `after-view`; aliases preserve the +same ordering even when the adjacent built-in control is disabled. + If you want a child table to appear seamlessly without drawing a second "card" border inside the parent, configure the child's `` with `removeOuterBorder: true`. ```tsx diff --git a/packages/table/src/auto/use-tree-adapter.ts b/packages/table/src/auto/use-tree-adapter.ts index 368417d..857fd32 100644 --- a/packages/table/src/auto/use-tree-adapter.ts +++ b/packages/table/src/auto/use-tree-adapter.ts @@ -136,6 +136,9 @@ export function useTreeAdapter>( const cacheRef = useRef>({}); // Per-parent abort controllers so rapid re-expands cancel in-flight fetches. const abortRef = useRef>({}); + // Incremented whenever a cache entry is invalidated so a response that + // ignores AbortSignal cannot repopulate stale data. + const generationRef = useRef>({}); // Bumped whenever the cache mutates. Forces the memoised adapter to be // recreated, which makes useTableData re-run its query effect and re-merge. const [version, setVersion] = useState(0); @@ -148,9 +151,10 @@ export function useTreeAdapter>( // Recursive merge of cached children into the row tree, depth-bounded. const mergeChildren = useCallback( - (node: T, depth: number): T => { + (node: T, depth: number, ancestors: ReadonlySet = new Set()): T => { if (depth >= maxDepth) return node; const id = getRowId(node); + if (ancestors.has(id)) return node; const cache = cacheRef.current; if (!(id in cache)) return node; const cached = cache[id]; @@ -160,9 +164,19 @@ export function useTreeAdapter>( : []; return { ...(node as RowWithChildren), children: placeholder } as T; } + const nextAncestors = new Set(ancestors); + nextAncestors.add(id); + const seen = new Set(); return { ...(node as RowWithChildren), - children: cached.map((child) => mergeChildren(child, depth + 1)), + children: cached + .filter((child) => { + const childId = getRowId(child); + if (seen.has(childId) || nextAncestors.has(childId)) return false; + seen.add(childId); + return true; + }) + .map((child) => mergeChildren(child, depth + 1, nextAncestors)), } as T; }, [getRowId, loadingRow, maxDepth], @@ -185,14 +199,13 @@ export function useTreeAdapter>( "useTreeAdapter: `list.url` or `list.fetch` is required", ); } - const qs = new URLSearchParams({ - page: String(params.page), - pageSize: String(params.pageSize), - }); - if (params.search) qs.set("search", params.search); - if (params.sort) qs.set("sort", params.sort); - if (params.sortOrder) qs.set("sortOrder", params.sortOrder); - const res = await fetch(`${list.url}?${qs.toString()}`, { signal }); + const url = new URL(list.url, globalThis.location?.href ?? "http://localhost"); + url.searchParams.set("page", String(params.page)); + url.searchParams.set("pageSize", String(params.pageSize)); + if (params.search) url.searchParams.set("search", params.search); + if (params.sort) url.searchParams.set("sort", params.sort); + if (params.sortOrder) url.searchParams.set("sortOrder", params.sortOrder); + const res = await fetch(url.toString(), { signal }); if (!res.ok) { throw new Error(`useTreeAdapter list: HTTP ${res.status}`); } @@ -209,9 +222,6 @@ export function useTreeAdapter>( data: result.data.map((row) => mergeChildren(row, 0)), }; }, - async queryByIds() { - return []; - }, }), // mergeChildren closes over cacheRef.current at call time, so version // is what we actually depend on for re-runs. @@ -223,13 +233,25 @@ export function useTreeAdapter>( UseTreeAdapterReturn["treeProps"]["onRowExpand"] >( ({ row, isExpanded }) => { - if (!isExpanded) return; if (isLoadingRow(row)) return; const id = getRowId(row); + if (!isExpanded) { + const active = abortRef.current[id]; + if (active) { + active.abort(); + delete abortRef.current[id]; + delete cacheRef.current[id]; + generationRef.current[id] = (generationRef.current[id] ?? 0) + 1; + bumpVersion(); + } + return; + } // Already loaded or in flight — let the existing fetch resolve. if (cacheRef.current[id] !== undefined) return; const ctrl = new AbortController(); + const generation = (generationRef.current[id] ?? 0) + 1; + generationRef.current[id] = generation; abortRef.current[id]?.abort(); abortRef.current[id] = ctrl; @@ -253,7 +275,7 @@ export function useTreeAdapter>( fetchPromise .then((kids) => { - if (ctrl.signal.aborted) return; + if (ctrl.signal.aborted || generationRef.current[id] !== generation) return; cacheRef.current[id] = kids; bumpVersion(); }) @@ -288,11 +310,16 @@ export function useTreeAdapter>( if (parentId === undefined) { cacheRef.current = {}; for (const ctrl of Object.values(abortRef.current)) ctrl.abort(); + for (const id of Object.keys(generationRef.current)) { + generationRef.current[id] += 1; + } abortRef.current = {}; } else { delete cacheRef.current[parentId]; abortRef.current[parentId]?.abort(); delete abortRef.current[parentId]; + generationRef.current[parentId] = + (generationRef.current[parentId] ?? 0) + 1; } bumpVersion(); }, @@ -301,9 +328,9 @@ export function useTreeAdapter>( // Abort any pending children fetches when the consumer unmounts. useEffect(() => { - const controllers = abortRef.current; return () => { - for (const ctrl of Object.values(controllers)) ctrl.abort(); + for (const ctrl of Object.values(abortRef.current)) ctrl.abort(); + abortRef.current = {}; }; }, []); diff --git a/packages/table/src/data-table.tsx b/packages/table/src/data-table.tsx index 63c3588..5d24d9f 100644 --- a/packages/table/src/data-table.tsx +++ b/packages/table/src/data-table.tsx @@ -161,12 +161,14 @@ export function DataTable>({ /> ), cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-[2px]" - /> + row.getIsGrouped() ? null : ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ) ), enableSorting: false, enableHiding: false, @@ -230,7 +232,7 @@ export function DataTable>({ const actionColumn: ColumnDef = { id: "__actions", header: () => null, - cell: ({ row }) => + cell: ({ row }) => row.getIsGrouped() ? null : actions({ row: row.original, table: tableContextRef.current, @@ -272,12 +274,23 @@ export function DataTable>({ const [grouping, setGrouping] = useState( () => rowGrouping ?? [] ); + const previousGroupingPropRef = useRef(rowGrouping ?? []); // Sync grouping state if rowGrouping prop changes useEffect(() => { - setGrouping(rowGrouping ?? []); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(rowGrouping)]); + const next = rowGrouping ?? []; + const previous = previousGroupingPropRef.current; + if ( + previous.length === next.length && + previous.every((column, index) => column === next[index]) + ) { + return; + } + previousGroupingPropRef.current = [...next]; + setGrouping(next); + setExpanded({}); + prevExpandedRef.current = {}; + }); // ─── Dev warning: manualPagination + rowGrouping conflict ─── // The table internally sets manualPagination:true, so the adapter returns @@ -521,13 +534,11 @@ export function DataTable>({ if (newPagination.pageSize !== pageSize) { setPageSize(newPagination.pageSize); setPage(1); - setRowSelection({}); return; } if (newPagination.pageIndex + 1 !== page) { setPage(newPagination.pageIndex + 1); - setRowSelection({}); } }, [page, pageSize, setPage, setPageSize] @@ -686,7 +697,8 @@ export function DataTable>({ onColumnSizingChange: handleColumnSizingChange, onColumnOrderChange: handleColumnOrderChange, pageCount: meta.totalPages ?? 0, - enableRowSelection: tableConfig.enableRowSelection, + enableRowSelection: (row: Row) => + tableConfig.enableRowSelection && !row.getIsGrouped(), enableColumnResizing: tableConfig.enableColumnResizing, getRowId: (row: T) => String(row[idField]), manualPagination: true, @@ -717,7 +729,8 @@ export function DataTable>({ // every group row which breaks getToggleExpandedHandler() and any // other caller that respects canExpand (keyboard nav, expand-all, etc). if (row.getIsGrouped()) return true; - return getRowCanExpand ? getRowCanExpand(row.original) : !!renderSubRow; + if (getRowCanExpand) return getRowCanExpand(row.original); + return row.subRows.length > 0 || !!renderSubRow; }, }), [ @@ -995,6 +1008,16 @@ export function DataTable>({ getGroupingDepth, ]); + const groupingRefOwner = useRef(groupingRef); + groupingRefOwner.current = groupingRef; + useEffect(() => () => { + const owner = groupingRefOwner.current; + if (owner) { + (owner as React.MutableRefObject) + .current = null; + } + }, []); + const customToolbar = renderToolbar ? renderToolbar(toolbarContext) : toolbarContent; @@ -1200,19 +1223,18 @@ export function DataTable>({ data-state={row.getIsSelected() ? "selected" : undefined} data-group-row={isGroupRow ? "true" : undefined} data-depth={isGroupRow ? String(row.depth) : undefined} - tabIndex={0} + tabIndex={isGroupRow ? -1 : 0} aria-selected={isGroupRow ? undefined : row.getIsSelected()} - aria-expanded={isGroupRow || row.getCanExpand() ? row.getIsExpanded() : undefined} + aria-expanded={undefined} className={cn( "border-b transition-colors", - isGroupRow || row.getCanExpand() + isGroupRow ? "hover:bg-muted/60 cursor-pointer" : "hover:bg-muted/50 data-[state=selected]:bg-muted", - onRowClick && !isGroupRow && !row.getCanExpand() ? "cursor-pointer" : undefined + onRowClick && !isGroupRow ? "cursor-pointer" : undefined )} onClick={(event) => { - if (isGroupRow || row.getCanExpand()) { - row.toggleExpanded(); + if (isGroupRow) { return; } if (tableConfig.enableClickRowSelect) { @@ -1223,7 +1245,7 @@ export function DataTable>({ } }} style={{ - cursor: isGroupRow || row.getCanExpand() ? "pointer" : onRowClick ? "pointer" : undefined, + cursor: isGroupRow || onRowClick ? "pointer" : undefined, }} > {visibleCells.map((cell) => { @@ -1278,10 +1300,16 @@ export function DataTable>({ paddingLeft: `${16 + row.depth * 20}px`, }} > - + ); } @@ -1340,7 +1368,7 @@ export function DataTable>({ } // Tree-expandable rows: first data cell gets chevron + depth indent - if (isFirstDataCell && row.getCanExpand()) { + if (isFirstDataCell && row.getCanExpand() && !renderSubRow) { return ( >({ }} > - + {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/packages/table/src/utils/export-utils.ts b/packages/table/src/utils/export-utils.ts index ba2b7cd..3b9c46c 100644 --- a/packages/table/src/utils/export-utils.ts +++ b/packages/table/src/utils/export-utils.ts @@ -34,6 +34,14 @@ function sanitizeCsvCell(value: string): string { return value; } +/** Quote one sanitized CSV field according to RFC 4180. */ +function escapeCsvField(value: string): string { + const sanitized = sanitizeCsvCell(value); + return /[",\n\r]/.test(sanitized) + ? `"${sanitized.replace(/"/g, '""')}"` + : sanitized; +} + /** * Convert array of objects to CSV string. */ @@ -48,34 +56,16 @@ function convertToCSV( let csvContent = ""; - if (columnMapping) { - const headerRow = headers.map((header) => { - const mappedHeader = columnMapping[header] || header; - return mappedHeader.includes(",") || - mappedHeader.includes('"') || - mappedHeader.includes("\n") || - mappedHeader.includes("\r") - ? `"${mappedHeader.replace(/"/g, '""')}"` - : mappedHeader; - }); - csvContent = `${headerRow.join(",")}\n`; - } else { - csvContent = `${headers.join(",")}\n`; - } + const headerRow = headers.map((header) => + escapeCsvField(columnMapping?.[header] ?? header) + ); + csvContent = `${headerRow.join(",")}\n`; for (const item of data) { const row = headers.map((header) => { const value = item[header]; const rawCell = value === null || value === undefined ? "" : String(value); - const cellValue = sanitizeCsvCell(rawCell); - const escapedValue = - cellValue.includes(",") || - cellValue.includes('"') || - cellValue.includes("\n") || - cellValue.includes("\r") - ? `"${cellValue.replace(/"/g, '""')}"` - : cellValue; - return escapedValue; + return escapeCsvField(rawCell); }); csvContent += `${row.join(",")}\n`; } diff --git a/packages/table/test/export-utils.test.ts b/packages/table/test/export-utils.test.ts index d12aa76..df618fb 100644 --- a/packages/table/test/export-utils.test.ts +++ b/packages/table/test/export-utils.test.ts @@ -272,6 +272,24 @@ describe("exportToCSV — basic", () => { cap.restore(); }); + it("neutralizes formula injection in raw and mapped headers", async () => { + const raw = captureDownload(); + exportToCSV([{ "=raw": "value" }], "raw-header", ["=raw"]); + expect(await raw.blobs[0].text()).toBe("'=raw\nvalue\n"); + + const mapped = captureDownload(); + exportToCSV( + [{ safe: "value", other: "value" }], + "mapped-header", + ["safe", "other"], + { safe: " @mapped", other: "\tformula" }, + ); + const text = await mapped.blobs[0].text(); + expect(text.startsWith("' @mapped,'\tformula\n")).toBe(true); + raw.restore(); + mapped.restore(); + }); + it("preserves tab characters within cell values", async () => { const cap = captureDownload(); const data = [{ id: 1, note: "hello\tworld" }]; diff --git a/packages/table/test/use-tree-adapter.test.tsx b/packages/table/test/use-tree-adapter.test.tsx new file mode 100644 index 0000000..6d1313c --- /dev/null +++ b/packages/table/test/use-tree-adapter.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +// ** import core packages +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// ** import utils +import { useTreeAdapter, type UseTreeAdapterReturn } from "../src/auto/use-tree-adapter"; + +interface NodeRow extends Record { + id: string; + children?: NodeRow[]; +} + +let root: Root | undefined; + +afterEach(() => { + act(() => root?.unmount()); + root = undefined; +}); + +function mountHook( + childrenFetch: (parentId: string, signal: AbortSignal) => Promise, + listUrl = "https://example.test/tree?tenant=acme", +) { + let current: UseTreeAdapterReturn | undefined; + function Harness() { + current = useTreeAdapter({ + list: { url: listUrl }, + children: { fetch: childrenFetch }, + }); + return null; + } + const container = document.createElement("div"); + root = createRoot(container); + act(() => root?.render()); + return () => current!; +} + +describe("useTreeAdapter", () => { + it("preserves existing list query parameters", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ data: [], meta: { total: 0, page: 2, pageSize: 5, totalPages: 0 } })), + ); + const hook = mountHook(async () => []); + + await hook().adapter.query({ + page: 2, + pageSize: 5, + search: "desk", + sort: "name", + sortOrder: "asc", + filters: {}, + dateRange: { from: "", to: "" }, + }); + + const url = new URL(String(fetchSpy.mock.calls[0][0])); + expect(url.searchParams.get("tenant")).toBe("acme"); + expect(url.searchParams.get("page")).toBe("2"); + expect(url.searchParams.get("search")).toBe("desk"); + fetchSpy.mockRestore(); + }); + + it("aborts active child requests on collapse, invalidation, and unmount", () => { + const signals: AbortSignal[] = []; + const hook = mountHook((_id, signal) => { + signals.push(signal); + return new Promise(() => undefined); + }); + const row = { id: "parent" }; + + act(() => hook().treeProps.onRowExpand({ row, rowId: row.id, depth: 0, isExpanded: true })); + act(() => hook().treeProps.onRowExpand({ row, rowId: row.id, depth: 0, isExpanded: false })); + expect(signals[0].aborted).toBe(true); + + act(() => hook().treeProps.onRowExpand({ row, rowId: row.id, depth: 0, isExpanded: true })); + act(() => hook().invalidateChildren(row.id)); + expect(signals[1].aborted).toBe(true); + + act(() => hook().treeProps.onRowExpand({ row, rowId: row.id, depth: 0, isExpanded: true })); + act(() => root?.unmount()); + root = undefined; + expect(signals[2].aborted).toBe(true); + }); + + it("allows retry after a non-abort child error", async () => { + const fetchChildren = vi.fn() + .mockRejectedValueOnce(new Error("temporary")) + .mockResolvedValueOnce([{ id: "child" }]); + const hook = mountHook(fetchChildren); + const row = { id: "parent" }; + + act(() => hook().treeProps.onRowExpand({ row, rowId: row.id, depth: 0, isExpanded: true })); + await act(async () => Promise.resolve()); + act(() => hook().treeProps.onRowExpand({ row, rowId: row.id, depth: 0, isExpanded: true })); + await act(async () => Promise.resolve()); + + expect(fetchChildren).toHaveBeenCalledTimes(2); + }); +}); From 2db6258acca196f8b289630512fa33699a927dc5 Mon Sep 17 00:00:00 2001 From: jacksonnkasi Date: Sat, 11 Jul 2026 22:55:53 +0530 Subject: [PATCH 4/8] fix(table): complete PR regression coverage --- .github/workflows/ci.yml | 23 ++++ apps/hono-example/package.json | 6 +- apps/hono-example/src/routes/manual/retail.ts | 24 +++- apps/sveltekit-example/package.json | 3 +- bun.lock | 1 + docs/api-reference.md | 5 +- package.json | 1 + packages/adapter-elysia/package.json | 2 +- packages/adapter-express/package.json | 2 +- packages/adapter-hono/package.json | 2 +- packages/adapter-next/package.json | 2 +- packages/adapter-sveltekit/package.json | 2 +- packages/client/package.json | 2 +- packages/codegen/package.json | 2 +- packages/engine/package.json | 2 +- packages/plugin-cache/package.json | 2 +- packages/table/package.json | 2 +- packages/table/src/auto/static-adapter.ts | 42 +++++- packages/table/src/auto/use-tree-adapter.ts | 19 ++- packages/table/src/data-table.tsx | 85 +++++++----- packages/table/src/expand-icon.tsx | 5 +- .../test/data-table-regressions.test.tsx | 127 ++++++++++++++++++ packages/table/test/expand-icon.test.tsx | 37 +++++ packages/table/test/static-adapter.test.ts | 19 +++ packages/table/test/use-tree-adapter.test.tsx | 73 ++++++++-- 25 files changed, 420 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 packages/table/test/data-table-regressions.test.tsx create mode 100644 packages/table/test/expand-icon.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..775bc05 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + push: + branches: [main, dev] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun run test + - run: bun run build diff --git a/apps/hono-example/package.json b/apps/hono-example/package.json index 64b812c..0cbeec2 100644 --- a/apps/hono-example/package.json +++ b/apps/hono-example/package.json @@ -8,7 +8,8 @@ "db:push": "drizzle-kit push", "seed": "bun run src/db/seed.ts", "seed:retail": "bun run src/db/seed-retail.ts", - "test": "bun test", + "test": "vitest run --passWithNoTests --exclude 'test/**'", + "test:integration": "bun test test", "typecheck": "bun run --bun tsc --noEmit" }, "dependencies": { @@ -22,6 +23,7 @@ }, "devDependencies": { "drizzle-kit": "^0.31.10", - "bun-types": "latest" + "bun-types": "latest", + "vitest": "^4.1.5" } } diff --git a/apps/hono-example/src/routes/manual/retail.ts b/apps/hono-example/src/routes/manual/retail.ts index 286da45..e40790a 100644 --- a/apps/hono-example/src/routes/manual/retail.ts +++ b/apps/hono-example/src/routes/manual/retail.ts @@ -16,6 +16,14 @@ const SORT = { }; type Dir = 'asc' | 'desc'; +type MixedSortKey = 'name' | 'totalSales' | 'revenue' | 'avgRating'; + +const MIXED_SORT_KEYS = new Set([ + 'name', + 'totalSales', + 'revenue', + 'avgRating', +]); /** Resolve a sort column from a whitelist + direction into a Drizzle ORDER BY clause. */ function order>(map: T, key: string, fallback: T[keyof T], dir: Dir) { @@ -58,7 +66,11 @@ app.get('/tree', async (c) => { // ── Deep search mode ──────────────────────────────────────────────────── if (search) { const term = `%${search}%`; - const safeSortKey = sortKey in SORT.region ? sortKey : 'name'; + // `stores` is region-only and therefore cannot define a consistent mixed + // ordering. Mixed search falls back to name for it and for invalid keys. + const safeSortKey: MixedSortKey = MIXED_SORT_KEYS.has(sortKey as MixedSortKey) + ? sortKey as MixedSortKey + : 'name'; const windowSize = offset + pageSize; const direction = sortDir === 'asc' ? 1 : -1; @@ -158,7 +170,10 @@ app.get('/tree/:id/children', async (c) => { if (region) { const rows = await db.select().from(schema.retailStores) .where(eq(schema.retailStores.regionId, id)) - .orderBy(order(SORT.store, sortKey, schema.retailStores.name, sortDir)); + .orderBy( + order(SORT.store, sortKey, schema.retailStores.name, sortDir), + asc(schema.retailStores.id), + ); return c.json(rows.map(s => ({ ...s, type: 'Store' as const, stores: null, breadcrumb: null, children: undefined }))); } @@ -167,7 +182,10 @@ app.get('/tree/:id/children', async (c) => { if (store) { const rows = await db.select().from(schema.retailProducts) .where(eq(schema.retailProducts.storeId, id)) - .orderBy(order(SORT.product, sortKey, schema.retailProducts.name, sortDir)); + .orderBy( + order(SORT.product, sortKey, schema.retailProducts.name, sortDir), + asc(schema.retailProducts.id), + ); return c.json(rows.map(p => ({ ...p, type: 'Product' as const, stores: null, breadcrumb: null, children: [] }))); } diff --git a/apps/sveltekit-example/package.json b/apps/sveltekit-example/package.json index 7c882c9..25c2b95 100644 --- a/apps/sveltekit-example/package.json +++ b/apps/sveltekit-example/package.json @@ -12,7 +12,8 @@ "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "seed": "bun run src/lib/server/db/seed.ts", - "test": "vitest run" + "test": "vitest run --passWithNoTests --exclude test-api.test.ts", + "test:integration": "vitest run test-api.test.ts" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", diff --git a/bun.lock b/bun.lock index 71897e7..ea6aa5f 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "devDependencies": { "bun-types": "latest", "drizzle-kit": "^0.31.10", + "vitest": "^4.1.5", }, }, "apps/sveltekit-example": { diff --git a/docs/api-reference.md b/docs/api-reference.md index e94b710..c283d67 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -402,7 +402,10 @@ existing query parameters in list URLs, cancels child requests on collapse, invalidation, and unmount, rejects stale responses, and permits retry after an error. `invalidateChildren(id)` clears one branch; calling it without an ID clears every branch. Lazy adapters intentionally omit `queryByIds` because they -cannot guarantee cross-page lookup. +cannot guarantee cross-page lookup. Provide the optional `queryByIds` callback +when selections can span root pages. Without it, attempting to export unloaded +selected IDs fails explicitly instead of exporting fewer rows than the selected +count. Use `rowGrouping` for client-side grouping, `rowGroupingConfig` for aggregates, custom group labels, and initial expansion, and `groupingRef` for imperative diff --git a/package.json b/package.json index af447d3..781b4e6 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "bun run --filter '*' build", "test": "bun run --filter '*' test", + "test:integration": "bun run --filter hono-example test:integration && bun run --filter sveltekit-example test:integration", "typecheck": "bun run --filter '*' typecheck" }, "workspaces": [ diff --git a/packages/adapter-elysia/package.json b/packages/adapter-elysia/package.json index 377808b..0c525dc 100644 --- a/packages/adapter-elysia/package.json +++ b/packages/adapter-elysia/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "bun run --bun tsc", "dev": "bun run --bun tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "bun run --bun tsc --noEmit" }, "peerDependencies": { diff --git a/packages/adapter-express/package.json b/packages/adapter-express/package.json index 72a3004..dbf1199 100644 --- a/packages/adapter-express/package.json +++ b/packages/adapter-express/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "bun run --bun tsc", "dev": "bun run --bun tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "bun run --bun tsc --noEmit" }, "peerDependencies": { diff --git a/packages/adapter-hono/package.json b/packages/adapter-hono/package.json index 63a0dce..7eabbd9 100644 --- a/packages/adapter-hono/package.json +++ b/packages/adapter-hono/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "bun run --bun tsc", "dev": "bun run --bun tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "bun run --bun tsc --noEmit" }, "peerDependencies": { diff --git a/packages/adapter-next/package.json b/packages/adapter-next/package.json index 3d055a6..6ceca9d 100644 --- a/packages/adapter-next/package.json +++ b/packages/adapter-next/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "bun run --bun tsc", "dev": "bun run --bun tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "bun run --bun tsc --noEmit" }, "peerDependencies": { diff --git a/packages/adapter-sveltekit/package.json b/packages/adapter-sveltekit/package.json index c3176df..54fbe4e 100644 --- a/packages/adapter-sveltekit/package.json +++ b/packages/adapter-sveltekit/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "bun run --bun tsc", "dev": "bun run --bun tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "bun run --bun tsc --noEmit" }, "peerDependencies": { diff --git a/packages/client/package.json b/packages/client/package.json index 08f2804..4bf999b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "peerDependencies": { diff --git a/packages/codegen/package.json b/packages/codegen/package.json index 3131f58..aaf2b62 100644 --- a/packages/codegen/package.json +++ b/packages/codegen/package.json @@ -20,7 +20,7 @@ ], "scripts": { "build": "tsup src/index.ts src/cli.ts --format esm --dts", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit", "dev": "tsup src/index.ts src/cli.ts --format esm --dts --watch" }, diff --git a/packages/engine/package.json b/packages/engine/package.json index 2641445..b5f4835 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "bun run --bun tsc", "dev": "bun run --bun tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "bun run --bun tsc --noEmit" }, "dependencies": { diff --git a/packages/plugin-cache/package.json b/packages/plugin-cache/package.json index b41ee46..a80ecfd 100644 --- a/packages/plugin-cache/package.json +++ b/packages/plugin-cache/package.json @@ -27,7 +27,7 @@ "scripts": { "build": "bun run --bun tsc", "dev": "bun run --bun tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "bun run --bun tsc --noEmit" }, "peerDependencies": { diff --git a/packages/table/package.json b/packages/table/package.json index 9a99d28..2c43d2b 100644 --- a/packages/table/package.json +++ b/packages/table/package.json @@ -21,7 +21,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "test": "vitest", + "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "peerDependencies": { diff --git a/packages/table/src/auto/static-adapter.ts b/packages/table/src/auto/static-adapter.ts index 56b7b9d..2402546 100644 --- a/packages/table/src/auto/static-adapter.ts +++ b/packages/table/src/auto/static-adapter.ts @@ -126,12 +126,42 @@ export function createStaticAdapter>( }; }, - async queryByIds(ids: (string | number)[]): Promise { + async queryByIds(ids, lookupOptions): Promise { const idStrings = new Set(ids.map(String)); - return data.filter((row) => { - const id = row.id ?? row.ID ?? row._id; - return id !== undefined && idStrings.has(String(id)); - }); + const matches: T[] = []; + const visited = new Set(); + const matchedIds = new Set(); + const visit = (rows: T[]) => { + for (const row of rows) { + if (visited.has(row)) continue; + visited.add(row); + const id = row.id ?? row.ID ?? row._id; + const normalizedId = id === undefined || id === null ? undefined : String(id); + if (normalizedId && idStrings.has(normalizedId) && !matchedIds.has(normalizedId)) { + matches.push(row); + matchedIds.add(normalizedId); + } + const children = row.children; + if (Array.isArray(children)) visit(children as T[]); + } + }; + visit(data); + if (lookupOptions?.sortBy) { + const sortBy = lookupOptions.sortBy; + const direction = lookupOptions.sortOrder === "desc" ? -1 : 1; + matches.sort((left, right) => { + const leftValue = left[sortBy]; + const rightValue = right[sortBy]; + if (leftValue === rightValue) return 0; + if (leftValue === null || leftValue === undefined) return 1; + if (rightValue === null || rightValue === undefined) return -1; + const comparison = typeof leftValue === "string" && typeof rightValue === "string" + ? leftValue.localeCompare(rightValue) + : leftValue > rightValue ? 1 : -1; + return comparison * direction; + }); + } + return matches; }, }; -} \ No newline at end of file +} diff --git a/packages/table/src/auto/use-tree-adapter.ts b/packages/table/src/auto/use-tree-adapter.ts index 857fd32..a758977 100644 --- a/packages/table/src/auto/use-tree-adapter.ts +++ b/packages/table/src/auto/use-tree-adapter.ts @@ -56,6 +56,8 @@ export interface UseTreeAdapterOptions { children: UseTreeAdapterChildrenSource; /** Returns the row's id. Defaults to `row.id`. */ getRowId?: (row: T) => string; + /** Optional lookup used for cross-page selection and export. */ + queryByIds?: DataAdapter["queryByIds"]; /** * Factory for the placeholder row shown while children are loading. * If omitted, the parent simply renders an empty children array — no @@ -127,8 +129,15 @@ export function useTreeAdapter>( } = options; const getRowId = useMemo( - () => - options.getRowId ?? ((row: T) => (row as unknown as { id: string }).id), + () => options.getRowId ?? ((row: T) => { + const value = (row as { id?: unknown }).id; + if (value === null || value === undefined) { + throw new Error( + "useTreeAdapter: every row must have an id or provide getRowId", + ); + } + return String(value); + }), [options.getRowId], ); @@ -203,8 +212,11 @@ export function useTreeAdapter>( url.searchParams.set("page", String(params.page)); url.searchParams.set("pageSize", String(params.pageSize)); if (params.search) url.searchParams.set("search", params.search); + else url.searchParams.delete("search"); if (params.sort) url.searchParams.set("sort", params.sort); + else url.searchParams.delete("sort"); if (params.sortOrder) url.searchParams.set("sortOrder", params.sortOrder); + else url.searchParams.delete("sortOrder"); const res = await fetch(url.toString(), { signal }); if (!res.ok) { throw new Error(`useTreeAdapter list: HTTP ${res.status}`); @@ -222,11 +234,12 @@ export function useTreeAdapter>( data: result.data.map((row) => mergeChildren(row, 0)), }; }, + queryByIds: options.queryByIds, }), // mergeChildren closes over cacheRef.current at call time, so version // is what we actually depend on for re-runs. // eslint-disable-next-line react-hooks/exhaustive-deps - [version, list.url, list.fetch, mergeChildren], + [version, list.url, list.fetch, mergeChildren, options.queryByIds], ); const onRowExpand = useCallback< diff --git a/packages/table/src/data-table.tsx b/packages/table/src/data-table.tsx index 5d24d9f..3b48812 100644 --- a/packages/table/src/data-table.tsx +++ b/packages/table/src/data-table.tsx @@ -289,7 +289,7 @@ export function DataTable>({ previousGroupingPropRef.current = [...next]; setGrouping(next); setExpanded({}); - prevExpandedRef.current = {}; + prevExpandedIdsRef.current = new Set(); }); // ─── Dev warning: manualPagination + rowGrouping conflict ─── @@ -314,34 +314,34 @@ export function DataTable>({ }, [rowGrouping, tableConfig.enablePagination]); // ─── Fire onRowGroupExpand / onRowExpand callbacks ─── - const prevExpandedRef = useRef({}); + const prevExpandedIdsRef = useRef>(new Set()); useEffect(() => { const wantsGroup = !!onRowGroupExpand && !!rowGrouping?.length; const wantsTree = !!onRowExpand; if ((!wantsGroup && !wantsTree) || !tableRef.current) { - prevExpandedRef.current = expanded; + prevExpandedIdsRef.current = new Set(); return; } - const prev = prevExpandedRef.current; - const curr = expanded; - - // `expanded` (ExpandedState) can be the boolean `true` when all rows are - // expanded via table.toggleAllRowsExpanded(true). Object.keys(true) - // returns [] in modern JS — silently swallowing every expand event and - // leaving prevExpandedRef permanently stuck at `true`, which then - // corrupts the diff on the next individual-row toggle. Skip per-row - // diffing for bulk toggle operations entirely. - if (typeof curr !== "object" || typeof prev !== "object") { - prevExpandedRef.current = expanded; - return; + const tableInstance = tableRef.current; + const candidateRows = rowGrouping?.length + ? tableInstance.getGroupedRowModel().flatRows + : tableInstance.getCoreRowModel().flatRows; + const candidates = new Map(candidateRows.map((row) => [row.id, row])); + const currentIds = new Set(); + if (expanded === true) { + for (const row of candidateRows) { + if (row.getCanExpand()) currentIds.add(row.id); + } + } else { + for (const [rowId, isOpen] of Object.entries(expanded)) { + if (isOpen && candidates.has(rowId)) currentIds.add(rowId); + } } - - const prevRecord = prev as Record; - const currRecord = curr as Record; + const previousIds = prevExpandedIdsRef.current; const fire = (rowId: string, isExpanded: boolean) => { try { - const row = tableRef.current!.getRow(rowId); + const row = candidates.get(rowId) ?? tableInstance.getRow(rowId); if (!row) return; if (row.getIsGrouped()) { if (wantsGroup) { @@ -363,13 +363,13 @@ export function DataTable>({ } catch { /* row may not exist */ } }; - for (const k of Object.keys(currRecord)) { - if (currRecord[k] && !prevRecord[k]) fire(k, true); + for (const rowId of currentIds) { + if (!previousIds.has(rowId)) fire(rowId, true); } - for (const k of Object.keys(prevRecord)) { - if (prevRecord[k] && !currRecord[k]) fire(k, false); + for (const rowId of previousIds) { + if (!currentIds.has(rowId) && candidates.has(rowId)) fire(rowId, false); } - prevExpandedRef.current = curr; + prevExpandedIdsRef.current = currentIds; // eslint-disable-next-line react-hooks/exhaustive-deps }, [expanded]); @@ -479,18 +479,25 @@ export function DataTable>({ const idsOnPage = new Set(itemsOnPage.map((item) => String(item[idField]))); const idsToFetch = [...selectedIds].filter((id) => !idsOnPage.has(id)); - // Tree mode: all items are already in the flattened data — no server call needed. - if (getSubRows || idsToFetch.length === 0 || !adapter.queryByIds) { + if (idsToFetch.length === 0) { return itemsOnPage; } - try { - // Cross-page export: fetch ALL selected IDs sorted via the backend. - const fetchedAllSorted = await adapter.queryByIds([...selectedIds], { sortBy, sortOrder }); - return fetchedAllSorted; - } catch { - return itemsOnPage; + if (!adapter.queryByIds) { + throw new Error( + `Cannot export ${selectedIds.size} selected rows: ${idsToFetch.length} ` + + "rows are not loaded and this adapter does not support queryByIds", + ); + } + + // Cross-page export: fetch ALL selected IDs sorted via the backend. + const fetchedAllSorted = await adapter.queryByIds([...selectedIds], { sortBy, sortOrder }); + if (fetchedAllSorted.length !== selectedIds.size) { + throw new Error( + `Adapter returned ${fetchedAllSorted.length} of ${selectedIds.size} selected rows`, + ); } + return fetchedAllSorted; }, [data, rowSelection, totalSelectedItems, adapter, idField, sortBy, sortOrder, flattenTree, getSubRows]); const getAllItems = useCallback((): T[] => flattenTree(data), [data, flattenTree]); @@ -979,7 +986,17 @@ export function DataTable>({ // (each helper is a useCallback that updates when its own deps change, so // this effect runs only when the closure's behaviour actually changes — // not on every render, which previously nulled the ref between renders). + const publishedGroupingRef = useRef(groupingRef); useEffect(() => { + if (publishedGroupingRef.current !== groupingRef) { + const previous = publishedGroupingRef.current; + if (previous) { + (previous as React.MutableRefObject< + import("./types").TableGroupingAPI | null + >).current = null; + } + publishedGroupingRef.current = groupingRef; + } if (!groupingRef) return; const handle: import("./types").TableGroupingAPI = { expandAll: () => table.toggleAllRowsExpanded(true), @@ -1008,10 +1025,8 @@ export function DataTable>({ getGroupingDepth, ]); - const groupingRefOwner = useRef(groupingRef); - groupingRefOwner.current = groupingRef; useEffect(() => () => { - const owner = groupingRefOwner.current; + const owner = publishedGroupingRef.current; if (owner) { (owner as React.MutableRefObject) .current = null; diff --git a/packages/table/src/expand-icon.tsx b/packages/table/src/expand-icon.tsx index 6ad52a2..0b97376 100644 --- a/packages/table/src/expand-icon.tsx +++ b/packages/table/src/expand-icon.tsx @@ -23,7 +23,10 @@ export function ExpandIcon({ return ( + Link + + Plain text +
+ ), + }, + { accessorKey: "group", header: "Group" }, + ]; + const container = await renderTable( + , + }} + config={{ + enableClickRowSelect: true, + enablePagination: false, + enableToolbar: false, + }} + />, + ); + const dataRow = container.querySelector("tbody tr") as HTMLTableRowElement; + for (const target of container.querySelectorAll("[data-interactive]")) { + act(() => (target as HTMLElement).click()); + expect(dataRow.dataset.state).toBeUndefined(); + expect(onRowClick).not.toHaveBeenCalled(); + } + + const plainText = [...container.querySelectorAll("span")] + .find((element) => element.textContent === "Plain text") as HTMLElement; + act(() => plainText.click()); + expect(dataRow.dataset.state).toBe("selected"); + expect(onRowClick).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/table/test/export-utils.test.ts b/packages/table/test/export-utils.test.ts index df618fb..b407ece 100644 --- a/packages/table/test/export-utils.test.ts +++ b/packages/table/test/export-utils.test.ts @@ -19,17 +19,13 @@ function captureDownload() { const filenames: string[] = []; const origCreate = document.createElement.bind(document); - const createSpy = vi - .spyOn(document, "createElement") - .mockImplementation((tag: string) => { - if (tag === "a") { - const a = origCreate("a"); - // Override click to prevent actual navigation - a.click = vi.fn(); - return a; - } - return origCreate(tag); - }); + document.createElement = ((tag: string, options?: ElementCreationOptions) => { + const element = origCreate(tag, options); + if (tag === "a") { + (element as HTMLAnchorElement).click = vi.fn(); + } + return element; + }) as typeof document.createElement; const origCreateObjectURL = URL.createObjectURL; URL.createObjectURL = (blob: Blob) => { @@ -61,7 +57,7 @@ function captureDownload() { blobs, filenames, restore() { - createSpy.mockRestore(); + document.createElement = origCreate; URL.createObjectURL = origCreateObjectURL; URL.revokeObjectURL = origRevokeObjectURL; document.body.appendChild = origAppendChild; diff --git a/packages/table/test/static-adapter.test.ts b/packages/table/test/static-adapter.test.ts index 21ca40d..e0fd56a 100644 --- a/packages/table/test/static-adapter.test.ts +++ b/packages/table/test/static-adapter.test.ts @@ -94,6 +94,31 @@ describe("createStaticAdapter — nested ID lookup", () => { }); expect(result?.map((row) => row.id)).toEqual([2, 1]); }); + + it("supports consumer-defined IDs and child containers", async () => { + interface CustomTreeRow extends Record { + uuid: string; + label: string; + nodes?: CustomTreeRow[]; + } + const nested: CustomTreeRow = { uuid: "child-2", label: "B" }; + const root: CustomTreeRow = { + uuid: "root-1", + label: "A", + nodes: [nested, nested], + }; + nested.nodes = [root]; + const adapter = createStaticAdapter([root], { + getRowId: (row) => row.uuid, + getSubRows: (row) => row.nodes, + }); + + const result = await adapter.queryByIds?.(["child-2", "root-1"], { + sortBy: "label", + sortOrder: "desc", + }); + expect(result?.map((row) => row.uuid)).toEqual(["child-2", "root-1"]); + }); }); describe("createStaticAdapter — pagination", () => { diff --git a/packages/table/test/use-tree-adapter.test.tsx b/packages/table/test/use-tree-adapter.test.tsx index e61a57f..5e44097 100644 --- a/packages/table/test/use-tree-adapter.test.tsx +++ b/packages/table/test/use-tree-adapter.test.tsx @@ -155,4 +155,51 @@ describe("useTreeAdapter", () => { })); expect(fetchChildren).toHaveBeenCalledWith("42", expect.any(AbortSignal)); }); + + it("remaps child cache updates without refetching the root list", async () => { + let resolveChildren: ((rows: NodeRow[]) => void) | undefined; + const listFetch = vi.fn(async () => ({ + data: [{ id: "root" }], + meta: { total: 1, page: 1, pageSize: 10, totalPages: 1 }, + })); + const childrenFetch = vi.fn(() => new Promise((resolve) => { + resolveChildren = resolve; + })); + let current: UseTreeAdapterReturn | undefined; + function Harness() { + current = useTreeAdapter({ + list: { fetch: listFetch }, + children: { fetch: childrenFetch }, + }); + return null; + } + const container = document.createElement("div"); + root = createRoot(container); + act(() => root?.render()); + const params = { + page: 1, pageSize: 10, search: "", sort: "", sortOrder: "asc" as const, + filters: {}, dateRange: { from: "", to: "" }, + }; + let rendered = await current!.adapter.query(params); + const refresh = vi.fn(async () => { + rendered = await current!.adapter.query(params); + }); + const unsubscribe = current!.adapter.subscribe?.(refresh); + + act(() => current!.treeProps.onRowExpand({ + row: { id: "root" }, rowId: "root", depth: 0, isExpanded: true, + })); + expect(childrenFetch).toHaveBeenCalledOnce(); + expect(listFetch).toHaveBeenCalledOnce(); + + await act(async () => resolveChildren?.([{ id: "child" }])); + await vi.waitFor(() => { + expect(rendered.data[0].children).toEqual([{ id: "child" }]); + }); + expect(listFetch).toHaveBeenCalledOnce(); + + await current!.adapter.query({ ...params, page: 2, search: "new", sort: "name" }); + expect(listFetch).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); }); From 60d4c70cb4d185c1b5eb4f981e663c1de4d0d10e Mon Sep 17 00:00:00 2001 From: jacksonnkasi Date: Sun, 12 Jul 2026 09:13:19 +0530 Subject: [PATCH 7/8] fix(table): invalidate lazy tree sources safely --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 5 +- .../test/retail.integration.test.ts | 7 +- bun.lock | 2 +- docs/api-reference.md | 6 +- docs/packages.md | 4 +- packages/adapter-next/README.md | 7 +- packages/adapter-next/package.json | 2 +- packages/table/src/auto/use-tree-adapter.ts | 60 ++++++++- packages/table/test/use-tree-adapter.test.tsx | 126 +++++++++++++++++- 10 files changed, 204 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1859b6..a58d0b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: DATABASE_URL: postgres://tablecraft:tablecraft@localhost:5432/tablecraft_test steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8872232..0a42aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- **Security:** `@tablecraft/adapter-next` now requires Next.js 15.5.16+ or - 16.2.5+, excluding releases affected by GHSA-gx5p-jg67-6x7h. +- **Security:** `@tablecraft/adapter-next` now requires Next.js 15.5.18+ or + 16.2.6+, excluding releases affected by GHSA-gx5p-jg67-6x7h and + GHSA-26hh-7cqf-hhc6. ## [0.1.1] - 2026-04-30 diff --git a/apps/hono-example/test/retail.integration.test.ts b/apps/hono-example/test/retail.integration.test.ts index 949a0ac..137d6ae 100644 --- a/apps/hono-example/test/retail.integration.test.ts +++ b/apps/hono-example/test/retail.integration.test.ts @@ -18,7 +18,12 @@ interface RetailRow { interface RetailResponse { data: RetailRow[]; - meta: { total: number; totalPages: number }; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + }; } async function request(path: string): Promise { diff --git a/bun.lock b/bun.lock index beae839..c7bdbcd 100644 --- a/bun.lock +++ b/bun.lock @@ -177,7 +177,7 @@ }, "peerDependencies": { "@tablecraft/engine": ">=0.1.0 <1", - "next": ">=15.5.16 <16 || >=16.2.5", + "next": ">=15.5.18 <16 || >=16.2.6", }, }, "packages/adapter-sveltekit": { diff --git a/docs/api-reference.md b/docs/api-reference.md index aa8ba2d..ac108e2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -406,7 +406,11 @@ list. Lazy adapters omit `queryByIds` by default because they cannot guarantee cross-page lookup. Provide the optional `queryByIds` callback when selections can span root pages. Without it, attempting to export unloaded selected IDs fails explicitly instead of exporting fewer rows than the selected -count. +count. When `list.fetch` captures tenant, account, or authentication context, +pass a matching stable `sourceKey` and change it with that context. A source-key +change aborts the old root request, clears root and child caches, and performs +one fresh root query. URL-backed lists use `list.url` as their default source +identity. Use `rowGrouping` for client-side grouping, `rowGroupingConfig` for aggregates, custom group labels, and initial expansion, and `groupingRef` for imperative diff --git a/docs/packages.md b/docs/packages.md index d72037b..3755b8c 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -1,8 +1,8 @@ # Packages Overview > **Next.js compatibility:** `@tablecraft/adapter-next` requires Next.js -> `>=15.5.16 <16` or `>=16.2.5`. Next.js 14 and earlier Next.js 15 releases -> are excluded due to GHSA-gx5p-jg67-6x7h. +> `>=15.5.18 <16` or `>=16.2.6`. Earlier releases are excluded due to +> GHSA-gx5p-jg67-6x7h and GHSA-26hh-7cqf-hhc6. TableCraft is a monorepo with multiple packages for different use cases. diff --git a/packages/adapter-next/README.md b/packages/adapter-next/README.md index 79d77c7..bd58aad 100644 --- a/packages/adapter-next/README.md +++ b/packages/adapter-next/README.md @@ -4,9 +4,10 @@ Next.js App Router adapter for TableCraft — build powerful data APIs in your N ## Next.js compatibility -Supported Next.js versions are `>=15.5.16 <16` and `>=16.2.5`. Older -releases are intentionally rejected because Next.js 13.0.0 through 15.5.15 -are affected by [GHSA-gx5p-jg67-6x7h](https://github.com/advisories/GHSA-gx5p-jg67-6x7h). +Supported Next.js versions are `>=15.5.18 <16` and `>=16.2.6`. Older +releases are intentionally rejected because they include versions affected by +[GHSA-gx5p-jg67-6x7h](https://github.com/advisories/GHSA-gx5p-jg67-6x7h) +and [GHSA-26hh-7cqf-hhc6](https://github.com/advisories/GHSA-26hh-7cqf-hhc6). ## Links diff --git a/packages/adapter-next/package.json b/packages/adapter-next/package.json index 5e0c08a..2550213 100644 --- a/packages/adapter-next/package.json +++ b/packages/adapter-next/package.json @@ -24,7 +24,7 @@ }, "peerDependencies": { "@tablecraft/engine": ">=0.1.0 <1", - "next": ">=15.5.16 <16 || >=16.2.5" + "next": ">=15.5.18 <16 || >=16.2.6" }, "devDependencies": { "@tablecraft/engine": "workspace:*", diff --git a/packages/table/src/auto/use-tree-adapter.ts b/packages/table/src/auto/use-tree-adapter.ts index 14e0827..3c4e365 100644 --- a/packages/table/src/auto/use-tree-adapter.ts +++ b/packages/table/src/auto/use-tree-adapter.ts @@ -54,6 +54,12 @@ export interface UseTreeAdapterChildrenSource { export interface UseTreeAdapterOptions { list: UseTreeAdapterListSource; children: UseTreeAdapterChildrenSource; + /** + * Stable identity for the root data source (for example a tenant or account + * ID). Change it whenever an inline `list.fetch` starts targeting a different + * source. URL-based lists automatically use `list.url` when this is omitted. + */ + sourceKey?: string | number; /** Returns the row's id. Defaults to `row.id`. */ getRowId?: (row: T) => string; /** Optional lookup used for cross-page selection and export. */ @@ -144,6 +150,7 @@ export function useTreeAdapter>( // ignores AbortSignal cannot repopulate stale data. const generationRef = useRef>({}); const subscribersRef = useRef(new Set<() => void>()); + const activeRootControllerRef = useRef(null); const rawResultRef = useRef<{ key: string; result: QueryResult; @@ -155,6 +162,8 @@ export function useTreeAdapter>( setVersion((value) => value + 1); for (const listener of subscribersRef.current) listener(); }, []); + const effectiveSourceKey = options.sourceKey ?? options.list.url ?? "__default__"; + const sourceKeyRef = useRef(effectiveSourceKey); const isLoadingRow = useCallback( (row: T) => isTreeLoadingRow(row, getRowId), @@ -205,10 +214,21 @@ export function useTreeAdapter>( // response can be cancelled by the table's own AbortController // (param change, unmount). Fall back to a never-aborted signal // so adapters that pass it straight through still typecheck. - const signal = options?.signal ?? new AbortController().signal; + activeRootControllerRef.current?.abort(); + const rootController = new AbortController(); + activeRootControllerRef.current = rootController; + const upstreamSignal = options?.signal; + const abortFromUpstream = () => rootController.abort(); + upstreamSignal?.addEventListener("abort", abortFromUpstream, { once: true }); + if (upstreamSignal?.aborted) rootController.abort(); + const signal = rootController.signal; const key = JSON.stringify(params); const cached = rawResultRef.current; if (cached?.key === key) { + upstreamSignal?.removeEventListener("abort", abortFromUpstream); + if (activeRootControllerRef.current === rootController) { + activeRootControllerRef.current = null; + } return { ...cached.result, data: cached.result.data.map((row) => mergeChildrenRef.current(row, 0)), @@ -246,6 +266,10 @@ export function useTreeAdapter>( throw Object.assign(new Error("Aborted"), { name: "AbortError" }); } rawResultRef.current = { key, result }; + upstreamSignal?.removeEventListener("abort", abortFromUpstream); + if (activeRootControllerRef.current === rootController) { + activeRootControllerRef.current = null; + } return { ...result, data: result.data.map((row) => mergeChildrenRef.current(row, 0)), @@ -256,10 +280,21 @@ export function useTreeAdapter>( return () => subscribersRef.current.delete(listener); }, }; - if (optionsRef.current.queryByIds) { - stableAdapter.queryByIds = (ids, lookupOptions) => - optionsRef.current.queryByIds!(ids, lookupOptions); - } + const queryByIds = (ids: (string | number)[], lookupOptions?: { + sortBy?: string; + sortOrder?: "asc" | "desc"; + }) => { + const lookup = optionsRef.current.queryByIds; + if (!lookup) { + throw new Error("useTreeAdapter: queryByIds is not supported by this source"); + } + return lookup(ids, lookupOptions); + }; + Object.defineProperty(stableAdapter, "queryByIds", { + configurable: false, + enumerable: true, + get: () => optionsRef.current.queryByIds ? queryByIds : undefined, + }); return stableAdapter; }, [], @@ -363,10 +398,25 @@ export function useTreeAdapter>( [bumpVersion], ); + useEffect(() => { + if (sourceKeyRef.current === effectiveSourceKey) return; + sourceKeyRef.current = effectiveSourceKey; + activeRootControllerRef.current?.abort(); + activeRootControllerRef.current = null; + rawResultRef.current = null; + cacheRef.current = {}; + generationRef.current = {}; + for (const controller of Object.values(abortRef.current)) controller.abort(); + abortRef.current = {}; + bumpVersion(); + }, [bumpVersion, effectiveSourceKey]); + // Abort any pending children fetches when the consumer unmounts. useEffect(() => { return () => { for (const ctrl of Object.values(abortRef.current)) ctrl.abort(); + activeRootControllerRef.current?.abort(); + activeRootControllerRef.current = null; abortRef.current = {}; }; }, []); diff --git a/packages/table/test/use-tree-adapter.test.tsx b/packages/table/test/use-tree-adapter.test.tsx index 5e44097..24a2b7d 100644 --- a/packages/table/test/use-tree-adapter.test.tsx +++ b/packages/table/test/use-tree-adapter.test.tsx @@ -6,7 +6,11 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; // ** import utils -import { useTreeAdapter, type UseTreeAdapterReturn } from "../src/auto/use-tree-adapter"; +import { + useTreeAdapter, + type UseTreeAdapterOptions, + type UseTreeAdapterReturn, +} from "../src/auto/use-tree-adapter"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -202,4 +206,124 @@ describe("useTreeAdapter", () => { expect(listFetch).toHaveBeenCalledTimes(2); unsubscribe?.(); }); + + it("refetches once and clears old data when sourceKey changes", async () => { + let currentTenant = "tenant-a"; + const listFetch = vi.fn(async (_params: unknown, _signal: AbortSignal) => ({ + data: [{ id: currentTenant }], + meta: { total: 1, page: 1, pageSize: 10, totalPages: 1 }, + })); + let setTenant: React.Dispatch> | undefined; + let current: UseTreeAdapterReturn | undefined; + const params = { + page: 1, pageSize: 10, search: "", sort: "", sortOrder: "asc" as const, + filters: {}, dateRange: { from: "", to: "" }, + }; + let rendered: NodeRow[] = []; + function Harness() { + const [tenant, updateTenant] = React.useState("tenant-a"); + currentTenant = tenant; + setTenant = updateTenant; + current = useTreeAdapter({ + sourceKey: tenant, + list: { fetch: listFetch }, + children: { fetch: async () => [] }, + }); + return null; + } + const container = document.createElement("div"); + root = createRoot(container); + act(() => root?.render()); + const adapter = current!.adapter; + rendered = (await adapter.query(params)).data; + const unsubscribe = adapter.subscribe?.(() => { + void adapter.query(params).then((result) => { rendered = result.data; }); + }); + + act(() => setTenant?.("tenant-b")); + await vi.waitFor(() => expect(rendered).toEqual([{ id: "tenant-b" }])); + expect(current!.adapter).toBe(adapter); + expect(listFetch).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); + + it("aborts an in-flight root request when sourceKey changes", async () => { + const signals: AbortSignal[] = []; + let tenant = "tenant-a"; + let setTenant: React.Dispatch> | undefined; + let current: UseTreeAdapterReturn | undefined; + const listFetch = vi.fn((_params: unknown, signal: AbortSignal) => { + signals.push(signal); + if (tenant === "tenant-b") { + return Promise.resolve({ + data: [{ id: "tenant-b" }], + meta: { total: 1, page: 1, pageSize: 10, totalPages: 1 }, + }); + } + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject( + Object.assign(new Error("Aborted"), { name: "AbortError" }), + ), { once: true }); + }); + }); + function Harness() { + const [sourceKey, updateTenant] = React.useState("tenant-a"); + tenant = sourceKey; + setTenant = updateTenant; + current = useTreeAdapter({ + sourceKey, + list: { fetch: listFetch }, + children: { fetch: async () => [] }, + }); + return null; + } + const container = document.createElement("div"); + root = createRoot(container); + act(() => root?.render()); + const params = { + page: 1, pageSize: 10, search: "", sort: "", sortOrder: "asc" as const, + filters: {}, dateRange: { from: "", to: "" }, + }; + const adapter = current!.adapter; + void adapter.query(params).catch(() => undefined); + const refresh = vi.fn(() => adapter.query(params)); + const unsubscribe = adapter.subscribe?.(() => { void refresh(); }); + + act(() => setTenant?.("tenant-b")); + await vi.waitFor(() => expect(refresh).toHaveBeenCalledOnce()); + expect(signals[0].aborted).toBe(true); + expect(listFetch).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); + + it("reconciles queryByIds when the capability is added or removed", async () => { + type Lookup = NonNullable["queryByIds"]>; + let setLookup: React.Dispatch> | undefined; + let current: UseTreeAdapterReturn | undefined; + function Harness() { + const [lookup, updateLookup] = React.useState(); + setLookup = updateLookup; + current = useTreeAdapter({ + sourceKey: "stable", + list: { fetch: async () => ({ + data: [], meta: { total: 0, page: 1, pageSize: 10, totalPages: 0 }, + }) }, + children: { fetch: async () => [] }, + queryByIds: lookup, + }); + return null; + } + const container = document.createElement("div"); + root = createRoot(container); + act(() => root?.render()); + const adapter = current!.adapter; + expect(adapter.queryByIds).toBeUndefined(); + + const lookup = vi.fn(async () => [{ id: "row" }]); + act(() => setLookup?.(() => lookup)); + expect(await adapter.queryByIds?.(["row"])).toEqual([{ id: "row" }]); + + act(() => setLookup?.(undefined)); + expect(adapter.queryByIds).toBeUndefined(); + }); }); From 1aa6317ef89c85a15deb56055c6c59d52d6d7892 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 10:01:57 +0530 Subject: [PATCH 8/8] chore(release): prepare TableCraft package release - Bump @tablecraft/table to 0.2.27 (grouping, tree adapters, exports) - Bump @tablecraft/adapter-next to 0.1.7 (Next.js peer dep floors) - Add 0.2.27 release entry to CHANGELOG.md covering PR #38 Co-Authored-By: Claude --- CHANGELOG.md | 49 ++++++++++++++++++++++++++++-- bun.lock | 4 +-- packages/adapter-next/package.json | 2 +- packages/table/package.json | 2 +- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a42aef..e5699c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- **Security:** `@tablecraft/adapter-next` now requires Next.js 15.5.18+ or - 16.2.6+, excluding releases affected by GHSA-gx5p-jg67-6x7h and - GHSA-26hh-7cqf-hhc6. +## [0.2.27] - 2026-07-12 + +### Added + +- Row grouping with controlled and imperative expansion APIs. +- Static and lazy tree adapters backed by `useTreeAdapter` and `useStaticAdapter`. +- Lazy-tree `sourceKey` lifecycle handling for safe source-switch refetches. +- Cross-page selection and export support across paginated views. +- Custom static-tree ID and child resolvers for arbitrary client hierarchies. +- Toolbar placement APIs (`toolbar` and `toolbarPlacement` options). +- Retail hierarchy demonstration and PostgreSQL integration coverage for the + end-to-end Hono example. + +### Changed + +- Stable lazy-tree adapter identity without unnecessary root refetches on + re-render. +- Deterministic retail sorting and pagination in the integration demo. +- Exact selected-ID validation when persisting tree selection state. +- Improved grouping callback consistency for controlled and uncontrolled + group expansion. +- Patched Next.js peer dependency floors to `>=15.5.18 <16` or `>=16.2.6`. + +### Fixed + +- Interactive cell controls no longer select or click rows on click or focus. +- Group rows are excluded from selection and row-action handlers. +- Stale tree requests and source-switch data are safely cancelled on unmount + and re-source. +- CSV formula injection protection by prefixing dangerous cells with a tab. +- CSV download test harness behavior aligned with the production export flow. +- Tree URL query parameter cleanup when a tree adapter unmounts. +- Duplicate and cyclic tree handling in the static adapter. + +### Security + +- `@tablecraft/adapter-next` now requires Next.js `>=15.5.18 <16` or + `>=16.2.6`, excluding releases affected by + [GHSA-gx5p-jg67-6x7h](https://github.com/advisories/GHSA-gx5p-jg67-6x7h) and + [GHSA-26hh-7cqf-hhc6](https://github.com/advisories/GHSA-26hh-7cqf-hhc6). +- Prevented unsafe CSV formula execution by neutralizing `=`, `+`, `-`, `@`, + tab, and carriage-return prefixes. +- Disabled persisted checkout credentials in the CI checkout step. + +See [#38](https://github.com/jacksonkasi1/TableCraft/pull/38) for the full +release pull request. ## [0.1.1] - 2026-04-30 diff --git a/bun.lock b/bun.lock index c7bdbcd..a305473 100644 --- a/bun.lock +++ b/bun.lock @@ -168,7 +168,7 @@ }, "packages/adapter-next": { "name": "@tablecraft/adapter-next", - "version": "0.1.6", + "version": "0.1.7", "devDependencies": { "@tablecraft/engine": "workspace:*", "@types/node": "^25.2.2", @@ -262,7 +262,7 @@ }, "packages/table": { "name": "@tablecraft/table", - "version": "0.2.26", + "version": "0.2.27", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", diff --git a/packages/adapter-next/package.json b/packages/adapter-next/package.json index 2550213..c5ff117 100644 --- a/packages/adapter-next/package.json +++ b/packages/adapter-next/package.json @@ -1,6 +1,6 @@ { "name": "@tablecraft/adapter-next", - "version": "0.1.6", + "version": "0.1.7", "description": "Next.js App Router adapter for TableCraft", "type": "module", "main": "dist/index.js", diff --git a/packages/table/package.json b/packages/table/package.json index 2c43d2b..63390e4 100644 --- a/packages/table/package.json +++ b/packages/table/package.json @@ -1,6 +1,6 @@ { "name": "@tablecraft/table", - "version": "0.2.26", + "version": "0.2.27", "description": "Schema-driven data table for React — built on TanStack Table + Shadcn UI with native TableCraft engine support", "type": "module", "main": "dist/index.js",