@fieldia/core 0.22.0 → 0.24.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 +135 -3
- package/package.json +1 -1
- package/schema/page.schema.json +159 -4
- package/src/index.d.mts +6 -2
- package/src/index.d.ts +6 -2
- package/src/index.js +18 -1
- package/src/index.js.map +1 -1
- package/src/index.mjs +8 -2
- package/src/index.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/field.d.mts +55 -34
- package/src/lib/format/field.d.ts +55 -34
- package/src/lib/format/field.js +16 -2
- package/src/lib/format/field.js.map +1 -1
- package/src/lib/format/field.mjs +15 -1
- package/src/lib/format/field.mjs.map +1 -1
- package/src/lib/format/layout.d.mts +365 -66
- package/src/lib/format/layout.d.ts +365 -66
- package/src/lib/format/layout.js +22 -4
- package/src/lib/format/layout.js.map +1 -1
- package/src/lib/format/layout.mjs +20 -3
- package/src/lib/format/layout.mjs.map +1 -1
- package/src/lib/format/page.d.mts +169 -29
- package/src/lib/format/page.d.ts +169 -29
- package/src/lib/format/page.js +11 -0
- package/src/lib/format/page.js.map +1 -1
- package/src/lib/format/page.mjs +12 -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 +10 -0
- package/src/lib/format/references.d.ts +10 -0
- package/src/lib/format/references.js +96 -1
- package/src/lib/format/references.js.map +1 -1
- package/src/lib/format/references.mjs +96 -1
- 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/upgrade.d.mts +4 -1
- package/src/lib/format/upgrade.d.ts +4 -1
- package/src/lib/format/upgrade.js +4 -1
- package/src/lib/format/upgrade.js.map +1 -1
- package/src/lib/format/upgrade.mjs +4 -1
- 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 +188 -3
- package/src/lib/record/data-source.d.ts +188 -3
- package/src/lib/record/data-source.js.map +1 -1
- package/src/lib/record/data-source.mjs.map +1 -1
- package/src/lib/record/form.d.mts +72 -25
- package/src/lib/record/form.d.ts +72 -25
- package/src/lib/record/form.js +404 -78
- package/src/lib/record/form.js.map +1 -1
- package/src/lib/record/form.mjs +404 -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 +13 -2
- package/src/lib/record/memory-data-source.d.ts +13 -2
- package/src/lib/record/memory-data-source.js +92 -35
- package/src/lib/record/memory-data-source.js.map +1 -1
- package/src/lib/record/memory-data-source.mjs +93 -36
- package/src/lib/record/memory-data-source.mjs.map +1 -1
- 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 +44 -8
- package/src/lib/record/run.d.ts +44 -8
- package/src/lib/record/run.js +155 -26
- package/src/lib/record/run.js.map +1 -1
- package/src/lib/record/run.mjs +156 -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/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 +12 -5
- package/src/runtime.d.ts +12 -5
- package/src/runtime.js +32 -2
- package/src/runtime.js.map +1 -1
- package/src/runtime.mjs +7 -1
- 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';
|
|
@@ -28,15 +29,23 @@ export function unsavedWork(state) {
|
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
30
31
|
* 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
|
-
*
|
|
32
|
+
* change of theirs the last save did not send, or text they left since that
|
|
33
|
+
* does not read ("12a" counts as "12" does: `unsent`), files still on
|
|
34
|
+
* their way, changes Reload set aside, the changes of a save refused for a
|
|
35
|
+
* conflict, waiting for Reload or Keep mine (only a source that keeps
|
|
36
|
+
* versions has one) — and, `how.changes`, any change not saved. What
|
|
37
|
+
* `form.leave()` — the one check every way out goes through — asks about,
|
|
38
|
+
* once the save on its way and the files on theirs settled. Before a
|
|
39
|
+
* record's first save, a change not saved is not at risk: as in 0.20, each
|
|
40
|
+
* way out decides.
|
|
37
41
|
*/
|
|
38
42
|
export function workAtRisk(state, how = {}) {
|
|
39
|
-
return (state.unsent ?? []).length > 0 ||
|
|
43
|
+
return ((state.unsent ?? []).length > 0 ||
|
|
44
|
+
state.setAside != null ||
|
|
45
|
+
(state.uploads ?? []).some(uploadGoing) ||
|
|
46
|
+
// The person asked for these to be saved, and someone else's save came first: still theirs, and not saved.
|
|
47
|
+
(state.conflict != null && state.dirty.length > 0) ||
|
|
48
|
+
(how.changes === true && (state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0)));
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
42
51
|
* The record state behind a page: values, what changed, what is visible and
|
|
@@ -48,6 +57,8 @@ export function createForm(options) {
|
|
|
48
57
|
const page = upgradePage(options.page);
|
|
49
58
|
return innerForm(page === options.page ? options : { ...options, page }, []).form;
|
|
50
59
|
}
|
|
60
|
+
/** Whether a run works a person's change out: a page's `change` steps, set off by it, or a run they set off. */
|
|
61
|
+
const worksOut = (chain) => chain.some((key) => key.startsWith('on.change.'));
|
|
51
62
|
/** A line's cell whose line is no longer there. */
|
|
52
63
|
const gone = Symbol('gone');
|
|
53
64
|
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -60,7 +71,8 @@ const asValue = (values) => values;
|
|
|
60
71
|
* its ways out go through that one's check.
|
|
61
72
|
*/
|
|
62
73
|
function innerForm(options, within, place, outer) {
|
|
63
|
-
|
|
74
|
+
// Its time zone written into each date and time field naming none, for whatever reads one from its field alone.
|
|
75
|
+
const page = zonedPage(options.page);
|
|
64
76
|
const scheduler = options.scheduler ?? hostScheduler;
|
|
65
77
|
const messages = options.messages ?? MESSAGES.en;
|
|
66
78
|
const index = indexLayout(page.layout);
|
|
@@ -124,7 +136,19 @@ function innerForm(options, within, place, outer) {
|
|
|
124
136
|
const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
|
|
125
137
|
const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
|
|
126
138
|
const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
|
|
127
|
-
|
|
139
|
+
/** Today, on the clock of the page's time zone (the browser's own when it names none). */
|
|
140
|
+
const today = () => dayIn(options.now?.() ?? new Date(), page.timeZone);
|
|
141
|
+
const clock = () => (options.now?.() ?? new Date()).getTime();
|
|
142
|
+
/** What a page's bot check saw — its hidden box filled, the captcha's token — and when the page was shown or started again. */
|
|
143
|
+
let bot = { honeypot: false, captcha: null, since: clock() };
|
|
144
|
+
/** What the bot check saw as the answers go; none without one. */
|
|
145
|
+
function botReport() {
|
|
146
|
+
const check = page.botCheck;
|
|
147
|
+
if (!check)
|
|
148
|
+
return undefined;
|
|
149
|
+
const taken = Math.max(0, clock() - bot.since) / 1000;
|
|
150
|
+
return { honeypot: check.honeypot !== false && bot.honeypot, tooFast: taken < (check.minSeconds ?? 3), seconds: Math.round(taken * 10) / 10, ...(check.captcha ? { captcha: bot.captcha } : {}) };
|
|
151
|
+
}
|
|
128
152
|
// 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
153
|
let started = false;
|
|
130
154
|
let editing = options.editing !== false;
|
|
@@ -286,16 +310,41 @@ function innerForm(options, within, place, outer) {
|
|
|
286
310
|
let generation = 0;
|
|
287
311
|
/** Closed (`dispose`): a run still going stops quietly, as its dialog or page is gone — no other record replaced it. */
|
|
288
312
|
let disposed = false;
|
|
289
|
-
/**
|
|
313
|
+
/** Counted at each reset (the person's Discard): a save on its way then lands as the record it stored. */
|
|
314
|
+
let resets = 0;
|
|
315
|
+
/**
|
|
316
|
+
* THE test every late answer passes (`recordShown`): taken as it is asked for, true while the record it was asked
|
|
317
|
+
* 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
|
|
318
|
+
* dialog, a record step's — none is laid on another record, nor over the values the person's Discard let go.
|
|
319
|
+
*/
|
|
320
|
+
function shownNow() {
|
|
321
|
+
const at = generation;
|
|
322
|
+
const reset = resets;
|
|
323
|
+
// A saved form placed in a record is the record's: another shown round it, its answers are laid nowhere either.
|
|
324
|
+
const round = outer?.recordShown();
|
|
325
|
+
return () => !disposed && at === generation && reset === resets && (!round || round());
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The person's claims on the record: the fields whose last write was theirs. A step's, a rule's or the app's
|
|
329
|
+
* write of the whole field takes one over; a write that works the person's change out (the source's onchange, the
|
|
330
|
+
* page's `change` steps it sets off) keeps it.
|
|
331
|
+
*/
|
|
290
332
|
const personal = new Set();
|
|
333
|
+
/**
|
|
334
|
+
* Of a table the person did not write whole: the keys of the lines they changed, added or took away. A line a
|
|
335
|
+
* step adds is none of theirs, and leaves theirs as they are.
|
|
336
|
+
*/
|
|
337
|
+
const personalLines = new Map();
|
|
291
338
|
/** Fields where a file the person picked landed from the store: at risk until a save sends it, a first save or not. */
|
|
292
339
|
const filed = new Set();
|
|
293
340
|
/** 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
341
|
let sentAs = null;
|
|
295
|
-
/**
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
342
|
+
/**
|
|
343
|
+
* The records being deleted, by the person's choice, each by its generation: a way out of one of them lets their
|
|
344
|
+
* work in it go, unasked. Another record shown meanwhile is asked about as any.
|
|
345
|
+
*/
|
|
346
|
+
const deleting = new Map();
|
|
347
|
+
const deletingShown = () => (deleting.get(generation) ?? 0) > 0;
|
|
299
348
|
/** Each field's last comparison with what was sent, so a value that did not move is not compared again. */
|
|
300
349
|
const compared = new Map();
|
|
301
350
|
/** Fields showing a file that did not reach the store: shown until it is sent again or taken away, whatever else changes. */
|
|
@@ -433,8 +482,9 @@ function innerForm(options, within, place, outer) {
|
|
|
433
482
|
const was = state.values;
|
|
434
483
|
const shown = state.errors;
|
|
435
484
|
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
|
-
|
|
485
|
+
// The person's changes the last save did not send, worked out again from the values as they are now, and from the
|
|
486
|
+
// text they left that does not read.
|
|
487
|
+
if (patch.unsent === undefined && (patch.values !== undefined || patch.unread !== undefined)) {
|
|
438
488
|
const unsent = unsentNow();
|
|
439
489
|
if (!sameList(unsent, state.unsent))
|
|
440
490
|
state = { ...state, unsent };
|
|
@@ -463,42 +513,66 @@ function innerForm(options, within, place, outer) {
|
|
|
463
513
|
for (const listener of [...heard])
|
|
464
514
|
listener(payload);
|
|
465
515
|
}
|
|
466
|
-
/**
|
|
467
|
-
|
|
468
|
-
|
|
516
|
+
/**
|
|
517
|
+
* A field written: told to the app, and — a person's change — the page's `change` steps for it run. `claim` says
|
|
518
|
+
* what of the person's it touches: `line`, the key of the one line of a table the person changed; `keep`, a write
|
|
519
|
+
* that leaves the person's claims as they are (a line a step adds, a write working their change out).
|
|
520
|
+
*/
|
|
521
|
+
function changed(field, by, claim = {}) {
|
|
522
|
+
noteOrigin(field, by, claim);
|
|
469
523
|
emit('change', { field, value: state.values[field], values: state.values, by });
|
|
470
524
|
const list = page.on?.change?.[field];
|
|
471
525
|
if (by === 'person' && list)
|
|
472
526
|
void track(moment(`on.change.${field}`, list, [], true));
|
|
473
527
|
}
|
|
474
|
-
/** Another record is shown: nothing of the last one's is the person's work any more. */
|
|
528
|
+
/** 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
529
|
function newGeneration() {
|
|
476
530
|
generation++;
|
|
477
531
|
personal.clear();
|
|
532
|
+
personalLines.clear();
|
|
478
533
|
filed.clear();
|
|
479
534
|
compared.clear();
|
|
480
535
|
sentAs = null;
|
|
536
|
+
clearFiles();
|
|
481
537
|
}
|
|
482
538
|
/** 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
539
|
const filedIn = (name) => filed.has(name) || [...attached.values()].some((part) => part.name === name && part.inner.files.filed());
|
|
484
540
|
/**
|
|
485
541
|
* The person's work no save took, by field, in the page's order: each of
|
|
486
542
|
* their changes that differs from the record as the last save left it —
|
|
487
|
-
*
|
|
543
|
+
* a field they wrote, or a line of a table they changed — and text they
|
|
544
|
+
* left in a box of it that does not read (`unread`), which no save can
|
|
545
|
+
* send; before a first save, only a file the store holds.
|
|
488
546
|
*/
|
|
489
547
|
function unsentNow() {
|
|
490
|
-
|
|
548
|
+
const typed = sentAs ? unreadFields() : null;
|
|
549
|
+
if (!personal.size && !personalLines.size && !typed?.size)
|
|
491
550
|
return [];
|
|
492
551
|
const out = [];
|
|
493
552
|
for (const name of Object.keys(fields)) {
|
|
494
|
-
|
|
553
|
+
// Typed and left unread since a save: the person's, as a change is — a value changed to "12" is, so is "12a".
|
|
554
|
+
if (typed?.has(name)) {
|
|
555
|
+
out.push(name);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const lines = personalLines.get(name);
|
|
559
|
+
if (!personal.has(name) && !lines)
|
|
495
560
|
continue;
|
|
496
561
|
const from = sentAs ?? (filedIn(name) ? baseline : null);
|
|
497
|
-
if (from
|
|
562
|
+
if (!from)
|
|
563
|
+
continue;
|
|
564
|
+
if (lines ? linesDiffer(name, lines, state.values[name], from[name]) : differs(name, state.values[name], from[name]))
|
|
498
565
|
out.push(name);
|
|
499
566
|
}
|
|
500
567
|
return out;
|
|
501
568
|
}
|
|
569
|
+
/** The fields with text left unread in a box of theirs: a field's, a line's cell's, a property's. */
|
|
570
|
+
function unreadFields() {
|
|
571
|
+
const out = new Set();
|
|
572
|
+
for (const path of Object.keys(state.unread ?? {}))
|
|
573
|
+
out.add(path.split('.')[0]);
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
502
576
|
function differs(name, value, sent) {
|
|
503
577
|
const known = compared.get(name);
|
|
504
578
|
if (known && known.value === value && known.sent === sent)
|
|
@@ -507,18 +581,53 @@ function innerForm(options, within, place, outer) {
|
|
|
507
581
|
compared.set(name, { value, sent, differs: verdict });
|
|
508
582
|
return verdict;
|
|
509
583
|
}
|
|
584
|
+
/** Whether any of these lines of a table differs from the table as sent: changed, added, or taken away. */
|
|
585
|
+
function linesDiffer(name, keys, value, sent) {
|
|
586
|
+
const def = fields[name];
|
|
587
|
+
const find = (lines, key) => (lines ?? []).find((line) => line.key === key);
|
|
588
|
+
for (const key of keys) {
|
|
589
|
+
const now = find(value, key);
|
|
590
|
+
const was = find(sent, key);
|
|
591
|
+
if (!now && !was)
|
|
592
|
+
continue;
|
|
593
|
+
if (!now || !was)
|
|
594
|
+
return true;
|
|
595
|
+
if (now.values === was.values)
|
|
596
|
+
continue;
|
|
597
|
+
for (const sub of new Set([...Object.keys(now.values), ...Object.keys(was.values)])) {
|
|
598
|
+
if (!sameValue(def.fields[sub], now.values[sub], was.values[sub]))
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
510
604
|
/** `unsent` as it is now, told when it changed. */
|
|
511
605
|
function refreshUnsent() {
|
|
512
606
|
const unsent = unsentNow();
|
|
513
607
|
if (!sameList(unsent, state.unsent))
|
|
514
608
|
set({ unsent });
|
|
515
609
|
}
|
|
516
|
-
/**
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
610
|
+
/**
|
|
611
|
+
* A field written, and by whom: the person's change is theirs — the field, or the line of a table they changed —
|
|
612
|
+
* until a step, a rule or the app writes the whole field in its place. A write that leaves their change where it
|
|
613
|
+
* is (`keep`: a line a step adds, a `change` step working out what they typed) leaves it theirs.
|
|
614
|
+
*/
|
|
615
|
+
function noteOrigin(field, by, claim) {
|
|
616
|
+
if (by === 'person') {
|
|
617
|
+
if (claim.line === undefined) {
|
|
618
|
+
personal.add(field);
|
|
619
|
+
personalLines.delete(field);
|
|
620
|
+
}
|
|
621
|
+
else if (!personal.has(field)) {
|
|
622
|
+
const keys = personalLines.get(field) ?? new Set();
|
|
623
|
+
keys.add(claim.line);
|
|
624
|
+
personalLines.set(field, keys);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
else if (!claim.keep) {
|
|
521
628
|
personal.delete(field);
|
|
629
|
+
personalLines.delete(field);
|
|
630
|
+
}
|
|
522
631
|
refreshUnsent();
|
|
523
632
|
}
|
|
524
633
|
/** A wizard step entered: told to the app, and its `show` steps run. */
|
|
@@ -688,10 +797,12 @@ function innerForm(options, within, place, outer) {
|
|
|
688
797
|
const ticks = node.source.widget === 'tick' && page.fields[name].type === 'boolean';
|
|
689
798
|
return ticks && state.values[name] !== true && nodeState(node.id).required ? messages.tick : undefined;
|
|
690
799
|
}
|
|
800
|
+
/** The widget a field is shown with where it keeps its value its own way (a per cent): the first of its nodes'. */
|
|
801
|
+
const shownWidget = (shown) => shown.map((node) => node.source.widget).find((widget) => widget === 'percentage');
|
|
691
802
|
/** A field node's error now: its field's own rules first, then its answer rules. */
|
|
692
803
|
function nodeError(node) {
|
|
693
804
|
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;
|
|
805
|
+
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
806
|
}
|
|
696
807
|
/** Warnings for the visible fields of the page, without publishing them. */
|
|
697
808
|
function collectWarnings() {
|
|
@@ -775,7 +886,7 @@ function innerForm(options, within, place, outer) {
|
|
|
775
886
|
const cell = ruled[sub];
|
|
776
887
|
if (cell?.invisible || (tables.has(node.id) && tables.get(node.id).compiled.hidden(sub, reading().context, reading().env, userRoles)))
|
|
777
888
|
continue;
|
|
778
|
-
const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today());
|
|
889
|
+
const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today(), node.source.cells?.[sub]?.widget);
|
|
779
890
|
if (lineMessage)
|
|
780
891
|
errors[`${name}.${line.key}.${sub}`] = lineMessage;
|
|
781
892
|
}
|
|
@@ -840,7 +951,7 @@ function innerForm(options, within, place, outer) {
|
|
|
840
951
|
if (isEmpty(fieldDef(field), value))
|
|
841
952
|
return undefined;
|
|
842
953
|
return (shown.map((node) => checkInput(page.fields[field], node.source, value, messages)).find(Boolean) ??
|
|
843
|
-
besidesRows(field, checkValue(checked(field), value, false, messages, today())) ??
|
|
954
|
+
besidesRows(field, checkValue(checked(field), value, false, messages, today(), shownWidget(shown))) ??
|
|
844
955
|
shown.map((node) => ruleCheck(node).error).find(Boolean));
|
|
845
956
|
}
|
|
846
957
|
if (parts.length === 2 && page.fields[field]?.type === 'properties')
|
|
@@ -1026,8 +1137,10 @@ function innerForm(options, within, place, outer) {
|
|
|
1026
1137
|
return;
|
|
1027
1138
|
const seq = ++onchangeSeq;
|
|
1028
1139
|
const request = { model: page.data.model, id: state.recordId, changed, values: structuredCopy(state.values) };
|
|
1140
|
+
const shown = shownNow();
|
|
1029
1141
|
void track(source.onchange(request).then((result) => {
|
|
1030
|
-
|
|
1142
|
+
// A newer change asked since; or the record it was asked for is gone, or its values let go: none of it is laid.
|
|
1143
|
+
if (seq !== onchangeSeq || !shown())
|
|
1031
1144
|
return;
|
|
1032
1145
|
const values = { ...state.values, ...(result.values ?? {}) };
|
|
1033
1146
|
writeValues(values, { warning: result.warning ?? null, warningField: result.warning ? changed : null });
|
|
@@ -1128,18 +1241,27 @@ function innerForm(options, within, place, outer) {
|
|
|
1128
1241
|
set({ status: 'error', error: problem.message, saveProblem: problem, conflict: null });
|
|
1129
1242
|
return { reason: 'save', message: problem.message };
|
|
1130
1243
|
}
|
|
1244
|
+
// Asked for the record shown now: another shown while it waits below, it saves nothing of that one, nor says anything there.
|
|
1245
|
+
const asked = generation;
|
|
1246
|
+
const elsewhere = () => ({ reason: 'save', message: messages.recordReplaced });
|
|
1131
1247
|
// 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
1248
|
// up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
|
|
1133
1249
|
const waiting = readTyped();
|
|
1134
1250
|
if (waiting)
|
|
1135
1251
|
await waiting;
|
|
1252
|
+
if (asked !== generation)
|
|
1253
|
+
return elsewhere();
|
|
1136
1254
|
// 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
1255
|
if (filesGoing()) {
|
|
1138
1256
|
set({ status: 'saving', error: null, saveProblem: null });
|
|
1139
1257
|
await filesSettled();
|
|
1258
|
+
if (asked !== generation)
|
|
1259
|
+
return elsewhere();
|
|
1140
1260
|
set({ status: 'ready' });
|
|
1141
1261
|
}
|
|
1142
1262
|
if (page.on?.beforeSave) {
|
|
1263
|
+
// Its run stops of itself on a record shown by anything but its own steps (a `call`'s `{ record }`, a reload):
|
|
1264
|
+
// those move the save on with it.
|
|
1143
1265
|
const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
|
|
1144
1266
|
if (!before.done) {
|
|
1145
1267
|
// A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
|
|
@@ -1164,6 +1286,8 @@ function innerForm(options, within, place, outer) {
|
|
|
1164
1286
|
try {
|
|
1165
1287
|
// 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
1288
|
await storeBytes(page.data.kind === 'responses' || state.recordId == null ? Object.keys(fields) : state.dirty);
|
|
1289
|
+
if (at !== generation)
|
|
1290
|
+
return elsewhere();
|
|
1167
1291
|
const failed = fileProblems();
|
|
1168
1292
|
if (Object.keys(failed).length) {
|
|
1169
1293
|
set({ errors: failed, ...unstored(failed) });
|
|
@@ -1175,7 +1299,11 @@ function innerForm(options, within, place, outer) {
|
|
|
1175
1299
|
const sent = shownValues();
|
|
1176
1300
|
const before = state.values;
|
|
1177
1301
|
at = generation;
|
|
1178
|
-
|
|
1302
|
+
const seen = botReport();
|
|
1303
|
+
await source.submit({ pageId: page.id, values: sent, ...(seen ? { bot: seen } : {}) });
|
|
1304
|
+
// The captcha's token is spent once sent: the next send waits for a new one.
|
|
1305
|
+
if (seen?.captcha)
|
|
1306
|
+
bot.captcha = null;
|
|
1179
1307
|
// Answered for answers no longer shown — the form was started again meanwhile: the source has them, the form lays nothing.
|
|
1180
1308
|
if (at !== generation)
|
|
1181
1309
|
return null;
|
|
@@ -1183,7 +1311,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1183
1311
|
// What it sent is what an answer changed since is told against: still to send.
|
|
1184
1312
|
sentAs = before;
|
|
1185
1313
|
set({ status: 'saved', unsent: unsentNow() });
|
|
1186
|
-
emit('send', { values: sent });
|
|
1314
|
+
emit('send', { values: sent, ...(seen ? { bot: seen } : {}) });
|
|
1187
1315
|
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
1188
1316
|
return null;
|
|
1189
1317
|
}
|
|
@@ -1219,7 +1347,8 @@ function innerForm(options, within, place, outer) {
|
|
|
1219
1347
|
scheduleDraft();
|
|
1220
1348
|
scheduleAutosave();
|
|
1221
1349
|
}
|
|
1222
|
-
|
|
1350
|
+
// The record as this save left it: a change made while it was on its way is no value of it.
|
|
1351
|
+
emit('save', { recordId: state.recordId, values: baseline });
|
|
1223
1352
|
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
1224
1353
|
return null;
|
|
1225
1354
|
}
|
|
@@ -1275,8 +1404,12 @@ function innerForm(options, within, place, outer) {
|
|
|
1275
1404
|
}
|
|
1276
1405
|
/** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
|
|
1277
1406
|
async function opening() {
|
|
1407
|
+
const at = generation;
|
|
1278
1408
|
emit('open', { recordId: state.recordId, values: state.values });
|
|
1279
1409
|
await moment('on.open', page.on?.open, [], true);
|
|
1410
|
+
// Another record shown while its open steps ran: that one's opening tells its own steps.
|
|
1411
|
+
if (at !== generation)
|
|
1412
|
+
return;
|
|
1280
1413
|
showing = true;
|
|
1281
1414
|
tellStep([]);
|
|
1282
1415
|
}
|
|
@@ -1297,8 +1430,8 @@ function innerForm(options, within, place, outer) {
|
|
|
1297
1430
|
for (const name of names)
|
|
1298
1431
|
changed(name, 'app');
|
|
1299
1432
|
}
|
|
1300
|
-
/** Write fields — a step's or the app's — worked out once, and tell each. */
|
|
1301
|
-
function write(written, by) {
|
|
1433
|
+
/** Write fields — a step's or the app's — worked out once, and tell each; `keep`, a write working the person's change out. */
|
|
1434
|
+
function write(written, by, keep = false) {
|
|
1302
1435
|
const names = Object.keys(written);
|
|
1303
1436
|
names.forEach(fieldDef);
|
|
1304
1437
|
if (!names.length)
|
|
@@ -1307,8 +1440,9 @@ function innerForm(options, within, place, outer) {
|
|
|
1307
1440
|
for (const name of names)
|
|
1308
1441
|
afterEdit(name);
|
|
1309
1442
|
for (const name of names)
|
|
1310
|
-
changed(name, by);
|
|
1443
|
+
changed(name, by, { keep });
|
|
1311
1444
|
}
|
|
1445
|
+
/** A line added: the person's, theirs; a step's, none of theirs, and the lines they changed stay theirs. */
|
|
1312
1446
|
function addLine(field, values, by) {
|
|
1313
1447
|
const def = lineField(field);
|
|
1314
1448
|
const current = state.values[field] ?? [];
|
|
@@ -1331,7 +1465,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1331
1465
|
const lines = [...current, line];
|
|
1332
1466
|
writeValues({ ...state.values, [field]: lines });
|
|
1333
1467
|
afterEdit(field);
|
|
1334
|
-
changed(field, by);
|
|
1468
|
+
changed(field, by, by === 'person' ? { line: key } : { keep: true });
|
|
1335
1469
|
return key;
|
|
1336
1470
|
}
|
|
1337
1471
|
/**
|
|
@@ -1340,15 +1474,44 @@ function innerForm(options, within, place, outer) {
|
|
|
1340
1474
|
*/
|
|
1341
1475
|
async function check(names) {
|
|
1342
1476
|
names?.forEach(fieldDef);
|
|
1477
|
+
const at = generation;
|
|
1343
1478
|
const waiting = readTyped();
|
|
1344
1479
|
if (waiting)
|
|
1345
1480
|
await waiting;
|
|
1481
|
+
// Another record shown while it waited: nothing found is shown on that one.
|
|
1482
|
+
if (at !== generation)
|
|
1483
|
+
return replaced();
|
|
1346
1484
|
const all = collectErrors();
|
|
1347
1485
|
const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
|
|
1348
1486
|
set({ errors: found });
|
|
1349
1487
|
const first = Object.values(found)[0];
|
|
1350
1488
|
return first === undefined ? null : { reason: 'check', message: first };
|
|
1351
1489
|
}
|
|
1490
|
+
/**
|
|
1491
|
+
* What is typed read first, for a run the person or the app set off (`RunScope.typed`): every box read as a save
|
|
1492
|
+
* reads it, a name looked up waited for. Text left that does not read stops the run, said under its box as a check
|
|
1493
|
+
* says it — never a value under it handed on or worked from.
|
|
1494
|
+
*/
|
|
1495
|
+
async function typedFirst() {
|
|
1496
|
+
const at = generation;
|
|
1497
|
+
const waiting = readTyped();
|
|
1498
|
+
if (waiting)
|
|
1499
|
+
await waiting;
|
|
1500
|
+
// Another record shown while it waited: the run is not for that one.
|
|
1501
|
+
if (at !== generation)
|
|
1502
|
+
return replaced();
|
|
1503
|
+
const found = unreadProblems();
|
|
1504
|
+
const first = Object.values(found)[0];
|
|
1505
|
+
if (first === undefined)
|
|
1506
|
+
return null;
|
|
1507
|
+
set({ errors: { ...state.errors, ...found } });
|
|
1508
|
+
return { reason: 'check', message: first };
|
|
1509
|
+
}
|
|
1510
|
+
/** The problems of text left unread in boxes shown — this form's and its saved forms' placed here — by path. */
|
|
1511
|
+
function unreadProblems() {
|
|
1512
|
+
const inner = (path) => [...attached.values()].some((part) => path.startsWith(`${part.name}.`) && !!part.inner.form.getState().unread?.[path.slice(part.name.length + 1)]);
|
|
1513
|
+
return Object.fromEntries(Object.entries(collectErrors()).filter(([path]) => !!state.unread[path] || inner(path)));
|
|
1514
|
+
}
|
|
1352
1515
|
/** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
|
|
1353
1516
|
async function goToStep(target, chain) {
|
|
1354
1517
|
if (!steps.includes(target))
|
|
@@ -1356,16 +1519,19 @@ function innerForm(options, within, place, outer) {
|
|
|
1356
1519
|
if (!form.steps().includes(target))
|
|
1357
1520
|
return { reason: 'cannot', message: `The step "${target}" does not apply now` };
|
|
1358
1521
|
// A name still being looked up in a box on the way is waited for, as a save waits for it.
|
|
1522
|
+
const at = generation;
|
|
1359
1523
|
const waiting = readTyped();
|
|
1360
1524
|
if (waiting)
|
|
1361
1525
|
await waiting;
|
|
1526
|
+
if (at !== generation)
|
|
1527
|
+
return replaced();
|
|
1362
1528
|
let went = false;
|
|
1363
1529
|
inRun(chain, () => (went = form.goTo(target)));
|
|
1364
1530
|
return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
|
|
1365
1531
|
}
|
|
1366
1532
|
/** 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
1533
|
async function runTop(list, scope) {
|
|
1368
|
-
scope.generation
|
|
1534
|
+
scope.generation ??= generation;
|
|
1369
1535
|
const result = await runner.runSteps(list, scope);
|
|
1370
1536
|
emit('run', { id: scope.id, steps: list, result });
|
|
1371
1537
|
return result;
|
|
@@ -1383,7 +1549,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1383
1549
|
throw new Error(`"${name}" cannot make records: its data source has no create, or it is not a link`);
|
|
1384
1550
|
return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
|
|
1385
1551
|
}
|
|
1386
|
-
async function runSearch(def, name, ctx, query, limit, model, ids) {
|
|
1552
|
+
async function runSearch(def, name, ctx, query, limit, { model, ids, exact } = {}) {
|
|
1387
1553
|
// Formatted text names people and records in place (@mention, #4127): searched by the model they are of.
|
|
1388
1554
|
const byModel = def.type === 'reference' || def.type === 'json' || def.type === 'html';
|
|
1389
1555
|
if (def.type !== 'many2one' && def.type !== 'many2many' && !byModel) {
|
|
@@ -1398,7 +1564,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1398
1564
|
const own = byModel || !def.filter ? undefined : resolveFilter(def.filter, ctx);
|
|
1399
1565
|
const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
|
|
1400
1566
|
const relation = byModel ? model : def.relation;
|
|
1401
|
-
return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
|
|
1567
|
+
return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit, ...(exact ? { exact: true } : {}) }));
|
|
1402
1568
|
}
|
|
1403
1569
|
function lineField(field) {
|
|
1404
1570
|
const def = fieldDef(field);
|
|
@@ -1499,7 +1665,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1499
1665
|
}
|
|
1500
1666
|
}
|
|
1501
1667
|
/** 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) => !
|
|
1668
|
+
const atOnce = (how, own = false) => !deletingShown() && !checking && !busy(own) && (how.discard === true || !workAtRisk(state, how));
|
|
1503
1669
|
/** The saves on their way, settled: what a load that is not a way out (a conflict's Reload) waits for. */
|
|
1504
1670
|
async function savesSettled() {
|
|
1505
1671
|
while (saveRunning)
|
|
@@ -1525,10 +1691,13 @@ function innerForm(options, within, place, outer) {
|
|
|
1525
1691
|
if (outer)
|
|
1526
1692
|
return outer.letGo(how, chain);
|
|
1527
1693
|
// A delete the person chose: their work goes with the record, unasked.
|
|
1528
|
-
if (
|
|
1694
|
+
if (deletingShown()) {
|
|
1529
1695
|
forget();
|
|
1530
1696
|
return true;
|
|
1531
1697
|
}
|
|
1698
|
+
// The person's own Discard stops the files on their way, never waits for them.
|
|
1699
|
+
if (how.discard)
|
|
1700
|
+
clearFiles();
|
|
1532
1701
|
const own = chain.includes('on.beforeSave');
|
|
1533
1702
|
if (atOnce(how, own)) {
|
|
1534
1703
|
if (how.discard)
|
|
@@ -1571,13 +1740,46 @@ function innerForm(options, within, place, outer) {
|
|
|
1571
1740
|
forget();
|
|
1572
1741
|
return yes;
|
|
1573
1742
|
}
|
|
1574
|
-
/**
|
|
1743
|
+
/**
|
|
1744
|
+
* Let go of the person's work at risk: files on their way stop, changes set aside go, a conflict's changes are
|
|
1745
|
+
* asked about no more, and the changes no save took go back to the record as the last save left it — so what a
|
|
1746
|
+
* dialog or a page in place hands back once it saved is the record as saved, never the change let go.
|
|
1747
|
+
*/
|
|
1575
1748
|
function forget() {
|
|
1749
|
+
const back = unsentLetGo();
|
|
1576
1750
|
clearFiles();
|
|
1577
1751
|
personal.clear();
|
|
1752
|
+
personalLines.clear();
|
|
1578
1753
|
filed.clear();
|
|
1579
|
-
if (
|
|
1580
|
-
|
|
1754
|
+
if (back)
|
|
1755
|
+
writeValues({ ...state.values, ...back });
|
|
1756
|
+
// Text left that does not read goes too: each box shows what its field holds.
|
|
1757
|
+
const unread = Object.keys(state.unread).length ? { unread: {} } : {};
|
|
1758
|
+
if (state.setAside !== null || state.unsent.length || state.conflict !== null || unread.unread)
|
|
1759
|
+
set({ setAside: null, unsent: [], conflict: null, ...unread });
|
|
1760
|
+
}
|
|
1761
|
+
/** The fields holding the person's changes no save took, as the record as last saved holds them: their lines alone, for a table. */
|
|
1762
|
+
function unsentLetGo() {
|
|
1763
|
+
if (!state.unsent.length)
|
|
1764
|
+
return null;
|
|
1765
|
+
const out = {};
|
|
1766
|
+
for (const name of state.unsent) {
|
|
1767
|
+
const from = sentAs ?? baseline;
|
|
1768
|
+
const keys = personalLines.get(name);
|
|
1769
|
+
out[name] = keys && !personal.has(name) ? linesAsSent(state.values[name], from[name], keys) : structuredCopy(from[name]);
|
|
1770
|
+
}
|
|
1771
|
+
return out;
|
|
1772
|
+
}
|
|
1773
|
+
/** A table with these lines as sent: changed ones as they were, added ones gone, ones taken away back in their place. */
|
|
1774
|
+
function linesAsSent(value, sent, keys) {
|
|
1775
|
+
const was = sent ?? [];
|
|
1776
|
+
const of = (key) => was.find((line) => line.key === key);
|
|
1777
|
+
const out = (value ?? []).filter((line) => !keys.has(line.key) || of(line.key)).map((line) => (keys.has(line.key) ? structuredCopy(of(line.key)) : line));
|
|
1778
|
+
was.forEach((line, at) => {
|
|
1779
|
+
if (keys.has(line.key) && !out.some((other) => other.key === line.key))
|
|
1780
|
+
out.splice(Math.min(at, out.length), 0, structuredCopy(line));
|
|
1781
|
+
});
|
|
1782
|
+
return out;
|
|
1581
1783
|
}
|
|
1582
1784
|
/** The check as a step meets it: null to go on — at once when nothing is at risk — else why the run stops. */
|
|
1583
1785
|
function letGoStep(chain) {
|
|
@@ -1585,12 +1787,19 @@ function innerForm(options, within, place, outer) {
|
|
|
1585
1787
|
return go === true ? null : go.then((yes) => (yes ? null : kept()));
|
|
1586
1788
|
}
|
|
1587
1789
|
const kept = () => ({ reason: 'no', message: messages.discardChanges });
|
|
1588
|
-
/**
|
|
1589
|
-
|
|
1790
|
+
/**
|
|
1791
|
+
* A step's own move — the record loaded again, or another shown — once the check let what is shown go: kept, the
|
|
1792
|
+
* person kept their work; replaced, another record was shown before it ended (the run's steps are not for that one).
|
|
1793
|
+
*/
|
|
1794
|
+
async function ownMove(chain, move) {
|
|
1590
1795
|
const go = letGo({}, chain);
|
|
1591
|
-
if (go
|
|
1592
|
-
return
|
|
1593
|
-
|
|
1796
|
+
if (go !== true && !(await go))
|
|
1797
|
+
return kept();
|
|
1798
|
+
// The record it shows is a generation of its own, made as it starts: another made before it ends replaced it.
|
|
1799
|
+
const moving = move();
|
|
1800
|
+
const mine = generation;
|
|
1801
|
+
await moving;
|
|
1802
|
+
return mine === generation ? null : replaced();
|
|
1594
1803
|
}
|
|
1595
1804
|
/**
|
|
1596
1805
|
* The host's close, as every `close` step reaches it: the form takes it
|
|
@@ -1612,6 +1821,10 @@ function innerForm(options, within, place, outer) {
|
|
|
1612
1821
|
const go = letGo({}, chain);
|
|
1613
1822
|
return go === true ? shut() : go.then((yes) => (yes ? shut() : kept()));
|
|
1614
1823
|
}
|
|
1824
|
+
/** Why a run stops whose record was replaced while it went on — quietly, when the form was closed. */
|
|
1825
|
+
function replaced() {
|
|
1826
|
+
return disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced };
|
|
1827
|
+
}
|
|
1615
1828
|
/** The record a record step asks the data source about. */
|
|
1616
1829
|
const record = () => ({ model: page.data.model, id: state.recordId, fields });
|
|
1617
1830
|
async function post(message) {
|
|
@@ -1634,18 +1847,30 @@ function innerForm(options, within, place, outer) {
|
|
|
1634
1847
|
recordId: () => state.recordId,
|
|
1635
1848
|
reading,
|
|
1636
1849
|
today,
|
|
1637
|
-
|
|
1850
|
+
// A step's write of a field takes the person's change in it over — unless the step works that change out: the
|
|
1851
|
+
// page's `change` steps, set off by it, as the source's onchange does.
|
|
1852
|
+
write: (values, chain) => write(values, 'step', worksOut(chain)),
|
|
1638
1853
|
store: (values) => store(values),
|
|
1854
|
+
// The person's own change: the field's, or a line of it (a claim by line): put back, the whole field is theirs
|
|
1855
|
+
// again — at risk, asked about — never less than it was.
|
|
1856
|
+
mine: (field) => personal.has(field) || personalLines.has(field),
|
|
1857
|
+
putBack(values, mine) {
|
|
1858
|
+
write(values, 'step');
|
|
1859
|
+
for (const name of mine)
|
|
1860
|
+
noteOrigin(name, 'person', {});
|
|
1861
|
+
},
|
|
1639
1862
|
addLine: (field, values) => void addLine(field, values, 'step'),
|
|
1640
1863
|
check,
|
|
1864
|
+
typedFirst,
|
|
1641
1865
|
save: saving,
|
|
1642
1866
|
generation: () => generation,
|
|
1643
|
-
gone:
|
|
1867
|
+
gone: replaced,
|
|
1644
1868
|
async reload(chain) {
|
|
1645
1869
|
if (state.recordId == null)
|
|
1646
1870
|
return { reason: 'cannot', message: 'The record is not saved yet: there is nothing to load again' };
|
|
1647
|
-
|
|
1648
|
-
|
|
1871
|
+
const stopped = await ownMove(chain, () => load(true));
|
|
1872
|
+
if (stopped)
|
|
1873
|
+
return stopped;
|
|
1649
1874
|
return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
|
|
1650
1875
|
},
|
|
1651
1876
|
reset(chain) {
|
|
@@ -1666,24 +1891,32 @@ function innerForm(options, within, place, outer) {
|
|
|
1666
1891
|
changed: () => state.dirty.length > 0 || state.uploads.some(uploadGoing),
|
|
1667
1892
|
letGo: letGoStep,
|
|
1668
1893
|
async deleting(remove, done) {
|
|
1669
|
-
|
|
1894
|
+
const of = generation;
|
|
1895
|
+
const shown = shownNow();
|
|
1896
|
+
deleting.set(of, (deleting.get(of) ?? 0) + 1);
|
|
1670
1897
|
try {
|
|
1671
1898
|
const out = await remove();
|
|
1672
|
-
// Deleted: what the person had not saved in it goes with it. Refused, it stays theirs.
|
|
1673
|
-
|
|
1899
|
+
// Deleted: what the person had not saved in it goes with it. Refused, it stays theirs. Another record shown
|
|
1900
|
+
// meanwhile keeps what was typed in it.
|
|
1901
|
+
if (done(out) && shown())
|
|
1674
1902
|
forget();
|
|
1675
1903
|
return out;
|
|
1676
1904
|
}
|
|
1677
1905
|
finally {
|
|
1678
|
-
|
|
1906
|
+
const left = (deleting.get(of) ?? 1) - 1;
|
|
1907
|
+
if (left)
|
|
1908
|
+
deleting.set(of, left);
|
|
1909
|
+
else
|
|
1910
|
+
deleting.delete(of);
|
|
1679
1911
|
}
|
|
1680
1912
|
},
|
|
1681
1913
|
...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
|
|
1682
1914
|
...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
|
|
1683
1915
|
...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
|
|
1684
1916
|
async showRecord(id, chain) {
|
|
1685
|
-
|
|
1686
|
-
|
|
1917
|
+
const stopped = await ownMove(chain, () => showNow(id));
|
|
1918
|
+
if (stopped)
|
|
1919
|
+
return stopped;
|
|
1687
1920
|
return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
|
|
1688
1921
|
},
|
|
1689
1922
|
told(event, id) {
|
|
@@ -1702,8 +1935,24 @@ function innerForm(options, within, place, outer) {
|
|
|
1702
1935
|
wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
|
|
1703
1936
|
barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
|
|
1704
1937
|
});
|
|
1705
|
-
|
|
1706
|
-
|
|
1938
|
+
// 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
|
|
1939
|
+
// go when another record is shown by then (its steps were for that one).
|
|
1940
|
+
const moment = createMoments((key, list, chain, on) => runTop(list, { id: key, chain, generation: on }), () => generation);
|
|
1941
|
+
/**
|
|
1942
|
+
* Whether steps do something before their first save — set a status, call the app, open a page — so a record's
|
|
1943
|
+
* button made of them saves first. Steps that start with the save (Save and close), or only check before it, need
|
|
1944
|
+
* no save before them: theirs is the first.
|
|
1945
|
+
*/
|
|
1946
|
+
function actsBeforeSaving(list) {
|
|
1947
|
+
const first = list.findIndex((step) => step.do === 'save');
|
|
1948
|
+
return first > 0 && list.slice(0, first).some((step) => step.do !== 'check');
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. A
|
|
1952
|
+
* record's button whose steps act and then save saves the record first when it is new or changed, as Flectra's
|
|
1953
|
+
* button does: refused — a field it requires left empty — none of its steps run, so nothing it does happens to a
|
|
1954
|
+
* record that cannot be saved.
|
|
1955
|
+
*/
|
|
1707
1956
|
function press(source, scope) {
|
|
1708
1957
|
const list = [...(source.steps ?? [])];
|
|
1709
1958
|
if (source.action !== undefined)
|
|
@@ -1714,12 +1963,37 @@ function innerForm(options, within, place, outer) {
|
|
|
1714
1963
|
list.push({ do: builtin });
|
|
1715
1964
|
const asked = builtin === 'archive' ? messages.archiveConfirm : builtin === 'delete' ? messages.deleteConfirm : undefined;
|
|
1716
1965
|
const confirm = 'confirm' in source && source.confirm !== undefined ? source.confirm : asked;
|
|
1966
|
+
// On the record it was pressed on: another shown while its question is asked, it does nothing to that one.
|
|
1967
|
+
scope.generation ??= generation;
|
|
1968
|
+
// Pressed by the person: what is typed is read before a step reads the form or hands its values on.
|
|
1969
|
+
scope.typed ??= 'first';
|
|
1717
1970
|
return track((async () => {
|
|
1718
1971
|
if (confirm && !(await runner.ask(confirm))) {
|
|
1719
1972
|
const result = { done: false, reason: 'no', message: confirm };
|
|
1720
1973
|
emit('run', { id: scope.id, steps: list, result });
|
|
1721
1974
|
return result;
|
|
1722
1975
|
}
|
|
1976
|
+
const saveFirst = page.data.kind === 'record' && actsBeforeSaving(list);
|
|
1977
|
+
// What is typed is read first, as the run reads it before its first step that reads the form (run.ts): text
|
|
1978
|
+
// that does not read stops it with `check`, said under its box — before the save that comes first, and any step.
|
|
1979
|
+
if (saveFirst && scope.typed === 'first') {
|
|
1980
|
+
const unread = await typedFirst();
|
|
1981
|
+
if (unread) {
|
|
1982
|
+
const at = Math.max(0, list.findIndex((step) => step.do !== 'check'));
|
|
1983
|
+
const result = { done: false, stoppedAt: at, step: list[at], reason: unread.reason, ...(unread.message !== undefined ? { message: unread.message } : {}) };
|
|
1984
|
+
emit('run', { id: scope.id, steps: list, result });
|
|
1985
|
+
return result;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
// Then the save first, then the steps.
|
|
1989
|
+
if (saveFirst && (state.recordId == null || state.dirty.length > 0 || Object.keys(state.unread ?? {}).length > 0 || state.uploads.some(uploadGoing))) {
|
|
1990
|
+
const first = await saving(scope.chain);
|
|
1991
|
+
if (first) {
|
|
1992
|
+
const result = { done: false, step: { do: 'save' }, reason: first.reason, ...(first.message !== undefined ? { message: first.message } : {}), ...(first.error !== undefined ? { error: first.error } : {}) };
|
|
1993
|
+
emit('run', { id: scope.id, steps: list, result });
|
|
1994
|
+
return result;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1723
1997
|
return runTop(list, scope);
|
|
1724
1998
|
})());
|
|
1725
1999
|
}
|
|
@@ -1745,6 +2019,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1745
2019
|
openRecord: (id) => track(openRecord(id)),
|
|
1746
2020
|
leave: (how) => Promise.resolve(letGo(how)),
|
|
1747
2021
|
leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
|
|
2022
|
+
recordShown: shownNow,
|
|
1748
2023
|
post,
|
|
1749
2024
|
setEditing(next) {
|
|
1750
2025
|
if (next === editing)
|
|
@@ -1773,7 +2048,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1773
2048
|
}
|
|
1774
2049
|
const own = shown.map(unticked).find(Boolean) ??
|
|
1775
2050
|
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());
|
|
2051
|
+
checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today(), shownWidget(shown));
|
|
1777
2052
|
return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
|
|
1778
2053
|
},
|
|
1779
2054
|
setUnread(path, unread) {
|
|
@@ -1819,6 +2094,12 @@ function innerForm(options, within, place, outer) {
|
|
|
1819
2094
|
};
|
|
1820
2095
|
},
|
|
1821
2096
|
settle: () => readTyped() ?? Promise.resolve(),
|
|
2097
|
+
botCheck(seen) {
|
|
2098
|
+
if (seen.honeypot !== undefined)
|
|
2099
|
+
bot.honeypot = seen.honeypot;
|
|
2100
|
+
if (seen.captcha !== undefined)
|
|
2101
|
+
bot.captcha = seen.captcha;
|
|
2102
|
+
},
|
|
1822
2103
|
reading: () => readingNow > 0 || [...attached.values()].some((part) => part.inner.form.reading()),
|
|
1823
2104
|
validate() {
|
|
1824
2105
|
readTyped();
|
|
@@ -1828,6 +2109,27 @@ function innerForm(options, within, place, outer) {
|
|
|
1828
2109
|
},
|
|
1829
2110
|
save: () => track(save()),
|
|
1830
2111
|
canUpload: () => typeof options.dataSource?.upload === 'function',
|
|
2112
|
+
today,
|
|
2113
|
+
canFindSlots: () => typeof options.dataSource?.slots === 'function',
|
|
2114
|
+
findSlots(field, ask) {
|
|
2115
|
+
const source = options.dataSource;
|
|
2116
|
+
if (!source?.slots)
|
|
2117
|
+
return Promise.reject(new Error('The data source has no slots()'));
|
|
2118
|
+
const def = page.fields[field];
|
|
2119
|
+
const { schedule = field, length, between, from, to } = ask;
|
|
2120
|
+
return source.slots({
|
|
2121
|
+
model: page.data.kind === 'record' ? page.data.model : '',
|
|
2122
|
+
field,
|
|
2123
|
+
recordId: state.recordId,
|
|
2124
|
+
schedule,
|
|
2125
|
+
from,
|
|
2126
|
+
to,
|
|
2127
|
+
timeZone: (def?.type === 'datetime' && def.timeZone) || page.timeZone || ownZone(),
|
|
2128
|
+
...(length ? { length } : {}),
|
|
2129
|
+
...(between ? { between } : {}),
|
|
2130
|
+
values: structuredCopy(state.values),
|
|
2131
|
+
});
|
|
2132
|
+
},
|
|
1831
2133
|
upload: (field, files, how = {}) => uploads.start(field, files, how),
|
|
1832
2134
|
uploading: (field) => state.uploads.filter((upload) => upload.field === field && !upload.line),
|
|
1833
2135
|
cancelUpload(id) {
|
|
@@ -1853,8 +2155,11 @@ function innerForm(options, within, place, outer) {
|
|
|
1853
2155
|
// 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
2156
|
resets++;
|
|
1855
2157
|
personal.clear();
|
|
2158
|
+
personalLines.clear();
|
|
1856
2159
|
filed.clear();
|
|
1857
2160
|
sentAs = null;
|
|
2161
|
+
// Started again: a new response, timed from now.
|
|
2162
|
+
bot = { ...bot, honeypot: false, since: clock() };
|
|
1858
2163
|
const values = syncParts(structuredCopy(baseline), 'start');
|
|
1859
2164
|
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
2165
|
},
|
|
@@ -1867,7 +2172,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1867
2172
|
const lines = (state.values[field] ?? []).map((line) => line.key === key ? { ...line, values: { ...line.values, [name]: structuredCopy(value) } } : line);
|
|
1868
2173
|
writeValues({ ...state.values, [field]: lines });
|
|
1869
2174
|
afterEdit(field);
|
|
1870
|
-
changed(field, 'person');
|
|
2175
|
+
changed(field, 'person', { line: key });
|
|
1871
2176
|
},
|
|
1872
2177
|
moveLine(field, key, to) {
|
|
1873
2178
|
const def = lineField(field);
|
|
@@ -1912,10 +2217,10 @@ function innerForm(options, within, place, outer) {
|
|
|
1912
2217
|
const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
|
|
1913
2218
|
writeValues({ ...state.values, [field]: lines });
|
|
1914
2219
|
afterEdit(field);
|
|
1915
|
-
changed(field, 'person');
|
|
2220
|
+
changed(field, 'person', { line: key });
|
|
1916
2221
|
},
|
|
1917
2222
|
async search(field, query, limit = 8, options = {}) {
|
|
1918
|
-
return runSearch(fieldDef(field), field, context(), query, limit, options
|
|
2223
|
+
return runSearch(fieldDef(field), field, context(), query, limit, options);
|
|
1919
2224
|
},
|
|
1920
2225
|
async previewLine(field, key, values) {
|
|
1921
2226
|
lineField(field);
|
|
@@ -1940,7 +2245,7 @@ function innerForm(options, within, place, outer) {
|
|
|
1940
2245
|
const line = (state.values[field] ?? []).find((l) => l.key === key);
|
|
1941
2246
|
if (!line)
|
|
1942
2247
|
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
|
|
2248
|
+
return runSearch(sub, subfield, lineContext(line.values, def.fields, context(), line.id ?? null), query, limit, options);
|
|
1944
2249
|
},
|
|
1945
2250
|
canCreate: (field) => creatable(page.fields[field]) !== null,
|
|
1946
2251
|
quickCreate: (field, typed) => runCreate(page.fields[field], field, typed, page.fields[field] ? createValuesOf(field) : {}),
|
|
@@ -2102,7 +2407,7 @@ function innerForm(options, within, place, outer) {
|
|
|
2102
2407
|
lines: { field: source.field, keys: [...keys], ids: lines.flatMap((line) => (line.id === undefined ? [] : [line.id])), values: lines.map((line) => structuredCopy(line.values)) },
|
|
2103
2408
|
});
|
|
2104
2409
|
},
|
|
2105
|
-
run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
|
|
2410
|
+
run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], typed: 'first', ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
|
|
2106
2411
|
shown(tab) {
|
|
2107
2412
|
// A wizard's steps are told by the form itself, as they are entered.
|
|
2108
2413
|
if (steps.includes(tab))
|
|
@@ -2129,20 +2434,36 @@ function innerForm(options, within, place, outer) {
|
|
|
2129
2434
|
},
|
|
2130
2435
|
async reloadTheirs() {
|
|
2131
2436
|
// A save on its way first: its answer is not dropped by the load.
|
|
2437
|
+
const asked = shownNow();
|
|
2132
2438
|
await savesSettled();
|
|
2133
|
-
|
|
2439
|
+
if (!asked())
|
|
2440
|
+
return false;
|
|
2441
|
+
// What is still typed is the person's too: read, a name looked up waited for, before theirs are loaded.
|
|
2442
|
+
const waiting = readTyped();
|
|
2443
|
+
if (waiting)
|
|
2444
|
+
await waiting;
|
|
2445
|
+
if (!asked())
|
|
2446
|
+
return false;
|
|
2447
|
+
// The person's changes, kept before theirs are loaded over them; and text they left that does not read.
|
|
2134
2448
|
const mine = {};
|
|
2135
2449
|
for (const name of state.dirty)
|
|
2136
2450
|
mine[name] = structuredCopy(state.values[name]);
|
|
2137
|
-
|
|
2138
|
-
|
|
2451
|
+
const typed = state.unread;
|
|
2452
|
+
// The record loaded again is a record shown anew: what is set aside is laid on it alone.
|
|
2453
|
+
const loading = load(true);
|
|
2454
|
+
const loaded = generation;
|
|
2455
|
+
await track(loading);
|
|
2456
|
+
if (loaded !== generation || state.status !== 'ready')
|
|
2139
2457
|
return false;
|
|
2140
2458
|
// Set aside where theirs differ from mine: a change they made the same way is no loss.
|
|
2141
2459
|
const aside = { ...(state.setAside ?? {}) };
|
|
2142
2460
|
for (const [name, value] of Object.entries(mine))
|
|
2143
2461
|
if (!sameValue(fields[name], value, state.values[name]))
|
|
2144
2462
|
aside[name] = value;
|
|
2145
|
-
set
|
|
2463
|
+
// Text that does not read is no value to set aside: it stays in its box, unread with why, over their value —
|
|
2464
|
+
// never dropped unsaid. A line of theirs no longer there takes its cells' text with it.
|
|
2465
|
+
const unread = Object.fromEntries(Object.entries(typed).filter(([path]) => valueAt(state.values, path) !== gone));
|
|
2466
|
+
set({ conflict: null, saveProblem: null, setAside: Object.keys(aside).length ? aside : null, ...(Object.keys(unread).length ? { unread } : {}) });
|
|
2146
2467
|
return true;
|
|
2147
2468
|
},
|
|
2148
2469
|
async keepMine() {
|
|
@@ -2153,6 +2474,7 @@ function innerForm(options, within, place, outer) {
|
|
|
2153
2474
|
const source = options.dataSource;
|
|
2154
2475
|
if (conflict && !told) {
|
|
2155
2476
|
if (source?.load && state.recordId != null && page.data.kind === 'record') {
|
|
2477
|
+
const shown = shownNow();
|
|
2156
2478
|
try {
|
|
2157
2479
|
const now = loadedRecordOf(await track(source.load({ model: page.data.model, id: state.recordId, fields })));
|
|
2158
2480
|
told = now.values;
|
|
@@ -2161,6 +2483,9 @@ function innerForm(options, within, place, outer) {
|
|
|
2161
2483
|
catch {
|
|
2162
2484
|
// Unread: said below.
|
|
2163
2485
|
}
|
|
2486
|
+
// Read, or not, once another record is shown or the values let go: nothing is laid, nothing saved.
|
|
2487
|
+
if (!shown())
|
|
2488
|
+
return false;
|
|
2164
2489
|
}
|
|
2165
2490
|
// Still unread: saving now would go over work this person never saw. Nothing is saved; they are told, and choose again.
|
|
2166
2491
|
if (!told) {
|
|
@@ -2223,7 +2548,8 @@ function innerForm(options, within, place, outer) {
|
|
|
2223
2548
|
const given = state.values[part.name];
|
|
2224
2549
|
// 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
2550
|
const made = innerForm({
|
|
2226
|
-
|
|
2551
|
+
// In the record's time zone, unless it names its own.
|
|
2552
|
+
page: { ...inner, data: { kind: 'responses' }, ...(inner.timeZone || !page.timeZone ? {} : { timeZone: page.timeZone }) },
|
|
2227
2553
|
dataSource: options.dataSource,
|
|
2228
2554
|
values: isRecord(given) ? given : {},
|
|
2229
2555
|
onAction: options.onAction,
|
|
@@ -2236,7 +2562,7 @@ function innerForm(options, within, place, outer) {
|
|
|
2236
2562
|
// Its files are the record's: stored for it, under the part's name.
|
|
2237
2563
|
{ model: where.model, recordId: where.recordId, under: `${where.under}${part.name}.` },
|
|
2238
2564
|
// Its ways out are the record's: through this form's check.
|
|
2239
|
-
{ letGo, close, leaveNow: (how) => atOnce(how) });
|
|
2565
|
+
{ letGo, close, leaveNow: (how) => atOnce(how), recordShown: shownNow });
|
|
2240
2566
|
attached.set(id, { name: part.name, inner: made });
|
|
2241
2567
|
// A change inside it is told as a change of its answers here, whoever made it.
|
|
2242
2568
|
made.form.on('change', ({ by }) => {
|