@cosmicdrift/kumiko-headless 0.1.0
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/package.json +37 -0
- package/src/contracts/__tests__/contracts.test.ts +123 -0
- package/src/contracts/asset.ts +48 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/locale.ts +33 -0
- package/src/contracts/primitives.ts +158 -0
- package/src/dispatcher/__tests__/contract.test.ts +160 -0
- package/src/dispatcher/index.ts +14 -0
- package/src/dispatcher/types.ts +194 -0
- package/src/form/__tests__/conditional-fields.test.ts +177 -0
- package/src/form/__tests__/form-controller.test.ts +195 -0
- package/src/form/__tests__/submit.test.ts +315 -0
- package/src/form/__tests__/validation.test.ts +124 -0
- package/src/form/form-controller.ts +333 -0
- package/src/form/index.ts +15 -0
- package/src/form/types.ts +264 -0
- package/src/form/zod-bridge.ts +75 -0
- package/src/index.ts +81 -0
- package/src/nav/__tests__/resolve.test.ts +202 -0
- package/src/nav/index.ts +8 -0
- package/src/nav/resolve.ts +77 -0
- package/src/nav/types.ts +61 -0
- package/src/store/__tests__/create-store.test.ts +139 -0
- package/src/store/__tests__/equality.test.ts +66 -0
- package/src/store/create-store.ts +44 -0
- package/src/store/equality.ts +34 -0
- package/src/store/index.ts +3 -0
- package/src/store/types.ts +27 -0
- package/src/view-model/__tests__/edit.test.ts +242 -0
- package/src/view-model/__tests__/list.test.ts +139 -0
- package/src/view-model/edit.ts +164 -0
- package/src/view-model/index.ts +19 -0
- package/src/view-model/list.ts +158 -0
- package/src/view-model/types.ts +150 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { Dispatcher, WriteResult } from "../../dispatcher";
|
|
4
|
+
import { createStore } from "../../store";
|
|
5
|
+
import { createFormController } from "../form-controller";
|
|
6
|
+
|
|
7
|
+
// Fake dispatcher scoped to this test file — same shape as contract.test.ts
|
|
8
|
+
// but with an explicit spy on write() so assertions can inspect argv.
|
|
9
|
+
function makeDispatcher(response?: WriteResult): Dispatcher & {
|
|
10
|
+
readonly writeSpy: ReturnType<typeof vi.fn>;
|
|
11
|
+
} {
|
|
12
|
+
const writeSpy = vi.fn(
|
|
13
|
+
async () => response ?? ({ isSuccess: true, data: { id: "srv-1" } } as WriteResult),
|
|
14
|
+
);
|
|
15
|
+
return {
|
|
16
|
+
writeSpy,
|
|
17
|
+
write: writeSpy as unknown as Dispatcher["write"],
|
|
18
|
+
async query<TData>() {
|
|
19
|
+
return { isSuccess: true, data: null } as unknown as { isSuccess: true; data: TData };
|
|
20
|
+
},
|
|
21
|
+
async batch() {
|
|
22
|
+
return { isSuccess: true, results: [] };
|
|
23
|
+
},
|
|
24
|
+
statusStore: createStore("online"),
|
|
25
|
+
pendingWrites: () => [],
|
|
26
|
+
pendingFiles: () => [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("createFormController — submit()", () => {
|
|
31
|
+
test("throws when called without a submit-config — no guessing the destination", async () => {
|
|
32
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
33
|
+
|
|
34
|
+
await expect(form.submit()).rejects.toThrow(/submit\(\) called without a `submit` config/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("happy path: dispatches values, returns success, rebases form", async () => {
|
|
38
|
+
const disp = makeDispatcher();
|
|
39
|
+
const form = createFormController({
|
|
40
|
+
initial: { title: "hello" },
|
|
41
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
42
|
+
});
|
|
43
|
+
form.setField("title", "world");
|
|
44
|
+
|
|
45
|
+
const result = await form.submit();
|
|
46
|
+
|
|
47
|
+
expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:create", { title: "world" });
|
|
48
|
+
expect(result.isSuccess).toBe(true);
|
|
49
|
+
if (result.isSuccess && !("validationBlocked" in result && result.validationBlocked)) {
|
|
50
|
+
expect(result.data).toEqual({ id: "srv-1" });
|
|
51
|
+
}
|
|
52
|
+
// Post-submit the form should be at its new clean baseline.
|
|
53
|
+
const snap = form.getSnapshot();
|
|
54
|
+
expect(snap.isDirty).toBe(false);
|
|
55
|
+
expect(snap.initial.title).toBe("world");
|
|
56
|
+
expect(snap.changes).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("local validation failure short-circuits — no network call", async () => {
|
|
60
|
+
const disp = makeDispatcher();
|
|
61
|
+
const form = createFormController({
|
|
62
|
+
initial: { title: "" },
|
|
63
|
+
schema: z.object({ title: z.string().min(3) }),
|
|
64
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await form.submit();
|
|
68
|
+
|
|
69
|
+
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
70
|
+
expect(result.validationBlocked).toBe(true);
|
|
71
|
+
expect(result.isSuccess).toBe(false);
|
|
72
|
+
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("server validation failure: field errors land on the form", async () => {
|
|
76
|
+
const disp = makeDispatcher({
|
|
77
|
+
isSuccess: false,
|
|
78
|
+
error: {
|
|
79
|
+
code: "validation_error",
|
|
80
|
+
httpStatus: 400,
|
|
81
|
+
i18nKey: "errors.validation.failed",
|
|
82
|
+
message: "Validation failed",
|
|
83
|
+
details: {
|
|
84
|
+
fields: [{ path: "title", code: "too_small", i18nKey: "errors.validation.too_small" }],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const form = createFormController({
|
|
89
|
+
initial: { title: "hello" }, // passes local validate()
|
|
90
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const result = await form.submit();
|
|
94
|
+
|
|
95
|
+
expect(result.isSuccess).toBe(false);
|
|
96
|
+
expect(result.validationBlocked).toBe(false);
|
|
97
|
+
// Errors pushed onto the form — renderer shows them identically to
|
|
98
|
+
// local-validate failures.
|
|
99
|
+
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
100
|
+
expect(form.getSnapshot().errors["title"]?.[0]?.code).toBe("too_small");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("non-validation server error: form state is left alone, error passes through", async () => {
|
|
104
|
+
const disp = makeDispatcher({
|
|
105
|
+
isSuccess: false,
|
|
106
|
+
error: {
|
|
107
|
+
code: "rate_limited",
|
|
108
|
+
httpStatus: 429,
|
|
109
|
+
i18nKey: "errors.rateLimited",
|
|
110
|
+
message: "Too many requests",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const form = createFormController({
|
|
114
|
+
initial: { title: "hello" },
|
|
115
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = await form.submit();
|
|
119
|
+
|
|
120
|
+
expect(result.isSuccess).toBe(false);
|
|
121
|
+
expect(form.getSnapshot().errors).toEqual({}); // untouched
|
|
122
|
+
if (!result.isSuccess && !("validationBlocked" in result && result.validationBlocked)) {
|
|
123
|
+
expect(result.error.code).toBe("rate_limited");
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("payloadMode: 'changes' — sends only the delta, skips dispatch when clean", async () => {
|
|
128
|
+
const disp = makeDispatcher();
|
|
129
|
+
const form = createFormController({
|
|
130
|
+
initial: { title: "hello", count: 3 },
|
|
131
|
+
submit: {
|
|
132
|
+
dispatcher: disp,
|
|
133
|
+
type: "app:write:task:update",
|
|
134
|
+
payloadMode: "changes",
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Clean form → no network call, success with current values.
|
|
139
|
+
const cleanResult = await form.submit();
|
|
140
|
+
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
141
|
+
expect(cleanResult.isSuccess).toBe(true);
|
|
142
|
+
|
|
143
|
+
// Touch one field, submit — only that field goes out.
|
|
144
|
+
form.setField("title", "world");
|
|
145
|
+
await form.submit();
|
|
146
|
+
expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:update", { title: "world" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("stale-submit race: edits during the in-flight write stay dirty after success", async () => {
|
|
150
|
+
// User submits "hello", the network takes 50ms. During those 50ms the
|
|
151
|
+
// user types "world" into the same field. The server sees "hello"
|
|
152
|
+
// (correct — we snapshotted before the await). On success, baseline
|
|
153
|
+
// becomes "hello" (what was submitted), NOT "world" (what's currently
|
|
154
|
+
// displayed). The user still sees their in-flight edit AND it still
|
|
155
|
+
// counts as dirty — so they can submit it again.
|
|
156
|
+
let resolve: ((result: WriteResult) => void) | undefined;
|
|
157
|
+
const slowResponse = new Promise<WriteResult>((r) => {
|
|
158
|
+
resolve = r;
|
|
159
|
+
});
|
|
160
|
+
const disp: Dispatcher = {
|
|
161
|
+
write: (async () => slowResponse) as unknown as Dispatcher["write"],
|
|
162
|
+
async query<TData>() {
|
|
163
|
+
return { isSuccess: true, data: null } as unknown as { isSuccess: true; data: TData };
|
|
164
|
+
},
|
|
165
|
+
async batch() {
|
|
166
|
+
return { isSuccess: true as const, results: [] };
|
|
167
|
+
},
|
|
168
|
+
statusStore: createStore("online"),
|
|
169
|
+
pendingWrites: () => [],
|
|
170
|
+
pendingFiles: () => [],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const form = createFormController({
|
|
174
|
+
initial: { title: "" },
|
|
175
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
176
|
+
});
|
|
177
|
+
form.setField("title", "hello");
|
|
178
|
+
|
|
179
|
+
const submitPromise = form.submit();
|
|
180
|
+
|
|
181
|
+
// User types during the in-flight call.
|
|
182
|
+
form.setField("title", "world");
|
|
183
|
+
|
|
184
|
+
resolve?.({ isSuccess: true, data: { id: "s1" } });
|
|
185
|
+
await submitPromise;
|
|
186
|
+
|
|
187
|
+
const snap = form.getSnapshot();
|
|
188
|
+
expect(snap.values.title).toBe("world"); // user's in-flight edit preserved
|
|
189
|
+
expect(snap.initial.title).toBe("hello"); // baseline = what was submitted
|
|
190
|
+
expect(snap.isDirty).toBe(true); // "world" is a new unsaved change
|
|
191
|
+
expect(snap.changes).toEqual({ title: "world" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("concurrent submit(): two parallel calls produce ONE write, both see the same result", async () => {
|
|
195
|
+
// Double-click scenario: the form fires submit() twice in quick
|
|
196
|
+
// succession, before the first write returned. The guard serializes
|
|
197
|
+
// them — only one network call, both promises resolve to the same
|
|
198
|
+
// result.
|
|
199
|
+
const disp = makeDispatcher({ isSuccess: true, data: { id: "only-one" } });
|
|
200
|
+
const form = createFormController({
|
|
201
|
+
initial: { title: "hello" },
|
|
202
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const [r1, r2] = await Promise.all([form.submit(), form.submit()]);
|
|
206
|
+
|
|
207
|
+
expect(disp.writeSpy).toHaveBeenCalledTimes(1);
|
|
208
|
+
// Both callers get the same success envelope.
|
|
209
|
+
expect(r1.isSuccess).toBe(true);
|
|
210
|
+
expect(r2.isSuccess).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("buildPayload: composes parent + child controllers into nested payload", async () => {
|
|
214
|
+
// Typical sub-controller pattern for hasMany-lines: parent form +
|
|
215
|
+
// N separate line-controllers. buildPayload assembles the nested
|
|
216
|
+
// payload the server's nested-write expects.
|
|
217
|
+
const disp = makeDispatcher();
|
|
218
|
+
|
|
219
|
+
const parent = createFormController({
|
|
220
|
+
initial: { title: "Order #1" },
|
|
221
|
+
});
|
|
222
|
+
const line1 = createFormController({
|
|
223
|
+
initial: { product: "Widget", qty: 2 },
|
|
224
|
+
});
|
|
225
|
+
const line2 = createFormController({
|
|
226
|
+
initial: { product: "Gadget", qty: 5 },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Parent drives submit; buildPayload pulls lines from the
|
|
230
|
+
// externally-held controllers. The server's nested-write expander
|
|
231
|
+
// does the rest.
|
|
232
|
+
const formWithSubmit = createFormController({
|
|
233
|
+
initial: parent.getSnapshot().values,
|
|
234
|
+
submit: {
|
|
235
|
+
dispatcher: disp,
|
|
236
|
+
type: "orders:write:order:create",
|
|
237
|
+
buildPayload: (snap) => ({
|
|
238
|
+
...snap.values,
|
|
239
|
+
lines: [line1.getSnapshot().values, line2.getSnapshot().values],
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await formWithSubmit.submit();
|
|
245
|
+
|
|
246
|
+
expect(disp.writeSpy).toHaveBeenCalledWith("orders:write:order:create", {
|
|
247
|
+
title: "Order #1",
|
|
248
|
+
lines: [
|
|
249
|
+
{ product: "Widget", qty: 2 },
|
|
250
|
+
{ product: "Gadget", qty: 5 },
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("buildPayload is called once at submit-start, not re-called during in-flight", async () => {
|
|
256
|
+
// Stale-safety check for buildPayload: the user may keep editing
|
|
257
|
+
// during the await. The payload is captured BEFORE the network
|
|
258
|
+
// call, so late edits don't leak into what the server receives.
|
|
259
|
+
let resolve: ((result: WriteResult) => void) | undefined;
|
|
260
|
+
const slow = new Promise<WriteResult>((r) => {
|
|
261
|
+
resolve = r;
|
|
262
|
+
});
|
|
263
|
+
const disp: Dispatcher = {
|
|
264
|
+
write: vi.fn(async () => slow) as unknown as Dispatcher["write"],
|
|
265
|
+
async query<TData>() {
|
|
266
|
+
return { isSuccess: true, data: null } as unknown as { isSuccess: true; data: TData };
|
|
267
|
+
},
|
|
268
|
+
async batch() {
|
|
269
|
+
return { isSuccess: true as const, results: [] };
|
|
270
|
+
},
|
|
271
|
+
statusStore: createStore("online"),
|
|
272
|
+
pendingWrites: () => [],
|
|
273
|
+
pendingFiles: () => [],
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
let buildCalls = 0;
|
|
277
|
+
const form = createFormController({
|
|
278
|
+
initial: { title: "a" },
|
|
279
|
+
submit: {
|
|
280
|
+
dispatcher: disp,
|
|
281
|
+
type: "app:write:x:create",
|
|
282
|
+
buildPayload: (snap) => {
|
|
283
|
+
buildCalls++;
|
|
284
|
+
return { title: snap.values.title };
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const submitPromise = form.submit();
|
|
290
|
+
// User types during the await — this MUST NOT re-trigger buildPayload.
|
|
291
|
+
form.setField("title", "mutated");
|
|
292
|
+
resolve?.({ isSuccess: true, data: { id: "s1" } });
|
|
293
|
+
await submitPromise;
|
|
294
|
+
|
|
295
|
+
expect(buildCalls).toBe(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("submit with schema + payloadMode='values' validates BEFORE dispatch", async () => {
|
|
299
|
+
// Order matters: if validate() runs after dispatch, a bad payload
|
|
300
|
+
// hits the network and the caller can't tell user-error from
|
|
301
|
+
// server-error. The form's contract is "validate first, network
|
|
302
|
+
// only if clean".
|
|
303
|
+
const disp = makeDispatcher();
|
|
304
|
+
const form = createFormController({
|
|
305
|
+
initial: { title: "" },
|
|
306
|
+
schema: z.object({ title: z.string().min(1) }),
|
|
307
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const result = await form.submit();
|
|
311
|
+
|
|
312
|
+
expect(result.validationBlocked).toBe(true);
|
|
313
|
+
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createFormController } from "../form-controller";
|
|
4
|
+
import { groupIssuesByPath, zodErrorToFieldIssues } from "../zod-bridge";
|
|
5
|
+
|
|
6
|
+
describe("zodErrorToFieldIssues", () => {
|
|
7
|
+
test("flattens zod issues to FieldIssue with dotted paths", () => {
|
|
8
|
+
const schema = z.object({
|
|
9
|
+
title: z.string().min(1),
|
|
10
|
+
address: z.object({ city: z.string().min(1) }),
|
|
11
|
+
tags: z.array(z.string().min(1)),
|
|
12
|
+
});
|
|
13
|
+
const result = schema.safeParse({ title: "", address: { city: "" }, tags: ["ok", ""] });
|
|
14
|
+
|
|
15
|
+
expect(result.success).toBe(false);
|
|
16
|
+
if (result.success) return;
|
|
17
|
+
const issues = zodErrorToFieldIssues(result.error);
|
|
18
|
+
|
|
19
|
+
const paths = issues.map((i) => i.path).sort();
|
|
20
|
+
expect(paths).toContain("title");
|
|
21
|
+
expect(paths).toContain("address.city");
|
|
22
|
+
expect(paths).toContain("tags.1");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("top-level issues get path='(root)' — matches server zod-bridge", () => {
|
|
26
|
+
const schema = z.object({ foo: z.string() });
|
|
27
|
+
// Pass a non-object → zod raises an issue with path=[].
|
|
28
|
+
const result = schema.safeParse("not-an-object");
|
|
29
|
+
|
|
30
|
+
expect(result.success).toBe(false);
|
|
31
|
+
if (result.success) return;
|
|
32
|
+
const issues = zodErrorToFieldIssues(result.error);
|
|
33
|
+
|
|
34
|
+
expect(issues[0]?.path).toBe("(root)");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("surfaces zod params (minimum/maximum/expected) under issue.params", () => {
|
|
38
|
+
const schema = z.object({ count: z.number().min(10).max(100) });
|
|
39
|
+
const result = schema.safeParse({ count: 3 });
|
|
40
|
+
|
|
41
|
+
expect(result.success).toBe(false);
|
|
42
|
+
if (result.success) return;
|
|
43
|
+
const issues = zodErrorToFieldIssues(result.error);
|
|
44
|
+
|
|
45
|
+
expect(issues[0]?.params).toBeDefined();
|
|
46
|
+
expect(issues[0]?.params?.["minimum"]).toBe(10);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("groupIssuesByPath", () => {
|
|
51
|
+
test("groups multiple issues on the same path into one bucket", () => {
|
|
52
|
+
const grouped = groupIssuesByPath([
|
|
53
|
+
{ path: "title", code: "too_small", i18nKey: "x" },
|
|
54
|
+
{ path: "title", code: "invalid_format", i18nKey: "x" },
|
|
55
|
+
{ path: "age", code: "invalid_type", i18nKey: "x" },
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
expect(grouped["title"]).toHaveLength(2);
|
|
59
|
+
expect(grouped["age"]).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("createFormController — validate()", () => {
|
|
64
|
+
test("without a schema: validate() is a no-op that returns true", () => {
|
|
65
|
+
const form = createFormController({ initial: { title: "" } });
|
|
66
|
+
const listener = vi.fn();
|
|
67
|
+
form.subscribe(listener);
|
|
68
|
+
|
|
69
|
+
const ok = form.validate();
|
|
70
|
+
|
|
71
|
+
expect(ok).toBe(true);
|
|
72
|
+
expect(form.getSnapshot().errors).toEqual({});
|
|
73
|
+
expect(listener).not.toHaveBeenCalled(); // no-op
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("with a schema: validate() runs it and populates errors on failure", () => {
|
|
77
|
+
const schema = z.object({ title: z.string().min(3), age: z.number().int() });
|
|
78
|
+
const form = createFormController({
|
|
79
|
+
initial: { title: "a", age: 1.5 },
|
|
80
|
+
schema,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const ok = form.validate();
|
|
84
|
+
const snap = form.getSnapshot();
|
|
85
|
+
|
|
86
|
+
expect(ok).toBe(false);
|
|
87
|
+
expect(snap.errors["title"]).toBeDefined();
|
|
88
|
+
expect(snap.errors["age"]).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("validate() returns true when values match the schema", () => {
|
|
92
|
+
const schema = z.object({ title: z.string().min(1) });
|
|
93
|
+
const form = createFormController({ initial: { title: "hello" }, schema });
|
|
94
|
+
|
|
95
|
+
expect(form.validate()).toBe(true);
|
|
96
|
+
expect(form.getSnapshot().errors).toEqual({});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("validate() clears previous errors on subsequent success", () => {
|
|
100
|
+
// Common flow: user submits, sees errors, fixes fields, hits validate
|
|
101
|
+
// again — old errors must disappear.
|
|
102
|
+
const schema = z.object({ title: z.string().min(3) });
|
|
103
|
+
const form = createFormController({ initial: { title: "a" }, schema });
|
|
104
|
+
|
|
105
|
+
form.validate();
|
|
106
|
+
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
107
|
+
|
|
108
|
+
form.setField("title", "hello");
|
|
109
|
+
form.validate();
|
|
110
|
+
|
|
111
|
+
expect(form.getSnapshot().errors).toEqual({});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("validate() with nested values: errors keyed by dotted path", () => {
|
|
115
|
+
const schema = z.object({
|
|
116
|
+
address: z.object({ city: z.string().min(1) }),
|
|
117
|
+
});
|
|
118
|
+
const form = createFormController({ initial: { address: { city: "" } }, schema });
|
|
119
|
+
|
|
120
|
+
form.validate();
|
|
121
|
+
|
|
122
|
+
expect(form.getSnapshot().errors["address.city"]).toBeDefined();
|
|
123
|
+
});
|
|
124
|
+
});
|