@izumisy-tailor/tailor-data-viewer 0.3.0 → 0.3.2
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/dist/generator/index.d.mts +0 -2
- package/dist/generator/index.mjs +0 -22
- package/package.json +1 -1
- package/src/component/collection/collection-provider.tsx +20 -23
- package/src/component/collection/use-collection.test.ts +67 -68
- package/src/component/collection/use-collection.ts +28 -25
- package/src/component/collection/use-collection.typetest.ts +6 -3
- package/src/component/data-table/data-table.tsx +6 -6
- package/src/component/data-table/pagination.tsx +2 -2
- package/src/component/data-table/search-filter-form.tsx +2 -2
- package/src/component/data-table/use-data-table.test.ts +2 -2
- package/src/component/data-table/use-data-table.ts +22 -27
- package/src/component/field-helpers.test.ts +0 -4
- package/src/component/index.ts +3 -2
- package/src/component/types.ts +72 -27
- package/src/generator/metadata-generator.ts +0 -63
- package/src/tests/helpers.tsx +8 -13
|
@@ -64,7 +64,6 @@ interface TableMetadata {
|
|
|
64
64
|
readonly name: string;
|
|
65
65
|
readonly pluralForm: string;
|
|
66
66
|
readonly description?: string;
|
|
67
|
-
readonly readAllowedRoles: readonly string[];
|
|
68
67
|
readonly fields: readonly FieldMetadata[];
|
|
69
68
|
/** Relations (manyToOne, oneToOne, and oneToMany) */
|
|
70
69
|
readonly relations?: readonly RelationMetadata[];
|
|
@@ -89,7 +88,6 @@ interface ProcessedTable {
|
|
|
89
88
|
pluralForm: string;
|
|
90
89
|
originalName: string;
|
|
91
90
|
description?: string;
|
|
92
|
-
readAllowedRoles: string[];
|
|
93
91
|
fields: FieldMetadata[];
|
|
94
92
|
relations: RelationMetadata[];
|
|
95
93
|
}
|
package/dist/generator/index.mjs
CHANGED
|
@@ -29,26 +29,6 @@ function toCamelCase(str) {
|
|
|
29
29
|
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* Extract allowed roles from gql permission policies
|
|
33
|
-
* Only extracts roles from 'read' action policies with 'allow' permit
|
|
34
|
-
*/
|
|
35
|
-
function extractReadAllowedRoles(gqlPermission) {
|
|
36
|
-
if (!gqlPermission) return [];
|
|
37
|
-
const roles = /* @__PURE__ */ new Set();
|
|
38
|
-
for (const policy of gqlPermission) {
|
|
39
|
-
if (policy.permit !== "allow") continue;
|
|
40
|
-
const actions = policy.actions;
|
|
41
|
-
if (!actions.includes("all") && !actions.includes("read")) continue;
|
|
42
|
-
for (const condition of policy.conditions) {
|
|
43
|
-
if (!Array.isArray(condition) || condition.length < 3) continue;
|
|
44
|
-
const [left, operator, right] = condition;
|
|
45
|
-
if (typeof left === "string" && operator === "in" && typeof right === "object" && right !== null && "user" in right && right.user === "roles") roles.add(left);
|
|
46
|
-
if (typeof right === "string" && operator === "in" && typeof left === "object" && left !== null && "user" in left && left.user === "roles") roles.add(right);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return Array.from(roles);
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
32
|
* Creates a custom generator that extracts table metadata for Data View
|
|
53
33
|
*/
|
|
54
34
|
function dataViewerMetadataGenerator(options = {}) {
|
|
@@ -100,13 +80,11 @@ function dataViewerMetadataGenerator(options = {}) {
|
|
|
100
80
|
};
|
|
101
81
|
fields.push(fieldMetadata);
|
|
102
82
|
}
|
|
103
|
-
const readAllowedRoles = extractReadAllowedRoles(type.permissions.gql);
|
|
104
83
|
return {
|
|
105
84
|
name: toCamelCase(type.name),
|
|
106
85
|
pluralForm: toCamelCase(type.pluralForm),
|
|
107
86
|
originalName: type.name,
|
|
108
87
|
description: type.description,
|
|
109
|
-
readAllowedRoles,
|
|
110
88
|
fields,
|
|
111
89
|
relations
|
|
112
90
|
};
|
package/package.json
CHANGED
|
@@ -1,69 +1,66 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { CollectionControl } from "../types";
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
string,
|
|
6
|
-
unknown
|
|
7
|
-
> | null>(null);
|
|
4
|
+
const CollectionControlContext = createContext<CollectionControl | null>(null);
|
|
8
5
|
|
|
9
6
|
/**
|
|
10
|
-
* Provider that shares collection
|
|
7
|
+
* Provider that shares collection control state via React Context.
|
|
11
8
|
*
|
|
12
9
|
* @example
|
|
13
10
|
* ```tsx
|
|
14
|
-
* const { variables,
|
|
11
|
+
* const { variables, control } = useCollectionVariables({ params: { pageSize: 20 } });
|
|
15
12
|
*
|
|
16
|
-
* <
|
|
13
|
+
* <CollectionControlProvider value={control}>
|
|
17
14
|
* <FilterPanel />
|
|
18
15
|
* <DataTable.Root>...</DataTable.Root>
|
|
19
16
|
* <Pagination />
|
|
20
|
-
* </
|
|
17
|
+
* </CollectionControlProvider>
|
|
21
18
|
* ```
|
|
22
19
|
*/
|
|
23
|
-
export function
|
|
20
|
+
export function CollectionControlProvider({
|
|
24
21
|
value,
|
|
25
22
|
children,
|
|
26
23
|
}: {
|
|
27
|
-
value:
|
|
24
|
+
value: CollectionControl;
|
|
28
25
|
children: ReactNode;
|
|
29
26
|
}) {
|
|
30
27
|
return (
|
|
31
|
-
<
|
|
28
|
+
<CollectionControlContext.Provider value={value}>
|
|
32
29
|
{children}
|
|
33
|
-
</
|
|
30
|
+
</CollectionControlContext.Provider>
|
|
34
31
|
);
|
|
35
32
|
}
|
|
36
33
|
|
|
37
34
|
/**
|
|
38
|
-
* Hook to access collection
|
|
35
|
+
* Hook to access collection control from the nearest `CollectionControlProvider`.
|
|
39
36
|
*
|
|
40
|
-
* Returns the
|
|
37
|
+
* Returns the `CollectionControl` interface. Pass a `TFieldName`
|
|
41
38
|
* type parameter to narrow method arguments like `addFilter` / `setSort`.
|
|
42
39
|
*
|
|
43
40
|
* @typeParam TFieldName - Union of allowed field name strings (default: `string`).
|
|
44
41
|
*
|
|
45
|
-
* @throws Error if used outside of `
|
|
42
|
+
* @throws Error if used outside of `CollectionControlProvider`.
|
|
46
43
|
*
|
|
47
44
|
* @example
|
|
48
45
|
* ```tsx
|
|
49
46
|
* function StatusFilter() {
|
|
50
|
-
* const { filters, addFilter, removeFilter } =
|
|
47
|
+
* const { filters, addFilter, removeFilter } = useCollectionControl();
|
|
51
48
|
* // ...
|
|
52
49
|
* }
|
|
53
50
|
*
|
|
54
51
|
* // With typed field names:
|
|
55
52
|
* type TaskField = FieldName<typeof tableMetadata, "task">;
|
|
56
|
-
* const { addFilter } =
|
|
53
|
+
* const { addFilter } = useCollectionControl<TaskField>();
|
|
57
54
|
* ```
|
|
58
55
|
*/
|
|
59
|
-
export function
|
|
56
|
+
export function useCollectionControl<
|
|
60
57
|
TFieldName extends string = string,
|
|
61
|
-
>():
|
|
62
|
-
const ctx = useContext(
|
|
58
|
+
>(): CollectionControl<TFieldName> {
|
|
59
|
+
const ctx = useContext(CollectionControlContext);
|
|
63
60
|
if (!ctx) {
|
|
64
61
|
throw new Error(
|
|
65
|
-
"
|
|
62
|
+
"useCollectionControl must be used within <CollectionControlProvider>",
|
|
66
63
|
);
|
|
67
64
|
}
|
|
68
|
-
return ctx as
|
|
65
|
+
return ctx as CollectionControl<TFieldName>;
|
|
69
66
|
}
|
|
@@ -13,10 +13,10 @@ describe("useCollectionVariables", () => {
|
|
|
13
13
|
expect(result.current.variables.pagination).toEqual({ first: 20 });
|
|
14
14
|
expect(result.current.variables.query).toBeUndefined();
|
|
15
15
|
expect(result.current.variables.order).toBeUndefined();
|
|
16
|
-
expect(result.current.filters).toEqual([]);
|
|
17
|
-
expect(result.current.sortStates).toEqual([]);
|
|
18
|
-
expect(result.current.cursor).toBeNull();
|
|
19
|
-
expect(result.current.hasPrevPage).toBe(false);
|
|
16
|
+
expect(result.current.control.filters).toEqual([]);
|
|
17
|
+
expect(result.current.control.sortStates).toEqual([]);
|
|
18
|
+
expect(result.current.control.cursor).toBeNull();
|
|
19
|
+
expect(result.current.control.hasPrevPage).toBe(false);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
it("uses custom pageSize", () => {
|
|
@@ -34,7 +34,7 @@ describe("useCollectionVariables", () => {
|
|
|
34
34
|
},
|
|
35
35
|
}),
|
|
36
36
|
);
|
|
37
|
-
expect(result.current.sortStates).toEqual([
|
|
37
|
+
expect(result.current.control.sortStates).toEqual([
|
|
38
38
|
{ field: "createdAt", direction: "Desc" },
|
|
39
39
|
]);
|
|
40
40
|
expect(result.current.variables.order).toEqual([
|
|
@@ -56,7 +56,7 @@ describe("useCollectionVariables", () => {
|
|
|
56
56
|
},
|
|
57
57
|
}),
|
|
58
58
|
);
|
|
59
|
-
expect(result.current.filters).toHaveLength(1);
|
|
59
|
+
expect(result.current.control.filters).toHaveLength(1);
|
|
60
60
|
expect(result.current.variables.query).toEqual({
|
|
61
61
|
status: { eq: "ACTIVE" },
|
|
62
62
|
});
|
|
@@ -71,11 +71,11 @@ describe("useCollectionVariables", () => {
|
|
|
71
71
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
72
72
|
|
|
73
73
|
act(() => {
|
|
74
|
-
result.current.addFilter("status", "eq", "ACTIVE");
|
|
74
|
+
result.current.control.addFilter("status", "eq", "ACTIVE");
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
expect(result.current.filters).toHaveLength(1);
|
|
78
|
-
expect(result.current.filters[0]).toMatchObject({
|
|
77
|
+
expect(result.current.control.filters).toHaveLength(1);
|
|
78
|
+
expect(result.current.control.filters[0]).toMatchObject({
|
|
79
79
|
field: "status",
|
|
80
80
|
operator: "eq",
|
|
81
81
|
value: "ACTIVE",
|
|
@@ -89,21 +89,21 @@ describe("useCollectionVariables", () => {
|
|
|
89
89
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
90
90
|
|
|
91
91
|
act(() => {
|
|
92
|
-
result.current.addFilter("status", "eq", "ACTIVE");
|
|
92
|
+
result.current.control.addFilter("status", "eq", "ACTIVE");
|
|
93
93
|
});
|
|
94
94
|
act(() => {
|
|
95
|
-
result.current.addFilter("status", "eq", "INACTIVE");
|
|
95
|
+
result.current.control.addFilter("status", "eq", "INACTIVE");
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
expect(result.current.filters).toHaveLength(1);
|
|
99
|
-
expect(result.current.filters[0].value).toBe("INACTIVE");
|
|
98
|
+
expect(result.current.control.filters).toHaveLength(1);
|
|
99
|
+
expect(result.current.control.filters[0].value).toBe("INACTIVE");
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
it("sets filters in bulk", () => {
|
|
103
103
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
104
104
|
|
|
105
105
|
act(() => {
|
|
106
|
-
result.current.setFilters([
|
|
106
|
+
result.current.control.setFilters([
|
|
107
107
|
{
|
|
108
108
|
field: "status",
|
|
109
109
|
operator: "eq",
|
|
@@ -117,7 +117,7 @@ describe("useCollectionVariables", () => {
|
|
|
117
117
|
]);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
expect(result.current.filters).toHaveLength(2);
|
|
120
|
+
expect(result.current.control.filters).toHaveLength(2);
|
|
121
121
|
expect(result.current.variables.query).toEqual({
|
|
122
122
|
status: { eq: "ACTIVE" },
|
|
123
123
|
amount: { gte: 1000 },
|
|
@@ -128,29 +128,29 @@ describe("useCollectionVariables", () => {
|
|
|
128
128
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
129
129
|
|
|
130
130
|
act(() => {
|
|
131
|
-
result.current.addFilter("status", "eq", "ACTIVE");
|
|
132
|
-
result.current.addFilter("amount", "gte", 1000);
|
|
131
|
+
result.current.control.addFilter("status", "eq", "ACTIVE");
|
|
132
|
+
result.current.control.addFilter("amount", "gte", 1000);
|
|
133
133
|
});
|
|
134
134
|
act(() => {
|
|
135
|
-
result.current.removeFilter("status");
|
|
135
|
+
result.current.control.removeFilter("status");
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
expect(result.current.filters).toHaveLength(1);
|
|
139
|
-
expect(result.current.filters[0].field).toBe("amount");
|
|
138
|
+
expect(result.current.control.filters).toHaveLength(1);
|
|
139
|
+
expect(result.current.control.filters[0].field).toBe("amount");
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
it("clears all filters", () => {
|
|
143
143
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
144
144
|
|
|
145
145
|
act(() => {
|
|
146
|
-
result.current.addFilter("status", "eq", "ACTIVE");
|
|
147
|
-
result.current.addFilter("amount", "gte", 1000);
|
|
146
|
+
result.current.control.addFilter("status", "eq", "ACTIVE");
|
|
147
|
+
result.current.control.addFilter("amount", "gte", 1000);
|
|
148
148
|
});
|
|
149
149
|
act(() => {
|
|
150
|
-
result.current.clearFilters();
|
|
150
|
+
result.current.control.clearFilters();
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
-
expect(result.current.filters).toHaveLength(0);
|
|
153
|
+
expect(result.current.control.filters).toHaveLength(0);
|
|
154
154
|
expect(result.current.variables.query).toBeUndefined();
|
|
155
155
|
});
|
|
156
156
|
|
|
@@ -159,16 +159,16 @@ describe("useCollectionVariables", () => {
|
|
|
159
159
|
|
|
160
160
|
// Navigate to next page
|
|
161
161
|
act(() => {
|
|
162
|
-
result.current.nextPage("cursor1");
|
|
162
|
+
result.current.control.nextPage("cursor1");
|
|
163
163
|
});
|
|
164
|
-
expect(result.current.cursor).toBe("cursor1");
|
|
164
|
+
expect(result.current.control.cursor).toBe("cursor1");
|
|
165
165
|
|
|
166
166
|
// Adding filter should reset pagination
|
|
167
167
|
act(() => {
|
|
168
|
-
result.current.addFilter("status", "eq", "ACTIVE");
|
|
168
|
+
result.current.control.addFilter("status", "eq", "ACTIVE");
|
|
169
169
|
});
|
|
170
|
-
expect(result.current.cursor).toBeNull();
|
|
171
|
-
expect(result.current.hasPrevPage).toBe(false);
|
|
170
|
+
expect(result.current.control.cursor).toBeNull();
|
|
171
|
+
expect(result.current.control.hasPrevPage).toBe(false);
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
@@ -180,10 +180,10 @@ describe("useCollectionVariables", () => {
|
|
|
180
180
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
181
181
|
|
|
182
182
|
act(() => {
|
|
183
|
-
result.current.setSort("createdAt", "Desc");
|
|
183
|
+
result.current.control.setSort("createdAt", "Desc");
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
-
expect(result.current.sortStates).toEqual([
|
|
186
|
+
expect(result.current.control.sortStates).toEqual([
|
|
187
187
|
{ field: "createdAt", direction: "Desc" },
|
|
188
188
|
]);
|
|
189
189
|
expect(result.current.variables.order).toEqual([
|
|
@@ -195,13 +195,13 @@ describe("useCollectionVariables", () => {
|
|
|
195
195
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
196
196
|
|
|
197
197
|
act(() => {
|
|
198
|
-
result.current.setSort("createdAt", "Desc");
|
|
198
|
+
result.current.control.setSort("createdAt", "Desc");
|
|
199
199
|
});
|
|
200
200
|
act(() => {
|
|
201
|
-
result.current.setSort("name", "Asc");
|
|
201
|
+
result.current.control.setSort("name", "Asc");
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
-
expect(result.current.sortStates).toEqual([
|
|
204
|
+
expect(result.current.control.sortStates).toEqual([
|
|
205
205
|
{ field: "createdAt", direction: "Desc" },
|
|
206
206
|
{ field: "name", direction: "Asc" },
|
|
207
207
|
]);
|
|
@@ -211,16 +211,16 @@ describe("useCollectionVariables", () => {
|
|
|
211
211
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
212
212
|
|
|
213
213
|
act(() => {
|
|
214
|
-
result.current.setSort("createdAt", "Desc");
|
|
214
|
+
result.current.control.setSort("createdAt", "Desc");
|
|
215
215
|
});
|
|
216
216
|
act(() => {
|
|
217
|
-
result.current.setSort("name", "Asc");
|
|
217
|
+
result.current.control.setSort("name", "Asc");
|
|
218
218
|
});
|
|
219
219
|
act(() => {
|
|
220
|
-
result.current.setSort("createdAt", "Asc");
|
|
220
|
+
result.current.control.setSort("createdAt", "Asc");
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
expect(result.current.sortStates).toEqual([
|
|
223
|
+
expect(result.current.control.sortStates).toEqual([
|
|
224
224
|
{ field: "name", direction: "Asc" },
|
|
225
225
|
{ field: "createdAt", direction: "Asc" },
|
|
226
226
|
]);
|
|
@@ -230,16 +230,16 @@ describe("useCollectionVariables", () => {
|
|
|
230
230
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
231
231
|
|
|
232
232
|
act(() => {
|
|
233
|
-
result.current.setSort("createdAt", "Desc");
|
|
233
|
+
result.current.control.setSort("createdAt", "Desc");
|
|
234
234
|
});
|
|
235
235
|
act(() => {
|
|
236
|
-
result.current.setSort("name", "Asc");
|
|
236
|
+
result.current.control.setSort("name", "Asc");
|
|
237
237
|
});
|
|
238
238
|
act(() => {
|
|
239
|
-
result.current.setSort("createdAt");
|
|
239
|
+
result.current.control.setSort("createdAt");
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
-
expect(result.current.sortStates).toEqual([
|
|
242
|
+
expect(result.current.control.sortStates).toEqual([
|
|
243
243
|
{ field: "name", direction: "Asc" },
|
|
244
244
|
]);
|
|
245
245
|
});
|
|
@@ -248,13 +248,13 @@ describe("useCollectionVariables", () => {
|
|
|
248
248
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
249
249
|
|
|
250
250
|
act(() => {
|
|
251
|
-
result.current.setSort("createdAt", "Desc");
|
|
251
|
+
result.current.control.setSort("createdAt", "Desc");
|
|
252
252
|
});
|
|
253
253
|
act(() => {
|
|
254
|
-
result.current.clearSort();
|
|
254
|
+
result.current.control.clearSort();
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
-
expect(result.current.sortStates).toEqual([]);
|
|
257
|
+
expect(result.current.control.sortStates).toEqual([]);
|
|
258
258
|
expect(result.current.variables.order).toBeUndefined();
|
|
259
259
|
});
|
|
260
260
|
});
|
|
@@ -267,11 +267,11 @@ describe("useCollectionVariables", () => {
|
|
|
267
267
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
268
268
|
|
|
269
269
|
act(() => {
|
|
270
|
-
result.current.nextPage("cursor1");
|
|
270
|
+
result.current.control.nextPage("cursor1");
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
-
expect(result.current.cursor).toBe("cursor1");
|
|
274
|
-
expect(result.current.paginationDirection).toBe("forward");
|
|
273
|
+
expect(result.current.control.cursor).toBe("cursor1");
|
|
274
|
+
expect(result.current.control.paginationDirection).toBe("forward");
|
|
275
275
|
expect(result.current.variables.pagination.after).toBe("cursor1");
|
|
276
276
|
expect(result.current.variables.pagination.first).toBe(20);
|
|
277
277
|
expect(result.current.variables.pagination.last).toBeUndefined();
|
|
@@ -282,11 +282,11 @@ describe("useCollectionVariables", () => {
|
|
|
282
282
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
283
283
|
|
|
284
284
|
act(() => {
|
|
285
|
-
result.current.prevPage("cursor1");
|
|
285
|
+
result.current.control.prevPage("cursor1");
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
-
expect(result.current.cursor).toBe("cursor1");
|
|
289
|
-
expect(result.current.paginationDirection).toBe("backward");
|
|
288
|
+
expect(result.current.control.cursor).toBe("cursor1");
|
|
289
|
+
expect(result.current.control.paginationDirection).toBe("backward");
|
|
290
290
|
expect(result.current.variables.pagination.before).toBe("cursor1");
|
|
291
291
|
expect(result.current.variables.pagination.last).toBe(20);
|
|
292
292
|
expect(result.current.variables.pagination.first).toBeUndefined();
|
|
@@ -297,14 +297,14 @@ describe("useCollectionVariables", () => {
|
|
|
297
297
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
298
298
|
|
|
299
299
|
act(() => {
|
|
300
|
-
result.current.prevPage("cursorB");
|
|
300
|
+
result.current.control.prevPage("cursorB");
|
|
301
301
|
});
|
|
302
|
-
expect(result.current.paginationDirection).toBe("backward");
|
|
302
|
+
expect(result.current.control.paginationDirection).toBe("backward");
|
|
303
303
|
|
|
304
304
|
act(() => {
|
|
305
|
-
result.current.nextPage("cursorA");
|
|
305
|
+
result.current.control.nextPage("cursorA");
|
|
306
306
|
});
|
|
307
|
-
expect(result.current.paginationDirection).toBe("forward");
|
|
307
|
+
expect(result.current.control.paginationDirection).toBe("forward");
|
|
308
308
|
expect(result.current.variables.pagination.after).toBe("cursorA");
|
|
309
309
|
expect(result.current.variables.pagination.first).toBe(20);
|
|
310
310
|
});
|
|
@@ -313,33 +313,33 @@ describe("useCollectionVariables", () => {
|
|
|
313
313
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
314
314
|
|
|
315
315
|
act(() => {
|
|
316
|
-
result.current.prevPage("cursor1");
|
|
316
|
+
result.current.control.prevPage("cursor1");
|
|
317
317
|
});
|
|
318
318
|
act(() => {
|
|
319
|
-
result.current.resetPage();
|
|
319
|
+
result.current.control.resetPage();
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
-
expect(result.current.cursor).toBeNull();
|
|
323
|
-
expect(result.current.paginationDirection).toBe("forward");
|
|
322
|
+
expect(result.current.control.cursor).toBeNull();
|
|
323
|
+
expect(result.current.control.paginationDirection).toBe("forward");
|
|
324
324
|
});
|
|
325
325
|
|
|
326
326
|
it("tracks hasPrevPage from currentPage and hasNextPage from setPageInfo", () => {
|
|
327
327
|
const { result } = renderHook(() => useCollectionVariables({}));
|
|
328
328
|
|
|
329
329
|
// Initially on page 1: no prev, no next
|
|
330
|
-
expect(result.current.hasPrevPage).toBe(false);
|
|
331
|
-
expect(result.current.hasNextPage).toBe(false);
|
|
330
|
+
expect(result.current.control.hasPrevPage).toBe(false);
|
|
331
|
+
expect(result.current.control.hasNextPage).toBe(false);
|
|
332
332
|
|
|
333
333
|
// After navigating to next page, hasPrevPage becomes true
|
|
334
334
|
act(() => {
|
|
335
|
-
result.current.nextPage("cursor-end");
|
|
335
|
+
result.current.control.nextPage("cursor-end");
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
-
expect(result.current.hasPrevPage).toBe(true);
|
|
338
|
+
expect(result.current.control.hasPrevPage).toBe(true);
|
|
339
339
|
|
|
340
340
|
// hasNextPage comes from setPageInfo when totalPages is null
|
|
341
341
|
act(() => {
|
|
342
|
-
result.current.setPageInfo({
|
|
342
|
+
result.current.control.setPageInfo({
|
|
343
343
|
hasNextPage: true,
|
|
344
344
|
endCursor: "end",
|
|
345
345
|
hasPreviousPage: false,
|
|
@@ -347,7 +347,7 @@ describe("useCollectionVariables", () => {
|
|
|
347
347
|
});
|
|
348
348
|
});
|
|
349
349
|
|
|
350
|
-
expect(result.current.hasNextPage).toBe(true);
|
|
350
|
+
expect(result.current.control.hasNextPage).toBe(true);
|
|
351
351
|
});
|
|
352
352
|
});
|
|
353
353
|
|
|
@@ -373,7 +373,7 @@ describe("useCollectionVariables", () => {
|
|
|
373
373
|
);
|
|
374
374
|
|
|
375
375
|
act(() => {
|
|
376
|
-
result.current.nextPage("abc123");
|
|
376
|
+
result.current.control.nextPage("abc123");
|
|
377
377
|
});
|
|
378
378
|
|
|
379
379
|
expect(result.current.variables).toEqual({
|
|
@@ -414,7 +414,6 @@ describe("useCollectionVariables", () => {
|
|
|
414
414
|
task: {
|
|
415
415
|
name: "task",
|
|
416
416
|
pluralForm: "tasks",
|
|
417
|
-
readAllowedRoles: [],
|
|
418
417
|
fields: [
|
|
419
418
|
{ name: "id", type: "uuid", required: true },
|
|
420
419
|
{ name: "title", type: "string", required: true },
|
|
@@ -450,7 +449,7 @@ describe("useCollectionVariables", () => {
|
|
|
450
449
|
}),
|
|
451
450
|
);
|
|
452
451
|
|
|
453
|
-
expect(result.current.sortStates).toEqual([
|
|
452
|
+
expect(result.current.control.sortStates).toEqual([
|
|
454
453
|
{ field: "dueDate", direction: "Desc" },
|
|
455
454
|
]);
|
|
456
455
|
});
|
|
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
|
|
2
2
|
import type { TableMetadata } from "../../generator/metadata-generator";
|
|
3
3
|
import type {
|
|
4
4
|
BuildQueryVariables,
|
|
5
|
+
CollectionControl,
|
|
5
6
|
CollectionVariables,
|
|
6
7
|
Filter,
|
|
7
8
|
FilterOperator,
|
|
@@ -298,30 +299,32 @@ export function useCollectionVariables(
|
|
|
298
299
|
// ---------------------------------------------------------------------------
|
|
299
300
|
return {
|
|
300
301
|
variables,
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
302
|
+
control: {
|
|
303
|
+
filters,
|
|
304
|
+
// Cast needed: implementation accepts FilterOperator (widest),
|
|
305
|
+
// but overload signatures narrow via OperatorForField<TFilter, F>.
|
|
306
|
+
addFilter: addFilter as CollectionControl["addFilter"],
|
|
307
|
+
setFilters,
|
|
308
|
+
removeFilter,
|
|
309
|
+
clearFilters,
|
|
310
|
+
sortStates,
|
|
311
|
+
setSort,
|
|
312
|
+
clearSort,
|
|
313
|
+
pageSize,
|
|
314
|
+
setPageSize,
|
|
315
|
+
cursor,
|
|
316
|
+
paginationDirection,
|
|
317
|
+
nextPage,
|
|
318
|
+
prevPage,
|
|
319
|
+
resetPage,
|
|
320
|
+
hasPrevPage,
|
|
321
|
+
hasNextPage,
|
|
322
|
+
setPageInfo,
|
|
323
|
+
currentPage,
|
|
324
|
+
totalPages,
|
|
325
|
+
goToFirstPage,
|
|
326
|
+
goToLastPage,
|
|
327
|
+
setTotal,
|
|
328
|
+
},
|
|
326
329
|
};
|
|
327
330
|
}
|
|
@@ -22,7 +22,6 @@ import type { TableMetadata } from "../../generator/metadata-generator";
|
|
|
22
22
|
type TestTable = {
|
|
23
23
|
readonly name: "test";
|
|
24
24
|
readonly pluralForm: "tests";
|
|
25
|
-
readonly readAllowedRoles: readonly [];
|
|
26
25
|
readonly fields: readonly [
|
|
27
26
|
{ readonly name: "id"; readonly type: "uuid"; readonly required: true },
|
|
28
27
|
{
|
|
@@ -82,9 +81,13 @@ type AssertNumberField = TestQuery extends {
|
|
|
82
81
|
: never;
|
|
83
82
|
export const assertNumberField: AssertNumberField = true;
|
|
84
83
|
|
|
85
|
-
// ✅ Enum fields produce
|
|
84
|
+
// ✅ Enum fields produce literal union types from enumValues
|
|
86
85
|
type AssertEnumField = TestQuery extends {
|
|
87
|
-
status?: {
|
|
86
|
+
status?: {
|
|
87
|
+
eq?: "active" | "inactive";
|
|
88
|
+
in?: ("active" | "inactive")[];
|
|
89
|
+
nin?: ("active" | "inactive")[];
|
|
90
|
+
};
|
|
88
91
|
}
|
|
89
92
|
? true
|
|
90
93
|
: never;
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { cn } from "../lib/utils";
|
|
11
|
-
import {
|
|
11
|
+
import { CollectionControlProvider } from "../collection/collection-provider";
|
|
12
12
|
import { Table } from "../table";
|
|
13
13
|
import type { RowAction, SortConfig, UseDataTableReturn } from "../types";
|
|
14
14
|
import {
|
|
@@ -102,7 +102,7 @@ function DataTableProviderComponent<TRow extends Record<string, unknown>>({
|
|
|
102
102
|
locale: value.locale,
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
-
const
|
|
105
|
+
const controlValue = value.control ?? null;
|
|
106
106
|
|
|
107
107
|
const inner = (
|
|
108
108
|
<DataTableContext.Provider value={dataTableValue}>
|
|
@@ -110,12 +110,12 @@ function DataTableProviderComponent<TRow extends Record<string, unknown>>({
|
|
|
110
110
|
</DataTableContext.Provider>
|
|
111
111
|
);
|
|
112
112
|
|
|
113
|
-
// Wrap with
|
|
114
|
-
if (
|
|
113
|
+
// Wrap with CollectionControlProvider when control is available
|
|
114
|
+
if (controlValue) {
|
|
115
115
|
return (
|
|
116
|
-
<
|
|
116
|
+
<CollectionControlProvider value={controlValue}>
|
|
117
117
|
{inner}
|
|
118
|
-
</
|
|
118
|
+
</CollectionControlProvider>
|
|
119
119
|
);
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useDataTableContext } from "./data-table-context";
|
|
2
|
-
import {
|
|
2
|
+
import { useCollectionControl } from "../collection/collection-provider";
|
|
3
3
|
import { getLabels } from "./i18n";
|
|
4
4
|
|
|
5
5
|
// =============================================================================
|
|
@@ -139,7 +139,7 @@ export function Pagination({ labels, pageSizeOptions }: PaginationProps = {}) {
|
|
|
139
139
|
goToLastPage,
|
|
140
140
|
pageSize,
|
|
141
141
|
setPageSize,
|
|
142
|
-
} =
|
|
142
|
+
} = useCollectionControl();
|
|
143
143
|
|
|
144
144
|
const pl = getLabels(locale).pagination;
|
|
145
145
|
const firstLabel = labels?.first ?? pl.first;
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
} from "../types";
|
|
15
15
|
import { OPERATORS_BY_FILTER_TYPE } from "../types";
|
|
16
16
|
import { useDataTableContext } from "./data-table-context";
|
|
17
|
-
import {
|
|
17
|
+
import { useCollectionControl } from "../collection/collection-provider";
|
|
18
18
|
import { getLabels } from "./i18n";
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -46,7 +46,7 @@ export function SearchFilterForm({
|
|
|
46
46
|
} = {}) {
|
|
47
47
|
const { columns, locale } = useDataTableContext();
|
|
48
48
|
const { filters, addFilter, removeFilter, clearFilters } =
|
|
49
|
-
|
|
49
|
+
useCollectionControl();
|
|
50
50
|
const sf = getLabels(locale).searchFilter;
|
|
51
51
|
const filterableColumns = columns.filter((col) => !!col.filter);
|
|
52
52
|
|
|
@@ -234,7 +234,7 @@ describe("useDataTable", () => {
|
|
|
234
234
|
// Sort state
|
|
235
235
|
// ---------------------------------------------------------------------------
|
|
236
236
|
describe("sort state", () => {
|
|
237
|
-
it("sortStates is empty when no
|
|
237
|
+
it("sortStates is empty when no control is provided", () => {
|
|
238
238
|
const { result } = renderHook(() =>
|
|
239
239
|
useDataTable<TestRow>({ columns: testColumns, data: testData }),
|
|
240
240
|
);
|
|
@@ -242,7 +242,7 @@ describe("useDataTable", () => {
|
|
|
242
242
|
expect(result.current.sortStates).toEqual([]);
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
it("onSort is undefined when no
|
|
245
|
+
it("onSort is undefined when no control is provided", () => {
|
|
246
246
|
const { result } = renderHook(() =>
|
|
247
247
|
useDataTable<TestRow>({ columns: testColumns, data: testData }),
|
|
248
248
|
);
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
*
|
|
14
14
|
* @example
|
|
15
15
|
* ```tsx
|
|
16
|
-
* const { variables,
|
|
16
|
+
* const { variables, control } = useCollectionVariables({ params: { pageSize: 20 } });
|
|
17
17
|
* const { query, order, pagination } = variables;
|
|
18
18
|
* const [result] = useQuery({
|
|
19
19
|
* query: GET_ORDERS,
|
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
* columns,
|
|
25
25
|
* data: result.data?.orders,
|
|
26
26
|
* loading: result.fetching,
|
|
27
|
-
*
|
|
27
|
+
* control,
|
|
28
28
|
* });
|
|
29
29
|
*
|
|
30
30
|
* <DataTable.Provider value={table}>
|
|
@@ -43,7 +43,7 @@ export function useDataTable<TRow extends Record<string, unknown>>(
|
|
|
43
43
|
data,
|
|
44
44
|
loading = false,
|
|
45
45
|
error = null,
|
|
46
|
-
|
|
46
|
+
control,
|
|
47
47
|
onClickRow,
|
|
48
48
|
rowActions,
|
|
49
49
|
locale = "en",
|
|
@@ -76,20 +76,15 @@ export function useDataTable<TRow extends Record<string, unknown>>(
|
|
|
76
76
|
);
|
|
77
77
|
}, [data]);
|
|
78
78
|
|
|
79
|
-
// Sync pageInfo to
|
|
79
|
+
// Sync pageInfo to control so hasPrevPage/hasNextPage are up-to-date
|
|
80
80
|
useEffect(() => {
|
|
81
81
|
if (data?.pageInfo) {
|
|
82
|
-
|
|
82
|
+
control?.setPageInfo(data.pageInfo);
|
|
83
83
|
}
|
|
84
84
|
if (data?.total != null) {
|
|
85
|
-
|
|
85
|
+
control?.setTotal(data.total);
|
|
86
86
|
}
|
|
87
|
-
}, [
|
|
88
|
-
data?.pageInfo,
|
|
89
|
-
data?.total,
|
|
90
|
-
collection?.setPageInfo,
|
|
91
|
-
collection?.setTotal,
|
|
92
|
-
]);
|
|
87
|
+
}, [data?.pageInfo, data?.total, control?.setPageInfo, control?.setTotal]);
|
|
93
88
|
|
|
94
89
|
// ---------------------------------------------------------------------------
|
|
95
90
|
// Column visibility management
|
|
@@ -131,24 +126,24 @@ export function useDataTable<TRow extends Record<string, unknown>>(
|
|
|
131
126
|
);
|
|
132
127
|
|
|
133
128
|
// ---------------------------------------------------------------------------
|
|
134
|
-
// Pagination (delegated from
|
|
129
|
+
// Pagination (delegated from control)
|
|
135
130
|
// ---------------------------------------------------------------------------
|
|
136
131
|
const nextPage = useCallback(
|
|
137
132
|
(endCursor: string) => {
|
|
138
|
-
|
|
133
|
+
control?.nextPage(endCursor);
|
|
139
134
|
},
|
|
140
|
-
[
|
|
135
|
+
[control],
|
|
141
136
|
);
|
|
142
137
|
|
|
143
138
|
const prevPage = useCallback(
|
|
144
139
|
(startCursor: string) => {
|
|
145
|
-
|
|
140
|
+
control?.prevPage(startCursor);
|
|
146
141
|
},
|
|
147
|
-
[
|
|
142
|
+
[control],
|
|
148
143
|
);
|
|
149
144
|
|
|
150
|
-
const hasPrevPage =
|
|
151
|
-
const hasNextPage =
|
|
145
|
+
const hasPrevPage = control?.hasPrevPage ?? false;
|
|
146
|
+
const hasNextPage = control?.hasNextPage ?? false;
|
|
152
147
|
|
|
153
148
|
// ---------------------------------------------------------------------------
|
|
154
149
|
// Row Operations (Optimistic Updates)
|
|
@@ -209,19 +204,19 @@ export function useDataTable<TRow extends Record<string, unknown>>(
|
|
|
209
204
|
);
|
|
210
205
|
|
|
211
206
|
// ---------------------------------------------------------------------------
|
|
212
|
-
// Sort (delegated from
|
|
207
|
+
// Sort (delegated from control)
|
|
213
208
|
// ---------------------------------------------------------------------------
|
|
214
209
|
const sortStates = useMemo<SortState[]>(() => {
|
|
215
|
-
return
|
|
216
|
-
}, [
|
|
210
|
+
return control?.sortStates ?? [];
|
|
211
|
+
}, [control?.sortStates]);
|
|
217
212
|
|
|
218
213
|
const onSort = useMemo<
|
|
219
214
|
((field: string, direction?: "Asc" | "Desc") => void) | undefined
|
|
220
215
|
>(() => {
|
|
221
|
-
if (!
|
|
216
|
+
if (!control) return undefined;
|
|
222
217
|
return (field: string, direction?: "Asc" | "Desc") =>
|
|
223
|
-
|
|
224
|
-
}, [
|
|
218
|
+
control.setSort(field, direction);
|
|
219
|
+
}, [control]);
|
|
225
220
|
|
|
226
221
|
// ---------------------------------------------------------------------------
|
|
227
222
|
// Return
|
|
@@ -254,8 +249,8 @@ export function useDataTable<TRow extends Record<string, unknown>>(
|
|
|
254
249
|
deleteRow,
|
|
255
250
|
insertRow,
|
|
256
251
|
|
|
257
|
-
//
|
|
258
|
-
|
|
252
|
+
// Control (passthrough for DataTable.Provider)
|
|
253
|
+
control,
|
|
259
254
|
|
|
260
255
|
// Row interaction (passthrough for DataTable.Provider)
|
|
261
256
|
onClickRow,
|
|
@@ -75,7 +75,6 @@ describe("inferColumns()", () => {
|
|
|
75
75
|
const metadata = {
|
|
76
76
|
name: "task",
|
|
77
77
|
pluralForm: "tasks",
|
|
78
|
-
readAllowedRoles: [],
|
|
79
78
|
fields: [
|
|
80
79
|
{ name: "id", type: "uuid", required: true },
|
|
81
80
|
{ name: "title", type: "string", required: true },
|
|
@@ -204,7 +203,6 @@ describe("inferColumns() with metadata", () => {
|
|
|
204
203
|
task: {
|
|
205
204
|
name: "task",
|
|
206
205
|
pluralForm: "tasks",
|
|
207
|
-
readAllowedRoles: [],
|
|
208
206
|
fields: [
|
|
209
207
|
{ name: "id", type: "uuid", required: true },
|
|
210
208
|
{ name: "title", type: "string", required: true },
|
|
@@ -370,7 +368,6 @@ describe("createColumnHelper()", () => {
|
|
|
370
368
|
const metadata = {
|
|
371
369
|
name: "order",
|
|
372
370
|
pluralForm: "orders",
|
|
373
|
-
readAllowedRoles: [],
|
|
374
371
|
fields: [
|
|
375
372
|
{ name: "id", type: "uuid", required: true },
|
|
376
373
|
{ name: "name", type: "string", required: true },
|
|
@@ -390,7 +387,6 @@ describe("createColumnHelper()", () => {
|
|
|
390
387
|
const metadata = {
|
|
391
388
|
name: "order",
|
|
392
389
|
pluralForm: "orders",
|
|
393
|
-
readAllowedRoles: [],
|
|
394
390
|
fields: [
|
|
395
391
|
{ name: "id", type: "uuid", required: true },
|
|
396
392
|
{ name: "name", type: "string", required: true },
|
package/src/component/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type {
|
|
|
18
18
|
ColumnDefinition,
|
|
19
19
|
UseCollectionOptions,
|
|
20
20
|
UseCollectionReturn,
|
|
21
|
+
CollectionControl,
|
|
21
22
|
UseDataTableOptions,
|
|
22
23
|
UseDataTableReturn,
|
|
23
24
|
RowAction,
|
|
@@ -44,8 +45,8 @@ export {
|
|
|
44
45
|
// Collection
|
|
45
46
|
export { useCollectionVariables } from "./collection/use-collection";
|
|
46
47
|
export {
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
CollectionControlProvider,
|
|
49
|
+
useCollectionControl,
|
|
49
50
|
} from "./collection/collection-provider";
|
|
50
51
|
|
|
51
52
|
// Table (static)
|
package/src/component/types.ts
CHANGED
|
@@ -274,6 +274,23 @@ type FieldTypeToTSType = {
|
|
|
274
274
|
enum: string;
|
|
275
275
|
};
|
|
276
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Resolves the value type for a field, narrowing enum fields to their
|
|
279
|
+
* literal union when `enumValues` is present in the metadata.
|
|
280
|
+
*
|
|
281
|
+
* - enum field with `enumValues: readonly ["OPEN", "CLOSED"]` → `"OPEN" | "CLOSED"`
|
|
282
|
+
* - enum field without `enumValues` → `string` (fallback)
|
|
283
|
+
* - all other field types → `FieldTypeToTSType[T]` (unchanged)
|
|
284
|
+
*/
|
|
285
|
+
type ResolveFieldValueType<
|
|
286
|
+
F,
|
|
287
|
+
T extends keyof FieldTypeToTSType,
|
|
288
|
+
> = T extends "enum"
|
|
289
|
+
? F extends { readonly enumValues: readonly (infer V extends string)[] }
|
|
290
|
+
? V
|
|
291
|
+
: string
|
|
292
|
+
: FieldTypeToTSType[T];
|
|
293
|
+
|
|
277
294
|
/**
|
|
278
295
|
* Resolves the value type for a filter operator.
|
|
279
296
|
* `in`/`nin` operators accept arrays, `between` accepts `{ min, max }`,
|
|
@@ -324,7 +341,7 @@ export type BuildQueryVariables<TTable extends TableMetadata> = {
|
|
|
324
341
|
}
|
|
325
342
|
? FieldTypeToFilterConfigType[T] extends infer FCT extends
|
|
326
343
|
FilterConfig["type"]
|
|
327
|
-
? FilterInputForFieldType<FCT,
|
|
344
|
+
? FilterInputForFieldType<FCT, ResolveFieldValueType<F, T>>
|
|
328
345
|
: never
|
|
329
346
|
: never;
|
|
330
347
|
};
|
|
@@ -449,40 +466,25 @@ export interface UseCollectionOptions<
|
|
|
449
466
|
}
|
|
450
467
|
|
|
451
468
|
/**
|
|
452
|
-
*
|
|
469
|
+
* Collection control interface for UI components to interact with
|
|
470
|
+
* filter, sort, and pagination state.
|
|
453
471
|
*
|
|
454
472
|
* Methods that accept a field name are typed with `TFieldName` so that
|
|
455
473
|
* auto-completion works when a concrete union is supplied.
|
|
456
474
|
*
|
|
457
475
|
* **Note:** Methods that accept `TFieldName` use *method syntax* intentionally
|
|
458
|
-
* so that `
|
|
459
|
-
* `
|
|
476
|
+
* so that `CollectionControl<"a" | "b">` remains assignable to
|
|
477
|
+
* `CollectionControl<string>` (bivariant method check).
|
|
460
478
|
* Methods that don't depend on `TFieldName` use property syntax so they
|
|
461
479
|
* can be safely destructured without triggering `unbound-method` lint rules.
|
|
462
480
|
*
|
|
463
481
|
* @typeParam TFieldName - Union of allowed field name strings (default: `string`).
|
|
464
|
-
* @typeParam
|
|
482
|
+
* @typeParam TFilter - Filter type (default: `Filter<TFieldName>`).
|
|
465
483
|
*/
|
|
466
|
-
export interface
|
|
484
|
+
export interface CollectionControl<
|
|
467
485
|
TFieldName extends string = string,
|
|
468
|
-
TVariables = CollectionVariables,
|
|
469
486
|
TFilter = Filter<TFieldName>,
|
|
470
487
|
> {
|
|
471
|
-
/**
|
|
472
|
-
* Collection variables split into explicit sub-properties
|
|
473
|
-
* for direct mapping to GraphQL query variables.
|
|
474
|
-
*
|
|
475
|
-
* @example
|
|
476
|
-
* ```tsx
|
|
477
|
-
* const { query, order, pagination } = collection.variables;
|
|
478
|
-
* const [result] = useQuery({
|
|
479
|
-
* query: GET_TASKS,
|
|
480
|
-
* variables: { ...pagination, query, order },
|
|
481
|
-
* });
|
|
482
|
-
* ```
|
|
483
|
-
*/
|
|
484
|
-
variables: TVariables;
|
|
485
|
-
|
|
486
488
|
// Filter operations
|
|
487
489
|
/** Current active filters */
|
|
488
490
|
filters: Filter[];
|
|
@@ -540,6 +542,49 @@ export interface UseCollectionReturn<
|
|
|
540
542
|
setTotal: (total: number) => void;
|
|
541
543
|
}
|
|
542
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Return type of `useCollectionVariables` hook.
|
|
547
|
+
*
|
|
548
|
+
* @typeParam TFieldName - Union of allowed field name strings (default: `string`).
|
|
549
|
+
* @typeParam TVariables - Type of `variables` property.
|
|
550
|
+
* @typeParam TFilter - Filter type (default: `Filter<TFieldName>`).
|
|
551
|
+
*/
|
|
552
|
+
export interface UseCollectionReturn<
|
|
553
|
+
TFieldName extends string = string,
|
|
554
|
+
TVariables = CollectionVariables,
|
|
555
|
+
TFilter = Filter<TFieldName>,
|
|
556
|
+
> {
|
|
557
|
+
/**
|
|
558
|
+
* Collection variables split into explicit sub-properties
|
|
559
|
+
* for direct mapping to GraphQL query variables.
|
|
560
|
+
*
|
|
561
|
+
* @example
|
|
562
|
+
* ```tsx
|
|
563
|
+
* const { variables, control } = useCollectionVariables({ params: { pageSize: 20 } });
|
|
564
|
+
* const { query, order, pagination } = variables;
|
|
565
|
+
* const [result] = useQuery({
|
|
566
|
+
* query: GET_TASKS,
|
|
567
|
+
* variables: { ...pagination, query, order },
|
|
568
|
+
* });
|
|
569
|
+
* ```
|
|
570
|
+
*/
|
|
571
|
+
variables: TVariables;
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Collection control interface for UI components.
|
|
575
|
+
*
|
|
576
|
+
* Pass this to `useDataTable({ control })` to connect filter, sort,
|
|
577
|
+
* and pagination state to the data table.
|
|
578
|
+
*
|
|
579
|
+
* @example
|
|
580
|
+
* ```tsx
|
|
581
|
+
* const { variables, control } = useCollectionVariables({ params: { pageSize: 20 } });
|
|
582
|
+
* const table = useDataTable({ columns, data: result.data?.orders, control });
|
|
583
|
+
* ```
|
|
584
|
+
*/
|
|
585
|
+
control: CollectionControl<TFieldName, TFilter>;
|
|
586
|
+
}
|
|
587
|
+
|
|
543
588
|
// =============================================================================
|
|
544
589
|
// useDataTable Types
|
|
545
590
|
// =============================================================================
|
|
@@ -556,8 +601,8 @@ export interface UseDataTableOptions<TRow extends Record<string, unknown>> {
|
|
|
556
601
|
loading?: boolean;
|
|
557
602
|
/** Error */
|
|
558
603
|
error?: Error | null;
|
|
559
|
-
/** Collection
|
|
560
|
-
|
|
604
|
+
/** Collection control for sort/filter/pagination integration */
|
|
605
|
+
control?: CollectionControl;
|
|
561
606
|
/** Handler called when a row is clicked */
|
|
562
607
|
onClickRow?: (row: TRow) => void;
|
|
563
608
|
/** Row action definitions for the actions column */
|
|
@@ -663,9 +708,9 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
|
|
|
663
708
|
/** Optimistically insert a row */
|
|
664
709
|
insertRow: (row: TRow) => { rollback: () => void };
|
|
665
710
|
|
|
666
|
-
//
|
|
667
|
-
/** Collection
|
|
668
|
-
|
|
711
|
+
// Control (passthrough for DataTable.Provider)
|
|
712
|
+
/** Collection control passed through from options */
|
|
713
|
+
control: CollectionControl | undefined;
|
|
669
714
|
|
|
670
715
|
// Row interaction (passthrough for DataTable.Provider)
|
|
671
716
|
/** Handler called when a row is clicked */
|
|
@@ -86,7 +86,6 @@ export interface TableMetadata {
|
|
|
86
86
|
readonly name: string;
|
|
87
87
|
readonly pluralForm: string;
|
|
88
88
|
readonly description?: string;
|
|
89
|
-
readonly readAllowedRoles: readonly string[];
|
|
90
89
|
readonly fields: readonly FieldMetadata[];
|
|
91
90
|
/** Relations (manyToOne, oneToOne, and oneToMany) */
|
|
92
91
|
readonly relations?: readonly RelationMetadata[];
|
|
@@ -112,7 +111,6 @@ interface ProcessedTable {
|
|
|
112
111
|
pluralForm: string;
|
|
113
112
|
originalName: string; // PascalCase name for relation lookup
|
|
114
113
|
description?: string;
|
|
115
|
-
readAllowedRoles: string[];
|
|
116
114
|
fields: FieldMetadata[];
|
|
117
115
|
relations: RelationMetadata[];
|
|
118
116
|
}
|
|
@@ -154,63 +152,6 @@ function toCamelCase(str: string): string {
|
|
|
154
152
|
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
155
153
|
}
|
|
156
154
|
|
|
157
|
-
/**
|
|
158
|
-
* Extract allowed roles from gql permission policies
|
|
159
|
-
* Only extracts roles from 'read' action policies with 'allow' permit
|
|
160
|
-
*/
|
|
161
|
-
function extractReadAllowedRoles(
|
|
162
|
-
gqlPermission?: readonly {
|
|
163
|
-
conditions: readonly unknown[];
|
|
164
|
-
actions: readonly ["all"] | readonly string[];
|
|
165
|
-
permit: "allow" | "deny";
|
|
166
|
-
description?: string;
|
|
167
|
-
}[],
|
|
168
|
-
): string[] {
|
|
169
|
-
if (!gqlPermission) return [];
|
|
170
|
-
|
|
171
|
-
const roles = new Set<string>();
|
|
172
|
-
|
|
173
|
-
for (const policy of gqlPermission) {
|
|
174
|
-
// Only process 'allow' policies that include 'read' action
|
|
175
|
-
if (policy.permit !== "allow") continue;
|
|
176
|
-
const actions = policy.actions as readonly string[];
|
|
177
|
-
if (!actions.includes("all") && !actions.includes("read")) continue;
|
|
178
|
-
|
|
179
|
-
// Extract roles from conditions
|
|
180
|
-
for (const condition of policy.conditions) {
|
|
181
|
-
if (!Array.isArray(condition) || condition.length < 3) continue;
|
|
182
|
-
|
|
183
|
-
const [left, operator, right] = condition;
|
|
184
|
-
|
|
185
|
-
// Check for pattern: ["ROLE_NAME", "in", { user: "roles" }]
|
|
186
|
-
if (
|
|
187
|
-
typeof left === "string" &&
|
|
188
|
-
operator === "in" &&
|
|
189
|
-
typeof right === "object" &&
|
|
190
|
-
right !== null &&
|
|
191
|
-
"user" in right &&
|
|
192
|
-
(right as { user: string }).user === "roles"
|
|
193
|
-
) {
|
|
194
|
-
roles.add(left);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Check for pattern: [{ user: "roles" }, "in", "ROLE_NAME"] (reversed)
|
|
198
|
-
if (
|
|
199
|
-
typeof right === "string" &&
|
|
200
|
-
operator === "in" &&
|
|
201
|
-
typeof left === "object" &&
|
|
202
|
-
left !== null &&
|
|
203
|
-
"user" in left &&
|
|
204
|
-
(left as { user: string }).user === "roles"
|
|
205
|
-
) {
|
|
206
|
-
roles.add(right);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return Array.from(roles);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
155
|
/**
|
|
215
156
|
* Parsed field type from TailorDB
|
|
216
157
|
*/
|
|
@@ -402,15 +343,11 @@ export function dataViewerMetadataGenerator(
|
|
|
402
343
|
fields.push(fieldMetadata);
|
|
403
344
|
}
|
|
404
345
|
|
|
405
|
-
// Extract read allowed roles from gql permission
|
|
406
|
-
const readAllowedRoles = extractReadAllowedRoles(type.permissions.gql);
|
|
407
|
-
|
|
408
346
|
return {
|
|
409
347
|
name: toCamelCase(type.name),
|
|
410
348
|
pluralForm: toCamelCase(type.pluralForm),
|
|
411
349
|
originalName: type.name,
|
|
412
350
|
description: type.description,
|
|
413
|
-
readAllowedRoles,
|
|
414
351
|
fields,
|
|
415
352
|
relations,
|
|
416
353
|
};
|
package/src/tests/helpers.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { vi } from "vitest";
|
|
3
|
-
import type { Column,
|
|
3
|
+
import type { Column, CollectionControl } from "../component/types";
|
|
4
4
|
import { DataTableContext } from "../component/data-table/data-table-context";
|
|
5
5
|
import type { DataTableContextValue } from "../component/data-table/data-table-context";
|
|
6
|
-
import {
|
|
6
|
+
import { CollectionControlProvider } from "../component/collection/collection-provider";
|
|
7
7
|
|
|
8
8
|
// =============================================================================
|
|
9
9
|
// Mock factory: DataTableContext
|
|
@@ -47,14 +47,9 @@ export function createMockDataTableContext<T extends Record<string, unknown>>(
|
|
|
47
47
|
// =============================================================================
|
|
48
48
|
|
|
49
49
|
export function createMockCollectionContext(
|
|
50
|
-
overrides?: Partial<
|
|
51
|
-
):
|
|
50
|
+
overrides?: Partial<CollectionControl>,
|
|
51
|
+
): CollectionControl {
|
|
52
52
|
return {
|
|
53
|
-
variables: {
|
|
54
|
-
query: undefined,
|
|
55
|
-
order: undefined,
|
|
56
|
-
pagination: { first: 20 },
|
|
57
|
-
},
|
|
58
53
|
filters: [],
|
|
59
54
|
addFilter: vi.fn(),
|
|
60
55
|
setFilters: vi.fn(),
|
|
@@ -110,7 +105,7 @@ export function createTestProviders<
|
|
|
110
105
|
columns: Column<T>[];
|
|
111
106
|
rows: T[];
|
|
112
107
|
dataTableDefaults?: Partial<DataTableContextValue<T>>;
|
|
113
|
-
collectionDefaults?: Partial<
|
|
108
|
+
collectionDefaults?: Partial<CollectionControl>;
|
|
114
109
|
}) {
|
|
115
110
|
return function TestProviders({
|
|
116
111
|
children,
|
|
@@ -119,10 +114,10 @@ export function createTestProviders<
|
|
|
119
114
|
}: {
|
|
120
115
|
children: ReactNode;
|
|
121
116
|
dataTable?: Partial<DataTableContextValue<T>>;
|
|
122
|
-
collection?: Partial<
|
|
117
|
+
collection?: Partial<CollectionControl>;
|
|
123
118
|
}) {
|
|
124
119
|
return (
|
|
125
|
-
<
|
|
120
|
+
<CollectionControlProvider
|
|
126
121
|
value={createMockCollectionContext({
|
|
127
122
|
...defaults.collectionDefaults,
|
|
128
123
|
...collection,
|
|
@@ -136,7 +131,7 @@ export function createTestProviders<
|
|
|
136
131
|
>
|
|
137
132
|
{children}
|
|
138
133
|
</DataTableContext.Provider>
|
|
139
|
-
</
|
|
134
|
+
</CollectionControlProvider>
|
|
140
135
|
);
|
|
141
136
|
};
|
|
142
137
|
}
|