@object-ui/plugin-grid 2.0.0 → 3.0.0
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/.turbo/turbo-build.log +42 -6
- package/CHANGELOG.md +22 -0
- package/dist/index.js +960 -641
- package/dist/index.umd.cjs +3 -3
- package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.stories.d.ts +33 -0
- package/dist/packages/plugin-grid/src/__tests__/InlineEditing.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/accessibility.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/performance-benchmark.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/view-states.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/index.d.ts +5 -0
- package/dist/packages/plugin-grid/src/useGroupedData.d.ts +30 -0
- package/dist/packages/plugin-grid/src/useRowColor.d.ts +8 -0
- package/package.json +11 -10
- package/src/InlineEditing.tsx +235 -0
- package/src/ObjectGrid.EdgeCases.stories.tsx +147 -0
- package/src/ObjectGrid.stories.tsx +139 -0
- package/src/ObjectGrid.tsx +148 -16
- package/src/__tests__/InlineEditing.test.tsx +360 -0
- package/src/__tests__/accessibility.test.tsx +254 -0
- package/src/__tests__/performance-benchmark.test.tsx +182 -0
- package/src/__tests__/view-states.test.tsx +203 -0
- package/src/index.tsx +5 -0
- package/src/useGroupedData.ts +122 -0
- package/src/useRowColor.ts +74 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*
|
|
8
|
+
* Performance benchmark tests for VirtualGrid.
|
|
9
|
+
* Part of P2.4 Performance at Scale roadmap.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
13
|
+
import { render, cleanup } from '@testing-library/react';
|
|
14
|
+
import '@testing-library/jest-dom';
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import type { VirtualGridColumn, VirtualGridProps } from '../VirtualGrid';
|
|
17
|
+
|
|
18
|
+
// --- Mock @tanstack/react-virtual ---
|
|
19
|
+
// Cap the visible window to simulate virtualisation (only render a subset)
|
|
20
|
+
const VISIBLE_WINDOW = 50;
|
|
21
|
+
|
|
22
|
+
vi.mock('@tanstack/react-virtual', () => ({
|
|
23
|
+
useVirtualizer: (opts: any) => {
|
|
24
|
+
const count: number = opts.count;
|
|
25
|
+
const size: number = opts.estimateSize();
|
|
26
|
+
const visible = Math.min(count, VISIBLE_WINDOW);
|
|
27
|
+
const items = [];
|
|
28
|
+
for (let i = 0; i < visible; i++) {
|
|
29
|
+
items.push({ index: i, key: String(i), start: i * size, size });
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
getVirtualItems: () => items,
|
|
33
|
+
getTotalSize: () => count * size,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// --- Data generators ---
|
|
39
|
+
function generateRows(count: number) {
|
|
40
|
+
const rows = [];
|
|
41
|
+
for (let i = 0; i < count; i++) {
|
|
42
|
+
rows.push({
|
|
43
|
+
id: i,
|
|
44
|
+
name: `User ${i}`,
|
|
45
|
+
email: `user${i}@example.com`,
|
|
46
|
+
department: `Dept ${i % 10}`,
|
|
47
|
+
salary: 50000 + (i * 100),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const benchmarkColumns: VirtualGridColumn[] = [
|
|
54
|
+
{ header: 'ID', accessorKey: 'id' },
|
|
55
|
+
{ header: 'Name', accessorKey: 'name' },
|
|
56
|
+
{ header: 'Email', accessorKey: 'email' },
|
|
57
|
+
{ header: 'Department', accessorKey: 'department' },
|
|
58
|
+
{ header: 'Salary', accessorKey: 'salary', align: 'right' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
type VirtualGridComponent = React.FC<VirtualGridProps>;
|
|
62
|
+
|
|
63
|
+
let VirtualGrid: VirtualGridComponent;
|
|
64
|
+
|
|
65
|
+
beforeEach(async () => {
|
|
66
|
+
cleanup();
|
|
67
|
+
vi.resetModules();
|
|
68
|
+
const mod = await import('../VirtualGrid');
|
|
69
|
+
VirtualGrid = mod.VirtualGrid;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function renderGrid(overrides: Partial<VirtualGridProps> = {}) {
|
|
73
|
+
const props: VirtualGridProps = {
|
|
74
|
+
data: [],
|
|
75
|
+
columns: benchmarkColumns,
|
|
76
|
+
...overrides,
|
|
77
|
+
};
|
|
78
|
+
return render(<VirtualGrid {...props} />);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =========================================================================
|
|
82
|
+
// Performance Benchmarks
|
|
83
|
+
// =========================================================================
|
|
84
|
+
|
|
85
|
+
describe('VirtualGrid: performance benchmarks', () => {
|
|
86
|
+
it('renders 1,000 rows under 500ms', () => {
|
|
87
|
+
const data = generateRows(1_000);
|
|
88
|
+
|
|
89
|
+
const start = performance.now();
|
|
90
|
+
const { container } = renderGrid({ data });
|
|
91
|
+
const elapsed = performance.now() - start;
|
|
92
|
+
|
|
93
|
+
expect(container.querySelector('.overflow-auto')).toBeInTheDocument();
|
|
94
|
+
expect(elapsed).toBeLessThan(500);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('renders 10,000 rows under 2,000ms', () => {
|
|
98
|
+
const data = generateRows(10_000);
|
|
99
|
+
|
|
100
|
+
const start = performance.now();
|
|
101
|
+
const { container } = renderGrid({ data });
|
|
102
|
+
const elapsed = performance.now() - start;
|
|
103
|
+
|
|
104
|
+
expect(container.querySelector('.overflow-auto')).toBeInTheDocument();
|
|
105
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders 50,000 rows without crashing', () => {
|
|
109
|
+
const data = generateRows(50_000);
|
|
110
|
+
|
|
111
|
+
const start = performance.now();
|
|
112
|
+
const { container } = renderGrid({ data });
|
|
113
|
+
const elapsed = performance.now() - start;
|
|
114
|
+
|
|
115
|
+
expect(container.querySelector('.overflow-auto')).toBeInTheDocument();
|
|
116
|
+
// Virtual scrolling should keep render time manageable even at 50K
|
|
117
|
+
expect(elapsed).toBeLessThan(10_000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('data generation for 10,000 rows is fast (< 200ms)', () => {
|
|
121
|
+
const start = performance.now();
|
|
122
|
+
const data = generateRows(10_000);
|
|
123
|
+
const elapsed = performance.now() - start;
|
|
124
|
+
|
|
125
|
+
expect(data).toHaveLength(10_000);
|
|
126
|
+
expect(elapsed).toBeLessThan(200);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// =========================================================================
|
|
131
|
+
// Scaling characteristics
|
|
132
|
+
// =========================================================================
|
|
133
|
+
|
|
134
|
+
describe('VirtualGrid: scaling characteristics', () => {
|
|
135
|
+
it('shows correct row count in footer for large dataset', () => {
|
|
136
|
+
const data = generateRows(1_000);
|
|
137
|
+
renderGrid({ data });
|
|
138
|
+
|
|
139
|
+
// Footer displays total count regardless of virtualised window
|
|
140
|
+
expect(document.body.textContent).toContain('1000');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders with many columns (20+) without degradation', () => {
|
|
144
|
+
const columns: VirtualGridColumn[] = Array.from({ length: 20 }, (_, i) => ({
|
|
145
|
+
header: `Col ${i}`,
|
|
146
|
+
accessorKey: `field${i}`,
|
|
147
|
+
}));
|
|
148
|
+
const data = Array.from({ length: 500 }, (_, row) =>
|
|
149
|
+
Object.fromEntries(columns.map((c) => [c.accessorKey, `R${row}-${c.accessorKey}`])),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const start = performance.now();
|
|
153
|
+
const { container } = renderGrid({ columns, data });
|
|
154
|
+
const elapsed = performance.now() - start;
|
|
155
|
+
|
|
156
|
+
expect(container.querySelector('.overflow-auto')).toBeInTheDocument();
|
|
157
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('custom cell renderers do not drastically increase render time for 1,000 rows', () => {
|
|
161
|
+
const columnsWithRenderers: VirtualGridColumn[] = [
|
|
162
|
+
{
|
|
163
|
+
header: 'Name',
|
|
164
|
+
accessorKey: 'name',
|
|
165
|
+
cell: (value: string) => <strong>{value}</strong>,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
header: 'Email',
|
|
169
|
+
accessorKey: 'email',
|
|
170
|
+
cell: (value: string) => <a href={`mailto:${value}`}>{value}</a>,
|
|
171
|
+
},
|
|
172
|
+
{ header: 'Department', accessorKey: 'department' },
|
|
173
|
+
];
|
|
174
|
+
const data = generateRows(1_000);
|
|
175
|
+
|
|
176
|
+
const start = performance.now();
|
|
177
|
+
renderGrid({ columns: columnsWithRenderers, data });
|
|
178
|
+
const elapsed = performance.now() - start;
|
|
179
|
+
|
|
180
|
+
expect(elapsed).toBeLessThan(1_000);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* P3.3 Plugin View Robustness - Grid View States
|
|
11
|
+
*
|
|
12
|
+
* Tests empty, loading, error states for VirtualGrid component,
|
|
13
|
+
* and edge cases like single-row data, many columns, and missing fields.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
17
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
18
|
+
import '@testing-library/jest-dom';
|
|
19
|
+
import React from 'react';
|
|
20
|
+
import type { VirtualGridColumn, VirtualGridProps } from '../VirtualGrid';
|
|
21
|
+
|
|
22
|
+
// Mock @tanstack/react-virtual
|
|
23
|
+
vi.mock('@tanstack/react-virtual', () => ({
|
|
24
|
+
useVirtualizer: (opts: any) => {
|
|
25
|
+
const count: number = opts.count;
|
|
26
|
+
const size: number = opts.estimateSize();
|
|
27
|
+
const items = [];
|
|
28
|
+
for (let i = 0; i < count; i++) {
|
|
29
|
+
items.push({ index: i, key: String(i), start: i * size, size });
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
getVirtualItems: () => items,
|
|
33
|
+
getTotalSize: () => count * size,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const defaultColumns: VirtualGridColumn[] = [
|
|
39
|
+
{ header: 'Name', accessorKey: 'name' },
|
|
40
|
+
{ header: 'Email', accessorKey: 'email' },
|
|
41
|
+
{ header: 'Status', accessorKey: 'status' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
type VirtualGridComponent = React.FC<VirtualGridProps>;
|
|
45
|
+
let VirtualGrid: VirtualGridComponent;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
cleanup();
|
|
49
|
+
vi.resetModules();
|
|
50
|
+
const mod = await import('../VirtualGrid');
|
|
51
|
+
VirtualGrid = mod.VirtualGrid;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function renderGrid(overrides: Partial<VirtualGridProps> = {}) {
|
|
55
|
+
return render(
|
|
56
|
+
<VirtualGrid
|
|
57
|
+
data={[]}
|
|
58
|
+
columns={defaultColumns}
|
|
59
|
+
{...overrides}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('P3.3 Grid View States', () => {
|
|
65
|
+
// ---------------------------------------------------------------
|
|
66
|
+
// Empty state
|
|
67
|
+
// ---------------------------------------------------------------
|
|
68
|
+
describe('empty state', () => {
|
|
69
|
+
it('renders column headers with empty data', () => {
|
|
70
|
+
renderGrid({ data: [] });
|
|
71
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
72
|
+
expect(screen.getByText('Email')).toBeInTheDocument();
|
|
73
|
+
expect(screen.getByText('Status')).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('shows 0 rows in footer for empty data', () => {
|
|
77
|
+
renderGrid({ data: [] });
|
|
78
|
+
expect(screen.getByText(/Showing 0 of 0 rows/)).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not render any data cells for empty data', () => {
|
|
82
|
+
renderGrid({ data: [] });
|
|
83
|
+
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders with empty columns array', () => {
|
|
87
|
+
const { container } = renderGrid({ data: [], columns: [] });
|
|
88
|
+
expect(container.firstElementChild).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------
|
|
93
|
+
// Normal data rendering
|
|
94
|
+
// ---------------------------------------------------------------
|
|
95
|
+
describe('normal data rendering', () => {
|
|
96
|
+
const sampleData = [
|
|
97
|
+
{ name: 'Alice', email: 'alice@test.com', status: 'active' },
|
|
98
|
+
{ name: 'Bob', email: 'bob@test.com', status: 'inactive' },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
it('renders all rows', () => {
|
|
102
|
+
renderGrid({ data: sampleData });
|
|
103
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
104
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('shows correct row count', () => {
|
|
108
|
+
renderGrid({ data: sampleData });
|
|
109
|
+
expect(screen.getByText(/Showing 2 of 2 rows/)).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('renders all column values', () => {
|
|
113
|
+
renderGrid({ data: sampleData });
|
|
114
|
+
expect(screen.getByText('alice@test.com')).toBeInTheDocument();
|
|
115
|
+
expect(screen.getByText('active')).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------
|
|
120
|
+
// Edge cases
|
|
121
|
+
// ---------------------------------------------------------------
|
|
122
|
+
describe('edge cases', () => {
|
|
123
|
+
it('renders single row', () => {
|
|
124
|
+
renderGrid({ data: [{ name: 'Solo', email: 'solo@test.com', status: 'ok' }] });
|
|
125
|
+
expect(screen.getByText('Solo')).toBeInTheDocument();
|
|
126
|
+
expect(screen.getByText(/Showing 1 of 1 rows/)).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('handles row with missing fields gracefully', () => {
|
|
130
|
+
renderGrid({
|
|
131
|
+
data: [{ name: 'Partial' }],
|
|
132
|
+
});
|
|
133
|
+
expect(screen.getByText('Partial')).toBeInTheDocument();
|
|
134
|
+
// Missing email and status should not crash
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('handles many columns', () => {
|
|
138
|
+
const cols: VirtualGridColumn[] = Array.from({ length: 20 }, (_, i) => ({
|
|
139
|
+
header: `Col${i}`,
|
|
140
|
+
accessorKey: `field${i}`,
|
|
141
|
+
}));
|
|
142
|
+
const data = [Object.fromEntries(cols.map((c, i) => [c.accessorKey, `val${i}`]))];
|
|
143
|
+
|
|
144
|
+
renderGrid({ columns: cols, data });
|
|
145
|
+
expect(screen.getByText('Col0')).toBeInTheDocument();
|
|
146
|
+
expect(screen.getByText('Col19')).toBeInTheDocument();
|
|
147
|
+
expect(screen.getByText('val0')).toBeInTheDocument();
|
|
148
|
+
expect(screen.getByText('val19')).toBeInTheDocument();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('handles data with null/undefined field values', () => {
|
|
152
|
+
renderGrid({
|
|
153
|
+
data: [{ name: null, email: undefined, status: 'ok' }],
|
|
154
|
+
});
|
|
155
|
+
expect(screen.getByText('ok')).toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('handles data with numeric values', () => {
|
|
159
|
+
const cols: VirtualGridColumn[] = [
|
|
160
|
+
{ header: 'ID', accessorKey: 'id' },
|
|
161
|
+
{ header: 'Score', accessorKey: 'score' },
|
|
162
|
+
];
|
|
163
|
+
renderGrid({
|
|
164
|
+
columns: cols,
|
|
165
|
+
data: [{ id: 1, score: 99.5 }],
|
|
166
|
+
});
|
|
167
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
168
|
+
expect(screen.getByText('99.5')).toBeInTheDocument();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('handles data with boolean values', () => {
|
|
172
|
+
const cols: VirtualGridColumn[] = [
|
|
173
|
+
{ header: 'Name', accessorKey: 'name' },
|
|
174
|
+
{ header: 'Active', accessorKey: 'active' },
|
|
175
|
+
];
|
|
176
|
+
renderGrid({
|
|
177
|
+
columns: cols,
|
|
178
|
+
data: [{ name: 'Test', active: true }],
|
|
179
|
+
});
|
|
180
|
+
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------
|
|
185
|
+
// Custom className support
|
|
186
|
+
// ---------------------------------------------------------------
|
|
187
|
+
describe('className support in states', () => {
|
|
188
|
+
it('applies className to empty grid', () => {
|
|
189
|
+
const { container } = renderGrid({ data: [], className: 'my-grid' });
|
|
190
|
+
const root = container.firstElementChild as HTMLElement;
|
|
191
|
+
expect(root).toHaveClass('my-grid');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('applies className to populated grid', () => {
|
|
195
|
+
const { container } = renderGrid({
|
|
196
|
+
data: [{ name: 'A', email: 'a@t.com', status: 'ok' }],
|
|
197
|
+
className: 'styled-grid',
|
|
198
|
+
});
|
|
199
|
+
const root = container.firstElementChild as HTMLElement;
|
|
200
|
+
expect(root).toHaveClass('styled-grid');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -13,8 +13,13 @@ import { ObjectGrid } from './ObjectGrid';
|
|
|
13
13
|
import { VirtualGrid } from './VirtualGrid';
|
|
14
14
|
|
|
15
15
|
export { ObjectGrid, VirtualGrid };
|
|
16
|
+
export { InlineEditing } from './InlineEditing';
|
|
17
|
+
export { useRowColor } from './useRowColor';
|
|
18
|
+
export { useGroupedData } from './useGroupedData';
|
|
16
19
|
export type { ObjectGridProps } from './ObjectGrid';
|
|
17
20
|
export type { VirtualGridProps, VirtualGridColumn } from './VirtualGrid';
|
|
21
|
+
export type { InlineEditingProps } from './InlineEditing';
|
|
22
|
+
export type { GroupEntry, UseGroupedDataResult } from './useGroupedData';
|
|
18
23
|
|
|
19
24
|
// Register object-grid component
|
|
20
25
|
export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
10
|
+
import type { GroupingConfig } from '@object-ui/types';
|
|
11
|
+
|
|
12
|
+
export interface GroupEntry {
|
|
13
|
+
/** Composite key identifying this group (field values joined by ' / ') */
|
|
14
|
+
key: string;
|
|
15
|
+
/** Display label for the group header */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Rows belonging to this group */
|
|
18
|
+
rows: any[];
|
|
19
|
+
/** Whether the group section is collapsed */
|
|
20
|
+
collapsed: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseGroupedDataResult {
|
|
24
|
+
/** Grouped entries (empty when grouping is not configured) */
|
|
25
|
+
groups: GroupEntry[];
|
|
26
|
+
/** Whether grouping is active */
|
|
27
|
+
isGrouped: boolean;
|
|
28
|
+
/** Toggle the collapsed state of a group by its key */
|
|
29
|
+
toggleGroup: (key: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build a composite group key from a row based on the grouping fields.
|
|
34
|
+
*/
|
|
35
|
+
function buildGroupKey(row: Record<string, any>, fields: GroupingConfig['fields']): string {
|
|
36
|
+
return fields.map((f) => String(row[f.field] ?? '')).join(' / ');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a human-readable label from a row based on the grouping fields.
|
|
41
|
+
*/
|
|
42
|
+
function buildGroupLabel(row: Record<string, any>, fields: GroupingConfig['fields']): string {
|
|
43
|
+
return fields
|
|
44
|
+
.map((f) => {
|
|
45
|
+
const val = row[f.field];
|
|
46
|
+
return val !== undefined && val !== null && val !== '' ? String(val) : '(empty)';
|
|
47
|
+
})
|
|
48
|
+
.join(' / ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compare function that respects per-field sort order.
|
|
53
|
+
*/
|
|
54
|
+
function compareGroups(a: string, b: string, order: 'asc' | 'desc'): number {
|
|
55
|
+
const cmp = a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
56
|
+
return order === 'desc' ? -cmp : cmp;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hook that groups a flat data array by the fields specified in GroupingConfig.
|
|
61
|
+
*
|
|
62
|
+
* Supports multi-level grouping, per-field sort order, and per-field default
|
|
63
|
+
* collapsed state. Collapse state is managed internally so the consumer only
|
|
64
|
+
* needs to wire `toggleGroup` to the UI.
|
|
65
|
+
*
|
|
66
|
+
* @param config - GroupingConfig from the grid schema (optional)
|
|
67
|
+
* @param data - flat data rows
|
|
68
|
+
*/
|
|
69
|
+
export function useGroupedData(
|
|
70
|
+
config: GroupingConfig | undefined,
|
|
71
|
+
data: any[],
|
|
72
|
+
): UseGroupedDataResult {
|
|
73
|
+
const fields = config?.fields;
|
|
74
|
+
const isGrouped = !!(fields && fields.length > 0);
|
|
75
|
+
|
|
76
|
+
// Track which group keys have been explicitly toggled by the user.
|
|
77
|
+
const [toggledKeys, setToggledKeys] = useState<Record<string, boolean>>({});
|
|
78
|
+
|
|
79
|
+
// Determine whether a field set defaults to collapsed.
|
|
80
|
+
const fieldsDefaultCollapsed = useMemo(() => {
|
|
81
|
+
if (!fields) return false;
|
|
82
|
+
// If any grouping field has collapsed: true, default all groups to collapsed.
|
|
83
|
+
return fields.some((f) => f.collapsed);
|
|
84
|
+
}, [fields]);
|
|
85
|
+
|
|
86
|
+
const groups: GroupEntry[] = useMemo(() => {
|
|
87
|
+
if (!isGrouped || !fields) return [];
|
|
88
|
+
|
|
89
|
+
// Group rows by composite key
|
|
90
|
+
const map = new Map<string, { label: string; rows: any[] }>();
|
|
91
|
+
const keyOrder: string[] = [];
|
|
92
|
+
|
|
93
|
+
for (const row of data) {
|
|
94
|
+
const key = buildGroupKey(row, fields);
|
|
95
|
+
if (!map.has(key)) {
|
|
96
|
+
map.set(key, { label: buildGroupLabel(row, fields), rows: [] });
|
|
97
|
+
keyOrder.push(key);
|
|
98
|
+
}
|
|
99
|
+
map.get(key)!.rows.push(row);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Sort groups using the first grouping field's order
|
|
103
|
+
const primaryOrder = fields[0]?.order ?? 'asc';
|
|
104
|
+
keyOrder.sort((a, b) => compareGroups(a, b, primaryOrder));
|
|
105
|
+
|
|
106
|
+
return keyOrder.map((key) => {
|
|
107
|
+
const entry = map.get(key)!;
|
|
108
|
+
const collapsed =
|
|
109
|
+
key in toggledKeys ? toggledKeys[key] : fieldsDefaultCollapsed;
|
|
110
|
+
return { key, label: entry.label, rows: entry.rows, collapsed };
|
|
111
|
+
});
|
|
112
|
+
}, [data, fields, isGrouped, toggledKeys, fieldsDefaultCollapsed]);
|
|
113
|
+
|
|
114
|
+
const toggleGroup = useCallback((key: string) => {
|
|
115
|
+
setToggledKeys((prev) => ({
|
|
116
|
+
...prev,
|
|
117
|
+
[key]: prev[key] !== undefined ? !prev[key] : !fieldsDefaultCollapsed,
|
|
118
|
+
}));
|
|
119
|
+
}, [fieldsDefaultCollapsed]);
|
|
120
|
+
|
|
121
|
+
return { groups, isGrouped, toggleGroup };
|
|
122
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback } from 'react';
|
|
10
|
+
import type { RowColorConfig } from '@object-ui/types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CSS color to Tailwind-compatible background class mapping.
|
|
14
|
+
* For colors not in this map, a CSS custom property approach is used.
|
|
15
|
+
*/
|
|
16
|
+
const COLOR_TO_CLASS: Record<string, string> = {
|
|
17
|
+
red: 'bg-red-100',
|
|
18
|
+
green: 'bg-green-100',
|
|
19
|
+
blue: 'bg-blue-100',
|
|
20
|
+
yellow: 'bg-yellow-100',
|
|
21
|
+
orange: 'bg-orange-100',
|
|
22
|
+
purple: 'bg-purple-100',
|
|
23
|
+
pink: 'bg-pink-100',
|
|
24
|
+
gray: 'bg-gray-100',
|
|
25
|
+
grey: 'bg-gray-100',
|
|
26
|
+
indigo: 'bg-indigo-100',
|
|
27
|
+
teal: 'bg-teal-100',
|
|
28
|
+
cyan: 'bg-cyan-100',
|
|
29
|
+
amber: 'bg-amber-100',
|
|
30
|
+
lime: 'bg-lime-100',
|
|
31
|
+
emerald: 'bg-emerald-100',
|
|
32
|
+
rose: 'bg-rose-100',
|
|
33
|
+
sky: 'bg-sky-100',
|
|
34
|
+
violet: 'bg-violet-100',
|
|
35
|
+
fuchsia: 'bg-fuchsia-100',
|
|
36
|
+
slate: 'bg-slate-100',
|
|
37
|
+
zinc: 'bg-zinc-100',
|
|
38
|
+
stone: 'bg-stone-100',
|
|
39
|
+
neutral: 'bg-neutral-100',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Maps a CSS color string to a Tailwind background class.
|
|
44
|
+
* Falls back to undefined for unrecognised values (dynamic CSS colors
|
|
45
|
+
* that cannot be represented as static Tailwind classes).
|
|
46
|
+
*/
|
|
47
|
+
function colorToClass(color: string): string | undefined {
|
|
48
|
+
// Direct Tailwind class (e.g. "bg-red-200")
|
|
49
|
+
if (color.startsWith('bg-')) return color;
|
|
50
|
+
|
|
51
|
+
const lower = color.toLowerCase().trim();
|
|
52
|
+
return COLOR_TO_CLASS[lower];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hook that returns a row-className resolver based on RowColorConfig.
|
|
57
|
+
*
|
|
58
|
+
* @param config - RowColorConfig from the grid schema (optional)
|
|
59
|
+
* @returns a function `(row) => className | undefined`
|
|
60
|
+
*/
|
|
61
|
+
export function useRowColor(config: RowColorConfig | undefined) {
|
|
62
|
+
return useCallback(
|
|
63
|
+
(row: Record<string, any>): string | undefined => {
|
|
64
|
+
if (!config?.field || !config.colors) return undefined;
|
|
65
|
+
|
|
66
|
+
const value = String(row[config.field] ?? '');
|
|
67
|
+
const color = config.colors[value];
|
|
68
|
+
if (!color) return undefined;
|
|
69
|
+
|
|
70
|
+
return colorToClass(color);
|
|
71
|
+
},
|
|
72
|
+
[config?.field, config?.colors],
|
|
73
|
+
);
|
|
74
|
+
}
|