rastack 0.0.58 → 0.0.60
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/CHANGELOG.md +16 -0
- package/README.md +190 -0
- package/dist/deploy/ci.js +2 -2
- package/dist/deploy/io.js +2 -2
- package/dist/dev/harness.d.ts +1 -1
- package/dist/dev/harness.js +1 -2
- package/dist/external/cache.d.ts +28 -0
- package/dist/external/cache.js +35 -0
- package/dist/external/envelope.d.ts +35 -0
- package/dist/external/envelope.js +48 -0
- package/dist/external/graphql.d.ts +25 -0
- package/dist/external/graphql.js +78 -0
- package/dist/external/handler.d.ts +52 -0
- package/dist/external/handler.js +139 -0
- package/dist/external/index.d.ts +19 -0
- package/dist/external/index.js +38 -0
- package/dist/external/lambda.d.ts +49 -0
- package/dist/external/lambda.js +86 -0
- package/dist/external/rest.d.ts +17 -0
- package/dist/external/rest.js +46 -0
- package/dist/external/route.d.ts +12 -0
- package/dist/external/route.js +33 -0
- package/dist/external/service.d.ts +50 -0
- package/dist/external/service.js +59 -0
- package/dist/external/types.d.ts +107 -0
- package/dist/external/types.js +22 -0
- package/dist/external/util.d.ts +16 -0
- package/dist/external/util.js +58 -0
- package/dist/rastack-dev.js +16 -1
- package/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +6 -6
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/external.ts +10 -0
- package/hooks/query/delete.ts +12 -11
- package/hooks/query/fetch.ts +6 -8
- package/hooks/query/interfaces.ts +1 -4
- package/hooks/query/list.ts +16 -9
- package/hooks/query/update.ts +8 -6
- package/package.json +24 -2
- package/scripts/copy-templates.js +1 -1
- package/scripts/copy-wasm.js +1 -1
- package/scripts/dev-install.mjs +63 -0
- package/src/deploy/ci.ts +2 -2
- package/src/deploy/io.ts +2 -2
- package/src/dev/harness.ts +1 -2
- package/src/external/cache.ts +53 -0
- package/src/external/envelope.ts +78 -0
- package/src/external/graphql.ts +98 -0
- package/src/external/handler.ts +207 -0
- package/src/external/index.ts +25 -0
- package/src/external/lambda.ts +108 -0
- package/src/external/rest.ts +60 -0
- package/src/external/route.ts +33 -0
- package/src/external/service.ts +86 -0
- package/src/external/types.ts +121 -0
- package/src/external/util.ts +57 -0
- package/src/rastack-dev.ts +17 -1
- package/src/rastack-wasm-build.ts +6 -6
- package/jest.config.cjs +0 -12
- package/test/auth.spec.ts +0 -207
- package/test/cache.spec.ts +0 -263
- package/test/cognito.spec.ts +0 -281
- package/test/compile.spec.ts +0 -240
- package/test/components.spec.ts +0 -524
- package/test/csv-schema.spec.ts +0 -143
- package/test/deploy.spec.ts +0 -571
- package/test/dev.spec.ts +0 -414
- package/test/entities.spec.ts +0 -597
- package/test/import.spec.ts +0 -241
- package/test/plugin.spec.ts +0 -315
- package/test/runtime-manifest.spec.ts +0 -309
- package/test/schema-entities.spec.ts +0 -688
- package/test/tokens.spec.ts +0 -302
- package/test/transitions.spec.ts +0 -372
- package/test/typed-hooks.spec.ts +0 -412
- package/test/update.spec.ts +0 -152
- package/test/validate.spec.ts +0 -319
- package/tsconfig.json +0 -27
- package/wasm/package.json +0 -4
- package/wasm/rastack_wasm.d.ts +0 -100
- package/wasm/rastack_wasm.js +0 -745
- package/wasm/rastack_wasm_bg.wasm +0 -0
- package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
package/test/components.spec.ts
DELETED
|
@@ -1,524 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the pure cores behind `rastack/components` — the logic that decides
|
|
3
|
-
* how a component renders itself from a hook. The React `.tsx` render layer is
|
|
4
|
-
* thin and ships as source (like `rastack/theme`); everything that makes a
|
|
5
|
-
* decision (field datatype → control, data → columns, hook → model) lives in
|
|
6
|
-
* plain modules exercised here.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { getFieldStructure } from "../hooks/form/structure";
|
|
10
|
-
import { cssVar, tokens } from "../components/style";
|
|
11
|
-
import { humanizeKey } from "../components/util";
|
|
12
|
-
import { resolveColumns, formatCell } from "../components/data-table/columns";
|
|
13
|
-
import { buildDataTableModel } from "../components/data-table/use-data-table";
|
|
14
|
-
import {
|
|
15
|
-
inputKindForFieldType,
|
|
16
|
-
resolveFieldSpecs,
|
|
17
|
-
} from "../components/auto-form/fields";
|
|
18
|
-
import { buildAutoFormModel } from "../components/auto-form/use-auto-form";
|
|
19
|
-
import { anonymousAuth, userAuth, type ResourcePolicy } from "../auth/core";
|
|
20
|
-
import type {
|
|
21
|
-
TableQuery,
|
|
22
|
-
AutoFormLike,
|
|
23
|
-
AutoFormField,
|
|
24
|
-
} from "../components/types";
|
|
25
|
-
|
|
26
|
-
// A write JSON Schema like the `Deref{Name}` the form hook validates against.
|
|
27
|
-
const schema = {
|
|
28
|
-
properties: {
|
|
29
|
-
name: { type: "string", maxLength: 120 },
|
|
30
|
-
active: { type: "boolean" },
|
|
31
|
-
seats: { type: "integer" },
|
|
32
|
-
price: { type: "number" },
|
|
33
|
-
created: { type: "string", format: "date-time" },
|
|
34
|
-
birthday: { type: "string", format: "date" },
|
|
35
|
-
email: { type: "string", format: "email" },
|
|
36
|
-
site: { type: "string", format: "uri" },
|
|
37
|
-
status: { type: "string", enum: ["draft", "active", "archived"] },
|
|
38
|
-
airport: {
|
|
39
|
-
type: "integer",
|
|
40
|
-
"x-rastack-relation": { app: "airports", model: "airport" },
|
|
41
|
-
},
|
|
42
|
-
nickname: { type: ["string", "null"] },
|
|
43
|
-
tags: { type: "array" },
|
|
44
|
-
},
|
|
45
|
-
required: ["name", "seats"],
|
|
46
|
-
} as const;
|
|
47
|
-
|
|
48
|
-
describe("getFieldStructure — datatype derivation from the write schema", () => {
|
|
49
|
-
const fieldType = (name: string) =>
|
|
50
|
-
getFieldStructure(schema as any, name).type;
|
|
51
|
-
|
|
52
|
-
it("maps JSON Schema types/formats to control kinds", () => {
|
|
53
|
-
expect(fieldType("name")).toBe("text");
|
|
54
|
-
expect(fieldType("active")).toBe("boolean");
|
|
55
|
-
expect(fieldType("seats")).toBe("number");
|
|
56
|
-
expect(fieldType("price")).toBe("number");
|
|
57
|
-
expect(fieldType("created")).toBe("datetime");
|
|
58
|
-
expect(fieldType("birthday")).toBe("date");
|
|
59
|
-
expect(fieldType("email")).toBe("email");
|
|
60
|
-
expect(fieldType("site")).toBe("url");
|
|
61
|
-
expect(fieldType("tags")).toBe("array");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("treats an enum as a select and exposes its options", () => {
|
|
65
|
-
const s = getFieldStructure(schema as any, "status");
|
|
66
|
-
expect(s.type).toBe("select");
|
|
67
|
-
expect(s.options).toEqual([
|
|
68
|
-
{ label: "draft", value: "draft" },
|
|
69
|
-
{ label: "active", value: "active" },
|
|
70
|
-
{ label: "archived", value: "archived" },
|
|
71
|
-
]);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("keeps a foreign key as its scalar but exposes the relation target", () => {
|
|
75
|
-
const s = getFieldStructure(schema as any, "airport");
|
|
76
|
-
expect(s.type).toBe("number");
|
|
77
|
-
expect(s.relation).toEqual({ app: "airports", model: "airport" });
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("reads maxLength, nullability and required", () => {
|
|
81
|
-
expect(getFieldStructure(schema as any, "name").maxLength).toBe(120);
|
|
82
|
-
expect(getFieldStructure(schema as any, "nickname").nullable).toBe(true);
|
|
83
|
-
expect(getFieldStructure(schema as any, "name").isRequired).toBe(true);
|
|
84
|
-
expect(getFieldStructure(schema as any, "active").isRequired).toBe(false);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("falls back to a non-required text field for unknown/absent fields", () => {
|
|
88
|
-
expect(getFieldStructure(undefined, "whatever")).toEqual({
|
|
89
|
-
isRequired: false,
|
|
90
|
-
type: "text",
|
|
91
|
-
});
|
|
92
|
-
expect(getFieldStructure(schema as any, "missing")).toEqual({
|
|
93
|
-
isRequired: false,
|
|
94
|
-
type: "text",
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe("cssVar + tokens", () => {
|
|
100
|
-
it("renders a token path as a CSS variable reference", () => {
|
|
101
|
-
expect(cssVar("color.surface")).toBe("var(--color-surface)");
|
|
102
|
-
expect(cssVar("space.md")).toBe("var(--space-md)");
|
|
103
|
-
expect(cssVar("color.surface-alt")).toBe("var(--color-surface-alt)");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("includes a fallback when given", () => {
|
|
107
|
-
expect(cssVar("color.danger", "#E5484D")).toBe(
|
|
108
|
-
"var(--color-danger, #E5484D)",
|
|
109
|
-
);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("exposes semantic tokens with fallbacks baked in", () => {
|
|
113
|
-
expect(tokens.color.accent).toBe("var(--color-accent, #4f6bff)");
|
|
114
|
-
expect(tokens.radius.md).toBe("var(--radius-md, 10px)");
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
describe("humanizeKey", () => {
|
|
119
|
-
it("turns field keys into labels", () => {
|
|
120
|
-
expect(humanizeKey("first_name")).toBe("First name");
|
|
121
|
-
expect(humanizeKey("createdAt")).toBe("Created at");
|
|
122
|
-
expect(humanizeKey("code")).toBe("Code");
|
|
123
|
-
expect(humanizeKey("airport-id")).toBe("Airport id");
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe("resolveColumns", () => {
|
|
128
|
-
it("prefers explicit columns and fills defaults", () => {
|
|
129
|
-
const cols = resolveColumns<{ code: string }>({
|
|
130
|
-
columns: [
|
|
131
|
-
{ key: "code" },
|
|
132
|
-
{ key: "name", header: "Full name", align: "right" },
|
|
133
|
-
],
|
|
134
|
-
});
|
|
135
|
-
expect(cols.map((c) => c.key)).toEqual(["code", "name"]);
|
|
136
|
-
expect(cols[0].header).toBe("Code");
|
|
137
|
-
expect(cols[1].header).toBe("Full name");
|
|
138
|
-
expect(cols[1].align).toBe("right");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("uses the server header when present", () => {
|
|
142
|
-
const cols = resolveColumns({
|
|
143
|
-
header: [
|
|
144
|
-
{ key: "code", name: "Code" },
|
|
145
|
-
{ key: "name", name: "Name" },
|
|
146
|
-
],
|
|
147
|
-
});
|
|
148
|
-
expect(cols.map((c) => c.key)).toEqual(["code", "name"]);
|
|
149
|
-
expect(cols[0].header).toBe("Code");
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("infers columns from the first row when there is no header", () => {
|
|
153
|
-
const cols = resolveColumns({ rows: [{ code: "LHR", city: "London" }] });
|
|
154
|
-
expect(cols.map((c) => c.key)).toEqual(["code", "city"]);
|
|
155
|
-
expect(cols[1].header).toBe("City");
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("honours the sortable default only for inferred columns", () => {
|
|
159
|
-
const inferred = resolveColumns({
|
|
160
|
-
rows: [{ a: 1 }],
|
|
161
|
-
sortableByDefault: true,
|
|
162
|
-
});
|
|
163
|
-
expect(inferred[0].sortable).toBe(true);
|
|
164
|
-
const explicit = resolveColumns({
|
|
165
|
-
columns: [{ key: "a", sortable: false }],
|
|
166
|
-
sortableByDefault: true,
|
|
167
|
-
});
|
|
168
|
-
expect(explicit[0].sortable).toBe(false);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("returns nothing when there is no source of columns", () => {
|
|
172
|
-
expect(resolveColumns({ rows: [] })).toEqual([]);
|
|
173
|
-
expect(resolveColumns({})).toEqual([]);
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
describe("formatCell", () => {
|
|
178
|
-
it("formats values conservatively", () => {
|
|
179
|
-
expect(formatCell(null)).toBe("—");
|
|
180
|
-
expect(formatCell(undefined)).toBe("—");
|
|
181
|
-
expect(formatCell(true)).toBe("Yes");
|
|
182
|
-
expect(formatCell(false)).toBe("No");
|
|
183
|
-
expect(formatCell(42)).toBe("42");
|
|
184
|
-
expect(formatCell("LHR")).toBe("LHR");
|
|
185
|
-
expect(formatCell({ a: 1 })).toBe('{"a":1}');
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
describe("buildDataTableModel — normalizing the two hook families", () => {
|
|
190
|
-
it("drives a rich REST list hook (search + sort + pagination)", () => {
|
|
191
|
-
const sortField = jest.fn();
|
|
192
|
-
const setSearchText = jest.fn();
|
|
193
|
-
const query = {
|
|
194
|
-
data: [{ code: "LHR" }, { code: "JFK" }],
|
|
195
|
-
isLoading: false,
|
|
196
|
-
header: [{ key: "code", name: "Code" }],
|
|
197
|
-
searching: { searchText: "lo", setSearchText },
|
|
198
|
-
sorting: { sortField, getSortingDirection: () => "asc" },
|
|
199
|
-
pagination: {
|
|
200
|
-
currentPage: 2,
|
|
201
|
-
pageSize: 20,
|
|
202
|
-
pageCount: 5,
|
|
203
|
-
itemCount: 100,
|
|
204
|
-
nextPage: jest.fn(),
|
|
205
|
-
previousPage: jest.fn(),
|
|
206
|
-
setPageSize: jest.fn(),
|
|
207
|
-
},
|
|
208
|
-
} as unknown as TableQuery<{ code: string }>;
|
|
209
|
-
|
|
210
|
-
const model = buildDataTableModel(query);
|
|
211
|
-
expect(model.columns.map((c) => c.key)).toEqual(["code"]);
|
|
212
|
-
expect(model.columns[0].sortable).toBe(true);
|
|
213
|
-
expect(model.rows).toHaveLength(2);
|
|
214
|
-
expect(model.isLoading).toBe(false);
|
|
215
|
-
expect(model.isEmpty).toBe(false);
|
|
216
|
-
|
|
217
|
-
expect(model.search?.value).toBe("lo");
|
|
218
|
-
model.search?.setValue("");
|
|
219
|
-
expect(setSearchText).toHaveBeenCalledWith(undefined); // empty box clears the search
|
|
220
|
-
|
|
221
|
-
expect(model.sort?.directionOf("code")).toBe("asc");
|
|
222
|
-
model.sort?.by("code");
|
|
223
|
-
expect(sortField).toHaveBeenCalledWith("code");
|
|
224
|
-
|
|
225
|
-
expect(model.pagination).toMatchObject({
|
|
226
|
-
current: 2,
|
|
227
|
-
count: 5,
|
|
228
|
-
canPrev: true,
|
|
229
|
-
canNext: true,
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("drives a minimal sync list hook (no search/sort/pagination)", () => {
|
|
234
|
-
const query = {
|
|
235
|
-
data: [{ id: "1", title: "A" }],
|
|
236
|
-
isLoading: false,
|
|
237
|
-
isReady: true,
|
|
238
|
-
} as unknown as TableQuery<{ id: string; title: string }>;
|
|
239
|
-
|
|
240
|
-
const model = buildDataTableModel(query);
|
|
241
|
-
expect(model.columns.map((c) => c.key)).toEqual(["id", "title"]);
|
|
242
|
-
expect(model.columns[0].sortable).toBe(false); // no sorting capability → not sortable
|
|
243
|
-
expect(model.search).toBeUndefined();
|
|
244
|
-
expect(model.sort).toBeUndefined();
|
|
245
|
-
expect(model.pagination).toBeUndefined();
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("reports loading and empty states", () => {
|
|
249
|
-
const loading = buildDataTableModel({
|
|
250
|
-
data: undefined,
|
|
251
|
-
isLoading: true,
|
|
252
|
-
} as TableQuery);
|
|
253
|
-
expect(loading.isLoading).toBe(true);
|
|
254
|
-
expect(loading.isEmpty).toBe(false);
|
|
255
|
-
|
|
256
|
-
const empty = buildDataTableModel({
|
|
257
|
-
data: [],
|
|
258
|
-
isLoading: false,
|
|
259
|
-
} as TableQuery);
|
|
260
|
-
expect(empty.isEmpty).toBe(true);
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
describe("inputKindForFieldType", () => {
|
|
265
|
-
it("maps every datatype to a control", () => {
|
|
266
|
-
expect(inputKindForFieldType("boolean")).toBe("checkbox");
|
|
267
|
-
expect(inputKindForFieldType("number")).toBe("number");
|
|
268
|
-
expect(inputKindForFieldType("date")).toBe("date");
|
|
269
|
-
expect(inputKindForFieldType("datetime")).toBe("datetime-local");
|
|
270
|
-
expect(inputKindForFieldType("select")).toBe("select");
|
|
271
|
-
expect(inputKindForFieldType("email")).toBe("email");
|
|
272
|
-
expect(inputKindForFieldType("url")).toBe("url");
|
|
273
|
-
expect(inputKindForFieldType("array")).toBe("textarea");
|
|
274
|
-
expect(inputKindForFieldType("text")).toBe("text");
|
|
275
|
-
});
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
describe("resolveFieldSpecs", () => {
|
|
279
|
-
const names = ["code", "name", "active"];
|
|
280
|
-
|
|
281
|
-
it("defaults to the value keys in order", () => {
|
|
282
|
-
expect(resolveFieldSpecs(names).map((s) => s.name)).toEqual([
|
|
283
|
-
"code",
|
|
284
|
-
"name",
|
|
285
|
-
"active",
|
|
286
|
-
]);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("honours an explicit ordered field list with config", () => {
|
|
290
|
-
const specs = resolveFieldSpecs(names, {
|
|
291
|
-
fields: ["name", { name: "code", label: "Airport code" }],
|
|
292
|
-
});
|
|
293
|
-
expect(specs.map((s) => s.name)).toEqual(["name", "code"]);
|
|
294
|
-
expect(specs[1].label).toBe("Airport code");
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("applies excludes", () => {
|
|
298
|
-
expect(
|
|
299
|
-
resolveFieldSpecs(names, { exclude: ["active"] }).map((s) => s.name),
|
|
300
|
-
).toEqual(["code", "name"]);
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
describe("buildAutoFormModel — wiring a form hook into field descriptors", () => {
|
|
305
|
-
// Minimal stand-in for a `useForm()` result.
|
|
306
|
-
const makeField = (over: Partial<AutoFormField>): AutoFormField =>
|
|
307
|
-
({
|
|
308
|
-
value: "",
|
|
309
|
-
error: "",
|
|
310
|
-
type: "text",
|
|
311
|
-
isChecked: false,
|
|
312
|
-
isError: false,
|
|
313
|
-
isDisabled: false,
|
|
314
|
-
isRequired: false,
|
|
315
|
-
setValue: jest.fn(),
|
|
316
|
-
setTouched: jest.fn(),
|
|
317
|
-
...over,
|
|
318
|
-
}) as AutoFormField;
|
|
319
|
-
|
|
320
|
-
const fieldsByName: Record<string, AutoFormField> = {
|
|
321
|
-
code: makeField({
|
|
322
|
-
type: "text",
|
|
323
|
-
isRequired: true,
|
|
324
|
-
value: "LHR",
|
|
325
|
-
maxLength: 3,
|
|
326
|
-
}),
|
|
327
|
-
active: makeField({ type: "boolean", value: true }),
|
|
328
|
-
status: makeField({
|
|
329
|
-
type: "select",
|
|
330
|
-
options: [{ label: "Draft", value: "draft" }],
|
|
331
|
-
}),
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
const onSubmit = jest.fn();
|
|
335
|
-
const resetForm = jest.fn();
|
|
336
|
-
const form: AutoFormLike = {
|
|
337
|
-
formValues: { code: "LHR", active: true, status: "draft" },
|
|
338
|
-
getFormField: (name: string) => fieldsByName[name],
|
|
339
|
-
onSubmit,
|
|
340
|
-
resetForm,
|
|
341
|
-
isSubmitDisabled: false,
|
|
342
|
-
isSubmitting: false,
|
|
343
|
-
errors: {},
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
it("produces one descriptor per field with the control picked from the datatype", () => {
|
|
347
|
-
const model = buildAutoFormModel(form);
|
|
348
|
-
expect(model.fields.map((f) => f.name)).toEqual([
|
|
349
|
-
"code",
|
|
350
|
-
"active",
|
|
351
|
-
"status",
|
|
352
|
-
]);
|
|
353
|
-
expect(model.fields.map((f) => f.input)).toEqual([
|
|
354
|
-
"text",
|
|
355
|
-
"checkbox",
|
|
356
|
-
"select",
|
|
357
|
-
]);
|
|
358
|
-
expect(model.fields[0].label).toBe("Code");
|
|
359
|
-
expect(model.fields[0].id).toBe("af-code");
|
|
360
|
-
expect(model.fields[2].options).toEqual([
|
|
361
|
-
{ label: "Draft", value: "draft" },
|
|
362
|
-
]);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("computes submit-ability and wires submit/reset", () => {
|
|
366
|
-
const model = buildAutoFormModel(form);
|
|
367
|
-
expect(model.canSubmit).toBe(true);
|
|
368
|
-
model.submit();
|
|
369
|
-
model.reset();
|
|
370
|
-
expect(onSubmit).toHaveBeenCalled();
|
|
371
|
-
expect(resetForm).toHaveBeenCalled();
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("blocks submission while invalid or submitting", () => {
|
|
375
|
-
expect(
|
|
376
|
-
buildAutoFormModel({ ...form, isSubmitDisabled: true }).canSubmit,
|
|
377
|
-
).toBe(false);
|
|
378
|
-
expect(buildAutoFormModel({ ...form, isSubmitting: true }).canSubmit).toBe(
|
|
379
|
-
false,
|
|
380
|
-
);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it("respects field selection options", () => {
|
|
384
|
-
const model = buildAutoFormModel(form, { exclude: ["status"] });
|
|
385
|
-
expect(model.fields.map((f) => f.name)).toEqual(["code", "active"]);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it("feature-detects state-machine transitions off a useForm(Entity) result", () => {
|
|
389
|
-
// A form without transitions renders no actions.
|
|
390
|
-
expect(buildAutoFormModel(form).transitions).toEqual([]);
|
|
391
|
-
|
|
392
|
-
const transition = jest.fn();
|
|
393
|
-
const machineForm: AutoFormLike = {
|
|
394
|
-
...form,
|
|
395
|
-
transitions: [
|
|
396
|
-
{ name: "board", label: "Board", to: "boarding" },
|
|
397
|
-
{ name: "cancel", to: "cancelled" }, // no label — humanised
|
|
398
|
-
],
|
|
399
|
-
transition,
|
|
400
|
-
};
|
|
401
|
-
const model = buildAutoFormModel(machineForm);
|
|
402
|
-
expect(model.transitions.map((t) => [t.name, t.label, t.to])).toEqual([
|
|
403
|
-
["board", "Board", "boarding"],
|
|
404
|
-
["cancel", "Cancel", "cancelled"],
|
|
405
|
-
]);
|
|
406
|
-
model.transitions[0].fire();
|
|
407
|
-
expect(transition).toHaveBeenCalledWith("board");
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
// Auth handling built into the components: the models mirror the server's
|
|
412
|
-
// `permission` gate + row-level security (rastack/auth) so the UI never
|
|
413
|
-
// offers a write the API would reject.
|
|
414
|
-
describe("component models — authorization gating", () => {
|
|
415
|
-
const rls: ResourcePolicy = {
|
|
416
|
-
permission: "authenticated",
|
|
417
|
-
access: { ownerField: "owner", scope: "owner", adminGroups: ["staff"] },
|
|
418
|
-
};
|
|
419
|
-
const readOnly: ResourcePolicy = { permission: "authenticatedOrReadOnly" };
|
|
420
|
-
|
|
421
|
-
const makeField = (): AutoFormField =>
|
|
422
|
-
({
|
|
423
|
-
value: "",
|
|
424
|
-
error: "",
|
|
425
|
-
type: "text",
|
|
426
|
-
isChecked: false,
|
|
427
|
-
isError: false,
|
|
428
|
-
isDisabled: false,
|
|
429
|
-
isRequired: false,
|
|
430
|
-
setValue: jest.fn(),
|
|
431
|
-
setTouched: jest.fn(),
|
|
432
|
-
}) as unknown as AutoFormField;
|
|
433
|
-
|
|
434
|
-
const form: AutoFormLike = {
|
|
435
|
-
formValues: { owner: "u1", notes: "hi" },
|
|
436
|
-
getFormField: () => makeField(),
|
|
437
|
-
onSubmit: jest.fn(),
|
|
438
|
-
resetForm: jest.fn(),
|
|
439
|
-
isSubmitDisabled: false,
|
|
440
|
-
isSubmitting: false,
|
|
441
|
-
errors: {},
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
it("auto-form: anonymous writes are blocked by the permission gate", () => {
|
|
445
|
-
const model = buildAutoFormModel(form, {
|
|
446
|
-
auth: anonymousAuth(),
|
|
447
|
-
policy: readOnly,
|
|
448
|
-
action: "update",
|
|
449
|
-
});
|
|
450
|
-
expect(model.canWrite).toBe(false);
|
|
451
|
-
expect(model.canSubmit).toBe(false);
|
|
452
|
-
|
|
453
|
-
const asUser = buildAutoFormModel(form, {
|
|
454
|
-
auth: userAuth("u1"),
|
|
455
|
-
policy: readOnly,
|
|
456
|
-
});
|
|
457
|
-
expect(asUser.canWrite).toBe(true);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it("auto-form: no identity wired up ⇒ ungated (server stays the enforcer)", () => {
|
|
461
|
-
const model = buildAutoFormModel(form, { policy: rls });
|
|
462
|
-
expect(model.canWrite).toBe(true);
|
|
463
|
-
expect(model.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
it("auto-form: hides the server-stamped owner field, except for RLS bypassers", () => {
|
|
467
|
-
const model = buildAutoFormModel(form, {
|
|
468
|
-
auth: userAuth("u1"),
|
|
469
|
-
policy: rls,
|
|
470
|
-
});
|
|
471
|
-
expect(model.fields.map((f) => f.name)).toEqual(["notes"]);
|
|
472
|
-
|
|
473
|
-
const staff = buildAutoFormModel(form, {
|
|
474
|
-
auth: userAuth("u1", ["staff"]),
|
|
475
|
-
policy: rls,
|
|
476
|
-
});
|
|
477
|
-
expect(staff.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
478
|
-
|
|
479
|
-
// An explicit field list is the author's call — never overridden.
|
|
480
|
-
const explicit = buildAutoFormModel(form, {
|
|
481
|
-
auth: userAuth("u1"),
|
|
482
|
-
policy: rls,
|
|
483
|
-
fields: ["owner", "notes"],
|
|
484
|
-
});
|
|
485
|
-
expect(explicit.fields.map((f) => f.name)).toEqual(["owner", "notes"]);
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it("data-table: canRead / can / canActOnRow mirror the server's rules", () => {
|
|
489
|
-
const query = {
|
|
490
|
-
data: [
|
|
491
|
-
{ id: 1, owner: "u1" },
|
|
492
|
-
{ id: 2, owner: "u2" },
|
|
493
|
-
],
|
|
494
|
-
isLoading: false,
|
|
495
|
-
} as TableQuery<{ id: number; owner: string }>;
|
|
496
|
-
|
|
497
|
-
const anon = buildDataTableModel(query, {
|
|
498
|
-
auth: anonymousAuth(),
|
|
499
|
-
policy: rls,
|
|
500
|
-
});
|
|
501
|
-
expect(anon.canRead).toBe(false);
|
|
502
|
-
|
|
503
|
-
const mine = buildDataTableModel(query, {
|
|
504
|
-
auth: userAuth("u1"),
|
|
505
|
-
policy: rls,
|
|
506
|
-
});
|
|
507
|
-
expect(mine.canRead).toBe(true);
|
|
508
|
-
expect(mine.can("delete")).toBe(true);
|
|
509
|
-
expect(mine.canActOnRow("delete", { id: 1, owner: "u1" })).toBe(true);
|
|
510
|
-
expect(mine.canActOnRow("delete", { id: 2, owner: "u2" })).toBe(false);
|
|
511
|
-
|
|
512
|
-
const anonPublicRead = buildDataTableModel(query, {
|
|
513
|
-
auth: anonymousAuth(),
|
|
514
|
-
policy: readOnly,
|
|
515
|
-
});
|
|
516
|
-
expect(anonPublicRead.canRead).toBe(true);
|
|
517
|
-
expect(anonPublicRead.can("create")).toBe(false);
|
|
518
|
-
|
|
519
|
-
// No identity wired up ⇒ everything stays enabled.
|
|
520
|
-
const ungated = buildDataTableModel(query, { policy: rls });
|
|
521
|
-
expect(ungated.canRead).toBe(true);
|
|
522
|
-
expect(ungated.can("delete")).toBe(true);
|
|
523
|
-
});
|
|
524
|
-
});
|
package/test/csv-schema.spec.ts
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import os from "os";
|
|
3
|
-
import fs from "fs";
|
|
4
|
-
import { parseCsv, inferSchema, discoverCsvFiles } from "../src/csv-schema";
|
|
5
|
-
|
|
6
|
-
const REPO_ROOT = path.join(__dirname, "..");
|
|
7
|
-
|
|
8
|
-
function tmpCsv(content: string): string {
|
|
9
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rastack-test-"));
|
|
10
|
-
const file = path.join(dir, "test.csv");
|
|
11
|
-
fs.writeFileSync(file, content);
|
|
12
|
-
return file;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
describe("parseCsv", () => {
|
|
16
|
-
it("parses headers and rows", () => {
|
|
17
|
-
const { headers, rows } = parseCsv("id,name,age\n1,Alice,30\n2,Bob,25");
|
|
18
|
-
expect(headers).toEqual(["id", "name", "age"]);
|
|
19
|
-
expect(rows).toHaveLength(2);
|
|
20
|
-
expect(rows[0]).toEqual(["1", "Alice", "30"]);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("handles quoted fields with commas", () => {
|
|
24
|
-
const { headers, rows } = parseCsv(`name,bio\nAlice,"Engineer, Pilot"\nBob,Writer`);
|
|
25
|
-
expect(rows[0][1]).toBe("Engineer, Pilot");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("returns empty for blank input", () => {
|
|
29
|
-
const { headers, rows } = parseCsv("");
|
|
30
|
-
expect(headers).toHaveLength(0);
|
|
31
|
-
expect(rows).toHaveLength(0);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe("inferSchema — type inference", () => {
|
|
36
|
-
it("infers integer columns", () => {
|
|
37
|
-
const csv = tmpCsv("id,score\n1,100\n2,200\n3,300");
|
|
38
|
-
const { schema } = inferSchema(csv, REPO_ROOT);
|
|
39
|
-
const id = schema.columns.find((c) => c.name === "id")!;
|
|
40
|
-
const score = schema.columns.find((c) => c.name === "score")!;
|
|
41
|
-
expect(id.type).toBe("integer");
|
|
42
|
-
expect(score.type).toBe("integer");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("infers float columns", () => {
|
|
46
|
-
const csv = tmpCsv("price\n1.99\n3.50\n0.01");
|
|
47
|
-
const { schema } = inferSchema(csv, REPO_ROOT);
|
|
48
|
-
expect(schema.columns[0].type).toBe("float");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("infers boolean columns", () => {
|
|
52
|
-
const csv = tmpCsv("active\ntrue\nfalse\ntrue");
|
|
53
|
-
const { schema } = inferSchema(csv, REPO_ROOT);
|
|
54
|
-
expect(schema.columns[0].type).toBe("boolean");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("infers date columns", () => {
|
|
58
|
-
const csv = tmpCsv("dob\n1990-01-01\n1985-06-15");
|
|
59
|
-
const { schema } = inferSchema(csv, REPO_ROOT);
|
|
60
|
-
// dob matches PII but type should still be date
|
|
61
|
-
expect(schema.columns[0].type).toBe("date");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("infers datetime columns", () => {
|
|
65
|
-
const csv = tmpCsv("created_at\n2024-01-01T10:00:00Z\n2024-02-01T12:30:00Z");
|
|
66
|
-
const { schema } = inferSchema(csv, REPO_ROOT);
|
|
67
|
-
expect(schema.columns[0].type).toBe("datetime");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("marks id column as primaryKey", () => {
|
|
71
|
-
const csv = tmpCsv("id,name\n1,Alice");
|
|
72
|
-
const { schema } = inferSchema(csv, REPO_ROOT);
|
|
73
|
-
const id = schema.columns.find((c) => c.name === "id")!;
|
|
74
|
-
expect(id.primaryKey).toBe(true);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("detects nullable columns", () => {
|
|
78
|
-
const csv = tmpCsv("id,note\n1,hello\n2,\n3,world");
|
|
79
|
-
const { schema } = inferSchema(csv, REPO_ROOT);
|
|
80
|
-
const note = schema.columns.find((c) => c.name === "note")!;
|
|
81
|
-
expect(note.nullable).toBe(true);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe("inferSchema — PII detection", () => {
|
|
86
|
-
it("flags passport_no as PII", () => {
|
|
87
|
-
const csv = tmpCsv("id,passport_no\n1,AB123456\n2,CD789012");
|
|
88
|
-
const { schema, piiViolations } = inferSchema(csv, REPO_ROOT);
|
|
89
|
-
const col = schema.columns.find((c) => c.name === "passport_no")!;
|
|
90
|
-
expect(col.pii).toBe(true);
|
|
91
|
-
expect(piiViolations).toHaveLength(1);
|
|
92
|
-
expect(piiViolations[0].column).toBe("passport_no");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("flags email as PII", () => {
|
|
96
|
-
const csv = tmpCsv("id,email\n1,alice@example.com");
|
|
97
|
-
const { piiViolations } = inferSchema(csv, REPO_ROOT);
|
|
98
|
-
expect(piiViolations.some((v) => v.column === "email")).toBe(true);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("flags dob as PII", () => {
|
|
102
|
-
const csv = tmpCsv("id,dob\n1,1990-01-01");
|
|
103
|
-
const { piiViolations } = inferSchema(csv, REPO_ROOT);
|
|
104
|
-
expect(piiViolations.some((v) => v.column === "dob")).toBe(true);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("does not flag non-PII columns", () => {
|
|
108
|
-
const csv = tmpCsv("id,flight_code,origin,destination\n1,SA101,JNB,LHR");
|
|
109
|
-
const { piiViolations } = inferSchema(csv, REPO_ROOT);
|
|
110
|
-
expect(piiViolations).toHaveLength(0);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("generates a stable fingerprint", () => {
|
|
114
|
-
const csv = tmpCsv("id,name\n1,Alice\n2,Bob");
|
|
115
|
-
const { schema: a } = inferSchema(csv, REPO_ROOT);
|
|
116
|
-
const { schema: b } = inferSchema(csv, REPO_ROOT);
|
|
117
|
-
expect(a.fingerprint).toBe(b.fingerprint);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("generates different fingerprints for different schemas", () => {
|
|
121
|
-
const csvA = tmpCsv("id,name\n1,Alice");
|
|
122
|
-
const csvB = tmpCsv("id,name,email\n1,Alice,a@b.com");
|
|
123
|
-
const { schema: a } = inferSchema(csvA, REPO_ROOT);
|
|
124
|
-
const { schema: b } = inferSchema(csvB, REPO_ROOT);
|
|
125
|
-
expect(a.fingerprint).not.toBe(b.fingerprint);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("discoverCsvFiles", () => {
|
|
130
|
-
it("finds all csv files in a directory", () => {
|
|
131
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rastack-test-"));
|
|
132
|
-
fs.writeFileSync(path.join(dir, "a.csv"), "id\n1");
|
|
133
|
-
fs.writeFileSync(path.join(dir, "b.csv"), "id\n2");
|
|
134
|
-
fs.writeFileSync(path.join(dir, "readme.txt"), "not a csv");
|
|
135
|
-
const files = discoverCsvFiles(dir);
|
|
136
|
-
expect(files).toHaveLength(2);
|
|
137
|
-
expect(files.every((f) => f.endsWith(".csv"))).toBe(true);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("returns empty for missing directory", () => {
|
|
141
|
-
expect(discoverCsvFiles("/nonexistent/path")).toHaveLength(0);
|
|
142
|
-
});
|
|
143
|
-
});
|