Skip to content

Commit a19639b

Browse files
authored
Merge pull request #390 from objectstack-ai/copilot/add-row-editing-to-object-grid
2 parents 97525f4 + 33c9eab commit a19639b

File tree

9 files changed

+1192
-25
lines changed

9 files changed

+1192
-25
lines changed

content/docs/plugins/plugin-grid.mdx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,87 @@ Add action buttons to each row:
228228
}
229229
```
230230

231+
### Inline Editing
232+
233+
Enable inline cell editing for quick data updates:
234+
235+
```json
236+
{
237+
"type": "object-grid",
238+
"objectName": "users",
239+
"columns": [
240+
{ "header": "ID", "accessorKey": "id", "editable": false },
241+
{ "header": "Name", "accessorKey": "name" },
242+
{ "header": "Email", "accessorKey": "email" },
243+
{ "header": "Status", "accessorKey": "status" }
244+
],
245+
"editable": true,
246+
"onCellChange": "handleCellChange"
247+
}
248+
```
249+
250+
**Features:**
251+
- Double-click or press Enter on a cell to start editing
252+
- Press Enter to save changes
253+
- Press Escape to cancel editing
254+
- Column-level control: Set `editable: false` on specific columns to prevent editing
255+
- Callback support: Use `onCellChange` to handle cell value updates
256+
257+
**Example Handler:**
258+
```javascript
259+
function handleCellChange(rowIndex, columnKey, newValue, row) {
260+
console.log(`Cell at row ${rowIndex}, column ${columnKey} changed to:`, newValue);
261+
console.log('Full row data:', row);
262+
// Update your data source here
263+
}
264+
```
265+
266+
### Batch Editing & Multi-Row Save
267+
268+
Edit multiple cells across multiple rows and save them individually or all at once:
269+
270+
```json
271+
{
272+
"type": "object-grid",
273+
"objectName": "products",
274+
"columns": [
275+
{ "header": "SKU", "accessorKey": "sku", "editable": false },
276+
{ "header": "Name", "accessorKey": "name" },
277+
{ "header": "Price", "accessorKey": "price" }
278+
],
279+
"editable": true,
280+
"rowActions": true,
281+
"onRowSave": "handleRowSave",
282+
"onBatchSave": "handleBatchSave"
283+
}
284+
```
285+
286+
**Features:**
287+
- **Pending changes tracking**: Edit multiple cells across rows before saving
288+
- **Visual indicators**: Modified rows highlighted in amber, modified cells in bold
289+
- **Row-level save/cancel**: Individual row save and cancel buttons
290+
- **Batch operations**: Save All and Cancel All buttons for bulk actions
291+
- **Flexible callbacks**: `onRowSave` for single row, `onBatchSave` for multiple rows
292+
293+
**Example Handlers:**
294+
```javascript
295+
function handleRowSave(rowIndex, changes, row) {
296+
console.log('Saving row:', rowIndex, changes);
297+
// Save single row to backend
298+
await dataSource.update(row.id, changes);
299+
}
300+
301+
function handleBatchSave(allChanges) {
302+
console.log('Batch saving:', allChanges.length, 'rows');
303+
// Save all changes at once
304+
await Promise.all(
305+
allChanges.map(({ row, changes }) =>
306+
dataSource.update(row.id, changes)
307+
)
308+
);
309+
}
310+
```
311+
231312
## TypeScript Support
232313

233314
```plaintext
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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 - Batch 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 track pending changes across multiple cells', async () => {
33+
const onRowSave = vi.fn();
34+
35+
const schema: DataTableSchema = {
36+
type: 'data-table',
37+
columns: mockColumns,
38+
data: mockData,
39+
editable: true,
40+
pagination: false,
41+
searchable: false,
42+
rowActions: true,
43+
onRowSave,
44+
};
45+
46+
const DataTableRenderer = ComponentRegistry.get('data-table');
47+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
48+
49+
const { container } = render(<DataTableRenderer schema={schema} />);
50+
51+
// Edit first cell in row
52+
const nameCell = screen.getByText('John Doe').closest('td');
53+
if (nameCell) {
54+
fireEvent.doubleClick(nameCell);
55+
56+
await waitFor(() => {
57+
const input = nameCell.querySelector('input');
58+
expect(input).toBeInTheDocument();
59+
});
60+
61+
const input = nameCell.querySelector('input');
62+
if (input) {
63+
fireEvent.change(input, { target: { value: 'John Smith' } });
64+
fireEvent.keyDown(input, { key: 'Enter' });
65+
}
66+
}
67+
68+
// Wait for the edit to be saved to pending changes
69+
await waitFor(() => {
70+
const modifiedIndicator = screen.getByText(/1 row modified/i);
71+
expect(modifiedIndicator).toBeInTheDocument();
72+
});
73+
74+
// Edit second cell in same row - find by searching through all cells
75+
const emailCell = Array.from(container.querySelectorAll('td')).find(el =>
76+
el.textContent?.includes('john@example.com')
77+
);
78+
79+
expect(emailCell).toBeInTheDocument();
80+
if (emailCell) {
81+
fireEvent.doubleClick(emailCell);
82+
83+
await waitFor(() => {
84+
const input = emailCell.querySelector('input');
85+
expect(input).toBeInTheDocument();
86+
});
87+
88+
const input = emailCell.querySelector('input');
89+
if (input) {
90+
fireEvent.change(input, { target: { value: 'johnsmith@example.com' } });
91+
fireEvent.keyDown(input, { key: 'Enter' });
92+
}
93+
}
94+
95+
// Row should still show as modified (still just 1 row)
96+
await waitFor(() => {
97+
const modifiedIndicator = screen.getByText(/1 row modified/i);
98+
expect(modifiedIndicator).toBeInTheDocument();
99+
});
100+
});
101+
102+
it('should save a single row with multiple changes', async () => {
103+
const onRowSave = vi.fn().mockResolvedValue(undefined);
104+
105+
const schema: DataTableSchema = {
106+
type: 'data-table',
107+
columns: mockColumns,
108+
data: mockData,
109+
editable: true,
110+
pagination: false,
111+
searchable: false,
112+
rowActions: true,
113+
onRowSave,
114+
};
115+
116+
const DataTableRenderer = ComponentRegistry.get('data-table');
117+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
118+
119+
render(<DataTableRenderer schema={schema} />);
120+
121+
// Edit name
122+
const nameCell = screen.getByText('John Doe').closest('td');
123+
if (nameCell) {
124+
fireEvent.doubleClick(nameCell);
125+
await waitFor(() => {
126+
const input = nameCell.querySelector('input');
127+
expect(input).toBeInTheDocument();
128+
});
129+
130+
const input = nameCell.querySelector('input');
131+
if (input) {
132+
fireEvent.change(input, { target: { value: 'John Smith' } });
133+
fireEvent.keyDown(input, { key: 'Enter' });
134+
}
135+
}
136+
137+
// Find and click save button for row
138+
await waitFor(() => {
139+
const saveButtons = screen.getAllByTitle('Save row');
140+
expect(saveButtons.length).toBeGreaterThan(0);
141+
fireEvent.click(saveButtons[0]);
142+
});
143+
144+
// Verify callback was called with correct data
145+
await waitFor(() => {
146+
expect(onRowSave).toHaveBeenCalledWith(
147+
0,
148+
{ name: 'John Smith' },
149+
mockData[0]
150+
);
151+
});
152+
});
153+
154+
it('should save all modified rows with batch save', async () => {
155+
const onBatchSave = vi.fn().mockResolvedValue(undefined);
156+
157+
const schema: DataTableSchema = {
158+
type: 'data-table',
159+
columns: mockColumns,
160+
data: mockData,
161+
editable: true,
162+
pagination: false,
163+
searchable: false,
164+
onBatchSave,
165+
};
166+
167+
const DataTableRenderer = ComponentRegistry.get('data-table');
168+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
169+
170+
render(<DataTableRenderer schema={schema} />);
171+
172+
// Edit row 1
173+
const nameCell1 = screen.getByText('John Doe').closest('td');
174+
if (nameCell1) {
175+
fireEvent.doubleClick(nameCell1);
176+
await waitFor(() => {
177+
const input = nameCell1.querySelector('input');
178+
expect(input).toBeInTheDocument();
179+
});
180+
181+
const input = nameCell1.querySelector('input');
182+
if (input) {
183+
fireEvent.change(input, { target: { value: 'John Smith' } });
184+
fireEvent.keyDown(input, { key: 'Enter' });
185+
}
186+
}
187+
188+
// Edit row 2
189+
await waitFor(() => {
190+
const nameCell2 = screen.getByText('Jane Smith').closest('td');
191+
expect(nameCell2).toBeInTheDocument();
192+
});
193+
194+
const nameCell2 = screen.getByText('Jane Smith').closest('td');
195+
if (nameCell2) {
196+
fireEvent.doubleClick(nameCell2);
197+
await waitFor(() => {
198+
const input = nameCell2.querySelector('input');
199+
expect(input).toBeInTheDocument();
200+
});
201+
202+
const input = nameCell2.querySelector('input');
203+
if (input) {
204+
fireEvent.change(input, { target: { value: 'Jane Doe' } });
205+
fireEvent.keyDown(input, { key: 'Enter' });
206+
}
207+
}
208+
209+
// Click save all button
210+
await waitFor(() => {
211+
const saveAllButton = screen.getByText(/Save All \(2\)/i);
212+
expect(saveAllButton).toBeInTheDocument();
213+
fireEvent.click(saveAllButton);
214+
});
215+
216+
// Verify callback was called
217+
await waitFor(() => {
218+
expect(onBatchSave).toHaveBeenCalledWith([
219+
{ rowIndex: 0, changes: { name: 'John Smith' }, row: mockData[0] },
220+
{ rowIndex: 1, changes: { name: 'Jane Doe' }, row: mockData[1] },
221+
]);
222+
});
223+
});
224+
225+
it('should cancel all changes', async () => {
226+
const onBatchSave = vi.fn();
227+
228+
const schema: DataTableSchema = {
229+
type: 'data-table',
230+
columns: mockColumns,
231+
data: mockData,
232+
editable: true,
233+
pagination: false,
234+
searchable: false,
235+
onBatchSave,
236+
};
237+
238+
const DataTableRenderer = ComponentRegistry.get('data-table');
239+
if (!DataTableRenderer) throw new Error('DataTableRenderer not found');
240+
241+
render(<DataTableRenderer schema={schema} />);
242+
243+
// Edit a cell
244+
const nameCell = screen.getByText('John Doe').closest('td');
245+
if (nameCell) {
246+
fireEvent.doubleClick(nameCell);
247+
await waitFor(() => {
248+
const input = nameCell.querySelector('input');
249+
expect(input).toBeInTheDocument();
250+
});
251+
252+
const input = nameCell.querySelector('input');
253+
if (input) {
254+
fireEvent.change(input, { target: { value: 'John Smith' } });
255+
fireEvent.keyDown(input, { key: 'Enter' });
256+
}
257+
}
258+
259+
// Click cancel all button
260+
await waitFor(() => {
261+
const cancelButton = screen.getByText(/Cancel All/i);
262+
expect(cancelButton).toBeInTheDocument();
263+
fireEvent.click(cancelButton);
264+
});
265+
266+
// Verify changes indicator is gone
267+
await waitFor(() => {
268+
const modifiedIndicator = screen.queryByText(/row modified/i);
269+
expect(modifiedIndicator).not.toBeInTheDocument();
270+
});
271+
272+
// Original value should be restored
273+
expect(screen.getByText('John Doe')).toBeInTheDocument();
274+
});
275+
});

0 commit comments

Comments
 (0)