@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.js
CHANGED
|
@@ -27,6 +27,8 @@ const values_1 = require("./values");
|
|
|
27
27
|
function createForm(options) {
|
|
28
28
|
return innerForm(options, []).form;
|
|
29
29
|
}
|
|
30
|
+
/** A line's cell whose line is no longer there. */
|
|
31
|
+
const gone = Symbol('gone');
|
|
30
32
|
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
31
33
|
/** A saved form's answers, as the one value they are in the form round it. */
|
|
32
34
|
const asValue = (values) => values;
|
|
@@ -112,6 +114,38 @@ function innerForm(options, within) {
|
|
|
112
114
|
parts.set(node.id, node.source);
|
|
113
115
|
/** Those given their page, with the inner form that holds their answers. */
|
|
114
116
|
const attached = new Map();
|
|
117
|
+
/** How each box with text typed and not yet read reads it now (`settling`). */
|
|
118
|
+
const readers = new Set();
|
|
119
|
+
/** Whether each box that says so is still being typed in (`settling`'s `typing`): autosave waits for them. */
|
|
120
|
+
const typings = new Set();
|
|
121
|
+
/** A box of this form, or of a saved form placed on it, still being typed in. */
|
|
122
|
+
const typingNow = () => [...typings].some((typing) => typing()) || [...attached.values()].some((part) => part.inner.typing());
|
|
123
|
+
/** Readings of typed text still waiting, as readTyped started them. */
|
|
124
|
+
let readingNow = 0;
|
|
125
|
+
/**
|
|
126
|
+
* Every box's text read now, this form's and its saved forms' placed on the page, before it is checked or saved:
|
|
127
|
+
* what a reading that waits (a name looked up) is waiting for, or null when none does. Such a box keeps its text
|
|
128
|
+
* unread meanwhile, so a check made now refuses it; a save waits.
|
|
129
|
+
*/
|
|
130
|
+
function readTyped() {
|
|
131
|
+
const waits = [];
|
|
132
|
+
for (const read of [...readers]) {
|
|
133
|
+
const wait = read();
|
|
134
|
+
if (wait && typeof wait.then === 'function')
|
|
135
|
+
waits.push(wait.catch(() => undefined));
|
|
136
|
+
}
|
|
137
|
+
for (const part of attached.values()) {
|
|
138
|
+
const inner = part.inner.form;
|
|
139
|
+
const wait = inner.settle();
|
|
140
|
+
if (inner.reading())
|
|
141
|
+
waits.push(wait);
|
|
142
|
+
}
|
|
143
|
+
if (!waits.length)
|
|
144
|
+
return null;
|
|
145
|
+
// Waiting on its own readings, or a saved form's: `reading` says so until they end.
|
|
146
|
+
readingNow++;
|
|
147
|
+
return Promise.all(waits).then(() => void readingNow--);
|
|
148
|
+
}
|
|
115
149
|
/** The page's fields, and each saved form's answers as a JSON value: what is loaded, told as changed and saved. */
|
|
116
150
|
const fields = { ...page.fields };
|
|
117
151
|
for (const part of parts.values())
|
|
@@ -205,6 +239,7 @@ function innerForm(options, within) {
|
|
|
205
239
|
saveProblem: null,
|
|
206
240
|
choices: {},
|
|
207
241
|
definitions: {},
|
|
242
|
+
unread: {},
|
|
208
243
|
};
|
|
209
244
|
started = true;
|
|
210
245
|
/** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
|
|
@@ -317,8 +352,24 @@ function innerForm(options, within) {
|
|
|
317
352
|
function checked(name) {
|
|
318
353
|
const def = page.fields[name];
|
|
319
354
|
const loaded = state.choices[name]?.options;
|
|
355
|
+
if (def.type === 'properties' && def.definitionsFrom) {
|
|
356
|
+
// The definitions the linked record keeps, once loaded: each property checked as one of its kind.
|
|
357
|
+
const definitions = state.definitions[name]?.definitions;
|
|
358
|
+
return definitions ? { ...def, definitions } : def;
|
|
359
|
+
}
|
|
320
360
|
return def.type === 'selection' && def.optionsFrom && loaded ? { ...def, options: loaded, optionsFrom: undefined } : def;
|
|
321
361
|
}
|
|
362
|
+
/** A properties field's problems, each at its property's path (`field.property`), as its row shows them. */
|
|
363
|
+
function propertyProblems(name) {
|
|
364
|
+
const def = checked(name);
|
|
365
|
+
if (def.type !== 'properties')
|
|
366
|
+
return {};
|
|
367
|
+
return Object.fromEntries(Object.entries((0, check_1.checkProperties)(def.definitions, state.values[name], messages, today())).map(([property, message]) => [`${name}.${property}`, message]));
|
|
368
|
+
}
|
|
369
|
+
/** A field's own problem, less what its properties' rows say of themselves. */
|
|
370
|
+
function besidesRows(name, message) {
|
|
371
|
+
return message !== undefined && page.fields[name]?.type === 'properties' && Object.values(propertyProblems(name)).includes(message) ? undefined : message;
|
|
372
|
+
}
|
|
322
373
|
function track(promise) {
|
|
323
374
|
pending.add(promise);
|
|
324
375
|
const done = () => pending.delete(promise);
|
|
@@ -364,10 +415,11 @@ function innerForm(options, within) {
|
|
|
364
415
|
invisible,
|
|
365
416
|
readonly: readonly || (def !== undefined && lockedField(def)),
|
|
366
417
|
required: node.required.evaluate(ctx, env) || def?.required === true,
|
|
418
|
+
...(node.pressed ? { pressed: node.pressed.evaluate(ctx, env) } : {}),
|
|
367
419
|
};
|
|
368
420
|
}
|
|
369
|
-
/** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
|
|
370
|
-
function visibleFieldNodes(root, kind = 'field') {
|
|
421
|
+
/** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now; `field`'s alone, when named. */
|
|
422
|
+
function visibleFieldNodes(root, kind = 'field', field) {
|
|
371
423
|
const inside = (node) => {
|
|
372
424
|
if (!root)
|
|
373
425
|
return true;
|
|
@@ -385,7 +437,7 @@ function innerForm(options, within) {
|
|
|
385
437
|
return true;
|
|
386
438
|
return false;
|
|
387
439
|
};
|
|
388
|
-
return [...index.values()].filter((node) => node.kind === kind && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
|
|
440
|
+
return [...index.values()].filter((node) => node.kind === kind && (field === undefined || node.field === field) && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
|
|
389
441
|
}
|
|
390
442
|
/** What a field node's answer rules say of its value now: the first error and the first warning. */
|
|
391
443
|
function ruleCheck(node) {
|
|
@@ -432,16 +484,52 @@ function innerForm(options, within) {
|
|
|
432
484
|
state = previous;
|
|
433
485
|
}
|
|
434
486
|
}
|
|
487
|
+
/** A range's last day (`options.endField` of a `daterange`), the field it writes besides its own; null for any other node. */
|
|
488
|
+
function rangeEnd(node) {
|
|
489
|
+
const source = node.source;
|
|
490
|
+
const end = source.widget === 'daterange' ? source.options?.['endField'] : undefined;
|
|
491
|
+
return typeof end === 'string' && page.fields[end] && end !== node.field ? end : null;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* A range's end, a field of its own though no node shows it alone: its required, min, max and days, and that it
|
|
495
|
+
* is not before its start ("Ends can’t be before Starts"). Required when its field is, or the range is.
|
|
496
|
+
*/
|
|
497
|
+
function rangeEndError(node, end, required = nodeState(node.id).required) {
|
|
498
|
+
const def = page.fields[end];
|
|
499
|
+
const value = state.values[end];
|
|
500
|
+
const own = (0, check_1.checkValue)(checked(end), value, required || def.required === true, messages, today());
|
|
501
|
+
if (own)
|
|
502
|
+
return own;
|
|
503
|
+
const start = state.values[node.field];
|
|
504
|
+
if (typeof start !== 'string' || typeof value !== 'string' || !start || !value)
|
|
505
|
+
return undefined;
|
|
506
|
+
const at = (v) => (def.type === 'datetime' ? Date.parse(v) : Date.parse(v.slice(0, 10)));
|
|
507
|
+
if (at(value) < at(start))
|
|
508
|
+
return (0, messages_1.fill)(messages.endBeforeStart, { end: def.label, start: page.fields[node.field].label });
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
435
511
|
/** Errors for the visible fields under `root`, without publishing them. */
|
|
436
512
|
function collectErrors(root) {
|
|
437
513
|
const errors = {};
|
|
514
|
+
const seen = new Set();
|
|
438
515
|
for (const node of visibleFieldNodes(root)) {
|
|
439
516
|
const name = node.field;
|
|
517
|
+
seen.add(name);
|
|
518
|
+
const end = rangeEnd(node);
|
|
519
|
+
if (end) {
|
|
520
|
+
seen.add(end);
|
|
521
|
+
const problem = errors[end] ? undefined : rangeEndError(node, end);
|
|
522
|
+
if (problem)
|
|
523
|
+
errors[end] = problem;
|
|
524
|
+
}
|
|
440
525
|
if (errors[name])
|
|
441
526
|
continue;
|
|
442
527
|
const def = page.fields[name];
|
|
443
528
|
const message = nodeError(node);
|
|
444
|
-
|
|
529
|
+
// A property says its own problem on its row; the field, only what is its own (that it is required).
|
|
530
|
+
if (def.type === 'properties')
|
|
531
|
+
Object.assign(errors, propertyProblems(name));
|
|
532
|
+
if (besidesRows(name, message))
|
|
445
533
|
errors[name] = message;
|
|
446
534
|
if (def.type === 'one2many') {
|
|
447
535
|
for (const line of state.values[name] ?? []) {
|
|
@@ -462,6 +550,10 @@ function innerForm(options, within) {
|
|
|
462
550
|
}
|
|
463
551
|
}
|
|
464
552
|
}
|
|
553
|
+
// Text left in a box that does not read answers for its field, in the words it was left with.
|
|
554
|
+
for (const [path, unread] of Object.entries(state.unread))
|
|
555
|
+
if (seen.has(path.split('.')[0]))
|
|
556
|
+
errors[path] = unread.message;
|
|
465
557
|
// A saved form placed here answers for its fields by its own page's rules.
|
|
466
558
|
for (const node of visibleFieldNodes(root, 'form')) {
|
|
467
559
|
const part = attached.get(node.id);
|
|
@@ -471,6 +563,63 @@ function innerForm(options, within) {
|
|
|
471
563
|
}
|
|
472
564
|
return errors;
|
|
473
565
|
}
|
|
566
|
+
/** The value at a path: a field's, or a line's cell (`field.lineKey.subfield`); `gone` when the line is not there. */
|
|
567
|
+
function valueAt(values, path) {
|
|
568
|
+
const parts = path.split('.');
|
|
569
|
+
// A property of a properties field (`field.property`).
|
|
570
|
+
if (parts.length === 2 && page.fields[parts[0]]?.type === 'properties') {
|
|
571
|
+
const held = values[parts[0]];
|
|
572
|
+
return held && typeof held === 'object' && !Array.isArray(held) ? held[parts[1]] : undefined;
|
|
573
|
+
}
|
|
574
|
+
if (parts.length < 3)
|
|
575
|
+
return values[path];
|
|
576
|
+
const [field, sub] = [parts[0], parts[parts.length - 1]];
|
|
577
|
+
const key = parts.slice(1, -1).join('.');
|
|
578
|
+
const line = (values[field] ?? []).find((l) => l.key === key);
|
|
579
|
+
return line ? line.values[sub] : gone;
|
|
580
|
+
}
|
|
581
|
+
/** Unread text whose field has since been written another way — a value typed that reads, the app's — is let go. */
|
|
582
|
+
function unreadKept(values) {
|
|
583
|
+
let kept = null;
|
|
584
|
+
for (const path of Object.keys(state.unread)) {
|
|
585
|
+
const now = valueAt(values, path);
|
|
586
|
+
if (now !== gone && now === valueAt(state.values, path))
|
|
587
|
+
continue;
|
|
588
|
+
kept ??= { ...state.unread };
|
|
589
|
+
delete kept[path];
|
|
590
|
+
}
|
|
591
|
+
return kept ?? state.unread;
|
|
592
|
+
}
|
|
593
|
+
/** A field's own problem as its box is left, never that it is required: undefined when it has none, or is not shown. */
|
|
594
|
+
function leftProblem(path) {
|
|
595
|
+
const parts = path.split('.');
|
|
596
|
+
const field = parts[0];
|
|
597
|
+
const shown = visibleFieldNodes(undefined, 'field', field);
|
|
598
|
+
// A range's end is left from its own box in the range: its problems, that it is before its start too.
|
|
599
|
+
const range = shown.length ? undefined : visibleFieldNodes().find((node) => rangeEnd(node) === field);
|
|
600
|
+
if (!shown.length && !range)
|
|
601
|
+
return undefined;
|
|
602
|
+
if (state.unread[path])
|
|
603
|
+
return state.unread[path].message;
|
|
604
|
+
if (range)
|
|
605
|
+
return (0, values_1.isEmpty)(fieldDef(field), state.values[field]) ? undefined : rangeEndError(range, field, false);
|
|
606
|
+
if (parts.length === 1) {
|
|
607
|
+
const value = state.values[field];
|
|
608
|
+
if ((0, values_1.isEmpty)(fieldDef(field), value))
|
|
609
|
+
return undefined;
|
|
610
|
+
return (shown.map((node) => (0, inputs_1.checkInput)(page.fields[field], node.source, value, messages)).find(Boolean) ??
|
|
611
|
+
besidesRows(field, (0, check_1.checkValue)(checked(field), value, false, messages, today())) ??
|
|
612
|
+
shown.map((node) => ruleCheck(node).error).find(Boolean));
|
|
613
|
+
}
|
|
614
|
+
if (parts.length === 2 && page.fields[field]?.type === 'properties')
|
|
615
|
+
return propertyProblems(field)[path];
|
|
616
|
+
const def = lineField(field);
|
|
617
|
+
const sub = def.fields[parts[parts.length - 1]];
|
|
618
|
+
const value = valueAt(state.values, path);
|
|
619
|
+
if (!sub || value === gone || (0, values_1.isEmpty)(sub, value))
|
|
620
|
+
return undefined;
|
|
621
|
+
return (0, check_1.checkValue)(sub, value, false, messages, today());
|
|
622
|
+
}
|
|
474
623
|
/**
|
|
475
624
|
* Answers changed from outside a saved form — loaded, put back, set — handed
|
|
476
625
|
* to it, and what it holds after taken back: its defaults and worked-out
|
|
@@ -517,26 +666,32 @@ function innerForm(options, within) {
|
|
|
517
666
|
/** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
|
|
518
667
|
function writeValues(written, extra = {}, fresh = false) {
|
|
519
668
|
const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
669
|
+
// Text left unread in a box goes once its field is written another way, and its words with it.
|
|
670
|
+
const unread = unreadKept(values);
|
|
671
|
+
const before = state;
|
|
672
|
+
state = { ...state, unread };
|
|
673
|
+
let errors = Object.keys(before.errors).length ? revalidateShown(values) : before.errors;
|
|
674
|
+
state = before;
|
|
675
|
+
// Words where a number goes — from a server or an old draft: a box never sets them — are said at once; what is
|
|
676
|
+
// missing waits for sending.
|
|
677
|
+
const words = {};
|
|
523
678
|
for (const name of Object.keys(written)) {
|
|
524
679
|
const def = page.fields[name];
|
|
525
680
|
if (written[name] === state.values[name] || !def)
|
|
526
681
|
continue;
|
|
527
682
|
if (notANumber(def, written[name]))
|
|
528
|
-
|
|
683
|
+
words[name] = (0, check_1.checkValue)(def, written[name], false, messages);
|
|
529
684
|
if (def.type !== 'one2many')
|
|
530
685
|
continue;
|
|
531
686
|
for (const line of written[name] ?? []) {
|
|
532
687
|
for (const [sub, v] of Object.entries(line.values))
|
|
533
688
|
if (notANumber(def.fields[sub], v))
|
|
534
|
-
|
|
689
|
+
words[`${name}.${line.key}.${sub}`] = (0, check_1.checkValue)(def.fields[sub], v, false, messages);
|
|
535
690
|
}
|
|
536
691
|
}
|
|
537
|
-
if (Object.keys(
|
|
538
|
-
errors = { ...errors, ...
|
|
539
|
-
set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
|
|
692
|
+
if (Object.keys(words).length)
|
|
693
|
+
errors = { ...errors, ...words };
|
|
694
|
+
set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), unread, ...extra });
|
|
540
695
|
}
|
|
541
696
|
/** Once errors are on screen, keep them current as the person fixes things. */
|
|
542
697
|
function revalidateShown(values) {
|
|
@@ -598,6 +753,10 @@ function innerForm(options, within) {
|
|
|
598
753
|
scheduler.clearTimeout(autosaveTimer);
|
|
599
754
|
autosaveTimer = scheduler.setTimeout(() => {
|
|
600
755
|
autosaveTimer = null;
|
|
756
|
+
// Nobody pressed anything: a box still typed in is never read under the person's keys. The save waits until it is
|
|
757
|
+
// left (or Enter is pressed in it), and reads it then, as any save does.
|
|
758
|
+
if (typingNow())
|
|
759
|
+
return scheduleAutosave();
|
|
601
760
|
if (state.dirty.length && Object.keys(collectErrors()).length === 0)
|
|
602
761
|
void track(save());
|
|
603
762
|
}, autosave.delayMs);
|
|
@@ -668,6 +827,11 @@ function innerForm(options, within) {
|
|
|
668
827
|
* and a stop keeps it unsaved; `afterSave` runs once it is saved.
|
|
669
828
|
*/
|
|
670
829
|
async function saving(chain) {
|
|
830
|
+
// What is still typed in a box is read first: it is what is saved, or why nothing is. A name still being looked
|
|
831
|
+
// up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
|
|
832
|
+
const waiting = readTyped();
|
|
833
|
+
if (waiting)
|
|
834
|
+
await waiting;
|
|
671
835
|
if (page.on?.beforeSave) {
|
|
672
836
|
const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
|
|
673
837
|
if (!before.done) {
|
|
@@ -712,7 +876,7 @@ function innerForm(options, within) {
|
|
|
712
876
|
// Lines the save brings back load again, in their tables' orders, as Flectra's reload after a save.
|
|
713
877
|
const values = syncParts(startFrom(result.values ? ordered({ ...state.values, ...result.values }, Object.keys(result.values)) : state.values), 'start');
|
|
714
878
|
baseline = (0, values_1.structuredCopy)(values);
|
|
715
|
-
set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
|
|
879
|
+
set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings), unread: {} });
|
|
716
880
|
emit('save', { recordId: state.recordId, values: state.values });
|
|
717
881
|
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
718
882
|
return null;
|
|
@@ -732,6 +896,23 @@ function innerForm(options, within) {
|
|
|
732
896
|
showing = true;
|
|
733
897
|
tellStep([]);
|
|
734
898
|
}
|
|
899
|
+
/**
|
|
900
|
+
* Values the record holds where it is kept, written as loaded ones are: the
|
|
901
|
+
* starting point moves with them, so they are no change to save, and no
|
|
902
|
+
* change steps, draft or autosave follow. A change of the person's in the
|
|
903
|
+
* same field is taken over by them.
|
|
904
|
+
*/
|
|
905
|
+
function store(written) {
|
|
906
|
+
const names = Object.keys(written);
|
|
907
|
+
names.forEach(fieldDef);
|
|
908
|
+
if (!names.length)
|
|
909
|
+
return;
|
|
910
|
+
const copy = (0, values_1.structuredCopy)(written);
|
|
911
|
+
baseline = { ...baseline, ...copy };
|
|
912
|
+
writeValues({ ...state.values, ...copy });
|
|
913
|
+
for (const name of names)
|
|
914
|
+
changed(name, 'app');
|
|
915
|
+
}
|
|
735
916
|
/** Write fields — a step's or the app's — worked out once, and tell each. */
|
|
736
917
|
function write(written, by) {
|
|
737
918
|
const names = Object.keys(written);
|
|
@@ -769,9 +950,15 @@ function innerForm(options, within) {
|
|
|
769
950
|
changed(field, by);
|
|
770
951
|
return key;
|
|
771
952
|
}
|
|
772
|
-
/**
|
|
773
|
-
|
|
953
|
+
/**
|
|
954
|
+
* Check the form, or only these fields, showing what is found as sending does: a step's check, which waits for a
|
|
955
|
+
* name still being looked up in a box, as a save does.
|
|
956
|
+
*/
|
|
957
|
+
async function check(names) {
|
|
774
958
|
names?.forEach(fieldDef);
|
|
959
|
+
const waiting = readTyped();
|
|
960
|
+
if (waiting)
|
|
961
|
+
await waiting;
|
|
775
962
|
const all = collectErrors();
|
|
776
963
|
const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
|
|
777
964
|
set({ errors: found });
|
|
@@ -779,11 +966,15 @@ function innerForm(options, within) {
|
|
|
779
966
|
return first === undefined ? null : { reason: 'check', message: first };
|
|
780
967
|
}
|
|
781
968
|
/** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
|
|
782
|
-
function goToStep(target, chain) {
|
|
969
|
+
async function goToStep(target, chain) {
|
|
783
970
|
if (!steps.includes(target))
|
|
784
971
|
return undefined;
|
|
785
972
|
if (!form.steps().includes(target))
|
|
786
973
|
return { reason: 'cannot', message: `The step "${target}" does not apply now` };
|
|
974
|
+
// A name still being looked up in a box on the way is waited for, as a save waits for it.
|
|
975
|
+
const waiting = readTyped();
|
|
976
|
+
if (waiting)
|
|
977
|
+
await waiting;
|
|
787
978
|
let went = false;
|
|
788
979
|
inRun(chain, () => (went = form.goTo(target)));
|
|
789
980
|
return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
|
|
@@ -808,18 +999,20 @@ function innerForm(options, within) {
|
|
|
808
999
|
return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
|
|
809
1000
|
}
|
|
810
1001
|
async function runSearch(def, name, ctx, query, limit, model, ids) {
|
|
811
|
-
|
|
812
|
-
|
|
1002
|
+
// Formatted text names people and records in place (@mention, #4127): searched by the model they are of.
|
|
1003
|
+
const byModel = def.type === 'reference' || def.type === 'json' || def.type === 'html';
|
|
1004
|
+
if (def.type !== 'many2one' && def.type !== 'many2many' && !byModel) {
|
|
1005
|
+
throw new Error(`"${name}" is not a many2one, many2many, reference, json or html`);
|
|
813
1006
|
}
|
|
814
|
-
if (
|
|
1007
|
+
if (byModel && !model)
|
|
815
1008
|
throw new Error(`Searching "${name}" needs a model`);
|
|
816
1009
|
const source = options.dataSource;
|
|
817
1010
|
if (!source?.search)
|
|
818
1011
|
return [];
|
|
819
1012
|
// Every valueFrom, in groups too, becomes the value it names before the search goes out.
|
|
820
|
-
const own =
|
|
1013
|
+
const own = byModel || !def.filter ? undefined : (0, filter_1.resolveFilter)(def.filter, ctx);
|
|
821
1014
|
const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
|
|
822
|
-
const relation =
|
|
1015
|
+
const relation = byModel ? model : def.relation;
|
|
823
1016
|
return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
|
|
824
1017
|
}
|
|
825
1018
|
function lineField(field) {
|
|
@@ -853,7 +1046,7 @@ function innerForm(options, within) {
|
|
|
853
1046
|
return;
|
|
854
1047
|
// Lines load in their tables' orders; where a person moves them after, they stay.
|
|
855
1048
|
baseline = syncParts(startFrom(ordered({ ...(0, values_1.initialValues)(fields, today()), ...loaded })), 'start');
|
|
856
|
-
set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
|
|
1049
|
+
set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), unread: {} });
|
|
857
1050
|
if (again)
|
|
858
1051
|
return;
|
|
859
1052
|
offerDraft();
|
|
@@ -875,7 +1068,7 @@ function innerForm(options, within) {
|
|
|
875
1068
|
serverErrors = {};
|
|
876
1069
|
told = null;
|
|
877
1070
|
showing = false;
|
|
878
|
-
const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null };
|
|
1071
|
+
const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} };
|
|
879
1072
|
if (id != null) {
|
|
880
1073
|
set({ ...fresh, recordId: id, status: 'idle' });
|
|
881
1074
|
return load();
|
|
@@ -910,6 +1103,7 @@ function innerForm(options, within) {
|
|
|
910
1103
|
reading,
|
|
911
1104
|
today,
|
|
912
1105
|
write: (values) => write(values, 'step'),
|
|
1106
|
+
store: (values) => store(values),
|
|
913
1107
|
addLine: (field, values) => void addLine(field, values, 'step'),
|
|
914
1108
|
check,
|
|
915
1109
|
save: saving,
|
|
@@ -984,7 +1178,7 @@ function innerForm(options, within) {
|
|
|
984
1178
|
afterEdit(name);
|
|
985
1179
|
changed(name, how?.by ?? 'person');
|
|
986
1180
|
},
|
|
987
|
-
setValues: (values) => write(values, 'app'),
|
|
1181
|
+
setValues: (values, how) => (how?.stored ? store(values) : write(values, 'app')),
|
|
988
1182
|
node: nodeState,
|
|
989
1183
|
openRecord: (id) => track(openRecord(id)),
|
|
990
1184
|
post,
|
|
@@ -1005,12 +1199,65 @@ function innerForm(options, within) {
|
|
|
1005
1199
|
const shown = visibleFieldNodes().filter((node) => node.field === name);
|
|
1006
1200
|
if (!shown.length)
|
|
1007
1201
|
return null;
|
|
1202
|
+
if (state.unread[name])
|
|
1203
|
+
return state.unread[name].message;
|
|
1204
|
+
// A property's text left unread answers for its field.
|
|
1205
|
+
if (page.fields[name].type === 'properties') {
|
|
1206
|
+
const row = Object.keys(state.unread).find((path) => path.startsWith(`${name}.`));
|
|
1207
|
+
if (row)
|
|
1208
|
+
return state.unread[row].message;
|
|
1209
|
+
}
|
|
1008
1210
|
const own = shown.map(unticked).find(Boolean) ??
|
|
1009
1211
|
shown.map((node) => (0, inputs_1.checkInput)(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
|
|
1010
1212
|
(0, check_1.checkValue)(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
|
|
1011
1213
|
return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
|
|
1012
1214
|
},
|
|
1215
|
+
setUnread(path, unread) {
|
|
1216
|
+
const field = path.split('.')[0];
|
|
1217
|
+
// A line's cell (`field.lineKey.subfield`), or a property (`field.property`).
|
|
1218
|
+
const def = fieldDef(field);
|
|
1219
|
+
if (path.includes('.') && def.type !== 'properties')
|
|
1220
|
+
lineField(field);
|
|
1221
|
+
const had = state.unread[path];
|
|
1222
|
+
if (!unread && !had)
|
|
1223
|
+
return;
|
|
1224
|
+
// A line taken away takes its cells' text with it: a box of it still reading (a name looked up) keeps none.
|
|
1225
|
+
if (unread && !had && path.split('.').length >= 3 && def.type !== 'properties' && valueAt(state.values, path) === gone)
|
|
1226
|
+
return;
|
|
1227
|
+
if (unread && had && had.text === unread.text && had.message === unread.message)
|
|
1228
|
+
return;
|
|
1229
|
+
const next = { ...state.unread };
|
|
1230
|
+
if (unread)
|
|
1231
|
+
next[path] = { text: unread.text, message: unread.message };
|
|
1232
|
+
else
|
|
1233
|
+
delete next[path];
|
|
1234
|
+
// Problems on screen stay current: an unread text put right takes its words with it.
|
|
1235
|
+
const before = state;
|
|
1236
|
+
state = { ...state, unread: next };
|
|
1237
|
+
const errors = Object.keys(before.errors).length ? revalidateShown(state.values) : before.errors;
|
|
1238
|
+
state = before;
|
|
1239
|
+
set({ unread: next, errors });
|
|
1240
|
+
},
|
|
1241
|
+
leave(path) {
|
|
1242
|
+
const problem = leftProblem(path);
|
|
1243
|
+
if (!problem || state.errors[path] === problem)
|
|
1244
|
+
return;
|
|
1245
|
+
set({ errors: { ...state.errors, [path]: problem } });
|
|
1246
|
+
},
|
|
1247
|
+
settling(read, typing) {
|
|
1248
|
+
readers.add(read);
|
|
1249
|
+
if (typing)
|
|
1250
|
+
typings.add(typing);
|
|
1251
|
+
return () => {
|
|
1252
|
+
readers.delete(read);
|
|
1253
|
+
if (typing)
|
|
1254
|
+
typings.delete(typing);
|
|
1255
|
+
};
|
|
1256
|
+
},
|
|
1257
|
+
settle: () => readTyped() ?? Promise.resolve(),
|
|
1258
|
+
reading: () => readingNow > 0 || [...attached.values()].some((part) => part.inner.form.reading()),
|
|
1013
1259
|
validate() {
|
|
1260
|
+
readTyped();
|
|
1014
1261
|
const errors = collectErrors();
|
|
1015
1262
|
set({ errors });
|
|
1016
1263
|
return Object.keys(errors).length === 0;
|
|
@@ -1021,7 +1268,7 @@ function innerForm(options, within) {
|
|
|
1021
1268
|
serverErrors = {};
|
|
1022
1269
|
holding = setWhen.holding(baseline);
|
|
1023
1270
|
const values = syncParts((0, values_1.structuredCopy)(baseline), 'start');
|
|
1024
|
-
set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
|
|
1271
|
+
set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null, unread: {} });
|
|
1025
1272
|
},
|
|
1026
1273
|
changes,
|
|
1027
1274
|
addLine: (field, values = {}) => addLine(field, values, 'person'),
|
|
@@ -1116,6 +1363,7 @@ function innerForm(options, within) {
|
|
|
1116
1363
|
next() {
|
|
1117
1364
|
if (!state.step)
|
|
1118
1365
|
return false;
|
|
1366
|
+
readTyped();
|
|
1119
1367
|
// A step skipped before and gone back to is answered now, and checked like any other.
|
|
1120
1368
|
if (state.skipped.includes(state.step))
|
|
1121
1369
|
set({ skipped: state.skipped.filter((id) => id !== state.step) });
|
|
@@ -1162,6 +1410,7 @@ function innerForm(options, within) {
|
|
|
1162
1410
|
set({ step: target, errors: {} });
|
|
1163
1411
|
return true;
|
|
1164
1412
|
}
|
|
1413
|
+
readTyped();
|
|
1165
1414
|
const skipped = [...state.skipped];
|
|
1166
1415
|
for (const id of visible.slice(at, to)) {
|
|
1167
1416
|
if (skipped.includes(id))
|
|
@@ -1364,9 +1613,10 @@ function innerForm(options, within) {
|
|
|
1364
1613
|
return writeValues({ ...(0, values_1.initialValues)(page.fields, today()), ...values }, {}, how === 'fresh');
|
|
1365
1614
|
baseline = startFrom({ ...(0, values_1.initialValues)(page.fields, today()), ...(0, values_1.structuredCopy)(values) });
|
|
1366
1615
|
const start = syncParts((0, values_1.structuredCopy)(baseline), 'start');
|
|
1367
|
-
set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null });
|
|
1616
|
+
set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null, unread: {} });
|
|
1368
1617
|
},
|
|
1369
1618
|
errorsNow: () => collectErrors(),
|
|
1619
|
+
typing: typingNow,
|
|
1370
1620
|
show(errors) {
|
|
1371
1621
|
if (JSON.stringify(errors) !== JSON.stringify(state.errors))
|
|
1372
1622
|
set({ errors });
|
|
@@ -1443,6 +1693,7 @@ function indexLayout(root) {
|
|
|
1443
1693
|
const index = new Map();
|
|
1444
1694
|
const add = (source, parent, kind, modifiers) => {
|
|
1445
1695
|
const roles = source.roles;
|
|
1696
|
+
const pressed = kind === 'button' ? source.pressed : undefined;
|
|
1446
1697
|
index.set(source.id, {
|
|
1447
1698
|
id: source.id,
|
|
1448
1699
|
parent,
|
|
@@ -1452,8 +1703,13 @@ function indexLayout(root) {
|
|
|
1452
1703
|
invisible: (0, modifier_1.compileModifier)(modifiers.invisible),
|
|
1453
1704
|
readonly: (0, modifier_1.compileModifier)(modifiers.readonly),
|
|
1454
1705
|
required: (0, modifier_1.compileModifier)(modifiers.required),
|
|
1706
|
+
...(pressed !== undefined ? { pressed: (0, modifier_1.compileModifier)(pressed) } : {}),
|
|
1455
1707
|
source,
|
|
1456
1708
|
});
|
|
1709
|
+
// A button's menu: each item a button of the record, hidden with it.
|
|
1710
|
+
if (kind === 'button')
|
|
1711
|
+
for (const item of source.menu ?? [])
|
|
1712
|
+
add(item, source.id, 'button', { invisible: item.invisible });
|
|
1457
1713
|
};
|
|
1458
1714
|
const walk = (nodes, parent) => {
|
|
1459
1715
|
for (const node of nodes) {
|
|
@@ -1537,6 +1793,17 @@ function indexLayout(root) {
|
|
|
1537
1793
|
// Fields over and under the title, and on its line, are field nodes like any other.
|
|
1538
1794
|
walk([...(root.title?.above ?? []), ...(root.title?.before ?? []), ...(root.title?.after ?? []), ...(root.title?.below ?? [])], root.id);
|
|
1539
1795
|
walk(root.children, root.id);
|
|
1796
|
+
// The head's row, buttons and band: its fields are field nodes, its link and buttons buttons of the record.
|
|
1797
|
+
const head = root.head;
|
|
1798
|
+
for (const part of head?.meta ?? []) {
|
|
1799
|
+
if (part.type === 'field')
|
|
1800
|
+
walk([part], root.id);
|
|
1801
|
+
else
|
|
1802
|
+
add(part, root.id, 'button', { invisible: part.invisible });
|
|
1803
|
+
}
|
|
1804
|
+
for (const button of head?.actions ?? [])
|
|
1805
|
+
add(button, root.id, 'button', { invisible: button.invisible });
|
|
1806
|
+
walk(head?.band?.children ?? [], root.id);
|
|
1540
1807
|
if (root.sidePanel)
|
|
1541
1808
|
add(root.sidePanel, root.id, 'other', { invisible: root.sidePanel.invisible });
|
|
1542
1809
|
// The gear menu's items are buttons of the record; Archive shows while it is active, Unarchive while it is not.
|