@fieldia/core 0.9.2 → 0.11.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 +199 -0
- package/package.json +1 -1
- package/schema/page.schema.json +1837 -94
- package/src/index.d.mts +1 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +4 -1
- package/src/index.js.map +1 -1
- package/src/index.mjs +1 -0
- package/src/index.mjs.map +1 -1
- package/src/lib/expression/evaluator.d.mts +6 -5
- package/src/lib/expression/evaluator.d.ts +6 -5
- package/src/lib/expression/evaluator.js +37 -22
- package/src/lib/expression/evaluator.js.map +1 -1
- package/src/lib/expression/evaluator.mjs +32 -18
- package/src/lib/expression/evaluator.mjs.map +1 -1
- package/src/lib/expression/expression.d.mts +21 -0
- package/src/lib/expression/expression.d.ts +21 -0
- package/src/lib/expression/expression.js +29 -0
- package/src/lib/expression/expression.js.map +1 -0
- package/src/lib/expression/expression.mjs +26 -0
- package/src/lib/expression/expression.mjs.map +1 -0
- package/src/lib/expression/extractFieldNames.js +6 -1
- package/src/lib/expression/extractFieldNames.js.map +1 -1
- package/src/lib/expression/extractFieldNames.mjs +6 -1
- package/src/lib/expression/extractFieldNames.mjs.map +1 -1
- package/src/lib/expression/functions.d.mts +35 -0
- package/src/lib/expression/functions.d.ts +35 -0
- package/src/lib/expression/functions.js +129 -0
- package/src/lib/expression/functions.js.map +1 -0
- package/src/lib/expression/functions.mjs +124 -0
- package/src/lib/expression/functions.mjs.map +1 -0
- package/src/lib/expression/modifier.d.mts +3 -1
- package/src/lib/expression/modifier.d.ts +3 -1
- package/src/lib/expression/modifier.js +4 -27
- package/src/lib/expression/modifier.js.map +1 -1
- package/src/lib/expression/modifier.mjs +3 -26
- package/src/lib/expression/modifier.mjs.map +1 -1
- package/src/lib/expression/operations.d.mts +25 -0
- package/src/lib/expression/operations.d.ts +25 -0
- package/src/lib/expression/operations.js +81 -0
- package/src/lib/expression/operations.js.map +1 -0
- package/src/lib/expression/operations.mjs +72 -0
- package/src/lib/expression/operations.mjs.map +1 -0
- package/src/lib/expression/order.d.mts +11 -0
- package/src/lib/expression/order.d.ts +11 -0
- package/src/lib/expression/order.js +36 -0
- package/src/lib/expression/order.js.map +1 -0
- package/src/lib/expression/order.mjs +33 -0
- package/src/lib/expression/order.mjs.map +1 -0
- package/src/lib/expression/parser.d.mts +34 -4
- package/src/lib/expression/parser.d.ts +34 -4
- package/src/lib/expression/parser.js +72 -6
- package/src/lib/expression/parser.js.map +1 -1
- package/src/lib/expression/parser.mjs +72 -6
- package/src/lib/expression/parser.mjs.map +1 -1
- package/src/lib/expression/reads.d.mts +8 -0
- package/src/lib/expression/reads.d.ts +8 -0
- package/src/lib/expression/reads.js +48 -0
- package/src/lib/expression/reads.js.map +1 -0
- package/src/lib/expression/reads.mjs +44 -0
- package/src/lib/expression/reads.mjs.map +1 -0
- package/src/lib/expression/tokenizer.d.mts +7 -6
- package/src/lib/expression/tokenizer.d.ts +7 -6
- package/src/lib/expression/tokenizer.js +28 -18
- package/src/lib/expression/tokenizer.js.map +1 -1
- package/src/lib/expression/tokenizer.mjs +28 -18
- package/src/lib/expression/tokenizer.mjs.map +1 -1
- package/src/lib/format/actions.d.mts +692 -0
- package/src/lib/format/actions.d.ts +692 -0
- package/src/lib/format/actions.js +53 -0
- package/src/lib/format/actions.js.map +1 -0
- package/src/lib/format/actions.mjs +50 -0
- package/src/lib/format/actions.mjs.map +1 -0
- package/src/lib/format/field.d.mts +686 -5
- package/src/lib/format/field.d.ts +686 -5
- package/src/lib/format/field.js +117 -13
- package/src/lib/format/field.js.map +1 -1
- package/src/lib/format/field.mjs +116 -12
- package/src/lib/format/field.mjs.map +1 -1
- package/src/lib/format/layout.d.mts +1130 -22
- package/src/lib/format/layout.d.ts +1130 -22
- package/src/lib/format/layout.js +67 -5
- package/src/lib/format/layout.js.map +1 -1
- package/src/lib/format/layout.mjs +66 -4
- package/src/lib/format/layout.mjs.map +1 -1
- package/src/lib/format/layout.types.d.mts +2 -1
- package/src/lib/format/layout.types.d.ts +2 -1
- package/src/lib/format/names.d.mts +1 -1
- package/src/lib/format/names.d.ts +1 -1
- package/src/lib/format/names.js +1 -0
- package/src/lib/format/names.js.map +1 -1
- package/src/lib/format/names.mjs +1 -0
- package/src/lib/format/names.mjs.map +1 -1
- package/src/lib/format/page.d.mts +1007 -7
- package/src/lib/format/page.d.ts +1007 -7
- package/src/lib/format/page.js +28 -0
- package/src/lib/format/page.js.map +1 -1
- package/src/lib/format/page.mjs +28 -0
- package/src/lib/format/page.mjs.map +1 -1
- package/src/lib/format/part-look.d.mts +36 -0
- package/src/lib/format/part-look.d.ts +36 -0
- package/src/lib/format/part-look.js +23 -0
- package/src/lib/format/part-look.js.map +1 -0
- package/src/lib/format/part-look.mjs +20 -0
- package/src/lib/format/part-look.mjs.map +1 -0
- package/src/lib/format/references.d.mts +33 -1
- package/src/lib/format/references.d.ts +33 -1
- package/src/lib/format/references.js +227 -10
- package/src/lib/format/references.js.map +1 -1
- package/src/lib/format/references.mjs +227 -10
- package/src/lib/format/references.mjs.map +1 -1
- package/src/lib/format/translate.d.mts +20 -0
- package/src/lib/format/translate.d.ts +20 -0
- package/src/lib/format/translate.js +79 -21
- package/src/lib/format/translate.js.map +1 -1
- package/src/lib/format/translate.mjs +76 -21
- package/src/lib/format/translate.mjs.map +1 -1
- package/src/lib/record/check.d.mts +19 -1
- package/src/lib/record/check.d.ts +19 -1
- package/src/lib/record/check.js +84 -21
- package/src/lib/record/check.js.map +1 -1
- package/src/lib/record/check.mjs +82 -21
- package/src/lib/record/check.mjs.map +1 -1
- package/src/lib/record/compute.d.mts +24 -0
- package/src/lib/record/compute.d.ts +24 -0
- package/src/lib/record/compute.js +107 -0
- package/src/lib/record/compute.js.map +1 -0
- package/src/lib/record/compute.mjs +103 -0
- package/src/lib/record/compute.mjs.map +1 -0
- package/src/lib/record/data-source.d.mts +14 -2
- package/src/lib/record/data-source.d.ts +14 -2
- package/src/lib/record/data-source.js.map +1 -1
- package/src/lib/record/data-source.mjs.map +1 -1
- package/src/lib/record/env.d.mts +9 -0
- package/src/lib/record/env.d.ts +9 -0
- package/src/lib/record/env.js +28 -0
- package/src/lib/record/env.js.map +1 -0
- package/src/lib/record/env.mjs +25 -0
- package/src/lib/record/env.mjs.map +1 -0
- package/src/lib/record/form.d.mts +110 -18
- package/src/lib/record/form.d.ts +110 -18
- package/src/lib/record/form.js +497 -72
- package/src/lib/record/form.js.map +1 -1
- package/src/lib/record/form.mjs +497 -72
- package/src/lib/record/form.mjs.map +1 -1
- package/src/lib/record/inputs.d.mts +5 -0
- package/src/lib/record/inputs.d.ts +5 -0
- package/src/lib/record/inputs.js +47 -0
- package/src/lib/record/inputs.js.map +1 -0
- package/src/lib/record/inputs.mjs +44 -0
- package/src/lib/record/inputs.mjs.map +1 -0
- package/src/lib/record/limits.d.mts +13 -0
- package/src/lib/record/limits.d.ts +13 -0
- package/src/lib/record/limits.js +45 -0
- package/src/lib/record/limits.js.map +1 -0
- package/src/lib/record/limits.mjs +41 -0
- package/src/lib/record/limits.mjs.map +1 -0
- package/src/lib/record/locales/all.d.mts +7 -0
- package/src/lib/record/locales/all.d.ts +7 -0
- package/src/lib/record/locales/all.js +13 -0
- package/src/lib/record/locales/all.js.map +1 -0
- package/src/lib/record/locales/all.mjs +10 -0
- package/src/lib/record/locales/all.mjs.map +1 -0
- package/src/lib/record/locales/ar.d.mts +3 -0
- package/src/lib/record/locales/ar.d.ts +3 -0
- package/src/lib/record/locales/ar.js +55 -0
- package/src/lib/record/locales/ar.js.map +1 -0
- package/src/lib/record/locales/ar.mjs +52 -0
- package/src/lib/record/locales/ar.mjs.map +1 -0
- package/src/lib/record/locales/de.d.mts +3 -0
- package/src/lib/record/locales/de.d.ts +3 -0
- package/src/lib/record/locales/de.js +55 -0
- package/src/lib/record/locales/de.js.map +1 -0
- package/src/lib/record/locales/de.mjs +52 -0
- package/src/lib/record/locales/de.mjs.map +1 -0
- package/src/lib/record/locales/fr.d.mts +3 -0
- package/src/lib/record/locales/fr.d.ts +3 -0
- package/src/lib/record/locales/fr.js +55 -0
- package/src/lib/record/locales/fr.js.map +1 -0
- package/src/lib/record/locales/fr.mjs +52 -0
- package/src/lib/record/locales/fr.mjs.map +1 -0
- package/src/lib/record/memory-data-source.d.mts +3 -0
- package/src/lib/record/memory-data-source.d.ts +3 -0
- package/src/lib/record/memory-data-source.js +8 -0
- package/src/lib/record/memory-data-source.js.map +1 -1
- package/src/lib/record/memory-data-source.mjs +8 -0
- package/src/lib/record/memory-data-source.mjs.map +1 -1
- package/src/lib/record/messages.d.mts +46 -0
- package/src/lib/record/messages.d.ts +46 -0
- package/src/lib/record/messages.js +58 -101
- package/src/lib/record/messages.js.map +1 -1
- package/src/lib/record/messages.mjs +58 -101
- package/src/lib/record/messages.mjs.map +1 -1
- package/src/lib/record/rules.d.mts +33 -0
- package/src/lib/record/rules.d.ts +33 -0
- package/src/lib/record/rules.js +83 -0
- package/src/lib/record/rules.js.map +1 -0
- package/src/lib/record/rules.mjs +79 -0
- package/src/lib/record/rules.mjs.map +1 -0
- package/src/lib/record/run.d.mts +169 -0
- package/src/lib/record/run.d.ts +169 -0
- package/src/lib/record/run.js +350 -0
- package/src/lib/record/run.js.map +1 -0
- package/src/lib/record/run.mjs +345 -0
- package/src/lib/record/run.mjs.map +1 -0
- package/src/lib/record/score.d.mts +16 -0
- package/src/lib/record/score.d.ts +16 -0
- package/src/lib/record/score.js +35 -0
- package/src/lib/record/score.js.map +1 -0
- package/src/lib/record/score.mjs +32 -0
- package/src/lib/record/score.mjs.map +1 -0
- package/src/lib/record/set-when.d.mts +24 -0
- package/src/lib/record/set-when.d.ts +24 -0
- package/src/lib/record/set-when.js +68 -0
- package/src/lib/record/set-when.js.map +1 -0
- package/src/lib/record/set-when.mjs +65 -0
- package/src/lib/record/set-when.mjs.map +1 -0
- package/src/lib/record/structures.d.mts +14 -0
- package/src/lib/record/structures.d.ts +14 -0
- package/src/lib/record/structures.js +32 -0
- package/src/lib/record/structures.js.map +1 -0
- package/src/lib/record/structures.mjs +28 -0
- package/src/lib/record/structures.mjs.map +1 -0
- package/src/lib/record/values.d.mts +10 -5
- package/src/lib/record/values.d.ts +10 -5
- package/src/lib/record/values.js +20 -6
- package/src/lib/record/values.js.map +1 -1
- package/src/lib/record/values.mjs +19 -6
- package/src/lib/record/values.mjs.map +1 -1
- package/src/runtime.d.mts +13 -7
- package/src/runtime.d.ts +13 -7
- package/src/runtime.js +14 -1
- package/src/runtime.js.map +1 -1
- package/src/runtime.mjs +6 -2
- package/src/runtime.mjs.map +1 -1
package/src/lib/record/form.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { compileModifier } from '../expression/modifier.mjs';
|
|
2
|
+
import { localDay } from '../expression/functions.mjs';
|
|
2
3
|
import { checkValue } from './check.mjs';
|
|
4
|
+
import { checkInput } from './inputs.mjs';
|
|
5
|
+
import { compileComputed } from './compute.mjs';
|
|
6
|
+
import { compileSetWhen } from './set-when.mjs';
|
|
7
|
+
import { checkRules, compileRules } from './rules.mjs';
|
|
8
|
+
import { expressionEnv } from './env.mjs';
|
|
3
9
|
import { MESSAGES } from './messages.mjs';
|
|
4
10
|
import { saveProblemOf } from './data-source.mjs';
|
|
5
11
|
import { hostScheduler } from './scheduler.mjs';
|
|
12
|
+
import { createMoments, createRunner } from './run.mjs';
|
|
6
13
|
import { emptyValue, expressionContext, initialValues, isEmpty, lineKind, structuredCopy, } from './values.mjs';
|
|
7
14
|
/**
|
|
8
15
|
* The record state behind a page: values, what changed, what is visible and
|
|
@@ -10,19 +17,54 @@ import { emptyValue, expressionContext, initialValues, isEmpty, lineKind, struct
|
|
|
10
17
|
* binding renders from `getState()` and calls these methods.
|
|
11
18
|
*/
|
|
12
19
|
export function createForm(options) {
|
|
20
|
+
return innerForm(options, []).form;
|
|
21
|
+
}
|
|
22
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
23
|
+
/** A saved form's answers, as the one value they are in the form round it. */
|
|
24
|
+
const asValue = (values) => values;
|
|
25
|
+
/** `within` are the pages round this one, outermost first: a page among them placed again would hold itself. */
|
|
26
|
+
function innerForm(options, within) {
|
|
13
27
|
const { page } = options;
|
|
14
28
|
const scheduler = options.scheduler ?? hostScheduler;
|
|
15
29
|
const messages = options.messages ?? MESSAGES.en;
|
|
16
30
|
const index = indexLayout(page.layout);
|
|
31
|
+
/** Each field node's answer rules, read once. */
|
|
32
|
+
const nodeRules = new Map();
|
|
33
|
+
for (const node of index.values()) {
|
|
34
|
+
const rules = node.source.validate;
|
|
35
|
+
if (node.kind === 'field' && rules)
|
|
36
|
+
nodeRules.set(node.id, compileRules(rules));
|
|
37
|
+
}
|
|
38
|
+
/** Whether any rule only warns: without one, there are never warnings to look for. */
|
|
39
|
+
const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
|
|
17
40
|
const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
|
|
18
41
|
const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
|
|
19
|
-
|
|
42
|
+
const today = () => localDay(options.now?.() ?? new Date());
|
|
43
|
+
const computed = compileComputed(page, today);
|
|
44
|
+
const setWhen = compileSetWhen(page, today);
|
|
45
|
+
/** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
|
|
46
|
+
const parts = new Map();
|
|
47
|
+
for (const node of index.values())
|
|
48
|
+
if (node.kind === 'form')
|
|
49
|
+
parts.set(node.id, node.source);
|
|
50
|
+
/** Those given their page, with the inner form that holds their answers. */
|
|
51
|
+
const attached = new Map();
|
|
52
|
+
/** The page's fields, and each saved form's answers as a JSON value: what is loaded, told as changed and saved. */
|
|
53
|
+
const fields = { ...page.fields };
|
|
54
|
+
for (const part of parts.values())
|
|
55
|
+
fields[part.name] = { type: 'json', label: part.title || part.name };
|
|
56
|
+
/** True while answers are handed to an inner form: what it says back then is no change of its own. */
|
|
57
|
+
let syncing = false;
|
|
58
|
+
let baseline = computed.apply({ ...initialValues(fields, today()), ...structuredCopy(options.values ?? {}) });
|
|
59
|
+
/** The setWhen conditions holding for the values as they last settled: one starts to hold only against these. */
|
|
60
|
+
let holding = setWhen.holding(baseline);
|
|
20
61
|
let state = {
|
|
21
62
|
status: options.recordId != null ? 'idle' : 'ready',
|
|
22
63
|
recordId: options.recordId ?? null,
|
|
23
64
|
values: structuredCopy(baseline),
|
|
24
65
|
errors: {},
|
|
25
66
|
dirty: [],
|
|
67
|
+
warnings: {},
|
|
26
68
|
warning: null,
|
|
27
69
|
warningField: null,
|
|
28
70
|
error: null,
|
|
@@ -30,6 +72,7 @@ export function createForm(options) {
|
|
|
30
72
|
step: steps[0] ?? null,
|
|
31
73
|
skipped: [],
|
|
32
74
|
saveProblem: null,
|
|
75
|
+
choices: {},
|
|
33
76
|
};
|
|
34
77
|
/** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
|
|
35
78
|
let serverErrors = {};
|
|
@@ -39,11 +82,87 @@ export function createForm(options) {
|
|
|
39
82
|
let autosaveTimer = null;
|
|
40
83
|
const pending = new Set();
|
|
41
84
|
const listeners = new Set();
|
|
85
|
+
/** The app's listeners, by event. */
|
|
86
|
+
const events = new Map();
|
|
87
|
+
/** The moments the run going to a step or tab right now was set off by: the `show` that entry runs knows them, and does not set them off again. */
|
|
88
|
+
let entering = [];
|
|
89
|
+
/** Whether wizard steps entered are told: from once the form has opened and run its `open` steps. */
|
|
90
|
+
let showing = false;
|
|
91
|
+
/** The wizard step last told as entered. */
|
|
92
|
+
let told = null;
|
|
42
93
|
let contextCache = null;
|
|
94
|
+
/** The latest load of each field's choices: an answer to an older one is let go. */
|
|
95
|
+
const choiceLoads = {};
|
|
43
96
|
function set(patch) {
|
|
97
|
+
const was = state.values;
|
|
98
|
+
const shown = state.errors;
|
|
44
99
|
state = { ...state, ...patch };
|
|
100
|
+
// The problems found inside a saved form are shown on it, beside its own fields.
|
|
101
|
+
if (state.errors !== shown)
|
|
102
|
+
for (const part of attached.values())
|
|
103
|
+
part.inner.show(inside(state.errors, part.name));
|
|
45
104
|
for (const listener of [...listeners])
|
|
46
105
|
listener(state);
|
|
106
|
+
// Choices that change with a value that just changed are asked for again.
|
|
107
|
+
if (state.values !== was) {
|
|
108
|
+
for (const name in state.choices)
|
|
109
|
+
if (fromList(name).dependsOn?.some((other) => was[other] !== state.values[other]))
|
|
110
|
+
loadChoices(name);
|
|
111
|
+
}
|
|
112
|
+
if (showing && state.step !== told)
|
|
113
|
+
tellStep(entering);
|
|
114
|
+
}
|
|
115
|
+
function emit(event, payload) {
|
|
116
|
+
const heard = events.get(event);
|
|
117
|
+
if (heard)
|
|
118
|
+
for (const listener of [...heard])
|
|
119
|
+
listener(payload);
|
|
120
|
+
}
|
|
121
|
+
/** A field written: told to the app, and — a person's change — the page's `change` steps for it run. */
|
|
122
|
+
function changed(field, by) {
|
|
123
|
+
emit('change', { field, value: state.values[field], values: state.values, by });
|
|
124
|
+
const list = page.on?.change?.[field];
|
|
125
|
+
if (by === 'person' && list)
|
|
126
|
+
void track(moment(`on.change.${field}`, list, [], true));
|
|
127
|
+
}
|
|
128
|
+
/** A wizard step entered: told to the app, and its `show` steps run. */
|
|
129
|
+
function tellStep(chain) {
|
|
130
|
+
const step = state.step;
|
|
131
|
+
if (step === told || step === null)
|
|
132
|
+
return;
|
|
133
|
+
told = step;
|
|
134
|
+
emit('step', { step });
|
|
135
|
+
const list = page.on?.show?.[step];
|
|
136
|
+
if (list)
|
|
137
|
+
void track(moment(`on.show.${step}`, list, chain, true));
|
|
138
|
+
}
|
|
139
|
+
/** Do something while `chain` is the run's that does it, so a step or tab it enters knows. */
|
|
140
|
+
function inRun(chain, act) {
|
|
141
|
+
const was = entering;
|
|
142
|
+
entering = chain;
|
|
143
|
+
try {
|
|
144
|
+
act();
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
entering = was;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const fromList = (name) => page.fields[name].optionsFrom;
|
|
151
|
+
function loadChoices(name) {
|
|
152
|
+
const { list } = fromList(name);
|
|
153
|
+
const load = (choiceLoads[name] = (choiceLoads[name] ?? 0) + 1);
|
|
154
|
+
const put = (choices) => load === choiceLoads[name] && set({ choices: { ...state.choices, [name]: choices } });
|
|
155
|
+
// What was shown stays while the next ones load, so the list does not flash empty.
|
|
156
|
+
const shown = state.choices[name]?.options ?? null;
|
|
157
|
+
put({ options: shown, loading: true });
|
|
158
|
+
const source = options.dataSource;
|
|
159
|
+
void track((source?.options ? source.options({ list, values: structuredCopy(state.values) }) : Promise.reject(new Error(`No list "${list}"`))).then((loaded) => put({ options: loaded }), (error) => put({ options: shown, error: error.message ?? String(error) })));
|
|
160
|
+
}
|
|
161
|
+
/** A field as its value is checked: a list's choices are those loaded, and until they are, any choice may be one. */
|
|
162
|
+
function checked(name) {
|
|
163
|
+
const def = page.fields[name];
|
|
164
|
+
const loaded = state.choices[name]?.options;
|
|
165
|
+
return def.type === 'selection' && def.optionsFrom && loaded ? { ...def, options: loaded, optionsFrom: undefined } : def;
|
|
47
166
|
}
|
|
48
167
|
function track(promise) {
|
|
49
168
|
pending.add(promise);
|
|
@@ -51,44 +170,47 @@ export function createForm(options) {
|
|
|
51
170
|
promise.then(done, done);
|
|
52
171
|
return promise;
|
|
53
172
|
}
|
|
54
|
-
|
|
173
|
+
/** The values as expressions read them, and what else they read: the lines' rows, today. */
|
|
174
|
+
function reading() {
|
|
55
175
|
if (contextCache?.values !== state.values) {
|
|
56
|
-
|
|
176
|
+
const values = state.values;
|
|
177
|
+
contextCache = { values, context: expressionContext(values, page.fields), env: expressionEnv(values, page.fields, today) };
|
|
57
178
|
}
|
|
58
|
-
return contextCache
|
|
179
|
+
return contextCache;
|
|
59
180
|
}
|
|
181
|
+
const context = () => reading().context;
|
|
60
182
|
function fieldDef(name) {
|
|
61
|
-
const def =
|
|
183
|
+
const def = fields[name];
|
|
62
184
|
if (!def)
|
|
63
185
|
throw new Error(`The page has no field "${name}"`);
|
|
64
186
|
return def;
|
|
65
187
|
}
|
|
66
188
|
function dirtyFields(values) {
|
|
67
|
-
return Object.keys(
|
|
189
|
+
return Object.keys(fields).filter((name) => JSON.stringify(values[name] ?? null) !== JSON.stringify(baseline[name] ?? null));
|
|
68
190
|
}
|
|
69
191
|
function nodeState(id) {
|
|
70
192
|
const node = index.get(id);
|
|
71
193
|
if (!node)
|
|
72
194
|
throw new Error(`The page has no element "${id}"`);
|
|
73
|
-
const ctx =
|
|
74
|
-
let invisible = node.invisible.evaluate(ctx);
|
|
195
|
+
const { context: ctx, env } = reading();
|
|
196
|
+
let invisible = node.invisible.evaluate(ctx, env);
|
|
75
197
|
for (let parent = node.parent; !invisible && parent; parent = index.get(parent)?.parent ?? null) {
|
|
76
|
-
invisible = index.get(parent)?.invisible.evaluate(ctx) ?? false;
|
|
198
|
+
invisible = index.get(parent)?.invisible.evaluate(ctx, env) ?? false;
|
|
77
199
|
}
|
|
78
200
|
// A section read-only right now makes everything inside it read-only too.
|
|
79
|
-
let readonly = node.readonly.evaluate(ctx);
|
|
201
|
+
let readonly = node.readonly.evaluate(ctx, env);
|
|
80
202
|
for (let parent = node.parent; !readonly && parent; parent = index.get(parent)?.parent ?? null) {
|
|
81
|
-
readonly = index.get(parent)?.readonly.evaluate(ctx) ?? false;
|
|
203
|
+
readonly = index.get(parent)?.readonly.evaluate(ctx, env) ?? false;
|
|
82
204
|
}
|
|
83
205
|
const def = node.field ? page.fields[node.field] : undefined;
|
|
84
206
|
return {
|
|
85
207
|
invisible,
|
|
86
|
-
readonly: readonly || def
|
|
87
|
-
required: node.required.evaluate(ctx) || def?.required === true,
|
|
208
|
+
readonly: readonly || (def !== undefined && lockedField(def)),
|
|
209
|
+
required: node.required.evaluate(ctx, env) || def?.required === true,
|
|
88
210
|
};
|
|
89
211
|
}
|
|
90
|
-
/** Field nodes inside `root` (or the whole page), visible right now. */
|
|
91
|
-
function visibleFieldNodes(root) {
|
|
212
|
+
/** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
|
|
213
|
+
function visibleFieldNodes(root, kind = 'field') {
|
|
92
214
|
const inside = (node) => {
|
|
93
215
|
if (!root)
|
|
94
216
|
return true;
|
|
@@ -106,7 +228,52 @@ export function createForm(options) {
|
|
|
106
228
|
return true;
|
|
107
229
|
return false;
|
|
108
230
|
};
|
|
109
|
-
return [...index.values()].filter((node) => node.kind ===
|
|
231
|
+
return [...index.values()].filter((node) => node.kind === kind && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
|
|
232
|
+
}
|
|
233
|
+
/** What a field node's answer rules say of its value now: the first error and the first warning. */
|
|
234
|
+
function ruleCheck(node) {
|
|
235
|
+
const rules = nodeRules.get(node.id);
|
|
236
|
+
if (!rules)
|
|
237
|
+
return {};
|
|
238
|
+
const name = node.field;
|
|
239
|
+
const def = page.fields[name];
|
|
240
|
+
const label = node.source.label ?? def.label;
|
|
241
|
+
const { context: ctx, env } = reading();
|
|
242
|
+
return checkRules(def, label, state.values[name], rules, { context: ctx, env, now: options.now?.() ?? new Date(), messages });
|
|
243
|
+
}
|
|
244
|
+
/** A tick box required and not ticked, as an "I agree" box: "no" answers a yes or no, never a box to tick. */
|
|
245
|
+
function unticked(node) {
|
|
246
|
+
const name = node.field;
|
|
247
|
+
const ticks = node.source.widget === 'tick' && page.fields[name].type === 'boolean';
|
|
248
|
+
return ticks && state.values[name] !== true && nodeState(node.id).required ? messages.tick : undefined;
|
|
249
|
+
}
|
|
250
|
+
/** A field node's error now: its field's own rules first, then its answer rules. */
|
|
251
|
+
function nodeError(node) {
|
|
252
|
+
const name = node.field;
|
|
253
|
+
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;
|
|
254
|
+
}
|
|
255
|
+
/** Warnings for the visible fields of the page, without publishing them. */
|
|
256
|
+
function collectWarnings() {
|
|
257
|
+
const warnings = {};
|
|
258
|
+
if (!warns)
|
|
259
|
+
return warnings;
|
|
260
|
+
for (const node of visibleFieldNodes()) {
|
|
261
|
+
const warning = nodeRules.has(node.id) ? ruleCheck(node).warning : undefined;
|
|
262
|
+
if (warning)
|
|
263
|
+
warnings[node.field] ??= warning;
|
|
264
|
+
}
|
|
265
|
+
return warnings;
|
|
266
|
+
}
|
|
267
|
+
/** What would be found for these values, without publishing them or touching the form. */
|
|
268
|
+
function withValues(values, find) {
|
|
269
|
+
const previous = state;
|
|
270
|
+
state = { ...state, values };
|
|
271
|
+
try {
|
|
272
|
+
return find();
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
state = previous;
|
|
276
|
+
}
|
|
110
277
|
}
|
|
111
278
|
/** Errors for the visible fields under `root`, without publishing them. */
|
|
112
279
|
function collectErrors(root) {
|
|
@@ -116,7 +283,7 @@ export function createForm(options) {
|
|
|
116
283
|
if (errors[name])
|
|
117
284
|
continue;
|
|
118
285
|
const def = page.fields[name];
|
|
119
|
-
const message =
|
|
286
|
+
const message = nodeError(node);
|
|
120
287
|
if (message)
|
|
121
288
|
errors[name] = message;
|
|
122
289
|
if (def.type === 'one2many') {
|
|
@@ -126,25 +293,74 @@ export function createForm(options) {
|
|
|
126
293
|
for (const [sub, subDef] of Object.entries(def.fields)) {
|
|
127
294
|
if (kind && sub !== def.lineKinds?.text)
|
|
128
295
|
continue;
|
|
129
|
-
const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true, messages);
|
|
296
|
+
const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true, messages, today());
|
|
130
297
|
if (lineMessage)
|
|
131
298
|
errors[`${name}.${line.key}.${sub}`] = lineMessage;
|
|
132
299
|
}
|
|
133
300
|
}
|
|
134
301
|
}
|
|
135
302
|
}
|
|
303
|
+
// A saved form placed here answers for its fields by its own page's rules.
|
|
304
|
+
for (const node of visibleFieldNodes(root, 'form')) {
|
|
305
|
+
const part = attached.get(node.id);
|
|
306
|
+
if (part)
|
|
307
|
+
for (const [key, message] of Object.entries(part.inner.errorsNow()))
|
|
308
|
+
errors[`${part.name}.${key}`] = message;
|
|
309
|
+
}
|
|
136
310
|
return errors;
|
|
137
311
|
}
|
|
138
|
-
|
|
312
|
+
/**
|
|
313
|
+
* Answers changed from outside a saved form — loaded, put back, set — handed
|
|
314
|
+
* to it, and what it holds after taken back: its defaults and worked-out
|
|
315
|
+
* values filled in. A new starting point (`start`) is handed to each.
|
|
316
|
+
*/
|
|
317
|
+
function syncParts(values, how) {
|
|
318
|
+
let out = values;
|
|
319
|
+
for (const part of attached.values()) {
|
|
320
|
+
if (how !== 'start' && out[part.name] === part.inner.form.getState().values)
|
|
321
|
+
continue;
|
|
322
|
+
syncing = true;
|
|
323
|
+
try {
|
|
324
|
+
part.inner.take(isRecord(out[part.name]) ? out[part.name] : {}, how);
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
syncing = false;
|
|
328
|
+
}
|
|
329
|
+
out = { ...out, [part.name]: asValue(part.inner.form.getState().values) };
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Values as they settle after a change: worked out, then set where a
|
|
335
|
+
* condition started to hold, then worked out again from what was set — a
|
|
336
|
+
* few rounds at most, so rules that set each other cannot run forever.
|
|
337
|
+
*/
|
|
338
|
+
function settle(written) {
|
|
339
|
+
let values = computed.apply(written);
|
|
340
|
+
for (let round = 0; round < 8; round++) {
|
|
341
|
+
const result = setWhen.apply(values, holding);
|
|
342
|
+
holding = result.holding;
|
|
343
|
+
if (result.values === values)
|
|
344
|
+
break;
|
|
345
|
+
values = computed.apply(result.values);
|
|
346
|
+
}
|
|
347
|
+
return values;
|
|
348
|
+
}
|
|
349
|
+
/** Values to start again from, as loaded or restored: conditions holding in them hold already, and set nothing. */
|
|
350
|
+
function startFrom(values) {
|
|
351
|
+
const worked = computed.apply(values);
|
|
352
|
+
holding = setWhen.holding(worked);
|
|
353
|
+
return worked;
|
|
354
|
+
}
|
|
355
|
+
/** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
|
|
356
|
+
function writeValues(written, extra = {}, fresh = false) {
|
|
357
|
+
const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
|
|
139
358
|
const errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
|
|
140
|
-
set({ values, dirty: dirtyFields(values), errors, ...extra });
|
|
359
|
+
set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
|
|
141
360
|
}
|
|
142
361
|
/** Once errors are on screen, keep them current as the person fixes things. */
|
|
143
362
|
function revalidateShown(values) {
|
|
144
|
-
const
|
|
145
|
-
state = { ...state, values };
|
|
146
|
-
const fresh = collectErrors(state.step ?? undefined);
|
|
147
|
-
state = previous;
|
|
363
|
+
const fresh = withValues(values, () => collectErrors(state.step ?? undefined));
|
|
148
364
|
const kept = {};
|
|
149
365
|
for (const key of Object.keys(state.errors))
|
|
150
366
|
if (fresh[key])
|
|
@@ -190,7 +406,7 @@ export function createForm(options) {
|
|
|
190
406
|
return;
|
|
191
407
|
}
|
|
192
408
|
if (drafts.restore === 'auto')
|
|
193
|
-
writeValues({ ...state.values, ...draft.values }, { draft: null });
|
|
409
|
+
writeValues({ ...state.values, ...draft.values }, { draft: null }, true);
|
|
194
410
|
else
|
|
195
411
|
set({ draft: { savedAt: draft.savedAt } });
|
|
196
412
|
}
|
|
@@ -230,10 +446,10 @@ export function createForm(options) {
|
|
|
230
446
|
// included; a stored one sends only what changed since it was loaded.
|
|
231
447
|
const creating = state.recordId === null || state.recordId === undefined;
|
|
232
448
|
const names = creating
|
|
233
|
-
? Object.keys(
|
|
449
|
+
? Object.keys(fields).filter((name) => state.dirty.includes(name) || !isEmpty(fields[name], state.values[name]))
|
|
234
450
|
: state.dirty;
|
|
235
451
|
for (const name of names) {
|
|
236
|
-
const def =
|
|
452
|
+
const def = fields[name];
|
|
237
453
|
const now = state.values[name];
|
|
238
454
|
const before = creating ? emptyValue(def) : baseline[name];
|
|
239
455
|
if (def.type === 'one2many')
|
|
@@ -252,13 +468,38 @@ export function createForm(options) {
|
|
|
252
468
|
const name = node.field;
|
|
253
469
|
shown[name] = structuredCopy(state.values[name]);
|
|
254
470
|
}
|
|
471
|
+
// And each saved form shown, by its page: one not found shows nothing to answer.
|
|
472
|
+
for (const node of visibleFieldNodes(undefined, 'form')) {
|
|
473
|
+
const part = attached.get(node.id);
|
|
474
|
+
if (part)
|
|
475
|
+
shown[part.name] = structuredCopy(state.values[part.name]);
|
|
476
|
+
}
|
|
255
477
|
return shown;
|
|
256
478
|
}
|
|
257
|
-
async function save() {
|
|
479
|
+
async function save(chain = []) {
|
|
480
|
+
return (await saving(chain)) === null;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Save, or send: null once done, or why not. The page's `beforeSave` steps
|
|
484
|
+
* run first — before its own check, so they may fill in what it asks for —
|
|
485
|
+
* and a stop keeps it unsaved; `afterSave` runs once it is saved.
|
|
486
|
+
*/
|
|
487
|
+
async function saving(chain) {
|
|
488
|
+
if (page.on?.beforeSave) {
|
|
489
|
+
const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
|
|
490
|
+
if (!before.done) {
|
|
491
|
+
// A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
|
|
492
|
+
if (before.reason !== 'check' && before.reason !== 'save') {
|
|
493
|
+
const problem = { kind: 'stopped', reason: before.reason, message: before.message ?? '' };
|
|
494
|
+
set({ status: 'error', error: problem.message || null, saveProblem: problem });
|
|
495
|
+
}
|
|
496
|
+
return { reason: 'save', ...(before.message !== undefined ? { message: before.message } : {}) };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
258
499
|
const errors = collectErrors();
|
|
259
500
|
if (Object.keys(errors).length) {
|
|
260
501
|
set({ errors });
|
|
261
|
-
return
|
|
502
|
+
return { reason: 'save', message: Object.values(errors)[0] };
|
|
262
503
|
}
|
|
263
504
|
const source = options.dataSource;
|
|
264
505
|
serverErrors = {};
|
|
@@ -267,34 +508,99 @@ export function createForm(options) {
|
|
|
267
508
|
if (page.data.kind === 'responses') {
|
|
268
509
|
if (!source?.submit)
|
|
269
510
|
throw new Error('This page has no data source to send its answers to');
|
|
270
|
-
|
|
511
|
+
const sent = shownValues();
|
|
512
|
+
await source.submit({ pageId: page.id, values: sent });
|
|
271
513
|
forgetDraft();
|
|
272
514
|
set({ status: 'saved' });
|
|
273
|
-
|
|
515
|
+
emit('send', { values: sent });
|
|
516
|
+
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
517
|
+
return null;
|
|
274
518
|
}
|
|
275
519
|
if (!source?.save)
|
|
276
520
|
throw new Error('This page has no data source to save to');
|
|
277
521
|
const result = await source.save({
|
|
278
522
|
model: page.data.model,
|
|
279
523
|
id: state.recordId,
|
|
280
|
-
fields
|
|
524
|
+
fields,
|
|
281
525
|
changes: changes(),
|
|
282
526
|
values: structuredCopy(state.values),
|
|
283
527
|
});
|
|
284
528
|
forgetDraft();
|
|
285
|
-
const values = result.values ? { ...state.values, ...result.values } : state.values;
|
|
529
|
+
const values = syncParts(startFrom(result.values ? { ...state.values, ...result.values } : state.values), 'start');
|
|
286
530
|
baseline = structuredCopy(values);
|
|
287
|
-
set({ status: 'saved', recordId: result.id, values, dirty: [] });
|
|
288
|
-
|
|
531
|
+
set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
|
|
532
|
+
emit('save', { recordId: state.recordId, values: state.values });
|
|
533
|
+
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
534
|
+
return null;
|
|
289
535
|
}
|
|
290
536
|
catch (error) {
|
|
291
537
|
const problem = saveProblemOf(error);
|
|
292
538
|
const fields = problem.kind === 'fields' ? problem.fields ?? {} : {};
|
|
293
539
|
serverErrors = Object.fromEntries(Object.entries(fields).map(([name, message]) => [name, { message, value: JSON.stringify(state.values[name] ?? null) }]));
|
|
294
540
|
set({ status: 'error', error: problem.message, saveProblem: problem, errors: { ...fields } });
|
|
295
|
-
return
|
|
541
|
+
return { reason: 'save', message: problem.message };
|
|
296
542
|
}
|
|
297
543
|
}
|
|
544
|
+
/** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
|
|
545
|
+
async function opening() {
|
|
546
|
+
emit('open', { recordId: state.recordId, values: state.values });
|
|
547
|
+
await moment('on.open', page.on?.open, [], true);
|
|
548
|
+
showing = true;
|
|
549
|
+
tellStep([]);
|
|
550
|
+
}
|
|
551
|
+
/** Write fields — a step's or the app's — worked out once, and tell each. */
|
|
552
|
+
function write(written, by) {
|
|
553
|
+
const names = Object.keys(written);
|
|
554
|
+
names.forEach(fieldDef);
|
|
555
|
+
if (!names.length)
|
|
556
|
+
return;
|
|
557
|
+
writeValues({ ...state.values, ...structuredCopy(written) });
|
|
558
|
+
for (const name of names)
|
|
559
|
+
afterEdit(name);
|
|
560
|
+
for (const name of names)
|
|
561
|
+
changed(name, by);
|
|
562
|
+
}
|
|
563
|
+
function addLine(field, values, by) {
|
|
564
|
+
const def = lineField(field);
|
|
565
|
+
const key = `new-${++lineKeys}`;
|
|
566
|
+
const current = state.values[field] ?? [];
|
|
567
|
+
const line = { key, values: { ...initialValues(def.fields, today()), ...structuredCopy(values) } };
|
|
568
|
+
// A new line comes last, so it is numbered after the last.
|
|
569
|
+
if (def.sequenceField && line.values[def.sequenceField] == null) {
|
|
570
|
+
const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
|
|
571
|
+
line.values[def.sequenceField] = numbers.length ? Math.max(...numbers) + 1 : 1;
|
|
572
|
+
}
|
|
573
|
+
const lines = [...current, line];
|
|
574
|
+
writeValues({ ...state.values, [field]: lines });
|
|
575
|
+
afterEdit(field);
|
|
576
|
+
changed(field, by);
|
|
577
|
+
return key;
|
|
578
|
+
}
|
|
579
|
+
/** Check the form, or only these fields, showing what is found as sending does. */
|
|
580
|
+
function check(names) {
|
|
581
|
+
names?.forEach(fieldDef);
|
|
582
|
+
const all = collectErrors();
|
|
583
|
+
const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
|
|
584
|
+
set({ errors: found });
|
|
585
|
+
const first = Object.values(found)[0];
|
|
586
|
+
return first === undefined ? null : { reason: 'check', message: first };
|
|
587
|
+
}
|
|
588
|
+
/** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
|
|
589
|
+
function goToStep(target, chain) {
|
|
590
|
+
if (!steps.includes(target))
|
|
591
|
+
return undefined;
|
|
592
|
+
if (!form.steps().includes(target))
|
|
593
|
+
return { reason: 'cannot', message: `The step "${target}" does not apply now` };
|
|
594
|
+
let went = false;
|
|
595
|
+
inRun(chain, () => (went = form.goTo(target)));
|
|
596
|
+
return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
|
|
597
|
+
}
|
|
598
|
+
/** Run steps from the top — a button's, a moment's, the app's — and tell how it ended. */
|
|
599
|
+
async function runTop(list, scope) {
|
|
600
|
+
const result = await runner.runSteps(list, scope);
|
|
601
|
+
emit('run', { id: scope.id, steps: list, result });
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
298
604
|
/** The model a link field's new record belongs to, or null when it cannot make one. */
|
|
299
605
|
function creatable(def) {
|
|
300
606
|
if (!options.dataSource?.create || !def || def.readonly)
|
|
@@ -350,15 +656,35 @@ export function createForm(options) {
|
|
|
350
656
|
}
|
|
351
657
|
set({ status: 'loading', error: null });
|
|
352
658
|
try {
|
|
353
|
-
const loaded = await source.load({ model: page.data.model, id: state.recordId, fields
|
|
354
|
-
baseline = { ...initialValues(
|
|
355
|
-
set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {} });
|
|
659
|
+
const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
|
|
660
|
+
baseline = syncParts(startFrom({ ...initialValues(fields, today()), ...loaded }), 'start');
|
|
661
|
+
set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
|
|
356
662
|
offerDraft();
|
|
663
|
+
void track(opening());
|
|
357
664
|
}
|
|
358
665
|
catch (error) {
|
|
359
666
|
set({ status: 'error', error: error.message });
|
|
360
667
|
}
|
|
361
668
|
}
|
|
669
|
+
const runner = createRunner({
|
|
670
|
+
fields,
|
|
671
|
+
values: () => state.values,
|
|
672
|
+
recordId: () => state.recordId,
|
|
673
|
+
reading,
|
|
674
|
+
today,
|
|
675
|
+
write: (values) => write(values, 'step'),
|
|
676
|
+
addLine: (field, values) => void addLine(field, values, 'step'),
|
|
677
|
+
check,
|
|
678
|
+
save: saving,
|
|
679
|
+
reset: () => form.reset(),
|
|
680
|
+
goTo: goToStep,
|
|
681
|
+
showing: inRun,
|
|
682
|
+
host: options.host,
|
|
683
|
+
confirm: options.confirm,
|
|
684
|
+
onAction: options.onAction,
|
|
685
|
+
answered: (request, result) => emit('action', { request, result }),
|
|
686
|
+
});
|
|
687
|
+
const moment = createMoments((key, list, chain) => runTop(list, { id: key, chain }));
|
|
362
688
|
const form = {
|
|
363
689
|
page,
|
|
364
690
|
getState: () => state,
|
|
@@ -367,20 +693,26 @@ export function createForm(options) {
|
|
|
367
693
|
return () => listeners.delete(listener);
|
|
368
694
|
},
|
|
369
695
|
load: () => track(load()),
|
|
370
|
-
setValue(name, value) {
|
|
696
|
+
setValue(name, value, how) {
|
|
371
697
|
fieldDef(name);
|
|
372
698
|
writeValues({ ...state.values, [name]: structuredCopy(value) });
|
|
373
699
|
afterEdit(name);
|
|
700
|
+
changed(name, how?.by ?? 'person');
|
|
374
701
|
},
|
|
702
|
+
setValues: (values) => write(values, 'app'),
|
|
375
703
|
node: nodeState,
|
|
376
|
-
fieldReadonly: (name) => fieldDef(name)
|
|
704
|
+
fieldReadonly: (name) => lockedField(fieldDef(name)),
|
|
705
|
+
loadChoices,
|
|
377
706
|
problem(name) {
|
|
378
|
-
|
|
707
|
+
fieldDef(name);
|
|
379
708
|
// Only a field someone can see is asked anything, and required wherever it is shown so.
|
|
380
709
|
const shown = visibleFieldNodes().filter((node) => node.field === name);
|
|
381
710
|
if (!shown.length)
|
|
382
711
|
return null;
|
|
383
|
-
|
|
712
|
+
const own = shown.map(unticked).find(Boolean) ??
|
|
713
|
+
shown.map((node) => checkInput(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
|
|
714
|
+
checkValue(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
|
|
715
|
+
return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
|
|
384
716
|
},
|
|
385
717
|
validate() {
|
|
386
718
|
const errors = collectErrors();
|
|
@@ -391,24 +723,12 @@ export function createForm(options) {
|
|
|
391
723
|
reset() {
|
|
392
724
|
forgetDraft();
|
|
393
725
|
serverErrors = {};
|
|
394
|
-
|
|
726
|
+
holding = setWhen.holding(baseline);
|
|
727
|
+
const values = syncParts(structuredCopy(baseline), 'start');
|
|
728
|
+
set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
|
|
395
729
|
},
|
|
396
730
|
changes,
|
|
397
|
-
addLine(field, values = {})
|
|
398
|
-
const def = lineField(field);
|
|
399
|
-
const key = `new-${++lineKeys}`;
|
|
400
|
-
const current = state.values[field] ?? [];
|
|
401
|
-
const line = { key, values: { ...initialValues(def.fields), ...structuredCopy(values) } };
|
|
402
|
-
// A new line comes last, so it is numbered after the last.
|
|
403
|
-
if (def.sequenceField && line.values[def.sequenceField] == null) {
|
|
404
|
-
const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
|
|
405
|
-
line.values[def.sequenceField] = numbers.length ? Math.max(...numbers) + 1 : 1;
|
|
406
|
-
}
|
|
407
|
-
const lines = [...current, line];
|
|
408
|
-
writeValues({ ...state.values, [field]: lines });
|
|
409
|
-
afterEdit(field);
|
|
410
|
-
return key;
|
|
411
|
-
},
|
|
731
|
+
addLine: (field, values = {}) => addLine(field, values, 'person'),
|
|
412
732
|
updateLine(field, key, name, value) {
|
|
413
733
|
const def = lineField(field);
|
|
414
734
|
if (!def.fields[name])
|
|
@@ -416,6 +736,7 @@ export function createForm(options) {
|
|
|
416
736
|
const lines = (state.values[field] ?? []).map((line) => line.key === key ? { ...line, values: { ...line.values, [name]: structuredCopy(value) } } : line);
|
|
417
737
|
writeValues({ ...state.values, [field]: lines });
|
|
418
738
|
afterEdit(field);
|
|
739
|
+
changed(field, 'person');
|
|
419
740
|
},
|
|
420
741
|
moveLine(field, key, to) {
|
|
421
742
|
const def = lineField(field);
|
|
@@ -445,12 +766,14 @@ export function createForm(options) {
|
|
|
445
766
|
});
|
|
446
767
|
writeValues({ ...state.values, [field]: renumbered });
|
|
447
768
|
afterEdit(field);
|
|
769
|
+
changed(field, 'person');
|
|
448
770
|
},
|
|
449
771
|
removeLine(field, key) {
|
|
450
772
|
lineField(field);
|
|
451
773
|
const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
|
|
452
774
|
writeValues({ ...state.values, [field]: lines });
|
|
453
775
|
afterEdit(field);
|
|
776
|
+
changed(field, 'person');
|
|
454
777
|
},
|
|
455
778
|
async search(field, query, limit = 8, options = {}) {
|
|
456
779
|
return runSearch(fieldDef(field), field, context(), query, limit, options.model);
|
|
@@ -461,14 +784,14 @@ export function createForm(options) {
|
|
|
461
784
|
const line = lines.find((l) => l.key === key);
|
|
462
785
|
if (!line)
|
|
463
786
|
throw new Error(`"${field}" has no line "${key}"`);
|
|
464
|
-
const merged = { ...line.values, ...values };
|
|
787
|
+
const merged = computed.line(field, { ...line.values, ...values });
|
|
465
788
|
const source = options.dataSource;
|
|
466
789
|
if (!source?.onchange || page.data.kind !== 'record')
|
|
467
790
|
return merged;
|
|
468
791
|
const changed = lines.map((l) => (l.key === key ? { ...l, values: merged } : l));
|
|
469
792
|
const result = await source.onchange({ model: page.data.model, id: state.recordId, changed: field, values: structuredCopy({ ...state.values, [field]: changed }) });
|
|
470
793
|
const after = (result.values?.[field] ?? []).find((l) => l.key === key);
|
|
471
|
-
return after ? { ...merged, ...after.values } : merged;
|
|
794
|
+
return after ? computed.line(field, { ...merged, ...after.values }) : merged;
|
|
472
795
|
},
|
|
473
796
|
async searchLine(field, key, subfield, query, limit = 8) {
|
|
474
797
|
const def = lineField(field);
|
|
@@ -556,23 +879,43 @@ export function createForm(options) {
|
|
|
556
879
|
if (!node || (node.kind !== 'button' && node.kind !== 'stat'))
|
|
557
880
|
throw new Error(`The page has no button "${id}"`);
|
|
558
881
|
if (nodeState(id).invisible)
|
|
559
|
-
return;
|
|
882
|
+
return { done: false, reason: 'cannot', message: `The button "${id}" is hidden` };
|
|
560
883
|
const source = node.source;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
...('params' in source && source.params ? { params: source.params } : {})
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
884
|
+
// Its steps, then its app action as a final call.
|
|
885
|
+
const list = [...(source.steps ?? [])];
|
|
886
|
+
if (source.action !== undefined)
|
|
887
|
+
list.push({ do: 'call', action: source.action, ...('params' in source && source.params ? { params: source.params } : {}) });
|
|
888
|
+
const scope = { id, chain: [], ...(chosen ? { recordIds: [...chosen.recordIds] } : {}) };
|
|
889
|
+
const confirm = 'confirm' in source ? source.confirm : undefined;
|
|
890
|
+
return track((async () => {
|
|
891
|
+
if (confirm && !(await runner.ask(confirm))) {
|
|
892
|
+
const result = { done: false, reason: 'no', message: confirm };
|
|
893
|
+
emit('run', { id, steps: list, result });
|
|
894
|
+
return result;
|
|
895
|
+
}
|
|
896
|
+
return runTop(list, scope);
|
|
897
|
+
})());
|
|
898
|
+
},
|
|
899
|
+
run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
|
|
900
|
+
shown(tab) {
|
|
901
|
+
// A wizard's steps are told by the form itself, as they are entered.
|
|
902
|
+
if (steps.includes(tab))
|
|
903
|
+
return;
|
|
904
|
+
void track(moment(`on.show.${tab}`, page.on?.show?.[tab], entering, true));
|
|
905
|
+
},
|
|
906
|
+
on(event, listener) {
|
|
907
|
+
let heard = events.get(event);
|
|
908
|
+
if (!heard)
|
|
909
|
+
events.set(event, (heard = new Set()));
|
|
910
|
+
heard.add(listener);
|
|
911
|
+
return () => void heard.delete(listener);
|
|
569
912
|
},
|
|
570
913
|
restoreDraft() {
|
|
571
914
|
const raw = options.drafts?.store.getItem(draftKey());
|
|
572
915
|
if (!raw)
|
|
573
916
|
return;
|
|
574
917
|
const draft = JSON.parse(raw);
|
|
575
|
-
writeValues({ ...state.values, ...draft.values }, { draft: null });
|
|
918
|
+
writeValues({ ...state.values, ...draft.values }, { draft: null }, true);
|
|
576
919
|
},
|
|
577
920
|
discardDraft() {
|
|
578
921
|
forgetDraft();
|
|
@@ -582,17 +925,96 @@ export function createForm(options) {
|
|
|
582
925
|
while (pending.size)
|
|
583
926
|
await Promise.allSettled([...pending]);
|
|
584
927
|
},
|
|
928
|
+
embed(id, inner) {
|
|
929
|
+
const known = attached.get(id);
|
|
930
|
+
if (known)
|
|
931
|
+
return known.inner.form;
|
|
932
|
+
const part = parts.get(id);
|
|
933
|
+
if (!part)
|
|
934
|
+
throw new Error(`"${id}" is not a saved form placed on this page`);
|
|
935
|
+
const chain = [...within, page.id];
|
|
936
|
+
if (chain.includes(inner.id))
|
|
937
|
+
throw new Error(`a page cannot be placed inside itself: ${[...chain.slice(chain.indexOf(inner.id)), inner.id].join(' → ')}`);
|
|
938
|
+
const given = state.values[part.name];
|
|
939
|
+
// A record of no model of its own: its links search and its lists load, but it never loads, saves, sends or recalculates alone.
|
|
940
|
+
const made = innerForm({
|
|
941
|
+
page: { ...inner, data: { kind: 'responses' } },
|
|
942
|
+
dataSource: options.dataSource,
|
|
943
|
+
values: isRecord(given) ? given : {},
|
|
944
|
+
onAction: options.onAction,
|
|
945
|
+
host: options.host,
|
|
946
|
+
confirm: options.confirm,
|
|
947
|
+
scheduler,
|
|
948
|
+
messages,
|
|
949
|
+
now: options.now,
|
|
950
|
+
}, chain);
|
|
951
|
+
attached.set(id, { name: part.name, inner: made });
|
|
952
|
+
// A change inside it is told as a change of its answers here, whoever made it.
|
|
953
|
+
made.form.on('change', ({ by }) => {
|
|
954
|
+
if (!syncing)
|
|
955
|
+
changed(part.name, by);
|
|
956
|
+
});
|
|
957
|
+
// A change made inside it is a change of this form's: shown, kept in a draft, saved.
|
|
958
|
+
made.form.subscribe((inside) => {
|
|
959
|
+
if (syncing || inside.values === state.values[part.name])
|
|
960
|
+
return;
|
|
961
|
+
writeValues({ ...state.values, [part.name]: asValue(inside.values) });
|
|
962
|
+
afterEdit(part.name);
|
|
963
|
+
});
|
|
964
|
+
// What it starts with, its defaults and worked-out values filled in, is this form's starting point too — unless changed already.
|
|
965
|
+
const start = asValue(made.form.getState().values);
|
|
966
|
+
if (!state.dirty.includes(part.name))
|
|
967
|
+
baseline = { ...baseline, [part.name]: start };
|
|
968
|
+
const values = { ...state.values, [part.name]: start };
|
|
969
|
+
set({ values, dirty: dirtyFields(values) });
|
|
970
|
+
return made.form;
|
|
971
|
+
},
|
|
972
|
+
embedded: (id) => attached.get(id)?.inner.form ?? null,
|
|
585
973
|
dispose() {
|
|
586
974
|
if (draftTimer !== null)
|
|
587
975
|
scheduler.clearTimeout(draftTimer);
|
|
588
976
|
if (autosaveTimer !== null)
|
|
589
977
|
scheduler.clearTimeout(autosaveTimer);
|
|
590
978
|
listeners.clear();
|
|
979
|
+
events.clear();
|
|
980
|
+
for (const part of attached.values())
|
|
981
|
+
part.inner.form.dispose();
|
|
591
982
|
},
|
|
592
983
|
};
|
|
984
|
+
// A record's starting values may already break a warning rule; nothing listens yet.
|
|
985
|
+
state = { ...state, warnings: collectWarnings() };
|
|
593
986
|
if (state.recordId == null)
|
|
594
987
|
offerDraft();
|
|
595
|
-
|
|
988
|
+
// A new form has its values now: it opens once whoever made it has had the chance to listen.
|
|
989
|
+
if (state.recordId == null)
|
|
990
|
+
void track(Promise.resolve().then(opening));
|
|
991
|
+
return {
|
|
992
|
+
form,
|
|
993
|
+
take(values, how) {
|
|
994
|
+
if (how !== 'start')
|
|
995
|
+
return writeValues({ ...initialValues(page.fields, today()), ...values }, {}, how === 'fresh');
|
|
996
|
+
baseline = startFrom({ ...initialValues(page.fields, today()), ...structuredCopy(values) });
|
|
997
|
+
const start = syncParts(structuredCopy(baseline), 'start');
|
|
998
|
+
set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null });
|
|
999
|
+
},
|
|
1000
|
+
errorsNow: () => collectErrors(),
|
|
1001
|
+
show(errors) {
|
|
1002
|
+
if (JSON.stringify(errors) !== JSON.stringify(state.errors))
|
|
1003
|
+
set({ errors });
|
|
1004
|
+
},
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/** The problems found inside a saved form, by its own fields' names. */
|
|
1008
|
+
function inside(errors, name) {
|
|
1009
|
+
const found = {};
|
|
1010
|
+
for (const [key, message] of Object.entries(errors))
|
|
1011
|
+
if (key.startsWith(`${name}.`))
|
|
1012
|
+
found[key.slice(name.length + 1)] = message;
|
|
1013
|
+
return found;
|
|
1014
|
+
}
|
|
1015
|
+
/** A field no one types in: read-only by its definition, or worked out from others. */
|
|
1016
|
+
function lockedField(def) {
|
|
1017
|
+
return def.readonly === true || def.compute !== undefined;
|
|
596
1018
|
}
|
|
597
1019
|
function lineOps(before, now) {
|
|
598
1020
|
const ops = [];
|
|
@@ -658,6 +1080,9 @@ function indexLayout(root) {
|
|
|
658
1080
|
add(node, parent, 'other', { invisible: node.invisible, readonly: node.readonly });
|
|
659
1081
|
walk(node.children, node.id);
|
|
660
1082
|
}
|
|
1083
|
+
else if (node.type === 'form') {
|
|
1084
|
+
add(node, parent, 'form', { invisible: node.invisible, readonly: node.readonly });
|
|
1085
|
+
}
|
|
661
1086
|
else {
|
|
662
1087
|
add(node, parent, 'other', { invisible: node.invisible });
|
|
663
1088
|
}
|