@fieldia/core 0.20.0 → 0.21.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 (116) hide show
  1. package/package.json +1 -1
  2. package/schema/page.schema.json +24 -0
  3. package/src/lib/expression/parser.js +4 -4
  4. package/src/lib/expression/parser.js.map +1 -1
  5. package/src/lib/expression/parser.mjs +5 -5
  6. package/src/lib/expression/parser.mjs.map +1 -1
  7. package/src/lib/expression/tokenizer.d.mts +12 -0
  8. package/src/lib/expression/tokenizer.d.ts +12 -0
  9. package/src/lib/expression/tokenizer.js +21 -12
  10. package/src/lib/expression/tokenizer.js.map +1 -1
  11. package/src/lib/expression/tokenizer.mjs +20 -12
  12. package/src/lib/expression/tokenizer.mjs.map +1 -1
  13. package/src/lib/format/field.d.mts +16 -0
  14. package/src/lib/format/field.d.ts +16 -0
  15. package/src/lib/format/field.js +11 -1
  16. package/src/lib/format/field.js.map +1 -1
  17. package/src/lib/format/field.mjs +11 -1
  18. package/src/lib/format/field.mjs.map +1 -1
  19. package/src/lib/format/page.d.mts +18 -0
  20. package/src/lib/format/page.d.ts +18 -0
  21. package/src/lib/format/page.js +1 -0
  22. package/src/lib/format/page.js.map +1 -1
  23. package/src/lib/format/page.mjs +1 -0
  24. package/src/lib/format/page.mjs.map +1 -1
  25. package/src/lib/format/pattern.d.mts +10 -0
  26. package/src/lib/format/pattern.d.ts +10 -0
  27. package/src/lib/format/pattern.js +33 -0
  28. package/src/lib/format/pattern.js.map +1 -0
  29. package/src/lib/format/pattern.mjs +30 -0
  30. package/src/lib/format/pattern.mjs.map +1 -0
  31. package/src/lib/format/references.js +8 -0
  32. package/src/lib/format/references.js.map +1 -1
  33. package/src/lib/format/references.mjs +8 -0
  34. package/src/lib/format/references.mjs.map +1 -1
  35. package/src/lib/format/translate.js +3 -2
  36. package/src/lib/format/translate.js.map +1 -1
  37. package/src/lib/format/translate.mjs +3 -2
  38. package/src/lib/format/translate.mjs.map +1 -1
  39. package/src/lib/record/check.d.mts +13 -3
  40. package/src/lib/record/check.d.ts +13 -3
  41. package/src/lib/record/check.js +87 -17
  42. package/src/lib/record/check.js.map +1 -1
  43. package/src/lib/record/check.mjs +85 -17
  44. package/src/lib/record/check.mjs.map +1 -1
  45. package/src/lib/record/filter.js +14 -2
  46. package/src/lib/record/filter.js.map +1 -1
  47. package/src/lib/record/filter.mjs +14 -2
  48. package/src/lib/record/filter.mjs.map +1 -1
  49. package/src/lib/record/form.d.mts +44 -0
  50. package/src/lib/record/form.d.ts +44 -0
  51. package/src/lib/record/form.js +249 -20
  52. package/src/lib/record/form.js.map +1 -1
  53. package/src/lib/record/form.mjs +251 -22
  54. package/src/lib/record/form.mjs.map +1 -1
  55. package/src/lib/record/input-rules.d.mts +277 -0
  56. package/src/lib/record/input-rules.d.ts +277 -0
  57. package/src/lib/record/input-rules.js +724 -0
  58. package/src/lib/record/input-rules.js.map +1 -0
  59. package/src/lib/record/input-rules.mjs +705 -0
  60. package/src/lib/record/input-rules.mjs.map +1 -0
  61. package/src/lib/record/inputs.d.mts +25 -0
  62. package/src/lib/record/inputs.d.ts +25 -0
  63. package/src/lib/record/inputs.js +135 -9
  64. package/src/lib/record/inputs.js.map +1 -1
  65. package/src/lib/record/inputs.mjs +131 -9
  66. package/src/lib/record/inputs.mjs.map +1 -1
  67. package/src/lib/record/locales/ar.js +13 -2
  68. package/src/lib/record/locales/ar.js.map +1 -1
  69. package/src/lib/record/locales/ar.mjs +13 -2
  70. package/src/lib/record/locales/ar.mjs.map +1 -1
  71. package/src/lib/record/locales/de.js +13 -2
  72. package/src/lib/record/locales/de.js.map +1 -1
  73. package/src/lib/record/locales/de.mjs +13 -2
  74. package/src/lib/record/locales/de.mjs.map +1 -1
  75. package/src/lib/record/locales/fr.js +13 -2
  76. package/src/lib/record/locales/fr.js.map +1 -1
  77. package/src/lib/record/locales/fr.mjs +13 -2
  78. package/src/lib/record/locales/fr.mjs.map +1 -1
  79. package/src/lib/record/memory-data-source.js +7 -3
  80. package/src/lib/record/memory-data-source.js.map +1 -1
  81. package/src/lib/record/memory-data-source.mjs +7 -3
  82. package/src/lib/record/memory-data-source.mjs.map +1 -1
  83. package/src/lib/record/messages.d.mts +22 -0
  84. package/src/lib/record/messages.d.ts +22 -0
  85. package/src/lib/record/messages.js +13 -2
  86. package/src/lib/record/messages.js.map +1 -1
  87. package/src/lib/record/messages.mjs +13 -2
  88. package/src/lib/record/messages.mjs.map +1 -1
  89. package/src/lib/record/rules.js +3 -1
  90. package/src/lib/record/rules.js.map +1 -1
  91. package/src/lib/record/rules.mjs +3 -1
  92. package/src/lib/record/rules.mjs.map +1 -1
  93. package/src/lib/record/run.d.mts +3 -2
  94. package/src/lib/record/run.d.ts +3 -2
  95. package/src/lib/record/run.js +1 -1
  96. package/src/lib/record/run.js.map +1 -1
  97. package/src/lib/record/run.mjs +1 -1
  98. package/src/lib/record/run.mjs.map +1 -1
  99. package/src/lib/record/search.d.mts +8 -1
  100. package/src/lib/record/search.d.ts +8 -1
  101. package/src/lib/record/search.js +28 -8
  102. package/src/lib/record/search.js.map +1 -1
  103. package/src/lib/record/search.mjs +28 -8
  104. package/src/lib/record/search.mjs.map +1 -1
  105. package/src/lib/record/structures.d.mts +8 -3
  106. package/src/lib/record/structures.d.ts +8 -3
  107. package/src/lib/record/structures.js +51 -7
  108. package/src/lib/record/structures.js.map +1 -1
  109. package/src/lib/record/structures.mjs +48 -6
  110. package/src/lib/record/structures.mjs.map +1 -1
  111. package/src/runtime.d.mts +5 -2
  112. package/src/runtime.d.ts +5 -2
  113. package/src/runtime.js +31 -1
  114. package/src/runtime.js.map +1 -1
  115. package/src/runtime.mjs +5 -2
  116. package/src/runtime.mjs.map +1 -1
@@ -1,6 +1,6 @@
1
1
  import { compileModifier } from '../expression/modifier.mjs';
2
2
  import { localDay } from '../expression/functions.mjs';
3
- import { checkValue } from './check.mjs';
3
+ import { checkProperties, checkValue } from './check.mjs';
4
4
  import { checkInput } from './inputs.mjs';
5
5
  import { compileComputed } from './compute.mjs';
6
6
  import { compileSetWhen } from './set-when.mjs';
@@ -10,7 +10,7 @@ import { firstValues, readLooseMap, readValueMap } from './value-map.mjs';
10
10
  import { resolveFilter } from './filter.mjs';
11
11
  import { rolesAllow } from './roles.mjs';
12
12
  import { compileFieldTone, compileTable } from './cells.mjs';
13
- import { MESSAGES } from './messages.mjs';
13
+ import { fill, MESSAGES } from './messages.mjs';
14
14
  import { saveProblemOf } from './data-source.mjs';
15
15
  import { hostScheduler } from './scheduler.mjs';
16
16
  import { createMoments, createRunner } from './run.mjs';
@@ -23,6 +23,8 @@ import { emptyValue, initialValues, isEmpty, lineKind, structuredCopy, } from '.
23
23
  export function createForm(options) {
24
24
  return innerForm(options, []).form;
25
25
  }
26
+ /** A line's cell whose line is no longer there. */
27
+ const gone = Symbol('gone');
26
28
  const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
27
29
  /** A saved form's answers, as the one value they are in the form round it. */
28
30
  const asValue = (values) => values;
@@ -108,6 +110,38 @@ function innerForm(options, within) {
108
110
  parts.set(node.id, node.source);
109
111
  /** Those given their page, with the inner form that holds their answers. */
110
112
  const attached = new Map();
113
+ /** How each box with text typed and not yet read reads it now (`settling`). */
114
+ const readers = new Set();
115
+ /** Whether each box that says so is still being typed in (`settling`'s `typing`): autosave waits for them. */
116
+ const typings = new Set();
117
+ /** A box of this form, or of a saved form placed on it, still being typed in. */
118
+ const typingNow = () => [...typings].some((typing) => typing()) || [...attached.values()].some((part) => part.inner.typing());
119
+ /** Readings of typed text still waiting, as readTyped started them. */
120
+ let readingNow = 0;
121
+ /**
122
+ * Every box's text read now, this form's and its saved forms' placed on the page, before it is checked or saved:
123
+ * what a reading that waits (a name looked up) is waiting for, or null when none does. Such a box keeps its text
124
+ * unread meanwhile, so a check made now refuses it; a save waits.
125
+ */
126
+ function readTyped() {
127
+ const waits = [];
128
+ for (const read of [...readers]) {
129
+ const wait = read();
130
+ if (wait && typeof wait.then === 'function')
131
+ waits.push(wait.catch(() => undefined));
132
+ }
133
+ for (const part of attached.values()) {
134
+ const inner = part.inner.form;
135
+ const wait = inner.settle();
136
+ if (inner.reading())
137
+ waits.push(wait);
138
+ }
139
+ if (!waits.length)
140
+ return null;
141
+ // Waiting on its own readings, or a saved form's: `reading` says so until they end.
142
+ readingNow++;
143
+ return Promise.all(waits).then(() => void readingNow--);
144
+ }
111
145
  /** The page's fields, and each saved form's answers as a JSON value: what is loaded, told as changed and saved. */
112
146
  const fields = { ...page.fields };
113
147
  for (const part of parts.values())
@@ -201,6 +235,7 @@ function innerForm(options, within) {
201
235
  saveProblem: null,
202
236
  choices: {},
203
237
  definitions: {},
238
+ unread: {},
204
239
  };
205
240
  started = true;
206
241
  /** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
@@ -313,8 +348,24 @@ function innerForm(options, within) {
313
348
  function checked(name) {
314
349
  const def = page.fields[name];
315
350
  const loaded = state.choices[name]?.options;
351
+ if (def.type === 'properties' && def.definitionsFrom) {
352
+ // The definitions the linked record keeps, once loaded: each property checked as one of its kind.
353
+ const definitions = state.definitions[name]?.definitions;
354
+ return definitions ? { ...def, definitions } : def;
355
+ }
316
356
  return def.type === 'selection' && def.optionsFrom && loaded ? { ...def, options: loaded, optionsFrom: undefined } : def;
317
357
  }
358
+ /** A properties field's problems, each at its property's path (`field.property`), as its row shows them. */
359
+ function propertyProblems(name) {
360
+ const def = checked(name);
361
+ if (def.type !== 'properties')
362
+ return {};
363
+ return Object.fromEntries(Object.entries(checkProperties(def.definitions, state.values[name], messages, today())).map(([property, message]) => [`${name}.${property}`, message]));
364
+ }
365
+ /** A field's own problem, less what its properties' rows say of themselves. */
366
+ function besidesRows(name, message) {
367
+ return message !== undefined && page.fields[name]?.type === 'properties' && Object.values(propertyProblems(name)).includes(message) ? undefined : message;
368
+ }
318
369
  function track(promise) {
319
370
  pending.add(promise);
320
371
  const done = () => pending.delete(promise);
@@ -363,8 +414,8 @@ function innerForm(options, within) {
363
414
  ...(node.pressed ? { pressed: node.pressed.evaluate(ctx, env) } : {}),
364
415
  };
365
416
  }
366
- /** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
367
- function visibleFieldNodes(root, kind = 'field') {
417
+ /** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now; `field`'s alone, when named. */
418
+ function visibleFieldNodes(root, kind = 'field', field) {
368
419
  const inside = (node) => {
369
420
  if (!root)
370
421
  return true;
@@ -382,7 +433,7 @@ function innerForm(options, within) {
382
433
  return true;
383
434
  return false;
384
435
  };
385
- return [...index.values()].filter((node) => node.kind === kind && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
436
+ return [...index.values()].filter((node) => node.kind === kind && (field === undefined || node.field === field) && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
386
437
  }
387
438
  /** What a field node's answer rules say of its value now: the first error and the first warning. */
388
439
  function ruleCheck(node) {
@@ -429,16 +480,52 @@ function innerForm(options, within) {
429
480
  state = previous;
430
481
  }
431
482
  }
483
+ /** A range's last day (`options.endField` of a `daterange`), the field it writes besides its own; null for any other node. */
484
+ function rangeEnd(node) {
485
+ const source = node.source;
486
+ const end = source.widget === 'daterange' ? source.options?.['endField'] : undefined;
487
+ return typeof end === 'string' && page.fields[end] && end !== node.field ? end : null;
488
+ }
489
+ /**
490
+ * A range's end, a field of its own though no node shows it alone: its required, min, max and days, and that it
491
+ * is not before its start ("Ends can’t be before Starts"). Required when its field is, or the range is.
492
+ */
493
+ function rangeEndError(node, end, required = nodeState(node.id).required) {
494
+ const def = page.fields[end];
495
+ const value = state.values[end];
496
+ const own = checkValue(checked(end), value, required || def.required === true, messages, today());
497
+ if (own)
498
+ return own;
499
+ const start = state.values[node.field];
500
+ if (typeof start !== 'string' || typeof value !== 'string' || !start || !value)
501
+ return undefined;
502
+ const at = (v) => (def.type === 'datetime' ? Date.parse(v) : Date.parse(v.slice(0, 10)));
503
+ if (at(value) < at(start))
504
+ return fill(messages.endBeforeStart, { end: def.label, start: page.fields[node.field].label });
505
+ return undefined;
506
+ }
432
507
  /** Errors for the visible fields under `root`, without publishing them. */
433
508
  function collectErrors(root) {
434
509
  const errors = {};
510
+ const seen = new Set();
435
511
  for (const node of visibleFieldNodes(root)) {
436
512
  const name = node.field;
513
+ seen.add(name);
514
+ const end = rangeEnd(node);
515
+ if (end) {
516
+ seen.add(end);
517
+ const problem = errors[end] ? undefined : rangeEndError(node, end);
518
+ if (problem)
519
+ errors[end] = problem;
520
+ }
437
521
  if (errors[name])
438
522
  continue;
439
523
  const def = page.fields[name];
440
524
  const message = nodeError(node);
441
- if (message)
525
+ // A property says its own problem on its row; the field, only what is its own (that it is required).
526
+ if (def.type === 'properties')
527
+ Object.assign(errors, propertyProblems(name));
528
+ if (besidesRows(name, message))
442
529
  errors[name] = message;
443
530
  if (def.type === 'one2many') {
444
531
  for (const line of state.values[name] ?? []) {
@@ -459,6 +546,10 @@ function innerForm(options, within) {
459
546
  }
460
547
  }
461
548
  }
549
+ // Text left in a box that does not read answers for its field, in the words it was left with.
550
+ for (const [path, unread] of Object.entries(state.unread))
551
+ if (seen.has(path.split('.')[0]))
552
+ errors[path] = unread.message;
462
553
  // A saved form placed here answers for its fields by its own page's rules.
463
554
  for (const node of visibleFieldNodes(root, 'form')) {
464
555
  const part = attached.get(node.id);
@@ -468,6 +559,63 @@ function innerForm(options, within) {
468
559
  }
469
560
  return errors;
470
561
  }
562
+ /** The value at a path: a field's, or a line's cell (`field.lineKey.subfield`); `gone` when the line is not there. */
563
+ function valueAt(values, path) {
564
+ const parts = path.split('.');
565
+ // A property of a properties field (`field.property`).
566
+ if (parts.length === 2 && page.fields[parts[0]]?.type === 'properties') {
567
+ const held = values[parts[0]];
568
+ return held && typeof held === 'object' && !Array.isArray(held) ? held[parts[1]] : undefined;
569
+ }
570
+ if (parts.length < 3)
571
+ return values[path];
572
+ const [field, sub] = [parts[0], parts[parts.length - 1]];
573
+ const key = parts.slice(1, -1).join('.');
574
+ const line = (values[field] ?? []).find((l) => l.key === key);
575
+ return line ? line.values[sub] : gone;
576
+ }
577
+ /** Unread text whose field has since been written another way — a value typed that reads, the app's — is let go. */
578
+ function unreadKept(values) {
579
+ let kept = null;
580
+ for (const path of Object.keys(state.unread)) {
581
+ const now = valueAt(values, path);
582
+ if (now !== gone && now === valueAt(state.values, path))
583
+ continue;
584
+ kept ??= { ...state.unread };
585
+ delete kept[path];
586
+ }
587
+ return kept ?? state.unread;
588
+ }
589
+ /** A field's own problem as its box is left, never that it is required: undefined when it has none, or is not shown. */
590
+ function leftProblem(path) {
591
+ const parts = path.split('.');
592
+ const field = parts[0];
593
+ const shown = visibleFieldNodes(undefined, 'field', field);
594
+ // A range's end is left from its own box in the range: its problems, that it is before its start too.
595
+ const range = shown.length ? undefined : visibleFieldNodes().find((node) => rangeEnd(node) === field);
596
+ if (!shown.length && !range)
597
+ return undefined;
598
+ if (state.unread[path])
599
+ return state.unread[path].message;
600
+ if (range)
601
+ return isEmpty(fieldDef(field), state.values[field]) ? undefined : rangeEndError(range, field, false);
602
+ if (parts.length === 1) {
603
+ const value = state.values[field];
604
+ if (isEmpty(fieldDef(field), value))
605
+ return undefined;
606
+ return (shown.map((node) => checkInput(page.fields[field], node.source, value, messages)).find(Boolean) ??
607
+ besidesRows(field, checkValue(checked(field), value, false, messages, today())) ??
608
+ shown.map((node) => ruleCheck(node).error).find(Boolean));
609
+ }
610
+ if (parts.length === 2 && page.fields[field]?.type === 'properties')
611
+ return propertyProblems(field)[path];
612
+ const def = lineField(field);
613
+ const sub = def.fields[parts[parts.length - 1]];
614
+ const value = valueAt(state.values, path);
615
+ if (!sub || value === gone || isEmpty(sub, value))
616
+ return undefined;
617
+ return checkValue(sub, value, false, messages, today());
618
+ }
471
619
  /**
472
620
  * Answers changed from outside a saved form — loaded, put back, set — handed
473
621
  * to it, and what it holds after taken back: its defaults and worked-out
@@ -514,26 +662,32 @@ function innerForm(options, within) {
514
662
  /** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
515
663
  function writeValues(written, extra = {}, fresh = false) {
516
664
  const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
517
- let errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
518
- // Words typed where a number goes are said at once, as Flectra marks a box it cannot read; what is missing waits for sending.
519
- const unread = {};
665
+ // Text left unread in a box goes once its field is written another way, and its words with it.
666
+ const unread = unreadKept(values);
667
+ const before = state;
668
+ state = { ...state, unread };
669
+ let errors = Object.keys(before.errors).length ? revalidateShown(values) : before.errors;
670
+ state = before;
671
+ // Words where a number goes — from a server or an old draft: a box never sets them — are said at once; what is
672
+ // missing waits for sending.
673
+ const words = {};
520
674
  for (const name of Object.keys(written)) {
521
675
  const def = page.fields[name];
522
676
  if (written[name] === state.values[name] || !def)
523
677
  continue;
524
678
  if (notANumber(def, written[name]))
525
- unread[name] = checkValue(def, written[name], false, messages);
679
+ words[name] = checkValue(def, written[name], false, messages);
526
680
  if (def.type !== 'one2many')
527
681
  continue;
528
682
  for (const line of written[name] ?? []) {
529
683
  for (const [sub, v] of Object.entries(line.values))
530
684
  if (notANumber(def.fields[sub], v))
531
- unread[`${name}.${line.key}.${sub}`] = checkValue(def.fields[sub], v, false, messages);
685
+ words[`${name}.${line.key}.${sub}`] = checkValue(def.fields[sub], v, false, messages);
532
686
  }
533
687
  }
534
- if (Object.keys(unread).length)
535
- errors = { ...errors, ...unread };
536
- set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
688
+ if (Object.keys(words).length)
689
+ errors = { ...errors, ...words };
690
+ set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), unread, ...extra });
537
691
  }
538
692
  /** Once errors are on screen, keep them current as the person fixes things. */
539
693
  function revalidateShown(values) {
@@ -595,6 +749,10 @@ function innerForm(options, within) {
595
749
  scheduler.clearTimeout(autosaveTimer);
596
750
  autosaveTimer = scheduler.setTimeout(() => {
597
751
  autosaveTimer = null;
752
+ // Nobody pressed anything: a box still typed in is never read under the person's keys. The save waits until it is
753
+ // left (or Enter is pressed in it), and reads it then, as any save does.
754
+ if (typingNow())
755
+ return scheduleAutosave();
598
756
  if (state.dirty.length && Object.keys(collectErrors()).length === 0)
599
757
  void track(save());
600
758
  }, autosave.delayMs);
@@ -665,6 +823,11 @@ function innerForm(options, within) {
665
823
  * and a stop keeps it unsaved; `afterSave` runs once it is saved.
666
824
  */
667
825
  async function saving(chain) {
826
+ // What is still typed in a box is read first: it is what is saved, or why nothing is. A name still being looked
827
+ // up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
828
+ const waiting = readTyped();
829
+ if (waiting)
830
+ await waiting;
668
831
  if (page.on?.beforeSave) {
669
832
  const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
670
833
  if (!before.done) {
@@ -709,7 +872,7 @@ function innerForm(options, within) {
709
872
  // Lines the save brings back load again, in their tables' orders, as Flectra's reload after a save.
710
873
  const values = syncParts(startFrom(result.values ? ordered({ ...state.values, ...result.values }, Object.keys(result.values)) : state.values), 'start');
711
874
  baseline = structuredCopy(values);
712
- set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
875
+ set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings), unread: {} });
713
876
  emit('save', { recordId: state.recordId, values: state.values });
714
877
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
715
878
  return null;
@@ -783,9 +946,15 @@ function innerForm(options, within) {
783
946
  changed(field, by);
784
947
  return key;
785
948
  }
786
- /** Check the form, or only these fields, showing what is found as sending does. */
787
- function check(names) {
949
+ /**
950
+ * Check the form, or only these fields, showing what is found as sending does: a step's check, which waits for a
951
+ * name still being looked up in a box, as a save does.
952
+ */
953
+ async function check(names) {
788
954
  names?.forEach(fieldDef);
955
+ const waiting = readTyped();
956
+ if (waiting)
957
+ await waiting;
789
958
  const all = collectErrors();
790
959
  const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
791
960
  set({ errors: found });
@@ -793,11 +962,15 @@ function innerForm(options, within) {
793
962
  return first === undefined ? null : { reason: 'check', message: first };
794
963
  }
795
964
  /** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
796
- function goToStep(target, chain) {
965
+ async function goToStep(target, chain) {
797
966
  if (!steps.includes(target))
798
967
  return undefined;
799
968
  if (!form.steps().includes(target))
800
969
  return { reason: 'cannot', message: `The step "${target}" does not apply now` };
970
+ // A name still being looked up in a box on the way is waited for, as a save waits for it.
971
+ const waiting = readTyped();
972
+ if (waiting)
973
+ await waiting;
801
974
  let went = false;
802
975
  inRun(chain, () => (went = form.goTo(target)));
803
976
  return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
@@ -869,7 +1042,7 @@ function innerForm(options, within) {
869
1042
  return;
870
1043
  // Lines load in their tables' orders; where a person moves them after, they stay.
871
1044
  baseline = syncParts(startFrom(ordered({ ...initialValues(fields, today()), ...loaded })), 'start');
872
- set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
1045
+ set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), unread: {} });
873
1046
  if (again)
874
1047
  return;
875
1048
  offerDraft();
@@ -891,7 +1064,7 @@ function innerForm(options, within) {
891
1064
  serverErrors = {};
892
1065
  told = null;
893
1066
  showing = false;
894
- const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null };
1067
+ const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} };
895
1068
  if (id != null) {
896
1069
  set({ ...fresh, recordId: id, status: 'idle' });
897
1070
  return load();
@@ -1022,12 +1195,65 @@ function innerForm(options, within) {
1022
1195
  const shown = visibleFieldNodes().filter((node) => node.field === name);
1023
1196
  if (!shown.length)
1024
1197
  return null;
1198
+ if (state.unread[name])
1199
+ return state.unread[name].message;
1200
+ // A property's text left unread answers for its field.
1201
+ if (page.fields[name].type === 'properties') {
1202
+ const row = Object.keys(state.unread).find((path) => path.startsWith(`${name}.`));
1203
+ if (row)
1204
+ return state.unread[row].message;
1205
+ }
1025
1206
  const own = shown.map(unticked).find(Boolean) ??
1026
1207
  shown.map((node) => checkInput(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
1027
1208
  checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
1028
1209
  return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
1029
1210
  },
1211
+ setUnread(path, unread) {
1212
+ const field = path.split('.')[0];
1213
+ // A line's cell (`field.lineKey.subfield`), or a property (`field.property`).
1214
+ const def = fieldDef(field);
1215
+ if (path.includes('.') && def.type !== 'properties')
1216
+ lineField(field);
1217
+ const had = state.unread[path];
1218
+ if (!unread && !had)
1219
+ return;
1220
+ // A line taken away takes its cells' text with it: a box of it still reading (a name looked up) keeps none.
1221
+ if (unread && !had && path.split('.').length >= 3 && def.type !== 'properties' && valueAt(state.values, path) === gone)
1222
+ return;
1223
+ if (unread && had && had.text === unread.text && had.message === unread.message)
1224
+ return;
1225
+ const next = { ...state.unread };
1226
+ if (unread)
1227
+ next[path] = { text: unread.text, message: unread.message };
1228
+ else
1229
+ delete next[path];
1230
+ // Problems on screen stay current: an unread text put right takes its words with it.
1231
+ const before = state;
1232
+ state = { ...state, unread: next };
1233
+ const errors = Object.keys(before.errors).length ? revalidateShown(state.values) : before.errors;
1234
+ state = before;
1235
+ set({ unread: next, errors });
1236
+ },
1237
+ leave(path) {
1238
+ const problem = leftProblem(path);
1239
+ if (!problem || state.errors[path] === problem)
1240
+ return;
1241
+ set({ errors: { ...state.errors, [path]: problem } });
1242
+ },
1243
+ settling(read, typing) {
1244
+ readers.add(read);
1245
+ if (typing)
1246
+ typings.add(typing);
1247
+ return () => {
1248
+ readers.delete(read);
1249
+ if (typing)
1250
+ typings.delete(typing);
1251
+ };
1252
+ },
1253
+ settle: () => readTyped() ?? Promise.resolve(),
1254
+ reading: () => readingNow > 0 || [...attached.values()].some((part) => part.inner.form.reading()),
1030
1255
  validate() {
1256
+ readTyped();
1031
1257
  const errors = collectErrors();
1032
1258
  set({ errors });
1033
1259
  return Object.keys(errors).length === 0;
@@ -1038,7 +1264,7 @@ function innerForm(options, within) {
1038
1264
  serverErrors = {};
1039
1265
  holding = setWhen.holding(baseline);
1040
1266
  const values = syncParts(structuredCopy(baseline), 'start');
1041
- set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
1267
+ set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} });
1042
1268
  },
1043
1269
  changes,
1044
1270
  addLine: (field, values = {}) => addLine(field, values, 'person'),
@@ -1133,6 +1359,7 @@ function innerForm(options, within) {
1133
1359
  next() {
1134
1360
  if (!state.step)
1135
1361
  return false;
1362
+ readTyped();
1136
1363
  // A step skipped before and gone back to is answered now, and checked like any other.
1137
1364
  if (state.skipped.includes(state.step))
1138
1365
  set({ skipped: state.skipped.filter((id) => id !== state.step) });
@@ -1179,6 +1406,7 @@ function innerForm(options, within) {
1179
1406
  set({ step: target, errors: {} });
1180
1407
  return true;
1181
1408
  }
1409
+ readTyped();
1182
1410
  const skipped = [...state.skipped];
1183
1411
  for (const id of visible.slice(at, to)) {
1184
1412
  if (skipped.includes(id))
@@ -1381,9 +1609,10 @@ function innerForm(options, within) {
1381
1609
  return writeValues({ ...initialValues(page.fields, today()), ...values }, {}, how === 'fresh');
1382
1610
  baseline = startFrom({ ...initialValues(page.fields, today()), ...structuredCopy(values) });
1383
1611
  const start = syncParts(structuredCopy(baseline), 'start');
1384
- set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null });
1612
+ set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null, unread: {} });
1385
1613
  },
1386
1614
  errorsNow: () => collectErrors(),
1615
+ typing: typingNow,
1387
1616
  show(errors) {
1388
1617
  if (JSON.stringify(errors) !== JSON.stringify(state.errors))
1389
1618
  set({ errors });