@fieldia/core 0.23.0 → 0.25.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 +211 -3
- package/package.json +1 -1
- package/schema/page.schema.json +1051 -325
- package/src/index.d.mts +9 -2
- package/src/index.d.ts +9 -2
- package/src/index.js +31 -1
- package/src/index.js.map +1 -1
- package/src/index.mjs +12 -2
- package/src/index.mjs.map +1 -1
- package/src/lib/format/actions.d.mts +31 -1
- package/src/lib/format/actions.d.ts +31 -1
- package/src/lib/format/actions.js +1 -0
- package/src/lib/format/actions.js.map +1 -1
- package/src/lib/format/actions.mjs +1 -0
- package/src/lib/format/actions.mjs.map +1 -1
- package/src/lib/format/autofill-group.d.mts +12 -0
- package/src/lib/format/autofill-group.d.ts +12 -0
- package/src/lib/format/autofill-group.js +23 -0
- package/src/lib/format/autofill-group.js.map +1 -0
- package/src/lib/format/autofill-group.mjs +19 -0
- package/src/lib/format/autofill-group.mjs.map +1 -0
- package/src/lib/format/autofill.d.mts +14 -0
- package/src/lib/format/autofill.d.ts +14 -0
- package/src/lib/format/autofill.js +54 -0
- package/src/lib/format/autofill.js.map +1 -0
- package/src/lib/format/autofill.mjs +48 -0
- package/src/lib/format/autofill.mjs.map +1 -0
- package/src/lib/format/bulk-edit.d.mts +18 -0
- package/src/lib/format/bulk-edit.d.ts +18 -0
- package/src/lib/format/bulk-edit.js +26 -0
- package/src/lib/format/bulk-edit.js.map +1 -0
- package/src/lib/format/bulk-edit.mjs +21 -0
- package/src/lib/format/bulk-edit.mjs.map +1 -0
- package/src/lib/format/duplicate-kinds.d.mts +10 -0
- package/src/lib/format/duplicate-kinds.d.ts +10 -0
- package/src/lib/format/duplicate-kinds.js +14 -0
- package/src/lib/format/duplicate-kinds.js.map +1 -0
- package/src/lib/format/duplicate-kinds.mjs +10 -0
- package/src/lib/format/duplicate-kinds.mjs.map +1 -0
- package/src/lib/format/duplicates.d.mts +22 -0
- package/src/lib/format/duplicates.d.ts +22 -0
- package/src/lib/format/duplicates.js +24 -0
- package/src/lib/format/duplicates.js.map +1 -0
- package/src/lib/format/duplicates.mjs +21 -0
- package/src/lib/format/duplicates.mjs.map +1 -0
- package/src/lib/format/field.d.mts +291 -34
- package/src/lib/format/field.d.ts +291 -34
- package/src/lib/format/field.js +19 -4
- package/src/lib/format/field.js.map +1 -1
- package/src/lib/format/field.mjs +17 -2
- package/src/lib/format/field.mjs.map +1 -1
- package/src/lib/format/import-fields.d.mts +18 -0
- package/src/lib/format/import-fields.d.ts +18 -0
- package/src/lib/format/import-fields.js +26 -0
- package/src/lib/format/import-fields.js.map +1 -0
- package/src/lib/format/import-fields.mjs +21 -0
- package/src/lib/format/import-fields.mjs.map +1 -0
- package/src/lib/format/layout.d.mts +7357 -3127
- package/src/lib/format/layout.d.ts +7357 -3127
- package/src/lib/format/layout.js +108 -32
- package/src/lib/format/layout.js.map +1 -1
- package/src/lib/format/layout.mjs +107 -32
- package/src/lib/format/layout.mjs.map +1 -1
- package/src/lib/format/layout.types.d.mts +5 -2
- package/src/lib/format/layout.types.d.ts +5 -2
- package/src/lib/format/page.d.mts +1080 -106
- package/src/lib/format/page.d.ts +1080 -106
- package/src/lib/format/page.js +25 -0
- package/src/lib/format/page.js.map +1 -1
- package/src/lib/format/page.mjs +26 -1
- package/src/lib/format/page.mjs.map +1 -1
- package/src/lib/format/phone.d.mts +27 -0
- package/src/lib/format/phone.d.ts +27 -0
- package/src/lib/format/phone.js +43 -0
- package/src/lib/format/phone.js.map +1 -0
- package/src/lib/format/phone.mjs +39 -0
- package/src/lib/format/phone.mjs.map +1 -0
- package/src/lib/format/references.d.mts +39 -0
- package/src/lib/format/references.d.ts +39 -0
- package/src/lib/format/references.js +263 -20
- package/src/lib/format/references.js.map +1 -1
- package/src/lib/format/references.mjs +263 -20
- package/src/lib/format/references.mjs.map +1 -1
- package/src/lib/format/slots-options.d.mts +26 -0
- package/src/lib/format/slots-options.d.ts +26 -0
- package/src/lib/format/slots-options.js +35 -0
- package/src/lib/format/slots-options.js.map +1 -0
- package/src/lib/format/slots-options.mjs +31 -0
- package/src/lib/format/slots-options.mjs.map +1 -0
- package/src/lib/format/translate.js +8 -4
- package/src/lib/format/translate.js.map +1 -1
- package/src/lib/format/translate.mjs +8 -4
- package/src/lib/format/translate.mjs.map +1 -1
- package/src/lib/format/upgrade.d.mts +8 -3
- package/src/lib/format/upgrade.d.ts +8 -3
- package/src/lib/format/upgrade.js +8 -3
- package/src/lib/format/upgrade.js.map +1 -1
- package/src/lib/format/upgrade.mjs +8 -3
- package/src/lib/format/upgrade.mjs.map +1 -1
- package/src/lib/format/version.d.mts +2 -2
- package/src/lib/format/version.d.ts +2 -2
- package/src/lib/format/version.js +2 -2
- package/src/lib/format/version.js.map +1 -1
- package/src/lib/format/version.mjs +2 -2
- package/src/lib/format/version.mjs.map +1 -1
- package/src/lib/format/zoned.d.mts +8 -0
- package/src/lib/format/zoned.d.ts +8 -0
- package/src/lib/format/zoned.js +36 -0
- package/src/lib/format/zoned.js.map +1 -0
- package/src/lib/format/zoned.mjs +33 -0
- package/src/lib/format/zoned.mjs.map +1 -0
- package/src/lib/record/check.d.mts +6 -1
- package/src/lib/record/check.d.ts +6 -1
- package/src/lib/record/check.js +12 -4
- package/src/lib/record/check.js.map +1 -1
- package/src/lib/record/check.mjs +12 -4
- package/src/lib/record/check.mjs.map +1 -1
- package/src/lib/record/data-source.d.mts +309 -3
- package/src/lib/record/data-source.d.ts +309 -3
- package/src/lib/record/data-source.js +16 -0
- package/src/lib/record/data-source.js.map +1 -1
- package/src/lib/record/data-source.mjs +15 -0
- package/src/lib/record/data-source.mjs.map +1 -1
- package/src/lib/record/form.d.mts +116 -25
- package/src/lib/record/form.d.ts +116 -25
- package/src/lib/record/form.js +457 -78
- package/src/lib/record/form.js.map +1 -1
- package/src/lib/record/form.mjs +457 -78
- package/src/lib/record/form.mjs.map +1 -1
- package/src/lib/record/input-rules.d.mts +51 -9
- package/src/lib/record/input-rules.d.ts +51 -9
- package/src/lib/record/input-rules.js +119 -20
- package/src/lib/record/input-rules.js.map +1 -1
- package/src/lib/record/input-rules.mjs +118 -19
- package/src/lib/record/input-rules.mjs.map +1 -1
- package/src/lib/record/inputs.js +4 -2
- package/src/lib/record/inputs.js.map +1 -1
- package/src/lib/record/inputs.mjs +4 -2
- package/src/lib/record/inputs.mjs.map +1 -1
- package/src/lib/record/limits.d.mts +1 -1
- package/src/lib/record/limits.d.ts +1 -1
- package/src/lib/record/limits.js +4 -2
- package/src/lib/record/limits.js.map +1 -1
- package/src/lib/record/limits.mjs +4 -2
- package/src/lib/record/limits.mjs.map +1 -1
- package/src/lib/record/locales/ar.js +3 -2
- package/src/lib/record/locales/ar.js.map +1 -1
- package/src/lib/record/locales/ar.mjs +3 -2
- package/src/lib/record/locales/ar.mjs.map +1 -1
- package/src/lib/record/locales/de.js +3 -2
- package/src/lib/record/locales/de.js.map +1 -1
- package/src/lib/record/locales/de.mjs +3 -2
- package/src/lib/record/locales/de.mjs.map +1 -1
- package/src/lib/record/locales/fr.js +3 -2
- package/src/lib/record/locales/fr.js.map +1 -1
- package/src/lib/record/locales/fr.mjs +3 -2
- package/src/lib/record/locales/fr.mjs.map +1 -1
- package/src/lib/record/memory-data-source.d.mts +20 -2
- package/src/lib/record/memory-data-source.d.ts +20 -2
- package/src/lib/record/memory-data-source.js +153 -35
- package/src/lib/record/memory-data-source.js.map +1 -1
- package/src/lib/record/memory-data-source.mjs +154 -36
- package/src/lib/record/memory-data-source.mjs.map +1 -1
- package/src/lib/record/memory-duplicates.d.mts +19 -0
- package/src/lib/record/memory-duplicates.d.ts +19 -0
- package/src/lib/record/memory-duplicates.js +78 -0
- package/src/lib/record/memory-duplicates.js.map +1 -0
- package/src/lib/record/memory-duplicates.mjs +74 -0
- package/src/lib/record/memory-duplicates.mjs.map +1 -0
- package/src/lib/record/memory-schedules.d.mts +45 -0
- package/src/lib/record/memory-schedules.d.ts +45 -0
- package/src/lib/record/memory-schedules.js +81 -0
- package/src/lib/record/memory-schedules.js.map +1 -0
- package/src/lib/record/memory-schedules.mjs +78 -0
- package/src/lib/record/memory-schedules.mjs.map +1 -0
- package/src/lib/record/messages.d.mts +3 -1
- package/src/lib/record/messages.d.ts +3 -1
- package/src/lib/record/messages.js +3 -2
- package/src/lib/record/messages.js.map +1 -1
- package/src/lib/record/messages.mjs +3 -2
- package/src/lib/record/messages.mjs.map +1 -1
- package/src/lib/record/periods.d.mts +47 -0
- package/src/lib/record/periods.d.ts +47 -0
- package/src/lib/record/periods.js +86 -0
- package/src/lib/record/periods.js.map +1 -0
- package/src/lib/record/periods.mjs +79 -0
- package/src/lib/record/periods.mjs.map +1 -0
- package/src/lib/record/rules.js +5 -6
- package/src/lib/record/rules.js.map +1 -1
- package/src/lib/record/rules.mjs +5 -6
- package/src/lib/record/rules.mjs.map +1 -1
- package/src/lib/record/run.d.mts +75 -9
- package/src/lib/record/run.d.ts +75 -9
- package/src/lib/record/run.js +169 -26
- package/src/lib/record/run.js.map +1 -1
- package/src/lib/record/run.mjs +170 -27
- package/src/lib/record/run.mjs.map +1 -1
- package/src/lib/record/search.d.mts +13 -3
- package/src/lib/record/search.d.ts +13 -3
- package/src/lib/record/search.js +11 -4
- package/src/lib/record/search.js.map +1 -1
- package/src/lib/record/search.mjs +11 -4
- package/src/lib/record/search.mjs.map +1 -1
- package/src/lib/record/waits.d.mts +32 -0
- package/src/lib/record/waits.d.ts +32 -0
- package/src/lib/record/waits.js +89 -0
- package/src/lib/record/waits.js.map +1 -0
- package/src/lib/record/waits.mjs +85 -0
- package/src/lib/record/waits.mjs.map +1 -0
- package/src/lib/record/zones.d.mts +57 -0
- package/src/lib/record/zones.d.ts +57 -0
- package/src/lib/record/zones.js +147 -0
- package/src/lib/record/zones.js.map +1 -0
- package/src/lib/record/zones.mjs +132 -0
- package/src/lib/record/zones.mjs.map +1 -0
- package/src/runtime.d.mts +19 -9
- package/src/runtime.d.ts +19 -9
- package/src/runtime.js +37 -2
- package/src/runtime.js.map +1 -1
- package/src/runtime.mjs +9 -2
- package/src/runtime.mjs.map +1 -1
package/src/lib/record/form.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { upgradePage } from '../format/upgrade.mjs';
|
|
2
|
+
import { zonedPage } from '../format/zoned.mjs';
|
|
2
3
|
import { compileModifier } from '../expression/modifier.mjs';
|
|
3
|
-
import {
|
|
4
|
+
import { dayIn, ownZone } from './zones.mjs';
|
|
4
5
|
import { checkProperties, checkValue } from './check.mjs';
|
|
5
6
|
import { checkInput } from './inputs.mjs';
|
|
6
7
|
import { compileComputed } from './compute.mjs';
|
|
@@ -13,6 +14,7 @@ import { rolesAllow } from './roles.mjs';
|
|
|
13
14
|
import { compileFieldTone, compileTable } from './cells.mjs';
|
|
14
15
|
import { fill, MESSAGES } from './messages.mjs';
|
|
15
16
|
import { loadedRecordOf, saveProblemOf, } from './data-source.mjs';
|
|
17
|
+
import { waitingOn, workedOutFrom } from './waits.mjs';
|
|
16
18
|
import { createUploads, fileBytesIn, uploadGoing, withoutFileBytes } from './uploads.mjs';
|
|
17
19
|
import { hostScheduler } from './scheduler.mjs';
|
|
18
20
|
import { createMoments, createRunner } from './run.mjs';
|
|
@@ -28,15 +30,23 @@ export function unsavedWork(state) {
|
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
32
|
* Whether a way out would lose the person's work without their knowing: a
|
|
31
|
-
* change of theirs the last save did not send
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* change of theirs the last save did not send, or text they left since that
|
|
34
|
+
* does not read ("12a" counts as "12" does: `unsent`), files still on
|
|
35
|
+
* their way, changes Reload set aside, the changes of a save refused for a
|
|
36
|
+
* conflict, waiting for Reload or Keep mine (only a source that keeps
|
|
37
|
+
* versions has one) — and, `how.changes`, any change not saved. What
|
|
38
|
+
* `form.leave()` — the one check every way out goes through — asks about,
|
|
39
|
+
* once the save on its way and the files on theirs settled. Before a
|
|
40
|
+
* record's first save, a change not saved is not at risk: as in 0.20, each
|
|
41
|
+
* way out decides.
|
|
37
42
|
*/
|
|
38
43
|
export function workAtRisk(state, how = {}) {
|
|
39
|
-
return (state.unsent ?? []).length > 0 ||
|
|
44
|
+
return ((state.unsent ?? []).length > 0 ||
|
|
45
|
+
state.setAside != null ||
|
|
46
|
+
(state.uploads ?? []).some(uploadGoing) ||
|
|
47
|
+
// The person asked for these to be saved, and someone else's save came first: still theirs, and not saved.
|
|
48
|
+
(state.conflict != null && state.dirty.length > 0) ||
|
|
49
|
+
(how.changes === true && (state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0)));
|
|
40
50
|
}
|
|
41
51
|
/**
|
|
42
52
|
* The record state behind a page: values, what changed, what is visible and
|
|
@@ -48,6 +58,8 @@ export function createForm(options) {
|
|
|
48
58
|
const page = upgradePage(options.page);
|
|
49
59
|
return innerForm(page === options.page ? options : { ...options, page }, []).form;
|
|
50
60
|
}
|
|
61
|
+
/** Whether a run works a person's change out: a page's `change` steps, set off by it, or a run they set off. */
|
|
62
|
+
const worksOut = (chain) => chain.some((key) => key.startsWith('on.change.'));
|
|
51
63
|
/** A line's cell whose line is no longer there. */
|
|
52
64
|
const gone = Symbol('gone');
|
|
53
65
|
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -60,7 +72,8 @@ const asValue = (values) => values;
|
|
|
60
72
|
* its ways out go through that one's check.
|
|
61
73
|
*/
|
|
62
74
|
function innerForm(options, within, place, outer) {
|
|
63
|
-
|
|
75
|
+
// Its time zone written into each date and time field naming none, for whatever reads one from its field alone.
|
|
76
|
+
const page = zonedPage(options.page);
|
|
64
77
|
const scheduler = options.scheduler ?? hostScheduler;
|
|
65
78
|
const messages = options.messages ?? MESSAGES.en;
|
|
66
79
|
const index = indexLayout(page.layout);
|
|
@@ -124,7 +137,19 @@ function innerForm(options, within, place, outer) {
|
|
|
124
137
|
const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
|
|
125
138
|
const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
|
|
126
139
|
const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
|
|
127
|
-
|
|
140
|
+
/** Today, on the clock of the page's time zone (the browser's own when it names none). */
|
|
141
|
+
const today = () => dayIn(options.now?.() ?? new Date(), page.timeZone);
|
|
142
|
+
const clock = () => (options.now?.() ?? new Date()).getTime();
|
|
143
|
+
/** What a page's bot check saw — its hidden box filled, the captcha's token — and when the page was shown or started again. */
|
|
144
|
+
let bot = { honeypot: false, captcha: null, since: clock() };
|
|
145
|
+
/** What the bot check saw as the answers go; none without one. */
|
|
146
|
+
function botReport() {
|
|
147
|
+
const check = page.botCheck;
|
|
148
|
+
if (!check)
|
|
149
|
+
return undefined;
|
|
150
|
+
const taken = Math.max(0, clock() - bot.since) / 1000;
|
|
151
|
+
return { honeypot: check.honeypot !== false && bot.honeypot, tooFast: taken < (check.minSeconds ?? 3), seconds: Math.round(taken * 10) / 10, ...(check.captcha ? { captcha: bot.captcha } : {}) };
|
|
152
|
+
}
|
|
128
153
|
// The record's id and the person, as worked-out values read them: the id is the one asked for until the form has its state.
|
|
129
154
|
let started = false;
|
|
130
155
|
let editing = options.editing !== false;
|
|
@@ -134,6 +159,8 @@ function innerForm(options, within, place, outer) {
|
|
|
134
159
|
const editingNow = () => editing && !(started && state.can?.edit === false);
|
|
135
160
|
const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing: editingNow(), context: options.context, parent: parentContext });
|
|
136
161
|
const computed = compileComputed(page, today, about);
|
|
162
|
+
/** Each value worked out from others — by the page, or by the app as the lines change — and what it waits for. */
|
|
163
|
+
const workedOut = workedOutFrom(page, !!options.dataSource?.onchange && page.data.kind === 'record', !!options.parent);
|
|
137
164
|
const setWhen = compileSetWhen(page, today, about);
|
|
138
165
|
/** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
|
|
139
166
|
const parts = new Map();
|
|
@@ -286,16 +313,48 @@ function innerForm(options, within, place, outer) {
|
|
|
286
313
|
let generation = 0;
|
|
287
314
|
/** Closed (`dispose`): a run still going stops quietly, as its dialog or page is gone — no other record replaced it. */
|
|
288
315
|
let disposed = false;
|
|
289
|
-
/**
|
|
316
|
+
/** Counted at each reset (the person's Discard): a save on its way then lands as the record it stored. */
|
|
317
|
+
let resets = 0;
|
|
318
|
+
/**
|
|
319
|
+
* THE test every late answer passes (`recordShown`): taken as it is asked for, true while the record it was asked
|
|
320
|
+
* for is still the one shown, as it was. A save's answer, a call's, an onchange's, a name looked up, a line's
|
|
321
|
+
* dialog, a record step's — none is laid on another record, nor over the values the person's Discard let go.
|
|
322
|
+
*/
|
|
323
|
+
/** The data source with a member a record's page asks of it, and the page's model: refused where either is missing. */
|
|
324
|
+
function recordSource(member) {
|
|
325
|
+
const source = options.dataSource;
|
|
326
|
+
if (page.data.kind !== 'record' || !source?.[member])
|
|
327
|
+
throw new Error(`The data source has no ${member}()`);
|
|
328
|
+
return [source, page.data.model];
|
|
329
|
+
}
|
|
330
|
+
function shownNow() {
|
|
331
|
+
const at = generation;
|
|
332
|
+
const reset = resets;
|
|
333
|
+
// A saved form placed in a record is the record's: another shown round it, its answers are laid nowhere either.
|
|
334
|
+
const round = outer?.recordShown();
|
|
335
|
+
return () => !disposed && at === generation && reset === resets && (!round || round());
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The person's claims on the record: the fields whose last write was theirs. A step's, a rule's or the app's
|
|
339
|
+
* write of the whole field takes one over; a write that works the person's change out (the source's onchange, the
|
|
340
|
+
* page's `change` steps it sets off) keeps it.
|
|
341
|
+
*/
|
|
290
342
|
const personal = new Set();
|
|
343
|
+
/**
|
|
344
|
+
* Of a table the person did not write whole: the keys of the lines they changed, added or took away. A line a
|
|
345
|
+
* step adds is none of theirs, and leaves theirs as they are.
|
|
346
|
+
*/
|
|
347
|
+
const personalLines = new Map();
|
|
291
348
|
/** Fields where a file the person picked landed from the store: at risk until a save sends it, a first save or not. */
|
|
292
349
|
const filed = new Set();
|
|
293
350
|
/** 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
351
|
let sentAs = null;
|
|
295
|
-
/**
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
352
|
+
/**
|
|
353
|
+
* The records being deleted, by the person's choice, each by its generation: a way out of one of them lets their
|
|
354
|
+
* work in it go, unasked. Another record shown meanwhile is asked about as any.
|
|
355
|
+
*/
|
|
356
|
+
const deleting = new Map();
|
|
357
|
+
const deletingShown = () => (deleting.get(generation) ?? 0) > 0;
|
|
299
358
|
/** Each field's last comparison with what was sent, so a value that did not move is not compared again. */
|
|
300
359
|
const compared = new Map();
|
|
301
360
|
/** Fields showing a file that did not reach the store: shown until it is sent again or taken away, whatever else changes. */
|
|
@@ -433,8 +492,9 @@ function innerForm(options, within, place, outer) {
|
|
|
433
492
|
const was = state.values;
|
|
434
493
|
const shown = state.errors;
|
|
435
494
|
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
|
-
|
|
495
|
+
// The person's changes the last save did not send, worked out again from the values as they are now, and from the
|
|
496
|
+
// text they left that does not read.
|
|
497
|
+
if (patch.unsent === undefined && (patch.values !== undefined || patch.unread !== undefined)) {
|
|
438
498
|
const unsent = unsentNow();
|
|
439
499
|
if (!sameList(unsent, state.unsent))
|
|
440
500
|
state = { ...state, unsent };
|
|
@@ -463,42 +523,66 @@ function innerForm(options, within, place, outer) {
|
|
|
463
523
|
for (const listener of [...heard])
|
|
464
524
|
listener(payload);
|
|
465
525
|
}
|
|
466
|
-
/**
|
|
467
|
-
|
|
468
|
-
|
|
526
|
+
/**
|
|
527
|
+
* A field written: told to the app, and — a person's change — the page's `change` steps for it run. `claim` says
|
|
528
|
+
* what of the person's it touches: `line`, the key of the one line of a table the person changed; `keep`, a write
|
|
529
|
+
* that leaves the person's claims as they are (a line a step adds, a write working their change out).
|
|
530
|
+
*/
|
|
531
|
+
function changed(field, by, claim = {}) {
|
|
532
|
+
noteOrigin(field, by, claim);
|
|
469
533
|
emit('change', { field, value: state.values[field], values: state.values, by });
|
|
470
534
|
const list = page.on?.change?.[field];
|
|
471
535
|
if (by === 'person' && list)
|
|
472
536
|
void track(moment(`on.change.${field}`, list, [], true));
|
|
473
537
|
}
|
|
474
|
-
/** Another record is shown: nothing of the last one's is the person's work any more. */
|
|
538
|
+
/** Another record is shown: nothing of the last one's is the person's work any more, and its files on their way stop. */
|
|
475
539
|
function newGeneration() {
|
|
476
540
|
generation++;
|
|
477
541
|
personal.clear();
|
|
542
|
+
personalLines.clear();
|
|
478
543
|
filed.clear();
|
|
479
544
|
compared.clear();
|
|
480
545
|
sentAs = null;
|
|
546
|
+
clearFiles();
|
|
481
547
|
}
|
|
482
548
|
/** 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
549
|
const filedIn = (name) => filed.has(name) || [...attached.values()].some((part) => part.name === name && part.inner.files.filed());
|
|
484
550
|
/**
|
|
485
551
|
* The person's work no save took, by field, in the page's order: each of
|
|
486
552
|
* their changes that differs from the record as the last save left it —
|
|
487
|
-
*
|
|
553
|
+
* a field they wrote, or a line of a table they changed — and text they
|
|
554
|
+
* left in a box of it that does not read (`unread`), which no save can
|
|
555
|
+
* send; before a first save, only a file the store holds.
|
|
488
556
|
*/
|
|
489
557
|
function unsentNow() {
|
|
490
|
-
|
|
558
|
+
const typed = sentAs ? unreadFields() : null;
|
|
559
|
+
if (!personal.size && !personalLines.size && !typed?.size)
|
|
491
560
|
return [];
|
|
492
561
|
const out = [];
|
|
493
562
|
for (const name of Object.keys(fields)) {
|
|
494
|
-
|
|
563
|
+
// Typed and left unread since a save: the person's, as a change is — a value changed to "12" is, so is "12a".
|
|
564
|
+
if (typed?.has(name)) {
|
|
565
|
+
out.push(name);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const lines = personalLines.get(name);
|
|
569
|
+
if (!personal.has(name) && !lines)
|
|
495
570
|
continue;
|
|
496
571
|
const from = sentAs ?? (filedIn(name) ? baseline : null);
|
|
497
|
-
if (from
|
|
572
|
+
if (!from)
|
|
573
|
+
continue;
|
|
574
|
+
if (lines ? linesDiffer(name, lines, state.values[name], from[name]) : differs(name, state.values[name], from[name]))
|
|
498
575
|
out.push(name);
|
|
499
576
|
}
|
|
500
577
|
return out;
|
|
501
578
|
}
|
|
579
|
+
/** The fields with text left unread in a box of theirs: a field's, a line's cell's, a property's. */
|
|
580
|
+
function unreadFields() {
|
|
581
|
+
const out = new Set();
|
|
582
|
+
for (const path of Object.keys(state.unread ?? {}))
|
|
583
|
+
out.add(path.split('.')[0]);
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
502
586
|
function differs(name, value, sent) {
|
|
503
587
|
const known = compared.get(name);
|
|
504
588
|
if (known && known.value === value && known.sent === sent)
|
|
@@ -507,18 +591,53 @@ function innerForm(options, within, place, outer) {
|
|
|
507
591
|
compared.set(name, { value, sent, differs: verdict });
|
|
508
592
|
return verdict;
|
|
509
593
|
}
|
|
594
|
+
/** Whether any of these lines of a table differs from the table as sent: changed, added, or taken away. */
|
|
595
|
+
function linesDiffer(name, keys, value, sent) {
|
|
596
|
+
const def = fields[name];
|
|
597
|
+
const find = (lines, key) => (lines ?? []).find((line) => line.key === key);
|
|
598
|
+
for (const key of keys) {
|
|
599
|
+
const now = find(value, key);
|
|
600
|
+
const was = find(sent, key);
|
|
601
|
+
if (!now && !was)
|
|
602
|
+
continue;
|
|
603
|
+
if (!now || !was)
|
|
604
|
+
return true;
|
|
605
|
+
if (now.values === was.values)
|
|
606
|
+
continue;
|
|
607
|
+
for (const sub of new Set([...Object.keys(now.values), ...Object.keys(was.values)])) {
|
|
608
|
+
if (!sameValue(def.fields[sub], now.values[sub], was.values[sub]))
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
510
614
|
/** `unsent` as it is now, told when it changed. */
|
|
511
615
|
function refreshUnsent() {
|
|
512
616
|
const unsent = unsentNow();
|
|
513
617
|
if (!sameList(unsent, state.unsent))
|
|
514
618
|
set({ unsent });
|
|
515
619
|
}
|
|
516
|
-
/**
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
620
|
+
/**
|
|
621
|
+
* A field written, and by whom: the person's change is theirs — the field, or the line of a table they changed —
|
|
622
|
+
* until a step, a rule or the app writes the whole field in its place. A write that leaves their change where it
|
|
623
|
+
* is (`keep`: a line a step adds, a `change` step working out what they typed) leaves it theirs.
|
|
624
|
+
*/
|
|
625
|
+
function noteOrigin(field, by, claim) {
|
|
626
|
+
if (by === 'person') {
|
|
627
|
+
if (claim.line === undefined) {
|
|
628
|
+
personal.add(field);
|
|
629
|
+
personalLines.delete(field);
|
|
630
|
+
}
|
|
631
|
+
else if (!personal.has(field)) {
|
|
632
|
+
const keys = personalLines.get(field) ?? new Set();
|
|
633
|
+
keys.add(claim.line);
|
|
634
|
+
personalLines.set(field, keys);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
else if (!claim.keep) {
|
|
521
638
|
personal.delete(field);
|
|
639
|
+
personalLines.delete(field);
|
|
640
|
+
}
|
|
522
641
|
refreshUnsent();
|
|
523
642
|
}
|
|
524
643
|
/** A wizard step entered: told to the app, and its `show` steps run. */
|
|
@@ -688,10 +807,12 @@ function innerForm(options, within, place, outer) {
|
|
|
688
807
|
const ticks = node.source.widget === 'tick' && page.fields[name].type === 'boolean';
|
|
689
808
|
return ticks && state.values[name] !== true && nodeState(node.id).required ? messages.tick : undefined;
|
|
690
809
|
}
|
|
810
|
+
/** The widget a field is shown with where it keeps its value its own way (a per cent): the first of its nodes'. */
|
|
811
|
+
const shownWidget = (shown) => shown.map((node) => node.source.widget).find((widget) => widget === 'percentage');
|
|
691
812
|
/** A field node's error now: its field's own rules first, then its answer rules. */
|
|
692
813
|
function nodeError(node) {
|
|
693
814
|
const name = node.field;
|
|
694
|
-
return unticked(node) ?? checkInput(page.fields[name], node.source, state.values[name], messages) ?? checkValue(checked(name), state.values[name], nodeState(node.id).required, messages, today()) ?? ruleCheck(node).error;
|
|
815
|
+
return unticked(node) ?? checkInput(page.fields[name], node.source, state.values[name], messages) ?? checkValue(checked(name), state.values[name], nodeState(node.id).required, messages, today(), node.source.widget) ?? ruleCheck(node).error;
|
|
695
816
|
}
|
|
696
817
|
/** Warnings for the visible fields of the page, without publishing them. */
|
|
697
818
|
function collectWarnings() {
|
|
@@ -775,7 +896,7 @@ function innerForm(options, within, place, outer) {
|
|
|
775
896
|
const cell = ruled[sub];
|
|
776
897
|
if (cell?.invisible || (tables.has(node.id) && tables.get(node.id).compiled.hidden(sub, reading().context, reading().env, userRoles)))
|
|
777
898
|
continue;
|
|
778
|
-
const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today());
|
|
899
|
+
const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today(), node.source.cells?.[sub]?.widget);
|
|
779
900
|
if (lineMessage)
|
|
780
901
|
errors[`${name}.${line.key}.${sub}`] = lineMessage;
|
|
781
902
|
}
|
|
@@ -840,7 +961,7 @@ function innerForm(options, within, place, outer) {
|
|
|
840
961
|
if (isEmpty(fieldDef(field), value))
|
|
841
962
|
return undefined;
|
|
842
963
|
return (shown.map((node) => checkInput(page.fields[field], node.source, value, messages)).find(Boolean) ??
|
|
843
|
-
besidesRows(field, checkValue(checked(field), value, false, messages, today())) ??
|
|
964
|
+
besidesRows(field, checkValue(checked(field), value, false, messages, today(), shownWidget(shown))) ??
|
|
844
965
|
shown.map((node) => ruleCheck(node).error).find(Boolean));
|
|
845
966
|
}
|
|
846
967
|
if (parts.length === 2 && page.fields[field]?.type === 'properties')
|
|
@@ -1026,8 +1147,10 @@ function innerForm(options, within, place, outer) {
|
|
|
1026
1147
|
return;
|
|
1027
1148
|
const seq = ++onchangeSeq;
|
|
1028
1149
|
const request = { model: page.data.model, id: state.recordId, changed, values: structuredCopy(state.values) };
|
|
1150
|
+
const shown = shownNow();
|
|
1029
1151
|
void track(source.onchange(request).then((result) => {
|
|
1030
|
-
|
|
1152
|
+
// A newer change asked since; or the record it was asked for is gone, or its values let go: none of it is laid.
|
|
1153
|
+
if (seq !== onchangeSeq || !shown())
|
|
1031
1154
|
return;
|
|
1032
1155
|
const values = { ...state.values, ...(result.values ?? {}) };
|
|
1033
1156
|
writeValues(values, { warning: result.warning ?? null, warningField: result.warning ? changed : null });
|
|
@@ -1128,18 +1251,27 @@ function innerForm(options, within, place, outer) {
|
|
|
1128
1251
|
set({ status: 'error', error: problem.message, saveProblem: problem, conflict: null });
|
|
1129
1252
|
return { reason: 'save', message: problem.message };
|
|
1130
1253
|
}
|
|
1254
|
+
// Asked for the record shown now: another shown while it waits below, it saves nothing of that one, nor says anything there.
|
|
1255
|
+
const asked = generation;
|
|
1256
|
+
const elsewhere = () => ({ reason: 'save', message: messages.recordReplaced });
|
|
1131
1257
|
// 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
1258
|
// up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
|
|
1133
1259
|
const waiting = readTyped();
|
|
1134
1260
|
if (waiting)
|
|
1135
1261
|
await waiting;
|
|
1262
|
+
if (asked !== generation)
|
|
1263
|
+
return elsewhere();
|
|
1136
1264
|
// 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
1265
|
if (filesGoing()) {
|
|
1138
1266
|
set({ status: 'saving', error: null, saveProblem: null });
|
|
1139
1267
|
await filesSettled();
|
|
1268
|
+
if (asked !== generation)
|
|
1269
|
+
return elsewhere();
|
|
1140
1270
|
set({ status: 'ready' });
|
|
1141
1271
|
}
|
|
1142
1272
|
if (page.on?.beforeSave) {
|
|
1273
|
+
// Its run stops of itself on a record shown by anything but its own steps (a `call`'s `{ record }`, a reload):
|
|
1274
|
+
// those move the save on with it.
|
|
1143
1275
|
const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
|
|
1144
1276
|
if (!before.done) {
|
|
1145
1277
|
// A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
|
|
@@ -1164,6 +1296,8 @@ function innerForm(options, within, place, outer) {
|
|
|
1164
1296
|
try {
|
|
1165
1297
|
// 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
1298
|
await storeBytes(page.data.kind === 'responses' || state.recordId == null ? Object.keys(fields) : state.dirty);
|
|
1299
|
+
if (at !== generation)
|
|
1300
|
+
return elsewhere();
|
|
1167
1301
|
const failed = fileProblems();
|
|
1168
1302
|
if (Object.keys(failed).length) {
|
|
1169
1303
|
set({ errors: failed, ...unstored(failed) });
|
|
@@ -1175,7 +1309,11 @@ function innerForm(options, within, place, outer) {
|
|
|
1175
1309
|
const sent = shownValues();
|
|
1176
1310
|
const before = state.values;
|
|
1177
1311
|
at = generation;
|
|
1178
|
-
|
|
1312
|
+
const seen = botReport();
|
|
1313
|
+
await source.submit({ pageId: page.id, values: sent, ...(seen ? { bot: seen } : {}) });
|
|
1314
|
+
// The captcha's token is spent once sent: the next send waits for a new one.
|
|
1315
|
+
if (seen?.captcha)
|
|
1316
|
+
bot.captcha = null;
|
|
1179
1317
|
// Answered for answers no longer shown — the form was started again meanwhile: the source has them, the form lays nothing.
|
|
1180
1318
|
if (at !== generation)
|
|
1181
1319
|
return null;
|
|
@@ -1183,7 +1321,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1183
1321
|
// What it sent is what an answer changed since is told against: still to send.
|
|
1184
1322
|
sentAs = before;
|
|
1185
1323
|
set({ status: 'saved', unsent: unsentNow() });
|
|
1186
|
-
emit('send', { values: sent });
|
|
1324
|
+
emit('send', { values: sent, ...(seen ? { bot: seen } : {}) });
|
|
1187
1325
|
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
1188
1326
|
return null;
|
|
1189
1327
|
}
|
|
@@ -1219,7 +1357,8 @@ function innerForm(options, within, place, outer) {
|
|
|
1219
1357
|
scheduleDraft();
|
|
1220
1358
|
scheduleAutosave();
|
|
1221
1359
|
}
|
|
1222
|
-
|
|
1360
|
+
// The record as this save left it: a change made while it was on its way is no value of it.
|
|
1361
|
+
emit('save', { recordId: state.recordId, values: baseline });
|
|
1223
1362
|
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
1224
1363
|
return null;
|
|
1225
1364
|
}
|
|
@@ -1275,8 +1414,12 @@ function innerForm(options, within, place, outer) {
|
|
|
1275
1414
|
}
|
|
1276
1415
|
/** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
|
|
1277
1416
|
async function opening() {
|
|
1417
|
+
const at = generation;
|
|
1278
1418
|
emit('open', { recordId: state.recordId, values: state.values });
|
|
1279
1419
|
await moment('on.open', page.on?.open, [], true);
|
|
1420
|
+
// Another record shown while its open steps ran: that one's opening tells its own steps.
|
|
1421
|
+
if (at !== generation)
|
|
1422
|
+
return;
|
|
1280
1423
|
showing = true;
|
|
1281
1424
|
tellStep([]);
|
|
1282
1425
|
}
|
|
@@ -1297,8 +1440,8 @@ function innerForm(options, within, place, outer) {
|
|
|
1297
1440
|
for (const name of names)
|
|
1298
1441
|
changed(name, 'app');
|
|
1299
1442
|
}
|
|
1300
|
-
/** Write fields — a step's or the app's — worked out once, and tell each. */
|
|
1301
|
-
function write(written, by) {
|
|
1443
|
+
/** Write fields — a step's or the app's — worked out once, and tell each; `keep`, a write working the person's change out. */
|
|
1444
|
+
function write(written, by, keep = false) {
|
|
1302
1445
|
const names = Object.keys(written);
|
|
1303
1446
|
names.forEach(fieldDef);
|
|
1304
1447
|
if (!names.length)
|
|
@@ -1307,8 +1450,9 @@ function innerForm(options, within, place, outer) {
|
|
|
1307
1450
|
for (const name of names)
|
|
1308
1451
|
afterEdit(name);
|
|
1309
1452
|
for (const name of names)
|
|
1310
|
-
changed(name, by);
|
|
1453
|
+
changed(name, by, { keep });
|
|
1311
1454
|
}
|
|
1455
|
+
/** A line added: the person's, theirs; a step's, none of theirs, and the lines they changed stay theirs. */
|
|
1312
1456
|
function addLine(field, values, by) {
|
|
1313
1457
|
const def = lineField(field);
|
|
1314
1458
|
const current = state.values[field] ?? [];
|
|
@@ -1331,7 +1475,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1331
1475
|
const lines = [...current, line];
|
|
1332
1476
|
writeValues({ ...state.values, [field]: lines });
|
|
1333
1477
|
afterEdit(field);
|
|
1334
|
-
changed(field, by);
|
|
1478
|
+
changed(field, by, by === 'person' ? { line: key } : { keep: true });
|
|
1335
1479
|
return key;
|
|
1336
1480
|
}
|
|
1337
1481
|
/**
|
|
@@ -1340,15 +1484,44 @@ function innerForm(options, within, place, outer) {
|
|
|
1340
1484
|
*/
|
|
1341
1485
|
async function check(names) {
|
|
1342
1486
|
names?.forEach(fieldDef);
|
|
1487
|
+
const at = generation;
|
|
1343
1488
|
const waiting = readTyped();
|
|
1344
1489
|
if (waiting)
|
|
1345
1490
|
await waiting;
|
|
1491
|
+
// Another record shown while it waited: nothing found is shown on that one.
|
|
1492
|
+
if (at !== generation)
|
|
1493
|
+
return replaced();
|
|
1346
1494
|
const all = collectErrors();
|
|
1347
1495
|
const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
|
|
1348
1496
|
set({ errors: found });
|
|
1349
1497
|
const first = Object.values(found)[0];
|
|
1350
1498
|
return first === undefined ? null : { reason: 'check', message: first };
|
|
1351
1499
|
}
|
|
1500
|
+
/**
|
|
1501
|
+
* What is typed read first, for a run the person or the app set off (`RunScope.typed`): every box read as a save
|
|
1502
|
+
* reads it, a name looked up waited for. Text left that does not read stops the run, said under its box as a check
|
|
1503
|
+
* says it — never a value under it handed on or worked from.
|
|
1504
|
+
*/
|
|
1505
|
+
async function typedFirst() {
|
|
1506
|
+
const at = generation;
|
|
1507
|
+
const waiting = readTyped();
|
|
1508
|
+
if (waiting)
|
|
1509
|
+
await waiting;
|
|
1510
|
+
// Another record shown while it waited: the run is not for that one.
|
|
1511
|
+
if (at !== generation)
|
|
1512
|
+
return replaced();
|
|
1513
|
+
const found = unreadProblems();
|
|
1514
|
+
const first = Object.values(found)[0];
|
|
1515
|
+
if (first === undefined)
|
|
1516
|
+
return null;
|
|
1517
|
+
set({ errors: { ...state.errors, ...found } });
|
|
1518
|
+
return { reason: 'check', message: first };
|
|
1519
|
+
}
|
|
1520
|
+
/** The problems of text left unread in boxes shown — this form's and its saved forms' placed here — by path. */
|
|
1521
|
+
function unreadProblems() {
|
|
1522
|
+
const inner = (path) => [...attached.values()].some((part) => path.startsWith(`${part.name}.`) && !!part.inner.form.getState().unread?.[path.slice(part.name.length + 1)]);
|
|
1523
|
+
return Object.fromEntries(Object.entries(collectErrors()).filter(([path]) => !!state.unread[path] || inner(path)));
|
|
1524
|
+
}
|
|
1352
1525
|
/** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
|
|
1353
1526
|
async function goToStep(target, chain) {
|
|
1354
1527
|
if (!steps.includes(target))
|
|
@@ -1356,16 +1529,19 @@ function innerForm(options, within, place, outer) {
|
|
|
1356
1529
|
if (!form.steps().includes(target))
|
|
1357
1530
|
return { reason: 'cannot', message: `The step "${target}" does not apply now` };
|
|
1358
1531
|
// A name still being looked up in a box on the way is waited for, as a save waits for it.
|
|
1532
|
+
const at = generation;
|
|
1359
1533
|
const waiting = readTyped();
|
|
1360
1534
|
if (waiting)
|
|
1361
1535
|
await waiting;
|
|
1536
|
+
if (at !== generation)
|
|
1537
|
+
return replaced();
|
|
1362
1538
|
let went = false;
|
|
1363
1539
|
inRun(chain, () => (went = form.goTo(target)));
|
|
1364
1540
|
return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
|
|
1365
1541
|
}
|
|
1366
1542
|
/** 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`). */
|
|
1367
1543
|
async function runTop(list, scope) {
|
|
1368
|
-
scope.generation
|
|
1544
|
+
scope.generation ??= generation;
|
|
1369
1545
|
const result = await runner.runSteps(list, scope);
|
|
1370
1546
|
emit('run', { id: scope.id, steps: list, result });
|
|
1371
1547
|
return result;
|
|
@@ -1383,7 +1559,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1383
1559
|
throw new Error(`"${name}" cannot make records: its data source has no create, or it is not a link`);
|
|
1384
1560
|
return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
|
|
1385
1561
|
}
|
|
1386
|
-
async function runSearch(def, name, ctx, query, limit, model, ids) {
|
|
1562
|
+
async function runSearch(def, name, ctx, query, limit, { model, ids, exact } = {}) {
|
|
1387
1563
|
// Formatted text names people and records in place (@mention, #4127): searched by the model they are of.
|
|
1388
1564
|
const byModel = def.type === 'reference' || def.type === 'json' || def.type === 'html';
|
|
1389
1565
|
if (def.type !== 'many2one' && def.type !== 'many2many' && !byModel) {
|
|
@@ -1398,7 +1574,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1398
1574
|
const own = byModel || !def.filter ? undefined : resolveFilter(def.filter, ctx);
|
|
1399
1575
|
const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
|
|
1400
1576
|
const relation = byModel ? model : def.relation;
|
|
1401
|
-
return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
|
|
1577
|
+
return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit, ...(exact ? { exact: true } : {}) }));
|
|
1402
1578
|
}
|
|
1403
1579
|
function lineField(field) {
|
|
1404
1580
|
const def = fieldDef(field);
|
|
@@ -1499,7 +1675,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1499
1675
|
}
|
|
1500
1676
|
}
|
|
1501
1677
|
/** 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) => !
|
|
1678
|
+
const atOnce = (how, own = false) => !deletingShown() && !checking && !busy(own) && (how.discard === true || !workAtRisk(state, how));
|
|
1503
1679
|
/** The saves on their way, settled: what a load that is not a way out (a conflict's Reload) waits for. */
|
|
1504
1680
|
async function savesSettled() {
|
|
1505
1681
|
while (saveRunning)
|
|
@@ -1525,10 +1701,13 @@ function innerForm(options, within, place, outer) {
|
|
|
1525
1701
|
if (outer)
|
|
1526
1702
|
return outer.letGo(how, chain);
|
|
1527
1703
|
// A delete the person chose: their work goes with the record, unasked.
|
|
1528
|
-
if (
|
|
1704
|
+
if (deletingShown()) {
|
|
1529
1705
|
forget();
|
|
1530
1706
|
return true;
|
|
1531
1707
|
}
|
|
1708
|
+
// The person's own Discard stops the files on their way, never waits for them.
|
|
1709
|
+
if (how.discard)
|
|
1710
|
+
clearFiles();
|
|
1532
1711
|
const own = chain.includes('on.beforeSave');
|
|
1533
1712
|
if (atOnce(how, own)) {
|
|
1534
1713
|
if (how.discard)
|
|
@@ -1571,13 +1750,46 @@ function innerForm(options, within, place, outer) {
|
|
|
1571
1750
|
forget();
|
|
1572
1751
|
return yes;
|
|
1573
1752
|
}
|
|
1574
|
-
/**
|
|
1753
|
+
/**
|
|
1754
|
+
* Let go of the person's work at risk: files on their way stop, changes set aside go, a conflict's changes are
|
|
1755
|
+
* asked about no more, and the changes no save took go back to the record as the last save left it — so what a
|
|
1756
|
+
* dialog or a page in place hands back once it saved is the record as saved, never the change let go.
|
|
1757
|
+
*/
|
|
1575
1758
|
function forget() {
|
|
1759
|
+
const back = unsentLetGo();
|
|
1576
1760
|
clearFiles();
|
|
1577
1761
|
personal.clear();
|
|
1762
|
+
personalLines.clear();
|
|
1578
1763
|
filed.clear();
|
|
1579
|
-
if (
|
|
1580
|
-
|
|
1764
|
+
if (back)
|
|
1765
|
+
writeValues({ ...state.values, ...back });
|
|
1766
|
+
// Text left that does not read goes too: each box shows what its field holds.
|
|
1767
|
+
const unread = Object.keys(state.unread).length ? { unread: {} } : {};
|
|
1768
|
+
if (state.setAside !== null || state.unsent.length || state.conflict !== null || unread.unread)
|
|
1769
|
+
set({ setAside: null, unsent: [], conflict: null, ...unread });
|
|
1770
|
+
}
|
|
1771
|
+
/** The fields holding the person's changes no save took, as the record as last saved holds them: their lines alone, for a table. */
|
|
1772
|
+
function unsentLetGo() {
|
|
1773
|
+
if (!state.unsent.length)
|
|
1774
|
+
return null;
|
|
1775
|
+
const out = {};
|
|
1776
|
+
for (const name of state.unsent) {
|
|
1777
|
+
const from = sentAs ?? baseline;
|
|
1778
|
+
const keys = personalLines.get(name);
|
|
1779
|
+
out[name] = keys && !personal.has(name) ? linesAsSent(state.values[name], from[name], keys) : structuredCopy(from[name]);
|
|
1780
|
+
}
|
|
1781
|
+
return out;
|
|
1782
|
+
}
|
|
1783
|
+
/** A table with these lines as sent: changed ones as they were, added ones gone, ones taken away back in their place. */
|
|
1784
|
+
function linesAsSent(value, sent, keys) {
|
|
1785
|
+
const was = sent ?? [];
|
|
1786
|
+
const of = (key) => was.find((line) => line.key === key);
|
|
1787
|
+
const out = (value ?? []).filter((line) => !keys.has(line.key) || of(line.key)).map((line) => (keys.has(line.key) ? structuredCopy(of(line.key)) : line));
|
|
1788
|
+
was.forEach((line, at) => {
|
|
1789
|
+
if (keys.has(line.key) && !out.some((other) => other.key === line.key))
|
|
1790
|
+
out.splice(Math.min(at, out.length), 0, structuredCopy(line));
|
|
1791
|
+
});
|
|
1792
|
+
return out;
|
|
1581
1793
|
}
|
|
1582
1794
|
/** The check as a step meets it: null to go on — at once when nothing is at risk — else why the run stops. */
|
|
1583
1795
|
function letGoStep(chain) {
|
|
@@ -1585,12 +1797,19 @@ function innerForm(options, within, place, outer) {
|
|
|
1585
1797
|
return go === true ? null : go.then((yes) => (yes ? null : kept()));
|
|
1586
1798
|
}
|
|
1587
1799
|
const kept = () => ({ reason: 'no', message: messages.discardChanges });
|
|
1588
|
-
/**
|
|
1589
|
-
|
|
1800
|
+
/**
|
|
1801
|
+
* A step's own move — the record loaded again, or another shown — once the check let what is shown go: kept, the
|
|
1802
|
+
* person kept their work; replaced, another record was shown before it ended (the run's steps are not for that one).
|
|
1803
|
+
*/
|
|
1804
|
+
async function ownMove(chain, move) {
|
|
1590
1805
|
const go = letGo({}, chain);
|
|
1591
|
-
if (go
|
|
1592
|
-
return
|
|
1593
|
-
|
|
1806
|
+
if (go !== true && !(await go))
|
|
1807
|
+
return kept();
|
|
1808
|
+
// The record it shows is a generation of its own, made as it starts: another made before it ends replaced it.
|
|
1809
|
+
const moving = move();
|
|
1810
|
+
const mine = generation;
|
|
1811
|
+
await moving;
|
|
1812
|
+
return mine === generation ? null : replaced();
|
|
1594
1813
|
}
|
|
1595
1814
|
/**
|
|
1596
1815
|
* The host's close, as every `close` step reaches it: the form takes it
|
|
@@ -1612,6 +1831,10 @@ function innerForm(options, within, place, outer) {
|
|
|
1612
1831
|
const go = letGo({}, chain);
|
|
1613
1832
|
return go === true ? shut() : go.then((yes) => (yes ? shut() : kept()));
|
|
1614
1833
|
}
|
|
1834
|
+
/** Why a run stops whose record was replaced while it went on — quietly, when the form was closed. */
|
|
1835
|
+
function replaced() {
|
|
1836
|
+
return disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced };
|
|
1837
|
+
}
|
|
1615
1838
|
/** The record a record step asks the data source about. */
|
|
1616
1839
|
const record = () => ({ model: page.data.model, id: state.recordId, fields });
|
|
1617
1840
|
async function post(message) {
|
|
@@ -1634,18 +1857,30 @@ function innerForm(options, within, place, outer) {
|
|
|
1634
1857
|
recordId: () => state.recordId,
|
|
1635
1858
|
reading,
|
|
1636
1859
|
today,
|
|
1637
|
-
|
|
1860
|
+
// A step's write of a field takes the person's change in it over — unless the step works that change out: the
|
|
1861
|
+
// page's `change` steps, set off by it, as the source's onchange does.
|
|
1862
|
+
write: (values, chain) => write(values, 'step', worksOut(chain)),
|
|
1638
1863
|
store: (values) => store(values),
|
|
1864
|
+
// The person's own change: the field's, or a line of it (a claim by line): put back, the whole field is theirs
|
|
1865
|
+
// again — at risk, asked about — never less than it was.
|
|
1866
|
+
mine: (field) => personal.has(field) || personalLines.has(field),
|
|
1867
|
+
putBack(values, mine) {
|
|
1868
|
+
write(values, 'step');
|
|
1869
|
+
for (const name of mine)
|
|
1870
|
+
noteOrigin(name, 'person', {});
|
|
1871
|
+
},
|
|
1639
1872
|
addLine: (field, values) => void addLine(field, values, 'step'),
|
|
1640
1873
|
check,
|
|
1874
|
+
typedFirst,
|
|
1641
1875
|
save: saving,
|
|
1642
1876
|
generation: () => generation,
|
|
1643
|
-
gone:
|
|
1877
|
+
gone: replaced,
|
|
1644
1878
|
async reload(chain) {
|
|
1645
1879
|
if (state.recordId == null)
|
|
1646
1880
|
return { reason: 'cannot', message: 'The record is not saved yet: there is nothing to load again' };
|
|
1647
|
-
|
|
1648
|
-
|
|
1881
|
+
const stopped = await ownMove(chain, () => load(true));
|
|
1882
|
+
if (stopped)
|
|
1883
|
+
return stopped;
|
|
1649
1884
|
return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
|
|
1650
1885
|
},
|
|
1651
1886
|
reset(chain) {
|
|
@@ -1666,24 +1901,32 @@ function innerForm(options, within, place, outer) {
|
|
|
1666
1901
|
changed: () => state.dirty.length > 0 || state.uploads.some(uploadGoing),
|
|
1667
1902
|
letGo: letGoStep,
|
|
1668
1903
|
async deleting(remove, done) {
|
|
1669
|
-
|
|
1904
|
+
const of = generation;
|
|
1905
|
+
const shown = shownNow();
|
|
1906
|
+
deleting.set(of, (deleting.get(of) ?? 0) + 1);
|
|
1670
1907
|
try {
|
|
1671
1908
|
const out = await remove();
|
|
1672
|
-
// Deleted: what the person had not saved in it goes with it. Refused, it stays theirs.
|
|
1673
|
-
|
|
1909
|
+
// Deleted: what the person had not saved in it goes with it. Refused, it stays theirs. Another record shown
|
|
1910
|
+
// meanwhile keeps what was typed in it.
|
|
1911
|
+
if (done(out) && shown())
|
|
1674
1912
|
forget();
|
|
1675
1913
|
return out;
|
|
1676
1914
|
}
|
|
1677
1915
|
finally {
|
|
1678
|
-
|
|
1916
|
+
const left = (deleting.get(of) ?? 1) - 1;
|
|
1917
|
+
if (left)
|
|
1918
|
+
deleting.set(of, left);
|
|
1919
|
+
else
|
|
1920
|
+
deleting.delete(of);
|
|
1679
1921
|
}
|
|
1680
1922
|
},
|
|
1681
1923
|
...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
|
|
1682
1924
|
...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
|
|
1683
1925
|
...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
|
|
1684
1926
|
async showRecord(id, chain) {
|
|
1685
|
-
|
|
1686
|
-
|
|
1927
|
+
const stopped = await ownMove(chain, () => showNow(id));
|
|
1928
|
+
if (stopped)
|
|
1929
|
+
return stopped;
|
|
1687
1930
|
return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
|
|
1688
1931
|
},
|
|
1689
1932
|
told(event, id) {
|
|
@@ -1702,8 +1945,24 @@ function innerForm(options, within, place, outer) {
|
|
|
1702
1945
|
wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
|
|
1703
1946
|
barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
|
|
1704
1947
|
});
|
|
1705
|
-
|
|
1706
|
-
|
|
1948
|
+
// A moment's run is on the record it was set off on: one set off again while its run goes, and run after it, is let
|
|
1949
|
+
// go when another record is shown by then (its steps were for that one).
|
|
1950
|
+
const moment = createMoments((key, list, chain, on) => runTop(list, { id: key, chain, generation: on }), () => generation);
|
|
1951
|
+
/**
|
|
1952
|
+
* Whether steps do something before their first save — set a status, call the app, open a page — so a record's
|
|
1953
|
+
* button made of them saves first. Steps that start with the save (Save and close), or only check before it, need
|
|
1954
|
+
* no save before them: theirs is the first.
|
|
1955
|
+
*/
|
|
1956
|
+
function actsBeforeSaving(list) {
|
|
1957
|
+
const first = list.findIndex((step) => step.do === 'save');
|
|
1958
|
+
return first > 0 && list.slice(0, first).some((step) => step.do !== 'check');
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. A
|
|
1962
|
+
* record's button whose steps act and then save saves the record first when it is new or changed, as Flectra's
|
|
1963
|
+
* button does: refused — a field it requires left empty — none of its steps run, so nothing it does happens to a
|
|
1964
|
+
* record that cannot be saved.
|
|
1965
|
+
*/
|
|
1707
1966
|
function press(source, scope) {
|
|
1708
1967
|
const list = [...(source.steps ?? [])];
|
|
1709
1968
|
if (source.action !== undefined)
|
|
@@ -1714,12 +1973,37 @@ function innerForm(options, within, place, outer) {
|
|
|
1714
1973
|
list.push({ do: builtin });
|
|
1715
1974
|
const asked = builtin === 'archive' ? messages.archiveConfirm : builtin === 'delete' ? messages.deleteConfirm : undefined;
|
|
1716
1975
|
const confirm = 'confirm' in source && source.confirm !== undefined ? source.confirm : asked;
|
|
1976
|
+
// On the record it was pressed on: another shown while its question is asked, it does nothing to that one.
|
|
1977
|
+
scope.generation ??= generation;
|
|
1978
|
+
// Pressed by the person: what is typed is read before a step reads the form or hands its values on.
|
|
1979
|
+
scope.typed ??= 'first';
|
|
1717
1980
|
return track((async () => {
|
|
1718
1981
|
if (confirm && !(await runner.ask(confirm))) {
|
|
1719
1982
|
const result = { done: false, reason: 'no', message: confirm };
|
|
1720
1983
|
emit('run', { id: scope.id, steps: list, result });
|
|
1721
1984
|
return result;
|
|
1722
1985
|
}
|
|
1986
|
+
const saveFirst = page.data.kind === 'record' && actsBeforeSaving(list);
|
|
1987
|
+
// What is typed is read first, as the run reads it before its first step that reads the form (run.ts): text
|
|
1988
|
+
// that does not read stops it with `check`, said under its box — before the save that comes first, and any step.
|
|
1989
|
+
if (saveFirst && scope.typed === 'first') {
|
|
1990
|
+
const unread = await typedFirst();
|
|
1991
|
+
if (unread) {
|
|
1992
|
+
const at = Math.max(0, list.findIndex((step) => step.do !== 'check'));
|
|
1993
|
+
const result = { done: false, stoppedAt: at, step: list[at], reason: unread.reason, ...(unread.message !== undefined ? { message: unread.message } : {}) };
|
|
1994
|
+
emit('run', { id: scope.id, steps: list, result });
|
|
1995
|
+
return result;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
// Then the save first, then the steps.
|
|
1999
|
+
if (saveFirst && (state.recordId == null || state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0 || state.uploads.some(uploadGoing))) {
|
|
2000
|
+
const first = await saving(scope.chain);
|
|
2001
|
+
if (first) {
|
|
2002
|
+
const result = { done: false, step: { do: 'save' }, reason: first.reason, ...(first.message !== undefined ? { message: first.message } : {}), ...(first.error !== undefined ? { error: first.error } : {}) };
|
|
2003
|
+
emit('run', { id: scope.id, steps: list, result });
|
|
2004
|
+
return result;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
1723
2007
|
return runTop(list, scope);
|
|
1724
2008
|
})());
|
|
1725
2009
|
}
|
|
@@ -1745,6 +2029,14 @@ function innerForm(options, within, place, outer) {
|
|
|
1745
2029
|
openRecord: (id) => track(openRecord(id)),
|
|
1746
2030
|
leave: (how) => Promise.resolve(letGo(how)),
|
|
1747
2031
|
leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
|
|
2032
|
+
recordShown: shownNow,
|
|
2033
|
+
relatedFilter(id) {
|
|
2034
|
+
const node = index.get(id)?.source;
|
|
2035
|
+
if (node?.type !== 'related' || state.recordId == null)
|
|
2036
|
+
return null;
|
|
2037
|
+
const related = node;
|
|
2038
|
+
return [{ field: related.field, op: '=', value: state.recordId }, ...(related.filter ? resolveFilter(related.filter, context()) : [])];
|
|
2039
|
+
},
|
|
1748
2040
|
post,
|
|
1749
2041
|
setEditing(next) {
|
|
1750
2042
|
if (next === editing)
|
|
@@ -1757,6 +2049,10 @@ function innerForm(options, within, place, outer) {
|
|
|
1757
2049
|
loadChoices,
|
|
1758
2050
|
loadDefinitions,
|
|
1759
2051
|
addDefinition,
|
|
2052
|
+
waitsFor(name) {
|
|
2053
|
+
const from = workedOut.get(name);
|
|
2054
|
+
return from ? waitingOn(page, from, state.values, state.unread) : null;
|
|
2055
|
+
},
|
|
1760
2056
|
problem(name) {
|
|
1761
2057
|
fieldDef(name);
|
|
1762
2058
|
// Only a field someone can see is asked anything, and required wherever it is shown so.
|
|
@@ -1773,7 +2069,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1773
2069
|
}
|
|
1774
2070
|
const own = shown.map(unticked).find(Boolean) ??
|
|
1775
2071
|
shown.map((node) => checkInput(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
|
|
1776
|
-
checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
|
|
2072
|
+
checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today(), shownWidget(shown));
|
|
1777
2073
|
return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
|
|
1778
2074
|
},
|
|
1779
2075
|
setUnread(path, unread) {
|
|
@@ -1819,6 +2115,12 @@ function innerForm(options, within, place, outer) {
|
|
|
1819
2115
|
};
|
|
1820
2116
|
},
|
|
1821
2117
|
settle: () => readTyped() ?? Promise.resolve(),
|
|
2118
|
+
botCheck(seen) {
|
|
2119
|
+
if (seen.honeypot !== undefined)
|
|
2120
|
+
bot.honeypot = seen.honeypot;
|
|
2121
|
+
if (seen.captcha !== undefined)
|
|
2122
|
+
bot.captcha = seen.captcha;
|
|
2123
|
+
},
|
|
1822
2124
|
reading: () => readingNow > 0 || [...attached.values()].some((part) => part.inner.form.reading()),
|
|
1823
2125
|
validate() {
|
|
1824
2126
|
readTyped();
|
|
@@ -1828,6 +2130,50 @@ function innerForm(options, within, place, outer) {
|
|
|
1828
2130
|
},
|
|
1829
2131
|
save: () => track(save()),
|
|
1830
2132
|
canUpload: () => typeof options.dataSource?.upload === 'function',
|
|
2133
|
+
today,
|
|
2134
|
+
recordReads: (id, values, fields) => recordContext(values, fields ?? page.fields, { recordId: id, user: options.user, editing: false, context: options.context }),
|
|
2135
|
+
canFindSlots: () => typeof options.dataSource?.slots === 'function',
|
|
2136
|
+
findSlots(field, ask) {
|
|
2137
|
+
const source = options.dataSource;
|
|
2138
|
+
if (!source?.slots)
|
|
2139
|
+
return Promise.reject(new Error('The data source has no slots()'));
|
|
2140
|
+
const def = page.fields[field];
|
|
2141
|
+
const { schedule = field, length, between, from, to } = ask;
|
|
2142
|
+
return source.slots({
|
|
2143
|
+
model: page.data.kind === 'record' ? page.data.model : '',
|
|
2144
|
+
field,
|
|
2145
|
+
recordId: state.recordId,
|
|
2146
|
+
schedule,
|
|
2147
|
+
from,
|
|
2148
|
+
to,
|
|
2149
|
+
timeZone: (def?.type === 'datetime' && def.timeZone) || page.timeZone || ownZone(),
|
|
2150
|
+
...(length ? { length } : {}),
|
|
2151
|
+
...(between ? { between } : {}),
|
|
2152
|
+
values: structuredCopy(state.values),
|
|
2153
|
+
});
|
|
2154
|
+
},
|
|
2155
|
+
canFindDuplicates: () => !!(page.data.kind === 'record' && page.duplicates?.fields?.length && options.dataSource?.duplicates),
|
|
2156
|
+
async findDuplicates() {
|
|
2157
|
+
const [source, model] = recordSource('duplicates');
|
|
2158
|
+
const fields = (page.duplicates?.fields ?? []).filter((name) => page.fields[name] && !isEmpty(page.fields[name], state.values[name]));
|
|
2159
|
+
const recordId = state.recordId;
|
|
2160
|
+
// Nothing typed in any of them: nothing asked.
|
|
2161
|
+
if (!fields.length)
|
|
2162
|
+
return [];
|
|
2163
|
+
const { matches } = await source.duplicates({ model, recordId, values: structuredCopy(state.values), fields });
|
|
2164
|
+
// Never the record itself, whatever the source says; the likest first where it scores them.
|
|
2165
|
+
const others = (matches ?? []).filter((match) => match && String(match.id) !== String(recordId));
|
|
2166
|
+
return others.every((match) => typeof match.score === 'number') ? others.sort((a, b) => b.score - a.score) : others;
|
|
2167
|
+
},
|
|
2168
|
+
canMerge: () => !!(page.data.kind === 'record' && options.dataSource?.merge && options.dataSource.load),
|
|
2169
|
+
async readRecord(id) {
|
|
2170
|
+
const [source, model] = recordSource('load');
|
|
2171
|
+
return loadedRecordOf(await source.load({ model, id, fields: page.fields })).values;
|
|
2172
|
+
},
|
|
2173
|
+
async merge(request) {
|
|
2174
|
+
const [source, model] = recordSource('merge');
|
|
2175
|
+
return source.merge({ model, keepId: request.keepId, mergeIds: [...request.mergeIds], values: structuredCopy(request.values), fields: page.fields });
|
|
2176
|
+
},
|
|
1831
2177
|
upload: (field, files, how = {}) => uploads.start(field, files, how),
|
|
1832
2178
|
uploading: (field) => state.uploads.filter((upload) => upload.field === field && !upload.line),
|
|
1833
2179
|
cancelUpload(id) {
|
|
@@ -1853,8 +2199,11 @@ function innerForm(options, within, place, outer) {
|
|
|
1853
2199
|
// 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
2200
|
resets++;
|
|
1855
2201
|
personal.clear();
|
|
2202
|
+
personalLines.clear();
|
|
1856
2203
|
filed.clear();
|
|
1857
2204
|
sentAs = null;
|
|
2205
|
+
// Started again: a new response, timed from now.
|
|
2206
|
+
bot = { ...bot, honeypot: false, since: clock() };
|
|
1858
2207
|
const values = syncParts(structuredCopy(baseline), 'start');
|
|
1859
2208
|
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: {} });
|
|
1860
2209
|
},
|
|
@@ -1867,7 +2216,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1867
2216
|
const lines = (state.values[field] ?? []).map((line) => line.key === key ? { ...line, values: { ...line.values, [name]: structuredCopy(value) } } : line);
|
|
1868
2217
|
writeValues({ ...state.values, [field]: lines });
|
|
1869
2218
|
afterEdit(field);
|
|
1870
|
-
changed(field, 'person');
|
|
2219
|
+
changed(field, 'person', { line: key });
|
|
1871
2220
|
},
|
|
1872
2221
|
moveLine(field, key, to) {
|
|
1873
2222
|
const def = lineField(field);
|
|
@@ -1912,10 +2261,10 @@ function innerForm(options, within, place, outer) {
|
|
|
1912
2261
|
const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
|
|
1913
2262
|
writeValues({ ...state.values, [field]: lines });
|
|
1914
2263
|
afterEdit(field);
|
|
1915
|
-
changed(field, 'person');
|
|
2264
|
+
changed(field, 'person', { line: key });
|
|
1916
2265
|
},
|
|
1917
2266
|
async search(field, query, limit = 8, options = {}) {
|
|
1918
|
-
return runSearch(fieldDef(field), field, context(), query, limit, options
|
|
2267
|
+
return runSearch(fieldDef(field), field, context(), query, limit, options);
|
|
1919
2268
|
},
|
|
1920
2269
|
async previewLine(field, key, values) {
|
|
1921
2270
|
lineField(field);
|
|
@@ -1940,7 +2289,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1940
2289
|
const line = (state.values[field] ?? []).find((l) => l.key === key);
|
|
1941
2290
|
if (!line)
|
|
1942
2291
|
throw new Error(`"${field}" has no line "${key}"`);
|
|
1943
|
-
return runSearch(sub, subfield, lineContext(line.values, def.fields, context(), line.id ?? null), query, limit, options
|
|
2292
|
+
return runSearch(sub, subfield, lineContext(line.values, def.fields, context(), line.id ?? null), query, limit, options);
|
|
1944
2293
|
},
|
|
1945
2294
|
canCreate: (field) => creatable(page.fields[field]) !== null,
|
|
1946
2295
|
quickCreate: (field, typed) => runCreate(page.fields[field], field, typed, page.fields[field] ? createValuesOf(field) : {}),
|
|
@@ -2102,7 +2451,7 @@ function innerForm(options, within, place, outer) {
|
|
|
2102
2451
|
lines: { field: source.field, keys: [...keys], ids: lines.flatMap((line) => (line.id === undefined ? [] : [line.id])), values: lines.map((line) => structuredCopy(line.values)) },
|
|
2103
2452
|
});
|
|
2104
2453
|
},
|
|
2105
|
-
run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
|
|
2454
|
+
run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], typed: 'first', ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
|
|
2106
2455
|
shown(tab) {
|
|
2107
2456
|
// A wizard's steps are told by the form itself, as they are entered.
|
|
2108
2457
|
if (steps.includes(tab))
|
|
@@ -2129,20 +2478,36 @@ function innerForm(options, within, place, outer) {
|
|
|
2129
2478
|
},
|
|
2130
2479
|
async reloadTheirs() {
|
|
2131
2480
|
// A save on its way first: its answer is not dropped by the load.
|
|
2481
|
+
const asked = shownNow();
|
|
2132
2482
|
await savesSettled();
|
|
2133
|
-
|
|
2483
|
+
if (!asked())
|
|
2484
|
+
return false;
|
|
2485
|
+
// What is still typed is the person's too: read, a name looked up waited for, before theirs are loaded.
|
|
2486
|
+
const waiting = readTyped();
|
|
2487
|
+
if (waiting)
|
|
2488
|
+
await waiting;
|
|
2489
|
+
if (!asked())
|
|
2490
|
+
return false;
|
|
2491
|
+
// The person's changes, kept before theirs are loaded over them; and text they left that does not read.
|
|
2134
2492
|
const mine = {};
|
|
2135
2493
|
for (const name of state.dirty)
|
|
2136
2494
|
mine[name] = structuredCopy(state.values[name]);
|
|
2137
|
-
|
|
2138
|
-
|
|
2495
|
+
const typed = state.unread;
|
|
2496
|
+
// The record loaded again is a record shown anew: what is set aside is laid on it alone.
|
|
2497
|
+
const loading = load(true);
|
|
2498
|
+
const loaded = generation;
|
|
2499
|
+
await track(loading);
|
|
2500
|
+
if (loaded !== generation || state.status !== 'ready')
|
|
2139
2501
|
return false;
|
|
2140
2502
|
// Set aside where theirs differ from mine: a change they made the same way is no loss.
|
|
2141
2503
|
const aside = { ...(state.setAside ?? {}) };
|
|
2142
2504
|
for (const [name, value] of Object.entries(mine))
|
|
2143
2505
|
if (!sameValue(fields[name], value, state.values[name]))
|
|
2144
2506
|
aside[name] = value;
|
|
2145
|
-
set
|
|
2507
|
+
// Text that does not read is no value to set aside: it stays in its box, unread with why, over their value —
|
|
2508
|
+
// never dropped unsaid. A line of theirs no longer there takes its cells' text with it.
|
|
2509
|
+
const unread = Object.fromEntries(Object.entries(typed).filter(([path]) => valueAt(state.values, path) !== gone));
|
|
2510
|
+
set({ conflict: null, saveProblem: null, setAside: Object.keys(aside).length ? aside : null, ...(Object.keys(unread).length ? { unread } : {}) });
|
|
2146
2511
|
return true;
|
|
2147
2512
|
},
|
|
2148
2513
|
async keepMine() {
|
|
@@ -2153,6 +2518,7 @@ function innerForm(options, within, place, outer) {
|
|
|
2153
2518
|
const source = options.dataSource;
|
|
2154
2519
|
if (conflict && !told) {
|
|
2155
2520
|
if (source?.load && state.recordId != null && page.data.kind === 'record') {
|
|
2521
|
+
const shown = shownNow();
|
|
2156
2522
|
try {
|
|
2157
2523
|
const now = loadedRecordOf(await track(source.load({ model: page.data.model, id: state.recordId, fields })));
|
|
2158
2524
|
told = now.values;
|
|
@@ -2161,6 +2527,9 @@ function innerForm(options, within, place, outer) {
|
|
|
2161
2527
|
catch {
|
|
2162
2528
|
// Unread: said below.
|
|
2163
2529
|
}
|
|
2530
|
+
// Read, or not, once another record is shown or the values let go: nothing is laid, nothing saved.
|
|
2531
|
+
if (!shown())
|
|
2532
|
+
return false;
|
|
2164
2533
|
}
|
|
2165
2534
|
// Still unread: saving now would go over work this person never saw. Nothing is saved; they are told, and choose again.
|
|
2166
2535
|
if (!told) {
|
|
@@ -2223,7 +2592,8 @@ function innerForm(options, within, place, outer) {
|
|
|
2223
2592
|
const given = state.values[part.name];
|
|
2224
2593
|
// A record of no model of its own: its links search and its lists load, but it never loads, saves, sends or recalculates alone.
|
|
2225
2594
|
const made = innerForm({
|
|
2226
|
-
|
|
2595
|
+
// In the record's time zone, unless it names its own.
|
|
2596
|
+
page: { ...inner, data: { kind: 'responses' }, ...(inner.timeZone || !page.timeZone ? {} : { timeZone: page.timeZone }) },
|
|
2227
2597
|
dataSource: options.dataSource,
|
|
2228
2598
|
values: isRecord(given) ? given : {},
|
|
2229
2599
|
onAction: options.onAction,
|
|
@@ -2236,7 +2606,7 @@ function innerForm(options, within, place, outer) {
|
|
|
2236
2606
|
// Its files are the record's: stored for it, under the part's name.
|
|
2237
2607
|
{ model: where.model, recordId: where.recordId, under: `${where.under}${part.name}.` },
|
|
2238
2608
|
// Its ways out are the record's: through this form's check.
|
|
2239
|
-
{ letGo, close, leaveNow: (how) => atOnce(how) });
|
|
2609
|
+
{ letGo, close, leaveNow: (how) => atOnce(how), recordShown: shownNow });
|
|
2240
2610
|
attached.set(id, { name: part.name, inner: made });
|
|
2241
2611
|
// A change inside it is told as a change of its answers here, whoever made it.
|
|
2242
2612
|
made.form.on('change', ({ by }) => {
|
|
@@ -2480,6 +2850,12 @@ function indexLayout(root) {
|
|
|
2480
2850
|
else if (node.type === 'form') {
|
|
2481
2851
|
add(node, parent, 'form', { invisible: node.invisible, readonly: node.readonly });
|
|
2482
2852
|
}
|
|
2853
|
+
else if (node.type === 'related') {
|
|
2854
|
+
add(node, parent, 'other', { invisible: node.invisible });
|
|
2855
|
+
// A row's buttons: their roles here, hidden with the list, their condition read by it on each row's record.
|
|
2856
|
+
for (const button of node.rowButtons ?? [])
|
|
2857
|
+
add(button, node.id, 'button', {});
|
|
2858
|
+
}
|
|
2483
2859
|
else {
|
|
2484
2860
|
add(node, parent, 'other', { invisible: node.invisible });
|
|
2485
2861
|
}
|
|
@@ -2506,6 +2882,9 @@ function indexLayout(root) {
|
|
|
2506
2882
|
// A list holds no fields of its own record: only the buttons for what is selected.
|
|
2507
2883
|
for (const button of root.actions ?? [])
|
|
2508
2884
|
add(button, root.id, 'button', { invisible: button.invisible });
|
|
2885
|
+
// A row's buttons: their roles here, their condition read by the list on each row's record, which the form does not hold.
|
|
2886
|
+
for (const button of root.rowButtons ?? [])
|
|
2887
|
+
add(button, root.id, 'button', {});
|
|
2509
2888
|
}
|
|
2510
2889
|
else {
|
|
2511
2890
|
// The title and statusbar show fields too, so they are validated like
|