@fieldia/core 0.19.1 → 0.21.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/package.json +1 -1
- package/schema/page.schema.json +946 -210
- package/src/index.d.mts +2 -1
- package/src/index.d.ts +2 -1
- package/src/index.js +5 -1
- package/src/index.js.map +1 -1
- package/src/index.mjs +2 -1
- package/src/index.mjs.map +1 -1
- package/src/lib/expression/parser.js +4 -4
- package/src/lib/expression/parser.js.map +1 -1
- package/src/lib/expression/parser.mjs +5 -5
- package/src/lib/expression/parser.mjs.map +1 -1
- package/src/lib/expression/tokenizer.d.mts +12 -0
- package/src/lib/expression/tokenizer.d.ts +12 -0
- package/src/lib/expression/tokenizer.js +21 -12
- package/src/lib/expression/tokenizer.js.map +1 -1
- package/src/lib/expression/tokenizer.mjs +20 -12
- package/src/lib/expression/tokenizer.mjs.map +1 -1
- package/src/lib/format/field.d.mts +16 -0
- package/src/lib/format/field.d.ts +16 -0
- package/src/lib/format/field.js +11 -1
- package/src/lib/format/field.js.map +1 -1
- package/src/lib/format/field.mjs +11 -1
- package/src/lib/format/field.mjs.map +1 -1
- package/src/lib/format/head-checks.d.mts +40 -0
- package/src/lib/format/head-checks.d.ts +40 -0
- package/src/lib/format/head-checks.js +172 -0
- package/src/lib/format/head-checks.js.map +1 -0
- package/src/lib/format/head-checks.mjs +167 -0
- package/src/lib/format/head-checks.mjs.map +1 -0
- package/src/lib/format/layout.d.mts +6485 -1447
- package/src/lib/format/layout.d.ts +6485 -1447
- package/src/lib/format/layout.js +106 -2
- package/src/lib/format/layout.js.map +1 -1
- package/src/lib/format/layout.mjs +105 -1
- package/src/lib/format/layout.mjs.map +1 -1
- package/src/lib/format/layout.types.d.mts +7 -1
- package/src/lib/format/layout.types.d.ts +7 -1
- package/src/lib/format/page.d.mts +1502 -162
- package/src/lib/format/page.d.ts +1502 -162
- package/src/lib/format/page.js +3 -0
- package/src/lib/format/page.js.map +1 -1
- package/src/lib/format/page.mjs +3 -0
- package/src/lib/format/page.mjs.map +1 -1
- package/src/lib/format/pattern.d.mts +10 -0
- package/src/lib/format/pattern.d.ts +10 -0
- package/src/lib/format/pattern.js +33 -0
- package/src/lib/format/pattern.js.map +1 -0
- package/src/lib/format/pattern.mjs +30 -0
- package/src/lib/format/pattern.mjs.map +1 -0
- package/src/lib/format/picker-options.d.mts +22 -0
- package/src/lib/format/picker-options.d.ts +22 -0
- package/src/lib/format/picker-options.js +50 -0
- package/src/lib/format/picker-options.js.map +1 -0
- package/src/lib/format/picker-options.mjs +46 -0
- package/src/lib/format/picker-options.mjs.map +1 -0
- package/src/lib/format/references.d.mts +9 -0
- package/src/lib/format/references.d.ts +9 -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/section-checks.d.mts +10 -0
- package/src/lib/format/section-checks.d.ts +10 -0
- package/src/lib/format/section-checks.js +35 -0
- package/src/lib/format/section-checks.js.map +1 -0
- package/src/lib/format/section-checks.mjs +31 -0
- package/src/lib/format/section-checks.mjs.map +1 -0
- package/src/lib/format/translate.js +14 -4
- package/src/lib/format/translate.js.map +1 -1
- package/src/lib/format/translate.mjs +14 -4
- package/src/lib/format/translate.mjs.map +1 -1
- package/src/lib/record/check.d.mts +13 -3
- package/src/lib/record/check.d.ts +13 -3
- package/src/lib/record/check.js +87 -17
- package/src/lib/record/check.js.map +1 -1
- package/src/lib/record/check.mjs +85 -17
- package/src/lib/record/check.mjs.map +1 -1
- package/src/lib/record/filter.js +14 -2
- package/src/lib/record/filter.js.map +1 -1
- package/src/lib/record/filter.mjs +14 -2
- package/src/lib/record/filter.mjs.map +1 -1
- package/src/lib/record/form.d.mts +56 -2
- package/src/lib/record/form.d.ts +56 -2
- package/src/lib/record/form.js +293 -26
- package/src/lib/record/form.js.map +1 -1
- package/src/lib/record/form.mjs +295 -28
- package/src/lib/record/form.mjs.map +1 -1
- package/src/lib/record/input-rules.d.mts +277 -0
- package/src/lib/record/input-rules.d.ts +277 -0
- package/src/lib/record/input-rules.js +724 -0
- package/src/lib/record/input-rules.js.map +1 -0
- package/src/lib/record/input-rules.mjs +705 -0
- package/src/lib/record/input-rules.mjs.map +1 -0
- package/src/lib/record/inputs.d.mts +25 -0
- package/src/lib/record/inputs.d.ts +25 -0
- package/src/lib/record/inputs.js +135 -9
- package/src/lib/record/inputs.js.map +1 -1
- package/src/lib/record/inputs.mjs +131 -9
- package/src/lib/record/inputs.mjs.map +1 -1
- package/src/lib/record/locales/ar.js +13 -2
- package/src/lib/record/locales/ar.js.map +1 -1
- package/src/lib/record/locales/ar.mjs +13 -2
- package/src/lib/record/locales/ar.mjs.map +1 -1
- package/src/lib/record/locales/de.js +13 -2
- package/src/lib/record/locales/de.js.map +1 -1
- package/src/lib/record/locales/de.mjs +13 -2
- package/src/lib/record/locales/de.mjs.map +1 -1
- package/src/lib/record/locales/fr.js +13 -2
- package/src/lib/record/locales/fr.js.map +1 -1
- package/src/lib/record/locales/fr.mjs +13 -2
- package/src/lib/record/locales/fr.mjs.map +1 -1
- package/src/lib/record/memory-data-source.d.mts +7 -0
- package/src/lib/record/memory-data-source.d.ts +7 -0
- package/src/lib/record/memory-data-source.js +24 -2
- package/src/lib/record/memory-data-source.js.map +1 -1
- package/src/lib/record/memory-data-source.mjs +24 -2
- package/src/lib/record/memory-data-source.mjs.map +1 -1
- package/src/lib/record/messages.d.mts +22 -0
- package/src/lib/record/messages.d.ts +22 -0
- package/src/lib/record/messages.js +13 -2
- package/src/lib/record/messages.js.map +1 -1
- package/src/lib/record/messages.mjs +13 -2
- package/src/lib/record/messages.mjs.map +1 -1
- package/src/lib/record/rules.js +3 -1
- package/src/lib/record/rules.js.map +1 -1
- package/src/lib/record/rules.mjs +3 -1
- package/src/lib/record/rules.mjs.map +1 -1
- package/src/lib/record/run.d.mts +10 -2
- package/src/lib/record/run.d.ts +10 -2
- package/src/lib/record/run.js +8 -1
- package/src/lib/record/run.js.map +1 -1
- package/src/lib/record/run.mjs +8 -1
- package/src/lib/record/run.mjs.map +1 -1
- package/src/lib/record/search.d.mts +8 -1
- package/src/lib/record/search.d.ts +8 -1
- package/src/lib/record/search.js +28 -8
- package/src/lib/record/search.js.map +1 -1
- package/src/lib/record/search.mjs +28 -8
- package/src/lib/record/search.mjs.map +1 -1
- package/src/lib/record/structures.d.mts +8 -3
- package/src/lib/record/structures.d.ts +8 -3
- package/src/lib/record/structures.js +51 -7
- package/src/lib/record/structures.js.map +1 -1
- package/src/lib/record/structures.mjs +48 -6
- package/src/lib/record/structures.mjs.map +1 -1
- package/src/lib/record/values.d.mts +4 -0
- package/src/lib/record/values.d.ts +4 -0
- package/src/lib/record/values.js.map +1 -1
- package/src/lib/record/values.mjs.map +1 -1
- package/src/runtime.d.mts +7 -3
- package/src/runtime.d.ts +7 -3
- package/src/runtime.js +31 -1
- package/src/runtime.js.map +1 -1
- package/src/runtime.mjs +5 -2
- package/src/runtime.mjs.map +1 -1
package/src/lib/record/form.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { compileModifier } from '../expression/modifier.mjs';
|
|
2
2
|
import { localDay } from '../expression/functions.mjs';
|
|
3
|
-
import { checkValue } from './check.mjs';
|
|
3
|
+
import { checkProperties, checkValue } from './check.mjs';
|
|
4
4
|
import { checkInput } from './inputs.mjs';
|
|
5
5
|
import { compileComputed } from './compute.mjs';
|
|
6
6
|
import { compileSetWhen } from './set-when.mjs';
|
|
@@ -10,7 +10,7 @@ import { firstValues, readLooseMap, readValueMap } from './value-map.mjs';
|
|
|
10
10
|
import { resolveFilter } from './filter.mjs';
|
|
11
11
|
import { rolesAllow } from './roles.mjs';
|
|
12
12
|
import { compileFieldTone, compileTable } from './cells.mjs';
|
|
13
|
-
import { MESSAGES } from './messages.mjs';
|
|
13
|
+
import { fill, MESSAGES } from './messages.mjs';
|
|
14
14
|
import { saveProblemOf } from './data-source.mjs';
|
|
15
15
|
import { hostScheduler } from './scheduler.mjs';
|
|
16
16
|
import { createMoments, createRunner } from './run.mjs';
|
|
@@ -23,6 +23,8 @@ import { emptyValue, initialValues, isEmpty, lineKind, structuredCopy, } from '.
|
|
|
23
23
|
export function createForm(options) {
|
|
24
24
|
return innerForm(options, []).form;
|
|
25
25
|
}
|
|
26
|
+
/** A line's cell whose line is no longer there. */
|
|
27
|
+
const gone = Symbol('gone');
|
|
26
28
|
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
27
29
|
/** A saved form's answers, as the one value they are in the form round it. */
|
|
28
30
|
const asValue = (values) => values;
|
|
@@ -108,6 +110,38 @@ function innerForm(options, within) {
|
|
|
108
110
|
parts.set(node.id, node.source);
|
|
109
111
|
/** Those given their page, with the inner form that holds their answers. */
|
|
110
112
|
const attached = new Map();
|
|
113
|
+
/** How each box with text typed and not yet read reads it now (`settling`). */
|
|
114
|
+
const readers = new Set();
|
|
115
|
+
/** Whether each box that says so is still being typed in (`settling`'s `typing`): autosave waits for them. */
|
|
116
|
+
const typings = new Set();
|
|
117
|
+
/** A box of this form, or of a saved form placed on it, still being typed in. */
|
|
118
|
+
const typingNow = () => [...typings].some((typing) => typing()) || [...attached.values()].some((part) => part.inner.typing());
|
|
119
|
+
/** Readings of typed text still waiting, as readTyped started them. */
|
|
120
|
+
let readingNow = 0;
|
|
121
|
+
/**
|
|
122
|
+
* Every box's text read now, this form's and its saved forms' placed on the page, before it is checked or saved:
|
|
123
|
+
* what a reading that waits (a name looked up) is waiting for, or null when none does. Such a box keeps its text
|
|
124
|
+
* unread meanwhile, so a check made now refuses it; a save waits.
|
|
125
|
+
*/
|
|
126
|
+
function readTyped() {
|
|
127
|
+
const waits = [];
|
|
128
|
+
for (const read of [...readers]) {
|
|
129
|
+
const wait = read();
|
|
130
|
+
if (wait && typeof wait.then === 'function')
|
|
131
|
+
waits.push(wait.catch(() => undefined));
|
|
132
|
+
}
|
|
133
|
+
for (const part of attached.values()) {
|
|
134
|
+
const inner = part.inner.form;
|
|
135
|
+
const wait = inner.settle();
|
|
136
|
+
if (inner.reading())
|
|
137
|
+
waits.push(wait);
|
|
138
|
+
}
|
|
139
|
+
if (!waits.length)
|
|
140
|
+
return null;
|
|
141
|
+
// Waiting on its own readings, or a saved form's: `reading` says so until they end.
|
|
142
|
+
readingNow++;
|
|
143
|
+
return Promise.all(waits).then(() => void readingNow--);
|
|
144
|
+
}
|
|
111
145
|
/** The page's fields, and each saved form's answers as a JSON value: what is loaded, told as changed and saved. */
|
|
112
146
|
const fields = { ...page.fields };
|
|
113
147
|
for (const part of parts.values())
|
|
@@ -201,6 +235,7 @@ function innerForm(options, within) {
|
|
|
201
235
|
saveProblem: null,
|
|
202
236
|
choices: {},
|
|
203
237
|
definitions: {},
|
|
238
|
+
unread: {},
|
|
204
239
|
};
|
|
205
240
|
started = true;
|
|
206
241
|
/** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
|
|
@@ -313,8 +348,24 @@ function innerForm(options, within) {
|
|
|
313
348
|
function checked(name) {
|
|
314
349
|
const def = page.fields[name];
|
|
315
350
|
const loaded = state.choices[name]?.options;
|
|
351
|
+
if (def.type === 'properties' && def.definitionsFrom) {
|
|
352
|
+
// The definitions the linked record keeps, once loaded: each property checked as one of its kind.
|
|
353
|
+
const definitions = state.definitions[name]?.definitions;
|
|
354
|
+
return definitions ? { ...def, definitions } : def;
|
|
355
|
+
}
|
|
316
356
|
return def.type === 'selection' && def.optionsFrom && loaded ? { ...def, options: loaded, optionsFrom: undefined } : def;
|
|
317
357
|
}
|
|
358
|
+
/** A properties field's problems, each at its property's path (`field.property`), as its row shows them. */
|
|
359
|
+
function propertyProblems(name) {
|
|
360
|
+
const def = checked(name);
|
|
361
|
+
if (def.type !== 'properties')
|
|
362
|
+
return {};
|
|
363
|
+
return Object.fromEntries(Object.entries(checkProperties(def.definitions, state.values[name], messages, today())).map(([property, message]) => [`${name}.${property}`, message]));
|
|
364
|
+
}
|
|
365
|
+
/** A field's own problem, less what its properties' rows say of themselves. */
|
|
366
|
+
function besidesRows(name, message) {
|
|
367
|
+
return message !== undefined && page.fields[name]?.type === 'properties' && Object.values(propertyProblems(name)).includes(message) ? undefined : message;
|
|
368
|
+
}
|
|
318
369
|
function track(promise) {
|
|
319
370
|
pending.add(promise);
|
|
320
371
|
const done = () => pending.delete(promise);
|
|
@@ -360,10 +411,11 @@ function innerForm(options, within) {
|
|
|
360
411
|
invisible,
|
|
361
412
|
readonly: readonly || (def !== undefined && lockedField(def)),
|
|
362
413
|
required: node.required.evaluate(ctx, env) || def?.required === true,
|
|
414
|
+
...(node.pressed ? { pressed: node.pressed.evaluate(ctx, env) } : {}),
|
|
363
415
|
};
|
|
364
416
|
}
|
|
365
|
-
/** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
|
|
366
|
-
function visibleFieldNodes(root, kind = 'field') {
|
|
417
|
+
/** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now; `field`'s alone, when named. */
|
|
418
|
+
function visibleFieldNodes(root, kind = 'field', field) {
|
|
367
419
|
const inside = (node) => {
|
|
368
420
|
if (!root)
|
|
369
421
|
return true;
|
|
@@ -381,7 +433,7 @@ function innerForm(options, within) {
|
|
|
381
433
|
return true;
|
|
382
434
|
return false;
|
|
383
435
|
};
|
|
384
|
-
return [...index.values()].filter((node) => node.kind === kind && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
|
|
436
|
+
return [...index.values()].filter((node) => node.kind === kind && (field === undefined || node.field === field) && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
|
|
385
437
|
}
|
|
386
438
|
/** What a field node's answer rules say of its value now: the first error and the first warning. */
|
|
387
439
|
function ruleCheck(node) {
|
|
@@ -428,16 +480,52 @@ function innerForm(options, within) {
|
|
|
428
480
|
state = previous;
|
|
429
481
|
}
|
|
430
482
|
}
|
|
483
|
+
/** A range's last day (`options.endField` of a `daterange`), the field it writes besides its own; null for any other node. */
|
|
484
|
+
function rangeEnd(node) {
|
|
485
|
+
const source = node.source;
|
|
486
|
+
const end = source.widget === 'daterange' ? source.options?.['endField'] : undefined;
|
|
487
|
+
return typeof end === 'string' && page.fields[end] && end !== node.field ? end : null;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* A range's end, a field of its own though no node shows it alone: its required, min, max and days, and that it
|
|
491
|
+
* is not before its start ("Ends can’t be before Starts"). Required when its field is, or the range is.
|
|
492
|
+
*/
|
|
493
|
+
function rangeEndError(node, end, required = nodeState(node.id).required) {
|
|
494
|
+
const def = page.fields[end];
|
|
495
|
+
const value = state.values[end];
|
|
496
|
+
const own = checkValue(checked(end), value, required || def.required === true, messages, today());
|
|
497
|
+
if (own)
|
|
498
|
+
return own;
|
|
499
|
+
const start = state.values[node.field];
|
|
500
|
+
if (typeof start !== 'string' || typeof value !== 'string' || !start || !value)
|
|
501
|
+
return undefined;
|
|
502
|
+
const at = (v) => (def.type === 'datetime' ? Date.parse(v) : Date.parse(v.slice(0, 10)));
|
|
503
|
+
if (at(value) < at(start))
|
|
504
|
+
return fill(messages.endBeforeStart, { end: def.label, start: page.fields[node.field].label });
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
431
507
|
/** Errors for the visible fields under `root`, without publishing them. */
|
|
432
508
|
function collectErrors(root) {
|
|
433
509
|
const errors = {};
|
|
510
|
+
const seen = new Set();
|
|
434
511
|
for (const node of visibleFieldNodes(root)) {
|
|
435
512
|
const name = node.field;
|
|
513
|
+
seen.add(name);
|
|
514
|
+
const end = rangeEnd(node);
|
|
515
|
+
if (end) {
|
|
516
|
+
seen.add(end);
|
|
517
|
+
const problem = errors[end] ? undefined : rangeEndError(node, end);
|
|
518
|
+
if (problem)
|
|
519
|
+
errors[end] = problem;
|
|
520
|
+
}
|
|
436
521
|
if (errors[name])
|
|
437
522
|
continue;
|
|
438
523
|
const def = page.fields[name];
|
|
439
524
|
const message = nodeError(node);
|
|
440
|
-
|
|
525
|
+
// A property says its own problem on its row; the field, only what is its own (that it is required).
|
|
526
|
+
if (def.type === 'properties')
|
|
527
|
+
Object.assign(errors, propertyProblems(name));
|
|
528
|
+
if (besidesRows(name, message))
|
|
441
529
|
errors[name] = message;
|
|
442
530
|
if (def.type === 'one2many') {
|
|
443
531
|
for (const line of state.values[name] ?? []) {
|
|
@@ -458,6 +546,10 @@ function innerForm(options, within) {
|
|
|
458
546
|
}
|
|
459
547
|
}
|
|
460
548
|
}
|
|
549
|
+
// Text left in a box that does not read answers for its field, in the words it was left with.
|
|
550
|
+
for (const [path, unread] of Object.entries(state.unread))
|
|
551
|
+
if (seen.has(path.split('.')[0]))
|
|
552
|
+
errors[path] = unread.message;
|
|
461
553
|
// A saved form placed here answers for its fields by its own page's rules.
|
|
462
554
|
for (const node of visibleFieldNodes(root, 'form')) {
|
|
463
555
|
const part = attached.get(node.id);
|
|
@@ -467,6 +559,63 @@ function innerForm(options, within) {
|
|
|
467
559
|
}
|
|
468
560
|
return errors;
|
|
469
561
|
}
|
|
562
|
+
/** The value at a path: a field's, or a line's cell (`field.lineKey.subfield`); `gone` when the line is not there. */
|
|
563
|
+
function valueAt(values, path) {
|
|
564
|
+
const parts = path.split('.');
|
|
565
|
+
// A property of a properties field (`field.property`).
|
|
566
|
+
if (parts.length === 2 && page.fields[parts[0]]?.type === 'properties') {
|
|
567
|
+
const held = values[parts[0]];
|
|
568
|
+
return held && typeof held === 'object' && !Array.isArray(held) ? held[parts[1]] : undefined;
|
|
569
|
+
}
|
|
570
|
+
if (parts.length < 3)
|
|
571
|
+
return values[path];
|
|
572
|
+
const [field, sub] = [parts[0], parts[parts.length - 1]];
|
|
573
|
+
const key = parts.slice(1, -1).join('.');
|
|
574
|
+
const line = (values[field] ?? []).find((l) => l.key === key);
|
|
575
|
+
return line ? line.values[sub] : gone;
|
|
576
|
+
}
|
|
577
|
+
/** Unread text whose field has since been written another way — a value typed that reads, the app's — is let go. */
|
|
578
|
+
function unreadKept(values) {
|
|
579
|
+
let kept = null;
|
|
580
|
+
for (const path of Object.keys(state.unread)) {
|
|
581
|
+
const now = valueAt(values, path);
|
|
582
|
+
if (now !== gone && now === valueAt(state.values, path))
|
|
583
|
+
continue;
|
|
584
|
+
kept ??= { ...state.unread };
|
|
585
|
+
delete kept[path];
|
|
586
|
+
}
|
|
587
|
+
return kept ?? state.unread;
|
|
588
|
+
}
|
|
589
|
+
/** A field's own problem as its box is left, never that it is required: undefined when it has none, or is not shown. */
|
|
590
|
+
function leftProblem(path) {
|
|
591
|
+
const parts = path.split('.');
|
|
592
|
+
const field = parts[0];
|
|
593
|
+
const shown = visibleFieldNodes(undefined, 'field', field);
|
|
594
|
+
// A range's end is left from its own box in the range: its problems, that it is before its start too.
|
|
595
|
+
const range = shown.length ? undefined : visibleFieldNodes().find((node) => rangeEnd(node) === field);
|
|
596
|
+
if (!shown.length && !range)
|
|
597
|
+
return undefined;
|
|
598
|
+
if (state.unread[path])
|
|
599
|
+
return state.unread[path].message;
|
|
600
|
+
if (range)
|
|
601
|
+
return isEmpty(fieldDef(field), state.values[field]) ? undefined : rangeEndError(range, field, false);
|
|
602
|
+
if (parts.length === 1) {
|
|
603
|
+
const value = state.values[field];
|
|
604
|
+
if (isEmpty(fieldDef(field), value))
|
|
605
|
+
return undefined;
|
|
606
|
+
return (shown.map((node) => checkInput(page.fields[field], node.source, value, messages)).find(Boolean) ??
|
|
607
|
+
besidesRows(field, checkValue(checked(field), value, false, messages, today())) ??
|
|
608
|
+
shown.map((node) => ruleCheck(node).error).find(Boolean));
|
|
609
|
+
}
|
|
610
|
+
if (parts.length === 2 && page.fields[field]?.type === 'properties')
|
|
611
|
+
return propertyProblems(field)[path];
|
|
612
|
+
const def = lineField(field);
|
|
613
|
+
const sub = def.fields[parts[parts.length - 1]];
|
|
614
|
+
const value = valueAt(state.values, path);
|
|
615
|
+
if (!sub || value === gone || isEmpty(sub, value))
|
|
616
|
+
return undefined;
|
|
617
|
+
return checkValue(sub, value, false, messages, today());
|
|
618
|
+
}
|
|
470
619
|
/**
|
|
471
620
|
* Answers changed from outside a saved form — loaded, put back, set — handed
|
|
472
621
|
* to it, and what it holds after taken back: its defaults and worked-out
|
|
@@ -513,26 +662,32 @@ function innerForm(options, within) {
|
|
|
513
662
|
/** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
|
|
514
663
|
function writeValues(written, extra = {}, fresh = false) {
|
|
515
664
|
const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const
|
|
665
|
+
// Text left unread in a box goes once its field is written another way, and its words with it.
|
|
666
|
+
const unread = unreadKept(values);
|
|
667
|
+
const before = state;
|
|
668
|
+
state = { ...state, unread };
|
|
669
|
+
let errors = Object.keys(before.errors).length ? revalidateShown(values) : before.errors;
|
|
670
|
+
state = before;
|
|
671
|
+
// Words where a number goes — from a server or an old draft: a box never sets them — are said at once; what is
|
|
672
|
+
// missing waits for sending.
|
|
673
|
+
const words = {};
|
|
519
674
|
for (const name of Object.keys(written)) {
|
|
520
675
|
const def = page.fields[name];
|
|
521
676
|
if (written[name] === state.values[name] || !def)
|
|
522
677
|
continue;
|
|
523
678
|
if (notANumber(def, written[name]))
|
|
524
|
-
|
|
679
|
+
words[name] = checkValue(def, written[name], false, messages);
|
|
525
680
|
if (def.type !== 'one2many')
|
|
526
681
|
continue;
|
|
527
682
|
for (const line of written[name] ?? []) {
|
|
528
683
|
for (const [sub, v] of Object.entries(line.values))
|
|
529
684
|
if (notANumber(def.fields[sub], v))
|
|
530
|
-
|
|
685
|
+
words[`${name}.${line.key}.${sub}`] = checkValue(def.fields[sub], v, false, messages);
|
|
531
686
|
}
|
|
532
687
|
}
|
|
533
|
-
if (Object.keys(
|
|
534
|
-
errors = { ...errors, ...
|
|
535
|
-
set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
|
|
688
|
+
if (Object.keys(words).length)
|
|
689
|
+
errors = { ...errors, ...words };
|
|
690
|
+
set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), unread, ...extra });
|
|
536
691
|
}
|
|
537
692
|
/** Once errors are on screen, keep them current as the person fixes things. */
|
|
538
693
|
function revalidateShown(values) {
|
|
@@ -594,6 +749,10 @@ function innerForm(options, within) {
|
|
|
594
749
|
scheduler.clearTimeout(autosaveTimer);
|
|
595
750
|
autosaveTimer = scheduler.setTimeout(() => {
|
|
596
751
|
autosaveTimer = null;
|
|
752
|
+
// Nobody pressed anything: a box still typed in is never read under the person's keys. The save waits until it is
|
|
753
|
+
// left (or Enter is pressed in it), and reads it then, as any save does.
|
|
754
|
+
if (typingNow())
|
|
755
|
+
return scheduleAutosave();
|
|
597
756
|
if (state.dirty.length && Object.keys(collectErrors()).length === 0)
|
|
598
757
|
void track(save());
|
|
599
758
|
}, autosave.delayMs);
|
|
@@ -664,6 +823,11 @@ function innerForm(options, within) {
|
|
|
664
823
|
* and a stop keeps it unsaved; `afterSave` runs once it is saved.
|
|
665
824
|
*/
|
|
666
825
|
async function saving(chain) {
|
|
826
|
+
// What is still typed in a box is read first: it is what is saved, or why nothing is. A name still being looked
|
|
827
|
+
// up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
|
|
828
|
+
const waiting = readTyped();
|
|
829
|
+
if (waiting)
|
|
830
|
+
await waiting;
|
|
667
831
|
if (page.on?.beforeSave) {
|
|
668
832
|
const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
|
|
669
833
|
if (!before.done) {
|
|
@@ -708,7 +872,7 @@ function innerForm(options, within) {
|
|
|
708
872
|
// Lines the save brings back load again, in their tables' orders, as Flectra's reload after a save.
|
|
709
873
|
const values = syncParts(startFrom(result.values ? ordered({ ...state.values, ...result.values }, Object.keys(result.values)) : state.values), 'start');
|
|
710
874
|
baseline = structuredCopy(values);
|
|
711
|
-
set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
|
|
875
|
+
set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings), unread: {} });
|
|
712
876
|
emit('save', { recordId: state.recordId, values: state.values });
|
|
713
877
|
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
714
878
|
return null;
|
|
@@ -728,6 +892,23 @@ function innerForm(options, within) {
|
|
|
728
892
|
showing = true;
|
|
729
893
|
tellStep([]);
|
|
730
894
|
}
|
|
895
|
+
/**
|
|
896
|
+
* Values the record holds where it is kept, written as loaded ones are: the
|
|
897
|
+
* starting point moves with them, so they are no change to save, and no
|
|
898
|
+
* change steps, draft or autosave follow. A change of the person's in the
|
|
899
|
+
* same field is taken over by them.
|
|
900
|
+
*/
|
|
901
|
+
function store(written) {
|
|
902
|
+
const names = Object.keys(written);
|
|
903
|
+
names.forEach(fieldDef);
|
|
904
|
+
if (!names.length)
|
|
905
|
+
return;
|
|
906
|
+
const copy = structuredCopy(written);
|
|
907
|
+
baseline = { ...baseline, ...copy };
|
|
908
|
+
writeValues({ ...state.values, ...copy });
|
|
909
|
+
for (const name of names)
|
|
910
|
+
changed(name, 'app');
|
|
911
|
+
}
|
|
731
912
|
/** Write fields — a step's or the app's — worked out once, and tell each. */
|
|
732
913
|
function write(written, by) {
|
|
733
914
|
const names = Object.keys(written);
|
|
@@ -765,9 +946,15 @@ function innerForm(options, within) {
|
|
|
765
946
|
changed(field, by);
|
|
766
947
|
return key;
|
|
767
948
|
}
|
|
768
|
-
/**
|
|
769
|
-
|
|
949
|
+
/**
|
|
950
|
+
* Check the form, or only these fields, showing what is found as sending does: a step's check, which waits for a
|
|
951
|
+
* name still being looked up in a box, as a save does.
|
|
952
|
+
*/
|
|
953
|
+
async function check(names) {
|
|
770
954
|
names?.forEach(fieldDef);
|
|
955
|
+
const waiting = readTyped();
|
|
956
|
+
if (waiting)
|
|
957
|
+
await waiting;
|
|
771
958
|
const all = collectErrors();
|
|
772
959
|
const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
|
|
773
960
|
set({ errors: found });
|
|
@@ -775,11 +962,15 @@ function innerForm(options, within) {
|
|
|
775
962
|
return first === undefined ? null : { reason: 'check', message: first };
|
|
776
963
|
}
|
|
777
964
|
/** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
|
|
778
|
-
function goToStep(target, chain) {
|
|
965
|
+
async function goToStep(target, chain) {
|
|
779
966
|
if (!steps.includes(target))
|
|
780
967
|
return undefined;
|
|
781
968
|
if (!form.steps().includes(target))
|
|
782
969
|
return { reason: 'cannot', message: `The step "${target}" does not apply now` };
|
|
970
|
+
// A name still being looked up in a box on the way is waited for, as a save waits for it.
|
|
971
|
+
const waiting = readTyped();
|
|
972
|
+
if (waiting)
|
|
973
|
+
await waiting;
|
|
783
974
|
let went = false;
|
|
784
975
|
inRun(chain, () => (went = form.goTo(target)));
|
|
785
976
|
return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
|
|
@@ -804,18 +995,20 @@ function innerForm(options, within) {
|
|
|
804
995
|
return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
|
|
805
996
|
}
|
|
806
997
|
async function runSearch(def, name, ctx, query, limit, model, ids) {
|
|
807
|
-
|
|
808
|
-
|
|
998
|
+
// Formatted text names people and records in place (@mention, #4127): searched by the model they are of.
|
|
999
|
+
const byModel = def.type === 'reference' || def.type === 'json' || def.type === 'html';
|
|
1000
|
+
if (def.type !== 'many2one' && def.type !== 'many2many' && !byModel) {
|
|
1001
|
+
throw new Error(`"${name}" is not a many2one, many2many, reference, json or html`);
|
|
809
1002
|
}
|
|
810
|
-
if (
|
|
1003
|
+
if (byModel && !model)
|
|
811
1004
|
throw new Error(`Searching "${name}" needs a model`);
|
|
812
1005
|
const source = options.dataSource;
|
|
813
1006
|
if (!source?.search)
|
|
814
1007
|
return [];
|
|
815
1008
|
// Every valueFrom, in groups too, becomes the value it names before the search goes out.
|
|
816
|
-
const own =
|
|
1009
|
+
const own = byModel || !def.filter ? undefined : resolveFilter(def.filter, ctx);
|
|
817
1010
|
const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
|
|
818
|
-
const relation =
|
|
1011
|
+
const relation = byModel ? model : def.relation;
|
|
819
1012
|
return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
|
|
820
1013
|
}
|
|
821
1014
|
function lineField(field) {
|
|
@@ -849,7 +1042,7 @@ function innerForm(options, within) {
|
|
|
849
1042
|
return;
|
|
850
1043
|
// Lines load in their tables' orders; where a person moves them after, they stay.
|
|
851
1044
|
baseline = syncParts(startFrom(ordered({ ...initialValues(fields, today()), ...loaded })), 'start');
|
|
852
|
-
set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
|
|
1045
|
+
set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), unread: {} });
|
|
853
1046
|
if (again)
|
|
854
1047
|
return;
|
|
855
1048
|
offerDraft();
|
|
@@ -871,7 +1064,7 @@ function innerForm(options, within) {
|
|
|
871
1064
|
serverErrors = {};
|
|
872
1065
|
told = null;
|
|
873
1066
|
showing = false;
|
|
874
|
-
const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null };
|
|
1067
|
+
const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} };
|
|
875
1068
|
if (id != null) {
|
|
876
1069
|
set({ ...fresh, recordId: id, status: 'idle' });
|
|
877
1070
|
return load();
|
|
@@ -906,6 +1099,7 @@ function innerForm(options, within) {
|
|
|
906
1099
|
reading,
|
|
907
1100
|
today,
|
|
908
1101
|
write: (values) => write(values, 'step'),
|
|
1102
|
+
store: (values) => store(values),
|
|
909
1103
|
addLine: (field, values) => void addLine(field, values, 'step'),
|
|
910
1104
|
check,
|
|
911
1105
|
save: saving,
|
|
@@ -980,7 +1174,7 @@ function innerForm(options, within) {
|
|
|
980
1174
|
afterEdit(name);
|
|
981
1175
|
changed(name, how?.by ?? 'person');
|
|
982
1176
|
},
|
|
983
|
-
setValues: (values) => write(values, 'app'),
|
|
1177
|
+
setValues: (values, how) => (how?.stored ? store(values) : write(values, 'app')),
|
|
984
1178
|
node: nodeState,
|
|
985
1179
|
openRecord: (id) => track(openRecord(id)),
|
|
986
1180
|
post,
|
|
@@ -1001,12 +1195,65 @@ function innerForm(options, within) {
|
|
|
1001
1195
|
const shown = visibleFieldNodes().filter((node) => node.field === name);
|
|
1002
1196
|
if (!shown.length)
|
|
1003
1197
|
return null;
|
|
1198
|
+
if (state.unread[name])
|
|
1199
|
+
return state.unread[name].message;
|
|
1200
|
+
// A property's text left unread answers for its field.
|
|
1201
|
+
if (page.fields[name].type === 'properties') {
|
|
1202
|
+
const row = Object.keys(state.unread).find((path) => path.startsWith(`${name}.`));
|
|
1203
|
+
if (row)
|
|
1204
|
+
return state.unread[row].message;
|
|
1205
|
+
}
|
|
1004
1206
|
const own = shown.map(unticked).find(Boolean) ??
|
|
1005
1207
|
shown.map((node) => checkInput(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
|
|
1006
1208
|
checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
|
|
1007
1209
|
return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
|
|
1008
1210
|
},
|
|
1211
|
+
setUnread(path, unread) {
|
|
1212
|
+
const field = path.split('.')[0];
|
|
1213
|
+
// A line's cell (`field.lineKey.subfield`), or a property (`field.property`).
|
|
1214
|
+
const def = fieldDef(field);
|
|
1215
|
+
if (path.includes('.') && def.type !== 'properties')
|
|
1216
|
+
lineField(field);
|
|
1217
|
+
const had = state.unread[path];
|
|
1218
|
+
if (!unread && !had)
|
|
1219
|
+
return;
|
|
1220
|
+
// A line taken away takes its cells' text with it: a box of it still reading (a name looked up) keeps none.
|
|
1221
|
+
if (unread && !had && path.split('.').length >= 3 && def.type !== 'properties' && valueAt(state.values, path) === gone)
|
|
1222
|
+
return;
|
|
1223
|
+
if (unread && had && had.text === unread.text && had.message === unread.message)
|
|
1224
|
+
return;
|
|
1225
|
+
const next = { ...state.unread };
|
|
1226
|
+
if (unread)
|
|
1227
|
+
next[path] = { text: unread.text, message: unread.message };
|
|
1228
|
+
else
|
|
1229
|
+
delete next[path];
|
|
1230
|
+
// Problems on screen stay current: an unread text put right takes its words with it.
|
|
1231
|
+
const before = state;
|
|
1232
|
+
state = { ...state, unread: next };
|
|
1233
|
+
const errors = Object.keys(before.errors).length ? revalidateShown(state.values) : before.errors;
|
|
1234
|
+
state = before;
|
|
1235
|
+
set({ unread: next, errors });
|
|
1236
|
+
},
|
|
1237
|
+
leave(path) {
|
|
1238
|
+
const problem = leftProblem(path);
|
|
1239
|
+
if (!problem || state.errors[path] === problem)
|
|
1240
|
+
return;
|
|
1241
|
+
set({ errors: { ...state.errors, [path]: problem } });
|
|
1242
|
+
},
|
|
1243
|
+
settling(read, typing) {
|
|
1244
|
+
readers.add(read);
|
|
1245
|
+
if (typing)
|
|
1246
|
+
typings.add(typing);
|
|
1247
|
+
return () => {
|
|
1248
|
+
readers.delete(read);
|
|
1249
|
+
if (typing)
|
|
1250
|
+
typings.delete(typing);
|
|
1251
|
+
};
|
|
1252
|
+
},
|
|
1253
|
+
settle: () => readTyped() ?? Promise.resolve(),
|
|
1254
|
+
reading: () => readingNow > 0 || [...attached.values()].some((part) => part.inner.form.reading()),
|
|
1009
1255
|
validate() {
|
|
1256
|
+
readTyped();
|
|
1010
1257
|
const errors = collectErrors();
|
|
1011
1258
|
set({ errors });
|
|
1012
1259
|
return Object.keys(errors).length === 0;
|
|
@@ -1017,7 +1264,7 @@ function innerForm(options, within) {
|
|
|
1017
1264
|
serverErrors = {};
|
|
1018
1265
|
holding = setWhen.holding(baseline);
|
|
1019
1266
|
const values = syncParts(structuredCopy(baseline), 'start');
|
|
1020
|
-
set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
|
|
1267
|
+
set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} });
|
|
1021
1268
|
},
|
|
1022
1269
|
changes,
|
|
1023
1270
|
addLine: (field, values = {}) => addLine(field, values, 'person'),
|
|
@@ -1112,6 +1359,7 @@ function innerForm(options, within) {
|
|
|
1112
1359
|
next() {
|
|
1113
1360
|
if (!state.step)
|
|
1114
1361
|
return false;
|
|
1362
|
+
readTyped();
|
|
1115
1363
|
// A step skipped before and gone back to is answered now, and checked like any other.
|
|
1116
1364
|
if (state.skipped.includes(state.step))
|
|
1117
1365
|
set({ skipped: state.skipped.filter((id) => id !== state.step) });
|
|
@@ -1158,6 +1406,7 @@ function innerForm(options, within) {
|
|
|
1158
1406
|
set({ step: target, errors: {} });
|
|
1159
1407
|
return true;
|
|
1160
1408
|
}
|
|
1409
|
+
readTyped();
|
|
1161
1410
|
const skipped = [...state.skipped];
|
|
1162
1411
|
for (const id of visible.slice(at, to)) {
|
|
1163
1412
|
if (skipped.includes(id))
|
|
@@ -1360,9 +1609,10 @@ function innerForm(options, within) {
|
|
|
1360
1609
|
return writeValues({ ...initialValues(page.fields, today()), ...values }, {}, how === 'fresh');
|
|
1361
1610
|
baseline = startFrom({ ...initialValues(page.fields, today()), ...structuredCopy(values) });
|
|
1362
1611
|
const start = syncParts(structuredCopy(baseline), 'start');
|
|
1363
|
-
set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null });
|
|
1612
|
+
set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null, unread: {} });
|
|
1364
1613
|
},
|
|
1365
1614
|
errorsNow: () => collectErrors(),
|
|
1615
|
+
typing: typingNow,
|
|
1366
1616
|
show(errors) {
|
|
1367
1617
|
if (JSON.stringify(errors) !== JSON.stringify(state.errors))
|
|
1368
1618
|
set({ errors });
|
|
@@ -1439,6 +1689,7 @@ function indexLayout(root) {
|
|
|
1439
1689
|
const index = new Map();
|
|
1440
1690
|
const add = (source, parent, kind, modifiers) => {
|
|
1441
1691
|
const roles = source.roles;
|
|
1692
|
+
const pressed = kind === 'button' ? source.pressed : undefined;
|
|
1442
1693
|
index.set(source.id, {
|
|
1443
1694
|
id: source.id,
|
|
1444
1695
|
parent,
|
|
@@ -1448,8 +1699,13 @@ function indexLayout(root) {
|
|
|
1448
1699
|
invisible: compileModifier(modifiers.invisible),
|
|
1449
1700
|
readonly: compileModifier(modifiers.readonly),
|
|
1450
1701
|
required: compileModifier(modifiers.required),
|
|
1702
|
+
...(pressed !== undefined ? { pressed: compileModifier(pressed) } : {}),
|
|
1451
1703
|
source,
|
|
1452
1704
|
});
|
|
1705
|
+
// A button's menu: each item a button of the record, hidden with it.
|
|
1706
|
+
if (kind === 'button')
|
|
1707
|
+
for (const item of source.menu ?? [])
|
|
1708
|
+
add(item, source.id, 'button', { invisible: item.invisible });
|
|
1453
1709
|
};
|
|
1454
1710
|
const walk = (nodes, parent) => {
|
|
1455
1711
|
for (const node of nodes) {
|
|
@@ -1533,6 +1789,17 @@ function indexLayout(root) {
|
|
|
1533
1789
|
// Fields over and under the title, and on its line, are field nodes like any other.
|
|
1534
1790
|
walk([...(root.title?.above ?? []), ...(root.title?.before ?? []), ...(root.title?.after ?? []), ...(root.title?.below ?? [])], root.id);
|
|
1535
1791
|
walk(root.children, root.id);
|
|
1792
|
+
// The head's row, buttons and band: its fields are field nodes, its link and buttons buttons of the record.
|
|
1793
|
+
const head = root.head;
|
|
1794
|
+
for (const part of head?.meta ?? []) {
|
|
1795
|
+
if (part.type === 'field')
|
|
1796
|
+
walk([part], root.id);
|
|
1797
|
+
else
|
|
1798
|
+
add(part, root.id, 'button', { invisible: part.invisible });
|
|
1799
|
+
}
|
|
1800
|
+
for (const button of head?.actions ?? [])
|
|
1801
|
+
add(button, root.id, 'button', { invisible: button.invisible });
|
|
1802
|
+
walk(head?.band?.children ?? [], root.id);
|
|
1536
1803
|
if (root.sidePanel)
|
|
1537
1804
|
add(root.sidePanel, root.id, 'other', { invisible: root.sidePanel.invisible });
|
|
1538
1805
|
// The gear menu's items are buttons of the record; Archive shows while it is active, Unarchive while it is not.
|