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