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.
- package/README.md +3 -3
- package/index.cjs +9 -9
- package/package.json +1 -1
- package/source/components/datagridbeta/README.md +232 -0
- package/source/components/datagridbeta/__tests__/core.test.ts +341 -0
- package/source/components/datagridbeta/__tests__/export.test.ts +70 -0
- package/source/components/datagridbeta/core/columnHelper.ts +21 -0
- package/source/components/datagridbeta/core/columns.ts +288 -0
- package/source/components/datagridbeta/core/filtering.ts +237 -0
- package/source/components/datagridbeta/core/grid.ts +616 -0
- package/source/components/datagridbeta/core/index.ts +14 -0
- package/source/components/datagridbeta/core/rows.ts +64 -0
- package/source/components/datagridbeta/core/sorting.ts +111 -0
- package/source/components/datagridbeta/core/state.ts +82 -0
- package/source/components/datagridbeta/core/store.ts +52 -0
- package/source/components/datagridbeta/core/types.ts +266 -0
- package/source/components/datagridbeta/core/values.ts +98 -0
- package/source/components/datagridbeta/core/virtual.ts +85 -0
- package/source/components/datagridbeta/export/csv.ts +32 -0
- package/source/components/datagridbeta/export/download.ts +12 -0
- package/source/components/datagridbeta/export/pdf.ts +61 -0
- package/source/components/datagridbeta/export/table.ts +73 -0
- package/source/components/datagridbeta/export/xlsx.ts +145 -0
- package/source/components/datagridbeta/export/zip.ts +87 -0
- package/source/components/datagridbeta/index.ts +7 -0
- package/source/components/datagridbeta/react/Cell.tsx +282 -0
- package/source/components/datagridbeta/react/ColumnMenu.tsx +135 -0
- package/source/components/datagridbeta/react/DataGrid.tsx +296 -0
- package/source/components/datagridbeta/react/FilterForm.tsx +142 -0
- package/source/components/datagridbeta/react/HeaderRow.tsx +306 -0
- package/source/components/datagridbeta/react/Pagination.tsx +107 -0
- package/source/components/datagridbeta/react/Popover.tsx +79 -0
- package/source/components/datagridbeta/react/Row.tsx +85 -0
- package/source/components/datagridbeta/react/Toolbar.tsx +266 -0
- package/source/components/datagridbeta/react/Viewport.tsx +167 -0
- package/source/components/datagridbeta/react/context.ts +57 -0
- package/source/components/datagridbeta/react/hooks.ts +80 -0
- package/source/components/datagridbeta/react/icons.tsx +72 -0
- package/source/components/datagridbeta/react/keyboard.ts +110 -0
- package/source/components/datagridbeta/react/locale.ts +128 -0
- package/source/components/datagridbeta/styles.css +717 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# GBS Building Blocks 2.0 (v1.2.
|
|
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.
|
|
9
|
+
## What's New 🎉 (Ver 1.2.8)
|
|
10
10
|
|
|
11
|
-
-
|
|
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
|
@@ -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
|
+
});
|