@fieldia/core 0.22.0 → 0.24.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 (177) hide show
  1. package/README.md +135 -3
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +159 -4
  4. package/src/index.d.mts +6 -2
  5. package/src/index.d.ts +6 -2
  6. package/src/index.js +18 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +8 -2
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/format/autofill-group.d.mts +12 -0
  11. package/src/lib/format/autofill-group.d.ts +12 -0
  12. package/src/lib/format/autofill-group.js +23 -0
  13. package/src/lib/format/autofill-group.js.map +1 -0
  14. package/src/lib/format/autofill-group.mjs +19 -0
  15. package/src/lib/format/autofill-group.mjs.map +1 -0
  16. package/src/lib/format/autofill.d.mts +14 -0
  17. package/src/lib/format/autofill.d.ts +14 -0
  18. package/src/lib/format/autofill.js +54 -0
  19. package/src/lib/format/autofill.js.map +1 -0
  20. package/src/lib/format/autofill.mjs +48 -0
  21. package/src/lib/format/autofill.mjs.map +1 -0
  22. package/src/lib/format/bulk-edit.d.mts +18 -0
  23. package/src/lib/format/bulk-edit.d.ts +18 -0
  24. package/src/lib/format/bulk-edit.js +26 -0
  25. package/src/lib/format/bulk-edit.js.map +1 -0
  26. package/src/lib/format/bulk-edit.mjs +21 -0
  27. package/src/lib/format/bulk-edit.mjs.map +1 -0
  28. package/src/lib/format/field.d.mts +55 -34
  29. package/src/lib/format/field.d.ts +55 -34
  30. package/src/lib/format/field.js +16 -2
  31. package/src/lib/format/field.js.map +1 -1
  32. package/src/lib/format/field.mjs +15 -1
  33. package/src/lib/format/field.mjs.map +1 -1
  34. package/src/lib/format/layout.d.mts +365 -66
  35. package/src/lib/format/layout.d.ts +365 -66
  36. package/src/lib/format/layout.js +22 -4
  37. package/src/lib/format/layout.js.map +1 -1
  38. package/src/lib/format/layout.mjs +20 -3
  39. package/src/lib/format/layout.mjs.map +1 -1
  40. package/src/lib/format/page.d.mts +169 -29
  41. package/src/lib/format/page.d.ts +169 -29
  42. package/src/lib/format/page.js +11 -0
  43. package/src/lib/format/page.js.map +1 -1
  44. package/src/lib/format/page.mjs +12 -1
  45. package/src/lib/format/page.mjs.map +1 -1
  46. package/src/lib/format/phone.d.mts +27 -0
  47. package/src/lib/format/phone.d.ts +27 -0
  48. package/src/lib/format/phone.js +43 -0
  49. package/src/lib/format/phone.js.map +1 -0
  50. package/src/lib/format/phone.mjs +39 -0
  51. package/src/lib/format/phone.mjs.map +1 -0
  52. package/src/lib/format/references.d.mts +10 -0
  53. package/src/lib/format/references.d.ts +10 -0
  54. package/src/lib/format/references.js +96 -1
  55. package/src/lib/format/references.js.map +1 -1
  56. package/src/lib/format/references.mjs +96 -1
  57. package/src/lib/format/references.mjs.map +1 -1
  58. package/src/lib/format/slots-options.d.mts +26 -0
  59. package/src/lib/format/slots-options.d.ts +26 -0
  60. package/src/lib/format/slots-options.js +35 -0
  61. package/src/lib/format/slots-options.js.map +1 -0
  62. package/src/lib/format/slots-options.mjs +31 -0
  63. package/src/lib/format/slots-options.mjs.map +1 -0
  64. package/src/lib/format/upgrade.d.mts +4 -1
  65. package/src/lib/format/upgrade.d.ts +4 -1
  66. package/src/lib/format/upgrade.js +4 -1
  67. package/src/lib/format/upgrade.js.map +1 -1
  68. package/src/lib/format/upgrade.mjs +4 -1
  69. package/src/lib/format/upgrade.mjs.map +1 -1
  70. package/src/lib/format/version.d.mts +2 -2
  71. package/src/lib/format/version.d.ts +2 -2
  72. package/src/lib/format/version.js +2 -2
  73. package/src/lib/format/version.js.map +1 -1
  74. package/src/lib/format/version.mjs +2 -2
  75. package/src/lib/format/version.mjs.map +1 -1
  76. package/src/lib/format/zoned.d.mts +8 -0
  77. package/src/lib/format/zoned.d.ts +8 -0
  78. package/src/lib/format/zoned.js +36 -0
  79. package/src/lib/format/zoned.js.map +1 -0
  80. package/src/lib/format/zoned.mjs +33 -0
  81. package/src/lib/format/zoned.mjs.map +1 -0
  82. package/src/lib/record/check.d.mts +6 -1
  83. package/src/lib/record/check.d.ts +6 -1
  84. package/src/lib/record/check.js +12 -4
  85. package/src/lib/record/check.js.map +1 -1
  86. package/src/lib/record/check.mjs +12 -4
  87. package/src/lib/record/check.mjs.map +1 -1
  88. package/src/lib/record/data-source.d.mts +188 -3
  89. package/src/lib/record/data-source.d.ts +188 -3
  90. package/src/lib/record/data-source.js.map +1 -1
  91. package/src/lib/record/data-source.mjs.map +1 -1
  92. package/src/lib/record/form.d.mts +72 -25
  93. package/src/lib/record/form.d.ts +72 -25
  94. package/src/lib/record/form.js +404 -78
  95. package/src/lib/record/form.js.map +1 -1
  96. package/src/lib/record/form.mjs +404 -78
  97. package/src/lib/record/form.mjs.map +1 -1
  98. package/src/lib/record/input-rules.d.mts +51 -9
  99. package/src/lib/record/input-rules.d.ts +51 -9
  100. package/src/lib/record/input-rules.js +119 -20
  101. package/src/lib/record/input-rules.js.map +1 -1
  102. package/src/lib/record/input-rules.mjs +118 -19
  103. package/src/lib/record/input-rules.mjs.map +1 -1
  104. package/src/lib/record/inputs.js +4 -2
  105. package/src/lib/record/inputs.js.map +1 -1
  106. package/src/lib/record/inputs.mjs +4 -2
  107. package/src/lib/record/inputs.mjs.map +1 -1
  108. package/src/lib/record/limits.d.mts +1 -1
  109. package/src/lib/record/limits.d.ts +1 -1
  110. package/src/lib/record/limits.js +4 -2
  111. package/src/lib/record/limits.js.map +1 -1
  112. package/src/lib/record/limits.mjs +4 -2
  113. package/src/lib/record/limits.mjs.map +1 -1
  114. package/src/lib/record/locales/ar.js +3 -2
  115. package/src/lib/record/locales/ar.js.map +1 -1
  116. package/src/lib/record/locales/ar.mjs +3 -2
  117. package/src/lib/record/locales/ar.mjs.map +1 -1
  118. package/src/lib/record/locales/de.js +3 -2
  119. package/src/lib/record/locales/de.js.map +1 -1
  120. package/src/lib/record/locales/de.mjs +3 -2
  121. package/src/lib/record/locales/de.mjs.map +1 -1
  122. package/src/lib/record/locales/fr.js +3 -2
  123. package/src/lib/record/locales/fr.js.map +1 -1
  124. package/src/lib/record/locales/fr.mjs +3 -2
  125. package/src/lib/record/locales/fr.mjs.map +1 -1
  126. package/src/lib/record/memory-data-source.d.mts +13 -2
  127. package/src/lib/record/memory-data-source.d.ts +13 -2
  128. package/src/lib/record/memory-data-source.js +92 -35
  129. package/src/lib/record/memory-data-source.js.map +1 -1
  130. package/src/lib/record/memory-data-source.mjs +93 -36
  131. package/src/lib/record/memory-data-source.mjs.map +1 -1
  132. package/src/lib/record/memory-schedules.d.mts +45 -0
  133. package/src/lib/record/memory-schedules.d.ts +45 -0
  134. package/src/lib/record/memory-schedules.js +81 -0
  135. package/src/lib/record/memory-schedules.js.map +1 -0
  136. package/src/lib/record/memory-schedules.mjs +78 -0
  137. package/src/lib/record/memory-schedules.mjs.map +1 -0
  138. package/src/lib/record/messages.d.mts +3 -1
  139. package/src/lib/record/messages.d.ts +3 -1
  140. package/src/lib/record/messages.js +3 -2
  141. package/src/lib/record/messages.js.map +1 -1
  142. package/src/lib/record/messages.mjs +3 -2
  143. package/src/lib/record/messages.mjs.map +1 -1
  144. package/src/lib/record/periods.d.mts +47 -0
  145. package/src/lib/record/periods.d.ts +47 -0
  146. package/src/lib/record/periods.js +86 -0
  147. package/src/lib/record/periods.js.map +1 -0
  148. package/src/lib/record/periods.mjs +79 -0
  149. package/src/lib/record/periods.mjs.map +1 -0
  150. package/src/lib/record/rules.js +5 -6
  151. package/src/lib/record/rules.js.map +1 -1
  152. package/src/lib/record/rules.mjs +5 -6
  153. package/src/lib/record/rules.mjs.map +1 -1
  154. package/src/lib/record/run.d.mts +44 -8
  155. package/src/lib/record/run.d.ts +44 -8
  156. package/src/lib/record/run.js +155 -26
  157. package/src/lib/record/run.js.map +1 -1
  158. package/src/lib/record/run.mjs +156 -27
  159. package/src/lib/record/run.mjs.map +1 -1
  160. package/src/lib/record/search.d.mts +13 -3
  161. package/src/lib/record/search.d.ts +13 -3
  162. package/src/lib/record/search.js +11 -4
  163. package/src/lib/record/search.js.map +1 -1
  164. package/src/lib/record/search.mjs +11 -4
  165. package/src/lib/record/search.mjs.map +1 -1
  166. package/src/lib/record/zones.d.mts +57 -0
  167. package/src/lib/record/zones.d.ts +57 -0
  168. package/src/lib/record/zones.js +147 -0
  169. package/src/lib/record/zones.js.map +1 -0
  170. package/src/lib/record/zones.mjs +132 -0
  171. package/src/lib/record/zones.mjs.map +1 -0
  172. package/src/runtime.d.mts +12 -5
  173. package/src/runtime.d.ts +12 -5
  174. package/src/runtime.js +32 -2
  175. package/src/runtime.js.map +1 -1
  176. package/src/runtime.mjs +7 -1
  177. package/src/runtime.mjs.map +1 -1
@@ -1,6 +1,7 @@
1
1
  import { upgradePage } from '../format/upgrade.mjs';
2
+ import { zonedPage } from '../format/zoned.mjs';
2
3
  import { compileModifier } from '../expression/modifier.mjs';
3
- import { localDay } from '../expression/functions.mjs';
4
+ import { dayIn, ownZone } from './zones.mjs';
4
5
  import { checkProperties, checkValue } from './check.mjs';
5
6
  import { checkInput } from './inputs.mjs';
6
7
  import { compileComputed } from './compute.mjs';
@@ -28,15 +29,23 @@ export function unsavedWork(state) {
28
29
  }
29
30
  /**
30
31
  * Whether a way out would lose the person's work without their knowing: a
31
- * change of theirs the last save did not send (`unsent`), files still on
32
- * their way, changes Reload set aside — and, `how.changes`, any change not
33
- * saved. What `form.leave()` — the one check every way out goes through —
34
- * asks about, once the save on its way and the files on theirs settled.
35
- * Before a record's first save, a change not saved is not at risk: as in
36
- * 0.20, each way out decides.
32
+ * change of theirs the last save did not send, or text they left since that
33
+ * does not read ("12a" counts as "12" does: `unsent`), files still on
34
+ * their way, changes Reload set aside, the changes of a save refused for a
35
+ * conflict, waiting for Reload or Keep mine (only a source that keeps
36
+ * versions has one) — and, `how.changes`, any change not saved. What
37
+ * `form.leave()` — the one check every way out goes through — asks about,
38
+ * once the save on its way and the files on theirs settled. Before a
39
+ * record's first save, a change not saved is not at risk: as in 0.20, each
40
+ * way out decides.
37
41
  */
38
42
  export function workAtRisk(state, how = {}) {
39
- return (state.unsent ?? []).length > 0 || state.setAside != null || (state.uploads ?? []).some(uploadGoing) || (how.changes === true && (state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0));
43
+ return ((state.unsent ?? []).length > 0 ||
44
+ state.setAside != null ||
45
+ (state.uploads ?? []).some(uploadGoing) ||
46
+ // The person asked for these to be saved, and someone else's save came first: still theirs, and not saved.
47
+ (state.conflict != null && state.dirty.length > 0) ||
48
+ (how.changes === true && (state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0)));
40
49
  }
41
50
  /**
42
51
  * The record state behind a page: values, what changed, what is visible and
@@ -48,6 +57,8 @@ export function createForm(options) {
48
57
  const page = upgradePage(options.page);
49
58
  return innerForm(page === options.page ? options : { ...options, page }, []).form;
50
59
  }
60
+ /** Whether a run works a person's change out: a page's `change` steps, set off by it, or a run they set off. */
61
+ const worksOut = (chain) => chain.some((key) => key.startsWith('on.change.'));
51
62
  /** A line's cell whose line is no longer there. */
52
63
  const gone = Symbol('gone');
53
64
  const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -60,7 +71,8 @@ const asValue = (values) => values;
60
71
  * its ways out go through that one's check.
61
72
  */
62
73
  function innerForm(options, within, place, outer) {
63
- const { page } = options;
74
+ // Its time zone written into each date and time field naming none, for whatever reads one from its field alone.
75
+ const page = zonedPage(options.page);
64
76
  const scheduler = options.scheduler ?? hostScheduler;
65
77
  const messages = options.messages ?? MESSAGES.en;
66
78
  const index = indexLayout(page.layout);
@@ -124,7 +136,19 @@ function innerForm(options, within, place, outer) {
124
136
  const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
125
137
  const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
126
138
  const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
127
- const today = () => localDay(options.now?.() ?? new Date());
139
+ /** Today, on the clock of the page's time zone (the browser's own when it names none). */
140
+ const today = () => dayIn(options.now?.() ?? new Date(), page.timeZone);
141
+ const clock = () => (options.now?.() ?? new Date()).getTime();
142
+ /** What a page's bot check saw — its hidden box filled, the captcha's token — and when the page was shown or started again. */
143
+ let bot = { honeypot: false, captcha: null, since: clock() };
144
+ /** What the bot check saw as the answers go; none without one. */
145
+ function botReport() {
146
+ const check = page.botCheck;
147
+ if (!check)
148
+ return undefined;
149
+ const taken = Math.max(0, clock() - bot.since) / 1000;
150
+ return { honeypot: check.honeypot !== false && bot.honeypot, tooFast: taken < (check.minSeconds ?? 3), seconds: Math.round(taken * 10) / 10, ...(check.captcha ? { captcha: bot.captcha } : {}) };
151
+ }
128
152
  // The record's id and the person, as worked-out values read them: the id is the one asked for until the form has its state.
129
153
  let started = false;
130
154
  let editing = options.editing !== false;
@@ -286,16 +310,41 @@ function innerForm(options, within, place, outer) {
286
310
  let generation = 0;
287
311
  /** Closed (`dispose`): a run still going stops quietly, as its dialog or page is gone — no other record replaced it. */
288
312
  let disposed = false;
289
- /** The fields whose last write was the person's: a step's, a rule's or the app's write takes one over. */
313
+ /** Counted at each reset (the person's Discard): a save on its way then lands as the record it stored. */
314
+ let resets = 0;
315
+ /**
316
+ * THE test every late answer passes (`recordShown`): taken as it is asked for, true while the record it was asked
317
+ * for is still the one shown, as it was. A save's answer, a call's, an onchange's, a name looked up, a line's
318
+ * dialog, a record step's — none is laid on another record, nor over the values the person's Discard let go.
319
+ */
320
+ function shownNow() {
321
+ const at = generation;
322
+ const reset = resets;
323
+ // A saved form placed in a record is the record's: another shown round it, its answers are laid nowhere either.
324
+ const round = outer?.recordShown();
325
+ return () => !disposed && at === generation && reset === resets && (!round || round());
326
+ }
327
+ /**
328
+ * The person's claims on the record: the fields whose last write was theirs. A step's, a rule's or the app's
329
+ * write of the whole field takes one over; a write that works the person's change out (the source's onchange, the
330
+ * page's `change` steps it sets off) keeps it.
331
+ */
290
332
  const personal = new Set();
333
+ /**
334
+ * Of a table the person did not write whole: the keys of the lines they changed, added or took away. A line a
335
+ * step adds is none of theirs, and leaves theirs as they are.
336
+ */
337
+ const personalLines = new Map();
291
338
  /** Fields where a file the person picked landed from the store: at risk until a save sends it, a first save or not. */
292
339
  const filed = new Set();
293
340
  /** The record as the last finished save left it — what it sent and what came back; a page of answers: what it sent. Null before one. */
294
341
  let sentAs = null;
295
- /** Counted at each reset (the person's Discard): a save on its way then lands as the record it stored. */
296
- let resets = 0;
297
- /** Above 0 while a delete the person chose goes on: a way out it takes lets their work go, unasked. */
298
- let deletes = 0;
342
+ /**
343
+ * The records being deleted, by the person's choice, each by its generation: a way out of one of them lets their
344
+ * work in it go, unasked. Another record shown meanwhile is asked about as any.
345
+ */
346
+ const deleting = new Map();
347
+ const deletingShown = () => (deleting.get(generation) ?? 0) > 0;
299
348
  /** Each field's last comparison with what was sent, so a value that did not move is not compared again. */
300
349
  const compared = new Map();
301
350
  /** Fields showing a file that did not reach the store: shown until it is sent again or taken away, whatever else changes. */
@@ -433,8 +482,9 @@ function innerForm(options, within, place, outer) {
433
482
  const was = state.values;
434
483
  const shown = state.errors;
435
484
  state = { ...state, ...patch };
436
- // The person's changes the last save did not send, worked out again from the values as they are now.
437
- if (patch.unsent === undefined && patch.values !== undefined) {
485
+ // The person's changes the last save did not send, worked out again from the values as they are now, and from the
486
+ // text they left that does not read.
487
+ if (patch.unsent === undefined && (patch.values !== undefined || patch.unread !== undefined)) {
438
488
  const unsent = unsentNow();
439
489
  if (!sameList(unsent, state.unsent))
440
490
  state = { ...state, unsent };
@@ -463,42 +513,66 @@ function innerForm(options, within, place, outer) {
463
513
  for (const listener of [...heard])
464
514
  listener(payload);
465
515
  }
466
- /** A field written: told to the app, and — a person's change — the page's `change` steps for it run. */
467
- function changed(field, by) {
468
- noteOrigin(field, by);
516
+ /**
517
+ * A field written: told to the app, and — a person's change — the page's `change` steps for it run. `claim` says
518
+ * what of the person's it touches: `line`, the key of the one line of a table the person changed; `keep`, a write
519
+ * that leaves the person's claims as they are (a line a step adds, a write working their change out).
520
+ */
521
+ function changed(field, by, claim = {}) {
522
+ noteOrigin(field, by, claim);
469
523
  emit('change', { field, value: state.values[field], values: state.values, by });
470
524
  const list = page.on?.change?.[field];
471
525
  if (by === 'person' && list)
472
526
  void track(moment(`on.change.${field}`, list, [], true));
473
527
  }
474
- /** Another record is shown: nothing of the last one's is the person's work any more. */
528
+ /** Another record is shown: nothing of the last one's is the person's work any more, and its files on their way stop. */
475
529
  function newGeneration() {
476
530
  generation++;
477
531
  personal.clear();
532
+ personalLines.clear();
478
533
  filed.clear();
479
534
  compared.clear();
480
535
  sentAs = null;
536
+ clearFiles();
481
537
  }
482
538
  /** Whether a field holds a file the store holds that no save sent: its own, or one in a saved form placed here under its name. */
483
539
  const filedIn = (name) => filed.has(name) || [...attached.values()].some((part) => part.name === name && part.inner.files.filed());
484
540
  /**
485
541
  * The person's work no save took, by field, in the page's order: each of
486
542
  * their changes that differs from the record as the last save left it —
487
- * before a first save, only a file the store holds.
543
+ * a field they wrote, or a line of a table they changed — and text they
544
+ * left in a box of it that does not read (`unread`), which no save can
545
+ * send; before a first save, only a file the store holds.
488
546
  */
489
547
  function unsentNow() {
490
- if (!personal.size)
548
+ const typed = sentAs ? unreadFields() : null;
549
+ if (!personal.size && !personalLines.size && !typed?.size)
491
550
  return [];
492
551
  const out = [];
493
552
  for (const name of Object.keys(fields)) {
494
- if (!personal.has(name))
553
+ // Typed and left unread since a save: the person's, as a change is — a value changed to "12" is, so is "12a".
554
+ if (typed?.has(name)) {
555
+ out.push(name);
556
+ continue;
557
+ }
558
+ const lines = personalLines.get(name);
559
+ if (!personal.has(name) && !lines)
495
560
  continue;
496
561
  const from = sentAs ?? (filedIn(name) ? baseline : null);
497
- if (from && differs(name, state.values[name], from[name]))
562
+ if (!from)
563
+ continue;
564
+ if (lines ? linesDiffer(name, lines, state.values[name], from[name]) : differs(name, state.values[name], from[name]))
498
565
  out.push(name);
499
566
  }
500
567
  return out;
501
568
  }
569
+ /** The fields with text left unread in a box of theirs: a field's, a line's cell's, a property's. */
570
+ function unreadFields() {
571
+ const out = new Set();
572
+ for (const path of Object.keys(state.unread ?? {}))
573
+ out.add(path.split('.')[0]);
574
+ return out;
575
+ }
502
576
  function differs(name, value, sent) {
503
577
  const known = compared.get(name);
504
578
  if (known && known.value === value && known.sent === sent)
@@ -507,18 +581,53 @@ function innerForm(options, within, place, outer) {
507
581
  compared.set(name, { value, sent, differs: verdict });
508
582
  return verdict;
509
583
  }
584
+ /** Whether any of these lines of a table differs from the table as sent: changed, added, or taken away. */
585
+ function linesDiffer(name, keys, value, sent) {
586
+ const def = fields[name];
587
+ const find = (lines, key) => (lines ?? []).find((line) => line.key === key);
588
+ for (const key of keys) {
589
+ const now = find(value, key);
590
+ const was = find(sent, key);
591
+ if (!now && !was)
592
+ continue;
593
+ if (!now || !was)
594
+ return true;
595
+ if (now.values === was.values)
596
+ continue;
597
+ for (const sub of new Set([...Object.keys(now.values), ...Object.keys(was.values)])) {
598
+ if (!sameValue(def.fields[sub], now.values[sub], was.values[sub]))
599
+ return true;
600
+ }
601
+ }
602
+ return false;
603
+ }
510
604
  /** `unsent` as it is now, told when it changed. */
511
605
  function refreshUnsent() {
512
606
  const unsent = unsentNow();
513
607
  if (!sameList(unsent, state.unsent))
514
608
  set({ unsent });
515
609
  }
516
- /** A field written, and by whom: the person's change is theirs until a step, a rule or the app writes the field in its place. */
517
- function noteOrigin(field, by) {
518
- if (by === 'person')
519
- personal.add(field);
520
- else
610
+ /**
611
+ * A field written, and by whom: the person's change is theirs — the field, or the line of a table they changed —
612
+ * until a step, a rule or the app writes the whole field in its place. A write that leaves their change where it
613
+ * is (`keep`: a line a step adds, a `change` step working out what they typed) leaves it theirs.
614
+ */
615
+ function noteOrigin(field, by, claim) {
616
+ if (by === 'person') {
617
+ if (claim.line === undefined) {
618
+ personal.add(field);
619
+ personalLines.delete(field);
620
+ }
621
+ else if (!personal.has(field)) {
622
+ const keys = personalLines.get(field) ?? new Set();
623
+ keys.add(claim.line);
624
+ personalLines.set(field, keys);
625
+ }
626
+ }
627
+ else if (!claim.keep) {
521
628
  personal.delete(field);
629
+ personalLines.delete(field);
630
+ }
522
631
  refreshUnsent();
523
632
  }
524
633
  /** A wizard step entered: told to the app, and its `show` steps run. */
@@ -688,10 +797,12 @@ function innerForm(options, within, place, outer) {
688
797
  const ticks = node.source.widget === 'tick' && page.fields[name].type === 'boolean';
689
798
  return ticks && state.values[name] !== true && nodeState(node.id).required ? messages.tick : undefined;
690
799
  }
800
+ /** The widget a field is shown with where it keeps its value its own way (a per cent): the first of its nodes'. */
801
+ const shownWidget = (shown) => shown.map((node) => node.source.widget).find((widget) => widget === 'percentage');
691
802
  /** A field node's error now: its field's own rules first, then its answer rules. */
692
803
  function nodeError(node) {
693
804
  const name = node.field;
694
- return unticked(node) ?? checkInput(page.fields[name], node.source, state.values[name], messages) ?? checkValue(checked(name), state.values[name], nodeState(node.id).required, messages, today()) ?? ruleCheck(node).error;
805
+ return unticked(node) ?? checkInput(page.fields[name], node.source, state.values[name], messages) ?? checkValue(checked(name), state.values[name], nodeState(node.id).required, messages, today(), node.source.widget) ?? ruleCheck(node).error;
695
806
  }
696
807
  /** Warnings for the visible fields of the page, without publishing them. */
697
808
  function collectWarnings() {
@@ -775,7 +886,7 @@ function innerForm(options, within, place, outer) {
775
886
  const cell = ruled[sub];
776
887
  if (cell?.invisible || (tables.has(node.id) && tables.get(node.id).compiled.hidden(sub, reading().context, reading().env, userRoles)))
777
888
  continue;
778
- const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today());
889
+ const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today(), node.source.cells?.[sub]?.widget);
779
890
  if (lineMessage)
780
891
  errors[`${name}.${line.key}.${sub}`] = lineMessage;
781
892
  }
@@ -840,7 +951,7 @@ function innerForm(options, within, place, outer) {
840
951
  if (isEmpty(fieldDef(field), value))
841
952
  return undefined;
842
953
  return (shown.map((node) => checkInput(page.fields[field], node.source, value, messages)).find(Boolean) ??
843
- besidesRows(field, checkValue(checked(field), value, false, messages, today())) ??
954
+ besidesRows(field, checkValue(checked(field), value, false, messages, today(), shownWidget(shown))) ??
844
955
  shown.map((node) => ruleCheck(node).error).find(Boolean));
845
956
  }
846
957
  if (parts.length === 2 && page.fields[field]?.type === 'properties')
@@ -1026,8 +1137,10 @@ function innerForm(options, within, place, outer) {
1026
1137
  return;
1027
1138
  const seq = ++onchangeSeq;
1028
1139
  const request = { model: page.data.model, id: state.recordId, changed, values: structuredCopy(state.values) };
1140
+ const shown = shownNow();
1029
1141
  void track(source.onchange(request).then((result) => {
1030
- if (seq !== onchangeSeq)
1142
+ // A newer change asked since; or the record it was asked for is gone, or its values let go: none of it is laid.
1143
+ if (seq !== onchangeSeq || !shown())
1031
1144
  return;
1032
1145
  const values = { ...state.values, ...(result.values ?? {}) };
1033
1146
  writeValues(values, { warning: result.warning ?? null, warningField: result.warning ? changed : null });
@@ -1128,18 +1241,27 @@ function innerForm(options, within, place, outer) {
1128
1241
  set({ status: 'error', error: problem.message, saveProblem: problem, conflict: null });
1129
1242
  return { reason: 'save', message: problem.message };
1130
1243
  }
1244
+ // Asked for the record shown now: another shown while it waits below, it saves nothing of that one, nor says anything there.
1245
+ const asked = generation;
1246
+ const elsewhere = () => ({ reason: 'save', message: messages.recordReplaced });
1131
1247
  // What is still typed in a box is read first: it is what is saved, or why nothing is. A name still being looked
1132
1248
  // up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
1133
1249
  const waiting = readTyped();
1134
1250
  if (waiting)
1135
1251
  await waiting;
1252
+ if (asked !== generation)
1253
+ return elsewhere();
1136
1254
  // Files still on their way are waited for first — a saved form's placed here too: the state says how many, so the page can say why it waits.
1137
1255
  if (filesGoing()) {
1138
1256
  set({ status: 'saving', error: null, saveProblem: null });
1139
1257
  await filesSettled();
1258
+ if (asked !== generation)
1259
+ return elsewhere();
1140
1260
  set({ status: 'ready' });
1141
1261
  }
1142
1262
  if (page.on?.beforeSave) {
1263
+ // Its run stops of itself on a record shown by anything but its own steps (a `call`'s `{ record }`, a reload):
1264
+ // those move the save on with it.
1143
1265
  const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
1144
1266
  if (!before.done) {
1145
1267
  // A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
@@ -1164,6 +1286,8 @@ function innerForm(options, within, place, outer) {
1164
1286
  try {
1165
1287
  // Bytes still in the record — a signature drawn, a value the app set, in a saved form placed here too — go to the store first, where it keeps files.
1166
1288
  await storeBytes(page.data.kind === 'responses' || state.recordId == null ? Object.keys(fields) : state.dirty);
1289
+ if (at !== generation)
1290
+ return elsewhere();
1167
1291
  const failed = fileProblems();
1168
1292
  if (Object.keys(failed).length) {
1169
1293
  set({ errors: failed, ...unstored(failed) });
@@ -1175,7 +1299,11 @@ function innerForm(options, within, place, outer) {
1175
1299
  const sent = shownValues();
1176
1300
  const before = state.values;
1177
1301
  at = generation;
1178
- await source.submit({ pageId: page.id, values: sent });
1302
+ const seen = botReport();
1303
+ await source.submit({ pageId: page.id, values: sent, ...(seen ? { bot: seen } : {}) });
1304
+ // The captcha's token is spent once sent: the next send waits for a new one.
1305
+ if (seen?.captcha)
1306
+ bot.captcha = null;
1179
1307
  // Answered for answers no longer shown — the form was started again meanwhile: the source has them, the form lays nothing.
1180
1308
  if (at !== generation)
1181
1309
  return null;
@@ -1183,7 +1311,7 @@ function innerForm(options, within, place, outer) {
1183
1311
  // What it sent is what an answer changed since is told against: still to send.
1184
1312
  sentAs = before;
1185
1313
  set({ status: 'saved', unsent: unsentNow() });
1186
- emit('send', { values: sent });
1314
+ emit('send', { values: sent, ...(seen ? { bot: seen } : {}) });
1187
1315
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
1188
1316
  return null;
1189
1317
  }
@@ -1219,7 +1347,8 @@ function innerForm(options, within, place, outer) {
1219
1347
  scheduleDraft();
1220
1348
  scheduleAutosave();
1221
1349
  }
1222
- emit('save', { recordId: state.recordId, values: state.values });
1350
+ // The record as this save left it: a change made while it was on its way is no value of it.
1351
+ emit('save', { recordId: state.recordId, values: baseline });
1223
1352
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
1224
1353
  return null;
1225
1354
  }
@@ -1275,8 +1404,12 @@ function innerForm(options, within, place, outer) {
1275
1404
  }
1276
1405
  /** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
1277
1406
  async function opening() {
1407
+ const at = generation;
1278
1408
  emit('open', { recordId: state.recordId, values: state.values });
1279
1409
  await moment('on.open', page.on?.open, [], true);
1410
+ // Another record shown while its open steps ran: that one's opening tells its own steps.
1411
+ if (at !== generation)
1412
+ return;
1280
1413
  showing = true;
1281
1414
  tellStep([]);
1282
1415
  }
@@ -1297,8 +1430,8 @@ function innerForm(options, within, place, outer) {
1297
1430
  for (const name of names)
1298
1431
  changed(name, 'app');
1299
1432
  }
1300
- /** Write fields — a step's or the app's — worked out once, and tell each. */
1301
- function write(written, by) {
1433
+ /** Write fields — a step's or the app's — worked out once, and tell each; `keep`, a write working the person's change out. */
1434
+ function write(written, by, keep = false) {
1302
1435
  const names = Object.keys(written);
1303
1436
  names.forEach(fieldDef);
1304
1437
  if (!names.length)
@@ -1307,8 +1440,9 @@ function innerForm(options, within, place, outer) {
1307
1440
  for (const name of names)
1308
1441
  afterEdit(name);
1309
1442
  for (const name of names)
1310
- changed(name, by);
1443
+ changed(name, by, { keep });
1311
1444
  }
1445
+ /** A line added: the person's, theirs; a step's, none of theirs, and the lines they changed stay theirs. */
1312
1446
  function addLine(field, values, by) {
1313
1447
  const def = lineField(field);
1314
1448
  const current = state.values[field] ?? [];
@@ -1331,7 +1465,7 @@ function innerForm(options, within, place, outer) {
1331
1465
  const lines = [...current, line];
1332
1466
  writeValues({ ...state.values, [field]: lines });
1333
1467
  afterEdit(field);
1334
- changed(field, by);
1468
+ changed(field, by, by === 'person' ? { line: key } : { keep: true });
1335
1469
  return key;
1336
1470
  }
1337
1471
  /**
@@ -1340,15 +1474,44 @@ function innerForm(options, within, place, outer) {
1340
1474
  */
1341
1475
  async function check(names) {
1342
1476
  names?.forEach(fieldDef);
1477
+ const at = generation;
1343
1478
  const waiting = readTyped();
1344
1479
  if (waiting)
1345
1480
  await waiting;
1481
+ // Another record shown while it waited: nothing found is shown on that one.
1482
+ if (at !== generation)
1483
+ return replaced();
1346
1484
  const all = collectErrors();
1347
1485
  const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
1348
1486
  set({ errors: found });
1349
1487
  const first = Object.values(found)[0];
1350
1488
  return first === undefined ? null : { reason: 'check', message: first };
1351
1489
  }
1490
+ /**
1491
+ * What is typed read first, for a run the person or the app set off (`RunScope.typed`): every box read as a save
1492
+ * reads it, a name looked up waited for. Text left that does not read stops the run, said under its box as a check
1493
+ * says it — never a value under it handed on or worked from.
1494
+ */
1495
+ async function typedFirst() {
1496
+ const at = generation;
1497
+ const waiting = readTyped();
1498
+ if (waiting)
1499
+ await waiting;
1500
+ // Another record shown while it waited: the run is not for that one.
1501
+ if (at !== generation)
1502
+ return replaced();
1503
+ const found = unreadProblems();
1504
+ const first = Object.values(found)[0];
1505
+ if (first === undefined)
1506
+ return null;
1507
+ set({ errors: { ...state.errors, ...found } });
1508
+ return { reason: 'check', message: first };
1509
+ }
1510
+ /** The problems of text left unread in boxes shown — this form's and its saved forms' placed here — by path. */
1511
+ function unreadProblems() {
1512
+ const inner = (path) => [...attached.values()].some((part) => path.startsWith(`${part.name}.`) && !!part.inner.form.getState().unread?.[path.slice(part.name.length + 1)]);
1513
+ return Object.fromEntries(Object.entries(collectErrors()).filter(([path]) => !!state.unread[path] || inner(path)));
1514
+ }
1352
1515
  /** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
1353
1516
  async function goToStep(target, chain) {
1354
1517
  if (!steps.includes(target))
@@ -1356,16 +1519,19 @@ function innerForm(options, within, place, outer) {
1356
1519
  if (!form.steps().includes(target))
1357
1520
  return { reason: 'cannot', message: `The step "${target}" does not apply now` };
1358
1521
  // A name still being looked up in a box on the way is waited for, as a save waits for it.
1522
+ const at = generation;
1359
1523
  const waiting = readTyped();
1360
1524
  if (waiting)
1361
1525
  await waiting;
1526
+ if (at !== generation)
1527
+ return replaced();
1362
1528
  let went = false;
1363
1529
  inRun(chain, () => (went = form.goTo(target)));
1364
1530
  return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
1365
1531
  }
1366
1532
  /** Run steps from the top — a button's, a moment's, the app's — and tell how it ended: on the record shown as it starts (`generation`). */
1367
1533
  async function runTop(list, scope) {
1368
- scope.generation = generation;
1534
+ scope.generation ??= generation;
1369
1535
  const result = await runner.runSteps(list, scope);
1370
1536
  emit('run', { id: scope.id, steps: list, result });
1371
1537
  return result;
@@ -1383,7 +1549,7 @@ function innerForm(options, within, place, outer) {
1383
1549
  throw new Error(`"${name}" cannot make records: its data source has no create, or it is not a link`);
1384
1550
  return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
1385
1551
  }
1386
- async function runSearch(def, name, ctx, query, limit, model, ids) {
1552
+ async function runSearch(def, name, ctx, query, limit, { model, ids, exact } = {}) {
1387
1553
  // Formatted text names people and records in place (@mention, #4127): searched by the model they are of.
1388
1554
  const byModel = def.type === 'reference' || def.type === 'json' || def.type === 'html';
1389
1555
  if (def.type !== 'many2one' && def.type !== 'many2many' && !byModel) {
@@ -1398,7 +1564,7 @@ function innerForm(options, within, place, outer) {
1398
1564
  const own = byModel || !def.filter ? undefined : resolveFilter(def.filter, ctx);
1399
1565
  const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
1400
1566
  const relation = byModel ? model : def.relation;
1401
- return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
1567
+ return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit, ...(exact ? { exact: true } : {}) }));
1402
1568
  }
1403
1569
  function lineField(field) {
1404
1570
  const def = fieldDef(field);
@@ -1499,7 +1665,7 @@ function innerForm(options, within, place, outer) {
1499
1665
  }
1500
1666
  }
1501
1667
  /** Whether a way out goes at once: nothing to wait for, no check going, nothing at risk — as in 0.20. */
1502
- const atOnce = (how, own = false) => !deletes && !checking && !busy(own) && (how.discard === true || !workAtRisk(state, how));
1668
+ const atOnce = (how, own = false) => !deletingShown() && !checking && !busy(own) && (how.discard === true || !workAtRisk(state, how));
1503
1669
  /** The saves on their way, settled: what a load that is not a way out (a conflict's Reload) waits for. */
1504
1670
  async function savesSettled() {
1505
1671
  while (saveRunning)
@@ -1525,10 +1691,13 @@ function innerForm(options, within, place, outer) {
1525
1691
  if (outer)
1526
1692
  return outer.letGo(how, chain);
1527
1693
  // A delete the person chose: their work goes with the record, unasked.
1528
- if (deletes) {
1694
+ if (deletingShown()) {
1529
1695
  forget();
1530
1696
  return true;
1531
1697
  }
1698
+ // The person's own Discard stops the files on their way, never waits for them.
1699
+ if (how.discard)
1700
+ clearFiles();
1532
1701
  const own = chain.includes('on.beforeSave');
1533
1702
  if (atOnce(how, own)) {
1534
1703
  if (how.discard)
@@ -1571,13 +1740,46 @@ function innerForm(options, within, place, outer) {
1571
1740
  forget();
1572
1741
  return yes;
1573
1742
  }
1574
- /** Let go of the person's work at risk: files on their way stop, changes set aside go, changes no save took are asked about no more. */
1743
+ /**
1744
+ * Let go of the person's work at risk: files on their way stop, changes set aside go, a conflict's changes are
1745
+ * asked about no more, and the changes no save took go back to the record as the last save left it — so what a
1746
+ * dialog or a page in place hands back once it saved is the record as saved, never the change let go.
1747
+ */
1575
1748
  function forget() {
1749
+ const back = unsentLetGo();
1576
1750
  clearFiles();
1577
1751
  personal.clear();
1752
+ personalLines.clear();
1578
1753
  filed.clear();
1579
- if (state.setAside !== null || state.unsent.length)
1580
- set({ setAside: null, unsent: [] });
1754
+ if (back)
1755
+ writeValues({ ...state.values, ...back });
1756
+ // Text left that does not read goes too: each box shows what its field holds.
1757
+ const unread = Object.keys(state.unread).length ? { unread: {} } : {};
1758
+ if (state.setAside !== null || state.unsent.length || state.conflict !== null || unread.unread)
1759
+ set({ setAside: null, unsent: [], conflict: null, ...unread });
1760
+ }
1761
+ /** The fields holding the person's changes no save took, as the record as last saved holds them: their lines alone, for a table. */
1762
+ function unsentLetGo() {
1763
+ if (!state.unsent.length)
1764
+ return null;
1765
+ const out = {};
1766
+ for (const name of state.unsent) {
1767
+ const from = sentAs ?? baseline;
1768
+ const keys = personalLines.get(name);
1769
+ out[name] = keys && !personal.has(name) ? linesAsSent(state.values[name], from[name], keys) : structuredCopy(from[name]);
1770
+ }
1771
+ return out;
1772
+ }
1773
+ /** A table with these lines as sent: changed ones as they were, added ones gone, ones taken away back in their place. */
1774
+ function linesAsSent(value, sent, keys) {
1775
+ const was = sent ?? [];
1776
+ const of = (key) => was.find((line) => line.key === key);
1777
+ const out = (value ?? []).filter((line) => !keys.has(line.key) || of(line.key)).map((line) => (keys.has(line.key) ? structuredCopy(of(line.key)) : line));
1778
+ was.forEach((line, at) => {
1779
+ if (keys.has(line.key) && !out.some((other) => other.key === line.key))
1780
+ out.splice(Math.min(at, out.length), 0, structuredCopy(line));
1781
+ });
1782
+ return out;
1581
1783
  }
1582
1784
  /** The check as a step meets it: null to go on — at once when nothing is at risk — else why the run stops. */
1583
1785
  function letGoStep(chain) {
@@ -1585,12 +1787,19 @@ function innerForm(options, within, place, outer) {
1585
1787
  return go === true ? null : go.then((yes) => (yes ? null : kept()));
1586
1788
  }
1587
1789
  const kept = () => ({ reason: 'no', message: messages.discardChanges });
1588
- /** Load the record again, once the check let what is shown go. */
1589
- function loadAgain(again, chain = []) {
1790
+ /**
1791
+ * A step's own move — the record loaded again, or another shown — once the check let what is shown go: kept, the
1792
+ * person kept their work; replaced, another record was shown before it ended (the run's steps are not for that one).
1793
+ */
1794
+ async function ownMove(chain, move) {
1590
1795
  const go = letGo({}, chain);
1591
- if (go === true)
1592
- return load(again).then(() => true);
1593
- return go.then((yes) => (yes ? load(again).then(() => true) : false));
1796
+ if (go !== true && !(await go))
1797
+ return kept();
1798
+ // The record it shows is a generation of its own, made as it starts: another made before it ends replaced it.
1799
+ const moving = move();
1800
+ const mine = generation;
1801
+ await moving;
1802
+ return mine === generation ? null : replaced();
1594
1803
  }
1595
1804
  /**
1596
1805
  * The host's close, as every `close` step reaches it: the form takes it
@@ -1612,6 +1821,10 @@ function innerForm(options, within, place, outer) {
1612
1821
  const go = letGo({}, chain);
1613
1822
  return go === true ? shut() : go.then((yes) => (yes ? shut() : kept()));
1614
1823
  }
1824
+ /** Why a run stops whose record was replaced while it went on — quietly, when the form was closed. */
1825
+ function replaced() {
1826
+ return disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced };
1827
+ }
1615
1828
  /** The record a record step asks the data source about. */
1616
1829
  const record = () => ({ model: page.data.model, id: state.recordId, fields });
1617
1830
  async function post(message) {
@@ -1634,18 +1847,30 @@ function innerForm(options, within, place, outer) {
1634
1847
  recordId: () => state.recordId,
1635
1848
  reading,
1636
1849
  today,
1637
- write: (values) => write(values, 'step'),
1850
+ // A step's write of a field takes the person's change in it over — unless the step works that change out: the
1851
+ // page's `change` steps, set off by it, as the source's onchange does.
1852
+ write: (values, chain) => write(values, 'step', worksOut(chain)),
1638
1853
  store: (values) => store(values),
1854
+ // The person's own change: the field's, or a line of it (a claim by line): put back, the whole field is theirs
1855
+ // again — at risk, asked about — never less than it was.
1856
+ mine: (field) => personal.has(field) || personalLines.has(field),
1857
+ putBack(values, mine) {
1858
+ write(values, 'step');
1859
+ for (const name of mine)
1860
+ noteOrigin(name, 'person', {});
1861
+ },
1639
1862
  addLine: (field, values) => void addLine(field, values, 'step'),
1640
1863
  check,
1864
+ typedFirst,
1641
1865
  save: saving,
1642
1866
  generation: () => generation,
1643
- gone: () => (disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced }),
1867
+ gone: replaced,
1644
1868
  async reload(chain) {
1645
1869
  if (state.recordId == null)
1646
1870
  return { reason: 'cannot', message: 'The record is not saved yet: there is nothing to load again' };
1647
- if (!(await loadAgain(true, chain)))
1648
- return kept();
1871
+ const stopped = await ownMove(chain, () => load(true));
1872
+ if (stopped)
1873
+ return stopped;
1649
1874
  return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
1650
1875
  },
1651
1876
  reset(chain) {
@@ -1666,24 +1891,32 @@ function innerForm(options, within, place, outer) {
1666
1891
  changed: () => state.dirty.length > 0 || state.uploads.some(uploadGoing),
1667
1892
  letGo: letGoStep,
1668
1893
  async deleting(remove, done) {
1669
- deletes++;
1894
+ const of = generation;
1895
+ const shown = shownNow();
1896
+ deleting.set(of, (deleting.get(of) ?? 0) + 1);
1670
1897
  try {
1671
1898
  const out = await remove();
1672
- // Deleted: what the person had not saved in it goes with it. Refused, it stays theirs.
1673
- if (done(out))
1899
+ // Deleted: what the person had not saved in it goes with it. Refused, it stays theirs. Another record shown
1900
+ // meanwhile keeps what was typed in it.
1901
+ if (done(out) && shown())
1674
1902
  forget();
1675
1903
  return out;
1676
1904
  }
1677
1905
  finally {
1678
- deletes--;
1906
+ const left = (deleting.get(of) ?? 1) - 1;
1907
+ if (left)
1908
+ deleting.set(of, left);
1909
+ else
1910
+ deleting.delete(of);
1679
1911
  }
1680
1912
  },
1681
1913
  ...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
1682
1914
  ...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
1683
1915
  ...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
1684
1916
  async showRecord(id, chain) {
1685
- if (!(await openRecord(id, chain)))
1686
- return kept();
1917
+ const stopped = await ownMove(chain, () => showNow(id));
1918
+ if (stopped)
1919
+ return stopped;
1687
1920
  return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
1688
1921
  },
1689
1922
  told(event, id) {
@@ -1702,8 +1935,24 @@ function innerForm(options, within, place, outer) {
1702
1935
  wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
1703
1936
  barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
1704
1937
  });
1705
- const moment = createMoments((key, list, chain) => runTop(list, { id: key, chain }));
1706
- /** A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. */
1938
+ // A moment's run is on the record it was set off on: one set off again while its run goes, and run after it, is let
1939
+ // go when another record is shown by then (its steps were for that one).
1940
+ const moment = createMoments((key, list, chain, on) => runTop(list, { id: key, chain, generation: on }), () => generation);
1941
+ /**
1942
+ * Whether steps do something before their first save — set a status, call the app, open a page — so a record's
1943
+ * button made of them saves first. Steps that start with the save (Save and close), or only check before it, need
1944
+ * no save before them: theirs is the first.
1945
+ */
1946
+ function actsBeforeSaving(list) {
1947
+ const first = list.findIndex((step) => step.do === 'save');
1948
+ return first > 0 && list.slice(0, first).some((step) => step.do !== 'check');
1949
+ }
1950
+ /**
1951
+ * A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. A
1952
+ * record's button whose steps act and then save saves the record first when it is new or changed, as Flectra's
1953
+ * button does: refused — a field it requires left empty — none of its steps run, so nothing it does happens to a
1954
+ * record that cannot be saved.
1955
+ */
1707
1956
  function press(source, scope) {
1708
1957
  const list = [...(source.steps ?? [])];
1709
1958
  if (source.action !== undefined)
@@ -1714,12 +1963,37 @@ function innerForm(options, within, place, outer) {
1714
1963
  list.push({ do: builtin });
1715
1964
  const asked = builtin === 'archive' ? messages.archiveConfirm : builtin === 'delete' ? messages.deleteConfirm : undefined;
1716
1965
  const confirm = 'confirm' in source && source.confirm !== undefined ? source.confirm : asked;
1966
+ // On the record it was pressed on: another shown while its question is asked, it does nothing to that one.
1967
+ scope.generation ??= generation;
1968
+ // Pressed by the person: what is typed is read before a step reads the form or hands its values on.
1969
+ scope.typed ??= 'first';
1717
1970
  return track((async () => {
1718
1971
  if (confirm && !(await runner.ask(confirm))) {
1719
1972
  const result = { done: false, reason: 'no', message: confirm };
1720
1973
  emit('run', { id: scope.id, steps: list, result });
1721
1974
  return result;
1722
1975
  }
1976
+ const saveFirst = page.data.kind === 'record' && actsBeforeSaving(list);
1977
+ // What is typed is read first, as the run reads it before its first step that reads the form (run.ts): text
1978
+ // that does not read stops it with `check`, said under its box — before the save that comes first, and any step.
1979
+ if (saveFirst && scope.typed === 'first') {
1980
+ const unread = await typedFirst();
1981
+ if (unread) {
1982
+ const at = Math.max(0, list.findIndex((step) => step.do !== 'check'));
1983
+ const result = { done: false, stoppedAt: at, step: list[at], reason: unread.reason, ...(unread.message !== undefined ? { message: unread.message } : {}) };
1984
+ emit('run', { id: scope.id, steps: list, result });
1985
+ return result;
1986
+ }
1987
+ }
1988
+ // Then the save first, then the steps.
1989
+ if (saveFirst && (state.recordId == null || state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0 || state.uploads.some(uploadGoing))) {
1990
+ const first = await saving(scope.chain);
1991
+ if (first) {
1992
+ const result = { done: false, step: { do: 'save' }, reason: first.reason, ...(first.message !== undefined ? { message: first.message } : {}), ...(first.error !== undefined ? { error: first.error } : {}) };
1993
+ emit('run', { id: scope.id, steps: list, result });
1994
+ return result;
1995
+ }
1996
+ }
1723
1997
  return runTop(list, scope);
1724
1998
  })());
1725
1999
  }
@@ -1745,6 +2019,7 @@ function innerForm(options, within, place, outer) {
1745
2019
  openRecord: (id) => track(openRecord(id)),
1746
2020
  leave: (how) => Promise.resolve(letGo(how)),
1747
2021
  leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
2022
+ recordShown: shownNow,
1748
2023
  post,
1749
2024
  setEditing(next) {
1750
2025
  if (next === editing)
@@ -1773,7 +2048,7 @@ function innerForm(options, within, place, outer) {
1773
2048
  }
1774
2049
  const own = shown.map(unticked).find(Boolean) ??
1775
2050
  shown.map((node) => checkInput(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
1776
- checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
2051
+ checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today(), shownWidget(shown));
1777
2052
  return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
1778
2053
  },
1779
2054
  setUnread(path, unread) {
@@ -1819,6 +2094,12 @@ function innerForm(options, within, place, outer) {
1819
2094
  };
1820
2095
  },
1821
2096
  settle: () => readTyped() ?? Promise.resolve(),
2097
+ botCheck(seen) {
2098
+ if (seen.honeypot !== undefined)
2099
+ bot.honeypot = seen.honeypot;
2100
+ if (seen.captcha !== undefined)
2101
+ bot.captcha = seen.captcha;
2102
+ },
1822
2103
  reading: () => readingNow > 0 || [...attached.values()].some((part) => part.inner.form.reading()),
1823
2104
  validate() {
1824
2105
  readTyped();
@@ -1828,6 +2109,27 @@ function innerForm(options, within, place, outer) {
1828
2109
  },
1829
2110
  save: () => track(save()),
1830
2111
  canUpload: () => typeof options.dataSource?.upload === 'function',
2112
+ today,
2113
+ canFindSlots: () => typeof options.dataSource?.slots === 'function',
2114
+ findSlots(field, ask) {
2115
+ const source = options.dataSource;
2116
+ if (!source?.slots)
2117
+ return Promise.reject(new Error('The data source has no slots()'));
2118
+ const def = page.fields[field];
2119
+ const { schedule = field, length, between, from, to } = ask;
2120
+ return source.slots({
2121
+ model: page.data.kind === 'record' ? page.data.model : '',
2122
+ field,
2123
+ recordId: state.recordId,
2124
+ schedule,
2125
+ from,
2126
+ to,
2127
+ timeZone: (def?.type === 'datetime' && def.timeZone) || page.timeZone || ownZone(),
2128
+ ...(length ? { length } : {}),
2129
+ ...(between ? { between } : {}),
2130
+ values: structuredCopy(state.values),
2131
+ });
2132
+ },
1831
2133
  upload: (field, files, how = {}) => uploads.start(field, files, how),
1832
2134
  uploading: (field) => state.uploads.filter((upload) => upload.field === field && !upload.line),
1833
2135
  cancelUpload(id) {
@@ -1853,8 +2155,11 @@ function innerForm(options, within, place, outer) {
1853
2155
  // The person's own Discard: nothing of theirs is left at risk, and a save on its way lands as the record it stored.
1854
2156
  resets++;
1855
2157
  personal.clear();
2158
+ personalLines.clear();
1856
2159
  filed.clear();
1857
2160
  sentAs = null;
2161
+ // Started again: a new response, timed from now.
2162
+ bot = { ...bot, honeypot: false, since: clock() };
1858
2163
  const values = syncParts(structuredCopy(baseline), 'start');
1859
2164
  set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, conflict: null, unsent: [], unread: {} });
1860
2165
  },
@@ -1867,7 +2172,7 @@ function innerForm(options, within, place, outer) {
1867
2172
  const lines = (state.values[field] ?? []).map((line) => line.key === key ? { ...line, values: { ...line.values, [name]: structuredCopy(value) } } : line);
1868
2173
  writeValues({ ...state.values, [field]: lines });
1869
2174
  afterEdit(field);
1870
- changed(field, 'person');
2175
+ changed(field, 'person', { line: key });
1871
2176
  },
1872
2177
  moveLine(field, key, to) {
1873
2178
  const def = lineField(field);
@@ -1912,10 +2217,10 @@ function innerForm(options, within, place, outer) {
1912
2217
  const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
1913
2218
  writeValues({ ...state.values, [field]: lines });
1914
2219
  afterEdit(field);
1915
- changed(field, 'person');
2220
+ changed(field, 'person', { line: key });
1916
2221
  },
1917
2222
  async search(field, query, limit = 8, options = {}) {
1918
- return runSearch(fieldDef(field), field, context(), query, limit, options.model, options.ids);
2223
+ return runSearch(fieldDef(field), field, context(), query, limit, options);
1919
2224
  },
1920
2225
  async previewLine(field, key, values) {
1921
2226
  lineField(field);
@@ -1940,7 +2245,7 @@ function innerForm(options, within, place, outer) {
1940
2245
  const line = (state.values[field] ?? []).find((l) => l.key === key);
1941
2246
  if (!line)
1942
2247
  throw new Error(`"${field}" has no line "${key}"`);
1943
- return runSearch(sub, subfield, lineContext(line.values, def.fields, context(), line.id ?? null), query, limit, options.model, options.ids);
2248
+ return runSearch(sub, subfield, lineContext(line.values, def.fields, context(), line.id ?? null), query, limit, options);
1944
2249
  },
1945
2250
  canCreate: (field) => creatable(page.fields[field]) !== null,
1946
2251
  quickCreate: (field, typed) => runCreate(page.fields[field], field, typed, page.fields[field] ? createValuesOf(field) : {}),
@@ -2102,7 +2407,7 @@ function innerForm(options, within, place, outer) {
2102
2407
  lines: { field: source.field, keys: [...keys], ids: lines.flatMap((line) => (line.id === undefined ? [] : [line.id])), values: lines.map((line) => structuredCopy(line.values)) },
2103
2408
  });
2104
2409
  },
2105
- run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
2410
+ run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], typed: 'first', ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
2106
2411
  shown(tab) {
2107
2412
  // A wizard's steps are told by the form itself, as they are entered.
2108
2413
  if (steps.includes(tab))
@@ -2129,20 +2434,36 @@ function innerForm(options, within, place, outer) {
2129
2434
  },
2130
2435
  async reloadTheirs() {
2131
2436
  // A save on its way first: its answer is not dropped by the load.
2437
+ const asked = shownNow();
2132
2438
  await savesSettled();
2133
- // The person's changes, kept before theirs are loaded over them.
2439
+ if (!asked())
2440
+ return false;
2441
+ // What is still typed is the person's too: read, a name looked up waited for, before theirs are loaded.
2442
+ const waiting = readTyped();
2443
+ if (waiting)
2444
+ await waiting;
2445
+ if (!asked())
2446
+ return false;
2447
+ // The person's changes, kept before theirs are loaded over them; and text they left that does not read.
2134
2448
  const mine = {};
2135
2449
  for (const name of state.dirty)
2136
2450
  mine[name] = structuredCopy(state.values[name]);
2137
- await track(load(true));
2138
- if (state.status !== 'ready')
2451
+ const typed = state.unread;
2452
+ // The record loaded again is a record shown anew: what is set aside is laid on it alone.
2453
+ const loading = load(true);
2454
+ const loaded = generation;
2455
+ await track(loading);
2456
+ if (loaded !== generation || state.status !== 'ready')
2139
2457
  return false;
2140
2458
  // Set aside where theirs differ from mine: a change they made the same way is no loss.
2141
2459
  const aside = { ...(state.setAside ?? {}) };
2142
2460
  for (const [name, value] of Object.entries(mine))
2143
2461
  if (!sameValue(fields[name], value, state.values[name]))
2144
2462
  aside[name] = value;
2145
- set({ conflict: null, saveProblem: null, setAside: Object.keys(aside).length ? aside : null });
2463
+ // Text that does not read is no value to set aside: it stays in its box, unread with why, over their value —
2464
+ // never dropped unsaid. A line of theirs no longer there takes its cells' text with it.
2465
+ const unread = Object.fromEntries(Object.entries(typed).filter(([path]) => valueAt(state.values, path) !== gone));
2466
+ set({ conflict: null, saveProblem: null, setAside: Object.keys(aside).length ? aside : null, ...(Object.keys(unread).length ? { unread } : {}) });
2146
2467
  return true;
2147
2468
  },
2148
2469
  async keepMine() {
@@ -2153,6 +2474,7 @@ function innerForm(options, within, place, outer) {
2153
2474
  const source = options.dataSource;
2154
2475
  if (conflict && !told) {
2155
2476
  if (source?.load && state.recordId != null && page.data.kind === 'record') {
2477
+ const shown = shownNow();
2156
2478
  try {
2157
2479
  const now = loadedRecordOf(await track(source.load({ model: page.data.model, id: state.recordId, fields })));
2158
2480
  told = now.values;
@@ -2161,6 +2483,9 @@ function innerForm(options, within, place, outer) {
2161
2483
  catch {
2162
2484
  // Unread: said below.
2163
2485
  }
2486
+ // Read, or not, once another record is shown or the values let go: nothing is laid, nothing saved.
2487
+ if (!shown())
2488
+ return false;
2164
2489
  }
2165
2490
  // Still unread: saving now would go over work this person never saw. Nothing is saved; they are told, and choose again.
2166
2491
  if (!told) {
@@ -2223,7 +2548,8 @@ function innerForm(options, within, place, outer) {
2223
2548
  const given = state.values[part.name];
2224
2549
  // A record of no model of its own: its links search and its lists load, but it never loads, saves, sends or recalculates alone.
2225
2550
  const made = innerForm({
2226
- page: { ...inner, data: { kind: 'responses' } },
2551
+ // In the record's time zone, unless it names its own.
2552
+ page: { ...inner, data: { kind: 'responses' }, ...(inner.timeZone || !page.timeZone ? {} : { timeZone: page.timeZone }) },
2227
2553
  dataSource: options.dataSource,
2228
2554
  values: isRecord(given) ? given : {},
2229
2555
  onAction: options.onAction,
@@ -2236,7 +2562,7 @@ function innerForm(options, within, place, outer) {
2236
2562
  // Its files are the record's: stored for it, under the part's name.
2237
2563
  { model: where.model, recordId: where.recordId, under: `${where.under}${part.name}.` },
2238
2564
  // Its ways out are the record's: through this form's check.
2239
- { letGo, close, leaveNow: (how) => atOnce(how) });
2565
+ { letGo, close, leaveNow: (how) => atOnce(how), recordShown: shownNow });
2240
2566
  attached.set(id, { name: part.name, inner: made });
2241
2567
  // A change inside it is told as a change of its answers here, whoever made it.
2242
2568
  made.form.on('change', ({ by }) => {