@object-ui/plugin-grid 3.3.0 → 3.3.2
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/CHANGELOG.md +23 -0
- package/README.md +21 -1
- package/dist/index.js +631 -599
- package/dist/index.umd.cjs +8 -8
- package/package.json +44 -12
- package/.turbo/turbo-build.log +0 -32
- package/src/FormulaBar.tsx +0 -151
- package/src/GroupRow.tsx +0 -69
- package/src/ImportWizard.tsx +0 -412
- package/src/InlineEditing.tsx +0 -235
- package/src/ListColumnExtensions.test.tsx +0 -373
- package/src/ListColumnSchema.test.ts +0 -88
- package/src/ObjectGrid.EdgeCases.stories.tsx +0 -147
- package/src/ObjectGrid.msw.test.tsx +0 -130
- package/src/ObjectGrid.stories.tsx +0 -139
- package/src/ObjectGrid.tsx +0 -1598
- package/src/SplitPaneGrid.tsx +0 -120
- package/src/VirtualGrid.tsx +0 -183
- package/src/__tests__/GroupRow.test.tsx +0 -206
- package/src/__tests__/ImportPreview.test.tsx +0 -171
- package/src/__tests__/InlineEditing.test.tsx +0 -360
- package/src/__tests__/VirtualGrid.test.tsx +0 -438
- package/src/__tests__/accessibility.test.tsx +0 -254
- package/src/__tests__/accessorKey-inference.test.tsx +0 -132
- package/src/__tests__/airtable-style.test.tsx +0 -508
- package/src/__tests__/column-features.test.tsx +0 -490
- package/src/__tests__/grid-export.test.tsx +0 -121
- package/src/__tests__/mobile-card-view.test.tsx +0 -355
- package/src/__tests__/objectdef-enrichment.test.tsx +0 -566
- package/src/__tests__/performance-benchmark.test.tsx +0 -182
- package/src/__tests__/phase11-features.test.tsx +0 -418
- package/src/__tests__/row-bulk-actions.test.tsx +0 -413
- package/src/__tests__/row-height.test.tsx +0 -160
- package/src/__tests__/useGroupedData.test.ts +0 -165
- package/src/__tests__/view-states.test.tsx +0 -203
- package/src/components/BulkActionBar.tsx +0 -66
- package/src/components/RowActionMenu.tsx +0 -91
- package/src/index.test.tsx +0 -29
- package/src/index.tsx +0 -99
- package/src/useCellClipboard.ts +0 -136
- package/src/useColumnSummary.ts +0 -128
- package/src/useGradientColor.ts +0 -103
- package/src/useGroupReorder.ts +0 -123
- package/src/useGroupedData.ts +0 -187
- package/src/useRowColor.ts +0 -74
- package/tsconfig.json +0 -9
- package/vite.config.ts +0 -58
- package/vitest.config.ts +0 -13
- package/vitest.setup.ts +0 -1
|
@@ -1,165 +0,0 @@
|
|
|
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 { describe, it, expect } from 'vitest';
|
|
10
|
-
import { renderHook, act } from '@testing-library/react';
|
|
11
|
-
import { useGroupedData } from '../useGroupedData';
|
|
12
|
-
|
|
13
|
-
const sampleData = [
|
|
14
|
-
{ category: 'A', priority: 'High', amount: 10 },
|
|
15
|
-
{ category: 'A', priority: 'Low', amount: 20 },
|
|
16
|
-
{ category: 'B', priority: 'High', amount: 30 },
|
|
17
|
-
{ category: 'B', priority: 'Medium', amount: 40 },
|
|
18
|
-
{ category: 'C', priority: 'Low', amount: 50 },
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
describe('useGroupedData – collapsed state management', () => {
|
|
22
|
-
it('returns isGrouped=false when config is undefined', () => {
|
|
23
|
-
const { result } = renderHook(() => useGroupedData(undefined, sampleData));
|
|
24
|
-
expect(result.current.isGrouped).toBe(false);
|
|
25
|
-
expect(result.current.groups).toEqual([]);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('returns isGrouped=false when config has empty fields', () => {
|
|
29
|
-
const { result } = renderHook(() => useGroupedData({ fields: [] }, sampleData));
|
|
30
|
-
expect(result.current.isGrouped).toBe(false);
|
|
31
|
-
expect(result.current.groups).toEqual([]);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('groups data correctly with single field', () => {
|
|
35
|
-
const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
|
|
36
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
37
|
-
|
|
38
|
-
expect(result.current.isGrouped).toBe(true);
|
|
39
|
-
expect(result.current.groups).toHaveLength(3);
|
|
40
|
-
expect(result.current.groups[0].key).toBe('A');
|
|
41
|
-
expect(result.current.groups[0].rows).toHaveLength(2);
|
|
42
|
-
expect(result.current.groups[1].key).toBe('B');
|
|
43
|
-
expect(result.current.groups[1].rows).toHaveLength(2);
|
|
44
|
-
expect(result.current.groups[2].key).toBe('C');
|
|
45
|
-
expect(result.current.groups[2].rows).toHaveLength(1);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('all groups default to expanded when collapsed=false', () => {
|
|
49
|
-
const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
|
|
50
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
51
|
-
|
|
52
|
-
result.current.groups.forEach((group) => {
|
|
53
|
-
expect(group.collapsed).toBe(false);
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('all groups default to collapsed when collapsed=true', () => {
|
|
58
|
-
const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: true }] };
|
|
59
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
60
|
-
|
|
61
|
-
result.current.groups.forEach((group) => {
|
|
62
|
-
expect(group.collapsed).toBe(true);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('toggleGroup toggles a group from expanded to collapsed', () => {
|
|
67
|
-
const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
|
|
68
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
69
|
-
|
|
70
|
-
// Initially all expanded
|
|
71
|
-
expect(result.current.groups[0].collapsed).toBe(false);
|
|
72
|
-
|
|
73
|
-
// Toggle group A
|
|
74
|
-
act(() => {
|
|
75
|
-
result.current.toggleGroup('A');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
expect(result.current.groups[0].collapsed).toBe(true);
|
|
79
|
-
// Other groups remain expanded
|
|
80
|
-
expect(result.current.groups[1].collapsed).toBe(false);
|
|
81
|
-
expect(result.current.groups[2].collapsed).toBe(false);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('toggleGroup toggles a group from collapsed back to expanded', () => {
|
|
85
|
-
const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
|
|
86
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
87
|
-
|
|
88
|
-
// Toggle twice: expand -> collapse -> expand
|
|
89
|
-
act(() => {
|
|
90
|
-
result.current.toggleGroup('A');
|
|
91
|
-
});
|
|
92
|
-
expect(result.current.groups[0].collapsed).toBe(true);
|
|
93
|
-
|
|
94
|
-
act(() => {
|
|
95
|
-
result.current.toggleGroup('A');
|
|
96
|
-
});
|
|
97
|
-
expect(result.current.groups[0].collapsed).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('toggleGroup expands a group that defaults to collapsed', () => {
|
|
101
|
-
const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: true }] };
|
|
102
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
103
|
-
|
|
104
|
-
// Initially all collapsed
|
|
105
|
-
expect(result.current.groups[0].collapsed).toBe(true);
|
|
106
|
-
|
|
107
|
-
// Toggle group A to expand
|
|
108
|
-
act(() => {
|
|
109
|
-
result.current.toggleGroup('A');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
expect(result.current.groups[0].collapsed).toBe(false);
|
|
113
|
-
// Other groups remain collapsed
|
|
114
|
-
expect(result.current.groups[1].collapsed).toBe(true);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('sorts groups in descending order when configured', () => {
|
|
118
|
-
const config = { fields: [{ field: 'category', order: 'desc' as const, collapsed: false }] };
|
|
119
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
120
|
-
|
|
121
|
-
expect(result.current.groups[0].key).toBe('C');
|
|
122
|
-
expect(result.current.groups[1].key).toBe('B');
|
|
123
|
-
expect(result.current.groups[2].key).toBe('A');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('builds correct labels for groups', () => {
|
|
127
|
-
const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
|
|
128
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
129
|
-
|
|
130
|
-
expect(result.current.groups[0].label).toBe('A');
|
|
131
|
-
expect(result.current.groups[1].label).toBe('B');
|
|
132
|
-
expect(result.current.groups[2].label).toBe('C');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('shows (empty) label for rows with missing grouping field', () => {
|
|
136
|
-
const data = [
|
|
137
|
-
{ category: 'A', amount: 10 },
|
|
138
|
-
{ amount: 20 }, // no category
|
|
139
|
-
{ category: '', amount: 30 }, // empty category
|
|
140
|
-
];
|
|
141
|
-
const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] };
|
|
142
|
-
const { result } = renderHook(() => useGroupedData(config, data));
|
|
143
|
-
|
|
144
|
-
const emptyGroup = result.current.groups.find((g) => g.label === '(empty)');
|
|
145
|
-
expect(emptyGroup).toBeDefined();
|
|
146
|
-
expect(emptyGroup!.rows).toHaveLength(2);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('supports multi-field grouping', () => {
|
|
150
|
-
const config = {
|
|
151
|
-
fields: [
|
|
152
|
-
{ field: 'category', order: 'asc' as const, collapsed: false },
|
|
153
|
-
{ field: 'priority', order: 'asc' as const, collapsed: false },
|
|
154
|
-
],
|
|
155
|
-
};
|
|
156
|
-
const { result } = renderHook(() => useGroupedData(config, sampleData));
|
|
157
|
-
|
|
158
|
-
expect(result.current.isGrouped).toBe(true);
|
|
159
|
-
// Each unique combination of category + priority should be a group
|
|
160
|
-
expect(result.current.groups.length).toBeGreaterThanOrEqual(4);
|
|
161
|
-
// Check label format is "A / High"
|
|
162
|
-
const firstGroup = result.current.groups[0];
|
|
163
|
-
expect(firstGroup.label).toContain(' / ');
|
|
164
|
-
});
|
|
165
|
-
});
|
|
@@ -1,203 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
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 React from 'react';
|
|
10
|
-
import { Button } from '@object-ui/components';
|
|
11
|
-
import { formatActionLabel } from './RowActionMenu';
|
|
12
|
-
|
|
13
|
-
export interface BulkActionBarProps {
|
|
14
|
-
/** Array of selected row records */
|
|
15
|
-
selectedRows: any[];
|
|
16
|
-
/** Bulk/batch action identifiers */
|
|
17
|
-
actions: string[];
|
|
18
|
-
/** Callback when a bulk action button is clicked */
|
|
19
|
-
onAction?: (action: string, selectedRows: any[]) => void;
|
|
20
|
-
/** Callback to clear selection */
|
|
21
|
-
onClearSelection?: () => void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const BulkActionBar: React.FC<BulkActionBarProps> = ({
|
|
25
|
-
selectedRows,
|
|
26
|
-
actions,
|
|
27
|
-
onAction,
|
|
28
|
-
onClearSelection,
|
|
29
|
-
}) => {
|
|
30
|
-
if (!actions || actions.length === 0 || selectedRows.length === 0) {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<div
|
|
36
|
-
className="border-t px-4 py-1.5 flex items-center gap-2 text-xs bg-primary/5 shrink-0"
|
|
37
|
-
data-testid="bulk-actions-bar"
|
|
38
|
-
>
|
|
39
|
-
<span className="text-muted-foreground font-medium">
|
|
40
|
-
{selectedRows.length} selected
|
|
41
|
-
</span>
|
|
42
|
-
<div className="flex items-center gap-1 ml-2">
|
|
43
|
-
{actions.map(action => (
|
|
44
|
-
<Button
|
|
45
|
-
key={action}
|
|
46
|
-
variant="outline"
|
|
47
|
-
size="sm"
|
|
48
|
-
className="h-6 px-2 text-xs"
|
|
49
|
-
onClick={() => onAction?.(action, selectedRows)}
|
|
50
|
-
data-testid={`bulk-action-${action}`}
|
|
51
|
-
>
|
|
52
|
-
{formatActionLabel(action)}
|
|
53
|
-
</Button>
|
|
54
|
-
))}
|
|
55
|
-
</div>
|
|
56
|
-
<Button
|
|
57
|
-
variant="ghost"
|
|
58
|
-
size="sm"
|
|
59
|
-
className="h-6 px-2 text-xs ml-auto"
|
|
60
|
-
onClick={onClearSelection}
|
|
61
|
-
>
|
|
62
|
-
Clear
|
|
63
|
-
</Button>
|
|
64
|
-
</div>
|
|
65
|
-
);
|
|
66
|
-
};
|
|
@@ -1,91 +0,0 @@
|
|
|
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 React from 'react';
|
|
10
|
-
import {
|
|
11
|
-
Button,
|
|
12
|
-
DropdownMenu,
|
|
13
|
-
DropdownMenuContent,
|
|
14
|
-
DropdownMenuItem,
|
|
15
|
-
DropdownMenuTrigger,
|
|
16
|
-
} from '@object-ui/components';
|
|
17
|
-
import { Edit, Trash2, MoreVertical } from 'lucide-react';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Format an action identifier string into a human-readable label.
|
|
21
|
-
* e.g., 'send_email' → 'Send Email'
|
|
22
|
-
*/
|
|
23
|
-
export function formatActionLabel(action: string): string {
|
|
24
|
-
return action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface RowActionMenuProps {
|
|
28
|
-
/** The row data record */
|
|
29
|
-
row: any;
|
|
30
|
-
/** Custom row action identifiers */
|
|
31
|
-
rowActions?: string[];
|
|
32
|
-
/** Whether edit operation is available */
|
|
33
|
-
canEdit?: boolean;
|
|
34
|
-
/** Whether delete operation is available */
|
|
35
|
-
canDelete?: boolean;
|
|
36
|
-
/** Callback when edit is clicked */
|
|
37
|
-
onEdit?: (row: any) => void;
|
|
38
|
-
/** Callback when delete is clicked */
|
|
39
|
-
onDelete?: (row: any) => void;
|
|
40
|
-
/** Callback when a custom row action is clicked */
|
|
41
|
-
onAction?: (action: string, row: any) => void;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export const RowActionMenu: React.FC<RowActionMenuProps> = ({
|
|
45
|
-
row,
|
|
46
|
-
rowActions,
|
|
47
|
-
canEdit,
|
|
48
|
-
canDelete,
|
|
49
|
-
onEdit,
|
|
50
|
-
onDelete,
|
|
51
|
-
onAction,
|
|
52
|
-
}) => {
|
|
53
|
-
return (
|
|
54
|
-
<DropdownMenu>
|
|
55
|
-
<DropdownMenuTrigger asChild>
|
|
56
|
-
<Button
|
|
57
|
-
variant="ghost"
|
|
58
|
-
size="icon"
|
|
59
|
-
className="h-8 w-8 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0"
|
|
60
|
-
data-testid="row-action-trigger"
|
|
61
|
-
>
|
|
62
|
-
<MoreVertical className="h-4 w-4" />
|
|
63
|
-
<span className="sr-only">Open menu</span>
|
|
64
|
-
</Button>
|
|
65
|
-
</DropdownMenuTrigger>
|
|
66
|
-
<DropdownMenuContent align="end">
|
|
67
|
-
{canEdit && onEdit && (
|
|
68
|
-
<DropdownMenuItem onClick={() => onEdit(row)}>
|
|
69
|
-
<Edit className="mr-2 h-4 w-4" />
|
|
70
|
-
Edit
|
|
71
|
-
</DropdownMenuItem>
|
|
72
|
-
)}
|
|
73
|
-
{canDelete && onDelete && (
|
|
74
|
-
<DropdownMenuItem onClick={() => onDelete(row)}>
|
|
75
|
-
<Trash2 className="mr-2 h-4 w-4" />
|
|
76
|
-
Delete
|
|
77
|
-
</DropdownMenuItem>
|
|
78
|
-
)}
|
|
79
|
-
{rowActions?.map(action => (
|
|
80
|
-
<DropdownMenuItem
|
|
81
|
-
key={action}
|
|
82
|
-
onClick={() => onAction?.(action, row)}
|
|
83
|
-
data-testid={`row-action-${action}`}
|
|
84
|
-
>
|
|
85
|
-
{formatActionLabel(action)}
|
|
86
|
-
</DropdownMenuItem>
|
|
87
|
-
))}
|
|
88
|
-
</DropdownMenuContent>
|
|
89
|
-
</DropdownMenu>
|
|
90
|
-
);
|
|
91
|
-
};
|
package/src/index.test.tsx
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
3
|
-
import React from 'react';
|
|
4
|
-
import { SchemaRendererProvider } from '@object-ui/react';
|
|
5
|
-
import * as ObjectGridModule from './ObjectGrid';
|
|
6
|
-
import { ObjectGridRenderer } from './index';
|
|
7
|
-
|
|
8
|
-
describe('Plugin Grid Registration', () => {
|
|
9
|
-
it('renderer passes dataSource from context', async () => {
|
|
10
|
-
// Spy and mock implementation
|
|
11
|
-
vi.spyOn(ObjectGridModule, 'ObjectGrid').mockImplementation(
|
|
12
|
-
(({ dataSource }: any) => (
|
|
13
|
-
<div data-testid="grid-mock">
|
|
14
|
-
{dataSource ? `DataSource: ${dataSource.type}` : 'No DataSource'}
|
|
15
|
-
</div>
|
|
16
|
-
)) as any
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
render(
|
|
20
|
-
<SchemaRendererProvider dataSource={{ type: 'mock-datasource' } as any}>
|
|
21
|
-
<ObjectGridRenderer schema={{ type: 'object-grid' }} />
|
|
22
|
-
</SchemaRendererProvider>
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
// Use findByTestId for async safety
|
|
26
|
-
const element = await screen.findByTestId('grid-mock', {}, { timeout: 5000 });
|
|
27
|
-
expect(element).toHaveTextContent('DataSource: mock-datasource');
|
|
28
|
-
});
|
|
29
|
-
});
|
package/src/index.tsx
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
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 React from 'react';
|
|
10
|
-
import { ComponentRegistry } from '@object-ui/core';
|
|
11
|
-
import { useSchemaContext } from '@object-ui/react';
|
|
12
|
-
import { ObjectGrid } from './ObjectGrid';
|
|
13
|
-
import { VirtualGrid } from './VirtualGrid';
|
|
14
|
-
import { ImportWizard } from './ImportWizard';
|
|
15
|
-
|
|
16
|
-
export { ObjectGrid, VirtualGrid, ImportWizard };
|
|
17
|
-
export { InlineEditing } from './InlineEditing';
|
|
18
|
-
export { useRowColor } from './useRowColor';
|
|
19
|
-
export { useGroupedData } from './useGroupedData';
|
|
20
|
-
export { GroupRow } from './GroupRow';
|
|
21
|
-
export { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
|
|
22
|
-
export { BulkActionBar } from './components/BulkActionBar';
|
|
23
|
-
export { useCellClipboard } from './useCellClipboard';
|
|
24
|
-
export { useGradientColor } from './useGradientColor';
|
|
25
|
-
export { useGroupReorder } from './useGroupReorder';
|
|
26
|
-
export { useColumnSummary } from './useColumnSummary';
|
|
27
|
-
export { FormulaBar } from './FormulaBar';
|
|
28
|
-
export { SplitPaneGrid } from './SplitPaneGrid';
|
|
29
|
-
export type { ObjectGridProps } from './ObjectGrid';
|
|
30
|
-
export type { VirtualGridProps, VirtualGridColumn } from './VirtualGrid';
|
|
31
|
-
export type { InlineEditingProps } from './InlineEditing';
|
|
32
|
-
export type { ImportWizardProps, ImportResult } from './ImportWizard';
|
|
33
|
-
export type { GroupEntry, UseGroupedDataResult, AggregationType, AggregationConfig, AggregationResult } from './useGroupedData';
|
|
34
|
-
export type { GroupRowProps } from './GroupRow';
|
|
35
|
-
export type { RowActionMenuProps } from './components/RowActionMenu';
|
|
36
|
-
export type { BulkActionBarProps } from './components/BulkActionBar';
|
|
37
|
-
export type { CellRange, UseCellClipboardOptions, UseCellClipboardResult } from './useCellClipboard';
|
|
38
|
-
export type { GradientStop, UseGradientColorOptions } from './useGradientColor';
|
|
39
|
-
export type { UseGroupReorderOptions, UseGroupReorderResult } from './useGroupReorder';
|
|
40
|
-
export type { ColumnSummaryConfig, ColumnSummaryResult } from './useColumnSummary';
|
|
41
|
-
export type { FormulaBarProps } from './FormulaBar';
|
|
42
|
-
export type { SplitPaneGridProps } from './SplitPaneGrid';
|
|
43
|
-
|
|
44
|
-
// Register object-grid component
|
|
45
|
-
export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => {
|
|
46
|
-
const { dataSource } = useSchemaContext() || {};
|
|
47
|
-
return <ObjectGrid schema={schema} dataSource={dataSource} {...props} />;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
ComponentRegistry.register('object-grid', ObjectGridRenderer, {
|
|
51
|
-
namespace: 'plugin-grid',
|
|
52
|
-
label: 'Object Grid',
|
|
53
|
-
category: 'plugin',
|
|
54
|
-
inputs: [
|
|
55
|
-
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
56
|
-
{ name: 'columns', type: 'array', label: 'Columns' },
|
|
57
|
-
{ name: 'filters', type: 'array', label: 'Filters' },
|
|
58
|
-
]
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// Alias for view namespace - this allows using { type: 'view:grid' } in schemas
|
|
62
|
-
// which is semantically meaningful for data display components
|
|
63
|
-
ComponentRegistry.register('grid', ObjectGridRenderer, {
|
|
64
|
-
namespace: 'view',
|
|
65
|
-
label: 'Data Grid',
|
|
66
|
-
category: 'view',
|
|
67
|
-
inputs: [
|
|
68
|
-
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
69
|
-
{ name: 'columns', type: 'array', label: 'Columns' },
|
|
70
|
-
{ name: 'filters', type: 'array', label: 'Filters' },
|
|
71
|
-
]
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
// Register import-wizard component
|
|
75
|
-
const ImportWizardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => {
|
|
76
|
-
const { dataSource } = useSchemaContext() || {};
|
|
77
|
-
return (
|
|
78
|
-
<ImportWizard
|
|
79
|
-
objectName={schema.objectName}
|
|
80
|
-
objectLabel={schema.objectLabel}
|
|
81
|
-
fields={schema.fields ?? []}
|
|
82
|
-
dataSource={dataSource}
|
|
83
|
-
{...props}
|
|
84
|
-
/>
|
|
85
|
-
);
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
ComponentRegistry.register('import-wizard', ImportWizardRenderer, {
|
|
89
|
-
namespace: 'plugin-grid',
|
|
90
|
-
label: 'Import Wizard',
|
|
91
|
-
category: 'plugin',
|
|
92
|
-
inputs: [
|
|
93
|
-
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
94
|
-
{ name: 'fields', type: 'array', label: 'Fields', required: true },
|
|
95
|
-
]
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// Note: 'grid' type is handled by @object-ui/components Grid layout component
|
|
99
|
-
// This plugin only handles 'object-grid' which integrates with ObjectQL/ObjectStack
|