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

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.18",
4
+ "version": "0.2.20",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -15,7 +15,7 @@ const CollectionContext = createContext<UseCollectionReturn<
15
15
  *
16
16
  * <CollectionProvider value={collection}>
17
17
  * <FilterPanel />
18
- * <DataTable.Root {...table.rootProps}>...</DataTable.Root>
18
+ * <DataTable.Root>...</DataTable.Root>
19
19
  * <Pagination {...table} />
20
20
  * </CollectionProvider>
21
21
  * ```
@@ -0,0 +1,120 @@
1
+ import { render, screen, fireEvent } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import type { Column } from "./types";
4
+ import { createTestProviders } from "../tests/helpers";
5
+ import { ColumnSelector } from "./column-selector";
6
+
7
+ // =============================================================================
8
+ // Fixtures
9
+ // =============================================================================
10
+
11
+ type TestRow = { id: string; name: string; status: string };
12
+
13
+ const testColumns: Column<TestRow>[] = [
14
+ { kind: "field", dataKey: "name", label: "Name" },
15
+ { kind: "field", dataKey: "status", label: "Status" },
16
+ {
17
+ kind: "display",
18
+ id: "actions",
19
+ label: "Actions",
20
+ render: (row) => <button>Edit {row.name}</button>,
21
+ },
22
+ ];
23
+
24
+ const testRows: TestRow[] = [
25
+ { id: "1", name: "Alice", status: "active" },
26
+ { id: "2", name: "Bob", status: "inactive" },
27
+ ];
28
+
29
+ const TestProviders = createTestProviders({
30
+ columns: testColumns,
31
+ rows: testRows,
32
+ });
33
+
34
+ // =============================================================================
35
+ // Tests
36
+ // =============================================================================
37
+
38
+ describe("ColumnSelector", () => {
39
+ it("renders column names with checkboxes", () => {
40
+ render(
41
+ <TestProviders>
42
+ <ColumnSelector />
43
+ </TestProviders>,
44
+ );
45
+
46
+ expect(screen.getByText("Columns")).toBeInTheDocument();
47
+ expect(screen.getByText("Name")).toBeInTheDocument();
48
+ expect(screen.getByText("Status")).toBeInTheDocument();
49
+ expect(screen.getByText("Actions")).toBeInTheDocument();
50
+ });
51
+
52
+ it("shows checkboxes as checked when columns are visible", () => {
53
+ render(
54
+ <TestProviders dataTable={{ isColumnVisible: vi.fn(() => true) }}>
55
+ <ColumnSelector />
56
+ </TestProviders>,
57
+ );
58
+
59
+ const checkboxes = screen.getAllByRole("checkbox");
60
+ for (const cb of checkboxes) {
61
+ expect(cb).toBeChecked();
62
+ }
63
+ });
64
+
65
+ it("shows checkboxes as unchecked when columns are hidden", () => {
66
+ render(
67
+ <TestProviders dataTable={{ isColumnVisible: vi.fn(() => false) }}>
68
+ <ColumnSelector />
69
+ </TestProviders>,
70
+ );
71
+
72
+ const checkboxes = screen.getAllByRole("checkbox");
73
+ for (const cb of checkboxes) {
74
+ expect(cb).not.toBeChecked();
75
+ }
76
+ });
77
+
78
+ it("calls toggleColumn when a checkbox is clicked", () => {
79
+ const toggleColumn = vi.fn();
80
+ render(
81
+ <TestProviders dataTable={{ toggleColumn }}>
82
+ <ColumnSelector />
83
+ </TestProviders>,
84
+ );
85
+
86
+ const checkboxes = screen.getAllByRole("checkbox");
87
+ fireEvent.click(checkboxes[0]);
88
+ expect(toggleColumn).toHaveBeenCalledWith("name");
89
+ });
90
+
91
+ it("calls showAllColumns when Select all is clicked", () => {
92
+ const showAllColumns = vi.fn();
93
+ render(
94
+ <TestProviders dataTable={{ showAllColumns }}>
95
+ <ColumnSelector />
96
+ </TestProviders>,
97
+ );
98
+
99
+ fireEvent.click(screen.getByText("Select all"));
100
+ expect(showAllColumns).toHaveBeenCalled();
101
+ });
102
+
103
+ it("calls hideAllColumns when Deselect all is clicked", () => {
104
+ const hideAllColumns = vi.fn();
105
+ render(
106
+ <TestProviders dataTable={{ hideAllColumns }}>
107
+ <ColumnSelector />
108
+ </TestProviders>,
109
+ );
110
+
111
+ fireEvent.click(screen.getByText("Deselect all"));
112
+ expect(hideAllColumns).toHaveBeenCalled();
113
+ });
114
+
115
+ it("throws when rendered outside provider", () => {
116
+ console.error = vi.fn();
117
+ expect(() => render(<ColumnSelector />)).toThrow();
118
+ console.error = globalThis.console.error;
119
+ });
120
+ });
@@ -1,22 +1,26 @@
1
- import type { ColumnSelectorProps } from "./types";
1
+ import { useDataTableContext } from "./data-table/data-table-context";
2
2
 
3
3
  /**
4
4
  * Column visibility toggle dropdown.
5
5
  *
6
- * Designed to receive props via spread from `useDataTable()`.
6
+ * Reads column definitions and visibility state from `DataTableContext`.
7
+ * Must be rendered inside `DataTable.Provider`.
7
8
  *
8
9
  * @example
9
10
  * ```tsx
10
- * <ColumnSelector {...table} />
11
+ * <DataTable.Provider value={table}>
12
+ * <ColumnSelector />
13
+ * </DataTable.Provider>
11
14
  * ```
12
15
  */
13
- export function ColumnSelector<TRow extends Record<string, unknown>>({
14
- columns,
15
- isColumnVisible,
16
- toggleColumn,
17
- showAllColumns,
18
- hideAllColumns,
19
- }: ColumnSelectorProps<TRow>) {
16
+ export function ColumnSelector() {
17
+ const {
18
+ columns,
19
+ isColumnVisible,
20
+ toggleColumn,
21
+ showAllColumns,
22
+ hideAllColumns,
23
+ } = useDataTableContext();
20
24
  return (
21
25
  <div className="relative inline-block">
22
26
  <details className="group">
@@ -24,28 +28,22 @@ export function ColumnSelector<TRow extends Record<string, unknown>>({
24
28
  Columns
25
29
  </summary>
26
30
  <div className="absolute right-0 z-50 mt-1 min-w-[180px] rounded-md border bg-popover p-2 text-popover-foreground shadow-md">
27
- {(showAllColumns || hideAllColumns) && (
28
- <div className="flex gap-2 border-b pb-2 mb-2">
29
- {showAllColumns && (
30
- <button
31
- type="button"
32
- className="text-xs text-muted-foreground hover:text-foreground"
33
- onClick={showAllColumns}
34
- >
35
- Select all
36
- </button>
37
- )}
38
- {hideAllColumns && (
39
- <button
40
- type="button"
41
- className="text-xs text-muted-foreground hover:text-foreground"
42
- onClick={hideAllColumns}
43
- >
44
- Deselect all
45
- </button>
46
- )}
47
- </div>
48
- )}
31
+ <div className="flex gap-2 border-b pb-2 mb-2">
32
+ <button
33
+ type="button"
34
+ className="text-xs text-muted-foreground hover:text-foreground"
35
+ onClick={showAllColumns}
36
+ >
37
+ Select all
38
+ </button>
39
+ <button
40
+ type="button"
41
+ className="text-xs text-muted-foreground hover:text-foreground"
42
+ onClick={hideAllColumns}
43
+ >
44
+ Deselect all
45
+ </button>
46
+ </div>
49
47
  {columns.map((col) => {
50
48
  const key = col.kind === "field" ? col.dataKey : col.id;
51
49
  const label =
@@ -1,7 +1,9 @@
1
1
  import { render, screen } from "@testing-library/react";
2
2
  import { describe, it, expect } from "vitest";
3
3
  import { Table } from "./table";
4
- import { DataTable } from "./data-table/index";
4
+ import { DataTable } from "./data-table/data-table";
5
+ import { DataTableContext } from "./data-table/data-table-context";
6
+ import type { DataTableContextValue } from "./data-table/data-table-context";
5
7
  import type { Column } from "./types";
6
8
 
7
9
  describe("Table (static)", () => {
@@ -65,13 +67,51 @@ const testRows: TestRow[] = [
65
67
  { id: "2", name: "Bob", status: "Inactive" },
66
68
  ];
67
69
 
70
+ const noopRowOps = {
71
+ updateRow: () => ({ rollback: () => {} }),
72
+ deleteRow: () => ({
73
+ rollback: () => {},
74
+ deletedRow: {} as TestRow,
75
+ }),
76
+ insertRow: () => ({ rollback: () => {} }),
77
+ };
78
+
79
+ const defaultPageInfo = {
80
+ hasNextPage: false,
81
+ endCursor: null,
82
+ hasPreviousPage: false,
83
+ startCursor: null,
84
+ };
85
+
86
+ function createCtx(
87
+ overrides: Partial<DataTableContextValue<TestRow>> = {},
88
+ ): DataTableContextValue<TestRow> {
89
+ return {
90
+ columns: testColumns,
91
+ rows: testRows,
92
+ loading: false,
93
+ error: null,
94
+ sortStates: [],
95
+ visibleColumns: testColumns,
96
+ isColumnVisible: () => true,
97
+ toggleColumn: () => {},
98
+ showAllColumns: () => {},
99
+ hideAllColumns: () => {},
100
+ pageInfo: defaultPageInfo,
101
+ ...noopRowOps,
102
+ ...overrides,
103
+ };
104
+ }
105
+
68
106
  describe("DataTable", () => {
69
107
  it("renders data-bound table with auto-generated rows", () => {
70
108
  render(
71
- <DataTable.Root columns={testColumns} rows={testRows}>
72
- <DataTable.Headers />
73
- <DataTable.Body />
74
- </DataTable.Root>,
109
+ <DataTableContext.Provider value={createCtx()}>
110
+ <DataTable.Root>
111
+ <DataTable.Headers />
112
+ <DataTable.Body />
113
+ </DataTable.Root>
114
+ </DataTableContext.Provider>,
75
115
  );
76
116
 
77
117
  expect(screen.getByText("Name")).toBeInTheDocument();
@@ -83,10 +123,12 @@ describe("DataTable", () => {
83
123
 
84
124
  it("renders display columns via render function", () => {
85
125
  render(
86
- <DataTable.Root columns={testColumns} rows={testRows}>
87
- <DataTable.Headers />
88
- <DataTable.Body />
89
- </DataTable.Root>,
126
+ <DataTableContext.Provider value={createCtx()}>
127
+ <DataTable.Root>
128
+ <DataTable.Headers />
129
+ <DataTable.Body />
130
+ </DataTable.Root>
131
+ </DataTableContext.Provider>,
90
132
  );
91
133
 
92
134
  expect(screen.getByText("Edit Alice")).toBeInTheDocument();
@@ -95,10 +137,12 @@ describe("DataTable", () => {
95
137
 
96
138
  it("shows loading state", () => {
97
139
  render(
98
- <DataTable.Root columns={testColumns} rows={[]} loading>
99
- <DataTable.Headers />
100
- <DataTable.Body />
101
- </DataTable.Root>,
140
+ <DataTableContext.Provider value={createCtx({ rows: [], loading: true })}>
141
+ <DataTable.Root>
142
+ <DataTable.Headers />
143
+ <DataTable.Body />
144
+ </DataTable.Root>
145
+ </DataTableContext.Provider>,
102
146
  );
103
147
 
104
148
  expect(screen.getByText("Loading...")).toBeInTheDocument();
@@ -107,10 +151,12 @@ describe("DataTable", () => {
107
151
  it("shows error state", () => {
108
152
  const err = new Error("Something went wrong");
109
153
  render(
110
- <DataTable.Root columns={testColumns} rows={[]} error={err}>
111
- <DataTable.Headers />
112
- <DataTable.Body />
113
- </DataTable.Root>,
154
+ <DataTableContext.Provider value={createCtx({ rows: [], error: err })}>
155
+ <DataTable.Root>
156
+ <DataTable.Headers />
157
+ <DataTable.Body />
158
+ </DataTable.Root>
159
+ </DataTableContext.Provider>,
114
160
  );
115
161
 
116
162
  expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument();
@@ -118,10 +164,12 @@ describe("DataTable", () => {
118
164
 
119
165
  it("shows empty state", () => {
120
166
  render(
121
- <DataTable.Root columns={testColumns} rows={[]}>
122
- <DataTable.Headers />
123
- <DataTable.Body />
124
- </DataTable.Root>,
167
+ <DataTableContext.Provider value={createCtx({ rows: [] })}>
168
+ <DataTable.Root>
169
+ <DataTable.Headers />
170
+ <DataTable.Body />
171
+ </DataTable.Root>
172
+ </DataTableContext.Provider>,
125
173
  );
126
174
 
127
175
  expect(screen.getByText("No data")).toBeInTheDocument();
@@ -129,14 +177,16 @@ describe("DataTable", () => {
129
177
 
130
178
  it("renders sort indicator on sorted column", () => {
131
179
  render(
132
- <DataTable.Root
133
- columns={testColumns}
134
- rows={testRows}
135
- sortStates={[{ field: "name", direction: "Asc" }]}
180
+ <DataTableContext.Provider
181
+ value={createCtx({
182
+ sortStates: [{ field: "name", direction: "Asc" }],
183
+ })}
136
184
  >
137
- <DataTable.Headers />
138
- <DataTable.Body />
139
- </DataTable.Root>,
185
+ <DataTable.Root>
186
+ <DataTable.Headers />
187
+ <DataTable.Body />
188
+ </DataTable.Root>
189
+ </DataTableContext.Provider>,
140
190
  );
141
191
 
142
192
  expect(screen.getByText("▲")).toBeInTheDocument();
@@ -144,14 +194,16 @@ describe("DataTable", () => {
144
194
 
145
195
  it("supports custom rendering with children", () => {
146
196
  render(
147
- <DataTable.Root columns={testColumns} rows={testRows}>
148
- <DataTable.Headers />
149
- <DataTable.Body>
150
- <DataTable.Row>
151
- <DataTable.Cell>Custom Cell</DataTable.Cell>
152
- </DataTable.Row>
153
- </DataTable.Body>
154
- </DataTable.Root>,
197
+ <DataTableContext.Provider value={createCtx()}>
198
+ <DataTable.Root>
199
+ <DataTable.Headers />
200
+ <DataTable.Body>
201
+ <DataTable.Row>
202
+ <DataTable.Cell>Custom Cell</DataTable.Cell>
203
+ </DataTable.Row>
204
+ </DataTable.Body>
205
+ </DataTable.Root>
206
+ </DataTableContext.Provider>,
155
207
  );
156
208
 
157
209
  expect(screen.getByText("Custom Cell")).toBeInTheDocument();
@@ -0,0 +1,122 @@
1
+ import { render, screen, fireEvent } from "@testing-library/react";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import type { Column } from "./types";
4
+ import { createTestProviders } from "../tests/helpers";
5
+ import { CsvButton } from "./csv-button";
6
+
7
+ // =============================================================================
8
+ // Fixtures
9
+ // =============================================================================
10
+
11
+ type TestRow = { id: string; name: string; status: string };
12
+
13
+ const testColumns: Column<TestRow>[] = [
14
+ { kind: "field", dataKey: "name", label: "Name" },
15
+ { kind: "field", dataKey: "status", label: "Status" },
16
+ ];
17
+
18
+ const testRows: TestRow[] = [
19
+ { id: "1", name: "Alice", status: "active" },
20
+ { id: "2", name: "Bob", status: "inactive" },
21
+ ];
22
+
23
+ const TestProviders = createTestProviders({
24
+ columns: testColumns,
25
+ rows: testRows,
26
+ });
27
+
28
+ // =============================================================================
29
+ // Tests
30
+ // =============================================================================
31
+
32
+ describe("CsvButton", () => {
33
+ // Save originals before each test to avoid cross-contamination
34
+ const originalCreateObjectURL = globalThis.URL.createObjectURL;
35
+ const originalRevokeObjectURL = globalThis.URL.revokeObjectURL;
36
+ let originalCreateElement: typeof document.createElement;
37
+
38
+ beforeEach(() => {
39
+ originalCreateElement = document.createElement.bind(document);
40
+ });
41
+
42
+ afterEach(() => {
43
+ globalThis.URL.createObjectURL = originalCreateObjectURL;
44
+ globalThis.URL.revokeObjectURL = originalRevokeObjectURL;
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ it("renders Export CSV button", () => {
49
+ render(
50
+ <TestProviders>
51
+ <CsvButton />
52
+ </TestProviders>,
53
+ );
54
+
55
+ expect(screen.getByText("Export CSV")).toBeInTheDocument();
56
+ });
57
+
58
+ it("triggers CSV download on click", () => {
59
+ const createObjectURL = vi.fn(() => "blob:mock-url");
60
+ const revokeObjectURL = vi.fn();
61
+ globalThis.URL.createObjectURL = createObjectURL;
62
+ globalThis.URL.revokeObjectURL = revokeObjectURL;
63
+
64
+ const clickSpy = vi.fn();
65
+ vi.spyOn(document, "createElement").mockImplementation(
66
+ (tag: string, options?: ElementCreationOptions) => {
67
+ if (tag === "a") {
68
+ return {
69
+ href: "",
70
+ download: "",
71
+ click: clickSpy,
72
+ } as unknown as HTMLAnchorElement;
73
+ }
74
+ return originalCreateElement(tag, options);
75
+ },
76
+ );
77
+
78
+ render(
79
+ <TestProviders>
80
+ <CsvButton filename="test-export" />
81
+ </TestProviders>,
82
+ );
83
+
84
+ fireEvent.click(screen.getByText("Export CSV"));
85
+
86
+ expect(createObjectURL).toHaveBeenCalled();
87
+ expect(clickSpy).toHaveBeenCalled();
88
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
89
+ });
90
+
91
+ it("uses default filename when not specified", () => {
92
+ globalThis.URL.createObjectURL = vi.fn(() => "blob:url");
93
+ globalThis.URL.revokeObjectURL = vi.fn();
94
+
95
+ const links: { download: string }[] = [];
96
+ vi.spyOn(document, "createElement").mockImplementation(
97
+ (tag: string, options?: ElementCreationOptions) => {
98
+ if (tag === "a") {
99
+ const link = { href: "", download: "", click: vi.fn() };
100
+ links.push(link);
101
+ return link as unknown as HTMLAnchorElement;
102
+ }
103
+ return originalCreateElement(tag, options);
104
+ },
105
+ );
106
+
107
+ render(
108
+ <TestProviders>
109
+ <CsvButton />
110
+ </TestProviders>,
111
+ );
112
+
113
+ fireEvent.click(screen.getByText("Export CSV"));
114
+ expect(links[0].download).toBe("export.csv");
115
+ });
116
+
117
+ it("throws when rendered outside provider", () => {
118
+ console.error = vi.fn();
119
+ expect(() => render(<CsvButton />)).toThrow();
120
+ console.error = globalThis.console.error;
121
+ });
122
+ });
@@ -1,20 +1,21 @@
1
- import type { CsvButtonProps } from "./types";
1
+ import { useDataTableContext } from "./data-table/data-table-context";
2
2
 
3
3
  /**
4
4
  * CSV export button.
5
5
  *
6
6
  * Exports visible columns and rows to a CSV file download.
7
+ * Reads `visibleColumns` and `rows` from `DataTableContext`.
8
+ * Must be rendered inside `DataTable.Provider`.
7
9
  *
8
10
  * @example
9
11
  * ```tsx
10
- * <CsvButton {...table} filename="orders-export" />
12
+ * <DataTable.Provider value={table}>
13
+ * <CsvButton filename="orders-export" />
14
+ * </DataTable.Provider>
11
15
  * ```
12
16
  */
13
- export function CsvButton<TRow extends Record<string, unknown>>({
14
- visibleColumns,
15
- rows,
16
- filename = "export",
17
- }: CsvButtonProps<TRow>) {
17
+ export function CsvButton({ filename = "export" }: { filename?: string }) {
18
+ const { visibleColumns, rows } = useDataTableContext();
18
19
  const handleExport = () => {
19
20
  // Build header row
20
21
  const headers = visibleColumns.map((col) => {
@@ -1,25 +1,36 @@
1
1
  import { createContext, useContext } from "react";
2
- import type { Column, RowOperations, SortState } from "../types";
2
+ import type { Column, PageInfo, RowOperations, SortState } from "../types";
3
3
 
4
4
  /**
5
- * Context value provided by `DataTable.Root`.
5
+ * Context value provided by `DataTable.Provider`.
6
6
  *
7
- * Exposes row operations for optimistic updates and table state
8
- * so that `DataTable.Headers` / `DataTable.Body` can read them
9
- * without explicit props.
7
+ * Exposes row operations for optimistic updates, table state, column
8
+ * visibility, and page info so that `DataTable.Headers` / `DataTable.Body`
9
+ * and utility components (`Pagination`, `ColumnSelector`, etc.) can read
10
+ * them without explicit props.
10
11
  */
11
12
  export interface DataTableContextValue<TRow extends Record<string, unknown>> {
12
13
  updateRow: RowOperations<TRow>["updateRow"];
13
14
  deleteRow: RowOperations<TRow>["deleteRow"];
14
15
  insertRow: RowOperations<TRow>["insertRow"];
15
16
 
16
- // Table state propagated from DataTable.Root
17
+ // Table state propagated from DataTable.Provider
17
18
  columns: Column<TRow>[];
18
19
  rows: TRow[];
19
20
  loading: boolean;
20
21
  error: Error | null;
21
22
  sortStates: SortState[];
22
23
  onSort?: (field: string, direction?: "Asc" | "Desc") => void;
24
+
25
+ // Column visibility (populated by DataTable.Provider)
26
+ visibleColumns: Column<TRow>[];
27
+ isColumnVisible: (fieldOrId: string) => boolean;
28
+ toggleColumn: (fieldOrId: string) => void;
29
+ showAllColumns: () => void;
30
+ hideAllColumns: () => void;
31
+
32
+ // Page info from GraphQL response (populated by DataTable.Provider)
33
+ pageInfo: PageInfo;
23
34
  }
24
35
 
25
36
  // Using `any` for the context default since generic contexts need a base type.
@@ -29,9 +40,9 @@ const DataTableContext = createContext<DataTableContextValue<any> | null>(null);
29
40
  export { DataTableContext };
30
41
 
31
42
  /**
32
- * Hook to access row operations from the nearest `DataTable.Root`.
43
+ * Hook to access row operations from the nearest `DataTable.Provider`.
33
44
  *
34
- * @throws Error if used outside of `DataTable.Root`.
45
+ * @throws Error if used outside of `DataTable.Provider`.
35
46
  *
36
47
  * @example
37
48
  * ```tsx
@@ -46,7 +57,9 @@ export function useDataTableContext<
46
57
  >(): DataTableContextValue<TRow> {
47
58
  const ctx = useContext(DataTableContext);
48
59
  if (!ctx) {
49
- throw new Error("useDataTableContext must be used within <DataTable.Root>");
60
+ throw new Error(
61
+ "useDataTableContext must be used within <DataTable.Provider>",
62
+ );
50
63
  }
51
64
  return ctx as DataTableContextValue<TRow>;
52
65
  }