@ornery/ui-grid-react 0.1.4
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/CLAUDE.md +283 -0
- package/demo/index.html +16 -0
- package/demo/main.tsx +95 -0
- package/demo/vite.config.ts +13 -0
- package/dist/index.d.mts +133 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.js +2020 -0
- package/dist/index.mjs +2050 -0
- package/package.json +41 -0
- package/src/UiGrid.test.tsx +370 -0
- package/src/UiGrid.tsx +440 -0
- package/src/index.ts +23 -0
- package/src/ui-grid.css +521 -0
- package/src/useGridState.ts +1414 -0
- package/src/useVirtualScroll.ts +44 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ornery/ui-grid-react",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "React wrapper for @ornery/ui-grid",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./styles": "./dist/ui-grid.css"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
18
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
19
|
+
"@ornery/ui-grid": "^0.1.3"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@ornery/ui-grid": "file:../ui-grid",
|
|
23
|
+
"react": "^19.1.0",
|
|
24
|
+
"react-dom": "^19.1.0",
|
|
25
|
+
"@testing-library/react": "^16.0.0",
|
|
26
|
+
"@types/react": "^19.0.0",
|
|
27
|
+
"@types/react-dom": "^19.0.0",
|
|
28
|
+
"typescript": "~5.8.0",
|
|
29
|
+
"vitest": "^4.1.0",
|
|
30
|
+
"jsdom": "^26.0.0",
|
|
31
|
+
"tsup": "^8.0.0",
|
|
32
|
+
"vite": "^6.0.0",
|
|
33
|
+
"@vitejs/plugin-react": "^4.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"start": "vite serve demo --config demo/vite.config.ts",
|
|
37
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --tsconfig tsconfig.json --external react --external react-dom --external @ornery/ui-grid",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { UiGrid } from './UiGrid';
|
|
5
|
+
import type { UiGridProps } from './UiGrid';
|
|
6
|
+
import type { GridOptions, UiGridApi, GridExpandableTemplateContext } from '@ornery/ui-grid';
|
|
7
|
+
import { SORT_DIRECTIONS, FILTER_CONDITIONS } from '@ornery/ui-grid';
|
|
8
|
+
|
|
9
|
+
const baseData = [
|
|
10
|
+
{
|
|
11
|
+
id: 'row-1',
|
|
12
|
+
name: 'Gamma',
|
|
13
|
+
status: 'Pilot',
|
|
14
|
+
revenue: 300,
|
|
15
|
+
active: true,
|
|
16
|
+
account: { owner: 'Mina Patel' },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'row-2',
|
|
20
|
+
name: 'alpha',
|
|
21
|
+
status: 'Active',
|
|
22
|
+
revenue: 100,
|
|
23
|
+
active: false,
|
|
24
|
+
account: { owner: 'Casey Tran' },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'row-3',
|
|
28
|
+
name: 'Beta',
|
|
29
|
+
status: 'Active',
|
|
30
|
+
revenue: 200,
|
|
31
|
+
active: true,
|
|
32
|
+
account: { owner: 'Jordan Silva' },
|
|
33
|
+
},
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
function createOptions(
|
|
37
|
+
overrides: Partial<GridOptions> = {},
|
|
38
|
+
onRegisterApi?: (api: UiGridApi) => void
|
|
39
|
+
): GridOptions {
|
|
40
|
+
return {
|
|
41
|
+
id: 'spec-grid',
|
|
42
|
+
title: 'Spec Grid',
|
|
43
|
+
emptyMessage: 'Nothing to show',
|
|
44
|
+
data: baseData,
|
|
45
|
+
rowIdentity: (row) => String(row['id']),
|
|
46
|
+
enableSorting: true,
|
|
47
|
+
enableFiltering: true,
|
|
48
|
+
enableGrouping: true,
|
|
49
|
+
enableColumnMoving: true,
|
|
50
|
+
enableVirtualization: true,
|
|
51
|
+
virtualizationThreshold: 99,
|
|
52
|
+
benchmark: { iterations: 3 },
|
|
53
|
+
columnDefs: [
|
|
54
|
+
{ name: 'name', displayName: 'Customer' },
|
|
55
|
+
{ name: 'status' },
|
|
56
|
+
{
|
|
57
|
+
name: 'revenue',
|
|
58
|
+
align: 'end',
|
|
59
|
+
filter: { condition: FILTER_CONDITIONS.greaterThan },
|
|
60
|
+
formatter: (value) => `$${value}`,
|
|
61
|
+
},
|
|
62
|
+
{ name: 'owner', field: 'account.owner' },
|
|
63
|
+
{
|
|
64
|
+
name: 'badge',
|
|
65
|
+
cellRenderer: ({ row }) => `${row['name']}-badge`,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
onRegisterApi: (api) => onRegisterApi?.(api as UiGridApi),
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderGrid(
|
|
74
|
+
overrides: Partial<GridOptions> = {},
|
|
75
|
+
props: Partial<Omit<UiGridProps, 'options'>> = {}
|
|
76
|
+
): { container: HTMLElement; gridApi: UiGridApi } {
|
|
77
|
+
let gridApi!: UiGridApi;
|
|
78
|
+
const options = createOptions(overrides, (api) => {
|
|
79
|
+
gridApi = api;
|
|
80
|
+
props.onRegisterApi?.(api);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<UiGrid options={options} onRegisterApi={options.onRegisterApi as any} {...props} />
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return { container, gridApi };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('UiGrid React component', () => {
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
vi.restoreAllMocks();
|
|
93
|
+
vi.useRealTimers();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('registers the API and renders headers and rows', () => {
|
|
97
|
+
const { container, gridApi } = renderGrid();
|
|
98
|
+
|
|
99
|
+
const headers = Array.from(container.querySelectorAll('.header-label')).map(
|
|
100
|
+
(el) => el.textContent?.trim()
|
|
101
|
+
);
|
|
102
|
+
const bodyCells = Array.from(container.querySelectorAll('.body-cell')).map(
|
|
103
|
+
(el) => el.textContent?.trim()
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(gridApi).toBeTruthy();
|
|
107
|
+
expect(headers).toEqual(['Customer', 'Status', 'Revenue', 'Owner', 'Badge']);
|
|
108
|
+
expect(bodyCells).toContain('Gamma');
|
|
109
|
+
expect(bodyCells).toContain('$300');
|
|
110
|
+
expect(bodyCells).toContain('Mina Patel');
|
|
111
|
+
expect(bodyCells).toContain('Gamma-badge');
|
|
112
|
+
expect(container.querySelector('.grid-viewport')).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('filters rows and renders empty state', () => {
|
|
116
|
+
const { container, gridApi } = renderGrid();
|
|
117
|
+
|
|
118
|
+
const filterChanged = vi.fn();
|
|
119
|
+
gridApi.core.on.filterChanged(filterChanged);
|
|
120
|
+
|
|
121
|
+
act(() => {
|
|
122
|
+
gridApi.core.setFilter('status', 'Active');
|
|
123
|
+
});
|
|
124
|
+
expect(gridApi.core.getVisibleRows().map((row) => row.entity['name'])).toEqual([
|
|
125
|
+
'alpha',
|
|
126
|
+
'Beta',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
act(() => {
|
|
130
|
+
gridApi.core.setFilter('status', 'Missing');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(gridApi.core.getVisibleRows()).toEqual([]);
|
|
134
|
+
expect(container.querySelector('.empty-state strong')?.textContent).toContain(
|
|
135
|
+
'Nothing to show'
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('sorts rows and cycles sort state from header button', () => {
|
|
140
|
+
const { container, gridApi } = renderGrid();
|
|
141
|
+
|
|
142
|
+
const sortChanged = vi.fn();
|
|
143
|
+
gridApi.core.on.sortChanged(sortChanged);
|
|
144
|
+
|
|
145
|
+
act(() => {
|
|
146
|
+
gridApi.core.sortColumn('name', SORT_DIRECTIONS.asc);
|
|
147
|
+
});
|
|
148
|
+
expect(gridApi.core.getVisibleRows().map((row) => row.entity['name'])).toEqual([
|
|
149
|
+
'alpha',
|
|
150
|
+
'Beta',
|
|
151
|
+
'Gamma',
|
|
152
|
+
]);
|
|
153
|
+
expect(sortChanged).toHaveBeenLastCalledWith('name', SORT_DIRECTIONS.asc);
|
|
154
|
+
|
|
155
|
+
const headerButton = container.querySelector('.header-action') as HTMLButtonElement;
|
|
156
|
+
act(() => {
|
|
157
|
+
headerButton.click();
|
|
158
|
+
});
|
|
159
|
+
expect(sortChanged).toHaveBeenLastCalledWith('name', SORT_DIRECTIONS.desc);
|
|
160
|
+
expect(gridApi.core.getVisibleRows().map((row) => row.entity['name'])).toEqual([
|
|
161
|
+
'Gamma',
|
|
162
|
+
'Beta',
|
|
163
|
+
'alpha',
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('groups rows and collapses groups', () => {
|
|
168
|
+
const { container, gridApi } = renderGrid();
|
|
169
|
+
|
|
170
|
+
const groupingChanged = vi.fn();
|
|
171
|
+
gridApi.core.on.groupingChanged(groupingChanged);
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
gridApi.core.groupByColumn('status');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const initialGroups = container.querySelectorAll('.group-row');
|
|
178
|
+
expect(groupingChanged).toHaveBeenLastCalledWith(['status']);
|
|
179
|
+
expect(initialGroups).toHaveLength(2);
|
|
180
|
+
expect(container.querySelectorAll('.body-cell')).toHaveLength(15);
|
|
181
|
+
|
|
182
|
+
const activeGroup = Array.from(initialGroups).find((node) =>
|
|
183
|
+
node.textContent?.includes('status: Active')
|
|
184
|
+
);
|
|
185
|
+
expect(activeGroup).toBeTruthy();
|
|
186
|
+
|
|
187
|
+
act(() => {
|
|
188
|
+
(activeGroup as HTMLButtonElement).click();
|
|
189
|
+
});
|
|
190
|
+
expect(container.querySelectorAll('.body-cell')).toHaveLength(5);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('exports visible rows as CSV', async () => {
|
|
194
|
+
const { gridApi } = renderGrid();
|
|
195
|
+
|
|
196
|
+
const anchor = document.createElement('a');
|
|
197
|
+
const originalCreateElement = document.createElement.bind(document);
|
|
198
|
+
const clickSpy = vi.spyOn(anchor, 'click').mockImplementation(() => {});
|
|
199
|
+
vi.spyOn(document, 'createElement').mockImplementation(
|
|
200
|
+
((tagName: string) =>
|
|
201
|
+
tagName === 'a' ? anchor : originalCreateElement(tagName)) as typeof document.createElement
|
|
202
|
+
);
|
|
203
|
+
const createObjectUrlSpy = vi
|
|
204
|
+
.spyOn(URL, 'createObjectURL')
|
|
205
|
+
.mockReturnValue('blob:spec-grid');
|
|
206
|
+
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
gridApi.core.exportCsv();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
213
|
+
expect(anchor.download).toBe('spec-grid.csv');
|
|
214
|
+
|
|
215
|
+
const blob = createObjectUrlSpy.mock.calls[0][0] as Blob;
|
|
216
|
+
const csv = await new Promise<string>((resolve) => {
|
|
217
|
+
const reader = new FileReader();
|
|
218
|
+
reader.onload = () => resolve(reader.result as string);
|
|
219
|
+
reader.readAsText(blob);
|
|
220
|
+
});
|
|
221
|
+
expect(csv).toContain('Customer,Status,Revenue,Owner,Badge');
|
|
222
|
+
expect(csv).toContain('Gamma,Pilot,$300,Mina Patel,Gamma-badge');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('virtualizes rows when count crosses threshold', () => {
|
|
226
|
+
const { container, gridApi } = renderGrid({
|
|
227
|
+
virtualizationThreshold: 1,
|
|
228
|
+
data: Array.from({ length: 5 }, (_, index) => ({
|
|
229
|
+
id: `virtual-${index}`,
|
|
230
|
+
name: `Row ${index}`,
|
|
231
|
+
status: index % 2 === 0 ? 'Active' : 'Pilot',
|
|
232
|
+
revenue: index * 100,
|
|
233
|
+
account: { owner: `Owner ${index}` },
|
|
234
|
+
})),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(gridApi.core.getVisibleRows()).toHaveLength(5);
|
|
238
|
+
expect(container.querySelector('.grid-viewport')).not.toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('paginates rows', () => {
|
|
242
|
+
const { container, gridApi } = renderGrid({
|
|
243
|
+
enablePagination: true,
|
|
244
|
+
enablePaginationControls: true,
|
|
245
|
+
paginationPageSizes: [1, 2],
|
|
246
|
+
paginationPageSize: 1,
|
|
247
|
+
paginationCurrentPage: 1,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const paginationChanged = vi.fn();
|
|
251
|
+
gridApi.pagination.on.paginationChanged(paginationChanged);
|
|
252
|
+
|
|
253
|
+
expect(gridApi.core.getVisibleRows().map((row) => row.id)).toEqual(['row-1']);
|
|
254
|
+
expect(gridApi.pagination.getTotalPages()).toBe(3);
|
|
255
|
+
|
|
256
|
+
act(() => {
|
|
257
|
+
gridApi.pagination.nextPage();
|
|
258
|
+
});
|
|
259
|
+
expect(gridApi.core.getVisibleRows().map((row) => row.id)).toEqual(['row-2']);
|
|
260
|
+
expect(paginationChanged).toHaveBeenLastCalledWith(2, 1);
|
|
261
|
+
|
|
262
|
+
act(() => {
|
|
263
|
+
gridApi.pagination.setPageSize(2);
|
|
264
|
+
});
|
|
265
|
+
expect(gridApi.core.getVisibleRows().map((row) => row.id)).toEqual(['row-1', 'row-2']);
|
|
266
|
+
|
|
267
|
+
expect(container.querySelector('.pagination-bar')?.textContent).toContain('1-2 of 3');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('keyboard cell editing: commit, navigate, cancel', async () => {
|
|
271
|
+
const { container, gridApi } = renderGrid({
|
|
272
|
+
enableGrouping: false,
|
|
273
|
+
enableCellEditOnFocus: true,
|
|
274
|
+
columnDefs: [
|
|
275
|
+
{ name: 'name', displayName: 'Customer', enableCellEdit: true },
|
|
276
|
+
{ name: 'status' },
|
|
277
|
+
{ name: 'owner', field: 'account.owner', enableCellEdit: true },
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const beginCellEdit = vi.fn();
|
|
282
|
+
const afterCellEdit = vi.fn();
|
|
283
|
+
const cancelCellEdit = vi.fn();
|
|
284
|
+
gridApi.edit.on.beginCellEdit(beginCellEdit);
|
|
285
|
+
gridApi.edit.on.afterCellEdit(afterCellEdit);
|
|
286
|
+
gridApi.edit.on.cancelCellEdit(cancelCellEdit);
|
|
287
|
+
|
|
288
|
+
const firstNameCell = container.querySelector(
|
|
289
|
+
'.body-cell[data-row-id="row-1"][data-col-name="name"]'
|
|
290
|
+
) as HTMLElement;
|
|
291
|
+
|
|
292
|
+
await act(async () => {
|
|
293
|
+
firstNameCell.focus();
|
|
294
|
+
fireEvent.keyDown(firstNameCell, { key: 'Z' });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
let editor = container.querySelector(
|
|
298
|
+
'.cell-editor[data-row-id="row-1"][data-col-name="name"]'
|
|
299
|
+
) as HTMLInputElement;
|
|
300
|
+
expect(editor).toBeTruthy();
|
|
301
|
+
expect(beginCellEdit).toHaveBeenCalled();
|
|
302
|
+
|
|
303
|
+
await act(async () => {
|
|
304
|
+
fireEvent.keyDown(editor, { key: 'Tab' });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(gridApi.core.getVisibleRows()[0]?.entity['name']).toBe('Z');
|
|
308
|
+
expect(afterCellEdit).toHaveBeenCalled();
|
|
309
|
+
|
|
310
|
+
const ownerCell = container.querySelector(
|
|
311
|
+
'.body-cell[data-row-id="row-1"][data-col-name="owner"]'
|
|
312
|
+
) as HTMLElement;
|
|
313
|
+
|
|
314
|
+
await act(async () => {
|
|
315
|
+
ownerCell.focus();
|
|
316
|
+
fireEvent.keyDown(ownerCell, { key: 'F2' });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
editor = container.querySelector(
|
|
320
|
+
'.cell-editor[data-row-id="row-1"][data-col-name="owner"]'
|
|
321
|
+
) as HTMLInputElement;
|
|
322
|
+
expect(editor).toBeTruthy();
|
|
323
|
+
|
|
324
|
+
await act(async () => {
|
|
325
|
+
fireEvent.change(editor, { target: { value: 'Taylor Morgan' } });
|
|
326
|
+
fireEvent.keyDown(editor, { key: 'Escape' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(gridApi.core.getVisibleRows()[0]?.entity['account']).toEqual({
|
|
330
|
+
owner: 'Mina Patel',
|
|
331
|
+
});
|
|
332
|
+
expect(cancelCellEdit).toHaveBeenCalled();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('resolves custom i18n label overrides', () => {
|
|
336
|
+
const { container } = renderGrid({
|
|
337
|
+
labels: {
|
|
338
|
+
sortDefault: 'Trier',
|
|
339
|
+
sortAsc: 'Tri croissant',
|
|
340
|
+
paginationNext: 'Suivant',
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const sortButton = container.querySelector('.header-action') as HTMLButtonElement;
|
|
345
|
+
expect(sortButton.getAttribute('aria-label')).toBe('Trier');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('feature flags disable unused template sections', () => {
|
|
349
|
+
const { container, gridApi } = renderGrid({
|
|
350
|
+
enableSorting: false,
|
|
351
|
+
enableFiltering: false,
|
|
352
|
+
enableGrouping: false,
|
|
353
|
+
enableColumnMoving: false,
|
|
354
|
+
enableVirtualization: false,
|
|
355
|
+
columnDefs: [
|
|
356
|
+
{ name: 'name', visible: false },
|
|
357
|
+
{ name: 'status', sortable: false, filterable: false },
|
|
358
|
+
{ name: 'owner', field: 'account.owner' },
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const headers = Array.from(container.querySelectorAll('.header-label')).map(
|
|
363
|
+
(el) => el.textContent?.trim()
|
|
364
|
+
);
|
|
365
|
+
expect(headers).toEqual(['Status', 'Owner']);
|
|
366
|
+
expect(container.querySelector('.filter-grid')).toBeNull();
|
|
367
|
+
expect(container.querySelector('.chip-action')).toBeNull();
|
|
368
|
+
expect(gridApi.core.getVisibleRows()).toHaveLength(3);
|
|
369
|
+
});
|
|
370
|
+
});
|