@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.
Files changed (96) hide show
  1. package/.turbo/turbo-build.log +12 -25
  2. package/CHANGELOG.md +13 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.js +23366 -22221
  5. package/dist/index.umd.cjs +30 -30
  6. package/dist/src/custom/action-param-dialog.d.ts +21 -0
  7. package/dist/src/custom/index.d.ts +2 -0
  8. package/dist/src/custom/navigation-overlay.d.ts +50 -0
  9. package/dist/src/index.d.ts +1 -0
  10. package/dist/src/renderers/action/action-button.d.ts +11 -0
  11. package/dist/src/renderers/action/action-group.d.ts +25 -0
  12. package/dist/src/renderers/action/action-icon.d.ts +10 -0
  13. package/dist/src/renderers/action/action-menu.d.ts +19 -0
  14. package/dist/src/renderers/action/index.d.ts +0 -0
  15. package/dist/src/renderers/action/resolve-icon.d.ts +6 -0
  16. package/package.json +9 -8
  17. package/src/__tests__/PageRendererRegions.test.tsx +664 -55
  18. package/src/__tests__/compliance.test.tsx +72 -0
  19. package/src/__tests__/navigation-overlay.test.tsx +273 -0
  20. package/src/__tests__/view-compliance.test.tsx +153 -0
  21. package/src/custom/action-param-dialog.tsx +264 -0
  22. package/src/custom/index.ts +2 -0
  23. package/src/custom/navigation-overlay.tsx +296 -0
  24. package/src/index.ts +1 -0
  25. package/src/renderers/action/action-button.tsx +147 -0
  26. package/src/renderers/action/action-group.tsx +270 -0
  27. package/src/renderers/action/action-icon.tsx +150 -0
  28. package/src/renderers/action/action-menu.tsx +203 -0
  29. package/src/renderers/action/index.ts +18 -0
  30. package/src/renderers/action/resolve-icon.ts +35 -0
  31. package/src/renderers/complex/__tests__/data-table-batch-editing.test.tsx +275 -0
  32. package/src/renderers/complex/__tests__/data-table-cell-renderer.test.tsx +120 -0
  33. package/src/renderers/complex/__tests__/data-table-editing.test.tsx +221 -0
  34. package/src/renderers/complex/data-table.tsx +242 -21
  35. package/src/renderers/form/form.tsx +23 -4
  36. package/src/renderers/index.ts +1 -0
  37. package/src/renderers/layout/page.tsx +416 -52
  38. package/src/renderers/navigation/sidebar.tsx +6 -0
  39. package/src/renderers/placeholders.tsx +2 -2
  40. package/src/stories/Introduction.mdx +54 -27
  41. package/src/stories/MockedData.stories.tsx +87 -37
  42. package/src/stories-json/accordion.stories.tsx +1 -1
  43. package/src/stories-json/aggrid.stories.tsx +1 -1
  44. package/src/stories-json/alert.stories.tsx +1 -1
  45. package/src/stories-json/aspect-ratio.stories.tsx +1 -1
  46. package/src/stories-json/avatar.stories.tsx +1 -1
  47. package/src/stories-json/badge.stories.tsx +1 -1
  48. package/src/stories-json/breadcrumb.stories.tsx +1 -1
  49. package/src/stories-json/button-group.stories.tsx +1 -1
  50. package/src/stories-json/button.stories.tsx +1 -1
  51. package/src/stories-json/calendar.stories.tsx +1 -1
  52. package/src/stories-json/card.stories.tsx +1 -1
  53. package/src/stories-json/carousel.stories.tsx +1 -1
  54. package/src/stories-json/charts.stories.tsx +1 -1
  55. package/src/stories-json/chatbot.stories.tsx +1 -1
  56. package/src/stories-json/code-editor.stories.tsx +1 -1
  57. package/src/stories-json/collapsible.stories.tsx +1 -1
  58. package/src/stories-json/controls.stories.tsx +1 -1
  59. package/src/stories-json/crm-live-data.stories.tsx +154 -0
  60. package/src/stories-json/data-table.stories.tsx +80 -4
  61. package/src/stories-json/data_display_extras.stories.tsx +1 -1
  62. package/src/stories-json/date-picker.stories.tsx +1 -1
  63. package/src/stories-json/detail-view.stories.tsx +1 -1
  64. package/src/stories-json/dialog.stories.tsx +1 -1
  65. package/src/stories-json/feedback_extras.stories.tsx +1 -1
  66. package/src/stories-json/feedback_others.stories.tsx +1 -1
  67. package/src/stories-json/form-variants.stories.tsx +210 -0
  68. package/src/stories-json/form_advanced.stories.tsx +1 -1
  69. package/src/stories-json/form_extras.stories.tsx +1 -1
  70. package/src/stories-json/grid.stories.tsx +1 -1
  71. package/src/stories-json/icon.stories.tsx +1 -1
  72. package/src/stories-json/input.stories.tsx +1 -1
  73. package/src/stories-json/kanban.stories.tsx +1 -1
  74. package/src/stories-json/layout_extended.stories.tsx +1 -1
  75. package/src/stories-json/layout_flex.stories.tsx +1 -1
  76. package/src/stories-json/list-view.stories.tsx +1 -1
  77. package/src/stories-json/markdown.stories.tsx +1 -1
  78. package/src/stories-json/menus.stories.tsx +1 -1
  79. package/src/stories-json/metric-card.stories.tsx +1 -1
  80. package/src/stories-json/navigation-menu.stories.tsx +1 -1
  81. package/src/stories-json/object-aggrid-advanced.stories.tsx +389 -0
  82. package/src/stories-json/object-aggrid.stories.tsx +1 -1
  83. package/src/stories-json/object-form.stories.tsx +1 -1
  84. package/src/stories-json/object-gantt.stories.tsx +1 -1
  85. package/src/stories-json/object-grid.stories.tsx +159 -1
  86. package/src/stories-json/object-map.stories.tsx +1 -1
  87. package/src/stories-json/object-view.stories.tsx +1 -1
  88. package/src/stories-json/overlay_extras.stories.tsx +1 -1
  89. package/src/stories-json/overlay_others.stories.tsx +1 -1
  90. package/src/stories-json/resizable.stories.tsx +1 -1
  91. package/src/stories-json/select.stories.tsx +1 -1
  92. package/src/stories-json/separator.stories.tsx +1 -1
  93. package/src/stories-json/statistic.stories.tsx +1 -1
  94. package/src/stories-json/tabs.stories.tsx +1 -1
  95. package/src/stories-json/timeline.stories.tsx +1 -1
  96. 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 showToolbar = searchable || exportable || (selectable && selectedRowIds.size > 0);
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
- // @ts-expect-error - onRowClick might not be in schema type definition
495
- schema.onRowClick && "cursor-pointer"
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={col.cellClassName}
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
- {row[col.accessorKey]}
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
- <Button
538
- variant="ghost"
539
- size="icon-sm"
540
- onClick={() => schema.onRowEdit?.(row)}
541
- >
542
- <Edit className="h-4 w-4" />
543
- </Button>
544
- <Button
545
- variant="ghost"
546
- size="icon-sm"
547
- onClick={() => schema.onRowDelete?.(row)}
548
- >
549
- <Trash2 className="h-4 w-4 text-destructive" />
550
- </Button>
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(type, {
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 && (
@@ -15,3 +15,4 @@ import './feedback';
15
15
  import './overlay';
16
16
  import './disclosure';
17
17
  import './complex';
18
+ import './action';