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.
- package/CHANGELOG.md +4 -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/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +3 -3
- package/hooks/form/form.ts +10 -4
- package/hooks/form/interfaces.ts +40 -1
- package/hooks/form/structure.ts +83 -26
- package/hooks/query/delete.ts +3 -1
- package/package.json +1 -1
- package/src/rastack-wasm-build.ts +3 -3
- package/test/components.spec.ts +386 -0
package/hooks/form/structure.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
}
|
package/hooks/query/delete.ts
CHANGED
|
@@ -32,7 +32,9 @@ export function useDeleteQuery<TResult>({
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const response: AxiosResponse = await API.delete(url);
|
|
35
|
-
return
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
"
|
|
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
|
+
});
|