@fieldia/core 0.21.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.
- package/README.md +116 -1
- package/package.json +1 -1
- package/schema/page.schema.json +149 -1
- package/src/index.d.mts +2 -2
- package/src/index.d.ts +2 -2
- package/src/index.js +3 -1
- package/src/index.js.map +1 -1
- package/src/index.mjs +2 -2
- package/src/index.mjs.map +1 -1
- package/src/lib/format/check-page.d.mts +3 -2
- package/src/lib/format/check-page.d.ts +3 -2
- package/src/lib/format/check-page.js +9 -4
- package/src/lib/format/check-page.js.map +1 -1
- package/src/lib/format/check-page.mjs +10 -5
- package/src/lib/format/check-page.mjs.map +1 -1
- package/src/lib/format/field.d.mts +85 -0
- package/src/lib/format/field.d.ts +85 -0
- package/src/lib/format/field.js +14 -1
- package/src/lib/format/field.js.map +1 -1
- package/src/lib/format/field.mjs +13 -0
- package/src/lib/format/field.mjs.map +1 -1
- package/src/lib/format/layout.d.mts +142 -17
- package/src/lib/format/layout.d.ts +142 -17
- package/src/lib/format/layout.js +18 -1
- package/src/lib/format/layout.js.map +1 -1
- package/src/lib/format/layout.mjs +17 -0
- package/src/lib/format/layout.mjs.map +1 -1
- package/src/lib/format/layout.types.d.mts +6 -2
- package/src/lib/format/layout.types.d.ts +6 -2
- package/src/lib/format/page.d.mts +69 -10
- package/src/lib/format/page.d.ts +69 -10
- package/src/lib/format/page.js +4 -1
- package/src/lib/format/page.js.map +1 -1
- package/src/lib/format/page.mjs +5 -2
- package/src/lib/format/page.mjs.map +1 -1
- package/src/lib/format/references.d.mts +7 -0
- package/src/lib/format/references.d.ts +7 -0
- package/src/lib/format/references.js +46 -0
- package/src/lib/format/references.js.map +1 -1
- package/src/lib/format/references.mjs +46 -0
- package/src/lib/format/references.mjs.map +1 -1
- package/src/lib/format/translate.js +3 -2
- package/src/lib/format/translate.js.map +1 -1
- package/src/lib/format/translate.mjs +3 -2
- package/src/lib/format/translate.mjs.map +1 -1
- package/src/lib/format/upgrade.d.mts +41 -0
- package/src/lib/format/upgrade.d.ts +41 -0
- package/src/lib/format/upgrade.js +41 -0
- package/src/lib/format/upgrade.js.map +1 -0
- package/src/lib/format/upgrade.mjs +37 -0
- package/src/lib/format/upgrade.mjs.map +1 -0
- package/src/lib/format/validate.d.mts +2 -1
- package/src/lib/format/validate.d.ts +2 -1
- package/src/lib/format/validate.js +4 -2
- package/src/lib/format/validate.js.map +1 -1
- package/src/lib/format/validate.mjs +4 -2
- package/src/lib/format/validate.mjs.map +1 -1
- package/src/lib/format/version.d.mts +16 -1
- package/src/lib/format/version.d.ts +16 -1
- package/src/lib/format/version.js +39 -2
- package/src/lib/format/version.js.map +1 -1
- package/src/lib/format/version.mjs +36 -1
- package/src/lib/format/version.mjs.map +1 -1
- package/src/lib/record/data-source.d.mts +224 -10
- package/src/lib/record/data-source.d.ts +224 -10
- package/src/lib/record/data-source.js +29 -0
- package/src/lib/record/data-source.js.map +1 -1
- package/src/lib/record/data-source.mjs +25 -0
- package/src/lib/record/data-source.mjs.map +1 -1
- package/src/lib/record/form.d.mts +144 -5
- package/src/lib/record/form.d.ts +144 -5
- package/src/lib/record/form.js +792 -42
- package/src/lib/record/form.js.map +1 -1
- package/src/lib/record/form.mjs +791 -43
- package/src/lib/record/form.mjs.map +1 -1
- package/src/lib/record/locales/ar.js +10 -0
- package/src/lib/record/locales/ar.js.map +1 -1
- package/src/lib/record/locales/ar.mjs +10 -0
- package/src/lib/record/locales/ar.mjs.map +1 -1
- package/src/lib/record/locales/de.js +10 -0
- package/src/lib/record/locales/de.js.map +1 -1
- package/src/lib/record/locales/de.mjs +10 -0
- package/src/lib/record/locales/de.mjs.map +1 -1
- package/src/lib/record/locales/fr.js +10 -0
- package/src/lib/record/locales/fr.js.map +1 -1
- package/src/lib/record/locales/fr.mjs +10 -0
- package/src/lib/record/locales/fr.mjs.map +1 -1
- package/src/lib/record/memory-data-source.d.mts +40 -3
- package/src/lib/record/memory-data-source.d.ts +40 -3
- package/src/lib/record/memory-data-source.js +164 -20
- package/src/lib/record/memory-data-source.js.map +1 -1
- package/src/lib/record/memory-data-source.mjs +164 -20
- package/src/lib/record/memory-data-source.mjs.map +1 -1
- package/src/lib/record/messages.d.mts +21 -0
- package/src/lib/record/messages.d.ts +21 -0
- package/src/lib/record/messages.js +10 -0
- package/src/lib/record/messages.js.map +1 -1
- package/src/lib/record/messages.mjs +10 -0
- package/src/lib/record/messages.mjs.map +1 -1
- package/src/lib/record/run.d.mts +50 -7
- package/src/lib/record/run.d.ts +50 -7
- package/src/lib/record/run.js +67 -20
- package/src/lib/record/run.js.map +1 -1
- package/src/lib/record/run.mjs +67 -20
- package/src/lib/record/run.mjs.map +1 -1
- package/src/lib/record/upload-to.d.mts +50 -0
- package/src/lib/record/upload-to.d.ts +50 -0
- package/src/lib/record/upload-to.js +117 -0
- package/src/lib/record/upload-to.js.map +1 -0
- package/src/lib/record/upload-to.mjs +114 -0
- package/src/lib/record/upload-to.mjs.map +1 -0
- package/src/lib/record/uploads.d.mts +152 -0
- package/src/lib/record/uploads.d.ts +152 -0
- package/src/lib/record/uploads.js +345 -0
- package/src/lib/record/uploads.js.map +1 -0
- package/src/lib/record/uploads.mjs +337 -0
- package/src/lib/record/uploads.mjs.map +1 -0
- package/src/lib/record/values.d.mts +6 -0
- package/src/lib/record/values.d.ts +6 -0
- package/src/lib/record/values.js.map +1 -1
- package/src/lib/record/values.mjs.map +1 -1
- package/src/runtime.d.mts +10 -6
- package/src/runtime.d.ts +10 -6
- package/src/runtime.js +19 -2
- package/src/runtime.js.map +1 -1
- package/src/runtime.mjs +7 -3
- package/src/runtime.mjs.map +1 -1
package/src/lib/record/form.js
CHANGED
|
@@ -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,24 +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
|
-
|
|
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
|
}
|
|
30
57
|
/** A line's cell whose line is no longer there. */
|
|
31
58
|
const gone = Symbol('gone');
|
|
32
59
|
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
33
60
|
/** A saved form's answers, as the one value they are in the form round it. */
|
|
34
61
|
const asValue = (values) => values;
|
|
35
|
-
/**
|
|
36
|
-
|
|
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) {
|
|
37
69
|
const { page } = options;
|
|
38
70
|
const scheduler = options.scheduler ?? scheduler_1.hostScheduler;
|
|
39
71
|
const messages = options.messages ?? messages_1.MESSAGES.en;
|
|
@@ -104,7 +136,9 @@ function innerForm(options, within) {
|
|
|
104
136
|
let editing = options.editing !== false;
|
|
105
137
|
/** The record a line's form is of, as its conditions read it: worked out once. */
|
|
106
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;
|
|
107
|
-
|
|
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 });
|
|
108
142
|
const computed = (0, compute_1.compileComputed)(page, today, about);
|
|
109
143
|
const setWhen = (0, set_when_1.compileSetWhen)(page, today, about);
|
|
110
144
|
/** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
|
|
@@ -239,9 +273,148 @@ function innerForm(options, within) {
|
|
|
239
273
|
saveProblem: null,
|
|
240
274
|
choices: {},
|
|
241
275
|
definitions: {},
|
|
276
|
+
uploads: [],
|
|
277
|
+
version: null,
|
|
278
|
+
can: null,
|
|
279
|
+
conflict: null,
|
|
280
|
+
setAside: null,
|
|
281
|
+
unsent: [],
|
|
282
|
+
leaving: false,
|
|
242
283
|
unread: {},
|
|
243
284
|
};
|
|
244
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
|
+
}
|
|
245
418
|
/** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
|
|
246
419
|
let serverErrors = {};
|
|
247
420
|
let lineKeys = 0;
|
|
@@ -266,6 +439,12 @@ function innerForm(options, within) {
|
|
|
266
439
|
const was = state.values;
|
|
267
440
|
const shown = state.errors;
|
|
268
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
|
+
}
|
|
269
448
|
// The problems found inside a saved form are shown on it, beside its own fields.
|
|
270
449
|
if (state.errors !== shown)
|
|
271
450
|
for (const part of attached.values())
|
|
@@ -292,11 +471,62 @@ function innerForm(options, within) {
|
|
|
292
471
|
}
|
|
293
472
|
/** A field written: told to the app, and — a person's change — the page's `change` steps for it run. */
|
|
294
473
|
function changed(field, by) {
|
|
474
|
+
noteOrigin(field, by);
|
|
295
475
|
emit('change', { field, value: state.values[field], values: state.values, by });
|
|
296
476
|
const list = page.on?.change?.[field];
|
|
297
477
|
if (by === 'person' && list)
|
|
298
478
|
void track(moment(`on.change.${field}`, list, [], true));
|
|
299
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
|
+
}
|
|
300
530
|
/** A wizard step entered: told to the app, and its `show` steps run. */
|
|
301
531
|
function tellStep(chain) {
|
|
302
532
|
const step = state.step;
|
|
@@ -378,10 +608,10 @@ function innerForm(options, within) {
|
|
|
378
608
|
}
|
|
379
609
|
/** The values as expressions read them, and what else they read: the record's id, the person, the lines' rows, today. */
|
|
380
610
|
function reading() {
|
|
381
|
-
if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !==
|
|
611
|
+
if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editingNow()) {
|
|
382
612
|
const values = state.values;
|
|
383
613
|
const context = (0, env_1.recordContext)(values, page.fields, about());
|
|
384
|
-
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) };
|
|
385
615
|
}
|
|
386
616
|
return contextCache;
|
|
387
617
|
}
|
|
@@ -411,6 +641,14 @@ function innerForm(options, within) {
|
|
|
411
641
|
readonly = index.get(parent)?.readonly.evaluate(ctx, env) ?? false;
|
|
412
642
|
}
|
|
413
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
|
+
}
|
|
414
652
|
return {
|
|
415
653
|
invisible,
|
|
416
654
|
readonly: readonly || (def !== undefined && lockedField(def)),
|
|
@@ -706,6 +944,11 @@ function innerForm(options, within) {
|
|
|
706
944
|
else
|
|
707
945
|
delete serverErrors[name];
|
|
708
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];
|
|
709
952
|
return kept;
|
|
710
953
|
}
|
|
711
954
|
function scheduleDraft() {
|
|
@@ -716,9 +959,27 @@ function innerForm(options, within) {
|
|
|
716
959
|
scheduler.clearTimeout(draftTimer);
|
|
717
960
|
draftTimer = scheduler.setTimeout(() => {
|
|
718
961
|
draftTimer = null;
|
|
719
|
-
|
|
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
|
+
}
|
|
720
971
|
}, drafts.delayMs ?? 400);
|
|
721
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
|
+
}
|
|
722
983
|
function forgetDraft() {
|
|
723
984
|
if (draftTimer !== null)
|
|
724
985
|
scheduler.clearTimeout(draftTimer);
|
|
@@ -741,13 +1002,14 @@ function innerForm(options, within) {
|
|
|
741
1002
|
return;
|
|
742
1003
|
}
|
|
743
1004
|
if (drafts.restore === 'auto')
|
|
744
|
-
writeValues(
|
|
1005
|
+
writeValues(withDraft(draft.values), { draft: null }, true);
|
|
745
1006
|
else
|
|
746
1007
|
set({ draft: { savedAt: draft.savedAt } });
|
|
747
1008
|
}
|
|
748
1009
|
function scheduleAutosave() {
|
|
749
1010
|
const autosave = options.autosave;
|
|
750
|
-
|
|
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)
|
|
751
1013
|
return;
|
|
752
1014
|
if (autosaveTimer !== null)
|
|
753
1015
|
scheduler.clearTimeout(autosaveTimer);
|
|
@@ -821,74 +1083,202 @@ function innerForm(options, within) {
|
|
|
821
1083
|
async function save(chain = []) {
|
|
822
1084
|
return (await saving(chain)) === null;
|
|
823
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
|
+
}
|
|
824
1100
|
/**
|
|
825
1101
|
* Save, or send: null once done, or why not. The page's `beforeSave` steps
|
|
826
1102
|
* run first — before its own check, so they may fill in what it asks for —
|
|
827
1103
|
* and a stop keeps it unsaved; `afterSave` runs once it is saved.
|
|
828
1104
|
*/
|
|
829
|
-
|
|
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
|
+
}
|
|
830
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
|
|
831
1138
|
// up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
|
|
832
1139
|
const waiting = readTyped();
|
|
833
1140
|
if (waiting)
|
|
834
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
|
+
}
|
|
835
1148
|
if (page.on?.beforeSave) {
|
|
836
1149
|
const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
|
|
837
1150
|
if (!before.done) {
|
|
838
1151
|
// A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
|
|
839
1152
|
if (before.reason !== 'check' && before.reason !== 'save') {
|
|
840
1153
|
const problem = { kind: 'stopped', reason: before.reason, message: before.message ?? '' };
|
|
841
|
-
set({ status: 'error', error: problem.message || null, saveProblem: problem });
|
|
1154
|
+
set({ status: 'error', error: problem.message || null, saveProblem: problem, conflict: null });
|
|
842
1155
|
}
|
|
843
1156
|
return { reason: 'save', ...(before.message !== undefined ? { message: before.message } : {}) };
|
|
844
1157
|
}
|
|
845
1158
|
}
|
|
846
|
-
|
|
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() };
|
|
847
1161
|
if (Object.keys(errors).length) {
|
|
848
|
-
set({ errors });
|
|
1162
|
+
set({ errors, ...unstored(errors) });
|
|
849
1163
|
return { reason: 'save', message: Object.values(errors)[0] };
|
|
850
1164
|
}
|
|
851
1165
|
const source = options.dataSource;
|
|
852
1166
|
serverErrors = {};
|
|
853
|
-
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;
|
|
854
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
|
+
}
|
|
855
1178
|
if (page.data.kind === 'responses') {
|
|
856
1179
|
if (!source?.submit)
|
|
857
1180
|
throw new Error('This page has no data source to send its answers to');
|
|
858
1181
|
const sent = shownValues();
|
|
1182
|
+
const before = state.values;
|
|
1183
|
+
at = generation;
|
|
859
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;
|
|
860
1188
|
forgetDraft();
|
|
861
|
-
|
|
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() });
|
|
862
1192
|
emit('send', { values: sent });
|
|
863
1193
|
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
864
1194
|
return null;
|
|
865
1195
|
}
|
|
866
1196
|
if (!source?.save)
|
|
867
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;
|
|
868
1202
|
const result = await source.save({
|
|
869
1203
|
model: page.data.model,
|
|
870
1204
|
id: state.recordId,
|
|
871
1205
|
fields,
|
|
872
1206
|
changes: changes(),
|
|
873
|
-
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 } : {}),
|
|
874
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;
|
|
875
1215
|
forgetDraft();
|
|
876
|
-
//
|
|
877
|
-
const values
|
|
878
|
-
|
|
879
|
-
|
|
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
|
+
}
|
|
880
1228
|
emit('save', { recordId: state.recordId, values: state.values });
|
|
881
1229
|
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
882
1230
|
return null;
|
|
883
1231
|
}
|
|
884
1232
|
catch (error) {
|
|
885
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.
|
|
886
1238
|
const fields = problem.kind === 'fields' ? problem.fields ?? {} : {};
|
|
887
1239
|
serverErrors = Object.fromEntries(Object.entries(fields).map(([name, message]) => [name, { message, value: JSON.stringify(state.values[name] ?? null) }]));
|
|
888
|
-
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) } : {}) });
|
|
889
1241
|
return { reason: 'save', message: problem.message };
|
|
890
1242
|
}
|
|
891
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
|
+
}
|
|
892
1282
|
/** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
|
|
893
1283
|
async function opening() {
|
|
894
1284
|
emit('open', { recordId: state.recordId, values: state.values });
|
|
@@ -979,8 +1369,9 @@ function innerForm(options, within) {
|
|
|
979
1369
|
inRun(chain, () => (went = form.goTo(target)));
|
|
980
1370
|
return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
|
|
981
1371
|
}
|
|
982
|
-
/** 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`). */
|
|
983
1373
|
async function runTop(list, scope) {
|
|
1374
|
+
scope.generation = generation;
|
|
984
1375
|
const result = await runner.runSteps(list, scope);
|
|
985
1376
|
emit('run', { id: scope.id, steps: list, result });
|
|
986
1377
|
return result;
|
|
@@ -1039,14 +1430,17 @@ function innerForm(options, within) {
|
|
|
1039
1430
|
}
|
|
1040
1431
|
// Only the latest load is taken: a pager pressed twice shows the record it ended on.
|
|
1041
1432
|
const seq = ++loads;
|
|
1042
|
-
|
|
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: [] });
|
|
1043
1436
|
try {
|
|
1044
|
-
|
|
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 }));
|
|
1045
1439
|
if (seq !== loads)
|
|
1046
1440
|
return;
|
|
1047
1441
|
// Lines load in their tables' orders; where a person moves them after, they stay.
|
|
1048
1442
|
baseline = syncParts(startFrom(ordered({ ...(0, values_1.initialValues)(fields, today()), ...loaded })), 'start');
|
|
1049
|
-
set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), unread: {} });
|
|
1443
|
+
set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), version: version ?? null, can: can ?? null, unread: {} });
|
|
1050
1444
|
if (again)
|
|
1051
1445
|
return;
|
|
1052
1446
|
offerDraft();
|
|
@@ -1058,8 +1452,14 @@ function innerForm(options, within) {
|
|
|
1058
1452
|
}
|
|
1059
1453
|
}
|
|
1060
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
|
+
}
|
|
1061
1460
|
/** Another record in this form's place, or a new one: what was there let go, the new one loaded and opened. */
|
|
1062
|
-
async function
|
|
1461
|
+
async function showNow(id) {
|
|
1462
|
+
newGeneration();
|
|
1063
1463
|
if (draftTimer !== null)
|
|
1064
1464
|
scheduler.clearTimeout(draftTimer);
|
|
1065
1465
|
if (autosaveTimer !== null)
|
|
@@ -1068,10 +1468,12 @@ function innerForm(options, within) {
|
|
|
1068
1468
|
serverErrors = {};
|
|
1069
1469
|
told = null;
|
|
1070
1470
|
showing = false;
|
|
1071
|
-
|
|
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: {} };
|
|
1072
1473
|
if (id != null) {
|
|
1073
1474
|
set({ ...fresh, recordId: id, status: 'idle' });
|
|
1074
|
-
|
|
1475
|
+
await load();
|
|
1476
|
+
return true;
|
|
1075
1477
|
}
|
|
1076
1478
|
loads++;
|
|
1077
1479
|
baseline = startFrom(startingValues(true, false));
|
|
@@ -1079,6 +1481,142 @@ function innerForm(options, within) {
|
|
|
1079
1481
|
set({ ...fresh, recordId: null, status: 'ready', values, warnings: withValues(values, collectWarnings) });
|
|
1080
1482
|
offerDraft();
|
|
1081
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()));
|
|
1082
1620
|
}
|
|
1083
1621
|
/** The record a record step asks the data source about. */
|
|
1084
1622
|
const record = () => ({ model: page.data.model, id: state.recordId, fields });
|
|
@@ -1107,25 +1645,51 @@ function innerForm(options, within) {
|
|
|
1107
1645
|
addLine: (field, values) => void addLine(field, values, 'step'),
|
|
1108
1646
|
check,
|
|
1109
1647
|
save: saving,
|
|
1110
|
-
|
|
1648
|
+
generation: () => generation,
|
|
1649
|
+
gone: () => (disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced }),
|
|
1650
|
+
async reload(chain) {
|
|
1111
1651
|
if (state.recordId == null)
|
|
1112
1652
|
return { reason: 'cannot', message: 'The record is not saved yet: there is nothing to load again' };
|
|
1113
|
-
await
|
|
1653
|
+
if (!(await loadAgain(true, chain)))
|
|
1654
|
+
return kept();
|
|
1114
1655
|
return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
|
|
1115
1656
|
},
|
|
1116
|
-
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,
|
|
1117
1666
|
goTo: goToStep,
|
|
1118
1667
|
showing: inRun,
|
|
1119
1668
|
host: options.host,
|
|
1120
1669
|
confirm: options.confirm,
|
|
1121
1670
|
onAction: options.onAction,
|
|
1122
1671
|
answered: (request, result) => emit('action', { request, result }),
|
|
1123
|
-
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
|
+
},
|
|
1124
1687
|
...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
|
|
1125
1688
|
...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
|
|
1126
1689
|
...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
|
|
1127
|
-
async showRecord(id) {
|
|
1128
|
-
await openRecord(id)
|
|
1690
|
+
async showRecord(id, chain) {
|
|
1691
|
+
if (!(await openRecord(id, chain)))
|
|
1692
|
+
return kept();
|
|
1129
1693
|
return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
|
|
1130
1694
|
},
|
|
1131
1695
|
told(event, id) {
|
|
@@ -1142,6 +1706,7 @@ function innerForm(options, within) {
|
|
|
1142
1706
|
},
|
|
1143
1707
|
post,
|
|
1144
1708
|
wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
|
|
1709
|
+
barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
|
|
1145
1710
|
});
|
|
1146
1711
|
const moment = (0, run_1.createMoments)((key, list, chain) => runTop(list, { id: key, chain }));
|
|
1147
1712
|
/** A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. */
|
|
@@ -1171,7 +1736,10 @@ function innerForm(options, within) {
|
|
|
1171
1736
|
listeners.add(listener);
|
|
1172
1737
|
return () => listeners.delete(listener);
|
|
1173
1738
|
},
|
|
1174
|
-
load
|
|
1739
|
+
load() {
|
|
1740
|
+
const go = letGo();
|
|
1741
|
+
return track(go === true ? load() : go.then((yes) => (yes ? load() : undefined)));
|
|
1742
|
+
},
|
|
1175
1743
|
setValue(name, value, how) {
|
|
1176
1744
|
fieldDef(name);
|
|
1177
1745
|
writeValues({ ...state.values, [name]: (0, values_1.structuredCopy)(value) });
|
|
@@ -1181,6 +1749,8 @@ function innerForm(options, within) {
|
|
|
1181
1749
|
setValues: (values, how) => (how?.stored ? store(values) : write(values, 'app')),
|
|
1182
1750
|
node: nodeState,
|
|
1183
1751
|
openRecord: (id) => track(openRecord(id)),
|
|
1752
|
+
leave: (how) => Promise.resolve(letGo(how)),
|
|
1753
|
+
leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
|
|
1184
1754
|
post,
|
|
1185
1755
|
setEditing(next) {
|
|
1186
1756
|
if (next === editing)
|
|
@@ -1189,7 +1759,7 @@ function innerForm(options, within) {
|
|
|
1189
1759
|
// Nothing of the record changed; what shows of it may have.
|
|
1190
1760
|
set({});
|
|
1191
1761
|
},
|
|
1192
|
-
fieldReadonly: (name) => lockedField(fieldDef(name)),
|
|
1762
|
+
fieldReadonly: (name) => lockedField(fieldDef(name)) || state.can?.edit === false || state.can?.readonlyFields?.includes(name) === true,
|
|
1193
1763
|
loadChoices,
|
|
1194
1764
|
loadDefinitions,
|
|
1195
1765
|
addDefinition,
|
|
@@ -1238,7 +1808,7 @@ function innerForm(options, within) {
|
|
|
1238
1808
|
state = before;
|
|
1239
1809
|
set({ unread: next, errors });
|
|
1240
1810
|
},
|
|
1241
|
-
|
|
1811
|
+
boxLeft(path) {
|
|
1242
1812
|
const problem = leftProblem(path);
|
|
1243
1813
|
if (!problem || state.errors[path] === problem)
|
|
1244
1814
|
return;
|
|
@@ -1263,12 +1833,36 @@ function innerForm(options, within) {
|
|
|
1263
1833
|
return Object.keys(errors).length === 0;
|
|
1264
1834
|
},
|
|
1265
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
|
+
},
|
|
1266
1853
|
reset() {
|
|
1267
1854
|
forgetDraft();
|
|
1855
|
+
// Files on their way were changes too: they go with the rest.
|
|
1856
|
+
clearFiles();
|
|
1268
1857
|
serverErrors = {};
|
|
1269
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;
|
|
1270
1864
|
const values = syncParts((0, values_1.structuredCopy)(baseline), 'start');
|
|
1271
|
-
set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} });
|
|
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: {} });
|
|
1272
1866
|
},
|
|
1273
1867
|
changes,
|
|
1274
1868
|
addLine: (field, values = {}) => addLine(field, values, 'person'),
|
|
@@ -1533,12 +2127,91 @@ function innerForm(options, within) {
|
|
|
1533
2127
|
if (!raw)
|
|
1534
2128
|
return;
|
|
1535
2129
|
const draft = JSON.parse(raw);
|
|
1536
|
-
writeValues(
|
|
2130
|
+
writeValues(withDraft(draft.values), { draft: null }, true);
|
|
1537
2131
|
},
|
|
1538
2132
|
discardDraft() {
|
|
1539
2133
|
forgetDraft();
|
|
1540
2134
|
set({ draft: null });
|
|
1541
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
|
+
},
|
|
1542
2215
|
async settled() {
|
|
1543
2216
|
while (pending.size)
|
|
1544
2217
|
await Promise.allSettled([...pending]);
|
|
@@ -1565,19 +2238,30 @@ function innerForm(options, within) {
|
|
|
1565
2238
|
scheduler,
|
|
1566
2239
|
messages,
|
|
1567
2240
|
now: options.now,
|
|
1568
|
-
}, 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) });
|
|
1569
2246
|
attached.set(id, { name: part.name, inner: made });
|
|
1570
2247
|
// A change inside it is told as a change of its answers here, whoever made it.
|
|
1571
2248
|
made.form.on('change', ({ by }) => {
|
|
1572
2249
|
if (!syncing)
|
|
1573
2250
|
changed(part.name, by);
|
|
1574
2251
|
});
|
|
1575
|
-
// 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;
|
|
1576
2254
|
made.form.subscribe((inside) => {
|
|
2255
|
+
if (inside.uploads !== seenUploads) {
|
|
2256
|
+
seenUploads = inside.uploads;
|
|
2257
|
+
filesChanged();
|
|
2258
|
+
}
|
|
1577
2259
|
if (syncing || inside.values === state.values[part.name])
|
|
1578
2260
|
return;
|
|
1579
2261
|
writeValues({ ...state.values, [part.name]: asValue(inside.values) });
|
|
1580
|
-
|
|
2262
|
+
// A link a save put in place of bytes is no change of the person's.
|
|
2263
|
+
if (!quietParts)
|
|
2264
|
+
afterEdit(part.name);
|
|
1581
2265
|
});
|
|
1582
2266
|
// What it starts with, its defaults and worked-out values filled in, is this form's starting point too — unless changed already.
|
|
1583
2267
|
const start = asValue(made.form.getState().values);
|
|
@@ -1589,6 +2273,10 @@ function innerForm(options, within) {
|
|
|
1589
2273
|
},
|
|
1590
2274
|
embedded: (id) => attached.get(id)?.inner.form ?? null,
|
|
1591
2275
|
dispose() {
|
|
2276
|
+
// An answer that lands after is for no record shown.
|
|
2277
|
+
disposed = true;
|
|
2278
|
+
newGeneration();
|
|
2279
|
+
clearFiles();
|
|
1592
2280
|
if (draftTimer !== null)
|
|
1593
2281
|
scheduler.clearTimeout(draftTimer);
|
|
1594
2282
|
if (autosaveTimer !== null)
|
|
@@ -1611,6 +2299,8 @@ function innerForm(options, within) {
|
|
|
1611
2299
|
take(values, how) {
|
|
1612
2300
|
if (how !== 'start')
|
|
1613
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();
|
|
1614
2304
|
baseline = startFrom({ ...(0, values_1.initialValues)(page.fields, today()), ...(0, values_1.structuredCopy)(values) });
|
|
1615
2305
|
const start = syncParts((0, values_1.structuredCopy)(baseline), 'start');
|
|
1616
2306
|
set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null, unread: {} });
|
|
@@ -1621,8 +2311,26 @@ function innerForm(options, within) {
|
|
|
1621
2311
|
if (JSON.stringify(errors) !== JSON.stringify(state.errors))
|
|
1622
2312
|
set({ errors });
|
|
1623
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
|
+
},
|
|
1624
2324
|
};
|
|
1625
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
|
+
}
|
|
1626
2334
|
/** The problems found inside a saved form, by its own fields' names. */
|
|
1627
2335
|
function inside(errors, name) {
|
|
1628
2336
|
const found = {};
|
|
@@ -1658,6 +2366,46 @@ function compareLines(a, b, by) {
|
|
|
1658
2366
|
function lockedField(def) {
|
|
1659
2367
|
return def.readonly === true || def.compute !== undefined;
|
|
1660
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
|
+
}
|
|
1661
2409
|
function lineOps(before, now) {
|
|
1662
2410
|
const ops = [];
|
|
1663
2411
|
const old = new Map(before.filter((line) => line.id !== undefined).map((line) => [line.key, line]));
|
|
@@ -1694,6 +2442,7 @@ function indexLayout(root) {
|
|
|
1694
2442
|
const add = (source, parent, kind, modifiers) => {
|
|
1695
2443
|
const roles = source.roles;
|
|
1696
2444
|
const pressed = kind === 'button' ? source.pressed : undefined;
|
|
2445
|
+
const does = kind === 'button' || kind === 'stat' ? rightsUsed(source) : [];
|
|
1697
2446
|
index.set(source.id, {
|
|
1698
2447
|
id: source.id,
|
|
1699
2448
|
parent,
|
|
@@ -1704,6 +2453,7 @@ function indexLayout(root) {
|
|
|
1704
2453
|
readonly: (0, modifier_1.compileModifier)(modifiers.readonly),
|
|
1705
2454
|
required: (0, modifier_1.compileModifier)(modifiers.required),
|
|
1706
2455
|
...(pressed !== undefined ? { pressed: (0, modifier_1.compileModifier)(pressed) } : {}),
|
|
2456
|
+
...(does.length ? { does } : {}),
|
|
1707
2457
|
source,
|
|
1708
2458
|
});
|
|
1709
2459
|
// A button's menu: each item a button of the record, hidden with it.
|