@izumisy-tailor/tailor-data-viewer 0.2.20 → 0.2.22

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.2.20",
4
+ "version": "0.2.22",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -1,5 +1,11 @@
1
1
  import { createContext, useContext } from "react";
2
- import type { Column, PageInfo, RowOperations, SortState } from "../types";
2
+ import type {
3
+ Column,
4
+ PageInfo,
5
+ RowAction,
6
+ RowOperations,
7
+ SortState,
8
+ } from "../types";
3
9
 
4
10
  /**
5
11
  * Context value provided by `DataTable.Provider`.
@@ -31,6 +37,12 @@ export interface DataTableContextValue<TRow extends Record<string, unknown>> {
31
37
 
32
38
  // Page info from GraphQL response (populated by DataTable.Provider)
33
39
  pageInfo: PageInfo;
40
+
41
+ // Row interaction (populated by DataTable.Root)
42
+ /** Handler called when a row is clicked */
43
+ onClickRow?: (row: TRow) => void;
44
+ /** Row action definitions for the actions column */
45
+ rowActions?: RowAction<TRow>[];
34
46
  }
35
47
 
36
48
  // Using `any` for the context default since generic contexts need a base type.
@@ -1,13 +1,17 @@
1
1
  import {
2
2
  createElement,
3
3
  useContext,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
4
8
  type ComponentProps,
5
9
  type ReactNode,
6
10
  } from "react";
7
11
  import { cn } from "../lib/utils";
8
12
  import { CollectionProvider } from "../collection/collection-provider";
9
13
  import { Table } from "../table";
10
- import type { Column, UseDataTableReturn } from "../types";
14
+ import type { Column, RowAction, UseDataTableReturn } from "../types";
11
15
  import {
12
16
  DataTableContext,
13
17
  type DataTableContextValue,
@@ -21,7 +25,16 @@ import {
21
25
  * Root container for the DataTable compound component.
22
26
  *
23
27
  * Must be used within `DataTable.Provider`.
24
- * Renders a `<Table.Root>` wrapper; all data is read from context.
28
+ * Renders a `<Table.Root>` wrapper; all data (including `onClickRow` and
29
+ * `rowActions`) is read from context via `useDataTable()` options.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <DataTable.Root>
34
+ * <DataTable.Headers />
35
+ * <DataTable.Body />
36
+ * </DataTable.Root>
37
+ * ```
25
38
  */
26
39
  function DataTableRoot({
27
40
  children,
@@ -84,6 +97,8 @@ function DataTableProviderComponent<TRow extends Record<string, unknown>>({
84
97
  showAllColumns: value.showAllColumns,
85
98
  hideAllColumns: value.hideAllColumns,
86
99
  pageInfo: value.pageInfo,
100
+ onClickRow: value.onClickRow,
101
+ rowActions: value.rowActions,
87
102
  };
88
103
 
89
104
  const collectionValue = value.collection ?? null;
@@ -115,7 +130,7 @@ function DataTableHeaders({ className }: { className?: string }) {
115
130
  "<DataTable.Headers> must be used within <DataTable.Provider>",
116
131
  );
117
132
  }
118
- const { columns, sortStates, onSort } = ctx;
133
+ const { columns, sortStates, onSort, rowActions } = ctx;
119
134
 
120
135
  return (
121
136
  <Table.Headers className={className}>
@@ -154,6 +169,11 @@ function DataTableHeaders({ className }: { className?: string }) {
154
169
  </Table.HeaderCell>
155
170
  );
156
171
  })}
172
+ {rowActions && rowActions.length > 0 && (
173
+ <Table.HeaderCell style={{ width: 50 }}>
174
+ <span className="sr-only">操作</span>
175
+ </Table.HeaderCell>
176
+ )}
157
177
  </Table.HeaderRow>
158
178
  </Table.Headers>
159
179
  );
@@ -184,7 +204,9 @@ function DataTableBody({
184
204
  "<DataTable.Body> must be used within <DataTable.Provider>",
185
205
  );
186
206
  }
187
- const { columns, rows, loading, error } = ctx;
207
+ const { columns, rows, loading, error, onClickRow, rowActions } = ctx;
208
+ const hasRowActions = rowActions && rowActions.length > 0;
209
+ const totalColSpan = (columns?.length ?? 1) + (hasRowActions ? 1 : 0);
188
210
 
189
211
  // If children are provided, render them directly (custom rendering)
190
212
  if (children) {
@@ -196,36 +218,31 @@ function DataTableBody({
196
218
  <Table.Body className={className}>
197
219
  {loading && (!rows || rows.length === 0) && (
198
220
  <Table.Row>
199
- <Table.Cell
200
- colSpan={columns?.length ?? 1}
201
- className="h-24 text-center"
202
- >
221
+ <Table.Cell colSpan={totalColSpan} className="h-24 text-center">
203
222
  <span className="text-muted-foreground">Loading...</span>
204
223
  </Table.Cell>
205
224
  </Table.Row>
206
225
  )}
207
226
  {error && (
208
227
  <Table.Row>
209
- <Table.Cell
210
- colSpan={columns?.length ?? 1}
211
- className="h-24 text-center"
212
- >
228
+ <Table.Cell colSpan={totalColSpan} className="h-24 text-center">
213
229
  <span className="text-destructive">Error: {error.message}</span>
214
230
  </Table.Cell>
215
231
  </Table.Row>
216
232
  )}
217
233
  {!loading && !error && (!rows || rows.length === 0) && (
218
234
  <Table.Row>
219
- <Table.Cell
220
- colSpan={columns?.length ?? 1}
221
- className="h-24 text-center"
222
- >
235
+ <Table.Cell colSpan={totalColSpan} className="h-24 text-center">
223
236
  <span className="text-muted-foreground">No data</span>
224
237
  </Table.Cell>
225
238
  </Table.Row>
226
239
  )}
227
240
  {rows?.map((row, rowIndex) => (
228
- <Table.Row key={rowIndex}>
241
+ <Table.Row
242
+ key={rowIndex}
243
+ className={cn(onClickRow && "cursor-pointer")}
244
+ onClick={onClickRow ? () => onClickRow(row) : undefined}
245
+ >
229
246
  {columns?.map((col) => {
230
247
  const key = col.kind === "field" ? col.dataKey : col.id;
231
248
  return (
@@ -237,6 +254,14 @@ function DataTableBody({
237
254
  </Table.Cell>
238
255
  );
239
256
  })}
257
+ {hasRowActions && (
258
+ <Table.Cell
259
+ style={{ width: 50 }}
260
+ onClick={(e) => e.stopPropagation()}
261
+ >
262
+ <RowActionsMenu actions={rowActions} row={row} />
263
+ </Table.Cell>
264
+ )}
240
265
  </Table.Row>
241
266
  ))}
242
267
  </Table.Body>
@@ -308,6 +333,96 @@ function formatValue(value: unknown): ReactNode {
308
333
  return String(value);
309
334
  }
310
335
 
336
+ // =============================================================================
337
+ // RowActionsMenu
338
+ // =============================================================================
339
+
340
+ /**
341
+ * Dropdown menu for row-level actions.
342
+ *
343
+ * Uses a simple toggle-based dropdown without external dependencies.
344
+ */
345
+ function RowActionsMenu<TRow extends Record<string, unknown>>({
346
+ actions,
347
+ row,
348
+ }: {
349
+ actions: RowAction<TRow>[];
350
+ row: TRow;
351
+ }) {
352
+ const [open, setOpen] = useState(false);
353
+ const menuRef = useRef<HTMLDivElement>(null);
354
+
355
+ // Close on outside click
356
+ useEffect(() => {
357
+ if (!open) return;
358
+ const handleClickOutside = (e: MouseEvent) => {
359
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
360
+ setOpen(false);
361
+ }
362
+ };
363
+ document.addEventListener("mousedown", handleClickOutside);
364
+ return () => document.removeEventListener("mousedown", handleClickOutside);
365
+ }, [open]);
366
+
367
+ // Close on Escape
368
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
369
+ if (e.key === "Escape") setOpen(false);
370
+ }, []);
371
+
372
+ return (
373
+ <div
374
+ className="relative inline-block"
375
+ ref={menuRef}
376
+ onKeyDown={handleKeyDown}
377
+ >
378
+ <button
379
+ type="button"
380
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-sm hover:bg-accent"
381
+ onClick={() => setOpen((prev) => !prev)}
382
+ aria-label="Row actions"
383
+ aria-haspopup="true"
384
+ aria-expanded={open}
385
+ >
386
+ <span aria-hidden>⋯</span>
387
+ </button>
388
+ {open && (
389
+ <div
390
+ className="absolute right-0 z-50 mt-1 min-w-[140px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
391
+ role="menu"
392
+ >
393
+ {actions.map((action) => {
394
+ const disabled = action.isDisabled?.(row) ?? false;
395
+ return (
396
+ <button
397
+ key={action.id}
398
+ type="button"
399
+ role="menuitem"
400
+ disabled={disabled}
401
+ className={cn(
402
+ "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
403
+ disabled
404
+ ? "cursor-not-allowed opacity-50"
405
+ : "cursor-pointer hover:bg-accent",
406
+ action.variant === "destructive" && "text-destructive",
407
+ )}
408
+ onClick={() => {
409
+ if (!disabled) {
410
+ action.onClick(row);
411
+ setOpen(false);
412
+ }
413
+ }}
414
+ >
415
+ {action.icon}
416
+ {action.label}
417
+ </button>
418
+ );
419
+ })}
420
+ </div>
421
+ )}
422
+ </div>
423
+ );
424
+ }
425
+
311
426
  // =============================================================================
312
427
  // DataTable namespace
313
428
  // =============================================================================
@@ -40,6 +40,8 @@ export function useDataTable<TRow extends Record<string, unknown>>(
40
40
  loading = false,
41
41
  error = null,
42
42
  collection,
43
+ onClickRow,
44
+ rowActions,
43
45
  } = options;
44
46
 
45
47
  // ---------------------------------------------------------------------------
@@ -241,5 +243,9 @@ export function useDataTable<TRow extends Record<string, unknown>>(
241
243
 
242
244
  // Collection (passthrough for DataTable.Provider)
243
245
  collection,
246
+
247
+ // Row interaction (passthrough for DataTable.Provider)
248
+ onClickRow,
249
+ rowActions,
244
250
  };
245
251
  }
@@ -73,6 +73,43 @@ describe("createColumnHelper()", () => {
73
73
  expect(col.width).toBe(100);
74
74
  expect(typeof col.render).toBe("function");
75
75
  });
76
+
77
+ it("inferColumns returns column/columns helpers with TRow bound", () => {
78
+ type TaskRow = { id: string; title: string; status: string };
79
+ const metadata = {
80
+ name: "task",
81
+ pluralForm: "tasks",
82
+ readAllowedRoles: [],
83
+ fields: [
84
+ { name: "id", type: "uuid", required: true },
85
+ { name: "title", type: "string", required: true },
86
+ {
87
+ name: "status",
88
+ type: "enum",
89
+ required: true,
90
+ enumValues: ["todo", "done"],
91
+ },
92
+ ],
93
+ } as const;
94
+
95
+ const { inferColumns, display } = createColumnHelper<TaskRow>();
96
+ const { column, columns } = inferColumns(metadata);
97
+
98
+ // column() works with auto-detection
99
+ const titleCol = column("title");
100
+ expect(titleCol.dataKey).toBe("title");
101
+ expect(titleCol.sort).toEqual({ type: "string" });
102
+
103
+ // columns() works
104
+ const cols = columns(["title", "status"]);
105
+ expect(cols).toHaveLength(2);
106
+
107
+ // display still works alongside inferColumns
108
+ const actionsCol = display("actions", {
109
+ render: (row) => `${row.title}`,
110
+ });
111
+ expect(actionsCol.kind).toBe("display");
112
+ });
76
113
  });
77
114
 
78
115
  describe("fieldTypeToSortConfig", () => {
@@ -87,21 +87,27 @@ export function display<TRow extends Record<string, unknown>>(
87
87
  // =============================================================================
88
88
 
89
89
  /**
90
- * Create `field` and `display` helpers with TRow bound once.
90
+ * Create `field`, `display`, and `inferColumns` helpers with TRow bound once.
91
91
  *
92
- * This avoids repeating the row type parameter on every `field()` / `display()` call.
92
+ * This avoids repeating the row type parameter on every helper call.
93
+ * Use `inferColumns(tableMetadata)` to create metadata-inferred columns
94
+ * without specifying TRow again.
93
95
  *
94
96
  * @example
95
97
  * ```tsx
96
98
  * type Order = { id: string; title: string; amount: number };
97
99
  *
98
- * const { field, display } = createColumnHelper<Order>();
100
+ * const { field, display, inferColumns } = createColumnHelper<Order>();
99
101
  *
100
- * const columns = [
102
+ * // Manual columns
103
+ * const manualColumns = [
101
104
  * field("title", { label: "Title", sort: { type: "string" } }),
102
- * field("amount", { label: "Amount", sort: { type: "number" } }),
103
105
  * display("actions", { render: (row) => <ActionMenu row={row} /> }),
104
106
  * ];
107
+ *
108
+ * // Metadata-inferred columns (no need to pass TRow again)
109
+ * const { column } = inferColumns(tableMetadata.order);
110
+ * const inferredColumns = [column("title"), column("amount")];
105
111
  * ```
106
112
  */
107
113
  export function createColumnHelper<TRow extends Record<string, unknown>>(): {
@@ -113,10 +119,23 @@ export function createColumnHelper<TRow extends Record<string, unknown>>(): {
113
119
  id: string,
114
120
  options: DisplayColumnOptions<TRow>,
115
121
  ) => DisplayColumn<TRow>;
122
+ inferColumns: <const TTable extends TableMetadata>(
123
+ tableMetadata: TTable,
124
+ ) => {
125
+ column: (
126
+ dataKey: TableFieldName<TTable>,
127
+ options?: MetadataFieldOptions<TRow>,
128
+ ) => FieldColumn<TRow>;
129
+ columns: (
130
+ dataKeys: TableFieldName<TTable>[],
131
+ options?: MetadataFieldsOptions,
132
+ ) => FieldColumn<TRow>[];
133
+ };
116
134
  } {
117
135
  return {
118
136
  field: (dataKey, options) => field<TRow>(dataKey, options),
119
137
  display: (id, options) => display<TRow>(id, options),
138
+ inferColumns: (tableMetadata) => inferColumnHelper<TRow>(tableMetadata),
120
139
  };
121
140
  }
122
141
 
@@ -22,6 +22,7 @@ export type {
22
22
  UseCollectionReturn,
23
23
  UseDataTableOptions,
24
24
  UseDataTableReturn,
25
+ RowAction,
25
26
  RowOperations,
26
27
  FieldName,
27
28
  TableFieldName,
@@ -405,9 +405,11 @@ export interface UseCollectionOptions<
405
405
  * Methods that accept a field name are typed with `TFieldName` so that
406
406
  * auto-completion works when a concrete union is supplied.
407
407
  *
408
- * **Note:** Methods use *method syntax* (not property syntax) intentionally
408
+ * **Note:** Methods that accept `TFieldName` use *method syntax* intentionally
409
409
  * so that `UseCollectionReturn<"a" | "b">` remains assignable to
410
410
  * `UseCollectionReturn<string>` (bivariant method check).
411
+ * Methods that don't depend on `TFieldName` use property syntax so they
412
+ * can be safely destructured without triggering `unbound-method` lint rules.
411
413
  *
412
414
  * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
413
415
  * @typeParam TQueryArgs - Type returned by `toQueryArgs()`. Contains both
@@ -428,7 +430,7 @@ export interface UseCollectionReturn<
428
430
  * const [result] = useQuery({ ...collection.toQueryArgs() });
429
431
  * ```
430
432
  */
431
- toQueryArgs(): TQueryArgs;
433
+ toQueryArgs: () => TQueryArgs;
432
434
 
433
435
  // Filter operations
434
436
  /** Current active filters */
@@ -440,11 +442,11 @@ export interface UseCollectionReturn<
440
442
  value: unknown,
441
443
  ): void;
442
444
  /** Replace all filters at once */
443
- setFilters(filters: Filter[]): void;
445
+ setFilters: (filters: Filter[]) => void;
444
446
  /** Remove filter for a specific field */
445
447
  removeFilter(field: TFieldName): void;
446
448
  /** Clear all filters */
447
- clearFilters(): void;
449
+ clearFilters: () => void;
448
450
 
449
451
  // Sort operations
450
452
  /** Current sort states (supports multi-sort) */
@@ -456,7 +458,7 @@ export interface UseCollectionReturn<
456
458
  append?: boolean,
457
459
  ): void;
458
460
  /** Clear all sort states */
459
- clearSort(): void;
461
+ clearSort: () => void;
460
462
 
461
463
  // Pagination operations
462
464
  /** Number of items per page */
@@ -466,17 +468,17 @@ export interface UseCollectionReturn<
466
468
  /** Current pagination direction */
467
469
  paginationDirection: "forward" | "backward";
468
470
  /** Navigate to next page using endCursor from pageInfo */
469
- nextPage(endCursor: string): void;
471
+ nextPage: (endCursor: string) => void;
470
472
  /** Navigate to previous page using startCursor from pageInfo */
471
- prevPage(startCursor: string): void;
473
+ prevPage: (startCursor: string) => void;
472
474
  /** Reset to first page */
473
- resetPage(): void;
475
+ resetPage: () => void;
474
476
  /** Whether there is a previous page (from GraphQL pageInfo) */
475
477
  hasPrevPage: boolean;
476
478
  /** Whether there is a next page (from GraphQL pageInfo) */
477
479
  hasNextPage: boolean;
478
480
  /** Set pageInfo from graphql result to track hasPrevPage/hasNextPage */
479
- setPageInfo(pageInfo: PageInfo): void;
481
+ setPageInfo: (pageInfo: PageInfo) => void;
480
482
  }
481
483
 
482
484
  // =============================================================================
@@ -497,6 +499,10 @@ export interface UseDataTableOptions<TRow extends Record<string, unknown>> {
497
499
  error?: Error | null;
498
500
  /** Collection state for sort/pagination integration */
499
501
  collection?: UseCollectionReturn<string, unknown>;
502
+ /** Handler called when a row is clicked */
503
+ onClickRow?: (row: TRow) => void;
504
+ /** Row action definitions for the actions column */
505
+ rowActions?: RowAction<TRow>[];
500
506
  }
501
507
 
502
508
  /**
@@ -511,6 +517,41 @@ export interface RowOperations<TRow extends Record<string, unknown>> {
511
517
  insertRow: (row: TRow) => { rollback: () => void };
512
518
  }
513
519
 
520
+ // =============================================================================
521
+ // Row Actions
522
+ // =============================================================================
523
+
524
+ /**
525
+ * A single row action definition for the actions column.
526
+ *
527
+ * @example
528
+ * ```tsx
529
+ * const actions: RowAction<Order>[] = [
530
+ * {
531
+ * id: "delete",
532
+ * label: "削除",
533
+ * icon: <Trash2 className="h-4 w-4" />,
534
+ * variant: "destructive",
535
+ * onClick: (row) => handleDelete(row.id),
536
+ * },
537
+ * ];
538
+ * ```
539
+ */
540
+ export interface RowAction<TRow extends Record<string, unknown>> {
541
+ /** Unique action identifier */
542
+ id: string;
543
+ /** Action label text */
544
+ label: string;
545
+ /** Optional icon element */
546
+ icon?: ReactNode;
547
+ /** Visual variant */
548
+ variant?: "default" | "destructive";
549
+ /** Whether the action is disabled for a given row */
550
+ isDisabled?: (row: TRow) => boolean;
551
+ /** Click handler receiving the row data */
552
+ onClick: (row: TRow) => void;
553
+ }
554
+
514
555
  /**
515
556
  * Return type of `useDataTable` hook.
516
557
  */
@@ -564,6 +605,12 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
564
605
  // Collection (passthrough for DataTable.Provider)
565
606
  /** Collection state passed through from options */
566
607
  collection: UseCollectionReturn<string, unknown> | undefined;
608
+
609
+ // Row interaction (passthrough for DataTable.Provider)
610
+ /** Handler called when a row is clicked */
611
+ onClickRow?: (row: TRow) => void;
612
+ /** Row action definitions for the actions column */
613
+ rowActions?: RowAction<TRow>[];
567
614
  }
568
615
 
569
616
  // =============================================================================