@fieldia/core 0.10.0 → 0.11.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 (67) hide show
  1. package/README.md +199 -0
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +829 -125
  4. package/src/index.d.mts +1 -0
  5. package/src/index.d.ts +1 -0
  6. package/src/index.js +4 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +1 -0
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/format/actions.d.mts +692 -0
  11. package/src/lib/format/actions.d.ts +692 -0
  12. package/src/lib/format/actions.js +53 -0
  13. package/src/lib/format/actions.js.map +1 -0
  14. package/src/lib/format/actions.mjs +50 -0
  15. package/src/lib/format/actions.mjs.map +1 -0
  16. package/src/lib/format/field.js +4 -2
  17. package/src/lib/format/field.js.map +1 -1
  18. package/src/lib/format/field.mjs +4 -2
  19. package/src/lib/format/field.mjs.map +1 -1
  20. package/src/lib/format/layout.d.mts +745 -15
  21. package/src/lib/format/layout.d.ts +745 -15
  22. package/src/lib/format/layout.js +18 -3
  23. package/src/lib/format/layout.js.map +1 -1
  24. package/src/lib/format/layout.mjs +17 -2
  25. package/src/lib/format/layout.mjs.map +1 -1
  26. package/src/lib/format/layout.types.d.mts +2 -1
  27. package/src/lib/format/layout.types.d.ts +2 -1
  28. package/src/lib/format/page.d.mts +616 -3
  29. package/src/lib/format/page.d.ts +616 -3
  30. package/src/lib/format/page.js +17 -2
  31. package/src/lib/format/page.js.map +1 -1
  32. package/src/lib/format/page.mjs +17 -2
  33. package/src/lib/format/page.mjs.map +1 -1
  34. package/src/lib/format/part-look.d.mts +36 -0
  35. package/src/lib/format/part-look.d.ts +36 -0
  36. package/src/lib/format/part-look.js +23 -0
  37. package/src/lib/format/part-look.js.map +1 -0
  38. package/src/lib/format/part-look.mjs +20 -0
  39. package/src/lib/format/part-look.mjs.map +1 -0
  40. package/src/lib/format/references.d.mts +19 -0
  41. package/src/lib/format/references.d.ts +19 -0
  42. package/src/lib/format/references.js +131 -2
  43. package/src/lib/format/references.js.map +1 -1
  44. package/src/lib/format/references.mjs +131 -2
  45. package/src/lib/format/references.mjs.map +1 -1
  46. package/src/lib/record/data-source.d.mts +5 -1
  47. package/src/lib/record/data-source.d.ts +5 -1
  48. package/src/lib/record/data-source.js.map +1 -1
  49. package/src/lib/record/data-source.mjs.map +1 -1
  50. package/src/lib/record/form.d.mts +86 -18
  51. package/src/lib/record/form.d.ts +86 -18
  52. package/src/lib/record/form.js +343 -46
  53. package/src/lib/record/form.js.map +1 -1
  54. package/src/lib/record/form.mjs +343 -46
  55. package/src/lib/record/form.mjs.map +1 -1
  56. package/src/lib/record/run.d.mts +169 -0
  57. package/src/lib/record/run.d.ts +169 -0
  58. package/src/lib/record/run.js +350 -0
  59. package/src/lib/record/run.js.map +1 -0
  60. package/src/lib/record/run.mjs +345 -0
  61. package/src/lib/record/run.mjs.map +1 -0
  62. package/src/runtime.d.mts +5 -2
  63. package/src/runtime.d.ts +5 -2
  64. package/src/runtime.js +3 -1
  65. package/src/runtime.js.map +1 -1
  66. package/src/runtime.mjs +1 -0
  67. package/src/runtime.mjs.map +1 -1
@@ -9,6 +9,7 @@ import { expressionEnv } from './env.mjs';
9
9
  import { MESSAGES } from './messages.mjs';
10
10
  import { saveProblemOf } from './data-source.mjs';
11
11
  import { hostScheduler } from './scheduler.mjs';
12
+ import { createMoments, createRunner } from './run.mjs';
12
13
  import { emptyValue, expressionContext, initialValues, isEmpty, lineKind, structuredCopy, } from './values.mjs';
13
14
  /**
14
15
  * The record state behind a page: values, what changed, what is visible and
@@ -16,6 +17,13 @@ import { emptyValue, expressionContext, initialValues, isEmpty, lineKind, struct
16
17
  * binding renders from `getState()` and calls these methods.
17
18
  */
18
19
  export function createForm(options) {
20
+ return innerForm(options, []).form;
21
+ }
22
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
23
+ /** A saved form's answers, as the one value they are in the form round it. */
24
+ const asValue = (values) => values;
25
+ /** `within` are the pages round this one, outermost first: a page among them placed again would hold itself. */
26
+ function innerForm(options, within) {
19
27
  const { page } = options;
20
28
  const scheduler = options.scheduler ?? hostScheduler;
21
29
  const messages = options.messages ?? MESSAGES.en;
@@ -34,7 +42,20 @@ export function createForm(options) {
34
42
  const today = () => localDay(options.now?.() ?? new Date());
35
43
  const computed = compileComputed(page, today);
36
44
  const setWhen = compileSetWhen(page, today);
37
- let baseline = computed.apply({ ...initialValues(page.fields, today()), ...structuredCopy(options.values ?? {}) });
45
+ /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
46
+ const parts = new Map();
47
+ for (const node of index.values())
48
+ if (node.kind === 'form')
49
+ parts.set(node.id, node.source);
50
+ /** Those given their page, with the inner form that holds their answers. */
51
+ const attached = new Map();
52
+ /** The page's fields, and each saved form's answers as a JSON value: what is loaded, told as changed and saved. */
53
+ const fields = { ...page.fields };
54
+ for (const part of parts.values())
55
+ fields[part.name] = { type: 'json', label: part.title || part.name };
56
+ /** True while answers are handed to an inner form: what it says back then is no change of its own. */
57
+ let syncing = false;
58
+ let baseline = computed.apply({ ...initialValues(fields, today()), ...structuredCopy(options.values ?? {}) });
38
59
  /** The setWhen conditions holding for the values as they last settled: one starts to hold only against these. */
39
60
  let holding = setWhen.holding(baseline);
40
61
  let state = {
@@ -61,12 +82,25 @@ export function createForm(options) {
61
82
  let autosaveTimer = null;
62
83
  const pending = new Set();
63
84
  const listeners = new Set();
85
+ /** The app's listeners, by event. */
86
+ const events = new Map();
87
+ /** The moments the run going to a step or tab right now was set off by: the `show` that entry runs knows them, and does not set them off again. */
88
+ let entering = [];
89
+ /** Whether wizard steps entered are told: from once the form has opened and run its `open` steps. */
90
+ let showing = false;
91
+ /** The wizard step last told as entered. */
92
+ let told = null;
64
93
  let contextCache = null;
65
94
  /** The latest load of each field's choices: an answer to an older one is let go. */
66
95
  const choiceLoads = {};
67
96
  function set(patch) {
68
97
  const was = state.values;
98
+ const shown = state.errors;
69
99
  state = { ...state, ...patch };
100
+ // The problems found inside a saved form are shown on it, beside its own fields.
101
+ if (state.errors !== shown)
102
+ for (const part of attached.values())
103
+ part.inner.show(inside(state.errors, part.name));
70
104
  for (const listener of [...listeners])
71
105
  listener(state);
72
106
  // Choices that change with a value that just changed are asked for again.
@@ -75,6 +109,43 @@ export function createForm(options) {
75
109
  if (fromList(name).dependsOn?.some((other) => was[other] !== state.values[other]))
76
110
  loadChoices(name);
77
111
  }
112
+ if (showing && state.step !== told)
113
+ tellStep(entering);
114
+ }
115
+ function emit(event, payload) {
116
+ const heard = events.get(event);
117
+ if (heard)
118
+ for (const listener of [...heard])
119
+ listener(payload);
120
+ }
121
+ /** A field written: told to the app, and — a person's change — the page's `change` steps for it run. */
122
+ function changed(field, by) {
123
+ emit('change', { field, value: state.values[field], values: state.values, by });
124
+ const list = page.on?.change?.[field];
125
+ if (by === 'person' && list)
126
+ void track(moment(`on.change.${field}`, list, [], true));
127
+ }
128
+ /** A wizard step entered: told to the app, and its `show` steps run. */
129
+ function tellStep(chain) {
130
+ const step = state.step;
131
+ if (step === told || step === null)
132
+ return;
133
+ told = step;
134
+ emit('step', { step });
135
+ const list = page.on?.show?.[step];
136
+ if (list)
137
+ void track(moment(`on.show.${step}`, list, chain, true));
138
+ }
139
+ /** Do something while `chain` is the run's that does it, so a step or tab it enters knows. */
140
+ function inRun(chain, act) {
141
+ const was = entering;
142
+ entering = chain;
143
+ try {
144
+ act();
145
+ }
146
+ finally {
147
+ entering = was;
148
+ }
78
149
  }
79
150
  const fromList = (name) => page.fields[name].optionsFrom;
80
151
  function loadChoices(name) {
@@ -109,13 +180,13 @@ export function createForm(options) {
109
180
  }
110
181
  const context = () => reading().context;
111
182
  function fieldDef(name) {
112
- const def = page.fields[name];
183
+ const def = fields[name];
113
184
  if (!def)
114
185
  throw new Error(`The page has no field "${name}"`);
115
186
  return def;
116
187
  }
117
188
  function dirtyFields(values) {
118
- return Object.keys(page.fields).filter((name) => JSON.stringify(values[name] ?? null) !== JSON.stringify(baseline[name] ?? null));
189
+ return Object.keys(fields).filter((name) => JSON.stringify(values[name] ?? null) !== JSON.stringify(baseline[name] ?? null));
119
190
  }
120
191
  function nodeState(id) {
121
192
  const node = index.get(id);
@@ -138,8 +209,8 @@ export function createForm(options) {
138
209
  required: node.required.evaluate(ctx, env) || def?.required === true,
139
210
  };
140
211
  }
141
- /** Field nodes inside `root` (or the whole page), visible right now. */
142
- function visibleFieldNodes(root) {
212
+ /** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
213
+ function visibleFieldNodes(root, kind = 'field') {
143
214
  const inside = (node) => {
144
215
  if (!root)
145
216
  return true;
@@ -157,7 +228,7 @@ export function createForm(options) {
157
228
  return true;
158
229
  return false;
159
230
  };
160
- return [...index.values()].filter((node) => node.kind === 'field' && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
231
+ return [...index.values()].filter((node) => node.kind === kind && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
161
232
  }
162
233
  /** What a field node's answer rules say of its value now: the first error and the first warning. */
163
234
  function ruleCheck(node) {
@@ -229,8 +300,36 @@ export function createForm(options) {
229
300
  }
230
301
  }
231
302
  }
303
+ // A saved form placed here answers for its fields by its own page's rules.
304
+ for (const node of visibleFieldNodes(root, 'form')) {
305
+ const part = attached.get(node.id);
306
+ if (part)
307
+ for (const [key, message] of Object.entries(part.inner.errorsNow()))
308
+ errors[`${part.name}.${key}`] = message;
309
+ }
232
310
  return errors;
233
311
  }
312
+ /**
313
+ * Answers changed from outside a saved form — loaded, put back, set — handed
314
+ * to it, and what it holds after taken back: its defaults and worked-out
315
+ * values filled in. A new starting point (`start`) is handed to each.
316
+ */
317
+ function syncParts(values, how) {
318
+ let out = values;
319
+ for (const part of attached.values()) {
320
+ if (how !== 'start' && out[part.name] === part.inner.form.getState().values)
321
+ continue;
322
+ syncing = true;
323
+ try {
324
+ part.inner.take(isRecord(out[part.name]) ? out[part.name] : {}, how);
325
+ }
326
+ finally {
327
+ syncing = false;
328
+ }
329
+ out = { ...out, [part.name]: asValue(part.inner.form.getState().values) };
330
+ }
331
+ return out;
332
+ }
234
333
  /**
235
334
  * Values as they settle after a change: worked out, then set where a
236
335
  * condition started to hold, then worked out again from what was set — a
@@ -255,7 +354,7 @@ export function createForm(options) {
255
354
  }
256
355
  /** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
257
356
  function writeValues(written, extra = {}, fresh = false) {
258
- const values = fresh ? startFrom(written) : settle(written);
357
+ const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
259
358
  const errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
260
359
  set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
261
360
  }
@@ -347,10 +446,10 @@ export function createForm(options) {
347
446
  // included; a stored one sends only what changed since it was loaded.
348
447
  const creating = state.recordId === null || state.recordId === undefined;
349
448
  const names = creating
350
- ? Object.keys(page.fields).filter((name) => state.dirty.includes(name) || !isEmpty(page.fields[name], state.values[name]))
449
+ ? Object.keys(fields).filter((name) => state.dirty.includes(name) || !isEmpty(fields[name], state.values[name]))
351
450
  : state.dirty;
352
451
  for (const name of names) {
353
- const def = page.fields[name];
452
+ const def = fields[name];
354
453
  const now = state.values[name];
355
454
  const before = creating ? emptyValue(def) : baseline[name];
356
455
  if (def.type === 'one2many')
@@ -369,13 +468,38 @@ export function createForm(options) {
369
468
  const name = node.field;
370
469
  shown[name] = structuredCopy(state.values[name]);
371
470
  }
471
+ // And each saved form shown, by its page: one not found shows nothing to answer.
472
+ for (const node of visibleFieldNodes(undefined, 'form')) {
473
+ const part = attached.get(node.id);
474
+ if (part)
475
+ shown[part.name] = structuredCopy(state.values[part.name]);
476
+ }
372
477
  return shown;
373
478
  }
374
- async function save() {
479
+ async function save(chain = []) {
480
+ return (await saving(chain)) === null;
481
+ }
482
+ /**
483
+ * Save, or send: null once done, or why not. The page's `beforeSave` steps
484
+ * run first — before its own check, so they may fill in what it asks for —
485
+ * and a stop keeps it unsaved; `afterSave` runs once it is saved.
486
+ */
487
+ async function saving(chain) {
488
+ if (page.on?.beforeSave) {
489
+ const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
490
+ if (!before.done) {
491
+ // A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
492
+ if (before.reason !== 'check' && before.reason !== 'save') {
493
+ const problem = { kind: 'stopped', reason: before.reason, message: before.message ?? '' };
494
+ set({ status: 'error', error: problem.message || null, saveProblem: problem });
495
+ }
496
+ return { reason: 'save', ...(before.message !== undefined ? { message: before.message } : {}) };
497
+ }
498
+ }
375
499
  const errors = collectErrors();
376
500
  if (Object.keys(errors).length) {
377
501
  set({ errors });
378
- return false;
502
+ return { reason: 'save', message: Object.values(errors)[0] };
379
503
  }
380
504
  const source = options.dataSource;
381
505
  serverErrors = {};
@@ -384,33 +508,98 @@ export function createForm(options) {
384
508
  if (page.data.kind === 'responses') {
385
509
  if (!source?.submit)
386
510
  throw new Error('This page has no data source to send its answers to');
387
- await source.submit({ pageId: page.id, values: shownValues() });
511
+ const sent = shownValues();
512
+ await source.submit({ pageId: page.id, values: sent });
388
513
  forgetDraft();
389
514
  set({ status: 'saved' });
390
- return true;
515
+ emit('send', { values: sent });
516
+ void track(moment('on.afterSave', page.on?.afterSave, chain, false));
517
+ return null;
391
518
  }
392
519
  if (!source?.save)
393
520
  throw new Error('This page has no data source to save to');
394
521
  const result = await source.save({
395
522
  model: page.data.model,
396
523
  id: state.recordId,
397
- fields: page.fields,
524
+ fields,
398
525
  changes: changes(),
399
526
  values: structuredCopy(state.values),
400
527
  });
401
528
  forgetDraft();
402
- const values = startFrom(result.values ? { ...state.values, ...result.values } : state.values);
529
+ const values = syncParts(startFrom(result.values ? { ...state.values, ...result.values } : state.values), 'start');
403
530
  baseline = structuredCopy(values);
404
531
  set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
405
- return true;
532
+ emit('save', { recordId: state.recordId, values: state.values });
533
+ void track(moment('on.afterSave', page.on?.afterSave, chain, false));
534
+ return null;
406
535
  }
407
536
  catch (error) {
408
537
  const problem = saveProblemOf(error);
409
538
  const fields = problem.kind === 'fields' ? problem.fields ?? {} : {};
410
539
  serverErrors = Object.fromEntries(Object.entries(fields).map(([name, message]) => [name, { message, value: JSON.stringify(state.values[name] ?? null) }]));
411
540
  set({ status: 'error', error: problem.message, saveProblem: problem, errors: { ...fields } });
412
- return false;
541
+ return { reason: 'save', message: problem.message };
542
+ }
543
+ }
544
+ /** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
545
+ async function opening() {
546
+ emit('open', { recordId: state.recordId, values: state.values });
547
+ await moment('on.open', page.on?.open, [], true);
548
+ showing = true;
549
+ tellStep([]);
550
+ }
551
+ /** Write fields — a step's or the app's — worked out once, and tell each. */
552
+ function write(written, by) {
553
+ const names = Object.keys(written);
554
+ names.forEach(fieldDef);
555
+ if (!names.length)
556
+ return;
557
+ writeValues({ ...state.values, ...structuredCopy(written) });
558
+ for (const name of names)
559
+ afterEdit(name);
560
+ for (const name of names)
561
+ changed(name, by);
562
+ }
563
+ function addLine(field, values, by) {
564
+ const def = lineField(field);
565
+ const key = `new-${++lineKeys}`;
566
+ const current = state.values[field] ?? [];
567
+ const line = { key, values: { ...initialValues(def.fields, today()), ...structuredCopy(values) } };
568
+ // A new line comes last, so it is numbered after the last.
569
+ if (def.sequenceField && line.values[def.sequenceField] == null) {
570
+ const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
571
+ line.values[def.sequenceField] = numbers.length ? Math.max(...numbers) + 1 : 1;
413
572
  }
573
+ const lines = [...current, line];
574
+ writeValues({ ...state.values, [field]: lines });
575
+ afterEdit(field);
576
+ changed(field, by);
577
+ return key;
578
+ }
579
+ /** Check the form, or only these fields, showing what is found as sending does. */
580
+ function check(names) {
581
+ names?.forEach(fieldDef);
582
+ const all = collectErrors();
583
+ const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
584
+ set({ errors: found });
585
+ const first = Object.values(found)[0];
586
+ return first === undefined ? null : { reason: 'check', message: first };
587
+ }
588
+ /** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
589
+ function goToStep(target, chain) {
590
+ if (!steps.includes(target))
591
+ return undefined;
592
+ if (!form.steps().includes(target))
593
+ return { reason: 'cannot', message: `The step "${target}" does not apply now` };
594
+ let went = false;
595
+ inRun(chain, () => (went = form.goTo(target)));
596
+ return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
597
+ }
598
+ /** Run steps from the top — a button's, a moment's, the app's — and tell how it ended. */
599
+ async function runTop(list, scope) {
600
+ const result = await runner.runSteps(list, scope);
601
+ emit('run', { id: scope.id, steps: list, result });
602
+ return result;
414
603
  }
415
604
  /** The model a link field's new record belongs to, or null when it cannot make one. */
416
605
  function creatable(def) {
@@ -467,15 +656,35 @@ export function createForm(options) {
467
656
  }
468
657
  set({ status: 'loading', error: null });
469
658
  try {
470
- const loaded = await source.load({ model: page.data.model, id: state.recordId, fields: page.fields });
471
- baseline = startFrom({ ...initialValues(page.fields, today()), ...loaded });
659
+ const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
660
+ baseline = syncParts(startFrom({ ...initialValues(fields, today()), ...loaded }), 'start');
472
661
  set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
473
662
  offerDraft();
663
+ void track(opening());
474
664
  }
475
665
  catch (error) {
476
666
  set({ status: 'error', error: error.message });
477
667
  }
478
668
  }
669
+ const runner = createRunner({
670
+ fields,
671
+ values: () => state.values,
672
+ recordId: () => state.recordId,
673
+ reading,
674
+ today,
675
+ write: (values) => write(values, 'step'),
676
+ addLine: (field, values) => void addLine(field, values, 'step'),
677
+ check,
678
+ save: saving,
679
+ reset: () => form.reset(),
680
+ goTo: goToStep,
681
+ showing: inRun,
682
+ host: options.host,
683
+ confirm: options.confirm,
684
+ onAction: options.onAction,
685
+ answered: (request, result) => emit('action', { request, result }),
686
+ });
687
+ const moment = createMoments((key, list, chain) => runTop(list, { id: key, chain }));
479
688
  const form = {
480
689
  page,
481
690
  getState: () => state,
@@ -484,11 +693,13 @@ export function createForm(options) {
484
693
  return () => listeners.delete(listener);
485
694
  },
486
695
  load: () => track(load()),
487
- setValue(name, value) {
696
+ setValue(name, value, how) {
488
697
  fieldDef(name);
489
698
  writeValues({ ...state.values, [name]: structuredCopy(value) });
490
699
  afterEdit(name);
700
+ changed(name, how?.by ?? 'person');
491
701
  },
702
+ setValues: (values) => write(values, 'app'),
492
703
  node: nodeState,
493
704
  fieldReadonly: (name) => lockedField(fieldDef(name)),
494
705
  loadChoices,
@@ -513,24 +724,11 @@ export function createForm(options) {
513
724
  forgetDraft();
514
725
  serverErrors = {};
515
726
  holding = setWhen.holding(baseline);
516
- set({ values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
727
+ const values = syncParts(structuredCopy(baseline), 'start');
728
+ set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
517
729
  },
518
730
  changes,
519
- addLine(field, values = {}) {
520
- const def = lineField(field);
521
- const key = `new-${++lineKeys}`;
522
- const current = state.values[field] ?? [];
523
- const line = { key, values: { ...initialValues(def.fields, today()), ...structuredCopy(values) } };
524
- // A new line comes last, so it is numbered after the last.
525
- if (def.sequenceField && line.values[def.sequenceField] == null) {
526
- const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
527
- line.values[def.sequenceField] = numbers.length ? Math.max(...numbers) + 1 : 1;
528
- }
529
- const lines = [...current, line];
530
- writeValues({ ...state.values, [field]: lines });
531
- afterEdit(field);
532
- return key;
533
- },
731
+ addLine: (field, values = {}) => addLine(field, values, 'person'),
534
732
  updateLine(field, key, name, value) {
535
733
  const def = lineField(field);
536
734
  if (!def.fields[name])
@@ -538,6 +736,7 @@ export function createForm(options) {
538
736
  const lines = (state.values[field] ?? []).map((line) => line.key === key ? { ...line, values: { ...line.values, [name]: structuredCopy(value) } } : line);
539
737
  writeValues({ ...state.values, [field]: lines });
540
738
  afterEdit(field);
739
+ changed(field, 'person');
541
740
  },
542
741
  moveLine(field, key, to) {
543
742
  const def = lineField(field);
@@ -567,12 +766,14 @@ export function createForm(options) {
567
766
  });
568
767
  writeValues({ ...state.values, [field]: renumbered });
569
768
  afterEdit(field);
769
+ changed(field, 'person');
570
770
  },
571
771
  removeLine(field, key) {
572
772
  lineField(field);
573
773
  const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
574
774
  writeValues({ ...state.values, [field]: lines });
575
775
  afterEdit(field);
776
+ changed(field, 'person');
576
777
  },
577
778
  async search(field, query, limit = 8, options = {}) {
578
779
  return runSearch(fieldDef(field), field, context(), query, limit, options.model);
@@ -678,16 +879,36 @@ export function createForm(options) {
678
879
  if (!node || (node.kind !== 'button' && node.kind !== 'stat'))
679
880
  throw new Error(`The page has no button "${id}"`);
680
881
  if (nodeState(id).invisible)
681
- return;
882
+ return { done: false, reason: 'cannot', message: `The button "${id}" is hidden` };
682
883
  const source = node.source;
683
- await track(Promise.resolve(options.onAction?.({
684
- id,
685
- action: source.action,
686
- ...('params' in source && source.params ? { params: source.params } : {}),
687
- recordId: state.recordId,
688
- values: structuredCopy(state.values),
689
- ...(chosen ? { recordIds: [...chosen.recordIds] } : {}),
690
- })));
884
+ // Its steps, then its app action as a final call.
885
+ const list = [...(source.steps ?? [])];
886
+ if (source.action !== undefined)
887
+ list.push({ do: 'call', action: source.action, ...('params' in source && source.params ? { params: source.params } : {}) });
888
+ const scope = { id, chain: [], ...(chosen ? { recordIds: [...chosen.recordIds] } : {}) };
889
+ const confirm = 'confirm' in source ? source.confirm : undefined;
890
+ return track((async () => {
891
+ if (confirm && !(await runner.ask(confirm))) {
892
+ const result = { done: false, reason: 'no', message: confirm };
893
+ emit('run', { id, steps: list, result });
894
+ return result;
895
+ }
896
+ return runTop(list, scope);
897
+ })());
898
+ },
899
+ run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
900
+ shown(tab) {
901
+ // A wizard's steps are told by the form itself, as they are entered.
902
+ if (steps.includes(tab))
903
+ return;
904
+ void track(moment(`on.show.${tab}`, page.on?.show?.[tab], entering, true));
905
+ },
906
+ on(event, listener) {
907
+ let heard = events.get(event);
908
+ if (!heard)
909
+ events.set(event, (heard = new Set()));
910
+ heard.add(listener);
911
+ return () => void heard.delete(listener);
691
912
  },
692
913
  restoreDraft() {
693
914
  const raw = options.drafts?.store.getItem(draftKey());
@@ -704,19 +925,92 @@ export function createForm(options) {
704
925
  while (pending.size)
705
926
  await Promise.allSettled([...pending]);
706
927
  },
928
+ embed(id, inner) {
929
+ const known = attached.get(id);
930
+ if (known)
931
+ return known.inner.form;
932
+ const part = parts.get(id);
933
+ if (!part)
934
+ throw new Error(`"${id}" is not a saved form placed on this page`);
935
+ const chain = [...within, page.id];
936
+ if (chain.includes(inner.id))
937
+ throw new Error(`a page cannot be placed inside itself: ${[...chain.slice(chain.indexOf(inner.id)), inner.id].join(' → ')}`);
938
+ const given = state.values[part.name];
939
+ // A record of no model of its own: its links search and its lists load, but it never loads, saves, sends or recalculates alone.
940
+ const made = innerForm({
941
+ page: { ...inner, data: { kind: 'responses' } },
942
+ dataSource: options.dataSource,
943
+ values: isRecord(given) ? given : {},
944
+ onAction: options.onAction,
945
+ host: options.host,
946
+ confirm: options.confirm,
947
+ scheduler,
948
+ messages,
949
+ now: options.now,
950
+ }, chain);
951
+ attached.set(id, { name: part.name, inner: made });
952
+ // A change inside it is told as a change of its answers here, whoever made it.
953
+ made.form.on('change', ({ by }) => {
954
+ if (!syncing)
955
+ changed(part.name, by);
956
+ });
957
+ // A change made inside it is a change of this form's: shown, kept in a draft, saved.
958
+ made.form.subscribe((inside) => {
959
+ if (syncing || inside.values === state.values[part.name])
960
+ return;
961
+ writeValues({ ...state.values, [part.name]: asValue(inside.values) });
962
+ afterEdit(part.name);
963
+ });
964
+ // What it starts with, its defaults and worked-out values filled in, is this form's starting point too — unless changed already.
965
+ const start = asValue(made.form.getState().values);
966
+ if (!state.dirty.includes(part.name))
967
+ baseline = { ...baseline, [part.name]: start };
968
+ const values = { ...state.values, [part.name]: start };
969
+ set({ values, dirty: dirtyFields(values) });
970
+ return made.form;
971
+ },
972
+ embedded: (id) => attached.get(id)?.inner.form ?? null,
707
973
  dispose() {
708
974
  if (draftTimer !== null)
709
975
  scheduler.clearTimeout(draftTimer);
710
976
  if (autosaveTimer !== null)
711
977
  scheduler.clearTimeout(autosaveTimer);
712
978
  listeners.clear();
979
+ events.clear();
980
+ for (const part of attached.values())
981
+ part.inner.form.dispose();
713
982
  },
714
983
  };
715
984
  // A record's starting values may already break a warning rule; nothing listens yet.
716
985
  state = { ...state, warnings: collectWarnings() };
717
986
  if (state.recordId == null)
718
987
  offerDraft();
719
- return form;
988
+ // A new form has its values now: it opens once whoever made it has had the chance to listen.
989
+ if (state.recordId == null)
990
+ void track(Promise.resolve().then(opening));
991
+ return {
992
+ form,
993
+ take(values, how) {
994
+ if (how !== 'start')
995
+ return writeValues({ ...initialValues(page.fields, today()), ...values }, {}, how === 'fresh');
996
+ baseline = startFrom({ ...initialValues(page.fields, today()), ...structuredCopy(values) });
997
+ const start = syncParts(structuredCopy(baseline), 'start');
998
+ set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null });
999
+ },
1000
+ errorsNow: () => collectErrors(),
1001
+ show(errors) {
1002
+ if (JSON.stringify(errors) !== JSON.stringify(state.errors))
1003
+ set({ errors });
1004
+ },
1005
+ };
1006
+ }
1007
+ /** The problems found inside a saved form, by its own fields' names. */
1008
+ function inside(errors, name) {
1009
+ const found = {};
1010
+ for (const [key, message] of Object.entries(errors))
1011
+ if (key.startsWith(`${name}.`))
1012
+ found[key.slice(name.length + 1)] = message;
1013
+ return found;
720
1014
  }
721
1015
  /** A field no one types in: read-only by its definition, or worked out from others. */
722
1016
  function lockedField(def) {
@@ -786,6 +1080,9 @@ function indexLayout(root) {
786
1080
  add(node, parent, 'other', { invisible: node.invisible, readonly: node.readonly });
787
1081
  walk(node.children, node.id);
788
1082
  }
1083
+ else if (node.type === 'form') {
1084
+ add(node, parent, 'form', { invisible: node.invisible, readonly: node.readonly });
1085
+ }
789
1086
  else {
790
1087
  add(node, parent, 'other', { invisible: node.invisible });
791
1088
  }