@izumisy-tailor/tailor-data-viewer 0.1.46 → 0.1.48
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/docs/README.md +2 -0
- package/docs/inline-cell-editing.md +711 -0
- package/docs/manual-refetch.md +242 -0
- package/package.json +1 -1
- package/src/component/data-table.test.tsx +458 -2
- package/src/component/data-table.tsx +13 -15
- package/src/component/types.ts +12 -1
package/docs/README.md
CHANGED
|
@@ -21,5 +21,7 @@ Documentation for `@izumisy-tailor/tailor-data-viewer`.
|
|
|
21
21
|
## Advanced Usage
|
|
22
22
|
|
|
23
23
|
- [Compositional API](compositional-api.md) - Building flexible UIs by composing components
|
|
24
|
+
- [Manual Refetch](manual-refetch.md) - Manually refetching table data after external operations
|
|
25
|
+
- [Inline Cell Editing](inline-cell-editing.md) - Implementing editable cells with custom renderers
|
|
24
26
|
- [Saved View Store](saved-view-store.md) - View persistence and restoration
|
|
25
27
|
- [AppShell Module](app-shell-module.md) - AppShell integration module
|
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
# Inline Cell Editing
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Data Viewer supports inline cell editing by combining [Custom Renderers](./custom-renderers.md) and [Manual Refetch](./manual-refetch.md) patterns. This document explains how to implement editable cells that update data and refresh the table automatically.
|
|
6
|
+
|
|
7
|
+
## How It Works
|
|
8
|
+
|
|
9
|
+
Inline cell editing is achieved through three key concepts:
|
|
10
|
+
|
|
11
|
+
1. **Custom Renderer**: Create a renderer that displays an editable input instead of static text
|
|
12
|
+
2. **Row Data Access**: Use the `row` prop to get the record ID for API calls
|
|
13
|
+
3. **Manual Refetch**: Call `refetch()` after successful update to sync the table
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
|
|
17
|
+
│ Custom Renderer │ ──> │ Update API │ ──> │ refetch() │
|
|
18
|
+
│ (editable) │ │ call │ │ to sync │
|
|
19
|
+
└─────────────────┘ └──────────────┘ └─────────────┘
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Basic Example: Editable Text Field
|
|
23
|
+
|
|
24
|
+
A simple inline text editor that updates on blur:
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { useState } from "react";
|
|
28
|
+
import {
|
|
29
|
+
CellRendererProps,
|
|
30
|
+
useDataViewer,
|
|
31
|
+
DataTable,
|
|
32
|
+
} from "@izumisy-tailor/tailor-data-viewer/component";
|
|
33
|
+
import { DataViewer } from "./data-viewer";
|
|
34
|
+
|
|
35
|
+
// Create a reusable editable text renderer factory
|
|
36
|
+
function EditableText(options: { onSave: (id: string, value: string) => Promise<void> }) {
|
|
37
|
+
return function EditableTextRenderer({ value, row }: CellRendererProps) {
|
|
38
|
+
const [editing, setEditing] = useState(false);
|
|
39
|
+
const [inputValue, setInputValue] = useState(String(value ?? ""));
|
|
40
|
+
const { refetch } = useDataViewer();
|
|
41
|
+
|
|
42
|
+
const handleSave = async () => {
|
|
43
|
+
setEditing(false);
|
|
44
|
+
if (inputValue !== String(value)) {
|
|
45
|
+
await options.onSave(row.id as string, inputValue);
|
|
46
|
+
await refetch();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (editing) {
|
|
51
|
+
return (
|
|
52
|
+
<input
|
|
53
|
+
type="text"
|
|
54
|
+
value={inputValue}
|
|
55
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
56
|
+
onBlur={handleSave}
|
|
57
|
+
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
58
|
+
autoFocus
|
|
59
|
+
className="w-full px-2 py-1 border rounded"
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
onClick={() => setEditing(true)}
|
|
67
|
+
className="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded"
|
|
68
|
+
>
|
|
69
|
+
{(value as string) ?? "-"}
|
|
70
|
+
</span>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Usage with Column API
|
|
76
|
+
function TaskListPage() {
|
|
77
|
+
return (
|
|
78
|
+
<DataViewer.TableDataProvider
|
|
79
|
+
tableName="task"
|
|
80
|
+
columns={[
|
|
81
|
+
[
|
|
82
|
+
"title",
|
|
83
|
+
EditableText({
|
|
84
|
+
onSave: async (id, value) => {
|
|
85
|
+
await fetch(`/api/tasks/${id}`, {
|
|
86
|
+
method: "PATCH",
|
|
87
|
+
body: JSON.stringify({ title: value }),
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
],
|
|
92
|
+
"status",
|
|
93
|
+
"createdAt",
|
|
94
|
+
]}
|
|
95
|
+
>
|
|
96
|
+
<DataTable />
|
|
97
|
+
</DataViewer.TableDataProvider>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Complete Example: Editable Status Dropdown
|
|
103
|
+
|
|
104
|
+
A dropdown selector for status fields:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { useState } from "react";
|
|
108
|
+
import {
|
|
109
|
+
CellRendererProps,
|
|
110
|
+
useDataViewer,
|
|
111
|
+
DataTable,
|
|
112
|
+
} from "@izumisy-tailor/tailor-data-viewer/component";
|
|
113
|
+
import { DataViewer } from "./data-viewer";
|
|
114
|
+
|
|
115
|
+
interface EditableStatusOptions {
|
|
116
|
+
options: { value: string; label: string; color?: string }[];
|
|
117
|
+
onSave: (id: string, value: string) => Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function EditableStatus({ options, onSave }: EditableStatusOptions) {
|
|
121
|
+
return function EditableStatusRenderer({ value, row }: CellRendererProps) {
|
|
122
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
123
|
+
const [saving, setSaving] = useState(false);
|
|
124
|
+
const { refetch } = useDataViewer();
|
|
125
|
+
|
|
126
|
+
const currentOption = options.find((opt) => opt.value === value);
|
|
127
|
+
|
|
128
|
+
const handleSelect = async (newValue: string) => {
|
|
129
|
+
if (newValue === value) {
|
|
130
|
+
setIsOpen(false);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setSaving(true);
|
|
135
|
+
setIsOpen(false);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await onSave(row.id as string, newValue);
|
|
139
|
+
await refetch();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error("Failed to update status:", error);
|
|
142
|
+
} finally {
|
|
143
|
+
setSaving(false);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className="relative">
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
151
|
+
disabled={saving}
|
|
152
|
+
className={`
|
|
153
|
+
px-3 py-1 rounded-full text-sm font-medium
|
|
154
|
+
${saving ? "opacity-50" : "cursor-pointer hover:opacity-80"}
|
|
155
|
+
`}
|
|
156
|
+
style={{
|
|
157
|
+
backgroundColor: currentOption?.color ?? "#E5E7EB",
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{saving ? "Saving..." : (currentOption?.label ?? String(value))}
|
|
161
|
+
</button>
|
|
162
|
+
|
|
163
|
+
{isOpen && (
|
|
164
|
+
<div className="absolute z-10 mt-1 bg-white border rounded shadow-lg">
|
|
165
|
+
{options.map((option) => (
|
|
166
|
+
<button
|
|
167
|
+
key={option.value}
|
|
168
|
+
onClick={() => handleSelect(option.value)}
|
|
169
|
+
className="block w-full px-4 py-2 text-left hover:bg-gray-100"
|
|
170
|
+
>
|
|
171
|
+
<span
|
|
172
|
+
className="inline-block w-3 h-3 rounded-full mr-2"
|
|
173
|
+
style={{ backgroundColor: option.color }}
|
|
174
|
+
/>
|
|
175
|
+
{option.label}
|
|
176
|
+
</button>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Usage with Column API
|
|
186
|
+
const statusOptions = [
|
|
187
|
+
{ value: "todo", label: "To Do", color: "#E5E7EB" },
|
|
188
|
+
{ value: "in-progress", label: "In Progress", color: "#DBEAFE" },
|
|
189
|
+
{ value: "done", label: "Done", color: "#D1FAE5" },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
function TaskListPage() {
|
|
193
|
+
return (
|
|
194
|
+
<DataViewer.TableDataProvider
|
|
195
|
+
tableName="task"
|
|
196
|
+
columns={[
|
|
197
|
+
"title",
|
|
198
|
+
[
|
|
199
|
+
"status",
|
|
200
|
+
EditableStatus({
|
|
201
|
+
options: statusOptions,
|
|
202
|
+
onSave: async (id, value) => {
|
|
203
|
+
await fetch(`/api/tasks/${id}`, {
|
|
204
|
+
method: "PATCH",
|
|
205
|
+
body: JSON.stringify({ status: value }),
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
],
|
|
210
|
+
"createdAt",
|
|
211
|
+
]}
|
|
212
|
+
>
|
|
213
|
+
<DataTable />
|
|
214
|
+
</DataViewer.TableDataProvider>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Example: Editable Checkbox (Boolean)
|
|
220
|
+
|
|
221
|
+
Toggle boolean values with a single click:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
import { useState } from "react";
|
|
225
|
+
import {
|
|
226
|
+
CellRendererProps,
|
|
227
|
+
useDataViewer,
|
|
228
|
+
DataTable,
|
|
229
|
+
} from "@izumisy-tailor/tailor-data-viewer/component";
|
|
230
|
+
import { DataViewer } from "./data-viewer";
|
|
231
|
+
|
|
232
|
+
function EditableCheckbox(options: { onSave: (id: string, value: boolean) => Promise<void> }) {
|
|
233
|
+
return function EditableCheckboxRenderer({ value, row }: CellRendererProps) {
|
|
234
|
+
const [saving, setSaving] = useState(false);
|
|
235
|
+
const { refetch } = useDataViewer();
|
|
236
|
+
|
|
237
|
+
const handleToggle = async () => {
|
|
238
|
+
setSaving(true);
|
|
239
|
+
try {
|
|
240
|
+
await options.onSave(row.id as string, !value);
|
|
241
|
+
await refetch();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error("Failed to toggle:", error);
|
|
244
|
+
} finally {
|
|
245
|
+
setSaving(false);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<button
|
|
251
|
+
onClick={handleToggle}
|
|
252
|
+
disabled={saving}
|
|
253
|
+
className={`
|
|
254
|
+
w-5 h-5 rounded border-2 flex items-center justify-center
|
|
255
|
+
${saving ? "opacity-50" : "cursor-pointer"}
|
|
256
|
+
${value ? "bg-blue-500 border-blue-500" : "border-gray-300"}
|
|
257
|
+
`}
|
|
258
|
+
>
|
|
259
|
+
{value && <span className="text-white text-xs">✓</span>}
|
|
260
|
+
</button>
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Usage with Column API
|
|
266
|
+
function TaskListPage() {
|
|
267
|
+
return (
|
|
268
|
+
<DataViewer.TableDataProvider
|
|
269
|
+
tableName="task"
|
|
270
|
+
columns={[
|
|
271
|
+
[
|
|
272
|
+
"isCompleted",
|
|
273
|
+
EditableCheckbox({
|
|
274
|
+
onSave: async (id, value) => {
|
|
275
|
+
await fetch(`/api/tasks/${id}`, {
|
|
276
|
+
method: "PATCH",
|
|
277
|
+
body: JSON.stringify({ isCompleted: value }),
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
],
|
|
282
|
+
"title",
|
|
283
|
+
"status",
|
|
284
|
+
]}
|
|
285
|
+
>
|
|
286
|
+
<DataTable />
|
|
287
|
+
</DataViewer.TableDataProvider>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Example: Editable Number Field
|
|
293
|
+
|
|
294
|
+
Number input with validation:
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
import { useState } from "react";
|
|
298
|
+
import {
|
|
299
|
+
CellRendererProps,
|
|
300
|
+
useDataViewer,
|
|
301
|
+
DataTable,
|
|
302
|
+
} from "@izumisy-tailor/tailor-data-viewer/component";
|
|
303
|
+
import { DataViewer } from "./data-viewer";
|
|
304
|
+
|
|
305
|
+
function EditableNumber(options: {
|
|
306
|
+
onSave: (id: string, value: number) => Promise<void>;
|
|
307
|
+
min?: number;
|
|
308
|
+
max?: number;
|
|
309
|
+
}) {
|
|
310
|
+
return function EditableNumberRenderer({ value, row }: CellRendererProps) {
|
|
311
|
+
const [editing, setEditing] = useState(false);
|
|
312
|
+
const [inputValue, setInputValue] = useState(String(value ?? 0));
|
|
313
|
+
const [error, setError] = useState<string | null>(null);
|
|
314
|
+
const { refetch } = useDataViewer();
|
|
315
|
+
|
|
316
|
+
const handleSave = async () => {
|
|
317
|
+
const numValue = Number(inputValue);
|
|
318
|
+
|
|
319
|
+
if (isNaN(numValue)) {
|
|
320
|
+
setError("Invalid number");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (options.min !== undefined && numValue < options.min) {
|
|
324
|
+
setError(`Minimum value is ${options.min}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (options.max !== undefined && numValue > options.max) {
|
|
328
|
+
setError(`Maximum value is ${options.max}`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
setEditing(false);
|
|
333
|
+
setError(null);
|
|
334
|
+
|
|
335
|
+
if (numValue !== value) {
|
|
336
|
+
await options.onSave(row.id as string, numValue);
|
|
337
|
+
await refetch();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
if (editing) {
|
|
342
|
+
return (
|
|
343
|
+
<div>
|
|
344
|
+
<input
|
|
345
|
+
type="number"
|
|
346
|
+
value={inputValue}
|
|
347
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
348
|
+
onBlur={handleSave}
|
|
349
|
+
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
350
|
+
min={options.min}
|
|
351
|
+
max={options.max}
|
|
352
|
+
autoFocus
|
|
353
|
+
className={`
|
|
354
|
+
w-24 px-2 py-1 border rounded
|
|
355
|
+
${error ? "border-red-500" : "border-gray-300"}
|
|
356
|
+
`}
|
|
357
|
+
/>
|
|
358
|
+
{error && <div className="text-red-500 text-xs">{error}</div>}
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<span
|
|
365
|
+
onClick={() => setEditing(true)}
|
|
366
|
+
className="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded"
|
|
367
|
+
>
|
|
368
|
+
{(value as number) ?? 0}
|
|
369
|
+
</span>
|
|
370
|
+
);
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Usage with Column API
|
|
375
|
+
function ProductListPage() {
|
|
376
|
+
return (
|
|
377
|
+
<DataViewer.TableDataProvider
|
|
378
|
+
tableName="product"
|
|
379
|
+
columns={[
|
|
380
|
+
"name",
|
|
381
|
+
[
|
|
382
|
+
"quantity",
|
|
383
|
+
EditableNumber({
|
|
384
|
+
min: 0,
|
|
385
|
+
max: 1000,
|
|
386
|
+
onSave: async (id, value) => {
|
|
387
|
+
await fetch(`/api/products/${id}`, {
|
|
388
|
+
method: "PATCH",
|
|
389
|
+
body: JSON.stringify({ quantity: value }),
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
}),
|
|
393
|
+
],
|
|
394
|
+
"price",
|
|
395
|
+
]}
|
|
396
|
+
>
|
|
397
|
+
<DataTable />
|
|
398
|
+
</DataViewer.TableDataProvider>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Pattern: Accessing Refetch from Renderers
|
|
404
|
+
|
|
405
|
+
When building reusable editable cell components, you may need to access `refetch` from within the renderer. The `useDataViewer` hook is available inside any component rendered within `TableDataProvider`:
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
// The renderer is always called inside TableDataProvider context
|
|
409
|
+
function EditableCellRenderer({ value, row }: CellRendererProps) {
|
|
410
|
+
// ✅ This works because renderers are rendered inside TableDataProvider
|
|
411
|
+
const { refetch } = useDataViewer();
|
|
412
|
+
|
|
413
|
+
// ... editing logic
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
## Pattern: Optimistic Updates
|
|
418
|
+
|
|
419
|
+
For better UX, show the updated value immediately while the API call is in progress:
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
function OptimisticEditableText(options: { onSave: (id: string, value: string) => Promise<void> }) {
|
|
423
|
+
return function OptimisticEditableTextRenderer({ value, row }: CellRendererProps) {
|
|
424
|
+
const [editing, setEditing] = useState(false);
|
|
425
|
+
const [inputValue, setInputValue] = useState(String(value ?? ""));
|
|
426
|
+
const [displayValue, setDisplayValue] = useState(String(value ?? ""));
|
|
427
|
+
const [saving, setSaving] = useState(false);
|
|
428
|
+
const { refetch } = useDataViewer();
|
|
429
|
+
|
|
430
|
+
const handleSave = async () => {
|
|
431
|
+
setEditing(false);
|
|
432
|
+
if (inputValue !== String(value)) {
|
|
433
|
+
// Optimistically update display value
|
|
434
|
+
setDisplayValue(inputValue);
|
|
435
|
+
setSaving(true);
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
await options.onSave(row.id as string, inputValue);
|
|
439
|
+
await refetch();
|
|
440
|
+
} catch (error) {
|
|
441
|
+
// Revert on error
|
|
442
|
+
setDisplayValue(String(value));
|
|
443
|
+
console.error("Failed to save:", error);
|
|
444
|
+
} finally {
|
|
445
|
+
setSaving(false);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
if (editing) {
|
|
451
|
+
return (
|
|
452
|
+
<input
|
|
453
|
+
type="text"
|
|
454
|
+
value={inputValue}
|
|
455
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
456
|
+
onBlur={handleSave}
|
|
457
|
+
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
458
|
+
autoFocus
|
|
459
|
+
className="w-full px-2 py-1 border rounded"
|
|
460
|
+
/>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<span
|
|
466
|
+
onClick={() => {
|
|
467
|
+
setInputValue(displayValue);
|
|
468
|
+
setEditing(true);
|
|
469
|
+
}}
|
|
470
|
+
className={`
|
|
471
|
+
cursor-pointer hover:bg-gray-100 px-2 py-1 rounded
|
|
472
|
+
${saving ? "opacity-50" : ""}
|
|
473
|
+
`}
|
|
474
|
+
>
|
|
475
|
+
{displayValue || "-"}
|
|
476
|
+
</span>
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Usage with Column API
|
|
482
|
+
<DataViewer.TableDataProvider
|
|
483
|
+
tableName="task"
|
|
484
|
+
columns={[
|
|
485
|
+
["title", OptimisticEditableText({ onSave: updateTitle })],
|
|
486
|
+
"status",
|
|
487
|
+
]}
|
|
488
|
+
>
|
|
489
|
+
<DataTable />
|
|
490
|
+
</DataViewer.TableDataProvider>
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## Pattern: Edit Permission Control
|
|
494
|
+
|
|
495
|
+
Show editable or read-only view based on user permissions:
|
|
496
|
+
|
|
497
|
+
```tsx
|
|
498
|
+
function EditableTextWithPermission(options: {
|
|
499
|
+
onSave: (id: string, value: string) => Promise<void>;
|
|
500
|
+
canEdit: (row: Record<string, unknown>) => boolean;
|
|
501
|
+
}) {
|
|
502
|
+
return function EditableTextWithPermissionRenderer({ value, row }: CellRendererProps) {
|
|
503
|
+
const [editing, setEditing] = useState(false);
|
|
504
|
+
const [inputValue, setInputValue] = useState(String(value ?? ""));
|
|
505
|
+
const { refetch } = useDataViewer();
|
|
506
|
+
|
|
507
|
+
const isEditable = options.canEdit(row);
|
|
508
|
+
|
|
509
|
+
const handleSave = async () => {
|
|
510
|
+
setEditing(false);
|
|
511
|
+
if (inputValue !== String(value)) {
|
|
512
|
+
await options.onSave(row.id as string, inputValue);
|
|
513
|
+
await refetch();
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Read-only view if user cannot edit
|
|
518
|
+
if (!isEditable) {
|
|
519
|
+
return <span className="px-2 py-1">{(value as string) ?? "-"}</span>;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (editing) {
|
|
523
|
+
return (
|
|
524
|
+
<input
|
|
525
|
+
type="text"
|
|
526
|
+
value={inputValue}
|
|
527
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
528
|
+
onBlur={handleSave}
|
|
529
|
+
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
530
|
+
autoFocus
|
|
531
|
+
className="w-full px-2 py-1 border rounded"
|
|
532
|
+
/>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return (
|
|
537
|
+
<span
|
|
538
|
+
onClick={() => setEditing(true)}
|
|
539
|
+
className="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded"
|
|
540
|
+
title="Click to edit"
|
|
541
|
+
>
|
|
542
|
+
{(value as string) ?? "-"}
|
|
543
|
+
<span className="ml-1 text-gray-400 text-xs">✎</span>
|
|
544
|
+
</span>
|
|
545
|
+
);
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Usage with Column API and permission check
|
|
550
|
+
const currentUserId = "user-123";
|
|
551
|
+
const isAdmin = false;
|
|
552
|
+
|
|
553
|
+
function TaskListPage() {
|
|
554
|
+
return (
|
|
555
|
+
<DataViewer.TableDataProvider
|
|
556
|
+
tableName="task"
|
|
557
|
+
columns={[
|
|
558
|
+
[
|
|
559
|
+
"title",
|
|
560
|
+
EditableTextWithPermission({
|
|
561
|
+
onSave: async (id, value) => {
|
|
562
|
+
await fetch(`/api/tasks/${id}`, {
|
|
563
|
+
method: "PATCH",
|
|
564
|
+
body: JSON.stringify({ title: value }),
|
|
565
|
+
});
|
|
566
|
+
},
|
|
567
|
+
canEdit: (row) => row.assigneeId === currentUserId || isAdmin,
|
|
568
|
+
}),
|
|
569
|
+
],
|
|
570
|
+
"status",
|
|
571
|
+
"assignee.name",
|
|
572
|
+
]}
|
|
573
|
+
>
|
|
574
|
+
<DataTable />
|
|
575
|
+
</DataViewer.TableDataProvider>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
## Combining Multiple Editable Fields
|
|
581
|
+
|
|
582
|
+
You can combine multiple editable renderers in a single view:
|
|
583
|
+
|
|
584
|
+
```tsx
|
|
585
|
+
function TaskListPage() {
|
|
586
|
+
return (
|
|
587
|
+
<DataViewer.TableDataProvider
|
|
588
|
+
tableName="task"
|
|
589
|
+
columns={[
|
|
590
|
+
[
|
|
591
|
+
"isCompleted",
|
|
592
|
+
EditableCheckbox({
|
|
593
|
+
onSave: async (id, value) => {
|
|
594
|
+
await fetch(`/api/tasks/${id}`, {
|
|
595
|
+
method: "PATCH",
|
|
596
|
+
body: JSON.stringify({ isCompleted: value }),
|
|
597
|
+
});
|
|
598
|
+
},
|
|
599
|
+
}),
|
|
600
|
+
],
|
|
601
|
+
[
|
|
602
|
+
"title",
|
|
603
|
+
EditableText({
|
|
604
|
+
onSave: async (id, value) => {
|
|
605
|
+
await fetch(`/api/tasks/${id}`, {
|
|
606
|
+
method: "PATCH",
|
|
607
|
+
body: JSON.stringify({ title: value }),
|
|
608
|
+
});
|
|
609
|
+
},
|
|
610
|
+
}),
|
|
611
|
+
],
|
|
612
|
+
[
|
|
613
|
+
"status",
|
|
614
|
+
EditableStatus({
|
|
615
|
+
options: statusOptions,
|
|
616
|
+
onSave: async (id, value) => {
|
|
617
|
+
await fetch(`/api/tasks/${id}`, {
|
|
618
|
+
method: "PATCH",
|
|
619
|
+
body: JSON.stringify({ status: value }),
|
|
620
|
+
});
|
|
621
|
+
},
|
|
622
|
+
}),
|
|
623
|
+
],
|
|
624
|
+
"createdAt", // Read-only
|
|
625
|
+
]}
|
|
626
|
+
>
|
|
627
|
+
<DataTable />
|
|
628
|
+
</DataViewer.TableDataProvider>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
## Tips
|
|
634
|
+
|
|
635
|
+
### Keyboard Navigation
|
|
636
|
+
|
|
637
|
+
For better accessibility, consider adding keyboard navigation:
|
|
638
|
+
|
|
639
|
+
```tsx
|
|
640
|
+
onKeyDown={(e) => {
|
|
641
|
+
if (e.key === "Enter") handleSave();
|
|
642
|
+
if (e.key === "Escape") {
|
|
643
|
+
setInputValue(String(value));
|
|
644
|
+
setEditing(false);
|
|
645
|
+
}
|
|
646
|
+
}}
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
### Debouncing Auto-Save
|
|
650
|
+
|
|
651
|
+
For auto-save on change (without blur), add debouncing:
|
|
652
|
+
|
|
653
|
+
```tsx
|
|
654
|
+
import { useDebouncedCallback } from "use-debounce";
|
|
655
|
+
|
|
656
|
+
const debouncedSave = useDebouncedCallback(async (newValue: string) => {
|
|
657
|
+
await options.onSave(row.id as string, newValue);
|
|
658
|
+
await refetch();
|
|
659
|
+
}, 500);
|
|
660
|
+
|
|
661
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
662
|
+
setInputValue(e.target.value);
|
|
663
|
+
debouncedSave(e.target.value);
|
|
664
|
+
};
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Extracting onSave Logic
|
|
668
|
+
|
|
669
|
+
For cleaner code, extract the API call logic into separate functions:
|
|
670
|
+
|
|
671
|
+
```tsx
|
|
672
|
+
// api/tasks.ts
|
|
673
|
+
export async function updateTask(id: string, data: Partial<Task>) {
|
|
674
|
+
await fetch(`/api/tasks/${id}`, {
|
|
675
|
+
method: "PATCH",
|
|
676
|
+
headers: { "Content-Type": "application/json" },
|
|
677
|
+
body: JSON.stringify(data),
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// TaskListPage.tsx
|
|
682
|
+
import { updateTask } from "./api/tasks";
|
|
683
|
+
|
|
684
|
+
<DataViewer.TableDataProvider
|
|
685
|
+
tableName="task"
|
|
686
|
+
columns={[
|
|
687
|
+
[
|
|
688
|
+
"title",
|
|
689
|
+
EditableText({
|
|
690
|
+
onSave: (id, value) => updateTask(id, { title: value }),
|
|
691
|
+
}),
|
|
692
|
+
],
|
|
693
|
+
[
|
|
694
|
+
"status",
|
|
695
|
+
EditableStatus({
|
|
696
|
+
options: statusOptions,
|
|
697
|
+
onSave: (id, value) => updateTask(id, { status: value }),
|
|
698
|
+
}),
|
|
699
|
+
],
|
|
700
|
+
]}
|
|
701
|
+
>
|
|
702
|
+
<DataTable />
|
|
703
|
+
</DataViewer.TableDataProvider>
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
## Related Documentation
|
|
707
|
+
|
|
708
|
+
- [Custom Renderers](./custom-renderers.md) - Custom cell renderer patterns and built-in renderers
|
|
709
|
+
- [Manual Refetch](./manual-refetch.md) - Manual refetch patterns after data updates
|
|
710
|
+
- [Column Definition API](./columns.md) - Column definition with renderer option
|
|
711
|
+
- [Compositional API](./compositional-api.md) - Building flexible UIs with Providers and Hooks
|