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