@fieldia/core 0.21.0 → 0.23.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 (127) hide show
  1. package/README.md +116 -1
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +149 -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/format/check-page.d.mts +3 -2
  11. package/src/lib/format/check-page.d.ts +3 -2
  12. package/src/lib/format/check-page.js +9 -4
  13. package/src/lib/format/check-page.js.map +1 -1
  14. package/src/lib/format/check-page.mjs +10 -5
  15. package/src/lib/format/check-page.mjs.map +1 -1
  16. package/src/lib/format/field.d.mts +85 -0
  17. package/src/lib/format/field.d.ts +85 -0
  18. package/src/lib/format/field.js +14 -1
  19. package/src/lib/format/field.js.map +1 -1
  20. package/src/lib/format/field.mjs +13 -0
  21. package/src/lib/format/field.mjs.map +1 -1
  22. package/src/lib/format/layout.d.mts +142 -17
  23. package/src/lib/format/layout.d.ts +142 -17
  24. package/src/lib/format/layout.js +18 -1
  25. package/src/lib/format/layout.js.map +1 -1
  26. package/src/lib/format/layout.mjs +17 -0
  27. package/src/lib/format/layout.mjs.map +1 -1
  28. package/src/lib/format/layout.types.d.mts +6 -2
  29. package/src/lib/format/layout.types.d.ts +6 -2
  30. package/src/lib/format/page.d.mts +69 -10
  31. package/src/lib/format/page.d.ts +69 -10
  32. package/src/lib/format/page.js +4 -1
  33. package/src/lib/format/page.js.map +1 -1
  34. package/src/lib/format/page.mjs +5 -2
  35. package/src/lib/format/page.mjs.map +1 -1
  36. package/src/lib/format/references.d.mts +7 -0
  37. package/src/lib/format/references.d.ts +7 -0
  38. package/src/lib/format/references.js +46 -0
  39. package/src/lib/format/references.js.map +1 -1
  40. package/src/lib/format/references.mjs +46 -0
  41. package/src/lib/format/references.mjs.map +1 -1
  42. package/src/lib/format/translate.js +3 -2
  43. package/src/lib/format/translate.js.map +1 -1
  44. package/src/lib/format/translate.mjs +3 -2
  45. package/src/lib/format/translate.mjs.map +1 -1
  46. package/src/lib/format/upgrade.d.mts +41 -0
  47. package/src/lib/format/upgrade.d.ts +41 -0
  48. package/src/lib/format/upgrade.js +41 -0
  49. package/src/lib/format/upgrade.js.map +1 -0
  50. package/src/lib/format/upgrade.mjs +37 -0
  51. package/src/lib/format/upgrade.mjs.map +1 -0
  52. package/src/lib/format/validate.d.mts +2 -1
  53. package/src/lib/format/validate.d.ts +2 -1
  54. package/src/lib/format/validate.js +4 -2
  55. package/src/lib/format/validate.js.map +1 -1
  56. package/src/lib/format/validate.mjs +4 -2
  57. package/src/lib/format/validate.mjs.map +1 -1
  58. package/src/lib/format/version.d.mts +16 -1
  59. package/src/lib/format/version.d.ts +16 -1
  60. package/src/lib/format/version.js +39 -2
  61. package/src/lib/format/version.js.map +1 -1
  62. package/src/lib/format/version.mjs +36 -1
  63. package/src/lib/format/version.mjs.map +1 -1
  64. package/src/lib/record/data-source.d.mts +224 -10
  65. package/src/lib/record/data-source.d.ts +224 -10
  66. package/src/lib/record/data-source.js +29 -0
  67. package/src/lib/record/data-source.js.map +1 -1
  68. package/src/lib/record/data-source.mjs +25 -0
  69. package/src/lib/record/data-source.mjs.map +1 -1
  70. package/src/lib/record/form.d.mts +144 -5
  71. package/src/lib/record/form.d.ts +144 -5
  72. package/src/lib/record/form.js +792 -42
  73. package/src/lib/record/form.js.map +1 -1
  74. package/src/lib/record/form.mjs +791 -43
  75. package/src/lib/record/form.mjs.map +1 -1
  76. package/src/lib/record/locales/ar.js +10 -0
  77. package/src/lib/record/locales/ar.js.map +1 -1
  78. package/src/lib/record/locales/ar.mjs +10 -0
  79. package/src/lib/record/locales/ar.mjs.map +1 -1
  80. package/src/lib/record/locales/de.js +10 -0
  81. package/src/lib/record/locales/de.js.map +1 -1
  82. package/src/lib/record/locales/de.mjs +10 -0
  83. package/src/lib/record/locales/de.mjs.map +1 -1
  84. package/src/lib/record/locales/fr.js +10 -0
  85. package/src/lib/record/locales/fr.js.map +1 -1
  86. package/src/lib/record/locales/fr.mjs +10 -0
  87. package/src/lib/record/locales/fr.mjs.map +1 -1
  88. package/src/lib/record/memory-data-source.d.mts +40 -3
  89. package/src/lib/record/memory-data-source.d.ts +40 -3
  90. package/src/lib/record/memory-data-source.js +164 -20
  91. package/src/lib/record/memory-data-source.js.map +1 -1
  92. package/src/lib/record/memory-data-source.mjs +164 -20
  93. package/src/lib/record/memory-data-source.mjs.map +1 -1
  94. package/src/lib/record/messages.d.mts +21 -0
  95. package/src/lib/record/messages.d.ts +21 -0
  96. package/src/lib/record/messages.js +10 -0
  97. package/src/lib/record/messages.js.map +1 -1
  98. package/src/lib/record/messages.mjs +10 -0
  99. package/src/lib/record/messages.mjs.map +1 -1
  100. package/src/lib/record/run.d.mts +50 -7
  101. package/src/lib/record/run.d.ts +50 -7
  102. package/src/lib/record/run.js +67 -20
  103. package/src/lib/record/run.js.map +1 -1
  104. package/src/lib/record/run.mjs +67 -20
  105. package/src/lib/record/run.mjs.map +1 -1
  106. package/src/lib/record/upload-to.d.mts +50 -0
  107. package/src/lib/record/upload-to.d.ts +50 -0
  108. package/src/lib/record/upload-to.js +117 -0
  109. package/src/lib/record/upload-to.js.map +1 -0
  110. package/src/lib/record/upload-to.mjs +114 -0
  111. package/src/lib/record/upload-to.mjs.map +1 -0
  112. package/src/lib/record/uploads.d.mts +152 -0
  113. package/src/lib/record/uploads.d.ts +152 -0
  114. package/src/lib/record/uploads.js +345 -0
  115. package/src/lib/record/uploads.js.map +1 -0
  116. package/src/lib/record/uploads.mjs +337 -0
  117. package/src/lib/record/uploads.mjs.map +1 -0
  118. package/src/lib/record/values.d.mts +6 -0
  119. package/src/lib/record/values.d.ts +6 -0
  120. package/src/lib/record/values.js.map +1 -1
  121. package/src/lib/record/values.mjs.map +1 -1
  122. package/src/runtime.d.mts +10 -6
  123. package/src/runtime.d.ts +10 -6
  124. package/src/runtime.js +19 -2
  125. package/src/runtime.js.map +1 -1
  126. package/src/runtime.mjs +7 -3
  127. package/src/runtime.mjs.map +1 -1
@@ -1,3 +1,4 @@
1
+ import { upgradePage } from '../format/upgrade.mjs';
1
2
  import { compileModifier } from '../expression/modifier.mjs';
2
3
  import { localDay } from '../expression/functions.mjs';
3
4
  import { checkProperties, checkValue } from './check.mjs';
@@ -11,25 +12,54 @@ import { resolveFilter } from './filter.mjs';
11
12
  import { rolesAllow } from './roles.mjs';
12
13
  import { compileFieldTone, compileTable } from './cells.mjs';
13
14
  import { fill, MESSAGES } from './messages.mjs';
14
- import { saveProblemOf } from './data-source.mjs';
15
+ import { loadedRecordOf, saveProblemOf, } from './data-source.mjs';
16
+ import { createUploads, fileBytesIn, uploadGoing, withoutFileBytes } from './uploads.mjs';
15
17
  import { hostScheduler } from './scheduler.mjs';
16
18
  import { createMoments, createRunner } from './run.mjs';
17
19
  import { emptyValue, initialValues, isEmpty, lineKind, structuredCopy, } from './values.mjs';
20
+ /**
21
+ * Whether leaving the record now would lose the person's work: changes not
22
+ * saved, files still on their way to the store, or changes Reload set aside
23
+ * and not yet put back or let go, or text left in a box that does not read.
24
+ * Every way out of a record asks this.
25
+ */
26
+ export function unsavedWork(state) {
27
+ return state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0 || state.setAside != null || (state.uploads ?? []).some(uploadGoing);
28
+ }
29
+ /**
30
+ * 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.
37
+ */
38
+ 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));
40
+ }
18
41
  /**
19
42
  * The record state behind a page: values, what changed, what is visible and
20
43
  * required, errors, loading and saving. No DOM and no framework — every
21
44
  * binding renders from `getState()` and calls these methods.
22
45
  */
23
46
  export function createForm(options) {
24
- return innerForm(options, []).form;
47
+ // A page in an older format is read in the current one (upgradePage): the same object when it already is.
48
+ const page = upgradePage(options.page);
49
+ return innerForm(page === options.page ? options : { ...options, page }, []).form;
25
50
  }
26
51
  /** A line's cell whose line is no longer there. */
27
52
  const gone = Symbol('gone');
28
53
  const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
29
54
  /** A saved form's answers, as the one value they are in the form round it. */
30
55
  const asValue = (values) => values;
31
- /** `within` are the pages round this one, outermost first: a page among them placed again would hold itself. */
32
- function innerForm(options, within) {
56
+ /**
57
+ * `within` are the pages round this one, outermost first: a page among them
58
+ * placed again would hold itself. `place`, for a saved form placed in a
59
+ * record: the record its files are stored for. `outer`, the form round it:
60
+ * its ways out go through that one's check.
61
+ */
62
+ function innerForm(options, within, place, outer) {
33
63
  const { page } = options;
34
64
  const scheduler = options.scheduler ?? hostScheduler;
35
65
  const messages = options.messages ?? MESSAGES.en;
@@ -100,7 +130,9 @@ function innerForm(options, within) {
100
130
  let editing = options.editing !== false;
101
131
  /** The record a line's form is of, as its conditions read it: worked out once. */
102
132
  const parentContext = options.parent ? recordContext(options.parent.values, options.parent.fields, { recordId: options.parent.id ?? null, user: options.user, context: options.context }) : undefined;
103
- const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing, context: options.context, parent: parentContext });
133
+ /** Whether the record is being edited now: not while a view locks it, nor when its source lets the person only read it. */
134
+ const editingNow = () => editing && !(started && state.can?.edit === false);
135
+ const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing: editingNow(), context: options.context, parent: parentContext });
104
136
  const computed = compileComputed(page, today, about);
105
137
  const setWhen = compileSetWhen(page, today, about);
106
138
  /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
@@ -235,9 +267,148 @@ function innerForm(options, within) {
235
267
  saveProblem: null,
236
268
  choices: {},
237
269
  definitions: {},
270
+ uploads: [],
271
+ version: null,
272
+ can: null,
273
+ conflict: null,
274
+ setAside: null,
275
+ unsent: [],
276
+ leaving: false,
238
277
  unread: {},
239
278
  };
240
279
  started = true;
280
+ /**
281
+ * Each record shown — opened, loaded again, a new one — is a generation of
282
+ * its own: a save's answer, or a call's, started in another is not laid
283
+ * over it. What follows is kept for the generation shown, and starts again
284
+ * with the next.
285
+ */
286
+ let generation = 0;
287
+ /** Closed (`dispose`): a run still going stops quietly, as its dialog or page is gone — no other record replaced it. */
288
+ 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. */
290
+ const personal = new Set();
291
+ /** Fields where a file the person picked landed from the store: at risk until a save sends it, a first save or not. */
292
+ const filed = new Set();
293
+ /** 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
+ 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;
299
+ /** Each field's last comparison with what was sent, so a value that did not move is not compared again. */
300
+ const compared = new Map();
301
+ /** Fields showing a file that did not reach the store: shown until it is sent again or taken away, whatever else changes. */
302
+ const unstoredShown = new Set();
303
+ /** 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. */
304
+ const where = place ?? { model: () => (page.data.kind === 'record' ? page.data.model : page.id), recordId: () => state.recordId, under: '' };
305
+ /** This form's own files on their way; the state's `uploads` adds those of each saved form placed in it. */
306
+ let ownUploads = [];
307
+ /** The problem a save was stopped by for files that did not reach the store: it goes once none of them is one. */
308
+ let filesProblem = null;
309
+ /** 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. */
310
+ let quietParts = 0;
311
+ /** Files on their way to the store: uploads.ts. */
312
+ const uploads = createUploads({
313
+ source: () => options.dataSource,
314
+ model: where.model,
315
+ recordId: where.recordId,
316
+ under: where.under,
317
+ lineId: (field, key) => (state.values[field] ?? []).find((line) => line.key === key)?.id,
318
+ fileField(field, line) {
319
+ const def = line ? (fields[line.field]?.type === 'one2many' ? fields[line.field].fields[field] : undefined) : fields[field];
320
+ return def?.type === 'binary' || def?.type === 'image' ? def : null;
321
+ },
322
+ read: (field, line) => (line ? (state.values[line.field] ?? []).find((l) => l.key === line.key)?.values[field] : state.values[field]),
323
+ write(field, line, value, quiet) {
324
+ if (!quiet) {
325
+ // A file the store holds now, picked by the person: at risk until a save sends it.
326
+ filed.add(line ? line.field : field);
327
+ return line ? form.updateLine(line.field, line.key, field, value) : form.setValue(field, value);
328
+ }
329
+ // A link in place of the bytes, as a save stores them: nothing else follows from it.
330
+ const values = line ? { [line.field]: (state.values[line.field] ?? []).map((l) => (l.key === line.key ? { ...l, values: { ...l.values, [field]: value } } : l)) } : { [field]: value };
331
+ writeValues({ ...state.values, ...values });
332
+ },
333
+ changed(list) {
334
+ ownUploads = list;
335
+ filesChanged();
336
+ },
337
+ track,
338
+ });
339
+ /**
340
+ * Files on their way changed, here or in a saved form placed here: the
341
+ * state shows them all, and a file sent again, or taken away, takes its
342
+ * problem with it — and the save it stopped is no longer said to be stopped
343
+ * once none of its files is a problem.
344
+ */
345
+ function filesChanged() {
346
+ const problems = fileProblems();
347
+ const gone = [...unstoredShown].filter((key) => !(key in problems));
348
+ for (const key of gone)
349
+ unstoredShown.delete(key);
350
+ const errors = gone.length ? Object.fromEntries(Object.entries(state.errors).filter(([key]) => !gone.includes(key))) : null;
351
+ const stopped = filesProblem;
352
+ const mended = stopped !== null && state.saveProblem === stopped && !Object.keys(stopped.fields ?? {}).some((key) => key in problems);
353
+ if (mended)
354
+ filesProblem = null;
355
+ set({ uploads: allUploads(), ...(errors ? { errors } : {}), ...(mended ? { status: 'ready', error: null, saveProblem: null } : {}) });
356
+ }
357
+ /** 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. */
358
+ function allUploads() {
359
+ if (!attached.size)
360
+ return ownUploads;
361
+ const all = [...ownUploads];
362
+ for (const part of attached.values())
363
+ for (const upload of part.inner.form.getState().uploads)
364
+ all.push(underPart(part.name, upload));
365
+ return all;
366
+ }
367
+ /** What stops a save for files: this form's, and each saved form's placed in it under `name.field`. */
368
+ function fileProblems() {
369
+ const found = uploads.problems(messages);
370
+ for (const part of attached.values())
371
+ for (const [key, message] of Object.entries(part.inner.files.problems()))
372
+ found[`${part.name}.${key}`] = message;
373
+ return found;
374
+ }
375
+ const filesGoing = () => uploads.going() || [...attached.values()].some((part) => part.inner.files.going());
376
+ async function filesSettled() {
377
+ while (filesGoing())
378
+ await Promise.all([uploads.settled(), ...[...attached.values()].map((part) => part.inner.files.settled())]);
379
+ }
380
+ /** 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. */
381
+ async function storeBytes(names) {
382
+ quietParts++;
383
+ try {
384
+ const inParts = [...attached.values()].filter((part) => names.includes(part.name)).map((part) => part.inner.files.storeBytes());
385
+ await Promise.all([uploads.storeBytes(fileBytesIn(fields, state.values, names)), ...inParts]);
386
+ }
387
+ finally {
388
+ quietParts--;
389
+ }
390
+ }
391
+ /** Values as a draft keeps them where files are stored apart: no file's bytes, a saved form's answers included. */
392
+ function draftOf(values) {
393
+ const kept = withoutFileBytes(fields, values);
394
+ for (const part of attached.values())
395
+ if (isRecord(kept[part.name]))
396
+ kept[part.name] = asValue(part.inner.files.draft(kept[part.name]));
397
+ return kept;
398
+ }
399
+ /** Stop every upload, here and in the saved forms placed here. */
400
+ function clearFiles() {
401
+ filesProblem = null;
402
+ uploads.clear();
403
+ for (const part of attached.values())
404
+ part.inner.files.clear();
405
+ }
406
+ /** 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. */
407
+ function partUpload(id) {
408
+ const at = id.indexOf('/');
409
+ const part = at < 0 ? undefined : [...attached.values()].find((p) => p.name === id.slice(0, at));
410
+ return part ? { form: part.inner.form, id: id.slice(at + 1) } : null;
411
+ }
241
412
  /** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
242
413
  let serverErrors = {};
243
414
  let lineKeys = 0;
@@ -262,6 +433,12 @@ function innerForm(options, within) {
262
433
  const was = state.values;
263
434
  const shown = state.errors;
264
435
  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) {
438
+ const unsent = unsentNow();
439
+ if (!sameList(unsent, state.unsent))
440
+ state = { ...state, unsent };
441
+ }
265
442
  // The problems found inside a saved form are shown on it, beside its own fields.
266
443
  if (state.errors !== shown)
267
444
  for (const part of attached.values())
@@ -288,11 +465,62 @@ function innerForm(options, within) {
288
465
  }
289
466
  /** A field written: told to the app, and — a person's change — the page's `change` steps for it run. */
290
467
  function changed(field, by) {
468
+ noteOrigin(field, by);
291
469
  emit('change', { field, value: state.values[field], values: state.values, by });
292
470
  const list = page.on?.change?.[field];
293
471
  if (by === 'person' && list)
294
472
  void track(moment(`on.change.${field}`, list, [], true));
295
473
  }
474
+ /** Another record is shown: nothing of the last one's is the person's work any more. */
475
+ function newGeneration() {
476
+ generation++;
477
+ personal.clear();
478
+ filed.clear();
479
+ compared.clear();
480
+ sentAs = null;
481
+ }
482
+ /** 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
+ const filedIn = (name) => filed.has(name) || [...attached.values()].some((part) => part.name === name && part.inner.files.filed());
484
+ /**
485
+ * The person's work no save took, by field, in the page's order: each of
486
+ * their changes that differs from the record as the last save left it —
487
+ * before a first save, only a file the store holds.
488
+ */
489
+ function unsentNow() {
490
+ if (!personal.size)
491
+ return [];
492
+ const out = [];
493
+ for (const name of Object.keys(fields)) {
494
+ if (!personal.has(name))
495
+ continue;
496
+ const from = sentAs ?? (filedIn(name) ? baseline : null);
497
+ if (from && differs(name, state.values[name], from[name]))
498
+ out.push(name);
499
+ }
500
+ return out;
501
+ }
502
+ function differs(name, value, sent) {
503
+ const known = compared.get(name);
504
+ if (known && known.value === value && known.sent === sent)
505
+ return known.differs;
506
+ const verdict = value !== sent && !sameValue(fields[name], value, sent);
507
+ compared.set(name, { value, sent, differs: verdict });
508
+ return verdict;
509
+ }
510
+ /** `unsent` as it is now, told when it changed. */
511
+ function refreshUnsent() {
512
+ const unsent = unsentNow();
513
+ if (!sameList(unsent, state.unsent))
514
+ set({ unsent });
515
+ }
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
521
+ personal.delete(field);
522
+ refreshUnsent();
523
+ }
296
524
  /** A wizard step entered: told to the app, and its `show` steps run. */
297
525
  function tellStep(chain) {
298
526
  const step = state.step;
@@ -374,10 +602,10 @@ function innerForm(options, within) {
374
602
  }
375
603
  /** The values as expressions read them, and what else they read: the record's id, the person, the lines' rows, today. */
376
604
  function reading() {
377
- if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editing) {
605
+ if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editingNow()) {
378
606
  const values = state.values;
379
607
  const context = recordContext(values, page.fields, about());
380
- contextCache = { values, recordId: state.recordId, editing, context, env: expressionEnv(values, page.fields, today) };
608
+ contextCache = { values, recordId: state.recordId, editing: editingNow(), context, env: expressionEnv(values, page.fields, today) };
381
609
  }
382
610
  return contextCache;
383
611
  }
@@ -407,6 +635,14 @@ function innerForm(options, within) {
407
635
  readonly = index.get(parent)?.readonly.evaluate(ctx, env) ?? false;
408
636
  }
409
637
  const def = node.field ? page.fields[node.field] : undefined;
638
+ // 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.
639
+ const can = state.can;
640
+ if (can) {
641
+ if ((node.field !== undefined && can.hiddenFields?.includes(node.field)) || node.does?.some((right) => can[right] === false))
642
+ invisible = true;
643
+ if ((node.kind === 'field' || node.kind === 'form') && (can.edit === false || (node.field !== undefined && can.readonlyFields?.includes(node.field) === true)))
644
+ readonly = true;
645
+ }
410
646
  return {
411
647
  invisible,
412
648
  readonly: readonly || (def !== undefined && lockedField(def)),
@@ -702,6 +938,11 @@ function innerForm(options, within) {
702
938
  else
703
939
  delete serverErrors[name];
704
940
  }
941
+ // A file that did not reach the store stays said under its field.
942
+ const unstoredNow = fileProblems();
943
+ for (const key of unstoredShown)
944
+ if (unstoredNow[key])
945
+ kept[key] ??= unstoredNow[key];
705
946
  return kept;
706
947
  }
707
948
  function scheduleDraft() {
@@ -712,9 +953,27 @@ function innerForm(options, within) {
712
953
  scheduler.clearTimeout(draftTimer);
713
954
  draftTimer = scheduler.setTimeout(() => {
714
955
  draftTimer = null;
715
- drafts.store.setItem(draftKey(), JSON.stringify({ savedAt: new Date().toISOString(), values: state.values }));
956
+ // Where files are stored apart, never a file's bytes: they would fill the browser's store (uploads.ts). Without, kept as always.
957
+ // A store that is full anyway keeps no draft.
958
+ const values = options.dataSource?.upload ? draftOf(state.values) : state.values;
959
+ try {
960
+ drafts.store.setItem(draftKey(), JSON.stringify({ savedAt: new Date().toISOString(), values }));
961
+ }
962
+ catch {
963
+ // The draft is a convenience: the form goes on without it.
964
+ }
716
965
  }, drafts.delayMs ?? 400);
717
966
  }
967
+ /** 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. */
968
+ function withDraft(kept) {
969
+ const values = { ...state.values, ...kept };
970
+ for (const part of attached.values()) {
971
+ const now = state.values[part.name];
972
+ if (isRecord(now) && isRecord(kept[part.name]))
973
+ values[part.name] = asValue({ ...now, ...kept[part.name] });
974
+ }
975
+ return values;
976
+ }
718
977
  function forgetDraft() {
719
978
  if (draftTimer !== null)
720
979
  scheduler.clearTimeout(draftTimer);
@@ -737,13 +996,14 @@ function innerForm(options, within) {
737
996
  return;
738
997
  }
739
998
  if (drafts.restore === 'auto')
740
- writeValues({ ...state.values, ...draft.values }, { draft: null }, true);
999
+ writeValues(withDraft(draft.values), { draft: null }, true);
741
1000
  else
742
1001
  set({ draft: { savedAt: draft.savedAt } });
743
1002
  }
744
1003
  function scheduleAutosave() {
745
1004
  const autosave = options.autosave;
746
- if (!autosave || page.data.kind !== 'record')
1005
+ // A record the person may not change is never saved unasked: it would only be refused.
1006
+ if (!autosave || page.data.kind !== 'record' || state.can?.edit === false)
747
1007
  return;
748
1008
  if (autosaveTimer !== null)
749
1009
  scheduler.clearTimeout(autosaveTimer);
@@ -817,74 +1077,202 @@ function innerForm(options, within) {
817
1077
  async function save(chain = []) {
818
1078
  return (await saving(chain)) === null;
819
1079
  }
1080
+ /** Every value, for a source that saves whole records: those of fields hidden from the person, which they never had, left out. */
1081
+ function sentValues() {
1082
+ const values = structuredCopy(state.values);
1083
+ for (const name of state.can?.hiddenFields ?? [])
1084
+ delete values[name];
1085
+ return values;
1086
+ }
1087
+ /** A conflict as the form tells it: what they changed since the record was opened, and what both changed. */
1088
+ function conflictOf(conflict) {
1089
+ const theirs = conflict.theirs;
1090
+ const changed = theirs ? Object.keys(fields).filter((name) => name in theirs && !sameValue(fields[name], theirs[name], baseline[name])) : [];
1091
+ const both = changed.filter((name) => state.dirty.includes(name) && !sameValue(fields[name], theirs[name], state.values[name]));
1092
+ return { version: conflict.version, ...(theirs ? { theirs } : {}), changed, both };
1093
+ }
820
1094
  /**
821
1095
  * Save, or send: null once done, or why not. The page's `beforeSave` steps
822
1096
  * run first — before its own check, so they may fill in what it asks for —
823
1097
  * and a stop keeps it unsaved; `afterSave` runs once it is saved.
824
1098
  */
825
- async function saving(chain) {
1099
+ /** The save running now: the next one waits for it. */
1100
+ let saveRunning = null;
1101
+ /**
1102
+ * One save at a time: a second — Ctrl+Enter pressed twice, or while the
1103
+ * first waits for files — waits for the first, then saves what is left to
1104
+ * save with the version the first got back, never as someone else's. A save
1105
+ * the page's own beforeSave steps ask for runs inside the one that set them off.
1106
+ */
1107
+ function saving(chain) {
1108
+ if (chain.includes('on.beforeSave'))
1109
+ return savingNow(chain);
1110
+ const before = saveRunning;
1111
+ const mine = (async () => {
1112
+ if (before)
1113
+ await before;
1114
+ return savingNow(chain);
1115
+ })();
1116
+ const done = mine.then(() => undefined, () => undefined);
1117
+ saveRunning = done;
1118
+ void done.then(() => {
1119
+ if (saveRunning === done)
1120
+ saveRunning = null;
1121
+ });
1122
+ return mine;
1123
+ }
1124
+ async function savingNow(chain) {
1125
+ // A record its source does not let this person change is refused here, in its words or the form's: no source is asked.
1126
+ if (page.data.kind === 'record' && state.can?.edit === false) {
1127
+ const problem = { kind: 'rule', message: state.can.reason ?? messages.mayNotEdit };
1128
+ set({ status: 'error', error: problem.message, saveProblem: problem, conflict: null });
1129
+ return { reason: 'save', message: problem.message };
1130
+ }
826
1131
  // What is still typed in a box is read first: it is what is saved, or why nothing is. A name still being looked
827
1132
  // up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
828
1133
  const waiting = readTyped();
829
1134
  if (waiting)
830
1135
  await waiting;
1136
+ // 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
+ if (filesGoing()) {
1138
+ set({ status: 'saving', error: null, saveProblem: null });
1139
+ await filesSettled();
1140
+ set({ status: 'ready' });
1141
+ }
831
1142
  if (page.on?.beforeSave) {
832
1143
  const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
833
1144
  if (!before.done) {
834
1145
  // A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
835
1146
  if (before.reason !== 'check' && before.reason !== 'save') {
836
1147
  const problem = { kind: 'stopped', reason: before.reason, message: before.message ?? '' };
837
- set({ status: 'error', error: problem.message || null, saveProblem: problem });
1148
+ set({ status: 'error', error: problem.message || null, saveProblem: problem, conflict: null });
838
1149
  }
839
1150
  return { reason: 'save', ...(before.message !== undefined ? { message: before.message } : {}) };
840
1151
  }
841
1152
  }
842
- const errors = collectErrors();
1153
+ // A file that did not reach the store stops it, under its field, with the form's own checks.
1154
+ const errors = { ...collectErrors(), ...fileProblems() };
843
1155
  if (Object.keys(errors).length) {
844
- set({ errors });
1156
+ set({ errors, ...unstored(errors) });
845
1157
  return { reason: 'save', message: Object.values(errors)[0] };
846
1158
  }
847
1159
  const source = options.dataSource;
848
1160
  serverErrors = {};
849
- set({ status: 'saving', errors: {}, error: null, saveProblem: null });
1161
+ set({ status: 'saving', errors: {}, error: null, saveProblem: null, conflict: null });
1162
+ /** The generation the record was in when this save went to the source. */
1163
+ let at = generation;
850
1164
  try {
1165
+ // 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
+ await storeBytes(page.data.kind === 'responses' || state.recordId == null ? Object.keys(fields) : state.dirty);
1167
+ const failed = fileProblems();
1168
+ if (Object.keys(failed).length) {
1169
+ set({ errors: failed, ...unstored(failed) });
1170
+ return { reason: 'save', message: Object.values(failed)[0] };
1171
+ }
851
1172
  if (page.data.kind === 'responses') {
852
1173
  if (!source?.submit)
853
1174
  throw new Error('This page has no data source to send its answers to');
854
1175
  const sent = shownValues();
1176
+ const before = state.values;
1177
+ at = generation;
855
1178
  await source.submit({ pageId: page.id, values: sent });
1179
+ // Answered for answers no longer shown — the form was started again meanwhile: the source has them, the form lays nothing.
1180
+ if (at !== generation)
1181
+ return null;
856
1182
  forgetDraft();
857
- set({ status: 'saved' });
1183
+ // What it sent is what an answer changed since is told against: still to send.
1184
+ sentAs = before;
1185
+ set({ status: 'saved', unsent: unsentNow() });
858
1186
  emit('send', { values: sent });
859
1187
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
860
1188
  return null;
861
1189
  }
862
1190
  if (!source?.save)
863
1191
  throw new Error('This page has no data source to save to');
1192
+ // What is sent: a change made while it is on its way is not in it, and stays a change to save.
1193
+ const sent = state.values;
1194
+ at = generation;
1195
+ const resetAt = resets;
864
1196
  const result = await source.save({
865
1197
  model: page.data.model,
866
1198
  id: state.recordId,
867
1199
  fields,
868
1200
  changes: changes(),
869
- values: structuredCopy(state.values),
1201
+ values: sentValues(),
1202
+ // The version it was opened at, for a source that refuses a save over someone else's.
1203
+ ...(state.recordId != null && state.version ? { version: state.version } : {}),
870
1204
  });
1205
+ // Answered for a record no longer shown — another opened, or this one loaded again: the source has it, and the
1206
+ // form lays nothing of it over the record it shows now.
1207
+ if (at !== generation)
1208
+ return null;
871
1209
  forgetDraft();
872
- // Lines the save brings back load again, in their tables' orders, as Flectra's reload after a save.
873
- const values = syncParts(startFrom(result.values ? ordered({ ...state.values, ...result.values }, Object.keys(result.values)) : state.values), 'start');
874
- baseline = structuredCopy(values);
875
- set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings), unread: {} });
1210
+ // Reset by the person meanwhile: the record as it stored it, the reset values never laid over it.
1211
+ const { values, dirty } = afterSave(sent, result.values, resets !== resetAt);
1212
+ // The record as this save left it: what the person changes from here is told against it.
1213
+ sentAs = baseline;
1214
+ // Its version now, for the next save to carry; and what the person may do now, when the save changed that.
1215
+ // The person's changes it did not send are at risk (`unsent`): every way out asks before it leaves them.
1216
+ set({ status: 'saved', recordId: result.id, values, dirty, warnings: withValues(values, collectWarnings), version: result.version ?? null, ...(result.can ? { can: result.can } : {}) });
1217
+ // Changed while it saved: kept in a draft, and saved as any change is.
1218
+ if (dirty.length) {
1219
+ scheduleDraft();
1220
+ scheduleAutosave();
1221
+ }
876
1222
  emit('save', { recordId: state.recordId, values: state.values });
877
1223
  void track(moment('on.afterSave', page.on?.afterSave, chain, false));
878
1224
  return null;
879
1225
  }
880
1226
  catch (error) {
881
1227
  const problem = saveProblemOf(error);
1228
+ // Refused for a record no longer shown: nothing of it is laid over the one shown now.
1229
+ if (at !== generation)
1230
+ return { reason: 'save', message: problem.message };
1231
+ // Refused: what the person changed meanwhile is a change not saved, as the rest.
882
1232
  const fields = problem.kind === 'fields' ? problem.fields ?? {} : {};
883
1233
  serverErrors = Object.fromEntries(Object.entries(fields).map(([name, message]) => [name, { message, value: JSON.stringify(state.values[name] ?? null) }]));
884
- set({ status: 'error', error: problem.message, saveProblem: problem, errors: { ...fields } });
1234
+ set({ status: 'error', error: problem.message, saveProblem: problem, errors: { ...fields }, ...(problem.conflict ? { conflict: conflictOf(problem.conflict) } : {}) });
885
1235
  return { reason: 'save', message: problem.message };
886
1236
  }
887
1237
  }
1238
+ /**
1239
+ * The record as a save left it — what was sent, with what the save brought
1240
+ * back, lines loaded again in their tables' orders, as Flectra's reload after
1241
+ * a save — as the new starting point. What the person changed while it was
1242
+ * on its way (a file stored meanwhile) was not sent: it is laid over that,
1243
+ * still a change to save, never taken as saved.
1244
+ */
1245
+ function afterSave(sent, back, whole = false) {
1246
+ const stored = (from) => (back ? ordered({ ...from, ...back }, Object.keys(back)) : from);
1247
+ const since = whole ? [] : Object.keys(fields).filter((name) => state.values[name] !== sent[name] && !sameValue(fields[name], state.values[name], sent[name]));
1248
+ if (!since.length) {
1249
+ const values = syncParts(startFrom(stored(whole ? sent : state.values)), 'start');
1250
+ baseline = structuredCopy(values);
1251
+ return { values, dirty: [] };
1252
+ }
1253
+ const record = stored(sent);
1254
+ const mine = { ...record };
1255
+ for (const name of since)
1256
+ mine[name] = keepIds(fields[name], state.values[name], record[name]);
1257
+ const values = syncParts(startFrom(mine), 'start');
1258
+ // The starting point is the record as stored: those fields as it holds them.
1259
+ const base = { ...values };
1260
+ for (const name of since)
1261
+ base[name] = record[name];
1262
+ baseline = structuredCopy(computed.apply(base));
1263
+ return { values, dirty: dirtyFields(values) };
1264
+ }
1265
+ /** A save stopped by files that did not reach the store: shown under their fields, as a refused save's fields are. */
1266
+ function unstored(errors) {
1267
+ const files = fileProblems();
1268
+ const only = Object.fromEntries(Object.entries(errors).filter(([key]) => key in files));
1269
+ for (const key of Object.keys(only))
1270
+ unstoredShown.add(key);
1271
+ if (!Object.keys(only).length)
1272
+ return {};
1273
+ filesProblem = { kind: 'fields', message: Object.values(only)[0], fields: only };
1274
+ return { status: 'error', error: Object.values(only)[0], saveProblem: filesProblem };
1275
+ }
888
1276
  /** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
889
1277
  async function opening() {
890
1278
  emit('open', { recordId: state.recordId, values: state.values });
@@ -975,8 +1363,9 @@ function innerForm(options, within) {
975
1363
  inRun(chain, () => (went = form.goTo(target)));
976
1364
  return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
977
1365
  }
978
- /** Run steps from the top — a button's, a moment's, the app's — and tell how it ended. */
1366
+ /** 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`). */
979
1367
  async function runTop(list, scope) {
1368
+ scope.generation = generation;
980
1369
  const result = await runner.runSteps(list, scope);
981
1370
  emit('run', { id: scope.id, steps: list, result });
982
1371
  return result;
@@ -1035,14 +1424,17 @@ function innerForm(options, within) {
1035
1424
  }
1036
1425
  // Only the latest load is taken: a pager pressed twice shows the record it ended on.
1037
1426
  const seq = ++loads;
1038
- set({ status: 'loading', error: null });
1427
+ // The record loaded again is shown anew: an answer started for it before is not laid over it.
1428
+ newGeneration();
1429
+ set({ status: 'loading', error: null, unsent: [] });
1039
1430
  try {
1040
- const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
1431
+ // A source may give the record's version and what the person may do with it beside its values.
1432
+ const { values: loaded, version, can } = loadedRecordOf(await source.load({ model: page.data.model, id: state.recordId, fields }));
1041
1433
  if (seq !== loads)
1042
1434
  return;
1043
1435
  // Lines load in their tables' orders; where a person moves them after, they stay.
1044
1436
  baseline = syncParts(startFrom(ordered({ ...initialValues(fields, today()), ...loaded })), 'start');
1045
- set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), unread: {} });
1437
+ set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), version: version ?? null, can: can ?? null, unread: {} });
1046
1438
  if (again)
1047
1439
  return;
1048
1440
  offerDraft();
@@ -1054,8 +1446,14 @@ function innerForm(options, within) {
1054
1446
  }
1055
1447
  }
1056
1448
  let loads = 0;
1449
+ /** 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. */
1450
+ function openRecord(id, chain = []) {
1451
+ const go = letGo({}, chain);
1452
+ return go === true ? showNow(id) : go.then((yes) => (yes ? showNow(id) : false));
1453
+ }
1057
1454
  /** Another record in this form's place, or a new one: what was there let go, the new one loaded and opened. */
1058
- async function openRecord(id) {
1455
+ async function showNow(id) {
1456
+ newGeneration();
1059
1457
  if (draftTimer !== null)
1060
1458
  scheduler.clearTimeout(draftTimer);
1061
1459
  if (autosaveTimer !== null)
@@ -1064,10 +1462,12 @@ function innerForm(options, within) {
1064
1462
  serverErrors = {};
1065
1463
  told = null;
1066
1464
  showing = false;
1067
- const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} };
1465
+ clearFiles();
1466
+ 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: {} };
1068
1467
  if (id != null) {
1069
1468
  set({ ...fresh, recordId: id, status: 'idle' });
1070
- return load();
1469
+ await load();
1470
+ return true;
1071
1471
  }
1072
1472
  loads++;
1073
1473
  baseline = startFrom(startingValues(true, false));
@@ -1075,6 +1475,142 @@ function innerForm(options, within) {
1075
1475
  set({ ...fresh, recordId: null, status: 'ready', values, warnings: withValues(values, collectWarnings) });
1076
1476
  offerDraft();
1077
1477
  await opening();
1478
+ return true;
1479
+ }
1480
+ /** The check going now — waiting, or asking — that a way out taken meanwhile joins, with how it was taken. */
1481
+ let checking = null;
1482
+ /** 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. */
1483
+ const busy = (own) => (!own && saveRunning !== null) || filesGoing();
1484
+ /** 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. */
1485
+ async function quietFirst(own) {
1486
+ if (!busy(own))
1487
+ return;
1488
+ set({ leaving: true });
1489
+ try {
1490
+ while (busy(own)) {
1491
+ if (!own && saveRunning)
1492
+ await saveRunning;
1493
+ else
1494
+ await filesSettled();
1495
+ }
1496
+ }
1497
+ finally {
1498
+ set({ leaving: false });
1499
+ }
1500
+ }
1501
+ /** 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));
1503
+ /** The saves on their way, settled: what a load that is not a way out (a conflict's Reload) waits for. */
1504
+ async function savesSettled() {
1505
+ while (saveRunning)
1506
+ await saveRunning;
1507
+ }
1508
+ /**
1509
+ * THE check: every way the form's values go — another record, the record
1510
+ * loaded again, the host's close, a reset step, the app's own ways out
1511
+ * through `leave()` — passes here, so none can lose the person's work
1512
+ * unasked. First it waits for a save on its way and files on theirs, so
1513
+ * no answer lands on a record that is gone and nothing is told against
1514
+ * values that are about to change; then it looks at the record as it is
1515
+ * now. Nothing at risk (`workAtRisk`; with `changes`, every change not
1516
+ * saved too): true — at once when nothing was on its way, as in 0.20. Else
1517
+ * "Discard your changes?" through the host — Discard worded Yes, the focus
1518
+ * on Cancel — or `confirm`; with neither, the work is kept. `discard`: the
1519
+ * person's own Discard, asked nothing. A way out taken while one waits or
1520
+ * asks joins it, and gets its answer. A saved form placed in a record asks
1521
+ * the record's. `chain`: the moments the run taking it was set off by — a
1522
+ * step of a save's own beforeSave does not wait for that save.
1523
+ */
1524
+ function letGo(how = {}, chain = []) {
1525
+ if (outer)
1526
+ return outer.letGo(how, chain);
1527
+ // A delete the person chose: their work goes with the record, unasked.
1528
+ if (deletes) {
1529
+ forget();
1530
+ return true;
1531
+ }
1532
+ const own = chain.includes('on.beforeSave');
1533
+ if (atOnce(how, own)) {
1534
+ if (how.discard)
1535
+ forget();
1536
+ return true;
1537
+ }
1538
+ if (checking) {
1539
+ const joined = checking;
1540
+ // Its answer is this one's too; asked another way (every change counting), it is checked that way after.
1541
+ return joined.going.then((yes) => yes && (sameHow(joined.how, how) || Promise.resolve(letGo(how, chain))));
1542
+ }
1543
+ const going = (async () => {
1544
+ await quietFirst(own);
1545
+ if (how.discard) {
1546
+ forget();
1547
+ return true;
1548
+ }
1549
+ if (!workAtRisk(state, how))
1550
+ return true;
1551
+ return askToLetGo();
1552
+ })();
1553
+ checking = { how, going };
1554
+ void going.then(() => {
1555
+ if (checking?.going === going)
1556
+ checking = null;
1557
+ });
1558
+ return going;
1559
+ }
1560
+ async function askToLetGo() {
1561
+ const message = messages.discardChanges;
1562
+ const host = options.host;
1563
+ let yes = false;
1564
+ try {
1565
+ yes = host ? await host.ask(message, { discard: true }) : options.confirm ? await options.confirm(message) : false;
1566
+ }
1567
+ catch {
1568
+ // A question that could not be asked keeps the work.
1569
+ }
1570
+ if (yes)
1571
+ forget();
1572
+ return yes;
1573
+ }
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. */
1575
+ function forget() {
1576
+ clearFiles();
1577
+ personal.clear();
1578
+ filed.clear();
1579
+ if (state.setAside !== null || state.unsent.length)
1580
+ set({ setAside: null, unsent: [] });
1581
+ }
1582
+ /** The check as a step meets it: null to go on — at once when nothing is at risk — else why the run stops. */
1583
+ function letGoStep(chain) {
1584
+ const go = letGo({}, chain);
1585
+ return go === true ? null : go.then((yes) => (yes ? null : kept()));
1586
+ }
1587
+ 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 = []) {
1590
+ 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));
1594
+ }
1595
+ /**
1596
+ * The host's close, as every `close` step reaches it: the form takes it
1597
+ * once, here where it is made, and puts the check before it — so the app's
1598
+ * own close (its panel's, its modal's: `host.close`) never drops the
1599
+ * person's work unasked, nor does the viewer's dialog, panel or page in
1600
+ * place. A saved form placed in a record closes as the record does.
1601
+ */
1602
+ function close(chain = []) {
1603
+ if (outer)
1604
+ return outer.close(chain);
1605
+ const host = options.host;
1606
+ if (!host?.close)
1607
+ return { reason: 'cannot', message: 'Nothing can close this form: it has no host, or was not opened in a dialog or panel' };
1608
+ const shut = () => {
1609
+ host.close?.();
1610
+ return null;
1611
+ };
1612
+ const go = letGo({}, chain);
1613
+ return go === true ? shut() : go.then((yes) => (yes ? shut() : kept()));
1078
1614
  }
1079
1615
  /** The record a record step asks the data source about. */
1080
1616
  const record = () => ({ model: page.data.model, id: state.recordId, fields });
@@ -1103,25 +1639,51 @@ function innerForm(options, within) {
1103
1639
  addLine: (field, values) => void addLine(field, values, 'step'),
1104
1640
  check,
1105
1641
  save: saving,
1106
- async reload() {
1642
+ generation: () => generation,
1643
+ gone: () => (disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced }),
1644
+ async reload(chain) {
1107
1645
  if (state.recordId == null)
1108
1646
  return { reason: 'cannot', message: 'The record is not saved yet: there is nothing to load again' };
1109
- await load(true);
1647
+ if (!(await loadAgain(true, chain)))
1648
+ return kept();
1110
1649
  return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
1111
1650
  },
1112
- reset: () => form.reset(),
1651
+ reset(chain) {
1652
+ const reset = () => {
1653
+ form.reset();
1654
+ return null;
1655
+ };
1656
+ const go = letGo({}, chain);
1657
+ return go === true ? reset() : go.then((yes) => (yes ? reset() : kept()));
1658
+ },
1659
+ close,
1113
1660
  goTo: goToStep,
1114
1661
  showing: inRun,
1115
1662
  host: options.host,
1116
1663
  confirm: options.confirm,
1117
1664
  onAction: options.onAction,
1118
1665
  answered: (request, result) => emit('action', { request, result }),
1119
- changed: () => state.dirty.length > 0,
1666
+ changed: () => state.dirty.length > 0 || state.uploads.some(uploadGoing),
1667
+ letGo: letGoStep,
1668
+ async deleting(remove, done) {
1669
+ deletes++;
1670
+ try {
1671
+ 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))
1674
+ forget();
1675
+ return out;
1676
+ }
1677
+ finally {
1678
+ deletes--;
1679
+ }
1680
+ },
1120
1681
  ...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
1121
1682
  ...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
1122
1683
  ...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
1123
- async showRecord(id) {
1124
- await openRecord(id);
1684
+ async showRecord(id, chain) {
1685
+ if (!(await openRecord(id, chain)))
1686
+ return kept();
1125
1687
  return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
1126
1688
  },
1127
1689
  told(event, id) {
@@ -1138,6 +1700,7 @@ function innerForm(options, within) {
1138
1700
  },
1139
1701
  post,
1140
1702
  wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
1703
+ barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
1141
1704
  });
1142
1705
  const moment = createMoments((key, list, chain) => runTop(list, { id: key, chain }));
1143
1706
  /** A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. */
@@ -1167,7 +1730,10 @@ function innerForm(options, within) {
1167
1730
  listeners.add(listener);
1168
1731
  return () => listeners.delete(listener);
1169
1732
  },
1170
- load: () => track(load()),
1733
+ load() {
1734
+ const go = letGo();
1735
+ return track(go === true ? load() : go.then((yes) => (yes ? load() : undefined)));
1736
+ },
1171
1737
  setValue(name, value, how) {
1172
1738
  fieldDef(name);
1173
1739
  writeValues({ ...state.values, [name]: structuredCopy(value) });
@@ -1177,6 +1743,8 @@ function innerForm(options, within) {
1177
1743
  setValues: (values, how) => (how?.stored ? store(values) : write(values, 'app')),
1178
1744
  node: nodeState,
1179
1745
  openRecord: (id) => track(openRecord(id)),
1746
+ leave: (how) => Promise.resolve(letGo(how)),
1747
+ leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
1180
1748
  post,
1181
1749
  setEditing(next) {
1182
1750
  if (next === editing)
@@ -1185,7 +1753,7 @@ function innerForm(options, within) {
1185
1753
  // Nothing of the record changed; what shows of it may have.
1186
1754
  set({});
1187
1755
  },
1188
- fieldReadonly: (name) => lockedField(fieldDef(name)),
1756
+ fieldReadonly: (name) => lockedField(fieldDef(name)) || state.can?.edit === false || state.can?.readonlyFields?.includes(name) === true,
1189
1757
  loadChoices,
1190
1758
  loadDefinitions,
1191
1759
  addDefinition,
@@ -1234,7 +1802,7 @@ function innerForm(options, within) {
1234
1802
  state = before;
1235
1803
  set({ unread: next, errors });
1236
1804
  },
1237
- leave(path) {
1805
+ boxLeft(path) {
1238
1806
  const problem = leftProblem(path);
1239
1807
  if (!problem || state.errors[path] === problem)
1240
1808
  return;
@@ -1259,12 +1827,36 @@ function innerForm(options, within) {
1259
1827
  return Object.keys(errors).length === 0;
1260
1828
  },
1261
1829
  save: () => track(save()),
1830
+ canUpload: () => typeof options.dataSource?.upload === 'function',
1831
+ upload: (field, files, how = {}) => uploads.start(field, files, how),
1832
+ uploading: (field) => state.uploads.filter((upload) => upload.field === field && !upload.line),
1833
+ cancelUpload(id) {
1834
+ const inner = partUpload(id);
1835
+ if (inner)
1836
+ inner.form.cancelUpload(inner.id);
1837
+ else
1838
+ uploads.cancel(id);
1839
+ },
1840
+ retryUpload(id) {
1841
+ const inner = partUpload(id);
1842
+ if (inner)
1843
+ inner.form.retryUpload(inner.id);
1844
+ else
1845
+ uploads.retry(id);
1846
+ },
1262
1847
  reset() {
1263
1848
  forgetDraft();
1849
+ // Files on their way were changes too: they go with the rest.
1850
+ clearFiles();
1264
1851
  serverErrors = {};
1265
1852
  holding = setWhen.holding(baseline);
1853
+ // 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
+ resets++;
1855
+ personal.clear();
1856
+ filed.clear();
1857
+ sentAs = null;
1266
1858
  const values = syncParts(structuredCopy(baseline), 'start');
1267
- set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} });
1859
+ 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: {} });
1268
1860
  },
1269
1861
  changes,
1270
1862
  addLine: (field, values = {}) => addLine(field, values, 'person'),
@@ -1529,12 +2121,91 @@ function innerForm(options, within) {
1529
2121
  if (!raw)
1530
2122
  return;
1531
2123
  const draft = JSON.parse(raw);
1532
- writeValues({ ...state.values, ...draft.values }, { draft: null }, true);
2124
+ writeValues(withDraft(draft.values), { draft: null }, true);
1533
2125
  },
1534
2126
  discardDraft() {
1535
2127
  forgetDraft();
1536
2128
  set({ draft: null });
1537
2129
  },
2130
+ async reloadTheirs() {
2131
+ // A save on its way first: its answer is not dropped by the load.
2132
+ await savesSettled();
2133
+ // The person's changes, kept before theirs are loaded over them.
2134
+ const mine = {};
2135
+ for (const name of state.dirty)
2136
+ mine[name] = structuredCopy(state.values[name]);
2137
+ await track(load(true));
2138
+ if (state.status !== 'ready')
2139
+ return false;
2140
+ // Set aside where theirs differ from mine: a change they made the same way is no loss.
2141
+ const aside = { ...(state.setAside ?? {}) };
2142
+ for (const [name, value] of Object.entries(mine))
2143
+ if (!sameValue(fields[name], value, state.values[name]))
2144
+ aside[name] = value;
2145
+ set({ conflict: null, saveProblem: null, setAside: Object.keys(aside).length ? aside : null });
2146
+ return true;
2147
+ },
2148
+ async keepMine() {
2149
+ const conflict = state.conflict;
2150
+ let told = conflict?.theirs;
2151
+ let version = conflict?.version;
2152
+ // Not told the record as they left it: read as it is now, so a source that saves whole records keeps their work.
2153
+ const source = options.dataSource;
2154
+ if (conflict && !told) {
2155
+ if (source?.load && state.recordId != null && page.data.kind === 'record') {
2156
+ try {
2157
+ const now = loadedRecordOf(await track(source.load({ model: page.data.model, id: state.recordId, fields })));
2158
+ told = now.values;
2159
+ version = now.version ?? version;
2160
+ }
2161
+ catch {
2162
+ // Unread: said below.
2163
+ }
2164
+ }
2165
+ // Still unread: saving now would go over work this person never saw. Nothing is saved; they are told, and choose again.
2166
+ if (!told) {
2167
+ const message = messages.theirsUnread;
2168
+ set({ status: 'error', error: message, saveProblem: { kind: 'conflict', message, conflict: { version: conflict.version } }, conflict: { ...conflict, unread: true } });
2169
+ return false;
2170
+ }
2171
+ }
2172
+ if (told) {
2173
+ // The record as they left it is where this person's changes start from: theirs stay wherever they changed nothing.
2174
+ const theirs = {};
2175
+ for (const [name, value] of Object.entries(told))
2176
+ if (fields[name])
2177
+ theirs[name] = structuredCopy(value);
2178
+ const mine = state.dirty;
2179
+ baseline = { ...baseline, ...theirs };
2180
+ const values = { ...state.values };
2181
+ for (const [name, value] of Object.entries(theirs))
2182
+ if (!mine.includes(name))
2183
+ values[name] = value;
2184
+ writeValues(values);
2185
+ }
2186
+ // Saved with the version it has now: these changes over the record as it is.
2187
+ if (version)
2188
+ set({ version });
2189
+ return track(save());
2190
+ },
2191
+ putBack(field) {
2192
+ const aside = state.setAside;
2193
+ if (!aside)
2194
+ return;
2195
+ const names = field === undefined ? Object.keys(aside) : Object.prototype.hasOwnProperty.call(aside, field) ? [field] : [];
2196
+ if (!names.length)
2197
+ return;
2198
+ const left = { ...aside };
2199
+ for (const name of names)
2200
+ delete left[name];
2201
+ set({ setAside: Object.keys(left).length ? left : null });
2202
+ for (const name of names)
2203
+ form.setValue(name, aside[name]);
2204
+ },
2205
+ discardSetAside() {
2206
+ if (state.setAside)
2207
+ set({ setAside: null });
2208
+ },
1538
2209
  async settled() {
1539
2210
  while (pending.size)
1540
2211
  await Promise.allSettled([...pending]);
@@ -1561,19 +2232,30 @@ function innerForm(options, within) {
1561
2232
  scheduler,
1562
2233
  messages,
1563
2234
  now: options.now,
1564
- }, chain);
2235
+ }, chain,
2236
+ // Its files are the record's: stored for it, under the part's name.
2237
+ { model: where.model, recordId: where.recordId, under: `${where.under}${part.name}.` },
2238
+ // Its ways out are the record's: through this form's check.
2239
+ { letGo, close, leaveNow: (how) => atOnce(how) });
1565
2240
  attached.set(id, { name: part.name, inner: made });
1566
2241
  // A change inside it is told as a change of its answers here, whoever made it.
1567
2242
  made.form.on('change', ({ by }) => {
1568
2243
  if (!syncing)
1569
2244
  changed(part.name, by);
1570
2245
  });
1571
- // A change made inside it is a change of this form's: shown, kept in a draft, saved.
2246
+ // 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.
2247
+ let seenUploads = made.form.getState().uploads;
1572
2248
  made.form.subscribe((inside) => {
2249
+ if (inside.uploads !== seenUploads) {
2250
+ seenUploads = inside.uploads;
2251
+ filesChanged();
2252
+ }
1573
2253
  if (syncing || inside.values === state.values[part.name])
1574
2254
  return;
1575
2255
  writeValues({ ...state.values, [part.name]: asValue(inside.values) });
1576
- afterEdit(part.name);
2256
+ // A link a save put in place of bytes is no change of the person's.
2257
+ if (!quietParts)
2258
+ afterEdit(part.name);
1577
2259
  });
1578
2260
  // What it starts with, its defaults and worked-out values filled in, is this form's starting point too — unless changed already.
1579
2261
  const start = asValue(made.form.getState().values);
@@ -1585,6 +2267,10 @@ function innerForm(options, within) {
1585
2267
  },
1586
2268
  embedded: (id) => attached.get(id)?.inner.form ?? null,
1587
2269
  dispose() {
2270
+ // An answer that lands after is for no record shown.
2271
+ disposed = true;
2272
+ newGeneration();
2273
+ clearFiles();
1588
2274
  if (draftTimer !== null)
1589
2275
  scheduler.clearTimeout(draftTimer);
1590
2276
  if (autosaveTimer !== null)
@@ -1607,6 +2293,8 @@ function innerForm(options, within) {
1607
2293
  take(values, how) {
1608
2294
  if (how !== 'start')
1609
2295
  return writeValues({ ...initialValues(page.fields, today()), ...values }, {}, how === 'fresh');
2296
+ // Started again from the record round it: a file it held is the record's now, sent or let go with it.
2297
+ filed.clear();
1610
2298
  baseline = startFrom({ ...initialValues(page.fields, today()), ...structuredCopy(values) });
1611
2299
  const start = syncParts(structuredCopy(baseline), 'start');
1612
2300
  set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null, unread: {} });
@@ -1617,8 +2305,26 @@ function innerForm(options, within) {
1617
2305
  if (JSON.stringify(errors) !== JSON.stringify(state.errors))
1618
2306
  set({ errors });
1619
2307
  },
2308
+ files: {
2309
+ going: filesGoing,
2310
+ settled: filesSettled,
2311
+ problems: fileProblems,
2312
+ // Its answers are sent whole: every field's bytes are stored.
2313
+ storeBytes: () => storeBytes(Object.keys(fields)),
2314
+ draft: draftOf,
2315
+ clear: clearFiles,
2316
+ filed: () => filed.size > 0,
2317
+ },
1620
2318
  };
1621
2319
  }
2320
+ /** Two ways out taken the same way: the one joined answers for the other. */
2321
+ const sameHow = (a, b) => !!a.changes === !!b.changes && !!a.discard === !!b.discard;
2322
+ /** Two lists of names the same, in the same order. */
2323
+ const sameList = (a, b) => a.length === b.length && a.every((name, i) => name === b[i]);
2324
+ /** 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. */
2325
+ function underPart(name, upload) {
2326
+ return { ...upload, id: `${name}/${upload.id}`, ...(upload.line ? { line: { ...upload.line, field: `${name}.${upload.line.field}` } } : { field: `${name}.${upload.field}` }) };
2327
+ }
1622
2328
  /** The problems found inside a saved form, by its own fields' names. */
1623
2329
  function inside(errors, name) {
1624
2330
  const found = {};
@@ -1654,6 +2360,46 @@ function compareLines(a, b, by) {
1654
2360
  function lockedField(def) {
1655
2361
  return def.readonly === true || def.compute !== undefined;
1656
2362
  }
2363
+ /** The words a person is told when a step would do what they may not, and the source gave no reason. */
2364
+ const MAY_NOT = { edit: 'mayNotEdit', archive: 'mayNotArchive', duplicate: 'mayNotDuplicate', delete: 'mayNotDelete' };
2365
+ /**
2366
+ * What a press does that a record's source may forbid, read from what it
2367
+ * runs: its steps — saving is editing; archiving and bringing back are both
2368
+ * archiving — or, for a gear menu's built-in item with nothing of its own, the
2369
+ * built-in itself.
2370
+ */
2371
+ function rightsUsed(source) {
2372
+ const steps = source.steps ?? [];
2373
+ const names = steps.map((step) => step.do);
2374
+ if ('builtin' in source && source.builtin && !steps.length && source.action === undefined)
2375
+ names.push(source.builtin);
2376
+ const rights = new Set();
2377
+ for (const name of names) {
2378
+ if (name === 'save')
2379
+ rights.add('edit');
2380
+ else if (name === 'archive' || name === 'unarchive')
2381
+ rights.add('archive');
2382
+ else if (name === 'duplicate' || name === 'delete')
2383
+ rights.add(name);
2384
+ }
2385
+ return [...rights];
2386
+ }
2387
+ /** 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. */
2388
+ function keepIds(def, mine, stored) {
2389
+ if (def.type !== 'one2many' || !Array.isArray(mine) || !Array.isArray(stored))
2390
+ return mine;
2391
+ const ids = new Map(stored.filter((line) => line.id !== undefined).map((line) => [line.key, line.id]));
2392
+ return mine.map((line) => (line.id === undefined && ids.has(line.key) ? { ...line, id: ids.get(line.key) } : line));
2393
+ }
2394
+ /** 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. */
2395
+ function sameValue(def, a, b) {
2396
+ const id = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) && 'id' in value ? value.id : value);
2397
+ if (def?.type === 'many2one')
2398
+ return JSON.stringify(id(a) ?? null) === JSON.stringify(id(b) ?? null);
2399
+ if (def?.type === 'many2many')
2400
+ return JSON.stringify((a ?? []).map(id)) === JSON.stringify((b ?? []).map(id));
2401
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
2402
+ }
1657
2403
  function lineOps(before, now) {
1658
2404
  const ops = [];
1659
2405
  const old = new Map(before.filter((line) => line.id !== undefined).map((line) => [line.key, line]));
@@ -1690,6 +2436,7 @@ function indexLayout(root) {
1690
2436
  const add = (source, parent, kind, modifiers) => {
1691
2437
  const roles = source.roles;
1692
2438
  const pressed = kind === 'button' ? source.pressed : undefined;
2439
+ const does = kind === 'button' || kind === 'stat' ? rightsUsed(source) : [];
1693
2440
  index.set(source.id, {
1694
2441
  id: source.id,
1695
2442
  parent,
@@ -1700,6 +2447,7 @@ function indexLayout(root) {
1700
2447
  readonly: compileModifier(modifiers.readonly),
1701
2448
  required: compileModifier(modifiers.required),
1702
2449
  ...(pressed !== undefined ? { pressed: compileModifier(pressed) } : {}),
2450
+ ...(does.length ? { does } : {}),
1703
2451
  source,
1704
2452
  });
1705
2453
  // A button's menu: each item a button of the record, hidden with it.