@object-ui/components 0.5.0 → 2.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 +12 -25
- package/CHANGELOG.md +13 -0
- package/dist/index.css +1 -1
- package/dist/index.js +23366 -22221
- package/dist/index.umd.cjs +30 -30
- package/dist/src/custom/action-param-dialog.d.ts +21 -0
- package/dist/src/custom/index.d.ts +2 -0
- package/dist/src/custom/navigation-overlay.d.ts +50 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/renderers/action/action-button.d.ts +11 -0
- package/dist/src/renderers/action/action-group.d.ts +25 -0
- package/dist/src/renderers/action/action-icon.d.ts +10 -0
- package/dist/src/renderers/action/action-menu.d.ts +19 -0
- package/dist/src/renderers/action/index.d.ts +0 -0
- package/dist/src/renderers/action/resolve-icon.d.ts +6 -0
- package/package.json +9 -8
- package/src/__tests__/PageRendererRegions.test.tsx +664 -55
- package/src/__tests__/compliance.test.tsx +72 -0
- package/src/__tests__/navigation-overlay.test.tsx +273 -0
- package/src/__tests__/view-compliance.test.tsx +153 -0
- package/src/custom/action-param-dialog.tsx +264 -0
- package/src/custom/index.ts +2 -0
- package/src/custom/navigation-overlay.tsx +296 -0
- package/src/index.ts +1 -0
- package/src/renderers/action/action-button.tsx +147 -0
- package/src/renderers/action/action-group.tsx +270 -0
- package/src/renderers/action/action-icon.tsx +150 -0
- package/src/renderers/action/action-menu.tsx +203 -0
- package/src/renderers/action/index.ts +18 -0
- package/src/renderers/action/resolve-icon.ts +35 -0
- package/src/renderers/complex/__tests__/data-table-batch-editing.test.tsx +275 -0
- package/src/renderers/complex/__tests__/data-table-cell-renderer.test.tsx +120 -0
- package/src/renderers/complex/__tests__/data-table-editing.test.tsx +221 -0
- package/src/renderers/complex/data-table.tsx +242 -21
- package/src/renderers/form/form.tsx +23 -4
- package/src/renderers/index.ts +1 -0
- package/src/renderers/layout/page.tsx +416 -52
- package/src/renderers/navigation/sidebar.tsx +6 -0
- package/src/renderers/placeholders.tsx +2 -2
- package/src/stories/Introduction.mdx +54 -27
- package/src/stories/MockedData.stories.tsx +87 -37
- package/src/stories-json/accordion.stories.tsx +1 -1
- package/src/stories-json/aggrid.stories.tsx +1 -1
- package/src/stories-json/alert.stories.tsx +1 -1
- package/src/stories-json/aspect-ratio.stories.tsx +1 -1
- package/src/stories-json/avatar.stories.tsx +1 -1
- package/src/stories-json/badge.stories.tsx +1 -1
- package/src/stories-json/breadcrumb.stories.tsx +1 -1
- package/src/stories-json/button-group.stories.tsx +1 -1
- package/src/stories-json/button.stories.tsx +1 -1
- package/src/stories-json/calendar.stories.tsx +1 -1
- package/src/stories-json/card.stories.tsx +1 -1
- package/src/stories-json/carousel.stories.tsx +1 -1
- package/src/stories-json/charts.stories.tsx +1 -1
- package/src/stories-json/chatbot.stories.tsx +1 -1
- package/src/stories-json/code-editor.stories.tsx +1 -1
- package/src/stories-json/collapsible.stories.tsx +1 -1
- package/src/stories-json/controls.stories.tsx +1 -1
- package/src/stories-json/crm-live-data.stories.tsx +154 -0
- package/src/stories-json/data-table.stories.tsx +80 -4
- package/src/stories-json/data_display_extras.stories.tsx +1 -1
- package/src/stories-json/date-picker.stories.tsx +1 -1
- package/src/stories-json/detail-view.stories.tsx +1 -1
- package/src/stories-json/dialog.stories.tsx +1 -1
- package/src/stories-json/feedback_extras.stories.tsx +1 -1
- package/src/stories-json/feedback_others.stories.tsx +1 -1
- package/src/stories-json/form-variants.stories.tsx +210 -0
- package/src/stories-json/form_advanced.stories.tsx +1 -1
- package/src/stories-json/form_extras.stories.tsx +1 -1
- package/src/stories-json/grid.stories.tsx +1 -1
- package/src/stories-json/icon.stories.tsx +1 -1
- package/src/stories-json/input.stories.tsx +1 -1
- package/src/stories-json/kanban.stories.tsx +1 -1
- package/src/stories-json/layout_extended.stories.tsx +1 -1
- package/src/stories-json/layout_flex.stories.tsx +1 -1
- package/src/stories-json/list-view.stories.tsx +1 -1
- package/src/stories-json/markdown.stories.tsx +1 -1
- package/src/stories-json/menus.stories.tsx +1 -1
- package/src/stories-json/metric-card.stories.tsx +1 -1
- package/src/stories-json/navigation-menu.stories.tsx +1 -1
- package/src/stories-json/object-aggrid-advanced.stories.tsx +389 -0
- package/src/stories-json/object-aggrid.stories.tsx +1 -1
- package/src/stories-json/object-form.stories.tsx +1 -1
- package/src/stories-json/object-gantt.stories.tsx +1 -1
- package/src/stories-json/object-grid.stories.tsx +159 -1
- package/src/stories-json/object-map.stories.tsx +1 -1
- package/src/stories-json/object-view.stories.tsx +1 -1
- package/src/stories-json/overlay_extras.stories.tsx +1 -1
- package/src/stories-json/overlay_others.stories.tsx +1 -1
- package/src/stories-json/resizable.stories.tsx +1 -1
- package/src/stories-json/select.stories.tsx +1 -1
- package/src/stories-json/separator.stories.tsx +1 -1
- package/src/stories-json/statistic.stories.tsx +1 -1
- package/src/stories-json/tabs.stories.tsx +1 -1
- package/src/stories-json/timeline.stories.tsx +1 -1
- package/src/stories-json/typography.stories.tsx +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
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, vi } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import type { DataTableSchema } from '@object-ui/types';
|
|
13
|
+
|
|
14
|
+
// Import the component
|
|
15
|
+
import '../data-table';
|
|
16
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
17
|
+
|
|
18
|
+
describe('Data Table - Inline Editing', () => {
|
|
19
|
+
const mockData = [
|
|
20
|
+
{ id: 1, name: 'John Doe', email: 'john@example.com', age: 30 },
|
|
21
|
+
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', age: 25 },
|
|
22
|
+
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', age: 35 },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const mockColumns = [
|
|
26
|
+
{ header: 'ID', accessorKey: 'id', editable: false },
|
|
27
|
+
{ header: 'Name', accessorKey: 'name' },
|
|
28
|
+
{ header: 'Email', accessorKey: 'email' },
|
|
29
|
+
{ header: 'Age', accessorKey: 'age' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
it('should render table with editable cells when editable is true', () => {
|
|
33
|
+
const schema: DataTableSchema = {
|
|
34
|
+
type: 'data-table',
|
|
35
|
+
columns: mockColumns,
|
|
36
|
+
data: mockData,
|
|
37
|
+
editable: true,
|
|
38
|
+
pagination: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DataTableRenderer = ComponentRegistry.get('data-table');
|
|
42
|
+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
|
|
43
|
+
|
|
44
|
+
const { container } = render(<DataTableRenderer schema={schema} />);
|
|
45
|
+
|
|
46
|
+
// Check that cells are rendered
|
|
47
|
+
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
|
|
49
|
+
|
|
50
|
+
// Editable cells should have tabindex
|
|
51
|
+
const cells = container.querySelectorAll('td[tabindex="0"]');
|
|
52
|
+
expect(cells.length).toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should call onCellChange when cell value is edited', async () => {
|
|
56
|
+
const onCellChange = vi.fn();
|
|
57
|
+
|
|
58
|
+
const schema: DataTableSchema = {
|
|
59
|
+
type: 'data-table',
|
|
60
|
+
columns: mockColumns,
|
|
61
|
+
data: mockData,
|
|
62
|
+
editable: true,
|
|
63
|
+
pagination: false,
|
|
64
|
+
searchable: false, // Disable search to avoid confusion with edit input
|
|
65
|
+
onCellChange,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const DataTableRenderer = ComponentRegistry.get('data-table');
|
|
69
|
+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
|
|
70
|
+
|
|
71
|
+
const { container } = render(<DataTableRenderer schema={schema} />);
|
|
72
|
+
|
|
73
|
+
// Find the first editable cell (name column)
|
|
74
|
+
const nameCell = screen.getByText('John Doe').closest('td');
|
|
75
|
+
expect(nameCell).toBeInTheDocument();
|
|
76
|
+
|
|
77
|
+
// Double-click to enter edit mode
|
|
78
|
+
if (nameCell) {
|
|
79
|
+
fireEvent.doubleClick(nameCell);
|
|
80
|
+
|
|
81
|
+
// Wait for input to appear inside the cell
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
const input = nameCell.querySelector('input');
|
|
84
|
+
expect(input).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const input = nameCell.querySelector('input');
|
|
88
|
+
if (input) {
|
|
89
|
+
// Change the value
|
|
90
|
+
fireEvent.change(input, { target: { value: 'John Smith' } });
|
|
91
|
+
|
|
92
|
+
// Press Enter to save
|
|
93
|
+
fireEvent.keyDown(input, { key: 'Enter' });
|
|
94
|
+
|
|
95
|
+
// Verify onCellChange was called
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
expect(onCellChange).toHaveBeenCalledWith(
|
|
98
|
+
0, // row index
|
|
99
|
+
'name', // column key
|
|
100
|
+
'John Smith', // new value
|
|
101
|
+
mockData[0] // row data
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should not allow editing when editable is false', () => {
|
|
109
|
+
const schema: DataTableSchema = {
|
|
110
|
+
type: 'data-table',
|
|
111
|
+
columns: mockColumns,
|
|
112
|
+
data: mockData,
|
|
113
|
+
editable: false,
|
|
114
|
+
pagination: false,
|
|
115
|
+
searchable: false, // Disable search to avoid confusion
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const DataTableRenderer = ComponentRegistry.get('data-table');
|
|
119
|
+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
|
|
120
|
+
|
|
121
|
+
const { container } = render(<DataTableRenderer schema={schema} />);
|
|
122
|
+
|
|
123
|
+
// Find a cell
|
|
124
|
+
const nameCell = screen.getByText('John Doe').closest('td');
|
|
125
|
+
expect(nameCell).toBeInTheDocument();
|
|
126
|
+
|
|
127
|
+
// Double-click should not trigger edit mode
|
|
128
|
+
if (nameCell) {
|
|
129
|
+
fireEvent.doubleClick(nameCell);
|
|
130
|
+
|
|
131
|
+
// Input should not appear in the cell
|
|
132
|
+
const input = nameCell?.querySelector('input');
|
|
133
|
+
expect(input).not.toBeInTheDocument();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should respect column-level editable flag', () => {
|
|
138
|
+
const onCellChange = vi.fn();
|
|
139
|
+
|
|
140
|
+
const schema: DataTableSchema = {
|
|
141
|
+
type: 'data-table',
|
|
142
|
+
columns: mockColumns,
|
|
143
|
+
data: mockData,
|
|
144
|
+
editable: true,
|
|
145
|
+
pagination: false,
|
|
146
|
+
searchable: false, // Disable search to avoid confusion
|
|
147
|
+
onCellChange,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const DataTableRenderer = ComponentRegistry.get('data-table');
|
|
151
|
+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
|
|
152
|
+
|
|
153
|
+
const { container } = render(<DataTableRenderer schema={schema} />);
|
|
154
|
+
|
|
155
|
+
// Try to edit ID column (which has editable: false)
|
|
156
|
+
const idCell = screen.getByText('1').closest('td');
|
|
157
|
+
expect(idCell).toBeInTheDocument();
|
|
158
|
+
|
|
159
|
+
if (idCell) {
|
|
160
|
+
fireEvent.doubleClick(idCell);
|
|
161
|
+
|
|
162
|
+
// Input should not appear for non-editable column
|
|
163
|
+
const input = idCell.querySelector('input');
|
|
164
|
+
expect(input).not.toBeInTheDocument();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should cancel edit on Escape key', async () => {
|
|
169
|
+
const onCellChange = vi.fn();
|
|
170
|
+
|
|
171
|
+
const schema: DataTableSchema = {
|
|
172
|
+
type: 'data-table',
|
|
173
|
+
columns: mockColumns,
|
|
174
|
+
data: mockData,
|
|
175
|
+
editable: true,
|
|
176
|
+
pagination: false,
|
|
177
|
+
searchable: false, // Disable search to avoid confusion
|
|
178
|
+
onCellChange,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const DataTableRenderer = ComponentRegistry.get('data-table');
|
|
182
|
+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
|
|
183
|
+
|
|
184
|
+
const { container } = render(<DataTableRenderer schema={schema} />);
|
|
185
|
+
|
|
186
|
+
// Find the first editable cell
|
|
187
|
+
const nameCell = screen.getByText('John Doe').closest('td');
|
|
188
|
+
expect(nameCell).toBeInTheDocument();
|
|
189
|
+
|
|
190
|
+
// Double-click to enter edit mode
|
|
191
|
+
if (nameCell) {
|
|
192
|
+
fireEvent.doubleClick(nameCell);
|
|
193
|
+
|
|
194
|
+
// Wait for input to appear
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
const input = nameCell.querySelector('input');
|
|
197
|
+
expect(input).toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const input = nameCell.querySelector('input');
|
|
201
|
+
if (input) {
|
|
202
|
+
// Change the value
|
|
203
|
+
fireEvent.change(input, { target: { value: 'John Smith' } });
|
|
204
|
+
|
|
205
|
+
// Press Escape to cancel
|
|
206
|
+
fireEvent.keyDown(input, { key: 'Escape' });
|
|
207
|
+
|
|
208
|
+
// Verify onCellChange was NOT called
|
|
209
|
+
await waitFor(() => {
|
|
210
|
+
expect(onCellChange).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Input should be removed
|
|
214
|
+
expect(nameCell.querySelector('input')).not.toBeInTheDocument();
|
|
215
|
+
|
|
216
|
+
// Original value should be preserved
|
|
217
|
+
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
ChevronsLeft,
|
|
45
45
|
ChevronsRight,
|
|
46
46
|
GripVertical,
|
|
47
|
+
Save,
|
|
48
|
+
X,
|
|
47
49
|
} from 'lucide-react';
|
|
48
50
|
|
|
49
51
|
type SortDirection = 'asc' | 'desc' | null;
|
|
@@ -97,6 +99,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
97
99
|
rowActions = false,
|
|
98
100
|
resizableColumns = true,
|
|
99
101
|
reorderableColumns = true,
|
|
102
|
+
editable = false,
|
|
100
103
|
className,
|
|
101
104
|
} = schema;
|
|
102
105
|
|
|
@@ -120,11 +123,17 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
120
123
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
|
121
124
|
const [draggedColumn, setDraggedColumn] = useState<number | null>(null);
|
|
122
125
|
const [dragOverColumn, setDragOverColumn] = useState<number | null>(null);
|
|
126
|
+
const [editingCell, setEditingCell] = useState<{ rowIndex: number; columnKey: string } | null>(null);
|
|
127
|
+
const [editValue, setEditValue] = useState<any>('');
|
|
128
|
+
// Track pending changes for multi-cell editing: rowIndex -> { columnKey -> newValue }
|
|
129
|
+
const [pendingChanges, setPendingChanges] = useState<Map<number, Record<string, any>>>(new Map());
|
|
130
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
123
131
|
|
|
124
132
|
// Refs for column resizing
|
|
125
133
|
const resizingColumn = useRef<string | null>(null);
|
|
126
134
|
const startX = useRef<number>(0);
|
|
127
135
|
const startWidth = useRef<number>(0);
|
|
136
|
+
const editInputRef = useRef<HTMLInputElement>(null);
|
|
128
137
|
|
|
129
138
|
// Update columns when schema changes
|
|
130
139
|
useEffect(() => {
|
|
@@ -340,6 +349,141 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
340
349
|
setDragOverColumn(null);
|
|
341
350
|
};
|
|
342
351
|
|
|
352
|
+
// Cell editing handlers
|
|
353
|
+
const startEdit = (rowIndex: number, columnKey: string) => {
|
|
354
|
+
if (!editable) return;
|
|
355
|
+
|
|
356
|
+
const column = columns.find(col => col.accessorKey === columnKey);
|
|
357
|
+
if (column?.editable === false) return;
|
|
358
|
+
|
|
359
|
+
setEditingCell({ rowIndex, columnKey });
|
|
360
|
+
|
|
361
|
+
// Check if there's a pending change for this cell, otherwise use current data value
|
|
362
|
+
const rowChanges = pendingChanges.get(rowIndex);
|
|
363
|
+
const currentValue = paginatedData[rowIndex][columnKey];
|
|
364
|
+
const valueToEdit = rowChanges?.[columnKey] ?? currentValue ?? '';
|
|
365
|
+
setEditValue(valueToEdit);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const saveEdit = (force: boolean = false) => {
|
|
369
|
+
if (!editingCell) return;
|
|
370
|
+
|
|
371
|
+
// Don't save if we're in cancelled state (unless forced)
|
|
372
|
+
if (!force && editingCell === null) return;
|
|
373
|
+
|
|
374
|
+
const { rowIndex, columnKey } = editingCell;
|
|
375
|
+
const globalIndex = (currentPage - 1) * pageSize + rowIndex;
|
|
376
|
+
const row = sortedData[globalIndex];
|
|
377
|
+
|
|
378
|
+
// Update pending changes
|
|
379
|
+
const newPendingChanges = new Map(pendingChanges);
|
|
380
|
+
const rowChanges = newPendingChanges.get(rowIndex) || {};
|
|
381
|
+
rowChanges[columnKey] = editValue;
|
|
382
|
+
newPendingChanges.set(rowIndex, rowChanges);
|
|
383
|
+
setPendingChanges(newPendingChanges);
|
|
384
|
+
|
|
385
|
+
// Call the legacy onCellChange callback if provided
|
|
386
|
+
if (schema.onCellChange) {
|
|
387
|
+
schema.onCellChange(globalIndex, columnKey, editValue, row);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
setEditingCell(null);
|
|
391
|
+
setEditValue('');
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const cancelEdit = () => {
|
|
395
|
+
setEditingCell(null);
|
|
396
|
+
setEditValue('');
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const saveRow = async (rowIndex: number) => {
|
|
400
|
+
const globalIndex = (currentPage - 1) * pageSize + rowIndex;
|
|
401
|
+
const row = sortedData[globalIndex];
|
|
402
|
+
const rowChanges = pendingChanges.get(rowIndex);
|
|
403
|
+
|
|
404
|
+
if (!rowChanges || Object.keys(rowChanges).length === 0) return;
|
|
405
|
+
|
|
406
|
+
setIsSaving(true);
|
|
407
|
+
try {
|
|
408
|
+
if (schema.onRowSave) {
|
|
409
|
+
await schema.onRowSave(globalIndex, rowChanges, row);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Clear pending changes for this row
|
|
413
|
+
const newPendingChanges = new Map(pendingChanges);
|
|
414
|
+
newPendingChanges.delete(rowIndex);
|
|
415
|
+
setPendingChanges(newPendingChanges);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
console.error('Failed to save row:', error);
|
|
418
|
+
} finally {
|
|
419
|
+
setIsSaving(false);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const cancelRowChanges = (rowIndex: number) => {
|
|
424
|
+
const newPendingChanges = new Map(pendingChanges);
|
|
425
|
+
newPendingChanges.delete(rowIndex);
|
|
426
|
+
setPendingChanges(newPendingChanges);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const saveBatch = async () => {
|
|
430
|
+
if (pendingChanges.size === 0) return;
|
|
431
|
+
|
|
432
|
+
setIsSaving(true);
|
|
433
|
+
try {
|
|
434
|
+
const changesToSave = Array.from(pendingChanges.entries()).map(([rowIndex, changes]) => {
|
|
435
|
+
const globalIndex = (currentPage - 1) * pageSize + rowIndex;
|
|
436
|
+
const row = sortedData[globalIndex];
|
|
437
|
+
return { rowIndex: globalIndex, changes, row };
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
if (schema.onBatchSave) {
|
|
441
|
+
await schema.onBatchSave(changesToSave);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Clear all pending changes
|
|
445
|
+
setPendingChanges(new Map());
|
|
446
|
+
} catch (error) {
|
|
447
|
+
console.error('Failed to save batch:', error);
|
|
448
|
+
} finally {
|
|
449
|
+
setIsSaving(false);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const cancelAllChanges = () => {
|
|
454
|
+
setPendingChanges(new Map());
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const handleCellKeyDown = (e: React.KeyboardEvent, rowIndex: number, columnKey: string) => {
|
|
458
|
+
if (!editable) return;
|
|
459
|
+
|
|
460
|
+
const column = columns.find(col => col.accessorKey === columnKey);
|
|
461
|
+
if (column?.editable === false) return;
|
|
462
|
+
|
|
463
|
+
if (e.key === 'Enter' && !editingCell) {
|
|
464
|
+
e.preventDefault();
|
|
465
|
+
startEdit(rowIndex, columnKey);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
|
470
|
+
if (e.key === 'Enter') {
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
saveEdit(true);
|
|
473
|
+
} else if (e.key === 'Escape') {
|
|
474
|
+
e.preventDefault();
|
|
475
|
+
cancelEdit();
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// Auto-focus on edit input when entering edit mode
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
if (editingCell && editInputRef.current) {
|
|
482
|
+
editInputRef.current.focus();
|
|
483
|
+
editInputRef.current.select();
|
|
484
|
+
}
|
|
485
|
+
}, [editingCell]);
|
|
486
|
+
|
|
343
487
|
// Cleanup on unmount
|
|
344
488
|
useEffect(() => {
|
|
345
489
|
return () => {
|
|
@@ -361,7 +505,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
361
505
|
return selectedRowIds.has(rowId);
|
|
362
506
|
}) && !allPageRowsSelected;
|
|
363
507
|
|
|
364
|
-
const
|
|
508
|
+
const hasPendingChanges = pendingChanges.size > 0;
|
|
509
|
+
const showToolbar = searchable || exportable || (selectable && selectedRowIds.size > 0) || hasPendingChanges;
|
|
365
510
|
|
|
366
511
|
return (
|
|
367
512
|
<div className={`flex flex-col h-full gap-4 ${className || ''}`}>
|
|
@@ -386,6 +531,32 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
386
531
|
</div>
|
|
387
532
|
|
|
388
533
|
<div className="flex items-center gap-2">
|
|
534
|
+
{hasPendingChanges && (
|
|
535
|
+
<>
|
|
536
|
+
<div className="text-sm text-muted-foreground">
|
|
537
|
+
{pendingChanges.size} row{pendingChanges.size > 1 ? 's' : ''} modified
|
|
538
|
+
</div>
|
|
539
|
+
<Button
|
|
540
|
+
variant="outline"
|
|
541
|
+
size="sm"
|
|
542
|
+
onClick={cancelAllChanges}
|
|
543
|
+
disabled={isSaving}
|
|
544
|
+
>
|
|
545
|
+
<X className="h-4 w-4 mr-2" />
|
|
546
|
+
Cancel All
|
|
547
|
+
</Button>
|
|
548
|
+
<Button
|
|
549
|
+
variant="default"
|
|
550
|
+
size="sm"
|
|
551
|
+
onClick={saveBatch}
|
|
552
|
+
disabled={isSaving}
|
|
553
|
+
>
|
|
554
|
+
<Save className="h-4 w-4 mr-2" />
|
|
555
|
+
Save All ({pendingChanges.size})
|
|
556
|
+
</Button>
|
|
557
|
+
</>
|
|
558
|
+
)}
|
|
559
|
+
|
|
389
560
|
{exportable && (
|
|
390
561
|
<Button
|
|
391
562
|
variant="outline"
|
|
@@ -485,24 +656,24 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
485
656
|
const globalIndex = (currentPage - 1) * pageSize + rowIndex;
|
|
486
657
|
const rowId = getRowId(row, globalIndex);
|
|
487
658
|
const isSelected = selectedRowIds.has(rowId);
|
|
659
|
+
const rowHasChanges = pendingChanges.has(rowIndex);
|
|
660
|
+
const rowChanges = pendingChanges.get(rowIndex) || {};
|
|
488
661
|
|
|
489
662
|
return (
|
|
490
663
|
<TableRow
|
|
491
664
|
key={rowId}
|
|
492
665
|
data-state={isSelected ? 'selected' : undefined}
|
|
493
666
|
className={cn(
|
|
494
|
-
|
|
495
|
-
|
|
667
|
+
schema.onRowClick && "cursor-pointer",
|
|
668
|
+
rowHasChanges && "bg-amber-50 dark:bg-amber-950/20"
|
|
496
669
|
)}
|
|
497
670
|
onClick={(e) => {
|
|
498
|
-
// @ts-expect-error - onRowClick might not be in schema type definition
|
|
499
671
|
if (schema.onRowClick && !e.defaultPrevented) {
|
|
500
672
|
// Simple heuristic to avoid triggering on interactive elements if they didn't stop propagation
|
|
501
673
|
const target = e.target as HTMLElement;
|
|
502
674
|
if (target.closest('button') || target.closest('[role="checkbox"]') || target.closest('a')) {
|
|
503
675
|
return;
|
|
504
676
|
}
|
|
505
|
-
// @ts-expect-error - onRowClick might not be in schema type definition
|
|
506
677
|
schema.onRowClick(row);
|
|
507
678
|
}
|
|
508
679
|
}}
|
|
@@ -517,37 +688,87 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
517
688
|
)}
|
|
518
689
|
{columns.map((col, colIndex) => {
|
|
519
690
|
const columnWidth = columnWidths[col.accessorKey] || col.width;
|
|
691
|
+
const originalValue = row[col.accessorKey];
|
|
692
|
+
const hasPendingChange = rowChanges[col.accessorKey] !== undefined;
|
|
693
|
+
const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue;
|
|
694
|
+
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === col.accessorKey;
|
|
695
|
+
const isEditable = editable && col.editable !== false;
|
|
696
|
+
|
|
520
697
|
return (
|
|
521
698
|
<TableCell
|
|
522
699
|
key={colIndex}
|
|
523
|
-
className={
|
|
700
|
+
className={cn(
|
|
701
|
+
col.cellClassName,
|
|
702
|
+
isEditable && !isEditing && "cursor-text hover:bg-muted/50",
|
|
703
|
+
hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400"
|
|
704
|
+
)}
|
|
524
705
|
style={{
|
|
525
706
|
width: columnWidth,
|
|
526
707
|
minWidth: columnWidth,
|
|
527
708
|
maxWidth: columnWidth
|
|
528
709
|
}}
|
|
710
|
+
onDoubleClick={() => isEditable && startEdit(rowIndex, col.accessorKey)}
|
|
711
|
+
onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)}
|
|
712
|
+
tabIndex={0}
|
|
529
713
|
>
|
|
530
|
-
{
|
|
714
|
+
{isEditing ? (
|
|
715
|
+
<Input
|
|
716
|
+
ref={editInputRef}
|
|
717
|
+
value={editValue}
|
|
718
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
719
|
+
onKeyDown={handleEditKeyDown}
|
|
720
|
+
className="h-8 px-2 py-1"
|
|
721
|
+
/>
|
|
722
|
+
) : typeof col.cell === 'function' ? (
|
|
723
|
+
col.cell(cellValue, row)
|
|
724
|
+
) : (
|
|
725
|
+
cellValue
|
|
726
|
+
)}
|
|
531
727
|
</TableCell>
|
|
532
728
|
);
|
|
533
729
|
})}
|
|
534
730
|
{rowActions && (
|
|
535
731
|
<TableCell className="text-right">
|
|
536
732
|
<div className="flex items-center justify-end gap-1">
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
733
|
+
{rowHasChanges && (schema.onRowSave || schema.onBatchSave) ? (
|
|
734
|
+
<>
|
|
735
|
+
<Button
|
|
736
|
+
variant="ghost"
|
|
737
|
+
size="icon-sm"
|
|
738
|
+
onClick={() => cancelRowChanges(rowIndex)}
|
|
739
|
+
disabled={isSaving}
|
|
740
|
+
title="Cancel changes"
|
|
741
|
+
>
|
|
742
|
+
<X className="h-4 w-4" />
|
|
743
|
+
</Button>
|
|
744
|
+
<Button
|
|
745
|
+
variant="ghost"
|
|
746
|
+
size="icon-sm"
|
|
747
|
+
onClick={() => saveRow(rowIndex)}
|
|
748
|
+
disabled={isSaving}
|
|
749
|
+
title="Save row"
|
|
750
|
+
>
|
|
751
|
+
<Save className="h-4 w-4 text-green-600" />
|
|
752
|
+
</Button>
|
|
753
|
+
</>
|
|
754
|
+
) : (
|
|
755
|
+
<>
|
|
756
|
+
<Button
|
|
757
|
+
variant="ghost"
|
|
758
|
+
size="icon-sm"
|
|
759
|
+
onClick={() => schema.onRowEdit?.(row)}
|
|
760
|
+
>
|
|
761
|
+
<Edit className="h-4 w-4" />
|
|
762
|
+
</Button>
|
|
763
|
+
<Button
|
|
764
|
+
variant="ghost"
|
|
765
|
+
size="icon-sm"
|
|
766
|
+
onClick={() => schema.onRowDelete?.(row)}
|
|
767
|
+
>
|
|
768
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
769
|
+
</Button>
|
|
770
|
+
</>
|
|
771
|
+
)}
|
|
551
772
|
</div>
|
|
552
773
|
</TableCell>
|
|
553
774
|
)}
|
|
@@ -228,9 +228,17 @@ ComponentRegistry.register('form',
|
|
|
228
228
|
disabled: fieldDisabled = false,
|
|
229
229
|
validation = {},
|
|
230
230
|
condition,
|
|
231
|
+
colSpan,
|
|
232
|
+
hidden,
|
|
233
|
+
widget,
|
|
234
|
+
visibleOn,
|
|
235
|
+
readonly,
|
|
231
236
|
...fieldProps
|
|
232
237
|
} = field;
|
|
233
238
|
|
|
239
|
+
// Skip hidden fields
|
|
240
|
+
if (hidden) return null;
|
|
241
|
+
|
|
234
242
|
// Handle conditional rendering with null/undefined safety
|
|
235
243
|
if (condition) {
|
|
236
244
|
const watchField = condition.field;
|
|
@@ -264,6 +272,17 @@ ComponentRegistry.register('form',
|
|
|
264
272
|
// Use field.id or field.name for stable keys (never use index alone)
|
|
265
273
|
const fieldKey = field.id ?? name;
|
|
266
274
|
|
|
275
|
+
// Resolve the component type: prefer widget override, fallback to field type
|
|
276
|
+
const resolvedType = widget || type;
|
|
277
|
+
|
|
278
|
+
// colSpan classes for grid layout
|
|
279
|
+
const colSpanClass = colSpan && colSpan > 1
|
|
280
|
+
? colSpan === 2 ? 'col-span-2'
|
|
281
|
+
: colSpan === 3 ? 'col-span-3'
|
|
282
|
+
: colSpan >= 4 ? 'col-span-4'
|
|
283
|
+
: ''
|
|
284
|
+
: '';
|
|
285
|
+
|
|
267
286
|
return (
|
|
268
287
|
<FormField
|
|
269
288
|
key={fieldKey}
|
|
@@ -271,7 +290,7 @@ ComponentRegistry.register('form',
|
|
|
271
290
|
name={name}
|
|
272
291
|
rules={rules}
|
|
273
292
|
render={({ field: formField }) => (
|
|
274
|
-
<FormItem>
|
|
293
|
+
<FormItem className={colSpanClass || undefined}>
|
|
275
294
|
{label && (
|
|
276
295
|
<FormLabel>
|
|
277
296
|
{label}
|
|
@@ -283,8 +302,8 @@ ComponentRegistry.register('form',
|
|
|
283
302
|
</FormLabel>
|
|
284
303
|
)}
|
|
285
304
|
<FormControl>
|
|
286
|
-
{/* Render the actual field component based on type */}
|
|
287
|
-
{renderFieldComponent(
|
|
305
|
+
{/* Render the actual field component based on resolved type */}
|
|
306
|
+
{renderFieldComponent(resolvedType, {
|
|
288
307
|
...fieldProps,
|
|
289
308
|
// specialized fields needs raw metadata, but we should traverse down if it exists
|
|
290
309
|
// field is the field configuration loop variable
|
|
@@ -293,7 +312,7 @@ ComponentRegistry.register('form',
|
|
|
293
312
|
inputType: fieldProps.inputType,
|
|
294
313
|
options: fieldProps.options,
|
|
295
314
|
placeholder: fieldProps.placeholder,
|
|
296
|
-
disabled: disabled || fieldDisabled || isSubmitting,
|
|
315
|
+
disabled: disabled || fieldDisabled || readonly || isSubmitting,
|
|
297
316
|
})}
|
|
298
317
|
</FormControl>
|
|
299
318
|
{description && (
|
package/src/renderers/index.ts
CHANGED