@cosmicdrift/kumiko-headless 0.328.0 → 0.329.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.
Files changed (108) hide show
  1. package/dist/apex/css.d.ts +3 -0
  2. package/{src/apex/css.ts → dist/apex/css.js} +11 -24
  3. package/dist/apex/index.d.ts +229 -0
  4. package/dist/apex/index.js +360 -0
  5. package/dist/apex/lightbox.d.ts +4 -0
  6. package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
  7. package/dist/contracts/asset.d.ts +15 -0
  8. package/dist/contracts/asset.js +17 -0
  9. package/dist/contracts/index.d.ts +3 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/locale.d.ts +7 -0
  12. package/dist/contracts/locale.js +11 -0
  13. package/dist/contracts/primitives.d.ts +93 -0
  14. package/dist/contracts/primitives.js +21 -0
  15. package/dist/dispatcher/index.d.ts +2 -0
  16. package/dist/dispatcher/index.js +1 -0
  17. package/dist/dispatcher/types.d.ts +86 -0
  18. package/dist/dispatcher/types.js +34 -0
  19. package/dist/form/form-controller.d.ts +2 -0
  20. package/dist/form/form-controller.js +404 -0
  21. package/dist/form/index.d.ts +3 -0
  22. package/dist/form/index.js +2 -0
  23. package/dist/form/types.d.ts +71 -0
  24. package/dist/form/types.js +1 -0
  25. package/dist/form/zod-bridge.d.ts +5 -0
  26. package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
  27. package/dist/format/escape.d.ts +5 -0
  28. package/{src/format/escape.ts → dist/format/escape.js} +43 -47
  29. package/dist/format/html-template.d.ts +9 -0
  30. package/dist/format/html-template.js +39 -0
  31. package/dist/format/index.d.ts +10 -0
  32. package/dist/format/index.js +192 -0
  33. package/dist/format/money.d.ts +1 -0
  34. package/{src/format/money.ts → dist/format/money.js} +6 -5
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +9 -0
  37. package/dist/locale-routing/index.d.ts +39 -0
  38. package/dist/locale-routing/index.js +143 -0
  39. package/dist/nav/index.d.ts +2 -0
  40. package/dist/nav/index.js +1 -0
  41. package/dist/nav/resolve.d.ts +2 -0
  42. package/dist/nav/resolve.js +58 -0
  43. package/dist/nav/types.d.ts +26 -0
  44. package/dist/nav/types.js +1 -0
  45. package/dist/store/create-store.d.ts +2 -0
  46. package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
  47. package/dist/store/equality.d.ts +1 -0
  48. package/{src/store/equality.ts → dist/store/equality.js} +19 -17
  49. package/dist/store/index.d.ts +3 -0
  50. package/dist/store/index.js +2 -0
  51. package/dist/store/types.d.ts +7 -0
  52. package/dist/store/types.js +10 -0
  53. package/dist/view-model/edit.d.ts +10 -0
  54. package/dist/view-model/edit.js +392 -0
  55. package/dist/view-model/embedded-list.d.ts +27 -0
  56. package/dist/view-model/embedded-list.js +63 -0
  57. package/dist/view-model/index.d.ts +7 -0
  58. package/dist/view-model/index.js +3 -0
  59. package/dist/view-model/list.d.ts +16 -0
  60. package/dist/view-model/list.js +201 -0
  61. package/dist/view-model/types.d.ts +300 -0
  62. package/dist/view-model/types.js +1 -0
  63. package/package.json +31 -12
  64. package/src/apex/__tests__/lightbox.test.ts +0 -20
  65. package/src/apex/__tests__/page-head.test.ts +0 -63
  66. package/src/apex/__tests__/render.test.ts +0 -401
  67. package/src/apex/index.ts +0 -667
  68. package/src/contracts/__tests__/contracts.test.ts +0 -123
  69. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  70. package/src/contracts/asset.ts +0 -48
  71. package/src/contracts/index.ts +0 -23
  72. package/src/contracts/locale.ts +0 -33
  73. package/src/contracts/primitives.ts +0 -158
  74. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  75. package/src/dispatcher/index.ts +0 -16
  76. package/src/dispatcher/types.ts +0 -217
  77. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  78. package/src/form/__tests__/form-controller.test.ts +0 -231
  79. package/src/form/__tests__/submit.test.ts +0 -575
  80. package/src/form/__tests__/validation.test.ts +0 -274
  81. package/src/form/form-controller.ts +0 -458
  82. package/src/form/index.ts +0 -15
  83. package/src/form/types.ts +0 -297
  84. package/src/format/__tests__/escape.test.ts +0 -79
  85. package/src/format/__tests__/format.test.ts +0 -285
  86. package/src/format/__tests__/html-template.test.ts +0 -47
  87. package/src/format/html-template.ts +0 -49
  88. package/src/format/index.ts +0 -223
  89. package/src/index.ts +0 -118
  90. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  91. package/src/locale-routing/index.ts +0 -218
  92. package/src/nav/__tests__/resolve.test.ts +0 -279
  93. package/src/nav/index.ts +0 -8
  94. package/src/nav/resolve.ts +0 -60
  95. package/src/nav/types.ts +0 -70
  96. package/src/store/__tests__/create-store.test.ts +0 -139
  97. package/src/store/__tests__/equality.test.ts +0 -66
  98. package/src/store/index.ts +0 -3
  99. package/src/store/types.ts +0 -27
  100. package/src/view-model/__tests__/edit.test.ts +0 -1072
  101. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  102. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  103. package/src/view-model/__tests__/list.test.ts +0 -439
  104. package/src/view-model/edit.ts +0 -645
  105. package/src/view-model/embedded-list.ts +0 -89
  106. package/src/view-model/index.ts +0 -43
  107. package/src/view-model/list.ts +0 -280
  108. 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
- });