rastack 0.0.25 → 0.0.27

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.
@@ -1,39 +1,96 @@
1
- import { SchemaObject } from "ajv";
2
- import { FormType } from "./interfaces";
1
+ import type { SchemaObject } from "ajv";
2
+ import type { FieldOption, FormType, RelationTarget } from "./interfaces";
3
3
 
4
- interface GetFieldStructureResponse {
4
+ export interface GetFieldStructureResponse {
5
5
  isRequired: boolean;
6
6
  type: FormType;
7
+ maxLength?: number;
8
+ options?: FieldOption[];
9
+ relation?: RelationTarget;
10
+ nullable?: boolean;
11
+ format?: string;
7
12
  }
8
13
 
14
+ /**
15
+ * Read one field's UI shape out of a write JSON Schema (the dereferenced
16
+ * `Deref{Name}` the form hook validates against).
17
+ *
18
+ * The schema is the only runtime artifact that still carries a field's real
19
+ * datatype — `type`, `format` (`date-time`/`email`/`uuid`), `enum`, `maxLength`,
20
+ * `nullable`, and the `x-rastack-relation` FK target the compiler stamps on
21
+ * foreign keys. This maps that to a {@link FormType} control kind plus the extra
22
+ * metadata a form UI needs, so `<AutoForm>` (and any hand-rolled form reading
23
+ * `getFormField`) can render the correct input per datatype instead of a text
24
+ * box for everything.
25
+ */
9
26
  export function getFieldStructure<TFieldName>(
10
27
  validationSchema: SchemaObject | undefined,
11
28
  fieldName: TFieldName,
12
29
  ): GetFieldStructureResponse {
13
- let type: FormType = "text";
14
- if (validationSchema?.properties) {
15
- const schemaJson = validationSchema.properties[fieldName];
16
- if (!schemaJson) {
17
- return { isRequired: false, type };
18
- }
19
- let schemaJsonType = schemaJson.type;
20
-
21
- if (typeof schemaJsonType === "string") {
22
- schemaJsonType = [schemaJsonType];
23
- }
24
- if (schemaJsonType) {
25
- if (schemaJsonType.includes("integer")) {
26
- type = "number";
27
- }
28
- if (schemaJsonType.includes("array")) {
29
- type = "array";
30
- }
31
- }
30
+ const result: GetFieldStructureResponse = { isRequired: false, type: "text" };
31
+
32
+ // JSON Schema shapes vary across ajv typings; access untyped.
33
+ const schema = validationSchema as
34
+ { properties?: Record<string, any>; required?: unknown } | undefined;
35
+ const property: any = schema?.properties?.[fieldName as string];
36
+ if (!property) return result;
37
+
38
+ // JSON Schema `type` can be a string or an array (nullable unions like
39
+ // `["string", "null"]`); normalise to a list.
40
+ const rawType = property.type;
41
+ const typeList: string[] =
42
+ typeof rawType === "string"
43
+ ? [rawType]
44
+ : Array.isArray(rawType)
45
+ ? rawType
46
+ : [];
47
+ if (typeList.includes("null") || property.nullable === true) {
48
+ result.nullable = true;
49
+ }
50
+
51
+ const format: unknown = property.format;
52
+ if (typeof format === "string") result.format = format;
53
+
54
+ if (typeof property.maxLength === "number")
55
+ result.maxLength = property.maxLength;
56
+
57
+ const relation = property["x-rastack-relation"];
58
+ if (relation && typeof relation === "object") {
59
+ result.relation = relation as RelationTarget;
32
60
  }
33
61
 
34
- let isRequired = false;
35
- if (validationSchema?.required) {
36
- isRequired = validationSchema.required?.includes(fieldName);
62
+ const enumValues: unknown = property.enum;
63
+
64
+ // Choose the control kind. Enum wins over the raw scalar type (a constrained
65
+ // string is a picker, not a free text box); FK metadata is advisory and does
66
+ // not change the underlying scalar control.
67
+ if (Array.isArray(enumValues) && enumValues.length > 0) {
68
+ result.type = "select";
69
+ result.options = enumValues
70
+ .filter((v): v is string | number => v !== null && v !== undefined)
71
+ .map((v) => ({ label: String(v), value: v }));
72
+ } else if (typeList.includes("boolean")) {
73
+ result.type = "boolean";
74
+ } else if (typeList.includes("integer") || typeList.includes("number")) {
75
+ result.type = "number";
76
+ } else if (typeList.includes("array")) {
77
+ result.type = "array";
78
+ } else if (result.format === "date-time") {
79
+ result.type = "datetime";
80
+ } else if (result.format === "date") {
81
+ result.type = "date";
82
+ } else if (result.format === "email") {
83
+ result.type = "email";
84
+ } else if (result.format === "uri" || result.format === "url") {
85
+ result.type = "url";
86
+ } else {
87
+ result.type = "text";
37
88
  }
38
- return { isRequired, type };
89
+
90
+ const required: unknown = schema?.required;
91
+ if (Array.isArray(required)) {
92
+ result.isRequired = required.includes(fieldName);
93
+ }
94
+
95
+ return result;
39
96
  }
@@ -32,7 +32,9 @@ export function useDeleteQuery<TResult>({
32
32
  }
33
33
 
34
34
  const response: AxiosResponse = await API.delete(url);
35
- return response.data.results;
35
+ // A delete may return 204 No Content (no body) or a JSON envelope; don't
36
+ // assume `.results` exists on an empty body.
37
+ return response.data?.results ?? response.data ?? null;
36
38
  },
37
39
  {
38
40
  onSuccess: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",
@@ -4,7 +4,7 @@
4
4
  * `rastack wasm` — compile the Rust API to WebAssembly for in-browser local dev.
5
5
  *
6
6
  * Pipeline:
7
- * 1. cargo build -p rad-wasm --release --target wasm32-unknown-unknown
7
+ * 1. cargo build -p rastack-wasm --release --target wasm32-unknown-unknown
8
8
  * 2. wasm-bindgen --target web → stack/wasm/rastack_wasm.js + rastack_wasm_bg.wasm
9
9
  * 3. wasm-opt -Oz (if available) to shrink the binary
10
10
  *
@@ -56,13 +56,13 @@ function has(cmd: string): boolean {
56
56
  return spawnSync(cmd, ["--version"], { stdio: "ignore" }).status === 0;
57
57
  }
58
58
 
59
- console.log("→ cargo build -p rad-wasm --release (wasm32-unknown-unknown)");
59
+ console.log("→ cargo build -p rastack-wasm --release (wasm32-unknown-unknown)");
60
60
  run(
61
61
  "cargo",
62
62
  [
63
63
  "build",
64
64
  "-p",
65
- "rad-wasm",
65
+ "rastack-wasm",
66
66
  "--release",
67
67
  "--target",
68
68
  "wasm32-unknown-unknown",
@@ -0,0 +1,386 @@
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 type {
20
+ TableQuery,
21
+ AutoFormLike,
22
+ AutoFormField,
23
+ } from "../components/types";
24
+
25
+ // A write JSON Schema like the `Deref{Name}` the form hook validates against.
26
+ const schema = {
27
+ properties: {
28
+ name: { type: "string", maxLength: 120 },
29
+ active: { type: "boolean" },
30
+ seats: { type: "integer" },
31
+ price: { type: "number" },
32
+ created: { type: "string", format: "date-time" },
33
+ birthday: { type: "string", format: "date" },
34
+ email: { type: "string", format: "email" },
35
+ site: { type: "string", format: "uri" },
36
+ status: { type: "string", enum: ["draft", "active", "archived"] },
37
+ airport: {
38
+ type: "integer",
39
+ "x-rastack-relation": { app: "airports", model: "airport" },
40
+ },
41
+ nickname: { type: ["string", "null"] },
42
+ tags: { type: "array" },
43
+ },
44
+ required: ["name", "seats"],
45
+ } as const;
46
+
47
+ describe("getFieldStructure — datatype derivation from the write schema", () => {
48
+ const fieldType = (name: string) =>
49
+ getFieldStructure(schema as any, name).type;
50
+
51
+ it("maps JSON Schema types/formats to control kinds", () => {
52
+ expect(fieldType("name")).toBe("text");
53
+ expect(fieldType("active")).toBe("boolean");
54
+ expect(fieldType("seats")).toBe("number");
55
+ expect(fieldType("price")).toBe("number");
56
+ expect(fieldType("created")).toBe("datetime");
57
+ expect(fieldType("birthday")).toBe("date");
58
+ expect(fieldType("email")).toBe("email");
59
+ expect(fieldType("site")).toBe("url");
60
+ expect(fieldType("tags")).toBe("array");
61
+ });
62
+
63
+ it("treats an enum as a select and exposes its options", () => {
64
+ const s = getFieldStructure(schema as any, "status");
65
+ expect(s.type).toBe("select");
66
+ expect(s.options).toEqual([
67
+ { label: "draft", value: "draft" },
68
+ { label: "active", value: "active" },
69
+ { label: "archived", value: "archived" },
70
+ ]);
71
+ });
72
+
73
+ it("keeps a foreign key as its scalar but exposes the relation target", () => {
74
+ const s = getFieldStructure(schema as any, "airport");
75
+ expect(s.type).toBe("number");
76
+ expect(s.relation).toEqual({ app: "airports", model: "airport" });
77
+ });
78
+
79
+ it("reads maxLength, nullability and required", () => {
80
+ expect(getFieldStructure(schema as any, "name").maxLength).toBe(120);
81
+ expect(getFieldStructure(schema as any, "nickname").nullable).toBe(true);
82
+ expect(getFieldStructure(schema as any, "name").isRequired).toBe(true);
83
+ expect(getFieldStructure(schema as any, "active").isRequired).toBe(false);
84
+ });
85
+
86
+ it("falls back to a non-required text field for unknown/absent fields", () => {
87
+ expect(getFieldStructure(undefined, "whatever")).toEqual({
88
+ isRequired: false,
89
+ type: "text",
90
+ });
91
+ expect(getFieldStructure(schema as any, "missing")).toEqual({
92
+ isRequired: false,
93
+ type: "text",
94
+ });
95
+ });
96
+ });
97
+
98
+ describe("cssVar + tokens", () => {
99
+ it("renders a token path as a CSS variable reference", () => {
100
+ expect(cssVar("color.surface")).toBe("var(--color-surface)");
101
+ expect(cssVar("space.md")).toBe("var(--space-md)");
102
+ expect(cssVar("color.surface-alt")).toBe("var(--color-surface-alt)");
103
+ });
104
+
105
+ it("includes a fallback when given", () => {
106
+ expect(cssVar("color.danger", "#E5484D")).toBe(
107
+ "var(--color-danger, #E5484D)",
108
+ );
109
+ });
110
+
111
+ it("exposes semantic tokens with fallbacks baked in", () => {
112
+ expect(tokens.color.accent).toBe("var(--color-accent, #4f6bff)");
113
+ expect(tokens.radius.md).toBe("var(--radius-md, 10px)");
114
+ });
115
+ });
116
+
117
+ describe("humanizeKey", () => {
118
+ it("turns field keys into labels", () => {
119
+ expect(humanizeKey("first_name")).toBe("First name");
120
+ expect(humanizeKey("createdAt")).toBe("Created at");
121
+ expect(humanizeKey("code")).toBe("Code");
122
+ expect(humanizeKey("airport-id")).toBe("Airport id");
123
+ });
124
+ });
125
+
126
+ describe("resolveColumns", () => {
127
+ it("prefers explicit columns and fills defaults", () => {
128
+ const cols = resolveColumns<{ code: string }>({
129
+ columns: [
130
+ { key: "code" },
131
+ { key: "name", header: "Full name", align: "right" },
132
+ ],
133
+ });
134
+ expect(cols.map((c) => c.key)).toEqual(["code", "name"]);
135
+ expect(cols[0].header).toBe("Code");
136
+ expect(cols[1].header).toBe("Full name");
137
+ expect(cols[1].align).toBe("right");
138
+ });
139
+
140
+ it("uses the server header when present", () => {
141
+ const cols = resolveColumns({
142
+ header: [
143
+ { key: "code", name: "Code" },
144
+ { key: "name", name: "Name" },
145
+ ],
146
+ });
147
+ expect(cols.map((c) => c.key)).toEqual(["code", "name"]);
148
+ expect(cols[0].header).toBe("Code");
149
+ });
150
+
151
+ it("infers columns from the first row when there is no header", () => {
152
+ const cols = resolveColumns({ rows: [{ code: "LHR", city: "London" }] });
153
+ expect(cols.map((c) => c.key)).toEqual(["code", "city"]);
154
+ expect(cols[1].header).toBe("City");
155
+ });
156
+
157
+ it("honours the sortable default only for inferred columns", () => {
158
+ const inferred = resolveColumns({
159
+ rows: [{ a: 1 }],
160
+ sortableByDefault: true,
161
+ });
162
+ expect(inferred[0].sortable).toBe(true);
163
+ const explicit = resolveColumns({
164
+ columns: [{ key: "a", sortable: false }],
165
+ sortableByDefault: true,
166
+ });
167
+ expect(explicit[0].sortable).toBe(false);
168
+ });
169
+
170
+ it("returns nothing when there is no source of columns", () => {
171
+ expect(resolveColumns({ rows: [] })).toEqual([]);
172
+ expect(resolveColumns({})).toEqual([]);
173
+ });
174
+ });
175
+
176
+ describe("formatCell", () => {
177
+ it("formats values conservatively", () => {
178
+ expect(formatCell(null)).toBe("—");
179
+ expect(formatCell(undefined)).toBe("—");
180
+ expect(formatCell(true)).toBe("Yes");
181
+ expect(formatCell(false)).toBe("No");
182
+ expect(formatCell(42)).toBe("42");
183
+ expect(formatCell("LHR")).toBe("LHR");
184
+ expect(formatCell({ a: 1 })).toBe('{"a":1}');
185
+ });
186
+ });
187
+
188
+ describe("buildDataTableModel — normalizing the two hook families", () => {
189
+ it("drives a rich REST list hook (search + sort + pagination)", () => {
190
+ const sortField = jest.fn();
191
+ const setSearchText = jest.fn();
192
+ const query = {
193
+ data: [{ code: "LHR" }, { code: "JFK" }],
194
+ isLoading: false,
195
+ header: [{ key: "code", name: "Code" }],
196
+ searching: { searchText: "lo", setSearchText },
197
+ sorting: { sortField, getSortingDirection: () => "asc" },
198
+ pagination: {
199
+ currentPage: 2,
200
+ pageSize: 20,
201
+ pageCount: 5,
202
+ itemCount: 100,
203
+ nextPage: jest.fn(),
204
+ previousPage: jest.fn(),
205
+ setPageSize: jest.fn(),
206
+ },
207
+ } as unknown as TableQuery<{ code: string }>;
208
+
209
+ const model = buildDataTableModel(query);
210
+ expect(model.columns.map((c) => c.key)).toEqual(["code"]);
211
+ expect(model.columns[0].sortable).toBe(true);
212
+ expect(model.rows).toHaveLength(2);
213
+ expect(model.isLoading).toBe(false);
214
+ expect(model.isEmpty).toBe(false);
215
+
216
+ expect(model.search?.value).toBe("lo");
217
+ model.search?.setValue("");
218
+ expect(setSearchText).toHaveBeenCalledWith(undefined); // empty box clears the search
219
+
220
+ expect(model.sort?.directionOf("code")).toBe("asc");
221
+ model.sort?.by("code");
222
+ expect(sortField).toHaveBeenCalledWith("code");
223
+
224
+ expect(model.pagination).toMatchObject({
225
+ current: 2,
226
+ count: 5,
227
+ canPrev: true,
228
+ canNext: true,
229
+ });
230
+ });
231
+
232
+ it("drives a minimal sync list hook (no search/sort/pagination)", () => {
233
+ const query = {
234
+ data: [{ id: "1", title: "A" }],
235
+ isLoading: false,
236
+ isReady: true,
237
+ } as unknown as TableQuery<{ id: string; title: string }>;
238
+
239
+ const model = buildDataTableModel(query);
240
+ expect(model.columns.map((c) => c.key)).toEqual(["id", "title"]);
241
+ expect(model.columns[0].sortable).toBe(false); // no sorting capability → not sortable
242
+ expect(model.search).toBeUndefined();
243
+ expect(model.sort).toBeUndefined();
244
+ expect(model.pagination).toBeUndefined();
245
+ });
246
+
247
+ it("reports loading and empty states", () => {
248
+ const loading = buildDataTableModel({
249
+ data: undefined,
250
+ isLoading: true,
251
+ } as TableQuery);
252
+ expect(loading.isLoading).toBe(true);
253
+ expect(loading.isEmpty).toBe(false);
254
+
255
+ const empty = buildDataTableModel({
256
+ data: [],
257
+ isLoading: false,
258
+ } as TableQuery);
259
+ expect(empty.isEmpty).toBe(true);
260
+ });
261
+ });
262
+
263
+ describe("inputKindForFieldType", () => {
264
+ it("maps every datatype to a control", () => {
265
+ expect(inputKindForFieldType("boolean")).toBe("checkbox");
266
+ expect(inputKindForFieldType("number")).toBe("number");
267
+ expect(inputKindForFieldType("date")).toBe("date");
268
+ expect(inputKindForFieldType("datetime")).toBe("datetime-local");
269
+ expect(inputKindForFieldType("select")).toBe("select");
270
+ expect(inputKindForFieldType("email")).toBe("email");
271
+ expect(inputKindForFieldType("url")).toBe("url");
272
+ expect(inputKindForFieldType("array")).toBe("textarea");
273
+ expect(inputKindForFieldType("text")).toBe("text");
274
+ });
275
+ });
276
+
277
+ describe("resolveFieldSpecs", () => {
278
+ const names = ["code", "name", "active"];
279
+
280
+ it("defaults to the value keys in order", () => {
281
+ expect(resolveFieldSpecs(names).map((s) => s.name)).toEqual([
282
+ "code",
283
+ "name",
284
+ "active",
285
+ ]);
286
+ });
287
+
288
+ it("honours an explicit ordered field list with config", () => {
289
+ const specs = resolveFieldSpecs(names, {
290
+ fields: ["name", { name: "code", label: "Airport code" }],
291
+ });
292
+ expect(specs.map((s) => s.name)).toEqual(["name", "code"]);
293
+ expect(specs[1].label).toBe("Airport code");
294
+ });
295
+
296
+ it("applies excludes", () => {
297
+ expect(
298
+ resolveFieldSpecs(names, { exclude: ["active"] }).map((s) => s.name),
299
+ ).toEqual(["code", "name"]);
300
+ });
301
+ });
302
+
303
+ describe("buildAutoFormModel — wiring a form hook into field descriptors", () => {
304
+ // Minimal stand-in for a `useForm()` result.
305
+ const makeField = (over: Partial<AutoFormField>): AutoFormField =>
306
+ ({
307
+ value: "",
308
+ error: "",
309
+ type: "text",
310
+ isChecked: false,
311
+ isError: false,
312
+ isDisabled: false,
313
+ isRequired: false,
314
+ setValue: jest.fn(),
315
+ setTouched: jest.fn(),
316
+ ...over,
317
+ }) as AutoFormField;
318
+
319
+ const fieldsByName: Record<string, AutoFormField> = {
320
+ code: makeField({
321
+ type: "text",
322
+ isRequired: true,
323
+ value: "LHR",
324
+ maxLength: 3,
325
+ }),
326
+ active: makeField({ type: "boolean", value: true }),
327
+ status: makeField({
328
+ type: "select",
329
+ options: [{ label: "Draft", value: "draft" }],
330
+ }),
331
+ };
332
+
333
+ const onSubmit = jest.fn();
334
+ const resetForm = jest.fn();
335
+ const form: AutoFormLike = {
336
+ formValues: { code: "LHR", active: true, status: "draft" },
337
+ getFormField: (name: string) => fieldsByName[name],
338
+ onSubmit,
339
+ resetForm,
340
+ isSubmitDisabled: false,
341
+ isSubmitting: false,
342
+ errors: {},
343
+ };
344
+
345
+ it("produces one descriptor per field with the control picked from the datatype", () => {
346
+ const model = buildAutoFormModel(form);
347
+ expect(model.fields.map((f) => f.name)).toEqual([
348
+ "code",
349
+ "active",
350
+ "status",
351
+ ]);
352
+ expect(model.fields.map((f) => f.input)).toEqual([
353
+ "text",
354
+ "checkbox",
355
+ "select",
356
+ ]);
357
+ expect(model.fields[0].label).toBe("Code");
358
+ expect(model.fields[0].id).toBe("af-code");
359
+ expect(model.fields[2].options).toEqual([
360
+ { label: "Draft", value: "draft" },
361
+ ]);
362
+ });
363
+
364
+ it("computes submit-ability and wires submit/reset", () => {
365
+ const model = buildAutoFormModel(form);
366
+ expect(model.canSubmit).toBe(true);
367
+ model.submit();
368
+ model.reset();
369
+ expect(onSubmit).toHaveBeenCalled();
370
+ expect(resetForm).toHaveBeenCalled();
371
+ });
372
+
373
+ it("blocks submission while invalid or submitting", () => {
374
+ expect(
375
+ buildAutoFormModel({ ...form, isSubmitDisabled: true }).canSubmit,
376
+ ).toBe(false);
377
+ expect(buildAutoFormModel({ ...form, isSubmitting: true }).canSubmit).toBe(
378
+ false,
379
+ );
380
+ });
381
+
382
+ it("respects field selection options", () => {
383
+ const model = buildAutoFormModel(form, { exclude: ["status"] });
384
+ expect(model.fields.map((f) => f.name)).toEqual(["code", "active"]);
385
+ });
386
+ });