rastack 0.0.26 → 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.
- package/CHANGELOG.md +2 -0
- package/components/auto-form/AutoForm.tsx +254 -0
- package/components/auto-form/fields.ts +108 -0
- package/components/auto-form/use-auto-form.ts +89 -0
- package/components/data-table/DataTable.tsx +267 -0
- package/components/data-table/columns.ts +117 -0
- package/components/data-table/use-data-table.ts +141 -0
- package/components/index.ts +77 -0
- package/components/primitives/controls.tsx +291 -0
- package/components/primitives/field.tsx +117 -0
- package/components/primitives/index.ts +11 -0
- package/components/primitives/misc.tsx +173 -0
- package/components/primitives/table.tsx +147 -0
- package/components/style.ts +195 -0
- package/components/types.ts +98 -0
- package/components/util.ts +14 -0
- package/hooks/form/form.ts +10 -4
- package/hooks/form/interfaces.ts +40 -1
- package/hooks/form/structure.ts +83 -26
- package/package.json +1 -1
- package/test/components.spec.ts +386 -0
|
@@ -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
|
+
});
|