@fieldia/core 0.23.0 → 0.25.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 (221) hide show
  1. package/README.md +211 -3
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +1051 -325
  4. package/src/index.d.mts +9 -2
  5. package/src/index.d.ts +9 -2
  6. package/src/index.js +31 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +12 -2
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/format/actions.d.mts +31 -1
  11. package/src/lib/format/actions.d.ts +31 -1
  12. package/src/lib/format/actions.js +1 -0
  13. package/src/lib/format/actions.js.map +1 -1
  14. package/src/lib/format/actions.mjs +1 -0
  15. package/src/lib/format/actions.mjs.map +1 -1
  16. package/src/lib/format/autofill-group.d.mts +12 -0
  17. package/src/lib/format/autofill-group.d.ts +12 -0
  18. package/src/lib/format/autofill-group.js +23 -0
  19. package/src/lib/format/autofill-group.js.map +1 -0
  20. package/src/lib/format/autofill-group.mjs +19 -0
  21. package/src/lib/format/autofill-group.mjs.map +1 -0
  22. package/src/lib/format/autofill.d.mts +14 -0
  23. package/src/lib/format/autofill.d.ts +14 -0
  24. package/src/lib/format/autofill.js +54 -0
  25. package/src/lib/format/autofill.js.map +1 -0
  26. package/src/lib/format/autofill.mjs +48 -0
  27. package/src/lib/format/autofill.mjs.map +1 -0
  28. package/src/lib/format/bulk-edit.d.mts +18 -0
  29. package/src/lib/format/bulk-edit.d.ts +18 -0
  30. package/src/lib/format/bulk-edit.js +26 -0
  31. package/src/lib/format/bulk-edit.js.map +1 -0
  32. package/src/lib/format/bulk-edit.mjs +21 -0
  33. package/src/lib/format/bulk-edit.mjs.map +1 -0
  34. package/src/lib/format/duplicate-kinds.d.mts +10 -0
  35. package/src/lib/format/duplicate-kinds.d.ts +10 -0
  36. package/src/lib/format/duplicate-kinds.js +14 -0
  37. package/src/lib/format/duplicate-kinds.js.map +1 -0
  38. package/src/lib/format/duplicate-kinds.mjs +10 -0
  39. package/src/lib/format/duplicate-kinds.mjs.map +1 -0
  40. package/src/lib/format/duplicates.d.mts +22 -0
  41. package/src/lib/format/duplicates.d.ts +22 -0
  42. package/src/lib/format/duplicates.js +24 -0
  43. package/src/lib/format/duplicates.js.map +1 -0
  44. package/src/lib/format/duplicates.mjs +21 -0
  45. package/src/lib/format/duplicates.mjs.map +1 -0
  46. package/src/lib/format/field.d.mts +291 -34
  47. package/src/lib/format/field.d.ts +291 -34
  48. package/src/lib/format/field.js +19 -4
  49. package/src/lib/format/field.js.map +1 -1
  50. package/src/lib/format/field.mjs +17 -2
  51. package/src/lib/format/field.mjs.map +1 -1
  52. package/src/lib/format/import-fields.d.mts +18 -0
  53. package/src/lib/format/import-fields.d.ts +18 -0
  54. package/src/lib/format/import-fields.js +26 -0
  55. package/src/lib/format/import-fields.js.map +1 -0
  56. package/src/lib/format/import-fields.mjs +21 -0
  57. package/src/lib/format/import-fields.mjs.map +1 -0
  58. package/src/lib/format/layout.d.mts +7357 -3127
  59. package/src/lib/format/layout.d.ts +7357 -3127
  60. package/src/lib/format/layout.js +108 -32
  61. package/src/lib/format/layout.js.map +1 -1
  62. package/src/lib/format/layout.mjs +107 -32
  63. package/src/lib/format/layout.mjs.map +1 -1
  64. package/src/lib/format/layout.types.d.mts +5 -2
  65. package/src/lib/format/layout.types.d.ts +5 -2
  66. package/src/lib/format/page.d.mts +1080 -106
  67. package/src/lib/format/page.d.ts +1080 -106
  68. package/src/lib/format/page.js +25 -0
  69. package/src/lib/format/page.js.map +1 -1
  70. package/src/lib/format/page.mjs +26 -1
  71. package/src/lib/format/page.mjs.map +1 -1
  72. package/src/lib/format/phone.d.mts +27 -0
  73. package/src/lib/format/phone.d.ts +27 -0
  74. package/src/lib/format/phone.js +43 -0
  75. package/src/lib/format/phone.js.map +1 -0
  76. package/src/lib/format/phone.mjs +39 -0
  77. package/src/lib/format/phone.mjs.map +1 -0
  78. package/src/lib/format/references.d.mts +39 -0
  79. package/src/lib/format/references.d.ts +39 -0
  80. package/src/lib/format/references.js +263 -20
  81. package/src/lib/format/references.js.map +1 -1
  82. package/src/lib/format/references.mjs +263 -20
  83. package/src/lib/format/references.mjs.map +1 -1
  84. package/src/lib/format/slots-options.d.mts +26 -0
  85. package/src/lib/format/slots-options.d.ts +26 -0
  86. package/src/lib/format/slots-options.js +35 -0
  87. package/src/lib/format/slots-options.js.map +1 -0
  88. package/src/lib/format/slots-options.mjs +31 -0
  89. package/src/lib/format/slots-options.mjs.map +1 -0
  90. package/src/lib/format/translate.js +8 -4
  91. package/src/lib/format/translate.js.map +1 -1
  92. package/src/lib/format/translate.mjs +8 -4
  93. package/src/lib/format/translate.mjs.map +1 -1
  94. package/src/lib/format/upgrade.d.mts +8 -3
  95. package/src/lib/format/upgrade.d.ts +8 -3
  96. package/src/lib/format/upgrade.js +8 -3
  97. package/src/lib/format/upgrade.js.map +1 -1
  98. package/src/lib/format/upgrade.mjs +8 -3
  99. package/src/lib/format/upgrade.mjs.map +1 -1
  100. package/src/lib/format/version.d.mts +2 -2
  101. package/src/lib/format/version.d.ts +2 -2
  102. package/src/lib/format/version.js +2 -2
  103. package/src/lib/format/version.js.map +1 -1
  104. package/src/lib/format/version.mjs +2 -2
  105. package/src/lib/format/version.mjs.map +1 -1
  106. package/src/lib/format/zoned.d.mts +8 -0
  107. package/src/lib/format/zoned.d.ts +8 -0
  108. package/src/lib/format/zoned.js +36 -0
  109. package/src/lib/format/zoned.js.map +1 -0
  110. package/src/lib/format/zoned.mjs +33 -0
  111. package/src/lib/format/zoned.mjs.map +1 -0
  112. package/src/lib/record/check.d.mts +6 -1
  113. package/src/lib/record/check.d.ts +6 -1
  114. package/src/lib/record/check.js +12 -4
  115. package/src/lib/record/check.js.map +1 -1
  116. package/src/lib/record/check.mjs +12 -4
  117. package/src/lib/record/check.mjs.map +1 -1
  118. package/src/lib/record/data-source.d.mts +309 -3
  119. package/src/lib/record/data-source.d.ts +309 -3
  120. package/src/lib/record/data-source.js +16 -0
  121. package/src/lib/record/data-source.js.map +1 -1
  122. package/src/lib/record/data-source.mjs +15 -0
  123. package/src/lib/record/data-source.mjs.map +1 -1
  124. package/src/lib/record/form.d.mts +116 -25
  125. package/src/lib/record/form.d.ts +116 -25
  126. package/src/lib/record/form.js +457 -78
  127. package/src/lib/record/form.js.map +1 -1
  128. package/src/lib/record/form.mjs +457 -78
  129. package/src/lib/record/form.mjs.map +1 -1
  130. package/src/lib/record/input-rules.d.mts +51 -9
  131. package/src/lib/record/input-rules.d.ts +51 -9
  132. package/src/lib/record/input-rules.js +119 -20
  133. package/src/lib/record/input-rules.js.map +1 -1
  134. package/src/lib/record/input-rules.mjs +118 -19
  135. package/src/lib/record/input-rules.mjs.map +1 -1
  136. package/src/lib/record/inputs.js +4 -2
  137. package/src/lib/record/inputs.js.map +1 -1
  138. package/src/lib/record/inputs.mjs +4 -2
  139. package/src/lib/record/inputs.mjs.map +1 -1
  140. package/src/lib/record/limits.d.mts +1 -1
  141. package/src/lib/record/limits.d.ts +1 -1
  142. package/src/lib/record/limits.js +4 -2
  143. package/src/lib/record/limits.js.map +1 -1
  144. package/src/lib/record/limits.mjs +4 -2
  145. package/src/lib/record/limits.mjs.map +1 -1
  146. package/src/lib/record/locales/ar.js +3 -2
  147. package/src/lib/record/locales/ar.js.map +1 -1
  148. package/src/lib/record/locales/ar.mjs +3 -2
  149. package/src/lib/record/locales/ar.mjs.map +1 -1
  150. package/src/lib/record/locales/de.js +3 -2
  151. package/src/lib/record/locales/de.js.map +1 -1
  152. package/src/lib/record/locales/de.mjs +3 -2
  153. package/src/lib/record/locales/de.mjs.map +1 -1
  154. package/src/lib/record/locales/fr.js +3 -2
  155. package/src/lib/record/locales/fr.js.map +1 -1
  156. package/src/lib/record/locales/fr.mjs +3 -2
  157. package/src/lib/record/locales/fr.mjs.map +1 -1
  158. package/src/lib/record/memory-data-source.d.mts +20 -2
  159. package/src/lib/record/memory-data-source.d.ts +20 -2
  160. package/src/lib/record/memory-data-source.js +153 -35
  161. package/src/lib/record/memory-data-source.js.map +1 -1
  162. package/src/lib/record/memory-data-source.mjs +154 -36
  163. package/src/lib/record/memory-data-source.mjs.map +1 -1
  164. package/src/lib/record/memory-duplicates.d.mts +19 -0
  165. package/src/lib/record/memory-duplicates.d.ts +19 -0
  166. package/src/lib/record/memory-duplicates.js +78 -0
  167. package/src/lib/record/memory-duplicates.js.map +1 -0
  168. package/src/lib/record/memory-duplicates.mjs +74 -0
  169. package/src/lib/record/memory-duplicates.mjs.map +1 -0
  170. package/src/lib/record/memory-schedules.d.mts +45 -0
  171. package/src/lib/record/memory-schedules.d.ts +45 -0
  172. package/src/lib/record/memory-schedules.js +81 -0
  173. package/src/lib/record/memory-schedules.js.map +1 -0
  174. package/src/lib/record/memory-schedules.mjs +78 -0
  175. package/src/lib/record/memory-schedules.mjs.map +1 -0
  176. package/src/lib/record/messages.d.mts +3 -1
  177. package/src/lib/record/messages.d.ts +3 -1
  178. package/src/lib/record/messages.js +3 -2
  179. package/src/lib/record/messages.js.map +1 -1
  180. package/src/lib/record/messages.mjs +3 -2
  181. package/src/lib/record/messages.mjs.map +1 -1
  182. package/src/lib/record/periods.d.mts +47 -0
  183. package/src/lib/record/periods.d.ts +47 -0
  184. package/src/lib/record/periods.js +86 -0
  185. package/src/lib/record/periods.js.map +1 -0
  186. package/src/lib/record/periods.mjs +79 -0
  187. package/src/lib/record/periods.mjs.map +1 -0
  188. package/src/lib/record/rules.js +5 -6
  189. package/src/lib/record/rules.js.map +1 -1
  190. package/src/lib/record/rules.mjs +5 -6
  191. package/src/lib/record/rules.mjs.map +1 -1
  192. package/src/lib/record/run.d.mts +75 -9
  193. package/src/lib/record/run.d.ts +75 -9
  194. package/src/lib/record/run.js +169 -26
  195. package/src/lib/record/run.js.map +1 -1
  196. package/src/lib/record/run.mjs +170 -27
  197. package/src/lib/record/run.mjs.map +1 -1
  198. package/src/lib/record/search.d.mts +13 -3
  199. package/src/lib/record/search.d.ts +13 -3
  200. package/src/lib/record/search.js +11 -4
  201. package/src/lib/record/search.js.map +1 -1
  202. package/src/lib/record/search.mjs +11 -4
  203. package/src/lib/record/search.mjs.map +1 -1
  204. package/src/lib/record/waits.d.mts +32 -0
  205. package/src/lib/record/waits.d.ts +32 -0
  206. package/src/lib/record/waits.js +89 -0
  207. package/src/lib/record/waits.js.map +1 -0
  208. package/src/lib/record/waits.mjs +85 -0
  209. package/src/lib/record/waits.mjs.map +1 -0
  210. package/src/lib/record/zones.d.mts +57 -0
  211. package/src/lib/record/zones.d.ts +57 -0
  212. package/src/lib/record/zones.js +147 -0
  213. package/src/lib/record/zones.js.map +1 -0
  214. package/src/lib/record/zones.mjs +132 -0
  215. package/src/lib/record/zones.mjs.map +1 -0
  216. package/src/runtime.d.mts +19 -9
  217. package/src/runtime.d.ts +19 -9
  218. package/src/runtime.js +37 -2
  219. package/src/runtime.js.map +1 -1
  220. package/src/runtime.mjs +9 -2
  221. 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';
@@ -13,6 +14,7 @@ import { rolesAllow } from './roles.mjs';
13
14
  import { compileFieldTone, compileTable } from './cells.mjs';
14
15
  import { fill, MESSAGES } from './messages.mjs';
15
16
  import { loadedRecordOf, saveProblemOf, } from './data-source.mjs';
17
+ import { waitingOn, workedOutFrom } from './waits.mjs';
16
18
  import { createUploads, fileBytesIn, uploadGoing, withoutFileBytes } from './uploads.mjs';
17
19
  import { hostScheduler } from './scheduler.mjs';
18
20
  import { createMoments, createRunner } from './run.mjs';
@@ -28,15 +30,23 @@ export function unsavedWork(state) {
28
30
  }
29
31
  /**
30
32
  * 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.
33
+ * change of theirs the last save did not send, or text they left since that
34
+ * does not read ("12a" counts as "12" does: `unsent`), files still on
35
+ * their way, changes Reload set aside, the changes of a save refused for a
36
+ * conflict, waiting for Reload or Keep mine (only a source that keeps
37
+ * versions has one) — and, `how.changes`, any change not saved. What
38
+ * `form.leave()` — the one check every way out goes through — asks about,
39
+ * once the save on its way and the files on theirs settled. Before a
40
+ * record's first save, a change not saved is not at risk: as in 0.20, each
41
+ * way out decides.
37
42
  */
38
43
  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));
44
+ return ((state.unsent ?? []).length > 0 ||
45
+ state.setAside != null ||
46
+ (state.uploads ?? []).some(uploadGoing) ||
47
+ // The person asked for these to be saved, and someone else's save came first: still theirs, and not saved.
48
+ (state.conflict != null && state.dirty.length > 0) ||
49
+ (how.changes === true && (state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0)));
40
50
  }
41
51
  /**
42
52
  * The record state behind a page: values, what changed, what is visible and
@@ -48,6 +58,8 @@ export function createForm(options) {
48
58
  const page = upgradePage(options.page);
49
59
  return innerForm(page === options.page ? options : { ...options, page }, []).form;
50
60
  }
61
+ /** Whether a run works a person's change out: a page's `change` steps, set off by it, or a run they set off. */
62
+ const worksOut = (chain) => chain.some((key) => key.startsWith('on.change.'));
51
63
  /** A line's cell whose line is no longer there. */
52
64
  const gone = Symbol('gone');
53
65
  const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -60,7 +72,8 @@ const asValue = (values) => values;
60
72
  * its ways out go through that one's check.
61
73
  */
62
74
  function innerForm(options, within, place, outer) {
63
- const { page } = options;
75
+ // Its time zone written into each date and time field naming none, for whatever reads one from its field alone.
76
+ const page = zonedPage(options.page);
64
77
  const scheduler = options.scheduler ?? hostScheduler;
65
78
  const messages = options.messages ?? MESSAGES.en;
66
79
  const index = indexLayout(page.layout);
@@ -124,7 +137,19 @@ function innerForm(options, within, place, outer) {
124
137
  const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
125
138
  const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
126
139
  const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
127
- const today = () => localDay(options.now?.() ?? new Date());
140
+ /** Today, on the clock of the page's time zone (the browser's own when it names none). */
141
+ const today = () => dayIn(options.now?.() ?? new Date(), page.timeZone);
142
+ const clock = () => (options.now?.() ?? new Date()).getTime();
143
+ /** What a page's bot check saw — its hidden box filled, the captcha's token — and when the page was shown or started again. */
144
+ let bot = { honeypot: false, captcha: null, since: clock() };
145
+ /** What the bot check saw as the answers go; none without one. */
146
+ function botReport() {
147
+ const check = page.botCheck;
148
+ if (!check)
149
+ return undefined;
150
+ const taken = Math.max(0, clock() - bot.since) / 1000;
151
+ return { honeypot: check.honeypot !== false && bot.honeypot, tooFast: taken < (check.minSeconds ?? 3), seconds: Math.round(taken * 10) / 10, ...(check.captcha ? { captcha: bot.captcha } : {}) };
152
+ }
128
153
  // 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
154
  let started = false;
130
155
  let editing = options.editing !== false;
@@ -134,6 +159,8 @@ function innerForm(options, within, place, outer) {
134
159
  const editingNow = () => editing && !(started && state.can?.edit === false);
135
160
  const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing: editingNow(), context: options.context, parent: parentContext });
136
161
  const computed = compileComputed(page, today, about);
162
+ /** Each value worked out from others — by the page, or by the app as the lines change — and what it waits for. */
163
+ const workedOut = workedOutFrom(page, !!options.dataSource?.onchange && page.data.kind === 'record', !!options.parent);
137
164
  const setWhen = compileSetWhen(page, today, about);
138
165
  /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
139
166
  const parts = new Map();
@@ -286,16 +313,48 @@ function innerForm(options, within, place, outer) {
286
313
  let generation = 0;
287
314
  /** Closed (`dispose`): a run still going stops quietly, as its dialog or page is gone — no other record replaced it. */
288
315
  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. */
316
+ /** Counted at each reset (the person's Discard): a save on its way then lands as the record it stored. */
317
+ let resets = 0;
318
+ /**
319
+ * THE test every late answer passes (`recordShown`): taken as it is asked for, true while the record it was asked
320
+ * 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
321
+ * dialog, a record step's — none is laid on another record, nor over the values the person's Discard let go.
322
+ */
323
+ /** The data source with a member a record's page asks of it, and the page's model: refused where either is missing. */
324
+ function recordSource(member) {
325
+ const source = options.dataSource;
326
+ if (page.data.kind !== 'record' || !source?.[member])
327
+ throw new Error(`The data source has no ${member}()`);
328
+ return [source, page.data.model];
329
+ }
330
+ function shownNow() {
331
+ const at = generation;
332
+ const reset = resets;
333
+ // A saved form placed in a record is the record's: another shown round it, its answers are laid nowhere either.
334
+ const round = outer?.recordShown();
335
+ return () => !disposed && at === generation && reset === resets && (!round || round());
336
+ }
337
+ /**
338
+ * The person's claims on the record: the fields whose last write was theirs. A step's, a rule's or the app's
339
+ * write of the whole field takes one over; a write that works the person's change out (the source's onchange, the
340
+ * page's `change` steps it sets off) keeps it.
341
+ */
290
342
  const personal = new Set();
343
+ /**
344
+ * Of a table the person did not write whole: the keys of the lines they changed, added or took away. A line a
345
+ * step adds is none of theirs, and leaves theirs as they are.
346
+ */
347
+ const personalLines = new Map();
291
348
  /** Fields where a file the person picked landed from the store: at risk until a save sends it, a first save or not. */
292
349
  const filed = new Set();
293
350
  /** 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
351
  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;
352
+ /**
353
+ * The records being deleted, by the person's choice, each by its generation: a way out of one of them lets their
354
+ * work in it go, unasked. Another record shown meanwhile is asked about as any.
355
+ */
356
+ const deleting = new Map();
357
+ const deletingShown = () => (deleting.get(generation) ?? 0) > 0;
299
358
  /** Each field's last comparison with what was sent, so a value that did not move is not compared again. */
300
359
  const compared = new Map();
301
360
  /** Fields showing a file that did not reach the store: shown until it is sent again or taken away, whatever else changes. */
@@ -433,8 +492,9 @@ function innerForm(options, within, place, outer) {
433
492
  const was = state.values;
434
493
  const shown = state.errors;
435
494
  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) {
495
+ // The person's changes the last save did not send, worked out again from the values as they are now, and from the
496
+ // text they left that does not read.
497
+ if (patch.unsent === undefined && (patch.values !== undefined || patch.unread !== undefined)) {
438
498
  const unsent = unsentNow();
439
499
  if (!sameList(unsent, state.unsent))
440
500
  state = { ...state, unsent };
@@ -463,42 +523,66 @@ function innerForm(options, within, place, outer) {
463
523
  for (const listener of [...heard])
464
524
  listener(payload);
465
525
  }
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);
526
+ /**
527
+ * A field written: told to the app, and — a person's change — the page's `change` steps for it run. `claim` says
528
+ * what of the person's it touches: `line`, the key of the one line of a table the person changed; `keep`, a write
529
+ * that leaves the person's claims as they are (a line a step adds, a write working their change out).
530
+ */
531
+ function changed(field, by, claim = {}) {
532
+ noteOrigin(field, by, claim);
469
533
  emit('change', { field, value: state.values[field], values: state.values, by });
470
534
  const list = page.on?.change?.[field];
471
535
  if (by === 'person' && list)
472
536
  void track(moment(`on.change.${field}`, list, [], true));
473
537
  }
474
- /** Another record is shown: nothing of the last one's is the person's work any more. */
538
+ /** 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
539
  function newGeneration() {
476
540
  generation++;
477
541
  personal.clear();
542
+ personalLines.clear();
478
543
  filed.clear();
479
544
  compared.clear();
480
545
  sentAs = null;
546
+ clearFiles();
481
547
  }
482
548
  /** 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
549
  const filedIn = (name) => filed.has(name) || [...attached.values()].some((part) => part.name === name && part.inner.files.filed());
484
550
  /**
485
551
  * The person's work no save took, by field, in the page's order: each of
486
552
  * their changes that differs from the record as the last save left it —
487
- * before a first save, only a file the store holds.
553
+ * a field they wrote, or a line of a table they changed — and text they
554
+ * left in a box of it that does not read (`unread`), which no save can
555
+ * send; before a first save, only a file the store holds.
488
556
  */
489
557
  function unsentNow() {
490
- if (!personal.size)
558
+ const typed = sentAs ? unreadFields() : null;
559
+ if (!personal.size && !personalLines.size && !typed?.size)
491
560
  return [];
492
561
  const out = [];
493
562
  for (const name of Object.keys(fields)) {
494
- if (!personal.has(name))
563
+ // Typed and left unread since a save: the person's, as a change is — a value changed to "12" is, so is "12a".
564
+ if (typed?.has(name)) {
565
+ out.push(name);
566
+ continue;
567
+ }
568
+ const lines = personalLines.get(name);
569
+ if (!personal.has(name) && !lines)
495
570
  continue;
496
571
  const from = sentAs ?? (filedIn(name) ? baseline : null);
497
- if (from && differs(name, state.values[name], from[name]))
572
+ if (!from)
573
+ continue;
574
+ if (lines ? linesDiffer(name, lines, state.values[name], from[name]) : differs(name, state.values[name], from[name]))
498
575
  out.push(name);
499
576
  }
500
577
  return out;
501
578
  }
579
+ /** The fields with text left unread in a box of theirs: a field's, a line's cell's, a property's. */
580
+ function unreadFields() {
581
+ const out = new Set();
582
+ for (const path of Object.keys(state.unread ?? {}))
583
+ out.add(path.split('.')[0]);
584
+ return out;
585
+ }
502
586
  function differs(name, value, sent) {
503
587
  const known = compared.get(name);
504
588
  if (known && known.value === value && known.sent === sent)
@@ -507,18 +591,53 @@ function innerForm(options, within, place, outer) {
507
591
  compared.set(name, { value, sent, differs: verdict });
508
592
  return verdict;
509
593
  }
594
+ /** Whether any of these lines of a table differs from the table as sent: changed, added, or taken away. */
595
+ function linesDiffer(name, keys, value, sent) {
596
+ const def = fields[name];
597
+ const find = (lines, key) => (lines ?? []).find((line) => line.key === key);
598
+ for (const key of keys) {
599
+ const now = find(value, key);
600
+ const was = find(sent, key);
601
+ if (!now && !was)
602
+ continue;
603
+ if (!now || !was)
604
+ return true;
605
+ if (now.values === was.values)
606
+ continue;
607
+ for (const sub of new Set([...Object.keys(now.values), ...Object.keys(was.values)])) {
608
+ if (!sameValue(def.fields[sub], now.values[sub], was.values[sub]))
609
+ return true;
610
+ }
611
+ }
612
+ return false;
613
+ }
510
614
  /** `unsent` as it is now, told when it changed. */
511
615
  function refreshUnsent() {
512
616
  const unsent = unsentNow();
513
617
  if (!sameList(unsent, state.unsent))
514
618
  set({ unsent });
515
619
  }
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
620
+ /**
621
+ * A field written, and by whom: the person's change is theirs — the field, or the line of a table they changed —
622
+ * until a step, a rule or the app writes the whole field in its place. A write that leaves their change where it
623
+ * is (`keep`: a line a step adds, a `change` step working out what they typed) leaves it theirs.
624
+ */
625
+ function noteOrigin(field, by, claim) {
626
+ if (by === 'person') {
627
+ if (claim.line === undefined) {
628
+ personal.add(field);
629
+ personalLines.delete(field);
630
+ }
631
+ else if (!personal.has(field)) {
632
+ const keys = personalLines.get(field) ?? new Set();
633
+ keys.add(claim.line);
634
+ personalLines.set(field, keys);
635
+ }
636
+ }
637
+ else if (!claim.keep) {
521
638
  personal.delete(field);
639
+ personalLines.delete(field);
640
+ }
522
641
  refreshUnsent();
523
642
  }
524
643
  /** A wizard step entered: told to the app, and its `show` steps run. */
@@ -688,10 +807,12 @@ function innerForm(options, within, place, outer) {
688
807
  const ticks = node.source.widget === 'tick' && page.fields[name].type === 'boolean';
689
808
  return ticks && state.values[name] !== true && nodeState(node.id).required ? messages.tick : undefined;
690
809
  }
810
+ /** The widget a field is shown with where it keeps its value its own way (a per cent): the first of its nodes'. */
811
+ const shownWidget = (shown) => shown.map((node) => node.source.widget).find((widget) => widget === 'percentage');
691
812
  /** A field node's error now: its field's own rules first, then its answer rules. */
692
813
  function nodeError(node) {
693
814
  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;
815
+ 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
816
  }
696
817
  /** Warnings for the visible fields of the page, without publishing them. */
697
818
  function collectWarnings() {
@@ -775,7 +896,7 @@ function innerForm(options, within, place, outer) {
775
896
  const cell = ruled[sub];
776
897
  if (cell?.invisible || (tables.has(node.id) && tables.get(node.id).compiled.hidden(sub, reading().context, reading().env, userRoles)))
777
898
  continue;
778
- const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today());
899
+ const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today(), node.source.cells?.[sub]?.widget);
779
900
  if (lineMessage)
780
901
  errors[`${name}.${line.key}.${sub}`] = lineMessage;
781
902
  }
@@ -840,7 +961,7 @@ function innerForm(options, within, place, outer) {
840
961
  if (isEmpty(fieldDef(field), value))
841
962
  return undefined;
842
963
  return (shown.map((node) => checkInput(page.fields[field], node.source, value, messages)).find(Boolean) ??
843
- besidesRows(field, checkValue(checked(field), value, false, messages, today())) ??
964
+ besidesRows(field, checkValue(checked(field), value, false, messages, today(), shownWidget(shown))) ??
844
965
  shown.map((node) => ruleCheck(node).error).find(Boolean));
845
966
  }
846
967
  if (parts.length === 2 && page.fields[field]?.type === 'properties')
@@ -1026,8 +1147,10 @@ function innerForm(options, within, place, outer) {
1026
1147
  return;
1027
1148
  const seq = ++onchangeSeq;
1028
1149
  const request = { model: page.data.model, id: state.recordId, changed, values: structuredCopy(state.values) };
1150
+ const shown = shownNow();
1029
1151
  void track(source.onchange(request).then((result) => {
1030
- if (seq !== onchangeSeq)
1152
+ // A newer change asked since; or the record it was asked for is gone, or its values let go: none of it is laid.
1153
+ if (seq !== onchangeSeq || !shown())
1031
1154
  return;
1032
1155
  const values = { ...state.values, ...(result.values ?? {}) };
1033
1156
  writeValues(values, { warning: result.warning ?? null, warningField: result.warning ? changed : null });
@@ -1128,18 +1251,27 @@ function innerForm(options, within, place, outer) {
1128
1251
  set({ status: 'error', error: problem.message, saveProblem: problem, conflict: null });
1129
1252
  return { reason: 'save', message: problem.message };
1130
1253
  }
1254
+ // Asked for the record shown now: another shown while it waits below, it saves nothing of that one, nor says anything there.
1255
+ const asked = generation;
1256
+ const elsewhere = () => ({ reason: 'save', message: messages.recordReplaced });
1131
1257
  // 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
1258
  // up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
1133
1259
  const waiting = readTyped();
1134
1260
  if (waiting)
1135
1261
  await waiting;
1262
+ if (asked !== generation)
1263
+ return elsewhere();
1136
1264
  // 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
1265
  if (filesGoing()) {
1138
1266
  set({ status: 'saving', error: null, saveProblem: null });
1139
1267
  await filesSettled();
1268
+ if (asked !== generation)
1269
+ return elsewhere();
1140
1270
  set({ status: 'ready' });
1141
1271
  }
1142
1272
  if (page.on?.beforeSave) {
1273
+ // Its run stops of itself on a record shown by anything but its own steps (a `call`'s `{ record }`, a reload):
1274
+ // those move the save on with it.
1143
1275
  const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
1144
1276
  if (!before.done) {
1145
1277
  // A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
@@ -1164,6 +1296,8 @@ function innerForm(options, within, place, outer) {
1164
1296
  try {
1165
1297
  // 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
1298
  await storeBytes(page.data.kind === 'responses' || state.recordId == null ? Object.keys(fields) : state.dirty);
1299
+ if (at !== generation)
1300
+ return elsewhere();
1167
1301
  const failed = fileProblems();
1168
1302
  if (Object.keys(failed).length) {
1169
1303
  set({ errors: failed, ...unstored(failed) });
@@ -1175,7 +1309,11 @@ function innerForm(options, within, place, outer) {
1175
1309
  const sent = shownValues();
1176
1310
  const before = state.values;
1177
1311
  at = generation;
1178
- await source.submit({ pageId: page.id, values: sent });
1312
+ const seen = botReport();
1313
+ await source.submit({ pageId: page.id, values: sent, ...(seen ? { bot: seen } : {}) });
1314
+ // The captcha's token is spent once sent: the next send waits for a new one.
1315
+ if (seen?.captcha)
1316
+ bot.captcha = null;
1179
1317
  // Answered for answers no longer shown — the form was started again meanwhile: the source has them, the form lays nothing.
1180
1318
  if (at !== generation)
1181
1319
  return null;
@@ -1183,7 +1321,7 @@ function innerForm(options, within, place, outer) {
1183
1321
  // What it sent is what an answer changed since is told against: still to send.
1184
1322
  sentAs = before;
1185
1323
  set({ status: 'saved', unsent: unsentNow() });
1186
- emit('send', { values: sent });
1324
+ emit('send', { values: sent, ...(seen ? { bot: seen } : {}) });
1187
1325
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
1188
1326
  return null;
1189
1327
  }
@@ -1219,7 +1357,8 @@ function innerForm(options, within, place, outer) {
1219
1357
  scheduleDraft();
1220
1358
  scheduleAutosave();
1221
1359
  }
1222
- emit('save', { recordId: state.recordId, values: state.values });
1360
+ // The record as this save left it: a change made while it was on its way is no value of it.
1361
+ emit('save', { recordId: state.recordId, values: baseline });
1223
1362
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
1224
1363
  return null;
1225
1364
  }
@@ -1275,8 +1414,12 @@ function innerForm(options, within, place, outer) {
1275
1414
  }
1276
1415
  /** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
1277
1416
  async function opening() {
1417
+ const at = generation;
1278
1418
  emit('open', { recordId: state.recordId, values: state.values });
1279
1419
  await moment('on.open', page.on?.open, [], true);
1420
+ // Another record shown while its open steps ran: that one's opening tells its own steps.
1421
+ if (at !== generation)
1422
+ return;
1280
1423
  showing = true;
1281
1424
  tellStep([]);
1282
1425
  }
@@ -1297,8 +1440,8 @@ function innerForm(options, within, place, outer) {
1297
1440
  for (const name of names)
1298
1441
  changed(name, 'app');
1299
1442
  }
1300
- /** Write fields — a step's or the app's — worked out once, and tell each. */
1301
- function write(written, by) {
1443
+ /** Write fields — a step's or the app's — worked out once, and tell each; `keep`, a write working the person's change out. */
1444
+ function write(written, by, keep = false) {
1302
1445
  const names = Object.keys(written);
1303
1446
  names.forEach(fieldDef);
1304
1447
  if (!names.length)
@@ -1307,8 +1450,9 @@ function innerForm(options, within, place, outer) {
1307
1450
  for (const name of names)
1308
1451
  afterEdit(name);
1309
1452
  for (const name of names)
1310
- changed(name, by);
1453
+ changed(name, by, { keep });
1311
1454
  }
1455
+ /** A line added: the person's, theirs; a step's, none of theirs, and the lines they changed stay theirs. */
1312
1456
  function addLine(field, values, by) {
1313
1457
  const def = lineField(field);
1314
1458
  const current = state.values[field] ?? [];
@@ -1331,7 +1475,7 @@ function innerForm(options, within, place, outer) {
1331
1475
  const lines = [...current, line];
1332
1476
  writeValues({ ...state.values, [field]: lines });
1333
1477
  afterEdit(field);
1334
- changed(field, by);
1478
+ changed(field, by, by === 'person' ? { line: key } : { keep: true });
1335
1479
  return key;
1336
1480
  }
1337
1481
  /**
@@ -1340,15 +1484,44 @@ function innerForm(options, within, place, outer) {
1340
1484
  */
1341
1485
  async function check(names) {
1342
1486
  names?.forEach(fieldDef);
1487
+ const at = generation;
1343
1488
  const waiting = readTyped();
1344
1489
  if (waiting)
1345
1490
  await waiting;
1491
+ // Another record shown while it waited: nothing found is shown on that one.
1492
+ if (at !== generation)
1493
+ return replaced();
1346
1494
  const all = collectErrors();
1347
1495
  const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
1348
1496
  set({ errors: found });
1349
1497
  const first = Object.values(found)[0];
1350
1498
  return first === undefined ? null : { reason: 'check', message: first };
1351
1499
  }
1500
+ /**
1501
+ * What is typed read first, for a run the person or the app set off (`RunScope.typed`): every box read as a save
1502
+ * reads it, a name looked up waited for. Text left that does not read stops the run, said under its box as a check
1503
+ * says it — never a value under it handed on or worked from.
1504
+ */
1505
+ async function typedFirst() {
1506
+ const at = generation;
1507
+ const waiting = readTyped();
1508
+ if (waiting)
1509
+ await waiting;
1510
+ // Another record shown while it waited: the run is not for that one.
1511
+ if (at !== generation)
1512
+ return replaced();
1513
+ const found = unreadProblems();
1514
+ const first = Object.values(found)[0];
1515
+ if (first === undefined)
1516
+ return null;
1517
+ set({ errors: { ...state.errors, ...found } });
1518
+ return { reason: 'check', message: first };
1519
+ }
1520
+ /** The problems of text left unread in boxes shown — this form's and its saved forms' placed here — by path. */
1521
+ function unreadProblems() {
1522
+ const inner = (path) => [...attached.values()].some((part) => path.startsWith(`${part.name}.`) && !!part.inner.form.getState().unread?.[path.slice(part.name.length + 1)]);
1523
+ return Object.fromEntries(Object.entries(collectErrors()).filter(([path]) => !!state.unread[path] || inner(path)));
1524
+ }
1352
1525
  /** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
1353
1526
  async function goToStep(target, chain) {
1354
1527
  if (!steps.includes(target))
@@ -1356,16 +1529,19 @@ function innerForm(options, within, place, outer) {
1356
1529
  if (!form.steps().includes(target))
1357
1530
  return { reason: 'cannot', message: `The step "${target}" does not apply now` };
1358
1531
  // A name still being looked up in a box on the way is waited for, as a save waits for it.
1532
+ const at = generation;
1359
1533
  const waiting = readTyped();
1360
1534
  if (waiting)
1361
1535
  await waiting;
1536
+ if (at !== generation)
1537
+ return replaced();
1362
1538
  let went = false;
1363
1539
  inRun(chain, () => (went = form.goTo(target)));
1364
1540
  return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
1365
1541
  }
1366
1542
  /** 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
1543
  async function runTop(list, scope) {
1368
- scope.generation = generation;
1544
+ scope.generation ??= generation;
1369
1545
  const result = await runner.runSteps(list, scope);
1370
1546
  emit('run', { id: scope.id, steps: list, result });
1371
1547
  return result;
@@ -1383,7 +1559,7 @@ function innerForm(options, within, place, outer) {
1383
1559
  throw new Error(`"${name}" cannot make records: its data source has no create, or it is not a link`);
1384
1560
  return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
1385
1561
  }
1386
- async function runSearch(def, name, ctx, query, limit, model, ids) {
1562
+ async function runSearch(def, name, ctx, query, limit, { model, ids, exact } = {}) {
1387
1563
  // Formatted text names people and records in place (@mention, #4127): searched by the model they are of.
1388
1564
  const byModel = def.type === 'reference' || def.type === 'json' || def.type === 'html';
1389
1565
  if (def.type !== 'many2one' && def.type !== 'many2many' && !byModel) {
@@ -1398,7 +1574,7 @@ function innerForm(options, within, place, outer) {
1398
1574
  const own = byModel || !def.filter ? undefined : resolveFilter(def.filter, ctx);
1399
1575
  const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
1400
1576
  const relation = byModel ? model : def.relation;
1401
- return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
1577
+ return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit, ...(exact ? { exact: true } : {}) }));
1402
1578
  }
1403
1579
  function lineField(field) {
1404
1580
  const def = fieldDef(field);
@@ -1499,7 +1675,7 @@ function innerForm(options, within, place, outer) {
1499
1675
  }
1500
1676
  }
1501
1677
  /** 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));
1678
+ const atOnce = (how, own = false) => !deletingShown() && !checking && !busy(own) && (how.discard === true || !workAtRisk(state, how));
1503
1679
  /** The saves on their way, settled: what a load that is not a way out (a conflict's Reload) waits for. */
1504
1680
  async function savesSettled() {
1505
1681
  while (saveRunning)
@@ -1525,10 +1701,13 @@ function innerForm(options, within, place, outer) {
1525
1701
  if (outer)
1526
1702
  return outer.letGo(how, chain);
1527
1703
  // A delete the person chose: their work goes with the record, unasked.
1528
- if (deletes) {
1704
+ if (deletingShown()) {
1529
1705
  forget();
1530
1706
  return true;
1531
1707
  }
1708
+ // The person's own Discard stops the files on their way, never waits for them.
1709
+ if (how.discard)
1710
+ clearFiles();
1532
1711
  const own = chain.includes('on.beforeSave');
1533
1712
  if (atOnce(how, own)) {
1534
1713
  if (how.discard)
@@ -1571,13 +1750,46 @@ function innerForm(options, within, place, outer) {
1571
1750
  forget();
1572
1751
  return yes;
1573
1752
  }
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. */
1753
+ /**
1754
+ * Let go of the person's work at risk: files on their way stop, changes set aside go, a conflict's changes are
1755
+ * asked about no more, and the changes no save took go back to the record as the last save left it — so what a
1756
+ * dialog or a page in place hands back once it saved is the record as saved, never the change let go.
1757
+ */
1575
1758
  function forget() {
1759
+ const back = unsentLetGo();
1576
1760
  clearFiles();
1577
1761
  personal.clear();
1762
+ personalLines.clear();
1578
1763
  filed.clear();
1579
- if (state.setAside !== null || state.unsent.length)
1580
- set({ setAside: null, unsent: [] });
1764
+ if (back)
1765
+ writeValues({ ...state.values, ...back });
1766
+ // Text left that does not read goes too: each box shows what its field holds.
1767
+ const unread = Object.keys(state.unread).length ? { unread: {} } : {};
1768
+ if (state.setAside !== null || state.unsent.length || state.conflict !== null || unread.unread)
1769
+ set({ setAside: null, unsent: [], conflict: null, ...unread });
1770
+ }
1771
+ /** The fields holding the person's changes no save took, as the record as last saved holds them: their lines alone, for a table. */
1772
+ function unsentLetGo() {
1773
+ if (!state.unsent.length)
1774
+ return null;
1775
+ const out = {};
1776
+ for (const name of state.unsent) {
1777
+ const from = sentAs ?? baseline;
1778
+ const keys = personalLines.get(name);
1779
+ out[name] = keys && !personal.has(name) ? linesAsSent(state.values[name], from[name], keys) : structuredCopy(from[name]);
1780
+ }
1781
+ return out;
1782
+ }
1783
+ /** A table with these lines as sent: changed ones as they were, added ones gone, ones taken away back in their place. */
1784
+ function linesAsSent(value, sent, keys) {
1785
+ const was = sent ?? [];
1786
+ const of = (key) => was.find((line) => line.key === key);
1787
+ const out = (value ?? []).filter((line) => !keys.has(line.key) || of(line.key)).map((line) => (keys.has(line.key) ? structuredCopy(of(line.key)) : line));
1788
+ was.forEach((line, at) => {
1789
+ if (keys.has(line.key) && !out.some((other) => other.key === line.key))
1790
+ out.splice(Math.min(at, out.length), 0, structuredCopy(line));
1791
+ });
1792
+ return out;
1581
1793
  }
1582
1794
  /** The check as a step meets it: null to go on — at once when nothing is at risk — else why the run stops. */
1583
1795
  function letGoStep(chain) {
@@ -1585,12 +1797,19 @@ function innerForm(options, within, place, outer) {
1585
1797
  return go === true ? null : go.then((yes) => (yes ? null : kept()));
1586
1798
  }
1587
1799
  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 = []) {
1800
+ /**
1801
+ * A step's own move — the record loaded again, or another shown — once the check let what is shown go: kept, the
1802
+ * person kept their work; replaced, another record was shown before it ended (the run's steps are not for that one).
1803
+ */
1804
+ async function ownMove(chain, move) {
1590
1805
  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));
1806
+ if (go !== true && !(await go))
1807
+ return kept();
1808
+ // The record it shows is a generation of its own, made as it starts: another made before it ends replaced it.
1809
+ const moving = move();
1810
+ const mine = generation;
1811
+ await moving;
1812
+ return mine === generation ? null : replaced();
1594
1813
  }
1595
1814
  /**
1596
1815
  * The host's close, as every `close` step reaches it: the form takes it
@@ -1612,6 +1831,10 @@ function innerForm(options, within, place, outer) {
1612
1831
  const go = letGo({}, chain);
1613
1832
  return go === true ? shut() : go.then((yes) => (yes ? shut() : kept()));
1614
1833
  }
1834
+ /** Why a run stops whose record was replaced while it went on — quietly, when the form was closed. */
1835
+ function replaced() {
1836
+ return disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced };
1837
+ }
1615
1838
  /** The record a record step asks the data source about. */
1616
1839
  const record = () => ({ model: page.data.model, id: state.recordId, fields });
1617
1840
  async function post(message) {
@@ -1634,18 +1857,30 @@ function innerForm(options, within, place, outer) {
1634
1857
  recordId: () => state.recordId,
1635
1858
  reading,
1636
1859
  today,
1637
- write: (values) => write(values, 'step'),
1860
+ // A step's write of a field takes the person's change in it over — unless the step works that change out: the
1861
+ // page's `change` steps, set off by it, as the source's onchange does.
1862
+ write: (values, chain) => write(values, 'step', worksOut(chain)),
1638
1863
  store: (values) => store(values),
1864
+ // The person's own change: the field's, or a line of it (a claim by line): put back, the whole field is theirs
1865
+ // again — at risk, asked about — never less than it was.
1866
+ mine: (field) => personal.has(field) || personalLines.has(field),
1867
+ putBack(values, mine) {
1868
+ write(values, 'step');
1869
+ for (const name of mine)
1870
+ noteOrigin(name, 'person', {});
1871
+ },
1639
1872
  addLine: (field, values) => void addLine(field, values, 'step'),
1640
1873
  check,
1874
+ typedFirst,
1641
1875
  save: saving,
1642
1876
  generation: () => generation,
1643
- gone: () => (disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced }),
1877
+ gone: replaced,
1644
1878
  async reload(chain) {
1645
1879
  if (state.recordId == null)
1646
1880
  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();
1881
+ const stopped = await ownMove(chain, () => load(true));
1882
+ if (stopped)
1883
+ return stopped;
1649
1884
  return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
1650
1885
  },
1651
1886
  reset(chain) {
@@ -1666,24 +1901,32 @@ function innerForm(options, within, place, outer) {
1666
1901
  changed: () => state.dirty.length > 0 || state.uploads.some(uploadGoing),
1667
1902
  letGo: letGoStep,
1668
1903
  async deleting(remove, done) {
1669
- deletes++;
1904
+ const of = generation;
1905
+ const shown = shownNow();
1906
+ deleting.set(of, (deleting.get(of) ?? 0) + 1);
1670
1907
  try {
1671
1908
  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))
1909
+ // Deleted: what the person had not saved in it goes with it. Refused, it stays theirs. Another record shown
1910
+ // meanwhile keeps what was typed in it.
1911
+ if (done(out) && shown())
1674
1912
  forget();
1675
1913
  return out;
1676
1914
  }
1677
1915
  finally {
1678
- deletes--;
1916
+ const left = (deleting.get(of) ?? 1) - 1;
1917
+ if (left)
1918
+ deleting.set(of, left);
1919
+ else
1920
+ deleting.delete(of);
1679
1921
  }
1680
1922
  },
1681
1923
  ...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
1682
1924
  ...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
1683
1925
  ...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
1684
1926
  async showRecord(id, chain) {
1685
- if (!(await openRecord(id, chain)))
1686
- return kept();
1927
+ const stopped = await ownMove(chain, () => showNow(id));
1928
+ if (stopped)
1929
+ return stopped;
1687
1930
  return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
1688
1931
  },
1689
1932
  told(event, id) {
@@ -1702,8 +1945,24 @@ function innerForm(options, within, place, outer) {
1702
1945
  wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
1703
1946
  barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
1704
1947
  });
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`. */
1948
+ // 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
1949
+ // go when another record is shown by then (its steps were for that one).
1950
+ const moment = createMoments((key, list, chain, on) => runTop(list, { id: key, chain, generation: on }), () => generation);
1951
+ /**
1952
+ * Whether steps do something before their first save — set a status, call the app, open a page — so a record's
1953
+ * button made of them saves first. Steps that start with the save (Save and close), or only check before it, need
1954
+ * no save before them: theirs is the first.
1955
+ */
1956
+ function actsBeforeSaving(list) {
1957
+ const first = list.findIndex((step) => step.do === 'save');
1958
+ return first > 0 && list.slice(0, first).some((step) => step.do !== 'check');
1959
+ }
1960
+ /**
1961
+ * A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. A
1962
+ * record's button whose steps act and then save saves the record first when it is new or changed, as Flectra's
1963
+ * button does: refused — a field it requires left empty — none of its steps run, so nothing it does happens to a
1964
+ * record that cannot be saved.
1965
+ */
1707
1966
  function press(source, scope) {
1708
1967
  const list = [...(source.steps ?? [])];
1709
1968
  if (source.action !== undefined)
@@ -1714,12 +1973,37 @@ function innerForm(options, within, place, outer) {
1714
1973
  list.push({ do: builtin });
1715
1974
  const asked = builtin === 'archive' ? messages.archiveConfirm : builtin === 'delete' ? messages.deleteConfirm : undefined;
1716
1975
  const confirm = 'confirm' in source && source.confirm !== undefined ? source.confirm : asked;
1976
+ // On the record it was pressed on: another shown while its question is asked, it does nothing to that one.
1977
+ scope.generation ??= generation;
1978
+ // Pressed by the person: what is typed is read before a step reads the form or hands its values on.
1979
+ scope.typed ??= 'first';
1717
1980
  return track((async () => {
1718
1981
  if (confirm && !(await runner.ask(confirm))) {
1719
1982
  const result = { done: false, reason: 'no', message: confirm };
1720
1983
  emit('run', { id: scope.id, steps: list, result });
1721
1984
  return result;
1722
1985
  }
1986
+ const saveFirst = page.data.kind === 'record' && actsBeforeSaving(list);
1987
+ // What is typed is read first, as the run reads it before its first step that reads the form (run.ts): text
1988
+ // that does not read stops it with `check`, said under its box — before the save that comes first, and any step.
1989
+ if (saveFirst && scope.typed === 'first') {
1990
+ const unread = await typedFirst();
1991
+ if (unread) {
1992
+ const at = Math.max(0, list.findIndex((step) => step.do !== 'check'));
1993
+ const result = { done: false, stoppedAt: at, step: list[at], reason: unread.reason, ...(unread.message !== undefined ? { message: unread.message } : {}) };
1994
+ emit('run', { id: scope.id, steps: list, result });
1995
+ return result;
1996
+ }
1997
+ }
1998
+ // Then the save first, then the steps.
1999
+ if (saveFirst && (state.recordId == null || state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0 || state.uploads.some(uploadGoing))) {
2000
+ const first = await saving(scope.chain);
2001
+ if (first) {
2002
+ const result = { done: false, step: { do: 'save' }, reason: first.reason, ...(first.message !== undefined ? { message: first.message } : {}), ...(first.error !== undefined ? { error: first.error } : {}) };
2003
+ emit('run', { id: scope.id, steps: list, result });
2004
+ return result;
2005
+ }
2006
+ }
1723
2007
  return runTop(list, scope);
1724
2008
  })());
1725
2009
  }
@@ -1745,6 +2029,14 @@ function innerForm(options, within, place, outer) {
1745
2029
  openRecord: (id) => track(openRecord(id)),
1746
2030
  leave: (how) => Promise.resolve(letGo(how)),
1747
2031
  leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
2032
+ recordShown: shownNow,
2033
+ relatedFilter(id) {
2034
+ const node = index.get(id)?.source;
2035
+ if (node?.type !== 'related' || state.recordId == null)
2036
+ return null;
2037
+ const related = node;
2038
+ return [{ field: related.field, op: '=', value: state.recordId }, ...(related.filter ? resolveFilter(related.filter, context()) : [])];
2039
+ },
1748
2040
  post,
1749
2041
  setEditing(next) {
1750
2042
  if (next === editing)
@@ -1757,6 +2049,10 @@ function innerForm(options, within, place, outer) {
1757
2049
  loadChoices,
1758
2050
  loadDefinitions,
1759
2051
  addDefinition,
2052
+ waitsFor(name) {
2053
+ const from = workedOut.get(name);
2054
+ return from ? waitingOn(page, from, state.values, state.unread) : null;
2055
+ },
1760
2056
  problem(name) {
1761
2057
  fieldDef(name);
1762
2058
  // Only a field someone can see is asked anything, and required wherever it is shown so.
@@ -1773,7 +2069,7 @@ function innerForm(options, within, place, outer) {
1773
2069
  }
1774
2070
  const own = shown.map(unticked).find(Boolean) ??
1775
2071
  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());
2072
+ checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today(), shownWidget(shown));
1777
2073
  return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
1778
2074
  },
1779
2075
  setUnread(path, unread) {
@@ -1819,6 +2115,12 @@ function innerForm(options, within, place, outer) {
1819
2115
  };
1820
2116
  },
1821
2117
  settle: () => readTyped() ?? Promise.resolve(),
2118
+ botCheck(seen) {
2119
+ if (seen.honeypot !== undefined)
2120
+ bot.honeypot = seen.honeypot;
2121
+ if (seen.captcha !== undefined)
2122
+ bot.captcha = seen.captcha;
2123
+ },
1822
2124
  reading: () => readingNow > 0 || [...attached.values()].some((part) => part.inner.form.reading()),
1823
2125
  validate() {
1824
2126
  readTyped();
@@ -1828,6 +2130,50 @@ function innerForm(options, within, place, outer) {
1828
2130
  },
1829
2131
  save: () => track(save()),
1830
2132
  canUpload: () => typeof options.dataSource?.upload === 'function',
2133
+ today,
2134
+ recordReads: (id, values, fields) => recordContext(values, fields ?? page.fields, { recordId: id, user: options.user, editing: false, context: options.context }),
2135
+ canFindSlots: () => typeof options.dataSource?.slots === 'function',
2136
+ findSlots(field, ask) {
2137
+ const source = options.dataSource;
2138
+ if (!source?.slots)
2139
+ return Promise.reject(new Error('The data source has no slots()'));
2140
+ const def = page.fields[field];
2141
+ const { schedule = field, length, between, from, to } = ask;
2142
+ return source.slots({
2143
+ model: page.data.kind === 'record' ? page.data.model : '',
2144
+ field,
2145
+ recordId: state.recordId,
2146
+ schedule,
2147
+ from,
2148
+ to,
2149
+ timeZone: (def?.type === 'datetime' && def.timeZone) || page.timeZone || ownZone(),
2150
+ ...(length ? { length } : {}),
2151
+ ...(between ? { between } : {}),
2152
+ values: structuredCopy(state.values),
2153
+ });
2154
+ },
2155
+ canFindDuplicates: () => !!(page.data.kind === 'record' && page.duplicates?.fields?.length && options.dataSource?.duplicates),
2156
+ async findDuplicates() {
2157
+ const [source, model] = recordSource('duplicates');
2158
+ const fields = (page.duplicates?.fields ?? []).filter((name) => page.fields[name] && !isEmpty(page.fields[name], state.values[name]));
2159
+ const recordId = state.recordId;
2160
+ // Nothing typed in any of them: nothing asked.
2161
+ if (!fields.length)
2162
+ return [];
2163
+ const { matches } = await source.duplicates({ model, recordId, values: structuredCopy(state.values), fields });
2164
+ // Never the record itself, whatever the source says; the likest first where it scores them.
2165
+ const others = (matches ?? []).filter((match) => match && String(match.id) !== String(recordId));
2166
+ return others.every((match) => typeof match.score === 'number') ? others.sort((a, b) => b.score - a.score) : others;
2167
+ },
2168
+ canMerge: () => !!(page.data.kind === 'record' && options.dataSource?.merge && options.dataSource.load),
2169
+ async readRecord(id) {
2170
+ const [source, model] = recordSource('load');
2171
+ return loadedRecordOf(await source.load({ model, id, fields: page.fields })).values;
2172
+ },
2173
+ async merge(request) {
2174
+ const [source, model] = recordSource('merge');
2175
+ return source.merge({ model, keepId: request.keepId, mergeIds: [...request.mergeIds], values: structuredCopy(request.values), fields: page.fields });
2176
+ },
1831
2177
  upload: (field, files, how = {}) => uploads.start(field, files, how),
1832
2178
  uploading: (field) => state.uploads.filter((upload) => upload.field === field && !upload.line),
1833
2179
  cancelUpload(id) {
@@ -1853,8 +2199,11 @@ function innerForm(options, within, place, outer) {
1853
2199
  // 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
2200
  resets++;
1855
2201
  personal.clear();
2202
+ personalLines.clear();
1856
2203
  filed.clear();
1857
2204
  sentAs = null;
2205
+ // Started again: a new response, timed from now.
2206
+ bot = { ...bot, honeypot: false, since: clock() };
1858
2207
  const values = syncParts(structuredCopy(baseline), 'start');
1859
2208
  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
2209
  },
@@ -1867,7 +2216,7 @@ function innerForm(options, within, place, outer) {
1867
2216
  const lines = (state.values[field] ?? []).map((line) => line.key === key ? { ...line, values: { ...line.values, [name]: structuredCopy(value) } } : line);
1868
2217
  writeValues({ ...state.values, [field]: lines });
1869
2218
  afterEdit(field);
1870
- changed(field, 'person');
2219
+ changed(field, 'person', { line: key });
1871
2220
  },
1872
2221
  moveLine(field, key, to) {
1873
2222
  const def = lineField(field);
@@ -1912,10 +2261,10 @@ function innerForm(options, within, place, outer) {
1912
2261
  const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
1913
2262
  writeValues({ ...state.values, [field]: lines });
1914
2263
  afterEdit(field);
1915
- changed(field, 'person');
2264
+ changed(field, 'person', { line: key });
1916
2265
  },
1917
2266
  async search(field, query, limit = 8, options = {}) {
1918
- return runSearch(fieldDef(field), field, context(), query, limit, options.model, options.ids);
2267
+ return runSearch(fieldDef(field), field, context(), query, limit, options);
1919
2268
  },
1920
2269
  async previewLine(field, key, values) {
1921
2270
  lineField(field);
@@ -1940,7 +2289,7 @@ function innerForm(options, within, place, outer) {
1940
2289
  const line = (state.values[field] ?? []).find((l) => l.key === key);
1941
2290
  if (!line)
1942
2291
  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);
2292
+ return runSearch(sub, subfield, lineContext(line.values, def.fields, context(), line.id ?? null), query, limit, options);
1944
2293
  },
1945
2294
  canCreate: (field) => creatable(page.fields[field]) !== null,
1946
2295
  quickCreate: (field, typed) => runCreate(page.fields[field], field, typed, page.fields[field] ? createValuesOf(field) : {}),
@@ -2102,7 +2451,7 @@ function innerForm(options, within, place, outer) {
2102
2451
  lines: { field: source.field, keys: [...keys], ids: lines.flatMap((line) => (line.id === undefined ? [] : [line.id])), values: lines.map((line) => structuredCopy(line.values)) },
2103
2452
  });
2104
2453
  },
2105
- run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
2454
+ run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], typed: 'first', ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
2106
2455
  shown(tab) {
2107
2456
  // A wizard's steps are told by the form itself, as they are entered.
2108
2457
  if (steps.includes(tab))
@@ -2129,20 +2478,36 @@ function innerForm(options, within, place, outer) {
2129
2478
  },
2130
2479
  async reloadTheirs() {
2131
2480
  // A save on its way first: its answer is not dropped by the load.
2481
+ const asked = shownNow();
2132
2482
  await savesSettled();
2133
- // The person's changes, kept before theirs are loaded over them.
2483
+ if (!asked())
2484
+ return false;
2485
+ // What is still typed is the person's too: read, a name looked up waited for, before theirs are loaded.
2486
+ const waiting = readTyped();
2487
+ if (waiting)
2488
+ await waiting;
2489
+ if (!asked())
2490
+ return false;
2491
+ // The person's changes, kept before theirs are loaded over them; and text they left that does not read.
2134
2492
  const mine = {};
2135
2493
  for (const name of state.dirty)
2136
2494
  mine[name] = structuredCopy(state.values[name]);
2137
- await track(load(true));
2138
- if (state.status !== 'ready')
2495
+ const typed = state.unread;
2496
+ // The record loaded again is a record shown anew: what is set aside is laid on it alone.
2497
+ const loading = load(true);
2498
+ const loaded = generation;
2499
+ await track(loading);
2500
+ if (loaded !== generation || state.status !== 'ready')
2139
2501
  return false;
2140
2502
  // Set aside where theirs differ from mine: a change they made the same way is no loss.
2141
2503
  const aside = { ...(state.setAside ?? {}) };
2142
2504
  for (const [name, value] of Object.entries(mine))
2143
2505
  if (!sameValue(fields[name], value, state.values[name]))
2144
2506
  aside[name] = value;
2145
- set({ conflict: null, saveProblem: null, setAside: Object.keys(aside).length ? aside : null });
2507
+ // Text that does not read is no value to set aside: it stays in its box, unread with why, over their value —
2508
+ // never dropped unsaid. A line of theirs no longer there takes its cells' text with it.
2509
+ const unread = Object.fromEntries(Object.entries(typed).filter(([path]) => valueAt(state.values, path) !== gone));
2510
+ set({ conflict: null, saveProblem: null, setAside: Object.keys(aside).length ? aside : null, ...(Object.keys(unread).length ? { unread } : {}) });
2146
2511
  return true;
2147
2512
  },
2148
2513
  async keepMine() {
@@ -2153,6 +2518,7 @@ function innerForm(options, within, place, outer) {
2153
2518
  const source = options.dataSource;
2154
2519
  if (conflict && !told) {
2155
2520
  if (source?.load && state.recordId != null && page.data.kind === 'record') {
2521
+ const shown = shownNow();
2156
2522
  try {
2157
2523
  const now = loadedRecordOf(await track(source.load({ model: page.data.model, id: state.recordId, fields })));
2158
2524
  told = now.values;
@@ -2161,6 +2527,9 @@ function innerForm(options, within, place, outer) {
2161
2527
  catch {
2162
2528
  // Unread: said below.
2163
2529
  }
2530
+ // Read, or not, once another record is shown or the values let go: nothing is laid, nothing saved.
2531
+ if (!shown())
2532
+ return false;
2164
2533
  }
2165
2534
  // Still unread: saving now would go over work this person never saw. Nothing is saved; they are told, and choose again.
2166
2535
  if (!told) {
@@ -2223,7 +2592,8 @@ function innerForm(options, within, place, outer) {
2223
2592
  const given = state.values[part.name];
2224
2593
  // 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
2594
  const made = innerForm({
2226
- page: { ...inner, data: { kind: 'responses' } },
2595
+ // In the record's time zone, unless it names its own.
2596
+ page: { ...inner, data: { kind: 'responses' }, ...(inner.timeZone || !page.timeZone ? {} : { timeZone: page.timeZone }) },
2227
2597
  dataSource: options.dataSource,
2228
2598
  values: isRecord(given) ? given : {},
2229
2599
  onAction: options.onAction,
@@ -2236,7 +2606,7 @@ function innerForm(options, within, place, outer) {
2236
2606
  // Its files are the record's: stored for it, under the part's name.
2237
2607
  { model: where.model, recordId: where.recordId, under: `${where.under}${part.name}.` },
2238
2608
  // Its ways out are the record's: through this form's check.
2239
- { letGo, close, leaveNow: (how) => atOnce(how) });
2609
+ { letGo, close, leaveNow: (how) => atOnce(how), recordShown: shownNow });
2240
2610
  attached.set(id, { name: part.name, inner: made });
2241
2611
  // A change inside it is told as a change of its answers here, whoever made it.
2242
2612
  made.form.on('change', ({ by }) => {
@@ -2480,6 +2850,12 @@ function indexLayout(root) {
2480
2850
  else if (node.type === 'form') {
2481
2851
  add(node, parent, 'form', { invisible: node.invisible, readonly: node.readonly });
2482
2852
  }
2853
+ else if (node.type === 'related') {
2854
+ add(node, parent, 'other', { invisible: node.invisible });
2855
+ // A row's buttons: their roles here, hidden with the list, their condition read by it on each row's record.
2856
+ for (const button of node.rowButtons ?? [])
2857
+ add(button, node.id, 'button', {});
2858
+ }
2483
2859
  else {
2484
2860
  add(node, parent, 'other', { invisible: node.invisible });
2485
2861
  }
@@ -2506,6 +2882,9 @@ function indexLayout(root) {
2506
2882
  // A list holds no fields of its own record: only the buttons for what is selected.
2507
2883
  for (const button of root.actions ?? [])
2508
2884
  add(button, root.id, 'button', { invisible: button.invisible });
2885
+ // A row's buttons: their roles here, their condition read by the list on each row's record, which the form does not hold.
2886
+ for (const button of root.rowButtons ?? [])
2887
+ add(button, root.id, 'button', {});
2509
2888
  }
2510
2889
  else {
2511
2890
  // The title and statusbar show fields too, so they are validated like