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