@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 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