@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.
Files changed (235) hide show
  1. package/README.md +199 -0
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +1837 -94
  4. package/src/index.d.mts +1 -0
  5. package/src/index.d.ts +1 -0
  6. package/src/index.js +4 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +1 -0
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/expression/evaluator.d.mts +6 -5
  11. package/src/lib/expression/evaluator.d.ts +6 -5
  12. package/src/lib/expression/evaluator.js +37 -22
  13. package/src/lib/expression/evaluator.js.map +1 -1
  14. package/src/lib/expression/evaluator.mjs +32 -18
  15. package/src/lib/expression/evaluator.mjs.map +1 -1
  16. package/src/lib/expression/expression.d.mts +21 -0
  17. package/src/lib/expression/expression.d.ts +21 -0
  18. package/src/lib/expression/expression.js +29 -0
  19. package/src/lib/expression/expression.js.map +1 -0
  20. package/src/lib/expression/expression.mjs +26 -0
  21. package/src/lib/expression/expression.mjs.map +1 -0
  22. package/src/lib/expression/extractFieldNames.js +6 -1
  23. package/src/lib/expression/extractFieldNames.js.map +1 -1
  24. package/src/lib/expression/extractFieldNames.mjs +6 -1
  25. package/src/lib/expression/extractFieldNames.mjs.map +1 -1
  26. package/src/lib/expression/functions.d.mts +35 -0
  27. package/src/lib/expression/functions.d.ts +35 -0
  28. package/src/lib/expression/functions.js +129 -0
  29. package/src/lib/expression/functions.js.map +1 -0
  30. package/src/lib/expression/functions.mjs +124 -0
  31. package/src/lib/expression/functions.mjs.map +1 -0
  32. package/src/lib/expression/modifier.d.mts +3 -1
  33. package/src/lib/expression/modifier.d.ts +3 -1
  34. package/src/lib/expression/modifier.js +4 -27
  35. package/src/lib/expression/modifier.js.map +1 -1
  36. package/src/lib/expression/modifier.mjs +3 -26
  37. package/src/lib/expression/modifier.mjs.map +1 -1
  38. package/src/lib/expression/operations.d.mts +25 -0
  39. package/src/lib/expression/operations.d.ts +25 -0
  40. package/src/lib/expression/operations.js +81 -0
  41. package/src/lib/expression/operations.js.map +1 -0
  42. package/src/lib/expression/operations.mjs +72 -0
  43. package/src/lib/expression/operations.mjs.map +1 -0
  44. package/src/lib/expression/order.d.mts +11 -0
  45. package/src/lib/expression/order.d.ts +11 -0
  46. package/src/lib/expression/order.js +36 -0
  47. package/src/lib/expression/order.js.map +1 -0
  48. package/src/lib/expression/order.mjs +33 -0
  49. package/src/lib/expression/order.mjs.map +1 -0
  50. package/src/lib/expression/parser.d.mts +34 -4
  51. package/src/lib/expression/parser.d.ts +34 -4
  52. package/src/lib/expression/parser.js +72 -6
  53. package/src/lib/expression/parser.js.map +1 -1
  54. package/src/lib/expression/parser.mjs +72 -6
  55. package/src/lib/expression/parser.mjs.map +1 -1
  56. package/src/lib/expression/reads.d.mts +8 -0
  57. package/src/lib/expression/reads.d.ts +8 -0
  58. package/src/lib/expression/reads.js +48 -0
  59. package/src/lib/expression/reads.js.map +1 -0
  60. package/src/lib/expression/reads.mjs +44 -0
  61. package/src/lib/expression/reads.mjs.map +1 -0
  62. package/src/lib/expression/tokenizer.d.mts +7 -6
  63. package/src/lib/expression/tokenizer.d.ts +7 -6
  64. package/src/lib/expression/tokenizer.js +28 -18
  65. package/src/lib/expression/tokenizer.js.map +1 -1
  66. package/src/lib/expression/tokenizer.mjs +28 -18
  67. package/src/lib/expression/tokenizer.mjs.map +1 -1
  68. package/src/lib/format/actions.d.mts +692 -0
  69. package/src/lib/format/actions.d.ts +692 -0
  70. package/src/lib/format/actions.js +53 -0
  71. package/src/lib/format/actions.js.map +1 -0
  72. package/src/lib/format/actions.mjs +50 -0
  73. package/src/lib/format/actions.mjs.map +1 -0
  74. package/src/lib/format/field.d.mts +686 -5
  75. package/src/lib/format/field.d.ts +686 -5
  76. package/src/lib/format/field.js +117 -13
  77. package/src/lib/format/field.js.map +1 -1
  78. package/src/lib/format/field.mjs +116 -12
  79. package/src/lib/format/field.mjs.map +1 -1
  80. package/src/lib/format/layout.d.mts +1130 -22
  81. package/src/lib/format/layout.d.ts +1130 -22
  82. package/src/lib/format/layout.js +67 -5
  83. package/src/lib/format/layout.js.map +1 -1
  84. package/src/lib/format/layout.mjs +66 -4
  85. package/src/lib/format/layout.mjs.map +1 -1
  86. package/src/lib/format/layout.types.d.mts +2 -1
  87. package/src/lib/format/layout.types.d.ts +2 -1
  88. package/src/lib/format/names.d.mts +1 -1
  89. package/src/lib/format/names.d.ts +1 -1
  90. package/src/lib/format/names.js +1 -0
  91. package/src/lib/format/names.js.map +1 -1
  92. package/src/lib/format/names.mjs +1 -0
  93. package/src/lib/format/names.mjs.map +1 -1
  94. package/src/lib/format/page.d.mts +1007 -7
  95. package/src/lib/format/page.d.ts +1007 -7
  96. package/src/lib/format/page.js +28 -0
  97. package/src/lib/format/page.js.map +1 -1
  98. package/src/lib/format/page.mjs +28 -0
  99. package/src/lib/format/page.mjs.map +1 -1
  100. package/src/lib/format/part-look.d.mts +36 -0
  101. package/src/lib/format/part-look.d.ts +36 -0
  102. package/src/lib/format/part-look.js +23 -0
  103. package/src/lib/format/part-look.js.map +1 -0
  104. package/src/lib/format/part-look.mjs +20 -0
  105. package/src/lib/format/part-look.mjs.map +1 -0
  106. package/src/lib/format/references.d.mts +33 -1
  107. package/src/lib/format/references.d.ts +33 -1
  108. package/src/lib/format/references.js +227 -10
  109. package/src/lib/format/references.js.map +1 -1
  110. package/src/lib/format/references.mjs +227 -10
  111. package/src/lib/format/references.mjs.map +1 -1
  112. package/src/lib/format/translate.d.mts +20 -0
  113. package/src/lib/format/translate.d.ts +20 -0
  114. package/src/lib/format/translate.js +79 -21
  115. package/src/lib/format/translate.js.map +1 -1
  116. package/src/lib/format/translate.mjs +76 -21
  117. package/src/lib/format/translate.mjs.map +1 -1
  118. package/src/lib/record/check.d.mts +19 -1
  119. package/src/lib/record/check.d.ts +19 -1
  120. package/src/lib/record/check.js +84 -21
  121. package/src/lib/record/check.js.map +1 -1
  122. package/src/lib/record/check.mjs +82 -21
  123. package/src/lib/record/check.mjs.map +1 -1
  124. package/src/lib/record/compute.d.mts +24 -0
  125. package/src/lib/record/compute.d.ts +24 -0
  126. package/src/lib/record/compute.js +107 -0
  127. package/src/lib/record/compute.js.map +1 -0
  128. package/src/lib/record/compute.mjs +103 -0
  129. package/src/lib/record/compute.mjs.map +1 -0
  130. package/src/lib/record/data-source.d.mts +14 -2
  131. package/src/lib/record/data-source.d.ts +14 -2
  132. package/src/lib/record/data-source.js.map +1 -1
  133. package/src/lib/record/data-source.mjs.map +1 -1
  134. package/src/lib/record/env.d.mts +9 -0
  135. package/src/lib/record/env.d.ts +9 -0
  136. package/src/lib/record/env.js +28 -0
  137. package/src/lib/record/env.js.map +1 -0
  138. package/src/lib/record/env.mjs +25 -0
  139. package/src/lib/record/env.mjs.map +1 -0
  140. package/src/lib/record/form.d.mts +110 -18
  141. package/src/lib/record/form.d.ts +110 -18
  142. package/src/lib/record/form.js +497 -72
  143. package/src/lib/record/form.js.map +1 -1
  144. package/src/lib/record/form.mjs +497 -72
  145. package/src/lib/record/form.mjs.map +1 -1
  146. package/src/lib/record/inputs.d.mts +5 -0
  147. package/src/lib/record/inputs.d.ts +5 -0
  148. package/src/lib/record/inputs.js +47 -0
  149. package/src/lib/record/inputs.js.map +1 -0
  150. package/src/lib/record/inputs.mjs +44 -0
  151. package/src/lib/record/inputs.mjs.map +1 -0
  152. package/src/lib/record/limits.d.mts +13 -0
  153. package/src/lib/record/limits.d.ts +13 -0
  154. package/src/lib/record/limits.js +45 -0
  155. package/src/lib/record/limits.js.map +1 -0
  156. package/src/lib/record/limits.mjs +41 -0
  157. package/src/lib/record/limits.mjs.map +1 -0
  158. package/src/lib/record/locales/all.d.mts +7 -0
  159. package/src/lib/record/locales/all.d.ts +7 -0
  160. package/src/lib/record/locales/all.js +13 -0
  161. package/src/lib/record/locales/all.js.map +1 -0
  162. package/src/lib/record/locales/all.mjs +10 -0
  163. package/src/lib/record/locales/all.mjs.map +1 -0
  164. package/src/lib/record/locales/ar.d.mts +3 -0
  165. package/src/lib/record/locales/ar.d.ts +3 -0
  166. package/src/lib/record/locales/ar.js +55 -0
  167. package/src/lib/record/locales/ar.js.map +1 -0
  168. package/src/lib/record/locales/ar.mjs +52 -0
  169. package/src/lib/record/locales/ar.mjs.map +1 -0
  170. package/src/lib/record/locales/de.d.mts +3 -0
  171. package/src/lib/record/locales/de.d.ts +3 -0
  172. package/src/lib/record/locales/de.js +55 -0
  173. package/src/lib/record/locales/de.js.map +1 -0
  174. package/src/lib/record/locales/de.mjs +52 -0
  175. package/src/lib/record/locales/de.mjs.map +1 -0
  176. package/src/lib/record/locales/fr.d.mts +3 -0
  177. package/src/lib/record/locales/fr.d.ts +3 -0
  178. package/src/lib/record/locales/fr.js +55 -0
  179. package/src/lib/record/locales/fr.js.map +1 -0
  180. package/src/lib/record/locales/fr.mjs +52 -0
  181. package/src/lib/record/locales/fr.mjs.map +1 -0
  182. package/src/lib/record/memory-data-source.d.mts +3 -0
  183. package/src/lib/record/memory-data-source.d.ts +3 -0
  184. package/src/lib/record/memory-data-source.js +8 -0
  185. package/src/lib/record/memory-data-source.js.map +1 -1
  186. package/src/lib/record/memory-data-source.mjs +8 -0
  187. package/src/lib/record/memory-data-source.mjs.map +1 -1
  188. package/src/lib/record/messages.d.mts +46 -0
  189. package/src/lib/record/messages.d.ts +46 -0
  190. package/src/lib/record/messages.js +58 -101
  191. package/src/lib/record/messages.js.map +1 -1
  192. package/src/lib/record/messages.mjs +58 -101
  193. package/src/lib/record/messages.mjs.map +1 -1
  194. package/src/lib/record/rules.d.mts +33 -0
  195. package/src/lib/record/rules.d.ts +33 -0
  196. package/src/lib/record/rules.js +83 -0
  197. package/src/lib/record/rules.js.map +1 -0
  198. package/src/lib/record/rules.mjs +79 -0
  199. package/src/lib/record/rules.mjs.map +1 -0
  200. package/src/lib/record/run.d.mts +169 -0
  201. package/src/lib/record/run.d.ts +169 -0
  202. package/src/lib/record/run.js +350 -0
  203. package/src/lib/record/run.js.map +1 -0
  204. package/src/lib/record/run.mjs +345 -0
  205. package/src/lib/record/run.mjs.map +1 -0
  206. package/src/lib/record/score.d.mts +16 -0
  207. package/src/lib/record/score.d.ts +16 -0
  208. package/src/lib/record/score.js +35 -0
  209. package/src/lib/record/score.js.map +1 -0
  210. package/src/lib/record/score.mjs +32 -0
  211. package/src/lib/record/score.mjs.map +1 -0
  212. package/src/lib/record/set-when.d.mts +24 -0
  213. package/src/lib/record/set-when.d.ts +24 -0
  214. package/src/lib/record/set-when.js +68 -0
  215. package/src/lib/record/set-when.js.map +1 -0
  216. package/src/lib/record/set-when.mjs +65 -0
  217. package/src/lib/record/set-when.mjs.map +1 -0
  218. package/src/lib/record/structures.d.mts +14 -0
  219. package/src/lib/record/structures.d.ts +14 -0
  220. package/src/lib/record/structures.js +32 -0
  221. package/src/lib/record/structures.js.map +1 -0
  222. package/src/lib/record/structures.mjs +28 -0
  223. package/src/lib/record/structures.mjs.map +1 -0
  224. package/src/lib/record/values.d.mts +10 -5
  225. package/src/lib/record/values.d.ts +10 -5
  226. package/src/lib/record/values.js +20 -6
  227. package/src/lib/record/values.js.map +1 -1
  228. package/src/lib/record/values.mjs +19 -6
  229. package/src/lib/record/values.mjs.map +1 -1
  230. package/src/runtime.d.mts +13 -7
  231. package/src/runtime.d.ts +13 -7
  232. package/src/runtime.js +14 -1
  233. package/src/runtime.js.map +1 -1
  234. package/src/runtime.mjs +6 -2
  235. package/src/runtime.mjs.map +1 -1
@@ -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
- let baseline = { ...(0, values_1.initialValues)(page.fields), ...(0, values_1.structuredCopy)(options.values ?? {}) };
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
- function context() {
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
- contextCache = { values: state.values, context: (0, values_1.expressionContext)(state.values, page.fields) };
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.context;
182
+ return contextCache;
62
183
  }
184
+ const context = () => reading().context;
63
185
  function fieldDef(name) {
64
- const def = page.fields[name];
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(page.fields).filter((name) => JSON.stringify(values[name] ?? null) !== JSON.stringify(baseline[name] ?? null));
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 = context();
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?.readonly === true,
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 === 'field' && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
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 = (0, check_1.checkValue)(def, state.values[name], nodeState(node.id).required, messages);
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
- function writeValues(values, extra = {}) {
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 previous = state;
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(page.fields).filter((name) => state.dirty.includes(name) || !(0, values_1.isEmpty)(page.fields[name], state.values[name]))
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 = page.fields[name];
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 false;
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
- await source.submit({ pageId: page.id, values: shownValues() });
514
+ const sent = shownValues();
515
+ await source.submit({ pageId: page.id, values: sent });
274
516
  forgetDraft();
275
517
  set({ status: 'saved' });
276
- return true;
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: page.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
- return true;
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 false;
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: page.fields });
357
- baseline = { ...(0, values_1.initialValues)(page.fields), ...loaded };
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).readonly === true,
707
+ fieldReadonly: (name) => lockedField(fieldDef(name)),
708
+ loadChoices,
380
709
  problem(name) {
381
- const def = fieldDef(name);
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
- return (0, check_1.checkValue)(def, state.values[name], shown.some((node) => nodeState(node.id).required), messages) ?? null;
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
- set({ values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
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
- await track(Promise.resolve(options.onAction?.({
565
- id,
566
- action: source.action,
567
- ...('params' in source && source.params ? { params: source.params } : {}),
568
- recordId: state.recordId,
569
- values: (0, values_1.structuredCopy)(state.values),
570
- ...(chosen ? { recordIds: [...chosen.recordIds] } : {}),
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
- return form;
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
  }