@cosmicdrift/kumiko-headless 0.328.0 → 0.328.1
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/dist/apex/css.d.ts +3 -0
- package/{src/apex/css.ts → dist/apex/css.js} +11 -24
- package/dist/apex/index.d.ts +229 -0
- package/dist/apex/index.js +360 -0
- package/dist/apex/lightbox.d.ts +4 -0
- package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
- package/dist/contracts/asset.d.ts +15 -0
- package/dist/contracts/asset.js +17 -0
- package/dist/contracts/index.d.ts +3 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/locale.d.ts +7 -0
- package/dist/contracts/locale.js +11 -0
- package/dist/contracts/primitives.d.ts +93 -0
- package/dist/contracts/primitives.js +21 -0
- package/dist/dispatcher/index.d.ts +2 -0
- package/dist/dispatcher/index.js +1 -0
- package/dist/dispatcher/types.d.ts +86 -0
- package/dist/dispatcher/types.js +34 -0
- package/dist/form/form-controller.d.ts +2 -0
- package/dist/form/form-controller.js +404 -0
- package/dist/form/index.d.ts +3 -0
- package/dist/form/index.js +2 -0
- package/dist/form/types.d.ts +71 -0
- package/dist/form/types.js +1 -0
- package/dist/form/zod-bridge.d.ts +5 -0
- package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
- package/dist/format/escape.d.ts +5 -0
- package/{src/format/escape.ts → dist/format/escape.js} +43 -47
- package/dist/format/html-template.d.ts +9 -0
- package/dist/format/html-template.js +39 -0
- package/dist/format/index.d.ts +10 -0
- package/dist/format/index.js +192 -0
- package/dist/format/money.d.ts +1 -0
- package/{src/format/money.ts → dist/format/money.js} +6 -5
- package/dist/index.d.ts +14 -0
- package/dist/index.js +9 -0
- package/dist/locale-routing/index.d.ts +39 -0
- package/dist/locale-routing/index.js +143 -0
- package/dist/nav/index.d.ts +2 -0
- package/dist/nav/index.js +1 -0
- package/dist/nav/resolve.d.ts +2 -0
- package/dist/nav/resolve.js +58 -0
- package/dist/nav/types.d.ts +26 -0
- package/dist/nav/types.js +1 -0
- package/dist/store/create-store.d.ts +2 -0
- package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
- package/dist/store/equality.d.ts +1 -0
- package/{src/store/equality.ts → dist/store/equality.js} +19 -17
- package/dist/store/index.d.ts +3 -0
- package/dist/store/index.js +2 -0
- package/dist/store/types.d.ts +7 -0
- package/dist/store/types.js +10 -0
- package/dist/view-model/edit.d.ts +10 -0
- package/dist/view-model/edit.js +392 -0
- package/dist/view-model/embedded-list.d.ts +27 -0
- package/dist/view-model/embedded-list.js +63 -0
- package/dist/view-model/index.d.ts +7 -0
- package/dist/view-model/index.js +3 -0
- package/dist/view-model/list.d.ts +16 -0
- package/dist/view-model/list.js +201 -0
- package/dist/view-model/types.d.ts +300 -0
- package/dist/view-model/types.js +1 -0
- package/package.json +31 -12
- package/src/apex/__tests__/lightbox.test.ts +0 -20
- package/src/apex/__tests__/page-head.test.ts +0 -63
- package/src/apex/__tests__/render.test.ts +0 -401
- package/src/apex/index.ts +0 -667
- package/src/contracts/__tests__/contracts.test.ts +0 -123
- package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
- package/src/contracts/asset.ts +0 -48
- package/src/contracts/index.ts +0 -23
- package/src/contracts/locale.ts +0 -33
- package/src/contracts/primitives.ts +0 -158
- package/src/dispatcher/__tests__/contract.test.ts +0 -163
- package/src/dispatcher/index.ts +0 -16
- package/src/dispatcher/types.ts +0 -217
- package/src/form/__tests__/conditional-fields.test.ts +0 -177
- package/src/form/__tests__/form-controller.test.ts +0 -231
- package/src/form/__tests__/submit.test.ts +0 -575
- package/src/form/__tests__/validation.test.ts +0 -274
- package/src/form/form-controller.ts +0 -458
- package/src/form/index.ts +0 -15
- package/src/form/types.ts +0 -297
- package/src/format/__tests__/escape.test.ts +0 -79
- package/src/format/__tests__/format.test.ts +0 -285
- package/src/format/__tests__/html-template.test.ts +0 -47
- package/src/format/html-template.ts +0 -49
- package/src/format/index.ts +0 -223
- package/src/index.ts +0 -118
- package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
- package/src/locale-routing/index.ts +0 -218
- package/src/nav/__tests__/resolve.test.ts +0 -279
- package/src/nav/index.ts +0 -8
- package/src/nav/resolve.ts +0 -60
- package/src/nav/types.ts +0 -70
- package/src/store/__tests__/create-store.test.ts +0 -139
- package/src/store/__tests__/equality.test.ts +0 -66
- package/src/store/index.ts +0 -3
- package/src/store/types.ts +0 -27
- package/src/view-model/__tests__/edit.test.ts +0 -1072
- package/src/view-model/__tests__/embedded-list.test.ts +0 -96
- package/src/view-model/__tests__/field-labels.test.ts +0 -62
- package/src/view-model/__tests__/list.test.ts +0 -439
- package/src/view-model/edit.ts +0 -645
- package/src/view-model/embedded-list.ts +0 -89
- package/src/view-model/index.ts +0 -43
- package/src/view-model/list.ts +0 -280
- package/src/view-model/types.ts +0 -400
|
@@ -1,575 +0,0 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import * as 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 mock>;
|
|
11
|
-
} {
|
|
12
|
-
const writeSpy = mock(
|
|
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
|
-
async *stream() {},
|
|
25
|
-
statusStore: createStore("online"),
|
|
26
|
-
pendingWrites: () => [],
|
|
27
|
-
pendingFiles: () => [],
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe("createFormController — submit()", () => {
|
|
32
|
-
test("throws when called without a submit-config — no guessing the destination", async () => {
|
|
33
|
-
const form = createFormController({ initial: { title: "hello" } });
|
|
34
|
-
|
|
35
|
-
await expect(form.submit()).rejects.toThrow(/submit\(\) called without a `submit` config/);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("returns isSuccess:false when submit-config has no dispatcher — forgot the provider", async () => {
|
|
39
|
-
const form = createFormController({
|
|
40
|
-
initial: { title: "hello" },
|
|
41
|
-
submit: { type: "app:write:task:create" },
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const result = await form.submit();
|
|
45
|
-
expect(result.validationBlocked).toBe(false);
|
|
46
|
-
expect(result.isSuccess).toBe(false);
|
|
47
|
-
if (result.isSuccess || result.validationBlocked) throw new Error("expected write failure");
|
|
48
|
-
expect(result.error.message).toMatch(/submit\(\) called without a dispatcher/);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("returns validationBlocked without a dispatcher when schema fails first", async () => {
|
|
52
|
-
const form = createFormController({
|
|
53
|
-
initial: { title: "" },
|
|
54
|
-
schema: z.object({ title: z.string().min(3) }),
|
|
55
|
-
submit: { type: "app:write:task:create" },
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const result = await form.submit();
|
|
59
|
-
expect(result.validationBlocked).toBe(true);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("returns isNoOp without a dispatcher when payloadMode changes is empty", async () => {
|
|
63
|
-
const form = createFormController({
|
|
64
|
-
initial: { title: "hello" },
|
|
65
|
-
submit: { type: "app:write:task:update", payloadMode: "changes" },
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
const result = await form.submit();
|
|
69
|
-
expect(result.validationBlocked).toBe(false);
|
|
70
|
-
if (!result.validationBlocked) {
|
|
71
|
-
expect(result.isNoOp).toBe(true);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("happy path: dispatches values, returns success, rebases form", async () => {
|
|
76
|
-
const disp = makeDispatcher();
|
|
77
|
-
const form = createFormController({
|
|
78
|
-
initial: { title: "hello" },
|
|
79
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
80
|
-
});
|
|
81
|
-
form.setField("title", "world");
|
|
82
|
-
|
|
83
|
-
const result = await form.submit();
|
|
84
|
-
|
|
85
|
-
expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:create", { title: "world" });
|
|
86
|
-
expect(result.isSuccess).toBe(true);
|
|
87
|
-
if (result.isSuccess && !("validationBlocked" in result && result.validationBlocked)) {
|
|
88
|
-
expect(result.data).toEqual({ id: "srv-1" });
|
|
89
|
-
}
|
|
90
|
-
// Post-submit the form should be at its new clean baseline.
|
|
91
|
-
const snap = form.getSnapshot();
|
|
92
|
-
expect(snap.isDirty).toBe(false);
|
|
93
|
-
expect(snap.initial.title).toBe("world");
|
|
94
|
-
expect(snap.changes).toEqual({});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("local validation failure short-circuits — no network call", async () => {
|
|
98
|
-
const disp = makeDispatcher();
|
|
99
|
-
const form = createFormController({
|
|
100
|
-
initial: { title: "" },
|
|
101
|
-
schema: z.object({ title: z.string().min(3) }),
|
|
102
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const result = await form.submit();
|
|
106
|
-
|
|
107
|
-
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
108
|
-
expect(result.validationBlocked).toBe(true);
|
|
109
|
-
expect(result.isSuccess).toBe(false);
|
|
110
|
-
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("validateScope: a required field outside the scope missing/empty does not block submit", async () => {
|
|
114
|
-
// RenderEdit's `fields` filter narrows what's rendered; a schema-required
|
|
115
|
-
// field the user never sees (outside validateScope) must not be able to
|
|
116
|
-
// block a submit they have no way to fix.
|
|
117
|
-
const disp = makeDispatcher();
|
|
118
|
-
const form = createFormController({
|
|
119
|
-
initial: { title: "hello", notes: "" },
|
|
120
|
-
schema: z.object({ title: z.string().min(1), notes: z.string().min(1) }),
|
|
121
|
-
submit: { dispatcher: disp, type: "app:write:task:create", validateScope: ["title"] },
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const result = await form.submit();
|
|
125
|
-
|
|
126
|
-
expect(result.validationBlocked).toBe(false);
|
|
127
|
-
expect(disp.writeSpy).toHaveBeenCalledTimes(1);
|
|
128
|
-
const call = disp.writeSpy.mock.calls[0]?.[1] as Record<string, unknown>;
|
|
129
|
-
expect(call["title"]).toBe("hello");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("validateScope: a required field inside the scope missing/empty still blocks submit", async () => {
|
|
133
|
-
const disp = makeDispatcher();
|
|
134
|
-
const form = createFormController({
|
|
135
|
-
initial: { title: "", notes: "filled" },
|
|
136
|
-
schema: z.object({ title: z.string().min(1), notes: z.string().min(1) }),
|
|
137
|
-
submit: { dispatcher: disp, type: "app:write:task:create", validateScope: ["title"] },
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const result = await form.submit();
|
|
141
|
-
|
|
142
|
-
expect(result.validationBlocked).toBe(true);
|
|
143
|
-
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
144
|
-
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("server validation failure: field errors land on the form", async () => {
|
|
148
|
-
const disp = makeDispatcher({
|
|
149
|
-
isSuccess: false,
|
|
150
|
-
error: {
|
|
151
|
-
code: "validation_error",
|
|
152
|
-
httpStatus: 400,
|
|
153
|
-
i18nKey: "errors.validation.failed",
|
|
154
|
-
message: "Validation failed",
|
|
155
|
-
details: {
|
|
156
|
-
fields: [{ path: "title", code: "too_small", i18nKey: "errors.validation.too_small" }],
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
});
|
|
160
|
-
const form = createFormController({
|
|
161
|
-
initial: { title: "hello" }, // passes local validate()
|
|
162
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const result = await form.submit();
|
|
166
|
-
|
|
167
|
-
expect(result.isSuccess).toBe(false);
|
|
168
|
-
expect(result.validationBlocked).toBe(false);
|
|
169
|
-
// Errors pushed onto the form — renderer shows them identically to
|
|
170
|
-
// local-validate failures.
|
|
171
|
-
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
172
|
-
expect(form.getSnapshot().errors["title"]?.[0]?.code).toBe("too_small");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("server validation failure with serverFieldPathPrefix: paths are stripped before mapping to form fields", async () => {
|
|
176
|
-
const disp = makeDispatcher({
|
|
177
|
-
isSuccess: false,
|
|
178
|
-
error: {
|
|
179
|
-
code: "validation_error",
|
|
180
|
-
httpStatus: 400,
|
|
181
|
-
i18nKey: "errors.validation",
|
|
182
|
-
message: "Validation failed",
|
|
183
|
-
details: {
|
|
184
|
-
fields: [
|
|
185
|
-
{ path: "changes.count", code: "too_small", i18nKey: "errors.validation.too_small" },
|
|
186
|
-
{ path: "title", code: "too_small", i18nKey: "errors.validation.too_small" },
|
|
187
|
-
],
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
});
|
|
191
|
-
const form = createFormController({
|
|
192
|
-
initial: { title: "hello", count: 1 },
|
|
193
|
-
submit: {
|
|
194
|
-
dispatcher: disp,
|
|
195
|
-
type: "app:write:task:update",
|
|
196
|
-
serverFieldPathPrefix: "changes.",
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
const result = await form.submit();
|
|
201
|
-
|
|
202
|
-
expect(result.isSuccess).toBe(false);
|
|
203
|
-
expect(form.getSnapshot().errors["count"]).toBeDefined();
|
|
204
|
-
expect(form.getSnapshot().errors["title"]).toBeDefined();
|
|
205
|
-
expect(form.getSnapshot().errors["changes.count"]).toBeUndefined();
|
|
206
|
-
if (result.validationBlocked || result.isSuccess) throw new Error("expected a server failure");
|
|
207
|
-
expect(result.error.details?.fields?.map((f) => f.path)).toEqual(["count", "title"]);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test("server validation failure without serverFieldPathPrefix: prefixed paths pass through unchanged", async () => {
|
|
211
|
-
const disp = makeDispatcher({
|
|
212
|
-
isSuccess: false,
|
|
213
|
-
error: {
|
|
214
|
-
code: "validation_error",
|
|
215
|
-
httpStatus: 400,
|
|
216
|
-
i18nKey: "errors.validation",
|
|
217
|
-
message: "Validation failed",
|
|
218
|
-
details: {
|
|
219
|
-
fields: [
|
|
220
|
-
{ path: "changes.count", code: "too_small", i18nKey: "errors.validation.too_small" },
|
|
221
|
-
],
|
|
222
|
-
},
|
|
223
|
-
},
|
|
224
|
-
});
|
|
225
|
-
const form = createFormController({
|
|
226
|
-
initial: { title: "hello", count: 1 },
|
|
227
|
-
submit: { dispatcher: disp, type: "app:write:task:update" },
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const result = await form.submit();
|
|
231
|
-
|
|
232
|
-
expect(result.isSuccess).toBe(false);
|
|
233
|
-
expect(form.getSnapshot().errors["changes.count"]).toBeDefined();
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test("non-validation server error: form state is left alone, error passes through", async () => {
|
|
237
|
-
const disp = makeDispatcher({
|
|
238
|
-
isSuccess: false,
|
|
239
|
-
error: {
|
|
240
|
-
code: "rate_limited",
|
|
241
|
-
httpStatus: 429,
|
|
242
|
-
i18nKey: "errors.rateLimited",
|
|
243
|
-
message: "Too many requests",
|
|
244
|
-
},
|
|
245
|
-
});
|
|
246
|
-
const form = createFormController({
|
|
247
|
-
initial: { title: "hello" },
|
|
248
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
const result = await form.submit();
|
|
252
|
-
|
|
253
|
-
expect(result.isSuccess).toBe(false);
|
|
254
|
-
expect(form.getSnapshot().errors).toEqual({}); // untouched
|
|
255
|
-
if (!result.isSuccess && !("validationBlocked" in result && result.validationBlocked)) {
|
|
256
|
-
expect(result.error.code).toBe("rate_limited");
|
|
257
|
-
}
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test("payloadMode: 'changes' — sends only the delta, skips dispatch when clean", async () => {
|
|
261
|
-
const disp = makeDispatcher();
|
|
262
|
-
const form = createFormController({
|
|
263
|
-
initial: { title: "hello", count: 3 },
|
|
264
|
-
submit: {
|
|
265
|
-
dispatcher: disp,
|
|
266
|
-
type: "app:write:task:update",
|
|
267
|
-
payloadMode: "changes",
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
// Clean form → no network call, success with current values.
|
|
272
|
-
const cleanResult = await form.submit();
|
|
273
|
-
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
274
|
-
expect(cleanResult.isSuccess).toBe(true);
|
|
275
|
-
|
|
276
|
-
// Touch one field, submit — only that field goes out.
|
|
277
|
-
form.setField("title", "world");
|
|
278
|
-
await form.submit();
|
|
279
|
-
expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:update", { title: "world" });
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("payloadMode: 'values' — strips an untouched optional field seeded with \"\"", async () => {
|
|
283
|
-
// `dueDate` was never touched by the user; buildInitialValues seeded it
|
|
284
|
-
// with "" for the controlled input. A `.optional()` server schema
|
|
285
|
-
// rejects "" but accepts a missing key, so the key must be dropped
|
|
286
|
-
// entirely — not sent as `dueDate: undefined`.
|
|
287
|
-
const disp = makeDispatcher();
|
|
288
|
-
const form = createFormController({
|
|
289
|
-
initial: { title: "hello", dueDate: "" },
|
|
290
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
await form.submit();
|
|
294
|
-
|
|
295
|
-
const call = disp.writeSpy.mock.calls[0]?.[1] as Record<string, unknown>;
|
|
296
|
-
expect("dueDate" in call).toBe(false);
|
|
297
|
-
expect(call["title"]).toBe("hello");
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
test("payloadMode: 'values' — keeps an explicitly-cleared field that started non-empty", async () => {
|
|
301
|
-
// `note` had a real value initially; the user cleared it via setField.
|
|
302
|
-
// That's a genuine change, not an untouched seed — it must survive.
|
|
303
|
-
const disp = makeDispatcher();
|
|
304
|
-
const form = createFormController({
|
|
305
|
-
initial: { note: "x" },
|
|
306
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
307
|
-
});
|
|
308
|
-
form.setField("note", "");
|
|
309
|
-
|
|
310
|
-
await form.submit();
|
|
311
|
-
|
|
312
|
-
expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:create", { note: "" });
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
test("payloadMode: 'values' with stripEmptySeeds: false — keeps an untouched empty-string field", async () => {
|
|
316
|
-
// Same setup as the strip test above, but the caller opts out — the
|
|
317
|
-
// untouched "" seed must survive unchanged in the payload.
|
|
318
|
-
const disp = makeDispatcher();
|
|
319
|
-
const form = createFormController({
|
|
320
|
-
initial: { title: "hello", dueDate: "" },
|
|
321
|
-
submit: { dispatcher: disp, type: "app:write:task:create", stripEmptySeeds: false },
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
await form.submit();
|
|
325
|
-
|
|
326
|
-
expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:create", {
|
|
327
|
-
title: "hello",
|
|
328
|
-
dueDate: "",
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
test("payloadMode: 'values' — strips a field that was touched and returned to \"\" (value-based, not touch-based)", async () => {
|
|
333
|
-
// stripUntouchedEmptyStrings only compares current vs. initial value —
|
|
334
|
-
// it has no concept of "touched". A field the user typed into and then
|
|
335
|
-
// cleared back to "" is dropped exactly like one nobody ever touched.
|
|
336
|
-
const disp = makeDispatcher();
|
|
337
|
-
const form = createFormController({
|
|
338
|
-
initial: { title: "hello", dueDate: "" },
|
|
339
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
340
|
-
});
|
|
341
|
-
form.setField("dueDate", "x");
|
|
342
|
-
form.setField("dueDate", "");
|
|
343
|
-
|
|
344
|
-
await form.submit();
|
|
345
|
-
|
|
346
|
-
const call = disp.writeSpy.mock.calls[0]?.[1] as Record<string, unknown>;
|
|
347
|
-
expect("dueDate" in call).toBe(false);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
test("second submit after a rebase diffs against the new baseline, not the stale original initial", async () => {
|
|
351
|
-
const disp = makeDispatcher();
|
|
352
|
-
const form = createFormController({
|
|
353
|
-
initial: { title: "hello" },
|
|
354
|
-
submit: { dispatcher: disp, type: "app:write:task:update", payloadMode: "changes" },
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
form.setField("title", "world");
|
|
358
|
-
await form.submit();
|
|
359
|
-
expect(form.getSnapshot().initial.title).toBe("world"); // baseline rebased
|
|
360
|
-
|
|
361
|
-
// Revert to the ORIGINAL initial value — clean against the stale
|
|
362
|
-
// baseline, but a real change against the rebased one.
|
|
363
|
-
form.setField("title", "hello");
|
|
364
|
-
await form.submit();
|
|
365
|
-
|
|
366
|
-
expect(disp.writeSpy).toHaveBeenCalledTimes(2);
|
|
367
|
-
expect(disp.writeSpy.mock.calls[1]).toEqual(["app:write:task:update", { title: "hello" }]);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test("stale-submit race: edits during the in-flight write stay dirty after success", async () => {
|
|
371
|
-
// User submits "hello", the network takes 50ms. During those 50ms the
|
|
372
|
-
// user types "world" into the same field. The server sees "hello"
|
|
373
|
-
// (correct — we snapshotted before the await). On success, baseline
|
|
374
|
-
// becomes "hello" (what was submitted), NOT "world" (what's currently
|
|
375
|
-
// displayed). The user still sees their in-flight edit AND it still
|
|
376
|
-
// counts as dirty — so they can submit it again.
|
|
377
|
-
let resolve: ((result: WriteResult) => void) | undefined;
|
|
378
|
-
const slowResponse = new Promise<WriteResult>((r) => {
|
|
379
|
-
resolve = r;
|
|
380
|
-
});
|
|
381
|
-
const disp: Dispatcher = {
|
|
382
|
-
write: (async () => slowResponse) as unknown as Dispatcher["write"],
|
|
383
|
-
async query<TData>() {
|
|
384
|
-
return { isSuccess: true, data: null } as unknown as { isSuccess: true; data: TData };
|
|
385
|
-
},
|
|
386
|
-
async batch() {
|
|
387
|
-
return { isSuccess: true as const, results: [] };
|
|
388
|
-
},
|
|
389
|
-
statusStore: createStore("online"),
|
|
390
|
-
async *stream() {},
|
|
391
|
-
pendingWrites: () => [],
|
|
392
|
-
pendingFiles: () => [],
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
const form = createFormController({
|
|
396
|
-
initial: { title: "" },
|
|
397
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
398
|
-
});
|
|
399
|
-
form.setField("title", "hello");
|
|
400
|
-
|
|
401
|
-
const submitPromise = form.submit();
|
|
402
|
-
|
|
403
|
-
// User types during the in-flight call.
|
|
404
|
-
form.setField("title", "world");
|
|
405
|
-
|
|
406
|
-
resolve?.({ isSuccess: true, data: { id: "s1" } });
|
|
407
|
-
await submitPromise;
|
|
408
|
-
|
|
409
|
-
const snap = form.getSnapshot();
|
|
410
|
-
expect(snap.values.title).toBe("world"); // user's in-flight edit preserved
|
|
411
|
-
expect(snap.initial.title).toBe("hello"); // baseline = what was submitted
|
|
412
|
-
expect(snap.isDirty).toBe(true); // "world" is a new unsaved change
|
|
413
|
-
expect(snap.changes).toEqual({ title: "world" });
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
test("concurrent submit(): two parallel calls produce ONE write, both see the same result", async () => {
|
|
417
|
-
// Double-click scenario: the form fires submit() twice in quick
|
|
418
|
-
// succession, before the first write returned. The guard serializes
|
|
419
|
-
// them — only one network call, both promises resolve to the same
|
|
420
|
-
// result.
|
|
421
|
-
const disp = makeDispatcher({ isSuccess: true, data: { id: "only-one" } });
|
|
422
|
-
const form = createFormController({
|
|
423
|
-
initial: { title: "hello" },
|
|
424
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
const [r1, r2] = await Promise.all([form.submit(), form.submit()]);
|
|
428
|
-
|
|
429
|
-
expect(disp.writeSpy).toHaveBeenCalledTimes(1);
|
|
430
|
-
// Both callers get the same success envelope.
|
|
431
|
-
expect(r1.isSuccess).toBe(true);
|
|
432
|
-
expect(r2.isSuccess).toBe(true);
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
test("buildPayload: composes parent + child controllers into nested payload", async () => {
|
|
436
|
-
// Typical sub-controller pattern for hasMany-lines: parent form +
|
|
437
|
-
// N separate line-controllers. buildPayload assembles the nested
|
|
438
|
-
// payload the server's nested-write expects.
|
|
439
|
-
const disp = makeDispatcher();
|
|
440
|
-
|
|
441
|
-
const parent = createFormController({
|
|
442
|
-
initial: { title: "Order #1" },
|
|
443
|
-
});
|
|
444
|
-
const line1 = createFormController({
|
|
445
|
-
initial: { product: "Widget", qty: 2 },
|
|
446
|
-
});
|
|
447
|
-
const line2 = createFormController({
|
|
448
|
-
initial: { product: "Gadget", qty: 5 },
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
// Parent drives submit; buildPayload pulls lines from the
|
|
452
|
-
// externally-held controllers. The server's nested-write expander
|
|
453
|
-
// does the rest.
|
|
454
|
-
const formWithSubmit = createFormController({
|
|
455
|
-
initial: parent.getSnapshot().values,
|
|
456
|
-
submit: {
|
|
457
|
-
dispatcher: disp,
|
|
458
|
-
type: "orders:write:order:create",
|
|
459
|
-
buildPayload: (snap) => ({
|
|
460
|
-
...snap.values,
|
|
461
|
-
lines: [line1.getSnapshot().values, line2.getSnapshot().values],
|
|
462
|
-
}),
|
|
463
|
-
},
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
await formWithSubmit.submit();
|
|
467
|
-
|
|
468
|
-
expect(disp.writeSpy).toHaveBeenCalledWith("orders:write:order:create", {
|
|
469
|
-
title: "Order #1",
|
|
470
|
-
lines: [
|
|
471
|
-
{ product: "Widget", qty: 2 },
|
|
472
|
-
{ product: "Gadget", qty: 5 },
|
|
473
|
-
],
|
|
474
|
-
});
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
test("buildPayload is called once at submit-start, not re-called during in-flight", async () => {
|
|
478
|
-
// Stale-safety check for buildPayload: the user may keep editing
|
|
479
|
-
// during the await. The payload is captured BEFORE the network
|
|
480
|
-
// call, so late edits don't leak into what the server receives.
|
|
481
|
-
let resolve: ((result: WriteResult) => void) | undefined;
|
|
482
|
-
const slow = new Promise<WriteResult>((r) => {
|
|
483
|
-
resolve = r;
|
|
484
|
-
});
|
|
485
|
-
const disp: Dispatcher = {
|
|
486
|
-
write: mock(async () => slow) as unknown as Dispatcher["write"],
|
|
487
|
-
async query<TData>() {
|
|
488
|
-
return { isSuccess: true, data: null } as unknown as { isSuccess: true; data: TData };
|
|
489
|
-
},
|
|
490
|
-
async batch() {
|
|
491
|
-
return { isSuccess: true as const, results: [] };
|
|
492
|
-
},
|
|
493
|
-
statusStore: createStore("online"),
|
|
494
|
-
async *stream() {},
|
|
495
|
-
pendingWrites: () => [],
|
|
496
|
-
pendingFiles: () => [],
|
|
497
|
-
};
|
|
498
|
-
|
|
499
|
-
let buildCalls = 0;
|
|
500
|
-
const form = createFormController({
|
|
501
|
-
initial: { title: "a" },
|
|
502
|
-
submit: {
|
|
503
|
-
dispatcher: disp,
|
|
504
|
-
type: "app:write:x:create",
|
|
505
|
-
buildPayload: (snap) => {
|
|
506
|
-
buildCalls++;
|
|
507
|
-
return { title: snap.values.title };
|
|
508
|
-
},
|
|
509
|
-
},
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
const submitPromise = form.submit();
|
|
513
|
-
// User types during the await — this MUST NOT re-trigger buildPayload.
|
|
514
|
-
form.setField("title", "mutated");
|
|
515
|
-
resolve?.({ isSuccess: true, data: { id: "s1" } });
|
|
516
|
-
await submitPromise;
|
|
517
|
-
|
|
518
|
-
expect(buildCalls).toBe(1);
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
test("submit with schema + payloadMode='values' validates BEFORE dispatch", async () => {
|
|
522
|
-
// Order matters: if validate() runs after dispatch, a bad payload
|
|
523
|
-
// hits the network and the caller can't tell user-error from
|
|
524
|
-
// server-error. The form's contract is "validate first, network
|
|
525
|
-
// only if clean".
|
|
526
|
-
const disp = makeDispatcher();
|
|
527
|
-
const form = createFormController({
|
|
528
|
-
initial: { title: "" },
|
|
529
|
-
schema: z.object({ title: z.string().min(1) }),
|
|
530
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
const result = await form.submit();
|
|
534
|
-
|
|
535
|
-
expect(result.validationBlocked).toBe(true);
|
|
536
|
-
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
test("cross-field refine still blocks submit when validateScope is set (#1907)", async () => {
|
|
540
|
-
const schema = z
|
|
541
|
-
.object({ title: z.string().min(1), note: z.string() })
|
|
542
|
-
.refine((v) => v.title !== v.note, { message: "title and note must differ" });
|
|
543
|
-
const disp = makeDispatcher();
|
|
544
|
-
const form = createFormController({
|
|
545
|
-
initial: { title: "same", note: "same" },
|
|
546
|
-
schema,
|
|
547
|
-
submit: {
|
|
548
|
-
dispatcher: disp,
|
|
549
|
-
type: "app:write:task:create",
|
|
550
|
-
validateScope: ["title"],
|
|
551
|
-
},
|
|
552
|
-
});
|
|
553
|
-
const result = await form.submit();
|
|
554
|
-
expect(result.validationBlocked).toBe(true);
|
|
555
|
-
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
test("intentional empty clear survives second submit after rebase (#1858)", async () => {
|
|
559
|
-
const schema = z.object({ title: z.string().min(1), note: z.string().optional() });
|
|
560
|
-
const disp = makeDispatcher();
|
|
561
|
-
const form = createFormController({
|
|
562
|
-
initial: { title: "t", note: "x" },
|
|
563
|
-
schema,
|
|
564
|
-
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
565
|
-
});
|
|
566
|
-
form.setField("note", "");
|
|
567
|
-
const first = await form.submit();
|
|
568
|
-
expect(first.isSuccess).toBe(true);
|
|
569
|
-
expect(disp.writeSpy.mock.calls[0]?.[1]).toMatchObject({ note: "" });
|
|
570
|
-
form.setField("title", "t2");
|
|
571
|
-
const second = await form.submit();
|
|
572
|
-
expect(second.isSuccess).toBe(true);
|
|
573
|
-
expect(disp.writeSpy.mock.calls[1]?.[1]).toMatchObject({ note: "" });
|
|
574
|
-
});
|
|
575
|
-
});
|