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