gbs-add-block 1.2.7 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +3 -3
  2. package/index.cjs +9 -9
  3. package/package.json +1 -1
  4. package/source/components/datagridbeta/README.md +232 -0
  5. package/source/components/datagridbeta/__tests__/core.test.ts +341 -0
  6. package/source/components/datagridbeta/__tests__/export.test.ts +70 -0
  7. package/source/components/datagridbeta/core/columnHelper.ts +21 -0
  8. package/source/components/datagridbeta/core/columns.ts +288 -0
  9. package/source/components/datagridbeta/core/filtering.ts +237 -0
  10. package/source/components/datagridbeta/core/grid.ts +616 -0
  11. package/source/components/datagridbeta/core/index.ts +14 -0
  12. package/source/components/datagridbeta/core/rows.ts +64 -0
  13. package/source/components/datagridbeta/core/sorting.ts +111 -0
  14. package/source/components/datagridbeta/core/state.ts +82 -0
  15. package/source/components/datagridbeta/core/store.ts +52 -0
  16. package/source/components/datagridbeta/core/types.ts +266 -0
  17. package/source/components/datagridbeta/core/values.ts +98 -0
  18. package/source/components/datagridbeta/core/virtual.ts +85 -0
  19. package/source/components/datagridbeta/export/csv.ts +32 -0
  20. package/source/components/datagridbeta/export/download.ts +12 -0
  21. package/source/components/datagridbeta/export/pdf.ts +61 -0
  22. package/source/components/datagridbeta/export/table.ts +73 -0
  23. package/source/components/datagridbeta/export/xlsx.ts +145 -0
  24. package/source/components/datagridbeta/export/zip.ts +87 -0
  25. package/source/components/datagridbeta/index.ts +7 -0
  26. package/source/components/datagridbeta/react/Cell.tsx +282 -0
  27. package/source/components/datagridbeta/react/ColumnMenu.tsx +135 -0
  28. package/source/components/datagridbeta/react/DataGrid.tsx +296 -0
  29. package/source/components/datagridbeta/react/FilterForm.tsx +142 -0
  30. package/source/components/datagridbeta/react/HeaderRow.tsx +306 -0
  31. package/source/components/datagridbeta/react/Pagination.tsx +107 -0
  32. package/source/components/datagridbeta/react/Popover.tsx +79 -0
  33. package/source/components/datagridbeta/react/Row.tsx +85 -0
  34. package/source/components/datagridbeta/react/Toolbar.tsx +266 -0
  35. package/source/components/datagridbeta/react/Viewport.tsx +167 -0
  36. package/source/components/datagridbeta/react/context.ts +57 -0
  37. package/source/components/datagridbeta/react/hooks.ts +80 -0
  38. package/source/components/datagridbeta/react/icons.tsx +72 -0
  39. package/source/components/datagridbeta/react/keyboard.ts +110 -0
  40. package/source/components/datagridbeta/react/locale.ts +128 -0
  41. package/source/components/datagridbeta/styles.css +717 -0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # GBS Building Blocks 2.0 (v1.2.7)
1
+ # GBS Building Blocks 2.0 (v1.2.8)
2
2
 
3
3
  Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
4
4
 
@@ -6,9 +6,9 @@ Latest and upgraded version of GBS building blocks with headless UI and removed
6
6
 
7
7
  For detailed documentation on usage and props, Please visit: [Building Block Documentation v2.0](https://gramprokit.vercel.app)
8
8
 
9
- ## What's New 🎉 (Ver 1.2.7)
9
+ ## What's New 🎉 (Ver 1.2.8)
10
10
 
11
- - UI Update (Canditate update for next major change 2.0.0)
11
+ - Canditate update for next major change 2.0.0
12
12
 
13
13
  ## Authors
14
14
 
package/index.cjs CHANGED
@@ -82,7 +82,7 @@ const copyComponent = async (component, destPath) => {
82
82
  component === "Grid"
83
83
  ? "This Version of Grid will be deprecated soon. Please Install The New Data Grid Component"
84
84
  : ""
85
- }`
85
+ }`,
86
86
  );
87
87
  } catch (error) {
88
88
  console.error(`Error installing component ${component}:`, error.message);
@@ -97,12 +97,12 @@ const installComponentWithDependencies = async (component, destPath) => {
97
97
 
98
98
  // Check which components need to be installed
99
99
  const pendingInstalls = Array.from(componentsToInstall).filter(
100
- (comp) => !checkComponentExists(comp, destPath)
100
+ (comp) => !checkComponentExists(comp, destPath),
101
101
  );
102
102
 
103
103
  if (pendingInstalls.length === 0) {
104
104
  console.log(
105
- `✓ ${component} and all its dependencies are already installed.`
105
+ `✓ ${component} and all its dependencies are already installed.`,
106
106
  );
107
107
  return;
108
108
  }
@@ -134,12 +134,12 @@ const installMultipleComponents = async (components, destPath) => {
134
134
 
135
135
  // Filter out already installed components
136
136
  const pendingInstalls = Array.from(allComponentsToInstall).filter(
137
- (comp) => !checkComponentExists(comp, destPath)
137
+ (comp) => !checkComponentExists(comp, destPath),
138
138
  );
139
139
 
140
140
  if (pendingInstalls.length === 0) {
141
141
  console.log(
142
- "✓ All selected components and their dependencies are already installed."
142
+ "✓ All selected components and their dependencies are already installed.",
143
143
  );
144
144
  return;
145
145
  }
@@ -182,7 +182,7 @@ const interactiveComponentSelector = async () => {
182
182
  console.clear();
183
183
  console.log("🚀 Component Installer - Interactive Mode");
184
184
  console.log(
185
- "Use ↑/↓ arrow keys to navigate, SPACE to select/deselect, ENTER to install\n"
185
+ "Use ↑/↓ arrow keys to navigate, SPACE to select/deselect, ENTER to install\n",
186
186
  );
187
187
 
188
188
  CONFIG.components.forEach((component, index) => {
@@ -216,7 +216,7 @@ const interactiveComponentSelector = async () => {
216
216
  case "\u001b[B": // Down arrow
217
217
  currentIndex = Math.min(
218
218
  CONFIG.components.length - 1,
219
- currentIndex + 1
219
+ currentIndex + 1,
220
220
  );
221
221
  renderMenu();
222
222
  break;
@@ -266,7 +266,7 @@ const parseMultipleComponents = (componentString) => {
266
266
 
267
267
  const validateComponents = (components) => {
268
268
  const invalidComponents = components.filter(
269
- (comp) => !CONFIG.components.includes(comp)
269
+ (comp) => !CONFIG.components.includes(comp),
270
270
  );
271
271
  if (invalidComponents.length > 0) {
272
272
  console.error(`Invalid components: ${invalidComponents.join(", ")}`);
@@ -338,7 +338,7 @@ const main = async () => {
338
338
 
339
339
  if (!argv.add) {
340
340
  console.error(
341
- "Please specify a component to install using -a/--add, use -i/--interactive for interactive mode, or -l/--list to see available components"
341
+ "Please specify a component to install using -a/--add, use -i/--interactive for interactive mode, or -l/--list to see available components",
342
342
  );
343
343
  process.exit(1);
344
344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,232 @@
1
+ # DataGrid
2
+
3
+ A virtualized data grid for React 19 with no runtime dependencies besides React.
4
+ Works in Vite/SPA apps and in the Next.js App Router.
5
+
6
+ - **Fast by design:** rows and columns are virtualized; state lives in an
7
+ external store with per-row subscriptions, so selecting a row or moving focus
8
+ re-renders only the rows involved; column resizing rewrites CSS variables
9
+ without rendering; filtering and sorting run at low priority via
10
+ `useDeferredValue`.
11
+ - **Features:** typed columns, multi-column sort, typed column filters, global
12
+ search, pagination, row selection (single, multiple, shift-range), column
13
+ resize / reorder (drag or menu) / pin / hide, inline editing with validation and
14
+ async saves, CSV / Excel / PDF export, clipboard copy, density, i18n, RTL, dark
15
+ mode, WAI-ARIA grid keyboard navigation.
16
+ - **Client or server mode:** let the grid sort/filter/paginate, or receive a
17
+ query and fetch pages yourself.
18
+
19
+ ## Setup
20
+
21
+ Import the stylesheet once (e.g. in your root CSS or layout):
22
+
23
+ ```css
24
+ @import "../component-lib/data-grid/styles.css";
25
+ ```
26
+
27
+ or from JS: `import "component-lib/data-grid/styles.css";`
28
+
29
+ ## Basic usage
30
+
31
+ ```tsx
32
+ import { createColumnHelper, DataGrid } from "component-lib/data-grid";
33
+
34
+ interface Employee { id: number; name: string; salary: number; startDate: string; active: boolean }
35
+
36
+ const col = createColumnHelper<Employee>();
37
+
38
+ // Define columns outside the component (or memoize them).
39
+ const columns = [
40
+ col.field("id", { header: "ID", type: "number", width: 80, pin: "left" }),
41
+ col.field("name", { width: 200 }),
42
+ col.field("salary", { type: "number", format: (v) => `$${v.toLocaleString()}` }),
43
+ col.field("startDate", { type: "date" }),
44
+ col.field("active", { type: "boolean" }),
45
+ ];
46
+
47
+ export function Employees({ data }: { data: Employee[] }) {
48
+ return <DataGrid data={data} columns={columns} getRowId="id" enableRowSelection height={600} />;
49
+ }
50
+ ```
51
+
52
+ `createColumnHelper` infers the value type for `cell`, `format`, `validate` and
53
+ `sortFn`. Plain `ColumnDef<T>[]` objects work too.
54
+
55
+ ### Stable inputs
56
+
57
+ The grid memoizes on the identity of `data`, `columns` and `getRowId`. Keep
58
+ them stable: define columns at module level or with `useMemo`, and pass
59
+ `getRowId` as a property name (`getRowId="id"`). With React Compiler enabled,
60
+ inline values are memoized for you.
61
+
62
+ ## Columns
63
+
64
+ | Option | Purpose |
65
+ | --- | --- |
66
+ | `field` / `accessor` / `id` | Where the value comes from. Display-only columns need just `id` and `cell`. |
67
+ | `header`, `width`, `minWidth`, `maxWidth`, `align` | Presentation. |
68
+ | `type` | `string` (default), `number`, `date`, `boolean`. Drives filter operators, sorting, alignment, editors and Excel cell types. |
69
+ | `options` | `{ label, value }[]` for enum columns: "is any of" filter, select editor, label display. |
70
+ | `format(value, row)` | Display text. Also used by search, CSV, PDF and copy. |
71
+ | `cell(ctx)` | Custom renderer. Clicks on buttons/inputs inside cells don't trigger `onRowClick`. |
72
+ | `pin`, `hidden` | Initial pin side / visibility. |
73
+ | `sortable`, `filterable`, `resizable`, `reorderable`, `pinnable`, `hideable`, `searchable` | Per-column feature switches (default `true`). |
74
+ | `sortFn`, `filterFn` | Custom comparison / matching. |
75
+ | `editable`, `editor`, `validate` | Inline editing (below). |
76
+ | `exportable`, `exportValue(row)` | Export control. |
77
+ | `headerClassName`, `cellClassName` | Styling hooks. |
78
+
79
+ ## Server mode
80
+
81
+ ```tsx
82
+ const [query, setQuery] = useState<GridQuery>(initialQuery);
83
+ const { data, isFetching } = useQuery({ queryKey: ["orders", query], queryFn: () => fetchOrders(query) });
84
+
85
+ <DataGrid
86
+ mode="server"
87
+ data={data?.rows ?? []}
88
+ rowCount={data?.total ?? 0}
89
+ loading={isFetching}
90
+ columns={columns}
91
+ initialState={initialQuery}
92
+ onQueryChange={setQuery}
93
+ />
94
+ ```
95
+
96
+ `onQueryChange` receives `{ sorting, filters, globalFilter, pagination }`
97
+ whenever one of them changes. Changing filters, search or sorting resets to the
98
+ first page. The previous rows stay visible while `loading`.
99
+
100
+ The framework-free core (`component-lib/data-grid/core`) exports `filterRows`,
101
+ `sortRows` and `paginate`, so a Node or Next.js route handler can apply exactly
102
+ the same semantics on the server.
103
+
104
+ ## State
105
+
106
+ All grid state can be controlled or left internal:
107
+
108
+ ```ts
109
+ interface GridState {
110
+ sorting; filters; globalFilter; pagination; rowSelection;
111
+ columnOrder; columnVisibility; columnSizing; columnPinning; density;
112
+ }
113
+ ```
114
+
115
+ - `initialState` seeds internal state.
116
+ - `state` controls any subset of keys; the grid then calls `onStateChange(next, prev)`
117
+ instead of updating those keys itself.
118
+ - Example: persist column layout with
119
+ `onStateChange={(s) => save(pick(s, ["columnOrder", "columnSizing", "columnPinning", "columnVisibility"]))}`.
120
+
121
+ ## Editing
122
+
123
+ ```ts
124
+ col.field("salary", {
125
+ type: "number",
126
+ editable: (row) => !row.locked,
127
+ validate: (v) => (v < 0 ? "Must be positive" : null),
128
+ });
129
+
130
+ <DataGrid onCellEdit={async ({ rowId, columnId, value }) => { await save(rowId, columnId, value); }} />
131
+ ```
132
+
133
+ Start editing with double-click, Enter or F2. Enter commits and moves down,
134
+ Tab commits and moves right, and Escape cancels. The grid never mutates `data`:
135
+ update it in `onCellEdit`. If `onCellEdit` returns a promise, the cell shows the
136
+ pending value until it settles, and a rejection marks the cell invalid.
137
+ Custom editors: `editor: (props) => <MyInput value={props.value} onChange={props.onChange} onBlur={() => props.commit()} />`.
138
+
139
+ ## Imperative API
140
+
141
+ ```tsx
142
+ const api = useRef<GridApi<Employee>>(null);
143
+ <DataGrid ref={api} ... />
144
+
145
+ api.current?.setFilter("status", { operator: "in", value: ["active"] });
146
+ api.current?.exportExcel({ scope: "selected", fileName: "people" });
147
+ api.current?.focusCell(0, "name");
148
+ ```
149
+
150
+ Methods include `toggleSort`, `setSorting`, `setFilter`, `clearFilters`,
151
+ `setGlobalFilter`, `setPageIndex`, `setPageSize`, `toggleRowSelected`,
152
+ `toggleAllRowsSelected`, `getSelectedRows`, `setColumnVisibility`,
153
+ `setColumnWidth`, `pinColumn`, `moveColumn`, `resetColumns`, `scrollToRow`,
154
+ `focusCell`, `startEditing`, `getRows`, `exportCsv`, `exportExcel`, `exportPdf`
155
+ and `copyToClipboard`.
156
+
157
+ ## Export
158
+
159
+ Export code is split into chunks loaded on first use.
160
+
161
+ | Format | Notes |
162
+ | --- | --- |
163
+ | CSV | UTF-8 with BOM (opens correctly in Excel). Cells starting with `= + - @` are prefixed with `'` to prevent formula injection. |
164
+ | Excel | Real `.xlsx`: typed numbers, booleans and dates, bold frozen header, auto-filter, column widths. Written without a library. |
165
+ | PDF | Opens the browser print dialog with a print-formatted table; users choose "Save as PDF". Best for up to a few thousand rows. |
166
+
167
+ Scopes: `filtered` (default), `all`, `selected`, `page`, or pass `rows`, e.g. a
168
+ full result set fetched from the server.
169
+
170
+ ## Keyboard
171
+
172
+ | Keys | Action |
173
+ | --- | --- |
174
+ | Arrows, Home/End, Ctrl+Home/End, PageUp/PageDown | Move between cells (header row included). |
175
+ | Enter (header) | Sort; Shift+Enter adds to the sort. |
176
+ | Alt+↓ or the Menu key (header) | Open the column menu. |
177
+ | Enter / F2 | Edit the cell, or focus the widget inside a custom cell. |
178
+ | Space | Toggle row selection (Shift for a range). |
179
+ | Ctrl/Cmd+A | Select all rows. |
180
+ | Ctrl/Cmd+C | Copy selected rows (TSV) or the active cell. |
181
+ | Escape | Cancel editing / leave a widget inside a cell. |
182
+
183
+ ## Theming
184
+
185
+ Styles live in `@layer components`, so Tailwind utilities passed via
186
+ `classNames` / `className` win. Override the `--dg-*` variables for a theme:
187
+
188
+ ```css
189
+ .dg-root { --dg-accent: #7c3aed; --dg-radius: 12px; --dg-font-size: 14px; }
190
+ ```
191
+
192
+ Colors follow the page's `color-scheme`. A `.dark` or `[data-theme="dark"]`
193
+ ancestor forces a scheme. State is exposed as attributes for styling:
194
+ `data-selected`, `data-active`, `data-editing`, `data-pinned`, `data-density`,
195
+ and `aria-sort` on header cells.
196
+
197
+ Slots for `classNames`: `root`, `toolbar`, `viewport`, `header`, `headerCell`,
198
+ `row`, `cell`, `pagination`.
199
+
200
+ ## Next.js
201
+
202
+ Components carry `"use client"`; import `DataGrid` from a Server Component and
203
+ pass serializable props (`data`, `initialState`). Column definitions contain
204
+ functions, so define them in a client module. For server mode, fetch in a
205
+ Server Component or Route Handler using `core` functions, and sync the query to
206
+ the URL (e.g. with `nuqs`) through `state` + `onQueryChange`.
207
+
208
+ ## Known limits
209
+
210
+ - Rows have a fixed height (per density or `rowHeight`); variable-height rows are not supported.
211
+ - Updates from the grid's store render synchronously; heavy client-side filtering is
212
+ deferred with `useDeferredValue`, but a single filter pass over very large data
213
+ (1M+ rows) still runs on the main thread. For that scale, use server mode or a worker.
214
+ - Row grouping, tree data and pivoting are not implemented.
215
+
216
+ ## Migrating from `component-lib/datagrid`
217
+
218
+ | Old prop | New |
219
+ | --- | --- |
220
+ | `dataSource` (array) | `data` |
221
+ | `dataSource` (URL string) | Fetch in your app; pass `data` (the grid no longer fetches). |
222
+ | `lazy` + `pageSettings.totalCount` | `mode="server"` + `rowCount` |
223
+ | `pageSettings.pageNumber` / `pageSize` | `initialState={{ pagination: { pageIndex: 0, pageSize } }}` |
224
+ | `enableSearch`, `enableExcelExport`, `enablePdfExport` | `toolbar={{ search, export }}` |
225
+ | `selectAll`, `onSelectRow` | `enableRowSelection`, `onStateChange` / `api.getSelectedRows()` |
226
+ | `rowChange` / `onRowClick` | `onRowClick` |
227
+ | `pageStatus` / `onPageChange`, `onFilterChange`, `onSearchChange` | `onQueryChange` |
228
+ | `initialFilters`, `initialSearchParam` | `initialState={{ filters, globalFilter }}` |
229
+ | `isFetching` | `loading` |
230
+ | `column.headerText`, `column.template` | `header`, `cell` |
231
+ | `grid*Class` props | `classNames` slots |
232
+ | `ref.goToPage(n)` etc. | `ref.setPageIndex(n)` and the API above |
@@ -0,0 +1,341 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { computeLayout, getLayoutVars, moveColumn, resolveColumns } from "../core/columns";
3
+ import { filterRows } from "../core/filtering";
4
+ import { createGridEngine, type GridModel } from "../core/grid";
5
+ import { buildRows, createRowIdGetter, paginate } from "../core/rows";
6
+ import { sortRows, toggleSorting } from "../core/sorting";
7
+ import { createInitialState, mergeState } from "../core/state";
8
+ import type { ColumnDef, ColumnFilter, GridOptions, GridRow, SortItem } from "../core/types";
9
+ import { createFormatters } from "../core/values";
10
+ import { getColumnRange, getRowRange } from "../core/virtual";
11
+
12
+ interface Person {
13
+ id: number;
14
+ name: string;
15
+ age: number | null;
16
+ city: string | null;
17
+ joined: string;
18
+ active: boolean;
19
+ status: "new" | "active" | "gone";
20
+ }
21
+
22
+ const people: Person[] = [
23
+ { id: 1, name: "Alice", age: 34, city: "Paris", joined: "2024-01-15", active: true, status: "active" },
24
+ { id: 2, name: "bob", age: null, city: "Berlin", joined: "2023-06-01", active: false, status: "new" },
25
+ { id: 3, name: "Carol", age: 28, city: null, joined: "2024-01-16", active: true, status: "gone" },
26
+ { id: 4, name: "Dave", age: 41, city: "Paris", joined: "2022-11-30", active: false, status: "active" },
27
+ { id: 5, name: "Item10", age: 28, city: "Rome", joined: "2024-03-01", active: true, status: "new" },
28
+ { id: 6, name: "Item2", age: 50, city: "Berlin", joined: "2021-07-07", active: true, status: "active" },
29
+ ];
30
+
31
+ const defs: ColumnDef<Person>[] = [
32
+ { field: "id", type: "number" },
33
+ { field: "name" },
34
+ { field: "age", type: "number" },
35
+ { field: "city" },
36
+ { field: "joined", type: "date" },
37
+ { field: "active", type: "boolean" },
38
+ {
39
+ field: "status",
40
+ options: [
41
+ { label: "New", value: "new" },
42
+ { label: "Active", value: "active" },
43
+ { label: "Gone", value: "gone" },
44
+ ],
45
+ },
46
+ ];
47
+
48
+ const columns = resolveColumns(defs);
49
+ const formatters = createFormatters("en-US");
50
+ const rows = buildRows(people, createRowIdGetter<Person>("id"));
51
+ const ids = (list: GridRow<Person>[]) => list.map((row) => row.original.id);
52
+
53
+ describe("columns", () => {
54
+ it("derives ids and readable headers", () => {
55
+ expect(columns[0].id).toBe("id");
56
+ expect(columns[1].header).toBe("Name");
57
+ expect(resolveColumns<{ firstName: string }>([{ field: "firstName" }])[0].header).toBe("First Name");
58
+ expect(resolveColumns<{ start_date: string }>([{ field: "start_date" }])[0].header).toBe("Start date");
59
+ });
60
+
61
+ it("rejects duplicate ids", () => {
62
+ expect(() => resolveColumns<Person>([{ field: "name" }, { field: "name" }])).toThrow(/Duplicate/);
63
+ });
64
+
65
+ it("lays out pinned, ordered, sized and hidden columns", () => {
66
+ const layout = computeLayout(columns, {
67
+ columnOrder: ["city", "name"],
68
+ columnVisibility: { age: false },
69
+ columnSizing: { name: 200 },
70
+ columnPinning: { left: ["id"], right: ["status"] },
71
+ });
72
+ expect(layout.left.map((i) => i.column.id)).toEqual(["id"]);
73
+ expect(layout.center.map((i) => i.column.id)).toEqual(["city", "name", "joined", "active"]);
74
+ expect(layout.right.map((i) => i.column.id)).toEqual(["status"]);
75
+ expect(layout.center[1]).toMatchObject({ offset: 160, width: 200 });
76
+ expect(layout.left[0].pinEdge).toBe(true);
77
+ expect(layout.right[0].pinEdge).toBe(true);
78
+ expect(layout.totalWidth).toBe(160 * 5 + 200);
79
+ });
80
+
81
+ it("offsets right-pinned columns from the end, including resize previews", () => {
82
+ const layout = computeLayout(columns, {
83
+ columnOrder: [],
84
+ columnVisibility: {},
85
+ columnSizing: {},
86
+ columnPinning: { left: [], right: ["city", "status"] },
87
+ });
88
+ const [city, status] = layout.right;
89
+ expect(getLayoutVars(layout)[`--dg-c${status.visibleIndex}-o`]).toBe("0px");
90
+ expect(getLayoutVars(layout)[`--dg-c${city.visibleIndex}-o`]).toBe("160px");
91
+ expect(getLayoutVars(layout, { status: 300 })[`--dg-c${city.visibleIndex}-o`]).toBe("300px");
92
+ });
93
+
94
+ it("moves a column into the target's pin section and back", () => {
95
+ const pinned = moveColumn(columns, { columnOrder: [], columnPinning: { left: ["id"], right: [] } }, "city", "id", "after");
96
+ expect(pinned.columnPinning.left).toEqual(["id", "city"]);
97
+
98
+ const unpinned = moveColumn(columns, pinned, "city", "name", "before");
99
+ expect(unpinned.columnPinning.left).toEqual(["id"]);
100
+ expect(unpinned.columnOrder.indexOf("city")).toBe(unpinned.columnOrder.indexOf("name") - 1);
101
+ });
102
+ });
103
+
104
+ describe("filtering", () => {
105
+ const run = (filters: ColumnFilter[], search = "") =>
106
+ ids(filterRows(rows, columns, filters, search, formatters));
107
+
108
+ it("returns the input array when nothing filters", () => {
109
+ expect(filterRows(rows, columns, [], " ", formatters)).toBe(rows);
110
+ expect(filterRows(rows, columns, [{ columnId: "name", operator: "contains", value: "" }], "", formatters)).toBe(rows);
111
+ });
112
+
113
+ it("matches text case-insensitively", () => {
114
+ expect(run([{ columnId: "name", operator: "contains", value: "B" }])).toEqual([2]);
115
+ });
116
+
117
+ it("applies every column filter together", () => {
118
+ expect(
119
+ run([
120
+ { columnId: "city", operator: "equals", value: "paris" },
121
+ { columnId: "active", operator: "equals", value: false },
122
+ ]),
123
+ ).toEqual([4]);
124
+ });
125
+
126
+ it("handles empty cell values", () => {
127
+ expect(run([{ columnId: "city", operator: "startsWith", value: "p" }])).toEqual([1, 4]);
128
+ expect(run([{ columnId: "age", operator: "isEmpty" }])).toEqual([2]);
129
+ });
130
+
131
+ it("filters numbers", () => {
132
+ expect(run([{ columnId: "age", operator: "between", value: 28, value2: 34 }])).toEqual([1, 3, 5]);
133
+ expect(run([{ columnId: "age", operator: "gt", value: 40 }])).toEqual([4, 6]);
134
+ });
135
+
136
+ it("filters dates by local calendar day", () => {
137
+ expect(run([{ columnId: "joined", operator: "equals", value: "2024-01-15" }])).toEqual([1]);
138
+ expect(run([{ columnId: "joined", operator: "before", value: "2023-01-01" }])).toEqual([4, 6]);
139
+ expect(run([{ columnId: "joined", operator: "after", value: "2024-01-15" }])).toEqual([3, 5]);
140
+ expect(run([{ columnId: "joined", operator: "between", value: "2024-01-15", value2: "2024-01-16" }])).toEqual([1, 3]);
141
+ });
142
+
143
+ it("filters option sets", () => {
144
+ expect(run([{ columnId: "status", operator: "in", value: ["new", "gone"] }])).toEqual([2, 3, 5]);
145
+ });
146
+
147
+ it("searches display values and requires every term", () => {
148
+ expect(run([], "paris active")).toEqual([1, 4]);
149
+ expect(run([], "Gone")).toEqual([3]);
150
+ });
151
+ });
152
+
153
+ describe("sorting", () => {
154
+ const sorted = (sorting: SortItem[]) => ids(sortRows(rows, columns, sorting));
155
+
156
+ it("sorts numbers with empty values last in both directions", () => {
157
+ expect(sorted([{ columnId: "age", desc: false }])).toEqual([3, 5, 1, 4, 6, 2]);
158
+ expect(sorted([{ columnId: "age", desc: true }])).toEqual([6, 4, 1, 3, 5, 2]);
159
+ });
160
+
161
+ it("uses natural, case-insensitive string order", () => {
162
+ expect(sorted([{ columnId: "name", desc: false }])).toEqual([1, 2, 3, 4, 6, 5]);
163
+ });
164
+
165
+ it("applies sorts in priority order", () => {
166
+ expect(
167
+ sorted([
168
+ { columnId: "city", desc: false },
169
+ { columnId: "age", desc: true },
170
+ ]),
171
+ ).toEqual([6, 2, 4, 1, 5, 3]);
172
+ });
173
+
174
+ it("sorts dates and option labels", () => {
175
+ expect(sorted([{ columnId: "joined", desc: false }])).toEqual([6, 4, 2, 1, 3, 5]);
176
+ expect(sorted([{ columnId: "status", desc: false }])).toEqual([1, 4, 6, 3, 2, 5]);
177
+ });
178
+
179
+ it("cycles ascending, descending, off", () => {
180
+ const asc = toggleSorting([], "a", false);
181
+ expect(asc).toEqual([{ columnId: "a", desc: false }]);
182
+ const desc = toggleSorting(asc, "a", false);
183
+ expect(desc).toEqual([{ columnId: "a", desc: true }]);
184
+ expect(toggleSorting(desc, "a", false)).toEqual([]);
185
+ expect(toggleSorting(desc, "b", true)).toEqual([
186
+ { columnId: "a", desc: true },
187
+ { columnId: "b", desc: false },
188
+ ]);
189
+ expect(toggleSorting(desc, "b", false)).toEqual([{ columnId: "b", desc: false }]);
190
+ });
191
+ });
192
+
193
+ describe("pagination and virtualization", () => {
194
+ it("clamps the page index", () => {
195
+ const page = paginate(rows, { pageIndex: 9, pageSize: 4 }, { enabled: true, server: false });
196
+ expect(page).toMatchObject({ pageIndex: 1, pageCount: 2, pageOffset: 4 });
197
+ expect(ids(page.rows)).toEqual([5, 6]);
198
+ });
199
+
200
+ it("uses the server row count", () => {
201
+ const page = paginate(rows.slice(0, 2), { pageIndex: 3, pageSize: 2 }, { enabled: true, server: true, rowCount: 101 });
202
+ expect(page).toMatchObject({ pageCount: 51, pageOffset: 6, rowCount: 101 });
203
+ expect(page.rows).toHaveLength(2);
204
+ });
205
+
206
+ it("computes the visible row window", () => {
207
+ const viewport = { scrollTop: 400, scrollLeft: 0, width: 800, height: 440 };
208
+ expect(getRowRange(viewport, 1000, 40, 40, 2)).toEqual({ start: 8, end: 22 });
209
+ });
210
+
211
+ it("computes the visible column window", () => {
212
+ const many = resolveColumns<Record<string, number>>(
213
+ Array.from({ length: 50 }, (_, i) => ({ id: `c${i}`, accessor: (row) => row[`c${i}`], width: 100 })),
214
+ );
215
+ const layout = computeLayout(many, {
216
+ columnOrder: [],
217
+ columnVisibility: {},
218
+ columnSizing: {},
219
+ columnPinning: { left: ["c0"], right: [] },
220
+ });
221
+ expect(getColumnRange(layout, { scrollTop: 0, scrollLeft: 1000, width: 500, height: 300 }, 0)).toEqual({ start: 10, end: 14 });
222
+ expect(getColumnRange(layout, { scrollTop: 0, scrollLeft: 0, width: 0, height: 0 })).toEqual({ start: 0, end: 49 });
223
+ });
224
+ });
225
+
226
+ describe("state", () => {
227
+ it("seeds pinning and visibility from column definitions", () => {
228
+ const state = createInitialState<Person>({
229
+ data: [],
230
+ columns: [{ field: "id", pin: "left" }, { field: "name", hidden: true }],
231
+ });
232
+ expect(state.columnPinning).toEqual({ left: ["id"], right: [] });
233
+ expect(state.columnVisibility).toEqual({ name: false });
234
+ });
235
+
236
+ it("prefers defined controlled values", () => {
237
+ const state = createInitialState<Person>({ data: [], columns: [] });
238
+ expect(mergeState(state, undefined)).toBe(state);
239
+ expect(mergeState(state, { globalFilter: undefined })).toBe(state);
240
+ expect(mergeState(state, { globalFilter: "x" }).globalFilter).toBe("x");
241
+ });
242
+ });
243
+
244
+ function setup(overrides: Partial<GridOptions<Person>> = {}) {
245
+ const options: GridOptions<Person> = {
246
+ data: people,
247
+ columns: defs,
248
+ getRowId: "id",
249
+ enableRowSelection: true,
250
+ ...overrides,
251
+ };
252
+ const engine = createGridEngine(options);
253
+ const resolved = resolveColumns(options.columns);
254
+ const state = engine.api.getState();
255
+ const filtered = filterRows(rows, resolved, state.filters, state.globalFilter, formatters);
256
+ const sortedRows = sortRows(filtered, resolved, state.sorting);
257
+ const model: GridModel<Person> = {
258
+ columns: resolved,
259
+ coreRows: rows,
260
+ sortedRows,
261
+ page: paginate(sortedRows, state.pagination, { enabled: true, server: false }),
262
+ layout: computeLayout(resolved, state),
263
+ rowHeight: 40,
264
+ headerHeight: 40,
265
+ formatters,
266
+ };
267
+ engine.sync(options, model);
268
+ return engine;
269
+ }
270
+
271
+ describe("engine", () => {
272
+ it("returns to the first page and reports the query when filters change", () => {
273
+ const onQueryChange = vi.fn();
274
+ const engine = setup({ onQueryChange, initialState: { pagination: { pageIndex: 2, pageSize: 2 } } });
275
+
276
+ engine.api.setFilter("city", { operator: "equals", value: "paris" });
277
+
278
+ expect(engine.api.getState().pagination.pageIndex).toBe(0);
279
+ expect(onQueryChange).toHaveBeenCalledWith(
280
+ expect.objectContaining({
281
+ filters: [{ columnId: "city", operator: "equals", value: "paris" }],
282
+ pagination: { pageIndex: 0, pageSize: 2 },
283
+ }),
284
+ );
285
+ });
286
+
287
+ it("leaves controlled state to the parent", () => {
288
+ const onStateChange = vi.fn();
289
+ const engine = setup({ state: { sorting: [] }, onStateChange });
290
+
291
+ engine.api.toggleSort("name");
292
+
293
+ expect(engine.api.getState().sorting).toEqual([]);
294
+ expect(onStateChange.mock.calls[0][0].sorting).toEqual([{ columnId: "name", desc: false }]);
295
+ });
296
+
297
+ it("selects ranges and skips rows that cannot be selected", () => {
298
+ const engine = setup({ enableRowSelection: (row) => row.id !== 3 });
299
+
300
+ engine.api.toggleRowSelected("1");
301
+ engine.api.toggleRowSelected("4", { range: true });
302
+ expect(engine.api.getSelectedRowIds()).toEqual(["1", "2", "4"]);
303
+
304
+ engine.api.toggleAllRowsSelected(true);
305
+ expect(engine.api.getSelectedRows().map((p) => p.id)).toEqual([1, 2, 4, 5, 6]);
306
+ });
307
+
308
+ it("keeps one row selected in single mode", () => {
309
+ const engine = setup({ selectionMode: "single" });
310
+ engine.api.toggleRowSelected("1");
311
+ engine.api.toggleRowSelected("2");
312
+ expect(engine.api.getSelectedRowIds()).toEqual(["2"]);
313
+ });
314
+
315
+ it("validates edits and tracks async saves", async () => {
316
+ let finishSave!: () => void;
317
+ const onCellEdit = vi.fn(() => new Promise<void>((resolve) => (finishSave = resolve)));
318
+ const engine = setup({
319
+ columns: [
320
+ { field: "id", type: "number" },
321
+ { field: "name", editable: true, validate: (value) => (String(value).trim() ? null : "Required") },
322
+ ],
323
+ onCellEdit,
324
+ });
325
+
326
+ engine.api.startEditing("1", "name");
327
+ expect(engine.store.getSnapshot().ui.editing).toEqual({ rowId: "1", columnId: "name" });
328
+
329
+ expect(engine.commitEdit("1", "name", " ")).toBe("Required");
330
+ expect(engine.commitEdit("1", "name", "Alicia")).toBeNull();
331
+ expect(onCellEdit).toHaveBeenCalledWith(
332
+ expect.objectContaining({ rowId: "1", columnId: "name", value: "Alicia", previousValue: "Alice" }),
333
+ );
334
+ expect(engine.store.getSnapshot().ui).toMatchObject({ editing: null, pending: { "1": { name: "Alicia" } } });
335
+
336
+ finishSave();
337
+ await Promise.resolve();
338
+ await Promise.resolve();
339
+ expect(engine.store.getSnapshot().ui.pending).toEqual({});
340
+ });
341
+ });