@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.
Files changed (208) hide show
  1. package/package.json +1 -1
  2. package/schema/page.schema.json +1128 -89
  3. package/src/lib/expression/evaluator.d.mts +6 -5
  4. package/src/lib/expression/evaluator.d.ts +6 -5
  5. package/src/lib/expression/evaluator.js +37 -22
  6. package/src/lib/expression/evaluator.js.map +1 -1
  7. package/src/lib/expression/evaluator.mjs +32 -18
  8. package/src/lib/expression/evaluator.mjs.map +1 -1
  9. package/src/lib/expression/expression.d.mts +21 -0
  10. package/src/lib/expression/expression.d.ts +21 -0
  11. package/src/lib/expression/expression.js +29 -0
  12. package/src/lib/expression/expression.js.map +1 -0
  13. package/src/lib/expression/expression.mjs +26 -0
  14. package/src/lib/expression/expression.mjs.map +1 -0
  15. package/src/lib/expression/extractFieldNames.js +6 -1
  16. package/src/lib/expression/extractFieldNames.js.map +1 -1
  17. package/src/lib/expression/extractFieldNames.mjs +6 -1
  18. package/src/lib/expression/extractFieldNames.mjs.map +1 -1
  19. package/src/lib/expression/functions.d.mts +35 -0
  20. package/src/lib/expression/functions.d.ts +35 -0
  21. package/src/lib/expression/functions.js +129 -0
  22. package/src/lib/expression/functions.js.map +1 -0
  23. package/src/lib/expression/functions.mjs +124 -0
  24. package/src/lib/expression/functions.mjs.map +1 -0
  25. package/src/lib/expression/modifier.d.mts +3 -1
  26. package/src/lib/expression/modifier.d.ts +3 -1
  27. package/src/lib/expression/modifier.js +4 -27
  28. package/src/lib/expression/modifier.js.map +1 -1
  29. package/src/lib/expression/modifier.mjs +3 -26
  30. package/src/lib/expression/modifier.mjs.map +1 -1
  31. package/src/lib/expression/operations.d.mts +25 -0
  32. package/src/lib/expression/operations.d.ts +25 -0
  33. package/src/lib/expression/operations.js +81 -0
  34. package/src/lib/expression/operations.js.map +1 -0
  35. package/src/lib/expression/operations.mjs +72 -0
  36. package/src/lib/expression/operations.mjs.map +1 -0
  37. package/src/lib/expression/order.d.mts +11 -0
  38. package/src/lib/expression/order.d.ts +11 -0
  39. package/src/lib/expression/order.js +36 -0
  40. package/src/lib/expression/order.js.map +1 -0
  41. package/src/lib/expression/order.mjs +33 -0
  42. package/src/lib/expression/order.mjs.map +1 -0
  43. package/src/lib/expression/parser.d.mts +34 -4
  44. package/src/lib/expression/parser.d.ts +34 -4
  45. package/src/lib/expression/parser.js +72 -6
  46. package/src/lib/expression/parser.js.map +1 -1
  47. package/src/lib/expression/parser.mjs +72 -6
  48. package/src/lib/expression/parser.mjs.map +1 -1
  49. package/src/lib/expression/reads.d.mts +8 -0
  50. package/src/lib/expression/reads.d.ts +8 -0
  51. package/src/lib/expression/reads.js +48 -0
  52. package/src/lib/expression/reads.js.map +1 -0
  53. package/src/lib/expression/reads.mjs +44 -0
  54. package/src/lib/expression/reads.mjs.map +1 -0
  55. package/src/lib/expression/tokenizer.d.mts +7 -6
  56. package/src/lib/expression/tokenizer.d.ts +7 -6
  57. package/src/lib/expression/tokenizer.js +28 -18
  58. package/src/lib/expression/tokenizer.js.map +1 -1
  59. package/src/lib/expression/tokenizer.mjs +28 -18
  60. package/src/lib/expression/tokenizer.mjs.map +1 -1
  61. package/src/lib/format/field.d.mts +686 -5
  62. package/src/lib/format/field.d.ts +686 -5
  63. package/src/lib/format/field.js +115 -13
  64. package/src/lib/format/field.js.map +1 -1
  65. package/src/lib/format/field.mjs +114 -12
  66. package/src/lib/format/field.mjs.map +1 -1
  67. package/src/lib/format/layout.d.mts +388 -10
  68. package/src/lib/format/layout.d.ts +388 -10
  69. package/src/lib/format/layout.js +50 -3
  70. package/src/lib/format/layout.js.map +1 -1
  71. package/src/lib/format/layout.mjs +49 -2
  72. package/src/lib/format/layout.mjs.map +1 -1
  73. package/src/lib/format/names.d.mts +1 -1
  74. package/src/lib/format/names.d.ts +1 -1
  75. package/src/lib/format/names.js +1 -0
  76. package/src/lib/format/names.js.map +1 -1
  77. package/src/lib/format/names.mjs +1 -0
  78. package/src/lib/format/names.mjs.map +1 -1
  79. package/src/lib/format/page.d.mts +391 -4
  80. package/src/lib/format/page.d.ts +391 -4
  81. package/src/lib/format/page.js +13 -0
  82. package/src/lib/format/page.js.map +1 -1
  83. package/src/lib/format/page.mjs +13 -0
  84. package/src/lib/format/page.mjs.map +1 -1
  85. package/src/lib/format/references.d.mts +14 -1
  86. package/src/lib/format/references.d.ts +14 -1
  87. package/src/lib/format/references.js +96 -8
  88. package/src/lib/format/references.js.map +1 -1
  89. package/src/lib/format/references.mjs +96 -8
  90. package/src/lib/format/references.mjs.map +1 -1
  91. package/src/lib/format/translate.d.mts +20 -0
  92. package/src/lib/format/translate.d.ts +20 -0
  93. package/src/lib/format/translate.js +79 -21
  94. package/src/lib/format/translate.js.map +1 -1
  95. package/src/lib/format/translate.mjs +76 -21
  96. package/src/lib/format/translate.mjs.map +1 -1
  97. package/src/lib/record/check.d.mts +19 -1
  98. package/src/lib/record/check.d.ts +19 -1
  99. package/src/lib/record/check.js +84 -21
  100. package/src/lib/record/check.js.map +1 -1
  101. package/src/lib/record/check.mjs +82 -21
  102. package/src/lib/record/check.mjs.map +1 -1
  103. package/src/lib/record/compute.d.mts +24 -0
  104. package/src/lib/record/compute.d.ts +24 -0
  105. package/src/lib/record/compute.js +107 -0
  106. package/src/lib/record/compute.js.map +1 -0
  107. package/src/lib/record/compute.mjs +103 -0
  108. package/src/lib/record/compute.mjs.map +1 -0
  109. package/src/lib/record/data-source.d.mts +9 -1
  110. package/src/lib/record/data-source.d.ts +9 -1
  111. package/src/lib/record/data-source.js.map +1 -1
  112. package/src/lib/record/data-source.mjs.map +1 -1
  113. package/src/lib/record/env.d.mts +9 -0
  114. package/src/lib/record/env.d.ts +9 -0
  115. package/src/lib/record/env.js +28 -0
  116. package/src/lib/record/env.js.map +1 -0
  117. package/src/lib/record/env.mjs +25 -0
  118. package/src/lib/record/env.mjs.map +1 -0
  119. package/src/lib/record/form.d.mts +24 -0
  120. package/src/lib/record/form.d.ts +24 -0
  121. package/src/lib/record/form.js +160 -32
  122. package/src/lib/record/form.js.map +1 -1
  123. package/src/lib/record/form.mjs +160 -32
  124. package/src/lib/record/form.mjs.map +1 -1
  125. package/src/lib/record/inputs.d.mts +5 -0
  126. package/src/lib/record/inputs.d.ts +5 -0
  127. package/src/lib/record/inputs.js +47 -0
  128. package/src/lib/record/inputs.js.map +1 -0
  129. package/src/lib/record/inputs.mjs +44 -0
  130. package/src/lib/record/inputs.mjs.map +1 -0
  131. package/src/lib/record/limits.d.mts +13 -0
  132. package/src/lib/record/limits.d.ts +13 -0
  133. package/src/lib/record/limits.js +45 -0
  134. package/src/lib/record/limits.js.map +1 -0
  135. package/src/lib/record/limits.mjs +41 -0
  136. package/src/lib/record/limits.mjs.map +1 -0
  137. package/src/lib/record/locales/all.d.mts +7 -0
  138. package/src/lib/record/locales/all.d.ts +7 -0
  139. package/src/lib/record/locales/all.js +13 -0
  140. package/src/lib/record/locales/all.js.map +1 -0
  141. package/src/lib/record/locales/all.mjs +10 -0
  142. package/src/lib/record/locales/all.mjs.map +1 -0
  143. package/src/lib/record/locales/ar.d.mts +3 -0
  144. package/src/lib/record/locales/ar.d.ts +3 -0
  145. package/src/lib/record/locales/ar.js +55 -0
  146. package/src/lib/record/locales/ar.js.map +1 -0
  147. package/src/lib/record/locales/ar.mjs +52 -0
  148. package/src/lib/record/locales/ar.mjs.map +1 -0
  149. package/src/lib/record/locales/de.d.mts +3 -0
  150. package/src/lib/record/locales/de.d.ts +3 -0
  151. package/src/lib/record/locales/de.js +55 -0
  152. package/src/lib/record/locales/de.js.map +1 -0
  153. package/src/lib/record/locales/de.mjs +52 -0
  154. package/src/lib/record/locales/de.mjs.map +1 -0
  155. package/src/lib/record/locales/fr.d.mts +3 -0
  156. package/src/lib/record/locales/fr.d.ts +3 -0
  157. package/src/lib/record/locales/fr.js +55 -0
  158. package/src/lib/record/locales/fr.js.map +1 -0
  159. package/src/lib/record/locales/fr.mjs +52 -0
  160. package/src/lib/record/locales/fr.mjs.map +1 -0
  161. package/src/lib/record/memory-data-source.d.mts +3 -0
  162. package/src/lib/record/memory-data-source.d.ts +3 -0
  163. package/src/lib/record/memory-data-source.js +8 -0
  164. package/src/lib/record/memory-data-source.js.map +1 -1
  165. package/src/lib/record/memory-data-source.mjs +8 -0
  166. package/src/lib/record/memory-data-source.mjs.map +1 -1
  167. package/src/lib/record/messages.d.mts +46 -0
  168. package/src/lib/record/messages.d.ts +46 -0
  169. package/src/lib/record/messages.js +58 -101
  170. package/src/lib/record/messages.js.map +1 -1
  171. package/src/lib/record/messages.mjs +58 -101
  172. package/src/lib/record/messages.mjs.map +1 -1
  173. package/src/lib/record/rules.d.mts +33 -0
  174. package/src/lib/record/rules.d.ts +33 -0
  175. package/src/lib/record/rules.js +83 -0
  176. package/src/lib/record/rules.js.map +1 -0
  177. package/src/lib/record/rules.mjs +79 -0
  178. package/src/lib/record/rules.mjs.map +1 -0
  179. package/src/lib/record/score.d.mts +16 -0
  180. package/src/lib/record/score.d.ts +16 -0
  181. package/src/lib/record/score.js +35 -0
  182. package/src/lib/record/score.js.map +1 -0
  183. package/src/lib/record/score.mjs +32 -0
  184. package/src/lib/record/score.mjs.map +1 -0
  185. package/src/lib/record/set-when.d.mts +24 -0
  186. package/src/lib/record/set-when.d.ts +24 -0
  187. package/src/lib/record/set-when.js +68 -0
  188. package/src/lib/record/set-when.js.map +1 -0
  189. package/src/lib/record/set-when.mjs +65 -0
  190. package/src/lib/record/set-when.mjs.map +1 -0
  191. package/src/lib/record/structures.d.mts +14 -0
  192. package/src/lib/record/structures.d.ts +14 -0
  193. package/src/lib/record/structures.js +32 -0
  194. package/src/lib/record/structures.js.map +1 -0
  195. package/src/lib/record/structures.mjs +28 -0
  196. package/src/lib/record/structures.mjs.map +1 -0
  197. package/src/lib/record/values.d.mts +10 -5
  198. package/src/lib/record/values.d.ts +10 -5
  199. package/src/lib/record/values.js +20 -6
  200. package/src/lib/record/values.js.map +1 -1
  201. package/src/lib/record/values.mjs +19 -6
  202. package/src/lib/record/values.mjs.map +1 -1
  203. package/src/runtime.d.mts +10 -7
  204. package/src/runtime.d.ts +10 -7
  205. package/src/runtime.js +12 -1
  206. package/src/runtime.js.map +1 -1
  207. package/src/runtime.mjs +5 -2
  208. 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;
@@ -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;
@@ -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
- let baseline = { ...(0, values_1.initialValues)(page.fields), ...(0, values_1.structuredCopy)(options.values ?? {}) };
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
- function context() {
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
- contextCache = { values: state.values, context: (0, values_1.expressionContext)(state.values, page.fields) };
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.context;
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 = context();
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?.readonly === true,
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 = (0, check_1.checkValue)(def, state.values[name], nodeState(node.id).required, messages);
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
- function writeValues(values, extra = {}) {
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 previous = state;
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).readonly === true,
496
+ fieldReadonly: (name) => lockedField(fieldDef(name)),
497
+ loadChoices,
380
498
  problem(name) {
381
- const def = fieldDef(name);
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
- return (0, check_1.checkValue)(def, state.values[name], shown.some((node) => nodeState(node.id).required), messages) ?? null;
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
- set({ values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
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]));