@fieldia/core 0.20.0 → 0.22.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 (181) hide show
  1. package/README.md +116 -1
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +173 -1
  4. package/src/index.d.mts +2 -2
  5. package/src/index.d.ts +2 -2
  6. package/src/index.js +3 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +2 -2
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/expression/parser.js +4 -4
  11. package/src/lib/expression/parser.js.map +1 -1
  12. package/src/lib/expression/parser.mjs +5 -5
  13. package/src/lib/expression/parser.mjs.map +1 -1
  14. package/src/lib/expression/tokenizer.d.mts +12 -0
  15. package/src/lib/expression/tokenizer.d.ts +12 -0
  16. package/src/lib/expression/tokenizer.js +21 -12
  17. package/src/lib/expression/tokenizer.js.map +1 -1
  18. package/src/lib/expression/tokenizer.mjs +20 -12
  19. package/src/lib/expression/tokenizer.mjs.map +1 -1
  20. package/src/lib/format/check-page.d.mts +3 -2
  21. package/src/lib/format/check-page.d.ts +3 -2
  22. package/src/lib/format/check-page.js +9 -4
  23. package/src/lib/format/check-page.js.map +1 -1
  24. package/src/lib/format/check-page.mjs +10 -5
  25. package/src/lib/format/check-page.mjs.map +1 -1
  26. package/src/lib/format/field.d.mts +101 -0
  27. package/src/lib/format/field.d.ts +101 -0
  28. package/src/lib/format/field.js +25 -2
  29. package/src/lib/format/field.js.map +1 -1
  30. package/src/lib/format/field.mjs +24 -1
  31. package/src/lib/format/field.mjs.map +1 -1
  32. package/src/lib/format/layout.d.mts +142 -17
  33. package/src/lib/format/layout.d.ts +142 -17
  34. package/src/lib/format/layout.js +18 -1
  35. package/src/lib/format/layout.js.map +1 -1
  36. package/src/lib/format/layout.mjs +17 -0
  37. package/src/lib/format/layout.mjs.map +1 -1
  38. package/src/lib/format/layout.types.d.mts +6 -2
  39. package/src/lib/format/layout.types.d.ts +6 -2
  40. package/src/lib/format/page.d.mts +87 -10
  41. package/src/lib/format/page.d.ts +87 -10
  42. package/src/lib/format/page.js +5 -1
  43. package/src/lib/format/page.js.map +1 -1
  44. package/src/lib/format/page.mjs +6 -2
  45. package/src/lib/format/page.mjs.map +1 -1
  46. package/src/lib/format/pattern.d.mts +10 -0
  47. package/src/lib/format/pattern.d.ts +10 -0
  48. package/src/lib/format/pattern.js +33 -0
  49. package/src/lib/format/pattern.js.map +1 -0
  50. package/src/lib/format/pattern.mjs +30 -0
  51. package/src/lib/format/pattern.mjs.map +1 -0
  52. package/src/lib/format/references.d.mts +7 -0
  53. package/src/lib/format/references.d.ts +7 -0
  54. package/src/lib/format/references.js +54 -0
  55. package/src/lib/format/references.js.map +1 -1
  56. package/src/lib/format/references.mjs +54 -0
  57. package/src/lib/format/references.mjs.map +1 -1
  58. package/src/lib/format/translate.js +6 -4
  59. package/src/lib/format/translate.js.map +1 -1
  60. package/src/lib/format/translate.mjs +6 -4
  61. package/src/lib/format/translate.mjs.map +1 -1
  62. package/src/lib/format/upgrade.d.mts +41 -0
  63. package/src/lib/format/upgrade.d.ts +41 -0
  64. package/src/lib/format/upgrade.js +41 -0
  65. package/src/lib/format/upgrade.js.map +1 -0
  66. package/src/lib/format/upgrade.mjs +37 -0
  67. package/src/lib/format/upgrade.mjs.map +1 -0
  68. package/src/lib/format/validate.d.mts +2 -1
  69. package/src/lib/format/validate.d.ts +2 -1
  70. package/src/lib/format/validate.js +4 -2
  71. package/src/lib/format/validate.js.map +1 -1
  72. package/src/lib/format/validate.mjs +4 -2
  73. package/src/lib/format/validate.mjs.map +1 -1
  74. package/src/lib/format/version.d.mts +16 -1
  75. package/src/lib/format/version.d.ts +16 -1
  76. package/src/lib/format/version.js +39 -2
  77. package/src/lib/format/version.js.map +1 -1
  78. package/src/lib/format/version.mjs +36 -1
  79. package/src/lib/format/version.mjs.map +1 -1
  80. package/src/lib/record/check.d.mts +13 -3
  81. package/src/lib/record/check.d.ts +13 -3
  82. package/src/lib/record/check.js +87 -17
  83. package/src/lib/record/check.js.map +1 -1
  84. package/src/lib/record/check.mjs +85 -17
  85. package/src/lib/record/check.mjs.map +1 -1
  86. package/src/lib/record/data-source.d.mts +224 -10
  87. package/src/lib/record/data-source.d.ts +224 -10
  88. package/src/lib/record/data-source.js +29 -0
  89. package/src/lib/record/data-source.js.map +1 -1
  90. package/src/lib/record/data-source.mjs +25 -0
  91. package/src/lib/record/data-source.mjs.map +1 -1
  92. package/src/lib/record/filter.js +14 -2
  93. package/src/lib/record/filter.js.map +1 -1
  94. package/src/lib/record/filter.mjs +14 -2
  95. package/src/lib/record/filter.mjs.map +1 -1
  96. package/src/lib/record/form.d.mts +186 -3
  97. package/src/lib/record/form.d.ts +186 -3
  98. package/src/lib/record/form.js +1036 -57
  99. package/src/lib/record/form.js.map +1 -1
  100. package/src/lib/record/form.mjs +1037 -60
  101. package/src/lib/record/form.mjs.map +1 -1
  102. package/src/lib/record/input-rules.d.mts +277 -0
  103. package/src/lib/record/input-rules.d.ts +277 -0
  104. package/src/lib/record/input-rules.js +724 -0
  105. package/src/lib/record/input-rules.js.map +1 -0
  106. package/src/lib/record/input-rules.mjs +705 -0
  107. package/src/lib/record/input-rules.mjs.map +1 -0
  108. package/src/lib/record/inputs.d.mts +25 -0
  109. package/src/lib/record/inputs.d.ts +25 -0
  110. package/src/lib/record/inputs.js +135 -9
  111. package/src/lib/record/inputs.js.map +1 -1
  112. package/src/lib/record/inputs.mjs +131 -9
  113. package/src/lib/record/inputs.mjs.map +1 -1
  114. package/src/lib/record/locales/ar.js +23 -2
  115. package/src/lib/record/locales/ar.js.map +1 -1
  116. package/src/lib/record/locales/ar.mjs +23 -2
  117. package/src/lib/record/locales/ar.mjs.map +1 -1
  118. package/src/lib/record/locales/de.js +23 -2
  119. package/src/lib/record/locales/de.js.map +1 -1
  120. package/src/lib/record/locales/de.mjs +23 -2
  121. package/src/lib/record/locales/de.mjs.map +1 -1
  122. package/src/lib/record/locales/fr.js +23 -2
  123. package/src/lib/record/locales/fr.js.map +1 -1
  124. package/src/lib/record/locales/fr.mjs +23 -2
  125. package/src/lib/record/locales/fr.mjs.map +1 -1
  126. package/src/lib/record/memory-data-source.d.mts +40 -3
  127. package/src/lib/record/memory-data-source.d.ts +40 -3
  128. package/src/lib/record/memory-data-source.js +171 -23
  129. package/src/lib/record/memory-data-source.js.map +1 -1
  130. package/src/lib/record/memory-data-source.mjs +171 -23
  131. package/src/lib/record/memory-data-source.mjs.map +1 -1
  132. package/src/lib/record/messages.d.mts +43 -0
  133. package/src/lib/record/messages.d.ts +43 -0
  134. package/src/lib/record/messages.js +23 -2
  135. package/src/lib/record/messages.js.map +1 -1
  136. package/src/lib/record/messages.mjs +23 -2
  137. package/src/lib/record/messages.mjs.map +1 -1
  138. package/src/lib/record/rules.js +3 -1
  139. package/src/lib/record/rules.js.map +1 -1
  140. package/src/lib/record/rules.mjs +3 -1
  141. package/src/lib/record/rules.mjs.map +1 -1
  142. package/src/lib/record/run.d.mts +53 -9
  143. package/src/lib/record/run.d.ts +53 -9
  144. package/src/lib/record/run.js +68 -21
  145. package/src/lib/record/run.js.map +1 -1
  146. package/src/lib/record/run.mjs +68 -21
  147. package/src/lib/record/run.mjs.map +1 -1
  148. package/src/lib/record/search.d.mts +8 -1
  149. package/src/lib/record/search.d.ts +8 -1
  150. package/src/lib/record/search.js +28 -8
  151. package/src/lib/record/search.js.map +1 -1
  152. package/src/lib/record/search.mjs +28 -8
  153. package/src/lib/record/search.mjs.map +1 -1
  154. package/src/lib/record/structures.d.mts +8 -3
  155. package/src/lib/record/structures.d.ts +8 -3
  156. package/src/lib/record/structures.js +51 -7
  157. package/src/lib/record/structures.js.map +1 -1
  158. package/src/lib/record/structures.mjs +48 -6
  159. package/src/lib/record/structures.mjs.map +1 -1
  160. package/src/lib/record/upload-to.d.mts +50 -0
  161. package/src/lib/record/upload-to.d.ts +50 -0
  162. package/src/lib/record/upload-to.js +117 -0
  163. package/src/lib/record/upload-to.js.map +1 -0
  164. package/src/lib/record/upload-to.mjs +114 -0
  165. package/src/lib/record/upload-to.mjs.map +1 -0
  166. package/src/lib/record/uploads.d.mts +152 -0
  167. package/src/lib/record/uploads.d.ts +152 -0
  168. package/src/lib/record/uploads.js +345 -0
  169. package/src/lib/record/uploads.js.map +1 -0
  170. package/src/lib/record/uploads.mjs +337 -0
  171. package/src/lib/record/uploads.mjs.map +1 -0
  172. package/src/lib/record/values.d.mts +6 -0
  173. package/src/lib/record/values.d.ts +6 -0
  174. package/src/lib/record/values.js.map +1 -1
  175. package/src/lib/record/values.mjs.map +1 -1
  176. package/src/runtime.d.mts +15 -8
  177. package/src/runtime.d.ts +15 -8
  178. package/src/runtime.js +48 -1
  179. package/src/runtime.js.map +1 -1
  180. package/src/runtime.mjs +12 -5
  181. package/src/runtime.mjs.map +1 -1
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.unsavedWork = unsavedWork;
4
+ exports.workAtRisk = workAtRisk;
3
5
  exports.createForm = createForm;
4
6
  exports.notANumber = notANumber;
7
+ const upgrade_1 = require("../format/upgrade");
5
8
  const modifier_1 = require("../expression/modifier");
6
9
  const functions_1 = require("../expression/functions");
7
10
  const check_1 = require("./check");
@@ -16,22 +19,53 @@ const roles_1 = require("./roles");
16
19
  const cells_1 = require("./cells");
17
20
  const messages_1 = require("./messages");
18
21
  const data_source_1 = require("./data-source");
22
+ const uploads_1 = require("./uploads");
19
23
  const scheduler_1 = require("./scheduler");
20
24
  const run_1 = require("./run");
21
25
  const values_1 = require("./values");
26
+ /**
27
+ * Whether leaving the record now would lose the person's work: changes not
28
+ * saved, files still on their way to the store, or changes Reload set aside
29
+ * and not yet put back or let go, or text left in a box that does not read.
30
+ * Every way out of a record asks this.
31
+ */
32
+ function unsavedWork(state) {
33
+ return state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0 || state.setAside != null || (state.uploads ?? []).some(uploads_1.uploadGoing);
34
+ }
35
+ /**
36
+ * Whether a way out would lose the person's work without their knowing: a
37
+ * change of theirs the last save did not send (`unsent`), files still on
38
+ * their way, changes Reload set aside — and, `how.changes`, any change not
39
+ * saved. What `form.leave()` — the one check every way out goes through —
40
+ * asks about, once the save on its way and the files on theirs settled.
41
+ * Before a record's first save, a change not saved is not at risk: as in
42
+ * 0.20, each way out decides.
43
+ */
44
+ function workAtRisk(state, how = {}) {
45
+ return (state.unsent ?? []).length > 0 || state.setAside != null || (state.uploads ?? []).some(uploads_1.uploadGoing) || (how.changes === true && (state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0));
46
+ }
22
47
  /**
23
48
  * The record state behind a page: values, what changed, what is visible and
24
49
  * required, errors, loading and saving. No DOM and no framework — every
25
50
  * binding renders from `getState()` and calls these methods.
26
51
  */
27
52
  function createForm(options) {
28
- return innerForm(options, []).form;
53
+ // A page in an older format is read in the current one (upgradePage): the same object when it already is.
54
+ const page = (0, upgrade_1.upgradePage)(options.page);
55
+ return innerForm(page === options.page ? options : { ...options, page }, []).form;
29
56
  }
57
+ /** A line's cell whose line is no longer there. */
58
+ const gone = Symbol('gone');
30
59
  const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
31
60
  /** A saved form's answers, as the one value they are in the form round it. */
32
61
  const asValue = (values) => values;
33
- /** `within` are the pages round this one, outermost first: a page among them placed again would hold itself. */
34
- function innerForm(options, within) {
62
+ /**
63
+ * `within` are the pages round this one, outermost first: a page among them
64
+ * placed again would hold itself. `place`, for a saved form placed in a
65
+ * record: the record its files are stored for. `outer`, the form round it:
66
+ * its ways out go through that one's check.
67
+ */
68
+ function innerForm(options, within, place, outer) {
35
69
  const { page } = options;
36
70
  const scheduler = options.scheduler ?? scheduler_1.hostScheduler;
37
71
  const messages = options.messages ?? messages_1.MESSAGES.en;
@@ -102,7 +136,9 @@ function innerForm(options, within) {
102
136
  let editing = options.editing !== false;
103
137
  /** The record a line's form is of, as its conditions read it: worked out once. */
104
138
  const parentContext = options.parent ? (0, env_1.recordContext)(options.parent.values, options.parent.fields, { recordId: options.parent.id ?? null, user: options.user, context: options.context }) : undefined;
105
- const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing, context: options.context, parent: parentContext });
139
+ /** Whether the record is being edited now: not while a view locks it, nor when its source lets the person only read it. */
140
+ const editingNow = () => editing && !(started && state.can?.edit === false);
141
+ const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing: editingNow(), context: options.context, parent: parentContext });
106
142
  const computed = (0, compute_1.compileComputed)(page, today, about);
107
143
  const setWhen = (0, set_when_1.compileSetWhen)(page, today, about);
108
144
  /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
@@ -112,6 +148,38 @@ function innerForm(options, within) {
112
148
  parts.set(node.id, node.source);
113
149
  /** Those given their page, with the inner form that holds their answers. */
114
150
  const attached = new Map();
151
+ /** How each box with text typed and not yet read reads it now (`settling`). */
152
+ const readers = new Set();
153
+ /** Whether each box that says so is still being typed in (`settling`'s `typing`): autosave waits for them. */
154
+ const typings = new Set();
155
+ /** A box of this form, or of a saved form placed on it, still being typed in. */
156
+ const typingNow = () => [...typings].some((typing) => typing()) || [...attached.values()].some((part) => part.inner.typing());
157
+ /** Readings of typed text still waiting, as readTyped started them. */
158
+ let readingNow = 0;
159
+ /**
160
+ * Every box's text read now, this form's and its saved forms' placed on the page, before it is checked or saved:
161
+ * what a reading that waits (a name looked up) is waiting for, or null when none does. Such a box keeps its text
162
+ * unread meanwhile, so a check made now refuses it; a save waits.
163
+ */
164
+ function readTyped() {
165
+ const waits = [];
166
+ for (const read of [...readers]) {
167
+ const wait = read();
168
+ if (wait && typeof wait.then === 'function')
169
+ waits.push(wait.catch(() => undefined));
170
+ }
171
+ for (const part of attached.values()) {
172
+ const inner = part.inner.form;
173
+ const wait = inner.settle();
174
+ if (inner.reading())
175
+ waits.push(wait);
176
+ }
177
+ if (!waits.length)
178
+ return null;
179
+ // Waiting on its own readings, or a saved form's: `reading` says so until they end.
180
+ readingNow++;
181
+ return Promise.all(waits).then(() => void readingNow--);
182
+ }
115
183
  /** The page's fields, and each saved form's answers as a JSON value: what is loaded, told as changed and saved. */
116
184
  const fields = { ...page.fields };
117
185
  for (const part of parts.values())
@@ -205,8 +273,148 @@ function innerForm(options, within) {
205
273
  saveProblem: null,
206
274
  choices: {},
207
275
  definitions: {},
276
+ uploads: [],
277
+ version: null,
278
+ can: null,
279
+ conflict: null,
280
+ setAside: null,
281
+ unsent: [],
282
+ leaving: false,
283
+ unread: {},
208
284
  };
209
285
  started = true;
286
+ /**
287
+ * Each record shown — opened, loaded again, a new one — is a generation of
288
+ * its own: a save's answer, or a call's, started in another is not laid
289
+ * over it. What follows is kept for the generation shown, and starts again
290
+ * with the next.
291
+ */
292
+ let generation = 0;
293
+ /** Closed (`dispose`): a run still going stops quietly, as its dialog or page is gone — no other record replaced it. */
294
+ let disposed = false;
295
+ /** The fields whose last write was the person's: a step's, a rule's or the app's write takes one over. */
296
+ const personal = new Set();
297
+ /** Fields where a file the person picked landed from the store: at risk until a save sends it, a first save or not. */
298
+ const filed = new Set();
299
+ /** 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. */
300
+ let sentAs = null;
301
+ /** Counted at each reset (the person's Discard): a save on its way then lands as the record it stored. */
302
+ let resets = 0;
303
+ /** Above 0 while a delete the person chose goes on: a way out it takes lets their work go, unasked. */
304
+ let deletes = 0;
305
+ /** Each field's last comparison with what was sent, so a value that did not move is not compared again. */
306
+ const compared = new Map();
307
+ /** Fields showing a file that did not reach the store: shown until it is sent again or taken away, whatever else changes. */
308
+ const unstoredShown = new Set();
309
+ /** The record this form's files are for: its own, or — a saved form placed in one — that record, its fields under the part's name. */
310
+ const where = place ?? { model: () => (page.data.kind === 'record' ? page.data.model : page.id), recordId: () => state.recordId, under: '' };
311
+ /** This form's own files on their way; the state's `uploads` adds those of each saved form placed in it. */
312
+ let ownUploads = [];
313
+ /** The problem a save was stopped by for files that did not reach the store: it goes once none of them is one. */
314
+ let filesProblem = null;
315
+ /** Above 0 while a save stores the bytes held in saved forms placed here: their links in place of bytes are no change of the person's. */
316
+ let quietParts = 0;
317
+ /** Files on their way to the store: uploads.ts. */
318
+ const uploads = (0, uploads_1.createUploads)({
319
+ source: () => options.dataSource,
320
+ model: where.model,
321
+ recordId: where.recordId,
322
+ under: where.under,
323
+ lineId: (field, key) => (state.values[field] ?? []).find((line) => line.key === key)?.id,
324
+ fileField(field, line) {
325
+ const def = line ? (fields[line.field]?.type === 'one2many' ? fields[line.field].fields[field] : undefined) : fields[field];
326
+ return def?.type === 'binary' || def?.type === 'image' ? def : null;
327
+ },
328
+ read: (field, line) => (line ? (state.values[line.field] ?? []).find((l) => l.key === line.key)?.values[field] : state.values[field]),
329
+ write(field, line, value, quiet) {
330
+ if (!quiet) {
331
+ // A file the store holds now, picked by the person: at risk until a save sends it.
332
+ filed.add(line ? line.field : field);
333
+ return line ? form.updateLine(line.field, line.key, field, value) : form.setValue(field, value);
334
+ }
335
+ // A link in place of the bytes, as a save stores them: nothing else follows from it.
336
+ const values = line ? { [line.field]: (state.values[line.field] ?? []).map((l) => (l.key === line.key ? { ...l, values: { ...l.values, [field]: value } } : l)) } : { [field]: value };
337
+ writeValues({ ...state.values, ...values });
338
+ },
339
+ changed(list) {
340
+ ownUploads = list;
341
+ filesChanged();
342
+ },
343
+ track,
344
+ });
345
+ /**
346
+ * Files on their way changed, here or in a saved form placed here: the
347
+ * state shows them all, and a file sent again, or taken away, takes its
348
+ * problem with it — and the save it stopped is no longer said to be stopped
349
+ * once none of its files is a problem.
350
+ */
351
+ function filesChanged() {
352
+ const problems = fileProblems();
353
+ const gone = [...unstoredShown].filter((key) => !(key in problems));
354
+ for (const key of gone)
355
+ unstoredShown.delete(key);
356
+ const errors = gone.length ? Object.fromEntries(Object.entries(state.errors).filter(([key]) => !gone.includes(key))) : null;
357
+ const stopped = filesProblem;
358
+ const mended = stopped !== null && state.saveProblem === stopped && !Object.keys(stopped.fields ?? {}).some((key) => key in problems);
359
+ if (mended)
360
+ filesProblem = null;
361
+ set({ uploads: allUploads(), ...(errors ? { errors } : {}), ...(mended ? { status: 'ready', error: null, saveProblem: null } : {}) });
362
+ }
363
+ /** Every file on its way: this form's, then each saved form's placed in it, its id and field under the part's name. */
364
+ function allUploads() {
365
+ if (!attached.size)
366
+ return ownUploads;
367
+ const all = [...ownUploads];
368
+ for (const part of attached.values())
369
+ for (const upload of part.inner.form.getState().uploads)
370
+ all.push(underPart(part.name, upload));
371
+ return all;
372
+ }
373
+ /** What stops a save for files: this form's, and each saved form's placed in it under `name.field`. */
374
+ function fileProblems() {
375
+ const found = uploads.problems(messages);
376
+ for (const part of attached.values())
377
+ for (const [key, message] of Object.entries(part.inner.files.problems()))
378
+ found[`${part.name}.${key}`] = message;
379
+ return found;
380
+ }
381
+ const filesGoing = () => uploads.going() || [...attached.values()].some((part) => part.inner.files.going());
382
+ async function filesSettled() {
383
+ while (filesGoing())
384
+ await Promise.all([uploads.settled(), ...[...attached.values()].map((part) => part.inner.files.settled())]);
385
+ }
386
+ /** Store each file still held as its bytes in these fields — a saved form placed here holding one, among them — and wait for them all. */
387
+ async function storeBytes(names) {
388
+ quietParts++;
389
+ try {
390
+ const inParts = [...attached.values()].filter((part) => names.includes(part.name)).map((part) => part.inner.files.storeBytes());
391
+ await Promise.all([uploads.storeBytes((0, uploads_1.fileBytesIn)(fields, state.values, names)), ...inParts]);
392
+ }
393
+ finally {
394
+ quietParts--;
395
+ }
396
+ }
397
+ /** Values as a draft keeps them where files are stored apart: no file's bytes, a saved form's answers included. */
398
+ function draftOf(values) {
399
+ const kept = (0, uploads_1.withoutFileBytes)(fields, values);
400
+ for (const part of attached.values())
401
+ if (isRecord(kept[part.name]))
402
+ kept[part.name] = asValue(part.inner.files.draft(kept[part.name]));
403
+ return kept;
404
+ }
405
+ /** Stop every upload, here and in the saved forms placed here. */
406
+ function clearFiles() {
407
+ filesProblem = null;
408
+ uploads.clear();
409
+ for (const part of attached.values())
410
+ part.inner.files.clear();
411
+ }
412
+ /** The saved form placed here an upload's id names (`consent/upload-3`), with the id it knows it by; null for this form's own. */
413
+ function partUpload(id) {
414
+ const at = id.indexOf('/');
415
+ const part = at < 0 ? undefined : [...attached.values()].find((p) => p.name === id.slice(0, at));
416
+ return part ? { form: part.inner.form, id: id.slice(at + 1) } : null;
417
+ }
210
418
  /** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
211
419
  let serverErrors = {};
212
420
  let lineKeys = 0;
@@ -231,6 +439,12 @@ function innerForm(options, within) {
231
439
  const was = state.values;
232
440
  const shown = state.errors;
233
441
  state = { ...state, ...patch };
442
+ // The person's changes the last save did not send, worked out again from the values as they are now.
443
+ if (patch.unsent === undefined && patch.values !== undefined) {
444
+ const unsent = unsentNow();
445
+ if (!sameList(unsent, state.unsent))
446
+ state = { ...state, unsent };
447
+ }
234
448
  // The problems found inside a saved form are shown on it, beside its own fields.
235
449
  if (state.errors !== shown)
236
450
  for (const part of attached.values())
@@ -257,11 +471,62 @@ function innerForm(options, within) {
257
471
  }
258
472
  /** A field written: told to the app, and — a person's change — the page's `change` steps for it run. */
259
473
  function changed(field, by) {
474
+ noteOrigin(field, by);
260
475
  emit('change', { field, value: state.values[field], values: state.values, by });
261
476
  const list = page.on?.change?.[field];
262
477
  if (by === 'person' && list)
263
478
  void track(moment(`on.change.${field}`, list, [], true));
264
479
  }
480
+ /** Another record is shown: nothing of the last one's is the person's work any more. */
481
+ function newGeneration() {
482
+ generation++;
483
+ personal.clear();
484
+ filed.clear();
485
+ compared.clear();
486
+ sentAs = null;
487
+ }
488
+ /** 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. */
489
+ const filedIn = (name) => filed.has(name) || [...attached.values()].some((part) => part.name === name && part.inner.files.filed());
490
+ /**
491
+ * The person's work no save took, by field, in the page's order: each of
492
+ * their changes that differs from the record as the last save left it —
493
+ * before a first save, only a file the store holds.
494
+ */
495
+ function unsentNow() {
496
+ if (!personal.size)
497
+ return [];
498
+ const out = [];
499
+ for (const name of Object.keys(fields)) {
500
+ if (!personal.has(name))
501
+ continue;
502
+ const from = sentAs ?? (filedIn(name) ? baseline : null);
503
+ if (from && differs(name, state.values[name], from[name]))
504
+ out.push(name);
505
+ }
506
+ return out;
507
+ }
508
+ function differs(name, value, sent) {
509
+ const known = compared.get(name);
510
+ if (known && known.value === value && known.sent === sent)
511
+ return known.differs;
512
+ const verdict = value !== sent && !sameValue(fields[name], value, sent);
513
+ compared.set(name, { value, sent, differs: verdict });
514
+ return verdict;
515
+ }
516
+ /** `unsent` as it is now, told when it changed. */
517
+ function refreshUnsent() {
518
+ const unsent = unsentNow();
519
+ if (!sameList(unsent, state.unsent))
520
+ set({ unsent });
521
+ }
522
+ /** 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. */
523
+ function noteOrigin(field, by) {
524
+ if (by === 'person')
525
+ personal.add(field);
526
+ else
527
+ personal.delete(field);
528
+ refreshUnsent();
529
+ }
265
530
  /** A wizard step entered: told to the app, and its `show` steps run. */
266
531
  function tellStep(chain) {
267
532
  const step = state.step;
@@ -317,8 +582,24 @@ function innerForm(options, within) {
317
582
  function checked(name) {
318
583
  const def = page.fields[name];
319
584
  const loaded = state.choices[name]?.options;
585
+ if (def.type === 'properties' && def.definitionsFrom) {
586
+ // The definitions the linked record keeps, once loaded: each property checked as one of its kind.
587
+ const definitions = state.definitions[name]?.definitions;
588
+ return definitions ? { ...def, definitions } : def;
589
+ }
320
590
  return def.type === 'selection' && def.optionsFrom && loaded ? { ...def, options: loaded, optionsFrom: undefined } : def;
321
591
  }
592
+ /** A properties field's problems, each at its property's path (`field.property`), as its row shows them. */
593
+ function propertyProblems(name) {
594
+ const def = checked(name);
595
+ if (def.type !== 'properties')
596
+ return {};
597
+ return Object.fromEntries(Object.entries((0, check_1.checkProperties)(def.definitions, state.values[name], messages, today())).map(([property, message]) => [`${name}.${property}`, message]));
598
+ }
599
+ /** A field's own problem, less what its properties' rows say of themselves. */
600
+ function besidesRows(name, message) {
601
+ return message !== undefined && page.fields[name]?.type === 'properties' && Object.values(propertyProblems(name)).includes(message) ? undefined : message;
602
+ }
322
603
  function track(promise) {
323
604
  pending.add(promise);
324
605
  const done = () => pending.delete(promise);
@@ -327,10 +608,10 @@ function innerForm(options, within) {
327
608
  }
328
609
  /** The values as expressions read them, and what else they read: the record's id, the person, the lines' rows, today. */
329
610
  function reading() {
330
- if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editing) {
611
+ if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editingNow()) {
331
612
  const values = state.values;
332
613
  const context = (0, env_1.recordContext)(values, page.fields, about());
333
- contextCache = { values, recordId: state.recordId, editing, context, env: (0, env_1.expressionEnv)(values, page.fields, today) };
614
+ contextCache = { values, recordId: state.recordId, editing: editingNow(), context, env: (0, env_1.expressionEnv)(values, page.fields, today) };
334
615
  }
335
616
  return contextCache;
336
617
  }
@@ -360,6 +641,14 @@ function innerForm(options, within) {
360
641
  readonly = index.get(parent)?.readonly.evaluate(ctx, env) ?? false;
361
642
  }
362
643
  const def = node.field ? page.fields[node.field] : undefined;
644
+ // What the record's source lets this person do: a field hidden from them or locked, a button that would do what they may not.
645
+ const can = state.can;
646
+ if (can) {
647
+ if ((node.field !== undefined && can.hiddenFields?.includes(node.field)) || node.does?.some((right) => can[right] === false))
648
+ invisible = true;
649
+ if ((node.kind === 'field' || node.kind === 'form') && (can.edit === false || (node.field !== undefined && can.readonlyFields?.includes(node.field) === true)))
650
+ readonly = true;
651
+ }
363
652
  return {
364
653
  invisible,
365
654
  readonly: readonly || (def !== undefined && lockedField(def)),
@@ -367,8 +656,8 @@ function innerForm(options, within) {
367
656
  ...(node.pressed ? { pressed: node.pressed.evaluate(ctx, env) } : {}),
368
657
  };
369
658
  }
370
- /** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
371
- function visibleFieldNodes(root, kind = 'field') {
659
+ /** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now; `field`'s alone, when named. */
660
+ function visibleFieldNodes(root, kind = 'field', field) {
372
661
  const inside = (node) => {
373
662
  if (!root)
374
663
  return true;
@@ -386,7 +675,7 @@ function innerForm(options, within) {
386
675
  return true;
387
676
  return false;
388
677
  };
389
- return [...index.values()].filter((node) => node.kind === kind && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
678
+ return [...index.values()].filter((node) => node.kind === kind && (field === undefined || node.field === field) && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
390
679
  }
391
680
  /** What a field node's answer rules say of its value now: the first error and the first warning. */
392
681
  function ruleCheck(node) {
@@ -433,16 +722,52 @@ function innerForm(options, within) {
433
722
  state = previous;
434
723
  }
435
724
  }
725
+ /** A range's last day (`options.endField` of a `daterange`), the field it writes besides its own; null for any other node. */
726
+ function rangeEnd(node) {
727
+ const source = node.source;
728
+ const end = source.widget === 'daterange' ? source.options?.['endField'] : undefined;
729
+ return typeof end === 'string' && page.fields[end] && end !== node.field ? end : null;
730
+ }
731
+ /**
732
+ * A range's end, a field of its own though no node shows it alone: its required, min, max and days, and that it
733
+ * is not before its start ("Ends can’t be before Starts"). Required when its field is, or the range is.
734
+ */
735
+ function rangeEndError(node, end, required = nodeState(node.id).required) {
736
+ const def = page.fields[end];
737
+ const value = state.values[end];
738
+ const own = (0, check_1.checkValue)(checked(end), value, required || def.required === true, messages, today());
739
+ if (own)
740
+ return own;
741
+ const start = state.values[node.field];
742
+ if (typeof start !== 'string' || typeof value !== 'string' || !start || !value)
743
+ return undefined;
744
+ const at = (v) => (def.type === 'datetime' ? Date.parse(v) : Date.parse(v.slice(0, 10)));
745
+ if (at(value) < at(start))
746
+ return (0, messages_1.fill)(messages.endBeforeStart, { end: def.label, start: page.fields[node.field].label });
747
+ return undefined;
748
+ }
436
749
  /** Errors for the visible fields under `root`, without publishing them. */
437
750
  function collectErrors(root) {
438
751
  const errors = {};
752
+ const seen = new Set();
439
753
  for (const node of visibleFieldNodes(root)) {
440
754
  const name = node.field;
755
+ seen.add(name);
756
+ const end = rangeEnd(node);
757
+ if (end) {
758
+ seen.add(end);
759
+ const problem = errors[end] ? undefined : rangeEndError(node, end);
760
+ if (problem)
761
+ errors[end] = problem;
762
+ }
441
763
  if (errors[name])
442
764
  continue;
443
765
  const def = page.fields[name];
444
766
  const message = nodeError(node);
445
- if (message)
767
+ // A property says its own problem on its row; the field, only what is its own (that it is required).
768
+ if (def.type === 'properties')
769
+ Object.assign(errors, propertyProblems(name));
770
+ if (besidesRows(name, message))
446
771
  errors[name] = message;
447
772
  if (def.type === 'one2many') {
448
773
  for (const line of state.values[name] ?? []) {
@@ -463,6 +788,10 @@ function innerForm(options, within) {
463
788
  }
464
789
  }
465
790
  }
791
+ // Text left in a box that does not read answers for its field, in the words it was left with.
792
+ for (const [path, unread] of Object.entries(state.unread))
793
+ if (seen.has(path.split('.')[0]))
794
+ errors[path] = unread.message;
466
795
  // A saved form placed here answers for its fields by its own page's rules.
467
796
  for (const node of visibleFieldNodes(root, 'form')) {
468
797
  const part = attached.get(node.id);
@@ -472,6 +801,63 @@ function innerForm(options, within) {
472
801
  }
473
802
  return errors;
474
803
  }
804
+ /** The value at a path: a field's, or a line's cell (`field.lineKey.subfield`); `gone` when the line is not there. */
805
+ function valueAt(values, path) {
806
+ const parts = path.split('.');
807
+ // A property of a properties field (`field.property`).
808
+ if (parts.length === 2 && page.fields[parts[0]]?.type === 'properties') {
809
+ const held = values[parts[0]];
810
+ return held && typeof held === 'object' && !Array.isArray(held) ? held[parts[1]] : undefined;
811
+ }
812
+ if (parts.length < 3)
813
+ return values[path];
814
+ const [field, sub] = [parts[0], parts[parts.length - 1]];
815
+ const key = parts.slice(1, -1).join('.');
816
+ const line = (values[field] ?? []).find((l) => l.key === key);
817
+ return line ? line.values[sub] : gone;
818
+ }
819
+ /** Unread text whose field has since been written another way — a value typed that reads, the app's — is let go. */
820
+ function unreadKept(values) {
821
+ let kept = null;
822
+ for (const path of Object.keys(state.unread)) {
823
+ const now = valueAt(values, path);
824
+ if (now !== gone && now === valueAt(state.values, path))
825
+ continue;
826
+ kept ??= { ...state.unread };
827
+ delete kept[path];
828
+ }
829
+ return kept ?? state.unread;
830
+ }
831
+ /** A field's own problem as its box is left, never that it is required: undefined when it has none, or is not shown. */
832
+ function leftProblem(path) {
833
+ const parts = path.split('.');
834
+ const field = parts[0];
835
+ const shown = visibleFieldNodes(undefined, 'field', field);
836
+ // A range's end is left from its own box in the range: its problems, that it is before its start too.
837
+ const range = shown.length ? undefined : visibleFieldNodes().find((node) => rangeEnd(node) === field);
838
+ if (!shown.length && !range)
839
+ return undefined;
840
+ if (state.unread[path])
841
+ return state.unread[path].message;
842
+ if (range)
843
+ return (0, values_1.isEmpty)(fieldDef(field), state.values[field]) ? undefined : rangeEndError(range, field, false);
844
+ if (parts.length === 1) {
845
+ const value = state.values[field];
846
+ if ((0, values_1.isEmpty)(fieldDef(field), value))
847
+ return undefined;
848
+ return (shown.map((node) => (0, inputs_1.checkInput)(page.fields[field], node.source, value, messages)).find(Boolean) ??
849
+ besidesRows(field, (0, check_1.checkValue)(checked(field), value, false, messages, today())) ??
850
+ shown.map((node) => ruleCheck(node).error).find(Boolean));
851
+ }
852
+ if (parts.length === 2 && page.fields[field]?.type === 'properties')
853
+ return propertyProblems(field)[path];
854
+ const def = lineField(field);
855
+ const sub = def.fields[parts[parts.length - 1]];
856
+ const value = valueAt(state.values, path);
857
+ if (!sub || value === gone || (0, values_1.isEmpty)(sub, value))
858
+ return undefined;
859
+ return (0, check_1.checkValue)(sub, value, false, messages, today());
860
+ }
475
861
  /**
476
862
  * Answers changed from outside a saved form — loaded, put back, set — handed
477
863
  * to it, and what it holds after taken back: its defaults and worked-out
@@ -518,26 +904,32 @@ function innerForm(options, within) {
518
904
  /** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
519
905
  function writeValues(written, extra = {}, fresh = false) {
520
906
  const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
521
- let errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
522
- // Words typed where a number goes are said at once, as Flectra marks a box it cannot read; what is missing waits for sending.
523
- const unread = {};
907
+ // Text left unread in a box goes once its field is written another way, and its words with it.
908
+ const unread = unreadKept(values);
909
+ const before = state;
910
+ state = { ...state, unread };
911
+ let errors = Object.keys(before.errors).length ? revalidateShown(values) : before.errors;
912
+ state = before;
913
+ // Words where a number goes — from a server or an old draft: a box never sets them — are said at once; what is
914
+ // missing waits for sending.
915
+ const words = {};
524
916
  for (const name of Object.keys(written)) {
525
917
  const def = page.fields[name];
526
918
  if (written[name] === state.values[name] || !def)
527
919
  continue;
528
920
  if (notANumber(def, written[name]))
529
- unread[name] = (0, check_1.checkValue)(def, written[name], false, messages);
921
+ words[name] = (0, check_1.checkValue)(def, written[name], false, messages);
530
922
  if (def.type !== 'one2many')
531
923
  continue;
532
924
  for (const line of written[name] ?? []) {
533
925
  for (const [sub, v] of Object.entries(line.values))
534
926
  if (notANumber(def.fields[sub], v))
535
- unread[`${name}.${line.key}.${sub}`] = (0, check_1.checkValue)(def.fields[sub], v, false, messages);
927
+ words[`${name}.${line.key}.${sub}`] = (0, check_1.checkValue)(def.fields[sub], v, false, messages);
536
928
  }
537
929
  }
538
- if (Object.keys(unread).length)
539
- errors = { ...errors, ...unread };
540
- set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
930
+ if (Object.keys(words).length)
931
+ errors = { ...errors, ...words };
932
+ set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), unread, ...extra });
541
933
  }
542
934
  /** Once errors are on screen, keep them current as the person fixes things. */
543
935
  function revalidateShown(values) {
@@ -552,6 +944,11 @@ function innerForm(options, within) {
552
944
  else
553
945
  delete serverErrors[name];
554
946
  }
947
+ // A file that did not reach the store stays said under its field.
948
+ const unstoredNow = fileProblems();
949
+ for (const key of unstoredShown)
950
+ if (unstoredNow[key])
951
+ kept[key] ??= unstoredNow[key];
555
952
  return kept;
556
953
  }
557
954
  function scheduleDraft() {
@@ -562,9 +959,27 @@ function innerForm(options, within) {
562
959
  scheduler.clearTimeout(draftTimer);
563
960
  draftTimer = scheduler.setTimeout(() => {
564
961
  draftTimer = null;
565
- drafts.store.setItem(draftKey(), JSON.stringify({ savedAt: new Date().toISOString(), values: state.values }));
962
+ // Where files are stored apart, never a file's bytes: they would fill the browser's store (uploads.ts). Without, kept as always.
963
+ // A store that is full anyway keeps no draft.
964
+ const values = options.dataSource?.upload ? draftOf(state.values) : state.values;
965
+ try {
966
+ drafts.store.setItem(draftKey(), JSON.stringify({ savedAt: new Date().toISOString(), values }));
967
+ }
968
+ catch {
969
+ // The draft is a convenience: the form goes on without it.
970
+ }
566
971
  }, drafts.delayMs ?? 400);
567
972
  }
973
+ /** The values with a draft's over them: a field it left out — a file's bytes — as the record holds it, in a saved form placed here too. */
974
+ function withDraft(kept) {
975
+ const values = { ...state.values, ...kept };
976
+ for (const part of attached.values()) {
977
+ const now = state.values[part.name];
978
+ if (isRecord(now) && isRecord(kept[part.name]))
979
+ values[part.name] = asValue({ ...now, ...kept[part.name] });
980
+ }
981
+ return values;
982
+ }
568
983
  function forgetDraft() {
569
984
  if (draftTimer !== null)
570
985
  scheduler.clearTimeout(draftTimer);
@@ -587,18 +1002,23 @@ function innerForm(options, within) {
587
1002
  return;
588
1003
  }
589
1004
  if (drafts.restore === 'auto')
590
- writeValues({ ...state.values, ...draft.values }, { draft: null }, true);
1005
+ writeValues(withDraft(draft.values), { draft: null }, true);
591
1006
  else
592
1007
  set({ draft: { savedAt: draft.savedAt } });
593
1008
  }
594
1009
  function scheduleAutosave() {
595
1010
  const autosave = options.autosave;
596
- if (!autosave || page.data.kind !== 'record')
1011
+ // A record the person may not change is never saved unasked: it would only be refused.
1012
+ if (!autosave || page.data.kind !== 'record' || state.can?.edit === false)
597
1013
  return;
598
1014
  if (autosaveTimer !== null)
599
1015
  scheduler.clearTimeout(autosaveTimer);
600
1016
  autosaveTimer = scheduler.setTimeout(() => {
601
1017
  autosaveTimer = null;
1018
+ // Nobody pressed anything: a box still typed in is never read under the person's keys. The save waits until it is
1019
+ // left (or Enter is pressed in it), and reads it then, as any save does.
1020
+ if (typingNow())
1021
+ return scheduleAutosave();
602
1022
  if (state.dirty.length && Object.keys(collectErrors()).length === 0)
603
1023
  void track(save());
604
1024
  }, autosave.delayMs);
@@ -663,69 +1083,202 @@ function innerForm(options, within) {
663
1083
  async function save(chain = []) {
664
1084
  return (await saving(chain)) === null;
665
1085
  }
1086
+ /** Every value, for a source that saves whole records: those of fields hidden from the person, which they never had, left out. */
1087
+ function sentValues() {
1088
+ const values = (0, values_1.structuredCopy)(state.values);
1089
+ for (const name of state.can?.hiddenFields ?? [])
1090
+ delete values[name];
1091
+ return values;
1092
+ }
1093
+ /** A conflict as the form tells it: what they changed since the record was opened, and what both changed. */
1094
+ function conflictOf(conflict) {
1095
+ const theirs = conflict.theirs;
1096
+ const changed = theirs ? Object.keys(fields).filter((name) => name in theirs && !sameValue(fields[name], theirs[name], baseline[name])) : [];
1097
+ const both = changed.filter((name) => state.dirty.includes(name) && !sameValue(fields[name], theirs[name], state.values[name]));
1098
+ return { version: conflict.version, ...(theirs ? { theirs } : {}), changed, both };
1099
+ }
666
1100
  /**
667
1101
  * Save, or send: null once done, or why not. The page's `beforeSave` steps
668
1102
  * run first — before its own check, so they may fill in what it asks for —
669
1103
  * and a stop keeps it unsaved; `afterSave` runs once it is saved.
670
1104
  */
671
- async function saving(chain) {
1105
+ /** The save running now: the next one waits for it. */
1106
+ let saveRunning = null;
1107
+ /**
1108
+ * One save at a time: a second — Ctrl+Enter pressed twice, or while the
1109
+ * first waits for files — waits for the first, then saves what is left to
1110
+ * save with the version the first got back, never as someone else's. A save
1111
+ * the page's own beforeSave steps ask for runs inside the one that set them off.
1112
+ */
1113
+ function saving(chain) {
1114
+ if (chain.includes('on.beforeSave'))
1115
+ return savingNow(chain);
1116
+ const before = saveRunning;
1117
+ const mine = (async () => {
1118
+ if (before)
1119
+ await before;
1120
+ return savingNow(chain);
1121
+ })();
1122
+ const done = mine.then(() => undefined, () => undefined);
1123
+ saveRunning = done;
1124
+ void done.then(() => {
1125
+ if (saveRunning === done)
1126
+ saveRunning = null;
1127
+ });
1128
+ return mine;
1129
+ }
1130
+ async function savingNow(chain) {
1131
+ // A record its source does not let this person change is refused here, in its words or the form's: no source is asked.
1132
+ if (page.data.kind === 'record' && state.can?.edit === false) {
1133
+ const problem = { kind: 'rule', message: state.can.reason ?? messages.mayNotEdit };
1134
+ set({ status: 'error', error: problem.message, saveProblem: problem, conflict: null });
1135
+ return { reason: 'save', message: problem.message };
1136
+ }
1137
+ // What is still typed in a box is read first: it is what is saved, or why nothing is. A name still being looked
1138
+ // up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
1139
+ const waiting = readTyped();
1140
+ if (waiting)
1141
+ await waiting;
1142
+ // 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.
1143
+ if (filesGoing()) {
1144
+ set({ status: 'saving', error: null, saveProblem: null });
1145
+ await filesSettled();
1146
+ set({ status: 'ready' });
1147
+ }
672
1148
  if (page.on?.beforeSave) {
673
1149
  const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
674
1150
  if (!before.done) {
675
1151
  // A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
676
1152
  if (before.reason !== 'check' && before.reason !== 'save') {
677
1153
  const problem = { kind: 'stopped', reason: before.reason, message: before.message ?? '' };
678
- set({ status: 'error', error: problem.message || null, saveProblem: problem });
1154
+ set({ status: 'error', error: problem.message || null, saveProblem: problem, conflict: null });
679
1155
  }
680
1156
  return { reason: 'save', ...(before.message !== undefined ? { message: before.message } : {}) };
681
1157
  }
682
1158
  }
683
- const errors = collectErrors();
1159
+ // A file that did not reach the store stops it, under its field, with the form's own checks.
1160
+ const errors = { ...collectErrors(), ...fileProblems() };
684
1161
  if (Object.keys(errors).length) {
685
- set({ errors });
1162
+ set({ errors, ...unstored(errors) });
686
1163
  return { reason: 'save', message: Object.values(errors)[0] };
687
1164
  }
688
1165
  const source = options.dataSource;
689
1166
  serverErrors = {};
690
- set({ status: 'saving', errors: {}, error: null, saveProblem: null });
1167
+ set({ status: 'saving', errors: {}, error: null, saveProblem: null, conflict: null });
1168
+ /** The generation the record was in when this save went to the source. */
1169
+ let at = generation;
691
1170
  try {
1171
+ // 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.
1172
+ await storeBytes(page.data.kind === 'responses' || state.recordId == null ? Object.keys(fields) : state.dirty);
1173
+ const failed = fileProblems();
1174
+ if (Object.keys(failed).length) {
1175
+ set({ errors: failed, ...unstored(failed) });
1176
+ return { reason: 'save', message: Object.values(failed)[0] };
1177
+ }
692
1178
  if (page.data.kind === 'responses') {
693
1179
  if (!source?.submit)
694
1180
  throw new Error('This page has no data source to send its answers to');
695
1181
  const sent = shownValues();
1182
+ const before = state.values;
1183
+ at = generation;
696
1184
  await source.submit({ pageId: page.id, values: sent });
1185
+ // Answered for answers no longer shown — the form was started again meanwhile: the source has them, the form lays nothing.
1186
+ if (at !== generation)
1187
+ return null;
697
1188
  forgetDraft();
698
- set({ status: 'saved' });
1189
+ // What it sent is what an answer changed since is told against: still to send.
1190
+ sentAs = before;
1191
+ set({ status: 'saved', unsent: unsentNow() });
699
1192
  emit('send', { values: sent });
700
1193
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
701
1194
  return null;
702
1195
  }
703
1196
  if (!source?.save)
704
1197
  throw new Error('This page has no data source to save to');
1198
+ // What is sent: a change made while it is on its way is not in it, and stays a change to save.
1199
+ const sent = state.values;
1200
+ at = generation;
1201
+ const resetAt = resets;
705
1202
  const result = await source.save({
706
1203
  model: page.data.model,
707
1204
  id: state.recordId,
708
1205
  fields,
709
1206
  changes: changes(),
710
- values: (0, values_1.structuredCopy)(state.values),
1207
+ values: sentValues(),
1208
+ // The version it was opened at, for a source that refuses a save over someone else's.
1209
+ ...(state.recordId != null && state.version ? { version: state.version } : {}),
711
1210
  });
1211
+ // Answered for a record no longer shown — another opened, or this one loaded again: the source has it, and the
1212
+ // form lays nothing of it over the record it shows now.
1213
+ if (at !== generation)
1214
+ return null;
712
1215
  forgetDraft();
713
- // Lines the save brings back load again, in their tables' orders, as Flectra's reload after a save.
714
- const values = syncParts(startFrom(result.values ? ordered({ ...state.values, ...result.values }, Object.keys(result.values)) : state.values), 'start');
715
- baseline = (0, values_1.structuredCopy)(values);
716
- set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
1216
+ // Reset by the person meanwhile: the record as it stored it, the reset values never laid over it.
1217
+ const { values, dirty } = afterSave(sent, result.values, resets !== resetAt);
1218
+ // The record as this save left it: what the person changes from here is told against it.
1219
+ sentAs = baseline;
1220
+ // Its version now, for the next save to carry; and what the person may do now, when the save changed that.
1221
+ // The person's changes it did not send are at risk (`unsent`): every way out asks before it leaves them.
1222
+ set({ status: 'saved', recordId: result.id, values, dirty, warnings: withValues(values, collectWarnings), version: result.version ?? null, ...(result.can ? { can: result.can } : {}) });
1223
+ // Changed while it saved: kept in a draft, and saved as any change is.
1224
+ if (dirty.length) {
1225
+ scheduleDraft();
1226
+ scheduleAutosave();
1227
+ }
717
1228
  emit('save', { recordId: state.recordId, values: state.values });
718
1229
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
719
1230
  return null;
720
1231
  }
721
1232
  catch (error) {
722
1233
  const problem = (0, data_source_1.saveProblemOf)(error);
1234
+ // Refused for a record no longer shown: nothing of it is laid over the one shown now.
1235
+ if (at !== generation)
1236
+ return { reason: 'save', message: problem.message };
1237
+ // Refused: what the person changed meanwhile is a change not saved, as the rest.
723
1238
  const fields = problem.kind === 'fields' ? problem.fields ?? {} : {};
724
1239
  serverErrors = Object.fromEntries(Object.entries(fields).map(([name, message]) => [name, { message, value: JSON.stringify(state.values[name] ?? null) }]));
725
- set({ status: 'error', error: problem.message, saveProblem: problem, errors: { ...fields } });
1240
+ set({ status: 'error', error: problem.message, saveProblem: problem, errors: { ...fields }, ...(problem.conflict ? { conflict: conflictOf(problem.conflict) } : {}) });
726
1241
  return { reason: 'save', message: problem.message };
727
1242
  }
728
1243
  }
1244
+ /**
1245
+ * The record as a save left it — what was sent, with what the save brought
1246
+ * back, lines loaded again in their tables' orders, as Flectra's reload after
1247
+ * a save — as the new starting point. What the person changed while it was
1248
+ * on its way (a file stored meanwhile) was not sent: it is laid over that,
1249
+ * still a change to save, never taken as saved.
1250
+ */
1251
+ function afterSave(sent, back, whole = false) {
1252
+ const stored = (from) => (back ? ordered({ ...from, ...back }, Object.keys(back)) : from);
1253
+ const since = whole ? [] : Object.keys(fields).filter((name) => state.values[name] !== sent[name] && !sameValue(fields[name], state.values[name], sent[name]));
1254
+ if (!since.length) {
1255
+ const values = syncParts(startFrom(stored(whole ? sent : state.values)), 'start');
1256
+ baseline = (0, values_1.structuredCopy)(values);
1257
+ return { values, dirty: [] };
1258
+ }
1259
+ const record = stored(sent);
1260
+ const mine = { ...record };
1261
+ for (const name of since)
1262
+ mine[name] = keepIds(fields[name], state.values[name], record[name]);
1263
+ const values = syncParts(startFrom(mine), 'start');
1264
+ // The starting point is the record as stored: those fields as it holds them.
1265
+ const base = { ...values };
1266
+ for (const name of since)
1267
+ base[name] = record[name];
1268
+ baseline = (0, values_1.structuredCopy)(computed.apply(base));
1269
+ return { values, dirty: dirtyFields(values) };
1270
+ }
1271
+ /** A save stopped by files that did not reach the store: shown under their fields, as a refused save's fields are. */
1272
+ function unstored(errors) {
1273
+ const files = fileProblems();
1274
+ const only = Object.fromEntries(Object.entries(errors).filter(([key]) => key in files));
1275
+ for (const key of Object.keys(only))
1276
+ unstoredShown.add(key);
1277
+ if (!Object.keys(only).length)
1278
+ return {};
1279
+ filesProblem = { kind: 'fields', message: Object.values(only)[0], fields: only };
1280
+ return { status: 'error', error: Object.values(only)[0], saveProblem: filesProblem };
1281
+ }
729
1282
  /** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
730
1283
  async function opening() {
731
1284
  emit('open', { recordId: state.recordId, values: state.values });
@@ -787,9 +1340,15 @@ function innerForm(options, within) {
787
1340
  changed(field, by);
788
1341
  return key;
789
1342
  }
790
- /** Check the form, or only these fields, showing what is found as sending does. */
791
- function check(names) {
1343
+ /**
1344
+ * Check the form, or only these fields, showing what is found as sending does: a step's check, which waits for a
1345
+ * name still being looked up in a box, as a save does.
1346
+ */
1347
+ async function check(names) {
792
1348
  names?.forEach(fieldDef);
1349
+ const waiting = readTyped();
1350
+ if (waiting)
1351
+ await waiting;
793
1352
  const all = collectErrors();
794
1353
  const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
795
1354
  set({ errors: found });
@@ -797,17 +1356,22 @@ function innerForm(options, within) {
797
1356
  return first === undefined ? null : { reason: 'check', message: first };
798
1357
  }
799
1358
  /** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
800
- function goToStep(target, chain) {
1359
+ async function goToStep(target, chain) {
801
1360
  if (!steps.includes(target))
802
1361
  return undefined;
803
1362
  if (!form.steps().includes(target))
804
1363
  return { reason: 'cannot', message: `The step "${target}" does not apply now` };
1364
+ // A name still being looked up in a box on the way is waited for, as a save waits for it.
1365
+ const waiting = readTyped();
1366
+ if (waiting)
1367
+ await waiting;
805
1368
  let went = false;
806
1369
  inRun(chain, () => (went = form.goTo(target)));
807
1370
  return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
808
1371
  }
809
- /** Run steps from the top — a button's, a moment's, the app's — and tell how it ended. */
1372
+ /** 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`). */
810
1373
  async function runTop(list, scope) {
1374
+ scope.generation = generation;
811
1375
  const result = await runner.runSteps(list, scope);
812
1376
  emit('run', { id: scope.id, steps: list, result });
813
1377
  return result;
@@ -866,14 +1430,17 @@ function innerForm(options, within) {
866
1430
  }
867
1431
  // Only the latest load is taken: a pager pressed twice shows the record it ended on.
868
1432
  const seq = ++loads;
869
- set({ status: 'loading', error: null });
1433
+ // The record loaded again is shown anew: an answer started for it before is not laid over it.
1434
+ newGeneration();
1435
+ set({ status: 'loading', error: null, unsent: [] });
870
1436
  try {
871
- const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
1437
+ // A source may give the record's version and what the person may do with it beside its values.
1438
+ const { values: loaded, version, can } = (0, data_source_1.loadedRecordOf)(await source.load({ model: page.data.model, id: state.recordId, fields }));
872
1439
  if (seq !== loads)
873
1440
  return;
874
1441
  // Lines load in their tables' orders; where a person moves them after, they stay.
875
1442
  baseline = syncParts(startFrom(ordered({ ...(0, values_1.initialValues)(fields, today()), ...loaded })), 'start');
876
- set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
1443
+ set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), version: version ?? null, can: can ?? null, unread: {} });
877
1444
  if (again)
878
1445
  return;
879
1446
  offerDraft();
@@ -885,8 +1452,14 @@ function innerForm(options, within) {
885
1452
  }
886
1453
  }
887
1454
  let loads = 0;
1455
+ /** Another record in this form's place, or a new one, once the one check let what was there go: false when the person kept it. */
1456
+ function openRecord(id, chain = []) {
1457
+ const go = letGo({}, chain);
1458
+ return go === true ? showNow(id) : go.then((yes) => (yes ? showNow(id) : false));
1459
+ }
888
1460
  /** Another record in this form's place, or a new one: what was there let go, the new one loaded and opened. */
889
- async function openRecord(id) {
1461
+ async function showNow(id) {
1462
+ newGeneration();
890
1463
  if (draftTimer !== null)
891
1464
  scheduler.clearTimeout(draftTimer);
892
1465
  if (autosaveTimer !== null)
@@ -895,10 +1468,12 @@ function innerForm(options, within) {
895
1468
  serverErrors = {};
896
1469
  told = null;
897
1470
  showing = false;
898
- const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null };
1471
+ clearFiles();
1472
+ const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, version: null, can: null, conflict: null, setAside: null, unsent: [], unread: {} };
899
1473
  if (id != null) {
900
1474
  set({ ...fresh, recordId: id, status: 'idle' });
901
- return load();
1475
+ await load();
1476
+ return true;
902
1477
  }
903
1478
  loads++;
904
1479
  baseline = startFrom(startingValues(true, false));
@@ -906,6 +1481,142 @@ function innerForm(options, within) {
906
1481
  set({ ...fresh, recordId: null, status: 'ready', values, warnings: withValues(values, collectWarnings) });
907
1482
  offerDraft();
908
1483
  await opening();
1484
+ return true;
1485
+ }
1486
+ /** The check going now — waiting, or asking — that a way out taken meanwhile joins, with how it was taken. */
1487
+ let checking = null;
1488
+ /** Whether a way out has to wait first: a save on its way (not the one whose beforeSave steps take it: `own`), or files on theirs. */
1489
+ const busy = (own) => (!own && saveRunning !== null) || filesGoing();
1490
+ /** Wait for the saves on their way — unless `own`, the way out is a step of one of them — and the files on theirs, saying so meanwhile. */
1491
+ async function quietFirst(own) {
1492
+ if (!busy(own))
1493
+ return;
1494
+ set({ leaving: true });
1495
+ try {
1496
+ while (busy(own)) {
1497
+ if (!own && saveRunning)
1498
+ await saveRunning;
1499
+ else
1500
+ await filesSettled();
1501
+ }
1502
+ }
1503
+ finally {
1504
+ set({ leaving: false });
1505
+ }
1506
+ }
1507
+ /** Whether a way out goes at once: nothing to wait for, no check going, nothing at risk — as in 0.20. */
1508
+ const atOnce = (how, own = false) => !deletes && !checking && !busy(own) && (how.discard === true || !workAtRisk(state, how));
1509
+ /** The saves on their way, settled: what a load that is not a way out (a conflict's Reload) waits for. */
1510
+ async function savesSettled() {
1511
+ while (saveRunning)
1512
+ await saveRunning;
1513
+ }
1514
+ /**
1515
+ * THE check: every way the form's values go — another record, the record
1516
+ * loaded again, the host's close, a reset step, the app's own ways out
1517
+ * through `leave()` — passes here, so none can lose the person's work
1518
+ * unasked. First it waits for a save on its way and files on theirs, so
1519
+ * no answer lands on a record that is gone and nothing is told against
1520
+ * values that are about to change; then it looks at the record as it is
1521
+ * now. Nothing at risk (`workAtRisk`; with `changes`, every change not
1522
+ * saved too): true — at once when nothing was on its way, as in 0.20. Else
1523
+ * "Discard your changes?" through the host — Discard worded Yes, the focus
1524
+ * on Cancel — or `confirm`; with neither, the work is kept. `discard`: the
1525
+ * person's own Discard, asked nothing. A way out taken while one waits or
1526
+ * asks joins it, and gets its answer. A saved form placed in a record asks
1527
+ * the record's. `chain`: the moments the run taking it was set off by — a
1528
+ * step of a save's own beforeSave does not wait for that save.
1529
+ */
1530
+ function letGo(how = {}, chain = []) {
1531
+ if (outer)
1532
+ return outer.letGo(how, chain);
1533
+ // A delete the person chose: their work goes with the record, unasked.
1534
+ if (deletes) {
1535
+ forget();
1536
+ return true;
1537
+ }
1538
+ const own = chain.includes('on.beforeSave');
1539
+ if (atOnce(how, own)) {
1540
+ if (how.discard)
1541
+ forget();
1542
+ return true;
1543
+ }
1544
+ if (checking) {
1545
+ const joined = checking;
1546
+ // Its answer is this one's too; asked another way (every change counting), it is checked that way after.
1547
+ return joined.going.then((yes) => yes && (sameHow(joined.how, how) || Promise.resolve(letGo(how, chain))));
1548
+ }
1549
+ const going = (async () => {
1550
+ await quietFirst(own);
1551
+ if (how.discard) {
1552
+ forget();
1553
+ return true;
1554
+ }
1555
+ if (!workAtRisk(state, how))
1556
+ return true;
1557
+ return askToLetGo();
1558
+ })();
1559
+ checking = { how, going };
1560
+ void going.then(() => {
1561
+ if (checking?.going === going)
1562
+ checking = null;
1563
+ });
1564
+ return going;
1565
+ }
1566
+ async function askToLetGo() {
1567
+ const message = messages.discardChanges;
1568
+ const host = options.host;
1569
+ let yes = false;
1570
+ try {
1571
+ yes = host ? await host.ask(message, { discard: true }) : options.confirm ? await options.confirm(message) : false;
1572
+ }
1573
+ catch {
1574
+ // A question that could not be asked keeps the work.
1575
+ }
1576
+ if (yes)
1577
+ forget();
1578
+ return yes;
1579
+ }
1580
+ /** 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. */
1581
+ function forget() {
1582
+ clearFiles();
1583
+ personal.clear();
1584
+ filed.clear();
1585
+ if (state.setAside !== null || state.unsent.length)
1586
+ set({ setAside: null, unsent: [] });
1587
+ }
1588
+ /** The check as a step meets it: null to go on — at once when nothing is at risk — else why the run stops. */
1589
+ function letGoStep(chain) {
1590
+ const go = letGo({}, chain);
1591
+ return go === true ? null : go.then((yes) => (yes ? null : kept()));
1592
+ }
1593
+ const kept = () => ({ reason: 'no', message: messages.discardChanges });
1594
+ /** Load the record again, once the check let what is shown go. */
1595
+ function loadAgain(again, chain = []) {
1596
+ const go = letGo({}, chain);
1597
+ if (go === true)
1598
+ return load(again).then(() => true);
1599
+ return go.then((yes) => (yes ? load(again).then(() => true) : false));
1600
+ }
1601
+ /**
1602
+ * The host's close, as every `close` step reaches it: the form takes it
1603
+ * once, here where it is made, and puts the check before it — so the app's
1604
+ * own close (its panel's, its modal's: `host.close`) never drops the
1605
+ * person's work unasked, nor does the viewer's dialog, panel or page in
1606
+ * place. A saved form placed in a record closes as the record does.
1607
+ */
1608
+ function close(chain = []) {
1609
+ if (outer)
1610
+ return outer.close(chain);
1611
+ const host = options.host;
1612
+ if (!host?.close)
1613
+ return { reason: 'cannot', message: 'Nothing can close this form: it has no host, or was not opened in a dialog or panel' };
1614
+ const shut = () => {
1615
+ host.close?.();
1616
+ return null;
1617
+ };
1618
+ const go = letGo({}, chain);
1619
+ return go === true ? shut() : go.then((yes) => (yes ? shut() : kept()));
909
1620
  }
910
1621
  /** The record a record step asks the data source about. */
911
1622
  const record = () => ({ model: page.data.model, id: state.recordId, fields });
@@ -934,25 +1645,51 @@ function innerForm(options, within) {
934
1645
  addLine: (field, values) => void addLine(field, values, 'step'),
935
1646
  check,
936
1647
  save: saving,
937
- async reload() {
1648
+ generation: () => generation,
1649
+ gone: () => (disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced }),
1650
+ async reload(chain) {
938
1651
  if (state.recordId == null)
939
1652
  return { reason: 'cannot', message: 'The record is not saved yet: there is nothing to load again' };
940
- await load(true);
1653
+ if (!(await loadAgain(true, chain)))
1654
+ return kept();
941
1655
  return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
942
1656
  },
943
- reset: () => form.reset(),
1657
+ reset(chain) {
1658
+ const reset = () => {
1659
+ form.reset();
1660
+ return null;
1661
+ };
1662
+ const go = letGo({}, chain);
1663
+ return go === true ? reset() : go.then((yes) => (yes ? reset() : kept()));
1664
+ },
1665
+ close,
944
1666
  goTo: goToStep,
945
1667
  showing: inRun,
946
1668
  host: options.host,
947
1669
  confirm: options.confirm,
948
1670
  onAction: options.onAction,
949
1671
  answered: (request, result) => emit('action', { request, result }),
950
- changed: () => state.dirty.length > 0,
1672
+ changed: () => state.dirty.length > 0 || state.uploads.some(uploads_1.uploadGoing),
1673
+ letGo: letGoStep,
1674
+ async deleting(remove, done) {
1675
+ deletes++;
1676
+ try {
1677
+ const out = await remove();
1678
+ // Deleted: what the person had not saved in it goes with it. Refused, it stays theirs.
1679
+ if (done(out))
1680
+ forget();
1681
+ return out;
1682
+ }
1683
+ finally {
1684
+ deletes--;
1685
+ }
1686
+ },
951
1687
  ...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
952
1688
  ...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
953
1689
  ...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
954
- async showRecord(id) {
955
- await openRecord(id);
1690
+ async showRecord(id, chain) {
1691
+ if (!(await openRecord(id, chain)))
1692
+ return kept();
956
1693
  return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
957
1694
  },
958
1695
  told(event, id) {
@@ -969,6 +1706,7 @@ function innerForm(options, within) {
969
1706
  },
970
1707
  post,
971
1708
  wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
1709
+ barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
972
1710
  });
973
1711
  const moment = (0, run_1.createMoments)((key, list, chain) => runTop(list, { id: key, chain }));
974
1712
  /** A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. */
@@ -998,7 +1736,10 @@ function innerForm(options, within) {
998
1736
  listeners.add(listener);
999
1737
  return () => listeners.delete(listener);
1000
1738
  },
1001
- load: () => track(load()),
1739
+ load() {
1740
+ const go = letGo();
1741
+ return track(go === true ? load() : go.then((yes) => (yes ? load() : undefined)));
1742
+ },
1002
1743
  setValue(name, value, how) {
1003
1744
  fieldDef(name);
1004
1745
  writeValues({ ...state.values, [name]: (0, values_1.structuredCopy)(value) });
@@ -1008,6 +1749,8 @@ function innerForm(options, within) {
1008
1749
  setValues: (values, how) => (how?.stored ? store(values) : write(values, 'app')),
1009
1750
  node: nodeState,
1010
1751
  openRecord: (id) => track(openRecord(id)),
1752
+ leave: (how) => Promise.resolve(letGo(how)),
1753
+ leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
1011
1754
  post,
1012
1755
  setEditing(next) {
1013
1756
  if (next === editing)
@@ -1016,7 +1759,7 @@ function innerForm(options, within) {
1016
1759
  // Nothing of the record changed; what shows of it may have.
1017
1760
  set({});
1018
1761
  },
1019
- fieldReadonly: (name) => lockedField(fieldDef(name)),
1762
+ fieldReadonly: (name) => lockedField(fieldDef(name)) || state.can?.edit === false || state.can?.readonlyFields?.includes(name) === true,
1020
1763
  loadChoices,
1021
1764
  loadDefinitions,
1022
1765
  addDefinition,
@@ -1026,23 +1769,100 @@ function innerForm(options, within) {
1026
1769
  const shown = visibleFieldNodes().filter((node) => node.field === name);
1027
1770
  if (!shown.length)
1028
1771
  return null;
1772
+ if (state.unread[name])
1773
+ return state.unread[name].message;
1774
+ // A property's text left unread answers for its field.
1775
+ if (page.fields[name].type === 'properties') {
1776
+ const row = Object.keys(state.unread).find((path) => path.startsWith(`${name}.`));
1777
+ if (row)
1778
+ return state.unread[row].message;
1779
+ }
1029
1780
  const own = shown.map(unticked).find(Boolean) ??
1030
1781
  shown.map((node) => (0, inputs_1.checkInput)(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
1031
1782
  (0, check_1.checkValue)(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
1032
1783
  return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
1033
1784
  },
1785
+ setUnread(path, unread) {
1786
+ const field = path.split('.')[0];
1787
+ // A line's cell (`field.lineKey.subfield`), or a property (`field.property`).
1788
+ const def = fieldDef(field);
1789
+ if (path.includes('.') && def.type !== 'properties')
1790
+ lineField(field);
1791
+ const had = state.unread[path];
1792
+ if (!unread && !had)
1793
+ return;
1794
+ // A line taken away takes its cells' text with it: a box of it still reading (a name looked up) keeps none.
1795
+ if (unread && !had && path.split('.').length >= 3 && def.type !== 'properties' && valueAt(state.values, path) === gone)
1796
+ return;
1797
+ if (unread && had && had.text === unread.text && had.message === unread.message)
1798
+ return;
1799
+ const next = { ...state.unread };
1800
+ if (unread)
1801
+ next[path] = { text: unread.text, message: unread.message };
1802
+ else
1803
+ delete next[path];
1804
+ // Problems on screen stay current: an unread text put right takes its words with it.
1805
+ const before = state;
1806
+ state = { ...state, unread: next };
1807
+ const errors = Object.keys(before.errors).length ? revalidateShown(state.values) : before.errors;
1808
+ state = before;
1809
+ set({ unread: next, errors });
1810
+ },
1811
+ boxLeft(path) {
1812
+ const problem = leftProblem(path);
1813
+ if (!problem || state.errors[path] === problem)
1814
+ return;
1815
+ set({ errors: { ...state.errors, [path]: problem } });
1816
+ },
1817
+ settling(read, typing) {
1818
+ readers.add(read);
1819
+ if (typing)
1820
+ typings.add(typing);
1821
+ return () => {
1822
+ readers.delete(read);
1823
+ if (typing)
1824
+ typings.delete(typing);
1825
+ };
1826
+ },
1827
+ settle: () => readTyped() ?? Promise.resolve(),
1828
+ reading: () => readingNow > 0 || [...attached.values()].some((part) => part.inner.form.reading()),
1034
1829
  validate() {
1830
+ readTyped();
1035
1831
  const errors = collectErrors();
1036
1832
  set({ errors });
1037
1833
  return Object.keys(errors).length === 0;
1038
1834
  },
1039
1835
  save: () => track(save()),
1836
+ canUpload: () => typeof options.dataSource?.upload === 'function',
1837
+ upload: (field, files, how = {}) => uploads.start(field, files, how),
1838
+ uploading: (field) => state.uploads.filter((upload) => upload.field === field && !upload.line),
1839
+ cancelUpload(id) {
1840
+ const inner = partUpload(id);
1841
+ if (inner)
1842
+ inner.form.cancelUpload(inner.id);
1843
+ else
1844
+ uploads.cancel(id);
1845
+ },
1846
+ retryUpload(id) {
1847
+ const inner = partUpload(id);
1848
+ if (inner)
1849
+ inner.form.retryUpload(inner.id);
1850
+ else
1851
+ uploads.retry(id);
1852
+ },
1040
1853
  reset() {
1041
1854
  forgetDraft();
1855
+ // Files on their way were changes too: they go with the rest.
1856
+ clearFiles();
1042
1857
  serverErrors = {};
1043
1858
  holding = setWhen.holding(baseline);
1859
+ // The person's own Discard: nothing of theirs is left at risk, and a save on its way lands as the record it stored.
1860
+ resets++;
1861
+ personal.clear();
1862
+ filed.clear();
1863
+ sentAs = null;
1044
1864
  const values = syncParts((0, values_1.structuredCopy)(baseline), 'start');
1045
- set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
1865
+ 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: {} });
1046
1866
  },
1047
1867
  changes,
1048
1868
  addLine: (field, values = {}) => addLine(field, values, 'person'),
@@ -1137,6 +1957,7 @@ function innerForm(options, within) {
1137
1957
  next() {
1138
1958
  if (!state.step)
1139
1959
  return false;
1960
+ readTyped();
1140
1961
  // A step skipped before and gone back to is answered now, and checked like any other.
1141
1962
  if (state.skipped.includes(state.step))
1142
1963
  set({ skipped: state.skipped.filter((id) => id !== state.step) });
@@ -1183,6 +2004,7 @@ function innerForm(options, within) {
1183
2004
  set({ step: target, errors: {} });
1184
2005
  return true;
1185
2006
  }
2007
+ readTyped();
1186
2008
  const skipped = [...state.skipped];
1187
2009
  for (const id of visible.slice(at, to)) {
1188
2010
  if (skipped.includes(id))
@@ -1305,12 +2127,91 @@ function innerForm(options, within) {
1305
2127
  if (!raw)
1306
2128
  return;
1307
2129
  const draft = JSON.parse(raw);
1308
- writeValues({ ...state.values, ...draft.values }, { draft: null }, true);
2130
+ writeValues(withDraft(draft.values), { draft: null }, true);
1309
2131
  },
1310
2132
  discardDraft() {
1311
2133
  forgetDraft();
1312
2134
  set({ draft: null });
1313
2135
  },
2136
+ async reloadTheirs() {
2137
+ // A save on its way first: its answer is not dropped by the load.
2138
+ await savesSettled();
2139
+ // The person's changes, kept before theirs are loaded over them.
2140
+ const mine = {};
2141
+ for (const name of state.dirty)
2142
+ mine[name] = (0, values_1.structuredCopy)(state.values[name]);
2143
+ await track(load(true));
2144
+ if (state.status !== 'ready')
2145
+ return false;
2146
+ // Set aside where theirs differ from mine: a change they made the same way is no loss.
2147
+ const aside = { ...(state.setAside ?? {}) };
2148
+ for (const [name, value] of Object.entries(mine))
2149
+ if (!sameValue(fields[name], value, state.values[name]))
2150
+ aside[name] = value;
2151
+ set({ conflict: null, saveProblem: null, setAside: Object.keys(aside).length ? aside : null });
2152
+ return true;
2153
+ },
2154
+ async keepMine() {
2155
+ const conflict = state.conflict;
2156
+ let told = conflict?.theirs;
2157
+ let version = conflict?.version;
2158
+ // Not told the record as they left it: read as it is now, so a source that saves whole records keeps their work.
2159
+ const source = options.dataSource;
2160
+ if (conflict && !told) {
2161
+ if (source?.load && state.recordId != null && page.data.kind === 'record') {
2162
+ try {
2163
+ const now = (0, data_source_1.loadedRecordOf)(await track(source.load({ model: page.data.model, id: state.recordId, fields })));
2164
+ told = now.values;
2165
+ version = now.version ?? version;
2166
+ }
2167
+ catch {
2168
+ // Unread: said below.
2169
+ }
2170
+ }
2171
+ // Still unread: saving now would go over work this person never saw. Nothing is saved; they are told, and choose again.
2172
+ if (!told) {
2173
+ const message = messages.theirsUnread;
2174
+ set({ status: 'error', error: message, saveProblem: { kind: 'conflict', message, conflict: { version: conflict.version } }, conflict: { ...conflict, unread: true } });
2175
+ return false;
2176
+ }
2177
+ }
2178
+ if (told) {
2179
+ // The record as they left it is where this person's changes start from: theirs stay wherever they changed nothing.
2180
+ const theirs = {};
2181
+ for (const [name, value] of Object.entries(told))
2182
+ if (fields[name])
2183
+ theirs[name] = (0, values_1.structuredCopy)(value);
2184
+ const mine = state.dirty;
2185
+ baseline = { ...baseline, ...theirs };
2186
+ const values = { ...state.values };
2187
+ for (const [name, value] of Object.entries(theirs))
2188
+ if (!mine.includes(name))
2189
+ values[name] = value;
2190
+ writeValues(values);
2191
+ }
2192
+ // Saved with the version it has now: these changes over the record as it is.
2193
+ if (version)
2194
+ set({ version });
2195
+ return track(save());
2196
+ },
2197
+ putBack(field) {
2198
+ const aside = state.setAside;
2199
+ if (!aside)
2200
+ return;
2201
+ const names = field === undefined ? Object.keys(aside) : Object.prototype.hasOwnProperty.call(aside, field) ? [field] : [];
2202
+ if (!names.length)
2203
+ return;
2204
+ const left = { ...aside };
2205
+ for (const name of names)
2206
+ delete left[name];
2207
+ set({ setAside: Object.keys(left).length ? left : null });
2208
+ for (const name of names)
2209
+ form.setValue(name, aside[name]);
2210
+ },
2211
+ discardSetAside() {
2212
+ if (state.setAside)
2213
+ set({ setAside: null });
2214
+ },
1314
2215
  async settled() {
1315
2216
  while (pending.size)
1316
2217
  await Promise.allSettled([...pending]);
@@ -1337,19 +2238,30 @@ function innerForm(options, within) {
1337
2238
  scheduler,
1338
2239
  messages,
1339
2240
  now: options.now,
1340
- }, chain);
2241
+ }, chain,
2242
+ // Its files are the record's: stored for it, under the part's name.
2243
+ { model: where.model, recordId: where.recordId, under: `${where.under}${part.name}.` },
2244
+ // Its ways out are the record's: through this form's check.
2245
+ { letGo, close, leaveNow: (how) => atOnce(how) });
1341
2246
  attached.set(id, { name: part.name, inner: made });
1342
2247
  // A change inside it is told as a change of its answers here, whoever made it.
1343
2248
  made.form.on('change', ({ by }) => {
1344
2249
  if (!syncing)
1345
2250
  changed(part.name, by);
1346
2251
  });
1347
- // A change made inside it is a change of this form's: shown, kept in a draft, saved.
2252
+ // A change made inside it is a change of this form's: shown, kept in a draft, saved. Its files on their way are this form's too.
2253
+ let seenUploads = made.form.getState().uploads;
1348
2254
  made.form.subscribe((inside) => {
2255
+ if (inside.uploads !== seenUploads) {
2256
+ seenUploads = inside.uploads;
2257
+ filesChanged();
2258
+ }
1349
2259
  if (syncing || inside.values === state.values[part.name])
1350
2260
  return;
1351
2261
  writeValues({ ...state.values, [part.name]: asValue(inside.values) });
1352
- afterEdit(part.name);
2262
+ // A link a save put in place of bytes is no change of the person's.
2263
+ if (!quietParts)
2264
+ afterEdit(part.name);
1353
2265
  });
1354
2266
  // What it starts with, its defaults and worked-out values filled in, is this form's starting point too — unless changed already.
1355
2267
  const start = asValue(made.form.getState().values);
@@ -1361,6 +2273,10 @@ function innerForm(options, within) {
1361
2273
  },
1362
2274
  embedded: (id) => attached.get(id)?.inner.form ?? null,
1363
2275
  dispose() {
2276
+ // An answer that lands after is for no record shown.
2277
+ disposed = true;
2278
+ newGeneration();
2279
+ clearFiles();
1364
2280
  if (draftTimer !== null)
1365
2281
  scheduler.clearTimeout(draftTimer);
1366
2282
  if (autosaveTimer !== null)
@@ -1383,17 +2299,38 @@ function innerForm(options, within) {
1383
2299
  take(values, how) {
1384
2300
  if (how !== 'start')
1385
2301
  return writeValues({ ...(0, values_1.initialValues)(page.fields, today()), ...values }, {}, how === 'fresh');
2302
+ // Started again from the record round it: a file it held is the record's now, sent or let go with it.
2303
+ filed.clear();
1386
2304
  baseline = startFrom({ ...(0, values_1.initialValues)(page.fields, today()), ...(0, values_1.structuredCopy)(values) });
1387
2305
  const start = syncParts((0, values_1.structuredCopy)(baseline), 'start');
1388
- set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null });
2306
+ set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null, unread: {} });
1389
2307
  },
1390
2308
  errorsNow: () => collectErrors(),
2309
+ typing: typingNow,
1391
2310
  show(errors) {
1392
2311
  if (JSON.stringify(errors) !== JSON.stringify(state.errors))
1393
2312
  set({ errors });
1394
2313
  },
2314
+ files: {
2315
+ going: filesGoing,
2316
+ settled: filesSettled,
2317
+ problems: fileProblems,
2318
+ // Its answers are sent whole: every field's bytes are stored.
2319
+ storeBytes: () => storeBytes(Object.keys(fields)),
2320
+ draft: draftOf,
2321
+ clear: clearFiles,
2322
+ filed: () => filed.size > 0,
2323
+ },
1395
2324
  };
1396
2325
  }
2326
+ /** Two ways out taken the same way: the one joined answers for the other. */
2327
+ const sameHow = (a, b) => !!a.changes === !!b.changes && !!a.discard === !!b.discard;
2328
+ /** Two lists of names the same, in the same order. */
2329
+ const sameList = (a, b) => a.length === b.length && a.every((name, i) => name === b[i]);
2330
+ /** A file on its way in a saved form placed in another, as that one names it: its id and its field under the part's name. */
2331
+ function underPart(name, upload) {
2332
+ return { ...upload, id: `${name}/${upload.id}`, ...(upload.line ? { line: { ...upload.line, field: `${name}.${upload.line.field}` } } : { field: `${name}.${upload.field}` }) };
2333
+ }
1397
2334
  /** The problems found inside a saved form, by its own fields' names. */
1398
2335
  function inside(errors, name) {
1399
2336
  const found = {};
@@ -1429,6 +2366,46 @@ function compareLines(a, b, by) {
1429
2366
  function lockedField(def) {
1430
2367
  return def.readonly === true || def.compute !== undefined;
1431
2368
  }
2369
+ /** The words a person is told when a step would do what they may not, and the source gave no reason. */
2370
+ const MAY_NOT = { edit: 'mayNotEdit', archive: 'mayNotArchive', duplicate: 'mayNotDuplicate', delete: 'mayNotDelete' };
2371
+ /**
2372
+ * What a press does that a record's source may forbid, read from what it
2373
+ * runs: its steps — saving is editing; archiving and bringing back are both
2374
+ * archiving — or, for a gear menu's built-in item with nothing of its own, the
2375
+ * built-in itself.
2376
+ */
2377
+ function rightsUsed(source) {
2378
+ const steps = source.steps ?? [];
2379
+ const names = steps.map((step) => step.do);
2380
+ if ('builtin' in source && source.builtin && !steps.length && source.action === undefined)
2381
+ names.push(source.builtin);
2382
+ const rights = new Set();
2383
+ for (const name of names) {
2384
+ if (name === 'save')
2385
+ rights.add('edit');
2386
+ else if (name === 'archive' || name === 'unarchive')
2387
+ rights.add('archive');
2388
+ else if (name === 'duplicate' || name === 'delete')
2389
+ rights.add(name);
2390
+ }
2391
+ return [...rights];
2392
+ }
2393
+ /** A table's lines as the person has them, each new one given the id a save just gave it, by its key: the next save changes it, never makes it again. */
2394
+ function keepIds(def, mine, stored) {
2395
+ if (def.type !== 'one2many' || !Array.isArray(mine) || !Array.isArray(stored))
2396
+ return mine;
2397
+ const ids = new Map(stored.filter((line) => line.id !== undefined).map((line) => [line.key, line.id]));
2398
+ return mine.map((line) => (line.id === undefined && ids.has(line.key) ? { ...line, id: ids.get(line.key) } : line));
2399
+ }
2400
+ /** Two values of a field the same: a link by the record it points to, whatever name it is shown by; the rest as written. */
2401
+ function sameValue(def, a, b) {
2402
+ const id = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) && 'id' in value ? value.id : value);
2403
+ if (def?.type === 'many2one')
2404
+ return JSON.stringify(id(a) ?? null) === JSON.stringify(id(b) ?? null);
2405
+ if (def?.type === 'many2many')
2406
+ return JSON.stringify((a ?? []).map(id)) === JSON.stringify((b ?? []).map(id));
2407
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
2408
+ }
1432
2409
  function lineOps(before, now) {
1433
2410
  const ops = [];
1434
2411
  const old = new Map(before.filter((line) => line.id !== undefined).map((line) => [line.key, line]));
@@ -1465,6 +2442,7 @@ function indexLayout(root) {
1465
2442
  const add = (source, parent, kind, modifiers) => {
1466
2443
  const roles = source.roles;
1467
2444
  const pressed = kind === 'button' ? source.pressed : undefined;
2445
+ const does = kind === 'button' || kind === 'stat' ? rightsUsed(source) : [];
1468
2446
  index.set(source.id, {
1469
2447
  id: source.id,
1470
2448
  parent,
@@ -1475,6 +2453,7 @@ function indexLayout(root) {
1475
2453
  readonly: (0, modifier_1.compileModifier)(modifiers.readonly),
1476
2454
  required: (0, modifier_1.compileModifier)(modifiers.required),
1477
2455
  ...(pressed !== undefined ? { pressed: (0, modifier_1.compileModifier)(pressed) } : {}),
2456
+ ...(does.length ? { does } : {}),
1478
2457
  source,
1479
2458
  });
1480
2459
  // A button's menu: each item a button of the record, hidden with it.