@fieldia/core 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schema/page.schema.json +1128 -89
- 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/field.d.mts +686 -5
- package/src/lib/format/field.d.ts +686 -5
- package/src/lib/format/field.js +115 -13
- package/src/lib/format/field.js.map +1 -1
- package/src/lib/format/field.mjs +114 -12
- package/src/lib/format/field.mjs.map +1 -1
- package/src/lib/format/layout.d.mts +388 -10
- package/src/lib/format/layout.d.ts +388 -10
- package/src/lib/format/layout.js +50 -3
- package/src/lib/format/layout.js.map +1 -1
- package/src/lib/format/layout.mjs +49 -2
- package/src/lib/format/layout.mjs.map +1 -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 +391 -4
- package/src/lib/format/page.d.ts +391 -4
- package/src/lib/format/page.js +13 -0
- package/src/lib/format/page.js.map +1 -1
- package/src/lib/format/page.mjs +13 -0
- package/src/lib/format/page.mjs.map +1 -1
- package/src/lib/format/references.d.mts +14 -1
- package/src/lib/format/references.d.ts +14 -1
- package/src/lib/format/references.js +96 -8
- package/src/lib/format/references.js.map +1 -1
- package/src/lib/format/references.mjs +96 -8
- 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 +9 -1
- package/src/lib/record/data-source.d.ts +9 -1
- 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 +24 -0
- package/src/lib/record/form.d.ts +24 -0
- package/src/lib/record/form.js +160 -32
- package/src/lib/record/form.js.map +1 -1
- package/src/lib/record/form.mjs +160 -32
- 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/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 +10 -7
- package/src/runtime.d.ts +10 -7
- package/src/runtime.js +12 -1
- package/src/runtime.js.map +1 -1
- package/src/runtime.mjs +5 -2
- package/src/runtime.mjs.map +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Option } from '../format/field.mjs';
|
|
1
2
|
import type { JsonValue } from '../format/json.mjs';
|
|
2
3
|
import type { Page } from '../format/page.mjs';
|
|
3
4
|
import { type Messages } from './messages.mjs';
|
|
@@ -13,6 +14,11 @@ export interface FormState {
|
|
|
13
14
|
readonly errors: Readonly<Record<string, string>>;
|
|
14
15
|
/** Fields that differ from the record as loaded or last saved. */
|
|
15
16
|
readonly dirty: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Warnings by field name, from answer rules at the warning level: shown,
|
|
19
|
+
* never blocking. Kept current as values change, for the fields people can see.
|
|
20
|
+
*/
|
|
21
|
+
readonly warnings: Readonly<Record<string, string>>;
|
|
16
22
|
/** A warning from the data source's onchange, shown without blocking. */
|
|
17
23
|
readonly warning: string | null;
|
|
18
24
|
/** The field whose change brought the warning, so it can be shown beside it. */
|
|
@@ -29,6 +35,16 @@ export interface FormState {
|
|
|
29
35
|
readonly skipped: readonly string[];
|
|
30
36
|
/** Why the last save was refused, until the next one starts or the form is reset. */
|
|
31
37
|
readonly saveProblem: SaveProblem | null;
|
|
38
|
+
/** The choices loaded from the app's lists, by field: those of a selection with `optionsFrom`, once asked for. */
|
|
39
|
+
readonly choices: Readonly<Record<string, Choices>>;
|
|
40
|
+
}
|
|
41
|
+
/** A field's choices from one of the app's lists, as they load. */
|
|
42
|
+
export interface Choices {
|
|
43
|
+
/** The choices last loaded: null until the first load ends well. */
|
|
44
|
+
readonly options: Option[] | null;
|
|
45
|
+
readonly loading?: boolean;
|
|
46
|
+
/** Why the last load failed. */
|
|
47
|
+
readonly error?: string;
|
|
32
48
|
}
|
|
33
49
|
/** What a layout element looks like right now. */
|
|
34
50
|
export interface NodeState {
|
|
@@ -74,6 +90,8 @@ export interface FormOptions {
|
|
|
74
90
|
scheduler?: Scheduler;
|
|
75
91
|
/** Validation messages in the page's language. English by default. */
|
|
76
92
|
messages?: Messages;
|
|
93
|
+
/** The clock, for `today()` and for dates that must be past or future. The computer's own when left out. */
|
|
94
|
+
now?: () => Date;
|
|
77
95
|
}
|
|
78
96
|
export interface Form {
|
|
79
97
|
readonly page: Page;
|
|
@@ -100,6 +118,12 @@ export interface Form {
|
|
|
100
118
|
* written: the record, its changes and its autosave stay as they are.
|
|
101
119
|
*/
|
|
102
120
|
previewLine(field: string, key: string, values: Values): Promise<Values>;
|
|
121
|
+
/**
|
|
122
|
+
* Load, or load again, the choices of a selection whose options come from
|
|
123
|
+
* the app's list (`optionsFrom`). Once loaded, they load again by themselves
|
|
124
|
+
* when a field they change with changes; the latest answer wins.
|
|
125
|
+
*/
|
|
126
|
+
loadChoices(field: string): void;
|
|
103
127
|
/** Records a many2one, many2many or reference may point to. A reference needs `options.model`. */
|
|
104
128
|
search(field: string, query: string, limit?: number, options?: {
|
|
105
129
|
model?: string;
|
package/src/lib/record/form.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Option } from '../format/field';
|
|
1
2
|
import type { JsonValue } from '../format/json';
|
|
2
3
|
import type { Page } from '../format/page';
|
|
3
4
|
import { type Messages } from './messages';
|
|
@@ -13,6 +14,11 @@ export interface FormState {
|
|
|
13
14
|
readonly errors: Readonly<Record<string, string>>;
|
|
14
15
|
/** Fields that differ from the record as loaded or last saved. */
|
|
15
16
|
readonly dirty: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Warnings by field name, from answer rules at the warning level: shown,
|
|
19
|
+
* never blocking. Kept current as values change, for the fields people can see.
|
|
20
|
+
*/
|
|
21
|
+
readonly warnings: Readonly<Record<string, string>>;
|
|
16
22
|
/** A warning from the data source's onchange, shown without blocking. */
|
|
17
23
|
readonly warning: string | null;
|
|
18
24
|
/** The field whose change brought the warning, so it can be shown beside it. */
|
|
@@ -29,6 +35,16 @@ export interface FormState {
|
|
|
29
35
|
readonly skipped: readonly string[];
|
|
30
36
|
/** Why the last save was refused, until the next one starts or the form is reset. */
|
|
31
37
|
readonly saveProblem: SaveProblem | null;
|
|
38
|
+
/** The choices loaded from the app's lists, by field: those of a selection with `optionsFrom`, once asked for. */
|
|
39
|
+
readonly choices: Readonly<Record<string, Choices>>;
|
|
40
|
+
}
|
|
41
|
+
/** A field's choices from one of the app's lists, as they load. */
|
|
42
|
+
export interface Choices {
|
|
43
|
+
/** The choices last loaded: null until the first load ends well. */
|
|
44
|
+
readonly options: Option[] | null;
|
|
45
|
+
readonly loading?: boolean;
|
|
46
|
+
/** Why the last load failed. */
|
|
47
|
+
readonly error?: string;
|
|
32
48
|
}
|
|
33
49
|
/** What a layout element looks like right now. */
|
|
34
50
|
export interface NodeState {
|
|
@@ -74,6 +90,8 @@ export interface FormOptions {
|
|
|
74
90
|
scheduler?: Scheduler;
|
|
75
91
|
/** Validation messages in the page's language. English by default. */
|
|
76
92
|
messages?: Messages;
|
|
93
|
+
/** The clock, for `today()` and for dates that must be past or future. The computer's own when left out. */
|
|
94
|
+
now?: () => Date;
|
|
77
95
|
}
|
|
78
96
|
export interface Form {
|
|
79
97
|
readonly page: Page;
|
|
@@ -100,6 +118,12 @@ export interface Form {
|
|
|
100
118
|
* written: the record, its changes and its autosave stay as they are.
|
|
101
119
|
*/
|
|
102
120
|
previewLine(field: string, key: string, values: Values): Promise<Values>;
|
|
121
|
+
/**
|
|
122
|
+
* Load, or load again, the choices of a selection whose options come from
|
|
123
|
+
* the app's list (`optionsFrom`). Once loaded, they load again by themselves
|
|
124
|
+
* when a field they change with changes; the latest answer wins.
|
|
125
|
+
*/
|
|
126
|
+
loadChoices(field: string): void;
|
|
103
127
|
/** Records a many2one, many2many or reference may point to. A reference needs `options.model`. */
|
|
104
128
|
search(field: string, query: string, limit?: number, options?: {
|
|
105
129
|
model?: string;
|
package/src/lib/record/form.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
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");
|
|
@@ -17,15 +23,30 @@ function createForm(options) {
|
|
|
17
23
|
const scheduler = options.scheduler ?? scheduler_1.hostScheduler;
|
|
18
24
|
const messages = options.messages ?? messages_1.MESSAGES.en;
|
|
19
25
|
const index = indexLayout(page.layout);
|
|
26
|
+
/** Each field node's answer rules, read once. */
|
|
27
|
+
const nodeRules = new Map();
|
|
28
|
+
for (const node of index.values()) {
|
|
29
|
+
const rules = node.source.validate;
|
|
30
|
+
if (node.kind === 'field' && rules)
|
|
31
|
+
nodeRules.set(node.id, (0, rules_1.compileRules)(rules));
|
|
32
|
+
}
|
|
33
|
+
/** Whether any rule only warns: without one, there are never warnings to look for. */
|
|
34
|
+
const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
|
|
20
35
|
const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
|
|
21
36
|
const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
|
|
22
|
-
|
|
37
|
+
const today = () => (0, functions_1.localDay)(options.now?.() ?? new Date());
|
|
38
|
+
const computed = (0, compute_1.compileComputed)(page, today);
|
|
39
|
+
const setWhen = (0, set_when_1.compileSetWhen)(page, today);
|
|
40
|
+
let baseline = computed.apply({ ...(0, values_1.initialValues)(page.fields, today()), ...(0, values_1.structuredCopy)(options.values ?? {}) });
|
|
41
|
+
/** The setWhen conditions holding for the values as they last settled: one starts to hold only against these. */
|
|
42
|
+
let holding = setWhen.holding(baseline);
|
|
23
43
|
let state = {
|
|
24
44
|
status: options.recordId != null ? 'idle' : 'ready',
|
|
25
45
|
recordId: options.recordId ?? null,
|
|
26
46
|
values: (0, values_1.structuredCopy)(baseline),
|
|
27
47
|
errors: {},
|
|
28
48
|
dirty: [],
|
|
49
|
+
warnings: {},
|
|
29
50
|
warning: null,
|
|
30
51
|
warningField: null,
|
|
31
52
|
error: null,
|
|
@@ -33,6 +54,7 @@ function createForm(options) {
|
|
|
33
54
|
step: steps[0] ?? null,
|
|
34
55
|
skipped: [],
|
|
35
56
|
saveProblem: null,
|
|
57
|
+
choices: {},
|
|
36
58
|
};
|
|
37
59
|
/** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
|
|
38
60
|
let serverErrors = {};
|
|
@@ -43,10 +65,36 @@ function createForm(options) {
|
|
|
43
65
|
const pending = new Set();
|
|
44
66
|
const listeners = new Set();
|
|
45
67
|
let contextCache = null;
|
|
68
|
+
/** The latest load of each field's choices: an answer to an older one is let go. */
|
|
69
|
+
const choiceLoads = {};
|
|
46
70
|
function set(patch) {
|
|
71
|
+
const was = state.values;
|
|
47
72
|
state = { ...state, ...patch };
|
|
48
73
|
for (const listener of [...listeners])
|
|
49
74
|
listener(state);
|
|
75
|
+
// Choices that change with a value that just changed are asked for again.
|
|
76
|
+
if (state.values !== was) {
|
|
77
|
+
for (const name in state.choices)
|
|
78
|
+
if (fromList(name).dependsOn?.some((other) => was[other] !== state.values[other]))
|
|
79
|
+
loadChoices(name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const fromList = (name) => page.fields[name].optionsFrom;
|
|
83
|
+
function loadChoices(name) {
|
|
84
|
+
const { list } = fromList(name);
|
|
85
|
+
const load = (choiceLoads[name] = (choiceLoads[name] ?? 0) + 1);
|
|
86
|
+
const put = (choices) => load === choiceLoads[name] && set({ choices: { ...state.choices, [name]: choices } });
|
|
87
|
+
// What was shown stays while the next ones load, so the list does not flash empty.
|
|
88
|
+
const shown = state.choices[name]?.options ?? null;
|
|
89
|
+
put({ options: shown, loading: true });
|
|
90
|
+
const source = options.dataSource;
|
|
91
|
+
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) })));
|
|
92
|
+
}
|
|
93
|
+
/** A field as its value is checked: a list's choices are those loaded, and until they are, any choice may be one. */
|
|
94
|
+
function checked(name) {
|
|
95
|
+
const def = page.fields[name];
|
|
96
|
+
const loaded = state.choices[name]?.options;
|
|
97
|
+
return def.type === 'selection' && def.optionsFrom && loaded ? { ...def, options: loaded, optionsFrom: undefined } : def;
|
|
50
98
|
}
|
|
51
99
|
function track(promise) {
|
|
52
100
|
pending.add(promise);
|
|
@@ -54,12 +102,15 @@ function createForm(options) {
|
|
|
54
102
|
promise.then(done, done);
|
|
55
103
|
return promise;
|
|
56
104
|
}
|
|
57
|
-
|
|
105
|
+
/** The values as expressions read them, and what else they read: the lines' rows, today. */
|
|
106
|
+
function reading() {
|
|
58
107
|
if (contextCache?.values !== state.values) {
|
|
59
|
-
|
|
108
|
+
const values = state.values;
|
|
109
|
+
contextCache = { values, context: (0, values_1.expressionContext)(values, page.fields), env: (0, env_1.expressionEnv)(values, page.fields, today) };
|
|
60
110
|
}
|
|
61
|
-
return contextCache
|
|
111
|
+
return contextCache;
|
|
62
112
|
}
|
|
113
|
+
const context = () => reading().context;
|
|
63
114
|
function fieldDef(name) {
|
|
64
115
|
const def = page.fields[name];
|
|
65
116
|
if (!def)
|
|
@@ -73,21 +124,21 @@ function createForm(options) {
|
|
|
73
124
|
const node = index.get(id);
|
|
74
125
|
if (!node)
|
|
75
126
|
throw new Error(`The page has no element "${id}"`);
|
|
76
|
-
const ctx =
|
|
77
|
-
let invisible = node.invisible.evaluate(ctx);
|
|
127
|
+
const { context: ctx, env } = reading();
|
|
128
|
+
let invisible = node.invisible.evaluate(ctx, env);
|
|
78
129
|
for (let parent = node.parent; !invisible && parent; parent = index.get(parent)?.parent ?? null) {
|
|
79
|
-
invisible = index.get(parent)?.invisible.evaluate(ctx) ?? false;
|
|
130
|
+
invisible = index.get(parent)?.invisible.evaluate(ctx, env) ?? false;
|
|
80
131
|
}
|
|
81
132
|
// A section read-only right now makes everything inside it read-only too.
|
|
82
|
-
let readonly = node.readonly.evaluate(ctx);
|
|
133
|
+
let readonly = node.readonly.evaluate(ctx, env);
|
|
83
134
|
for (let parent = node.parent; !readonly && parent; parent = index.get(parent)?.parent ?? null) {
|
|
84
|
-
readonly = index.get(parent)?.readonly.evaluate(ctx) ?? false;
|
|
135
|
+
readonly = index.get(parent)?.readonly.evaluate(ctx, env) ?? false;
|
|
85
136
|
}
|
|
86
137
|
const def = node.field ? page.fields[node.field] : undefined;
|
|
87
138
|
return {
|
|
88
139
|
invisible,
|
|
89
|
-
readonly: readonly || def
|
|
90
|
-
required: node.required.evaluate(ctx) || def?.required === true,
|
|
140
|
+
readonly: readonly || (def !== undefined && lockedField(def)),
|
|
141
|
+
required: node.required.evaluate(ctx, env) || def?.required === true,
|
|
91
142
|
};
|
|
92
143
|
}
|
|
93
144
|
/** Field nodes inside `root` (or the whole page), visible right now. */
|
|
@@ -111,6 +162,51 @@ function createForm(options) {
|
|
|
111
162
|
};
|
|
112
163
|
return [...index.values()].filter((node) => node.kind === 'field' && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
|
|
113
164
|
}
|
|
165
|
+
/** What a field node's answer rules say of its value now: the first error and the first warning. */
|
|
166
|
+
function ruleCheck(node) {
|
|
167
|
+
const rules = nodeRules.get(node.id);
|
|
168
|
+
if (!rules)
|
|
169
|
+
return {};
|
|
170
|
+
const name = node.field;
|
|
171
|
+
const def = page.fields[name];
|
|
172
|
+
const label = node.source.label ?? def.label;
|
|
173
|
+
const { context: ctx, env } = reading();
|
|
174
|
+
return (0, rules_1.checkRules)(def, label, state.values[name], rules, { context: ctx, env, now: options.now?.() ?? new Date(), messages });
|
|
175
|
+
}
|
|
176
|
+
/** A tick box required and not ticked, as an "I agree" box: "no" answers a yes or no, never a box to tick. */
|
|
177
|
+
function unticked(node) {
|
|
178
|
+
const name = node.field;
|
|
179
|
+
const ticks = node.source.widget === 'tick' && page.fields[name].type === 'boolean';
|
|
180
|
+
return ticks && state.values[name] !== true && nodeState(node.id).required ? messages.tick : undefined;
|
|
181
|
+
}
|
|
182
|
+
/** A field node's error now: its field's own rules first, then its answer rules. */
|
|
183
|
+
function nodeError(node) {
|
|
184
|
+
const name = node.field;
|
|
185
|
+
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;
|
|
186
|
+
}
|
|
187
|
+
/** Warnings for the visible fields of the page, without publishing them. */
|
|
188
|
+
function collectWarnings() {
|
|
189
|
+
const warnings = {};
|
|
190
|
+
if (!warns)
|
|
191
|
+
return warnings;
|
|
192
|
+
for (const node of visibleFieldNodes()) {
|
|
193
|
+
const warning = nodeRules.has(node.id) ? ruleCheck(node).warning : undefined;
|
|
194
|
+
if (warning)
|
|
195
|
+
warnings[node.field] ??= warning;
|
|
196
|
+
}
|
|
197
|
+
return warnings;
|
|
198
|
+
}
|
|
199
|
+
/** What would be found for these values, without publishing them or touching the form. */
|
|
200
|
+
function withValues(values, find) {
|
|
201
|
+
const previous = state;
|
|
202
|
+
state = { ...state, values };
|
|
203
|
+
try {
|
|
204
|
+
return find();
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
state = previous;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
114
210
|
/** Errors for the visible fields under `root`, without publishing them. */
|
|
115
211
|
function collectErrors(root) {
|
|
116
212
|
const errors = {};
|
|
@@ -119,7 +215,7 @@ function createForm(options) {
|
|
|
119
215
|
if (errors[name])
|
|
120
216
|
continue;
|
|
121
217
|
const def = page.fields[name];
|
|
122
|
-
const message = (
|
|
218
|
+
const message = nodeError(node);
|
|
123
219
|
if (message)
|
|
124
220
|
errors[name] = message;
|
|
125
221
|
if (def.type === 'one2many') {
|
|
@@ -129,7 +225,7 @@ function createForm(options) {
|
|
|
129
225
|
for (const [sub, subDef] of Object.entries(def.fields)) {
|
|
130
226
|
if (kind && sub !== def.lineKinds?.text)
|
|
131
227
|
continue;
|
|
132
|
-
const lineMessage = (0, check_1.checkValue)(subDef, line.values[sub], subDef.required === true, messages);
|
|
228
|
+
const lineMessage = (0, check_1.checkValue)(subDef, line.values[sub], subDef.required === true, messages, today());
|
|
133
229
|
if (lineMessage)
|
|
134
230
|
errors[`${name}.${line.key}.${sub}`] = lineMessage;
|
|
135
231
|
}
|
|
@@ -138,16 +234,37 @@ function createForm(options) {
|
|
|
138
234
|
}
|
|
139
235
|
return errors;
|
|
140
236
|
}
|
|
141
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Values as they settle after a change: worked out, then set where a
|
|
239
|
+
* condition started to hold, then worked out again from what was set — a
|
|
240
|
+
* few rounds at most, so rules that set each other cannot run forever.
|
|
241
|
+
*/
|
|
242
|
+
function settle(written) {
|
|
243
|
+
let values = computed.apply(written);
|
|
244
|
+
for (let round = 0; round < 8; round++) {
|
|
245
|
+
const result = setWhen.apply(values, holding);
|
|
246
|
+
holding = result.holding;
|
|
247
|
+
if (result.values === values)
|
|
248
|
+
break;
|
|
249
|
+
values = computed.apply(result.values);
|
|
250
|
+
}
|
|
251
|
+
return values;
|
|
252
|
+
}
|
|
253
|
+
/** Values to start again from, as loaded or restored: conditions holding in them hold already, and set nothing. */
|
|
254
|
+
function startFrom(values) {
|
|
255
|
+
const worked = computed.apply(values);
|
|
256
|
+
holding = setWhen.holding(worked);
|
|
257
|
+
return worked;
|
|
258
|
+
}
|
|
259
|
+
/** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
|
|
260
|
+
function writeValues(written, extra = {}, fresh = false) {
|
|
261
|
+
const values = fresh ? startFrom(written) : settle(written);
|
|
142
262
|
const errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
|
|
143
|
-
set({ values, dirty: dirtyFields(values), errors, ...extra });
|
|
263
|
+
set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
|
|
144
264
|
}
|
|
145
265
|
/** Once errors are on screen, keep them current as the person fixes things. */
|
|
146
266
|
function revalidateShown(values) {
|
|
147
|
-
const
|
|
148
|
-
state = { ...state, values };
|
|
149
|
-
const fresh = collectErrors(state.step ?? undefined);
|
|
150
|
-
state = previous;
|
|
267
|
+
const fresh = withValues(values, () => collectErrors(state.step ?? undefined));
|
|
151
268
|
const kept = {};
|
|
152
269
|
for (const key of Object.keys(state.errors))
|
|
153
270
|
if (fresh[key])
|
|
@@ -193,7 +310,7 @@ function createForm(options) {
|
|
|
193
310
|
return;
|
|
194
311
|
}
|
|
195
312
|
if (drafts.restore === 'auto')
|
|
196
|
-
writeValues({ ...state.values, ...draft.values }, { draft: null });
|
|
313
|
+
writeValues({ ...state.values, ...draft.values }, { draft: null }, true);
|
|
197
314
|
else
|
|
198
315
|
set({ draft: { savedAt: draft.savedAt } });
|
|
199
316
|
}
|
|
@@ -285,9 +402,9 @@ function createForm(options) {
|
|
|
285
402
|
values: (0, values_1.structuredCopy)(state.values),
|
|
286
403
|
});
|
|
287
404
|
forgetDraft();
|
|
288
|
-
const values = result.values ? { ...state.values, ...result.values } : state.values;
|
|
405
|
+
const values = startFrom(result.values ? { ...state.values, ...result.values } : state.values);
|
|
289
406
|
baseline = (0, values_1.structuredCopy)(values);
|
|
290
|
-
set({ status: 'saved', recordId: result.id, values, dirty: [] });
|
|
407
|
+
set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
|
|
291
408
|
return true;
|
|
292
409
|
}
|
|
293
410
|
catch (error) {
|
|
@@ -354,8 +471,8 @@ function createForm(options) {
|
|
|
354
471
|
set({ status: 'loading', error: null });
|
|
355
472
|
try {
|
|
356
473
|
const loaded = await source.load({ model: page.data.model, id: state.recordId, fields: page.fields });
|
|
357
|
-
baseline = { ...(0, values_1.initialValues)(page.fields), ...loaded };
|
|
358
|
-
set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {} });
|
|
474
|
+
baseline = startFrom({ ...(0, values_1.initialValues)(page.fields, today()), ...loaded });
|
|
475
|
+
set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
|
|
359
476
|
offerDraft();
|
|
360
477
|
}
|
|
361
478
|
catch (error) {
|
|
@@ -376,14 +493,18 @@ function createForm(options) {
|
|
|
376
493
|
afterEdit(name);
|
|
377
494
|
},
|
|
378
495
|
node: nodeState,
|
|
379
|
-
fieldReadonly: (name) => fieldDef(name)
|
|
496
|
+
fieldReadonly: (name) => lockedField(fieldDef(name)),
|
|
497
|
+
loadChoices,
|
|
380
498
|
problem(name) {
|
|
381
|
-
|
|
499
|
+
fieldDef(name);
|
|
382
500
|
// Only a field someone can see is asked anything, and required wherever it is shown so.
|
|
383
501
|
const shown = visibleFieldNodes().filter((node) => node.field === name);
|
|
384
502
|
if (!shown.length)
|
|
385
503
|
return null;
|
|
386
|
-
|
|
504
|
+
const own = shown.map(unticked).find(Boolean) ??
|
|
505
|
+
shown.map((node) => (0, inputs_1.checkInput)(page.fields[name], node.source, state.values[name], messages)).find(Boolean) ??
|
|
506
|
+
(0, check_1.checkValue)(checked(name), state.values[name], shown.some((node) => nodeState(node.id).required), messages, today());
|
|
507
|
+
return own ?? shown.map((node) => ruleCheck(node).error).find((error) => error !== undefined) ?? null;
|
|
387
508
|
},
|
|
388
509
|
validate() {
|
|
389
510
|
const errors = collectErrors();
|
|
@@ -394,14 +515,15 @@ function createForm(options) {
|
|
|
394
515
|
reset() {
|
|
395
516
|
forgetDraft();
|
|
396
517
|
serverErrors = {};
|
|
397
|
-
|
|
518
|
+
holding = setWhen.holding(baseline);
|
|
519
|
+
set({ values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
|
|
398
520
|
},
|
|
399
521
|
changes,
|
|
400
522
|
addLine(field, values = {}) {
|
|
401
523
|
const def = lineField(field);
|
|
402
524
|
const key = `new-${++lineKeys}`;
|
|
403
525
|
const current = state.values[field] ?? [];
|
|
404
|
-
const line = { key, values: { ...(0, values_1.initialValues)(def.fields), ...(0, values_1.structuredCopy)(values) } };
|
|
526
|
+
const line = { key, values: { ...(0, values_1.initialValues)(def.fields, today()), ...(0, values_1.structuredCopy)(values) } };
|
|
405
527
|
// A new line comes last, so it is numbered after the last.
|
|
406
528
|
if (def.sequenceField && line.values[def.sequenceField] == null) {
|
|
407
529
|
const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
|
|
@@ -464,14 +586,14 @@ function createForm(options) {
|
|
|
464
586
|
const line = lines.find((l) => l.key === key);
|
|
465
587
|
if (!line)
|
|
466
588
|
throw new Error(`"${field}" has no line "${key}"`);
|
|
467
|
-
const merged = { ...line.values, ...values };
|
|
589
|
+
const merged = computed.line(field, { ...line.values, ...values });
|
|
468
590
|
const source = options.dataSource;
|
|
469
591
|
if (!source?.onchange || page.data.kind !== 'record')
|
|
470
592
|
return merged;
|
|
471
593
|
const changed = lines.map((l) => (l.key === key ? { ...l, values: merged } : l));
|
|
472
594
|
const result = await source.onchange({ model: page.data.model, id: state.recordId, changed: field, values: (0, values_1.structuredCopy)({ ...state.values, [field]: changed }) });
|
|
473
595
|
const after = (result.values?.[field] ?? []).find((l) => l.key === key);
|
|
474
|
-
return after ? { ...merged, ...after.values } : merged;
|
|
596
|
+
return after ? computed.line(field, { ...merged, ...after.values }) : merged;
|
|
475
597
|
},
|
|
476
598
|
async searchLine(field, key, subfield, query, limit = 8) {
|
|
477
599
|
const def = lineField(field);
|
|
@@ -575,7 +697,7 @@ function createForm(options) {
|
|
|
575
697
|
if (!raw)
|
|
576
698
|
return;
|
|
577
699
|
const draft = JSON.parse(raw);
|
|
578
|
-
writeValues({ ...state.values, ...draft.values }, { draft: null });
|
|
700
|
+
writeValues({ ...state.values, ...draft.values }, { draft: null }, true);
|
|
579
701
|
},
|
|
580
702
|
discardDraft() {
|
|
581
703
|
forgetDraft();
|
|
@@ -593,10 +715,16 @@ function createForm(options) {
|
|
|
593
715
|
listeners.clear();
|
|
594
716
|
},
|
|
595
717
|
};
|
|
718
|
+
// A record's starting values may already break a warning rule; nothing listens yet.
|
|
719
|
+
state = { ...state, warnings: collectWarnings() };
|
|
596
720
|
if (state.recordId == null)
|
|
597
721
|
offerDraft();
|
|
598
722
|
return form;
|
|
599
723
|
}
|
|
724
|
+
/** A field no one types in: read-only by its definition, or worked out from others. */
|
|
725
|
+
function lockedField(def) {
|
|
726
|
+
return def.readonly === true || def.compute !== undefined;
|
|
727
|
+
}
|
|
600
728
|
function lineOps(before, now) {
|
|
601
729
|
const ops = [];
|
|
602
730
|
const old = new Map(before.filter((line) => line.id !== undefined).map((line) => [line.key, line]));
|