@fieldia/core 0.19.1 → 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 (156) hide show
  1. package/package.json +1 -1
  2. package/schema/page.schema.json +946 -210
  3. package/src/index.d.mts +2 -1
  4. package/src/index.d.ts +2 -1
  5. package/src/index.js +5 -1
  6. package/src/index.js.map +1 -1
  7. package/src/index.mjs +2 -1
  8. package/src/index.mjs.map +1 -1
  9. package/src/lib/expression/parser.js +4 -4
  10. package/src/lib/expression/parser.js.map +1 -1
  11. package/src/lib/expression/parser.mjs +5 -5
  12. package/src/lib/expression/parser.mjs.map +1 -1
  13. package/src/lib/expression/tokenizer.d.mts +12 -0
  14. package/src/lib/expression/tokenizer.d.ts +12 -0
  15. package/src/lib/expression/tokenizer.js +21 -12
  16. package/src/lib/expression/tokenizer.js.map +1 -1
  17. package/src/lib/expression/tokenizer.mjs +20 -12
  18. package/src/lib/expression/tokenizer.mjs.map +1 -1
  19. package/src/lib/format/field.d.mts +16 -0
  20. package/src/lib/format/field.d.ts +16 -0
  21. package/src/lib/format/field.js +11 -1
  22. package/src/lib/format/field.js.map +1 -1
  23. package/src/lib/format/field.mjs +11 -1
  24. package/src/lib/format/field.mjs.map +1 -1
  25. package/src/lib/format/head-checks.d.mts +40 -0
  26. package/src/lib/format/head-checks.d.ts +40 -0
  27. package/src/lib/format/head-checks.js +172 -0
  28. package/src/lib/format/head-checks.js.map +1 -0
  29. package/src/lib/format/head-checks.mjs +167 -0
  30. package/src/lib/format/head-checks.mjs.map +1 -0
  31. package/src/lib/format/layout.d.mts +6485 -1447
  32. package/src/lib/format/layout.d.ts +6485 -1447
  33. package/src/lib/format/layout.js +106 -2
  34. package/src/lib/format/layout.js.map +1 -1
  35. package/src/lib/format/layout.mjs +105 -1
  36. package/src/lib/format/layout.mjs.map +1 -1
  37. package/src/lib/format/layout.types.d.mts +7 -1
  38. package/src/lib/format/layout.types.d.ts +7 -1
  39. package/src/lib/format/page.d.mts +1502 -162
  40. package/src/lib/format/page.d.ts +1502 -162
  41. package/src/lib/format/page.js +3 -0
  42. package/src/lib/format/page.js.map +1 -1
  43. package/src/lib/format/page.mjs +3 -0
  44. package/src/lib/format/page.mjs.map +1 -1
  45. package/src/lib/format/pattern.d.mts +10 -0
  46. package/src/lib/format/pattern.d.ts +10 -0
  47. package/src/lib/format/pattern.js +33 -0
  48. package/src/lib/format/pattern.js.map +1 -0
  49. package/src/lib/format/pattern.mjs +30 -0
  50. package/src/lib/format/pattern.mjs.map +1 -0
  51. package/src/lib/format/picker-options.d.mts +22 -0
  52. package/src/lib/format/picker-options.d.ts +22 -0
  53. package/src/lib/format/picker-options.js +50 -0
  54. package/src/lib/format/picker-options.js.map +1 -0
  55. package/src/lib/format/picker-options.mjs +46 -0
  56. package/src/lib/format/picker-options.mjs.map +1 -0
  57. package/src/lib/format/references.d.mts +9 -0
  58. package/src/lib/format/references.d.ts +9 -0
  59. package/src/lib/format/references.js +96 -1
  60. package/src/lib/format/references.js.map +1 -1
  61. package/src/lib/format/references.mjs +96 -1
  62. package/src/lib/format/references.mjs.map +1 -1
  63. package/src/lib/format/section-checks.d.mts +10 -0
  64. package/src/lib/format/section-checks.d.ts +10 -0
  65. package/src/lib/format/section-checks.js +35 -0
  66. package/src/lib/format/section-checks.js.map +1 -0
  67. package/src/lib/format/section-checks.mjs +31 -0
  68. package/src/lib/format/section-checks.mjs.map +1 -0
  69. package/src/lib/format/translate.js +14 -4
  70. package/src/lib/format/translate.js.map +1 -1
  71. package/src/lib/format/translate.mjs +14 -4
  72. package/src/lib/format/translate.mjs.map +1 -1
  73. package/src/lib/record/check.d.mts +13 -3
  74. package/src/lib/record/check.d.ts +13 -3
  75. package/src/lib/record/check.js +87 -17
  76. package/src/lib/record/check.js.map +1 -1
  77. package/src/lib/record/check.mjs +85 -17
  78. package/src/lib/record/check.mjs.map +1 -1
  79. package/src/lib/record/filter.js +14 -2
  80. package/src/lib/record/filter.js.map +1 -1
  81. package/src/lib/record/filter.mjs +14 -2
  82. package/src/lib/record/filter.mjs.map +1 -1
  83. package/src/lib/record/form.d.mts +56 -2
  84. package/src/lib/record/form.d.ts +56 -2
  85. package/src/lib/record/form.js +293 -26
  86. package/src/lib/record/form.js.map +1 -1
  87. package/src/lib/record/form.mjs +295 -28
  88. package/src/lib/record/form.mjs.map +1 -1
  89. package/src/lib/record/input-rules.d.mts +277 -0
  90. package/src/lib/record/input-rules.d.ts +277 -0
  91. package/src/lib/record/input-rules.js +724 -0
  92. package/src/lib/record/input-rules.js.map +1 -0
  93. package/src/lib/record/input-rules.mjs +705 -0
  94. package/src/lib/record/input-rules.mjs.map +1 -0
  95. package/src/lib/record/inputs.d.mts +25 -0
  96. package/src/lib/record/inputs.d.ts +25 -0
  97. package/src/lib/record/inputs.js +135 -9
  98. package/src/lib/record/inputs.js.map +1 -1
  99. package/src/lib/record/inputs.mjs +131 -9
  100. package/src/lib/record/inputs.mjs.map +1 -1
  101. package/src/lib/record/locales/ar.js +13 -2
  102. package/src/lib/record/locales/ar.js.map +1 -1
  103. package/src/lib/record/locales/ar.mjs +13 -2
  104. package/src/lib/record/locales/ar.mjs.map +1 -1
  105. package/src/lib/record/locales/de.js +13 -2
  106. package/src/lib/record/locales/de.js.map +1 -1
  107. package/src/lib/record/locales/de.mjs +13 -2
  108. package/src/lib/record/locales/de.mjs.map +1 -1
  109. package/src/lib/record/locales/fr.js +13 -2
  110. package/src/lib/record/locales/fr.js.map +1 -1
  111. package/src/lib/record/locales/fr.mjs +13 -2
  112. package/src/lib/record/locales/fr.mjs.map +1 -1
  113. package/src/lib/record/memory-data-source.d.mts +7 -0
  114. package/src/lib/record/memory-data-source.d.ts +7 -0
  115. package/src/lib/record/memory-data-source.js +24 -2
  116. package/src/lib/record/memory-data-source.js.map +1 -1
  117. package/src/lib/record/memory-data-source.mjs +24 -2
  118. package/src/lib/record/memory-data-source.mjs.map +1 -1
  119. package/src/lib/record/messages.d.mts +22 -0
  120. package/src/lib/record/messages.d.ts +22 -0
  121. package/src/lib/record/messages.js +13 -2
  122. package/src/lib/record/messages.js.map +1 -1
  123. package/src/lib/record/messages.mjs +13 -2
  124. package/src/lib/record/messages.mjs.map +1 -1
  125. package/src/lib/record/rules.js +3 -1
  126. package/src/lib/record/rules.js.map +1 -1
  127. package/src/lib/record/rules.mjs +3 -1
  128. package/src/lib/record/rules.mjs.map +1 -1
  129. package/src/lib/record/run.d.mts +10 -2
  130. package/src/lib/record/run.d.ts +10 -2
  131. package/src/lib/record/run.js +8 -1
  132. package/src/lib/record/run.js.map +1 -1
  133. package/src/lib/record/run.mjs +8 -1
  134. package/src/lib/record/run.mjs.map +1 -1
  135. package/src/lib/record/search.d.mts +8 -1
  136. package/src/lib/record/search.d.ts +8 -1
  137. package/src/lib/record/search.js +28 -8
  138. package/src/lib/record/search.js.map +1 -1
  139. package/src/lib/record/search.mjs +28 -8
  140. package/src/lib/record/search.mjs.map +1 -1
  141. package/src/lib/record/structures.d.mts +8 -3
  142. package/src/lib/record/structures.d.ts +8 -3
  143. package/src/lib/record/structures.js +51 -7
  144. package/src/lib/record/structures.js.map +1 -1
  145. package/src/lib/record/structures.mjs +48 -6
  146. package/src/lib/record/structures.mjs.map +1 -1
  147. package/src/lib/record/values.d.mts +4 -0
  148. package/src/lib/record/values.d.ts +4 -0
  149. package/src/lib/record/values.js.map +1 -1
  150. package/src/lib/record/values.mjs.map +1 -1
  151. package/src/runtime.d.mts +7 -3
  152. package/src/runtime.d.ts +7 -3
  153. package/src/runtime.js +31 -1
  154. package/src/runtime.js.map +1 -1
  155. package/src/runtime.mjs +5 -2
  156. 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);
@@ -360,10 +411,11 @@ function innerForm(options, within) {
360
411
  invisible,
361
412
  readonly: readonly || (def !== undefined && lockedField(def)),
362
413
  required: node.required.evaluate(ctx, env) || def?.required === true,
414
+ ...(node.pressed ? { pressed: node.pressed.evaluate(ctx, env) } : {}),
363
415
  };
364
416
  }
365
- /** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
366
- 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) {
367
419
  const inside = (node) => {
368
420
  if (!root)
369
421
  return true;
@@ -381,7 +433,7 @@ function innerForm(options, within) {
381
433
  return true;
382
434
  return false;
383
435
  };
384
- 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);
385
437
  }
386
438
  /** What a field node's answer rules say of its value now: the first error and the first warning. */
387
439
  function ruleCheck(node) {
@@ -428,16 +480,52 @@ function innerForm(options, within) {
428
480
  state = previous;
429
481
  }
430
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
+ }
431
507
  /** Errors for the visible fields under `root`, without publishing them. */
432
508
  function collectErrors(root) {
433
509
  const errors = {};
510
+ const seen = new Set();
434
511
  for (const node of visibleFieldNodes(root)) {
435
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
+ }
436
521
  if (errors[name])
437
522
  continue;
438
523
  const def = page.fields[name];
439
524
  const message = nodeError(node);
440
- 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))
441
529
  errors[name] = message;
442
530
  if (def.type === 'one2many') {
443
531
  for (const line of state.values[name] ?? []) {
@@ -458,6 +546,10 @@ function innerForm(options, within) {
458
546
  }
459
547
  }
460
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;
461
553
  // A saved form placed here answers for its fields by its own page's rules.
462
554
  for (const node of visibleFieldNodes(root, 'form')) {
463
555
  const part = attached.get(node.id);
@@ -467,6 +559,63 @@ function innerForm(options, within) {
467
559
  }
468
560
  return errors;
469
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
+ }
470
619
  /**
471
620
  * Answers changed from outside a saved form — loaded, put back, set — handed
472
621
  * to it, and what it holds after taken back: its defaults and worked-out
@@ -513,26 +662,32 @@ function innerForm(options, within) {
513
662
  /** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
514
663
  function writeValues(written, extra = {}, fresh = false) {
515
664
  const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
516
- let errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
517
- // Words typed where a number goes are said at once, as Flectra marks a box it cannot read; what is missing waits for sending.
518
- 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 = {};
519
674
  for (const name of Object.keys(written)) {
520
675
  const def = page.fields[name];
521
676
  if (written[name] === state.values[name] || !def)
522
677
  continue;
523
678
  if (notANumber(def, written[name]))
524
- unread[name] = checkValue(def, written[name], false, messages);
679
+ words[name] = checkValue(def, written[name], false, messages);
525
680
  if (def.type !== 'one2many')
526
681
  continue;
527
682
  for (const line of written[name] ?? []) {
528
683
  for (const [sub, v] of Object.entries(line.values))
529
684
  if (notANumber(def.fields[sub], v))
530
- 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);
531
686
  }
532
687
  }
533
- if (Object.keys(unread).length)
534
- errors = { ...errors, ...unread };
535
- 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 });
536
691
  }
537
692
  /** Once errors are on screen, keep them current as the person fixes things. */
538
693
  function revalidateShown(values) {
@@ -594,6 +749,10 @@ function innerForm(options, within) {
594
749
  scheduler.clearTimeout(autosaveTimer);
595
750
  autosaveTimer = scheduler.setTimeout(() => {
596
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();
597
756
  if (state.dirty.length && Object.keys(collectErrors()).length === 0)
598
757
  void track(save());
599
758
  }, autosave.delayMs);
@@ -664,6 +823,11 @@ function innerForm(options, within) {
664
823
  * and a stop keeps it unsaved; `afterSave` runs once it is saved.
665
824
  */
666
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;
667
831
  if (page.on?.beforeSave) {
668
832
  const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
669
833
  if (!before.done) {
@@ -708,7 +872,7 @@ function innerForm(options, within) {
708
872
  // Lines the save brings back load again, in their tables' orders, as Flectra's reload after a save.
709
873
  const values = syncParts(startFrom(result.values ? ordered({ ...state.values, ...result.values }, Object.keys(result.values)) : state.values), 'start');
710
874
  baseline = structuredCopy(values);
711
- 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: {} });
712
876
  emit('save', { recordId: state.recordId, values: state.values });
713
877
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
714
878
  return null;
@@ -728,6 +892,23 @@ function innerForm(options, within) {
728
892
  showing = true;
729
893
  tellStep([]);
730
894
  }
895
+ /**
896
+ * Values the record holds where it is kept, written as loaded ones are: the
897
+ * starting point moves with them, so they are no change to save, and no
898
+ * change steps, draft or autosave follow. A change of the person's in the
899
+ * same field is taken over by them.
900
+ */
901
+ function store(written) {
902
+ const names = Object.keys(written);
903
+ names.forEach(fieldDef);
904
+ if (!names.length)
905
+ return;
906
+ const copy = structuredCopy(written);
907
+ baseline = { ...baseline, ...copy };
908
+ writeValues({ ...state.values, ...copy });
909
+ for (const name of names)
910
+ changed(name, 'app');
911
+ }
731
912
  /** Write fields — a step's or the app's — worked out once, and tell each. */
732
913
  function write(written, by) {
733
914
  const names = Object.keys(written);
@@ -765,9 +946,15 @@ function innerForm(options, within) {
765
946
  changed(field, by);
766
947
  return key;
767
948
  }
768
- /** Check the form, or only these fields, showing what is found as sending does. */
769
- 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) {
770
954
  names?.forEach(fieldDef);
955
+ const waiting = readTyped();
956
+ if (waiting)
957
+ await waiting;
771
958
  const all = collectErrors();
772
959
  const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
773
960
  set({ errors: found });
@@ -775,11 +962,15 @@ function innerForm(options, within) {
775
962
  return first === undefined ? null : { reason: 'check', message: first };
776
963
  }
777
964
  /** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
778
- function goToStep(target, chain) {
965
+ async function goToStep(target, chain) {
779
966
  if (!steps.includes(target))
780
967
  return undefined;
781
968
  if (!form.steps().includes(target))
782
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;
783
974
  let went = false;
784
975
  inRun(chain, () => (went = form.goTo(target)));
785
976
  return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
@@ -804,18 +995,20 @@ function innerForm(options, within) {
804
995
  return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
805
996
  }
806
997
  async function runSearch(def, name, ctx, query, limit, model, ids) {
807
- if (def.type !== 'many2one' && def.type !== 'many2many' && def.type !== 'reference' && def.type !== 'json') {
808
- throw new Error(`"${name}" is not a many2one, many2many, reference or json`);
998
+ // Formatted text names people and records in place (@mention, #4127): searched by the model they are of.
999
+ const byModel = def.type === 'reference' || def.type === 'json' || def.type === 'html';
1000
+ if (def.type !== 'many2one' && def.type !== 'many2many' && !byModel) {
1001
+ throw new Error(`"${name}" is not a many2one, many2many, reference, json or html`);
809
1002
  }
810
- if ((def.type === 'reference' || def.type === 'json') && !model)
1003
+ if (byModel && !model)
811
1004
  throw new Error(`Searching "${name}" needs a model`);
812
1005
  const source = options.dataSource;
813
1006
  if (!source?.search)
814
1007
  return [];
815
1008
  // Every valueFrom, in groups too, becomes the value it names before the search goes out.
816
- const own = def.type === 'reference' || def.type === 'json' || !def.filter ? undefined : resolveFilter(def.filter, ctx);
1009
+ const own = byModel || !def.filter ? undefined : resolveFilter(def.filter, ctx);
817
1010
  const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
818
- const relation = def.type === 'reference' || def.type === 'json' ? model : def.relation;
1011
+ const relation = byModel ? model : def.relation;
819
1012
  return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
820
1013
  }
821
1014
  function lineField(field) {
@@ -849,7 +1042,7 @@ function innerForm(options, within) {
849
1042
  return;
850
1043
  // Lines load in their tables' orders; where a person moves them after, they stay.
851
1044
  baseline = syncParts(startFrom(ordered({ ...initialValues(fields, today()), ...loaded })), 'start');
852
- 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: {} });
853
1046
  if (again)
854
1047
  return;
855
1048
  offerDraft();
@@ -871,7 +1064,7 @@ function innerForm(options, within) {
871
1064
  serverErrors = {};
872
1065
  told = null;
873
1066
  showing = false;
874
- 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: {} };
875
1068
  if (id != null) {
876
1069
  set({ ...fresh, recordId: id, status: 'idle' });
877
1070
  return load();
@@ -906,6 +1099,7 @@ function innerForm(options, within) {
906
1099
  reading,
907
1100
  today,
908
1101
  write: (values) => write(values, 'step'),
1102
+ store: (values) => store(values),
909
1103
  addLine: (field, values) => void addLine(field, values, 'step'),
910
1104
  check,
911
1105
  save: saving,
@@ -980,7 +1174,7 @@ function innerForm(options, within) {
980
1174
  afterEdit(name);
981
1175
  changed(name, how?.by ?? 'person');
982
1176
  },
983
- setValues: (values) => write(values, 'app'),
1177
+ setValues: (values, how) => (how?.stored ? store(values) : write(values, 'app')),
984
1178
  node: nodeState,
985
1179
  openRecord: (id) => track(openRecord(id)),
986
1180
  post,
@@ -1001,12 +1195,65 @@ function innerForm(options, within) {
1001
1195
  const shown = visibleFieldNodes().filter((node) => node.field === name);
1002
1196
  if (!shown.length)
1003
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
+ }
1004
1206
  const own = shown.map(unticked).find(Boolean) ??
1005
1207
  shown.map((node) => checkInput(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
1006
1208
  checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
1007
1209
  return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
1008
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()),
1009
1255
  validate() {
1256
+ readTyped();
1010
1257
  const errors = collectErrors();
1011
1258
  set({ errors });
1012
1259
  return Object.keys(errors).length === 0;
@@ -1017,7 +1264,7 @@ function innerForm(options, within) {
1017
1264
  serverErrors = {};
1018
1265
  holding = setWhen.holding(baseline);
1019
1266
  const values = syncParts(structuredCopy(baseline), 'start');
1020
- 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: {} });
1021
1268
  },
1022
1269
  changes,
1023
1270
  addLine: (field, values = {}) => addLine(field, values, 'person'),
@@ -1112,6 +1359,7 @@ function innerForm(options, within) {
1112
1359
  next() {
1113
1360
  if (!state.step)
1114
1361
  return false;
1362
+ readTyped();
1115
1363
  // A step skipped before and gone back to is answered now, and checked like any other.
1116
1364
  if (state.skipped.includes(state.step))
1117
1365
  set({ skipped: state.skipped.filter((id) => id !== state.step) });
@@ -1158,6 +1406,7 @@ function innerForm(options, within) {
1158
1406
  set({ step: target, errors: {} });
1159
1407
  return true;
1160
1408
  }
1409
+ readTyped();
1161
1410
  const skipped = [...state.skipped];
1162
1411
  for (const id of visible.slice(at, to)) {
1163
1412
  if (skipped.includes(id))
@@ -1360,9 +1609,10 @@ function innerForm(options, within) {
1360
1609
  return writeValues({ ...initialValues(page.fields, today()), ...values }, {}, how === 'fresh');
1361
1610
  baseline = startFrom({ ...initialValues(page.fields, today()), ...structuredCopy(values) });
1362
1611
  const start = syncParts(structuredCopy(baseline), 'start');
1363
- 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: {} });
1364
1613
  },
1365
1614
  errorsNow: () => collectErrors(),
1615
+ typing: typingNow,
1366
1616
  show(errors) {
1367
1617
  if (JSON.stringify(errors) !== JSON.stringify(state.errors))
1368
1618
  set({ errors });
@@ -1439,6 +1689,7 @@ function indexLayout(root) {
1439
1689
  const index = new Map();
1440
1690
  const add = (source, parent, kind, modifiers) => {
1441
1691
  const roles = source.roles;
1692
+ const pressed = kind === 'button' ? source.pressed : undefined;
1442
1693
  index.set(source.id, {
1443
1694
  id: source.id,
1444
1695
  parent,
@@ -1448,8 +1699,13 @@ function indexLayout(root) {
1448
1699
  invisible: compileModifier(modifiers.invisible),
1449
1700
  readonly: compileModifier(modifiers.readonly),
1450
1701
  required: compileModifier(modifiers.required),
1702
+ ...(pressed !== undefined ? { pressed: compileModifier(pressed) } : {}),
1451
1703
  source,
1452
1704
  });
1705
+ // A button's menu: each item a button of the record, hidden with it.
1706
+ if (kind === 'button')
1707
+ for (const item of source.menu ?? [])
1708
+ add(item, source.id, 'button', { invisible: item.invisible });
1453
1709
  };
1454
1710
  const walk = (nodes, parent) => {
1455
1711
  for (const node of nodes) {
@@ -1533,6 +1789,17 @@ function indexLayout(root) {
1533
1789
  // Fields over and under the title, and on its line, are field nodes like any other.
1534
1790
  walk([...(root.title?.above ?? []), ...(root.title?.before ?? []), ...(root.title?.after ?? []), ...(root.title?.below ?? [])], root.id);
1535
1791
  walk(root.children, root.id);
1792
+ // The head's row, buttons and band: its fields are field nodes, its link and buttons buttons of the record.
1793
+ const head = root.head;
1794
+ for (const part of head?.meta ?? []) {
1795
+ if (part.type === 'field')
1796
+ walk([part], root.id);
1797
+ else
1798
+ add(part, root.id, 'button', { invisible: part.invisible });
1799
+ }
1800
+ for (const button of head?.actions ?? [])
1801
+ add(button, root.id, 'button', { invisible: button.invisible });
1802
+ walk(head?.band?.children ?? [], root.id);
1536
1803
  if (root.sidePanel)
1537
1804
  add(root.sidePanel, root.id, 'other', { invisible: root.sidePanel.invisible });
1538
1805
  // The gear menu's items are buttons of the record; Archive shows while it is active, Unarchive while it is not.