@fieldia/core 0.10.0 → 0.12.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 (205) hide show
  1. package/README.md +204 -0
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +4700 -904
  4. package/src/index.d.mts +2 -1
  5. package/src/index.d.ts +2 -1
  6. package/src/index.js +7 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +2 -1
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/expression/built-ins.d.mts +4 -0
  11. package/src/lib/expression/built-ins.d.ts +4 -0
  12. package/src/lib/expression/built-ins.js +8 -0
  13. package/src/lib/expression/built-ins.js.map +1 -0
  14. package/src/lib/expression/built-ins.mjs +5 -0
  15. package/src/lib/expression/built-ins.mjs.map +1 -0
  16. package/src/lib/expression/evaluator.js +17 -7
  17. package/src/lib/expression/evaluator.js.map +1 -1
  18. package/src/lib/expression/evaluator.mjs +17 -7
  19. package/src/lib/expression/evaluator.mjs.map +1 -1
  20. package/src/lib/expression/expression.d.mts +7 -0
  21. package/src/lib/expression/expression.d.ts +7 -0
  22. package/src/lib/expression/expression.js +2 -0
  23. package/src/lib/expression/expression.js.map +1 -1
  24. package/src/lib/expression/expression.mjs +3 -1
  25. package/src/lib/expression/expression.mjs.map +1 -1
  26. package/src/lib/expression/functions.d.mts +2 -0
  27. package/src/lib/expression/functions.d.ts +2 -0
  28. package/src/lib/expression/functions.js +38 -5
  29. package/src/lib/expression/functions.js.map +1 -1
  30. package/src/lib/expression/functions.mjs +38 -5
  31. package/src/lib/expression/functions.mjs.map +1 -1
  32. package/src/lib/expression/modifier.d.mts +7 -0
  33. package/src/lib/expression/modifier.d.ts +7 -0
  34. package/src/lib/expression/modifier.js +3 -1
  35. package/src/lib/expression/modifier.js.map +1 -1
  36. package/src/lib/expression/modifier.mjs +4 -2
  37. package/src/lib/expression/modifier.mjs.map +1 -1
  38. package/src/lib/expression/parser.d.mts +8 -1
  39. package/src/lib/expression/parser.d.ts +8 -1
  40. package/src/lib/expression/parser.js +30 -2
  41. package/src/lib/expression/parser.js.map +1 -1
  42. package/src/lib/expression/parser.mjs +30 -2
  43. package/src/lib/expression/parser.mjs.map +1 -1
  44. package/src/lib/expression/reads.d.mts +7 -0
  45. package/src/lib/expression/reads.d.ts +7 -0
  46. package/src/lib/expression/reads.js +23 -0
  47. package/src/lib/expression/reads.js.map +1 -1
  48. package/src/lib/expression/reads.mjs +21 -0
  49. package/src/lib/expression/reads.mjs.map +1 -1
  50. package/src/lib/format/actions.d.mts +1176 -0
  51. package/src/lib/format/actions.d.ts +1176 -0
  52. package/src/lib/format/actions.js +62 -0
  53. package/src/lib/format/actions.js.map +1 -0
  54. package/src/lib/format/actions.mjs +59 -0
  55. package/src/lib/format/actions.mjs.map +1 -0
  56. package/src/lib/format/check-page.d.mts +7 -2
  57. package/src/lib/format/check-page.d.ts +7 -2
  58. package/src/lib/format/check-page.js +6 -3
  59. package/src/lib/format/check-page.js.map +1 -1
  60. package/src/lib/format/check-page.mjs +6 -3
  61. package/src/lib/format/check-page.mjs.map +1 -1
  62. package/src/lib/format/field.d.mts +3285 -214
  63. package/src/lib/format/field.d.ts +3285 -214
  64. package/src/lib/format/field.js +105 -16
  65. package/src/lib/format/field.js.map +1 -1
  66. package/src/lib/format/field.mjs +104 -15
  67. package/src/lib/format/field.mjs.map +1 -1
  68. package/src/lib/format/hotkeys.d.mts +6 -0
  69. package/src/lib/format/hotkeys.d.ts +6 -0
  70. package/src/lib/format/hotkeys.js +11 -0
  71. package/src/lib/format/hotkeys.js.map +1 -0
  72. package/src/lib/format/hotkeys.mjs +7 -0
  73. package/src/lib/format/hotkeys.mjs.map +1 -0
  74. package/src/lib/format/layout.d.mts +4773 -211
  75. package/src/lib/format/layout.d.ts +4773 -211
  76. package/src/lib/format/layout.js +147 -21
  77. package/src/lib/format/layout.js.map +1 -1
  78. package/src/lib/format/layout.mjs +146 -20
  79. package/src/lib/format/layout.mjs.map +1 -1
  80. package/src/lib/format/layout.types.d.mts +2 -1
  81. package/src/lib/format/layout.types.d.ts +2 -1
  82. package/src/lib/format/page.d.mts +3563 -222
  83. package/src/lib/format/page.d.ts +3563 -222
  84. package/src/lib/format/page.js +19 -2
  85. package/src/lib/format/page.js.map +1 -1
  86. package/src/lib/format/page.mjs +20 -3
  87. package/src/lib/format/page.mjs.map +1 -1
  88. package/src/lib/format/part-look.d.mts +36 -0
  89. package/src/lib/format/part-look.d.ts +36 -0
  90. package/src/lib/format/part-look.js +23 -0
  91. package/src/lib/format/part-look.js.map +1 -0
  92. package/src/lib/format/part-look.mjs +20 -0
  93. package/src/lib/format/part-look.mjs.map +1 -0
  94. package/src/lib/format/references.d.mts +54 -2
  95. package/src/lib/format/references.d.ts +54 -2
  96. package/src/lib/format/references.js +450 -43
  97. package/src/lib/format/references.js.map +1 -1
  98. package/src/lib/format/references.mjs +450 -43
  99. package/src/lib/format/references.mjs.map +1 -1
  100. package/src/lib/format/translate.js +5 -3
  101. package/src/lib/format/translate.js.map +1 -1
  102. package/src/lib/format/translate.mjs +5 -3
  103. package/src/lib/format/translate.mjs.map +1 -1
  104. package/src/lib/format/words.d.mts +9 -0
  105. package/src/lib/format/words.d.ts +9 -0
  106. package/src/lib/format/words.js +35 -0
  107. package/src/lib/format/words.js.map +1 -0
  108. package/src/lib/format/words.mjs +30 -0
  109. package/src/lib/format/words.mjs.map +1 -0
  110. package/src/lib/record/cells.d.mts +37 -0
  111. package/src/lib/record/cells.d.ts +37 -0
  112. package/src/lib/record/cells.js +59 -0
  113. package/src/lib/record/cells.js.map +1 -0
  114. package/src/lib/record/cells.mjs +54 -0
  115. package/src/lib/record/cells.mjs.map +1 -0
  116. package/src/lib/record/compute.d.mts +5 -4
  117. package/src/lib/record/compute.d.ts +5 -4
  118. package/src/lib/record/compute.js +11 -10
  119. package/src/lib/record/compute.js.map +1 -1
  120. package/src/lib/record/compute.mjs +12 -11
  121. package/src/lib/record/compute.mjs.map +1 -1
  122. package/src/lib/record/data-source.d.mts +37 -3
  123. package/src/lib/record/data-source.d.ts +37 -3
  124. package/src/lib/record/data-source.js.map +1 -1
  125. package/src/lib/record/data-source.mjs.map +1 -1
  126. package/src/lib/record/env.d.mts +38 -1
  127. package/src/lib/record/env.d.ts +38 -1
  128. package/src/lib/record/env.js +41 -3
  129. package/src/lib/record/env.js.map +1 -1
  130. package/src/lib/record/env.mjs +38 -3
  131. package/src/lib/record/env.mjs.map +1 -1
  132. package/src/lib/record/filter.d.mts +10 -0
  133. package/src/lib/record/filter.d.ts +10 -0
  134. package/src/lib/record/filter.js +49 -0
  135. package/src/lib/record/filter.js.map +1 -1
  136. package/src/lib/record/filter.mjs +48 -0
  137. package/src/lib/record/filter.mjs.map +1 -1
  138. package/src/lib/record/form.d.mts +226 -25
  139. package/src/lib/record/form.d.ts +226 -25
  140. package/src/lib/record/form.js +785 -83
  141. package/src/lib/record/form.js.map +1 -1
  142. package/src/lib/record/form.mjs +787 -85
  143. package/src/lib/record/form.mjs.map +1 -1
  144. package/src/lib/record/locales/ar.js +3 -0
  145. package/src/lib/record/locales/ar.js.map +1 -1
  146. package/src/lib/record/locales/ar.mjs +3 -0
  147. package/src/lib/record/locales/ar.mjs.map +1 -1
  148. package/src/lib/record/locales/de.js +3 -0
  149. package/src/lib/record/locales/de.js.map +1 -1
  150. package/src/lib/record/locales/de.mjs +3 -0
  151. package/src/lib/record/locales/de.mjs.map +1 -1
  152. package/src/lib/record/locales/fr.js +3 -0
  153. package/src/lib/record/locales/fr.js.map +1 -1
  154. package/src/lib/record/locales/fr.mjs +3 -0
  155. package/src/lib/record/locales/fr.mjs.map +1 -1
  156. package/src/lib/record/memory-data-source.d.mts +26 -2
  157. package/src/lib/record/memory-data-source.d.ts +26 -2
  158. package/src/lib/record/memory-data-source.js +98 -7
  159. package/src/lib/record/memory-data-source.js.map +1 -1
  160. package/src/lib/record/memory-data-source.mjs +98 -7
  161. package/src/lib/record/memory-data-source.mjs.map +1 -1
  162. package/src/lib/record/messages.d.mts +5 -0
  163. package/src/lib/record/messages.d.ts +5 -0
  164. package/src/lib/record/messages.js +3 -0
  165. package/src/lib/record/messages.js.map +1 -1
  166. package/src/lib/record/messages.mjs +3 -0
  167. package/src/lib/record/messages.mjs.map +1 -1
  168. package/src/lib/record/roles.d.mts +10 -0
  169. package/src/lib/record/roles.d.ts +10 -0
  170. package/src/lib/record/roles.js +21 -0
  171. package/src/lib/record/roles.js.map +1 -0
  172. package/src/lib/record/roles.mjs +18 -0
  173. package/src/lib/record/roles.mjs.map +1 -0
  174. package/src/lib/record/rules.js +30 -0
  175. package/src/lib/record/rules.js.map +1 -1
  176. package/src/lib/record/rules.mjs +31 -1
  177. package/src/lib/record/rules.mjs.map +1 -1
  178. package/src/lib/record/run.d.mts +248 -0
  179. package/src/lib/record/run.d.ts +248 -0
  180. package/src/lib/record/run.js +455 -0
  181. package/src/lib/record/run.js.map +1 -0
  182. package/src/lib/record/run.mjs +450 -0
  183. package/src/lib/record/run.mjs.map +1 -0
  184. package/src/lib/record/set-when.d.mts +5 -2
  185. package/src/lib/record/set-when.d.ts +5 -2
  186. package/src/lib/record/set-when.js +28 -12
  187. package/src/lib/record/set-when.js.map +1 -1
  188. package/src/lib/record/set-when.mjs +30 -14
  189. package/src/lib/record/set-when.mjs.map +1 -1
  190. package/src/lib/record/value-map.d.mts +41 -0
  191. package/src/lib/record/value-map.d.ts +41 -0
  192. package/src/lib/record/value-map.js +80 -0
  193. package/src/lib/record/value-map.js.map +1 -0
  194. package/src/lib/record/value-map.mjs +73 -0
  195. package/src/lib/record/value-map.mjs.map +1 -0
  196. package/src/lib/record/values.d.mts +10 -0
  197. package/src/lib/record/values.d.ts +10 -0
  198. package/src/lib/record/values.js.map +1 -1
  199. package/src/lib/record/values.mjs.map +1 -1
  200. package/src/runtime.d.mts +11 -4
  201. package/src/runtime.d.ts +11 -4
  202. package/src/runtime.js +13 -1
  203. package/src/runtime.js.map +1 -1
  204. package/src/runtime.mjs +4 -0
  205. package/src/runtime.mjs.map +1 -1
@@ -9,9 +9,14 @@ const compute_1 = require("./compute");
9
9
  const set_when_1 = require("./set-when");
10
10
  const rules_1 = require("./rules");
11
11
  const env_1 = require("./env");
12
+ const value_map_1 = require("./value-map");
13
+ const filter_1 = require("./filter");
14
+ const roles_1 = require("./roles");
15
+ const cells_1 = require("./cells");
12
16
  const messages_1 = require("./messages");
13
17
  const data_source_1 = require("./data-source");
14
18
  const scheduler_1 = require("./scheduler");
19
+ const run_1 = require("./run");
15
20
  const values_1 = require("./values");
16
21
  /**
17
22
  * The record state behind a page: values, what changed, what is visible and
@@ -19,6 +24,13 @@ const values_1 = require("./values");
19
24
  * binding renders from `getState()` and calls these methods.
20
25
  */
21
26
  function createForm(options) {
27
+ return innerForm(options, []).form;
28
+ }
29
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
30
+ /** A saved form's answers, as the one value they are in the form round it. */
31
+ const asValue = (values) => values;
32
+ /** `within` are the pages round this one, outermost first: a page among them placed again would hold itself. */
33
+ function innerForm(options, within) {
22
34
  const { page } = options;
23
35
  const scheduler = options.scheduler ?? scheduler_1.hostScheduler;
24
36
  const messages = options.messages ?? messages_1.MESSAGES.en;
@@ -30,14 +42,150 @@ function createForm(options) {
30
42
  if (node.kind === 'field' && rules)
31
43
  nodeRules.set(node.id, (0, rules_1.compileRules)(rules));
32
44
  }
45
+ /** Tables of lines with rules of their own, by their field node: each read once. */
46
+ const tables = new Map();
47
+ for (const node of index.values()) {
48
+ const source = node.source;
49
+ if (node.kind !== 'field' || page.fields[source.field]?.type !== 'one2many')
50
+ continue;
51
+ if (source.cells || source.rowTones || source.rowBold !== undefined || source.rowButtons || source.lineDelete !== undefined)
52
+ tables.set(node.id, { field: source.field, compiled: (0, cells_1.compileTable)(source) });
53
+ }
54
+ /** Field nodes whose value takes a tone or bold, by id: each read once. */
55
+ const fieldTones = new Map();
56
+ for (const node of index.values()) {
57
+ const source = node.source;
58
+ if (node.kind === 'field' && (source.tones || source.bold !== undefined))
59
+ fieldTones.set(node.id, (0, cells_1.compileFieldTone)(source));
60
+ }
61
+ /** The tables whose lines load in an order of their own fields, by the field: the first node to give one. */
62
+ const lineOrders = new Map();
63
+ for (const node of index.values()) {
64
+ const source = node.source;
65
+ if (node.kind === 'field' && source.order?.length && page.fields[source.field]?.type === 'one2many' && !lineOrders.has(source.field))
66
+ lineOrders.set(source.field, source.order);
67
+ }
68
+ /** Lines as they load, in their tables' orders — only `loaded` fields, when given: the same values when nothing moved. */
69
+ function ordered(values, loaded) {
70
+ let out = values;
71
+ for (const [field, by] of lineOrders) {
72
+ if (loaded && !loaded.includes(field))
73
+ continue;
74
+ const lines = values[field];
75
+ if (!Array.isArray(lines) || lines.length < 2)
76
+ continue;
77
+ const sorted = [...lines].sort((a, b) => compareLines(a.values, b.values, by));
78
+ if (sorted.some((line, i) => line !== lines[i]))
79
+ out = { ...out, [field]: sorted };
80
+ }
81
+ return out;
82
+ }
83
+ /** The words of each field's empty box chosen by conditions, by its node: read once. */
84
+ const placeholders = new Map();
85
+ const placeholderOf = (id, own) => {
86
+ if (own.placeholderWhen?.length)
87
+ placeholders.set(id, { fixed: own.placeholder, when: own.placeholderWhen.map((item) => ({ text: item.text, holds: (0, modifier_1.compileModifier)(item.when) })) });
88
+ };
89
+ for (const node of index.values())
90
+ if (node.kind === 'field' && 'type' in node.source)
91
+ placeholderOf(node.id, node.source);
92
+ if (page.layout.type === 'sheet' && page.layout.title)
93
+ placeholderOf('#title', page.layout.title);
33
94
  /** Whether any rule only warns: without one, there are never warnings to look for. */
34
95
  const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
35
96
  const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
36
97
  const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
37
98
  const today = () => (0, functions_1.localDay)(options.now?.() ?? new Date());
38
- const computed = (0, compute_1.compileComputed)(page, today);
39
- const setWhen = (0, set_when_1.compileSetWhen)(page, today);
40
- let baseline = computed.apply({ ...(0, values_1.initialValues)(page.fields, today()), ...(0, values_1.structuredCopy)(options.values ?? {}) });
99
+ // The record's id and the person, as worked-out values read them: the id is the one asked for until the form has its state.
100
+ let started = false;
101
+ let editing = options.editing !== false;
102
+ /** The record a line's form is of, as its conditions read it: worked out once. */
103
+ const parentContext = options.parent ? (0, env_1.recordContext)(options.parent.values, options.parent.fields, { recordId: options.parent.id ?? null, user: options.user, context: options.context }) : undefined;
104
+ const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing, context: options.context, parent: parentContext });
105
+ const computed = (0, compute_1.compileComputed)(page, today, about);
106
+ const setWhen = (0, set_when_1.compileSetWhen)(page, today, about);
107
+ /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
108
+ const parts = new Map();
109
+ for (const node of index.values())
110
+ if (node.kind === 'form')
111
+ parts.set(node.id, node.source);
112
+ /** Those given their page, with the inner form that holds their answers. */
113
+ const attached = new Map();
114
+ /** The page's fields, and each saved form's answers as a JSON value: what is loaded, told as changed and saved. */
115
+ const fields = { ...page.fields };
116
+ for (const part of parts.values())
117
+ fields[part.name] = { type: 'json', label: part.title || part.name };
118
+ /** True while answers are handed to an inner form: what it says back then is no change of its own. */
119
+ let syncing = false;
120
+ /** A new record's values: each field's default, then those worked out from `defaultFrom`, then what the form is given. */
121
+ /** What the record starts with: each field's default, a new one's worked-out first values, and — as the form opens — what it was given. */
122
+ function startingValues(isNew = options.recordId == null, given = true) {
123
+ const values = (0, values_1.initialValues)(fields, today());
124
+ if (isNew) {
125
+ const about = { recordId: null, user: options.user, context: options.context, parent: parentContext };
126
+ Object.assign(values, (0, value_map_1.firstValues)(page.fields, mapScope(values, page.fields, (0, env_1.recordContext)(values, page.fields, about), (0, env_1.expressionEnv)(values, page.fields, today))));
127
+ }
128
+ return given ? ordered({ ...values, ...(0, values_1.structuredCopy)(options.values ?? {}) }) : values;
129
+ }
130
+ /** The name this form knows a record by, from a link to the same model it holds. */
131
+ function knownLabel(relation, id) {
132
+ const values = (started ? state.values : {});
133
+ for (const [name, def] of Object.entries(page.fields)) {
134
+ if ((def.type !== 'many2one' && def.type !== 'many2many') || def.relation !== relation)
135
+ continue;
136
+ const held = values[name];
137
+ const found = (Array.isArray(held) ? held : held ? [held] : []).find((record) => record?.id === id);
138
+ if (found)
139
+ return found.label;
140
+ }
141
+ return undefined;
142
+ }
143
+ function mapScope(values, scopeFields, context, env, parent) {
144
+ return { values, fields: scopeFields, context, env, ...(parent ? { parent } : {}), ...(options.user ? { user: options.user } : {}), labelOf: knownLabel };
145
+ }
146
+ /** Where a value map on the record is read. */
147
+ function recordScope() {
148
+ const { context, env } = reading();
149
+ return mapScope(state.values, page.fields, context, env);
150
+ }
151
+ /** Where a value map on one line is read: the line, with its record as `parent`. */
152
+ function lineScope(field, values) {
153
+ const def = lineField(field);
154
+ const lineFields = def.fields;
155
+ return mapScope(values, lineFields, (0, env_1.lineContext)(values, lineFields, reading().context), { today }, { values: state.values, fields: page.fields });
156
+ }
157
+ function tableOf(nodeId) {
158
+ const table = tables.get(nodeId);
159
+ if (table)
160
+ return table;
161
+ const node = index.get(nodeId);
162
+ if (node?.kind !== 'field' || page.fields[node.field]?.type !== 'one2many')
163
+ throw new Error(`"${nodeId}" is not a table of lines`);
164
+ return null;
165
+ }
166
+ /** A line of a table as its rules have it now; none, plain. */
167
+ function lineStateOf(nodeId, key) {
168
+ const table = tableOf(nodeId);
169
+ if (!table)
170
+ return { tone: null, bold: false, cells: {}, buttons: {}, deletable: true };
171
+ const line = (state.values[table.field] ?? []).find((l) => l.key === key);
172
+ if (!line)
173
+ throw new Error(`"${table.field}" has no line "${key}"`);
174
+ const def = lineField(table.field);
175
+ return table.compiled.line((0, env_1.lineContext)(line.values, def.fields, reading().context, line.id ?? null), { today }, userRoles);
176
+ }
177
+ function createValuesOf(field, line) {
178
+ if (!line) {
179
+ const def = fieldDef(field);
180
+ return def.type === 'many2one' || def.type === 'many2many' ? (def.createValues ? (0, value_map_1.readLooseMap)(def.createValues, recordScope()) : {}) : {};
181
+ }
182
+ const found = (state.values[line.lines] ?? []).find((l) => l.key === line.key);
183
+ const sub = lineField(line.lines).fields[field];
184
+ if (!found || !sub || (sub.type !== 'many2one' && sub.type !== 'many2many') || !sub.createValues)
185
+ return {};
186
+ return (0, value_map_1.readLooseMap)(sub.createValues, lineScope(line.lines, found.values));
187
+ }
188
+ let baseline = computed.apply(startingValues());
41
189
  /** The setWhen conditions holding for the values as they last settled: one starts to hold only against these. */
42
190
  let holding = setWhen.holding(baseline);
43
191
  let state = {
@@ -55,7 +203,9 @@ function createForm(options) {
55
203
  skipped: [],
56
204
  saveProblem: null,
57
205
  choices: {},
206
+ definitions: {},
58
207
  };
208
+ started = true;
59
209
  /** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
60
210
  let serverErrors = {};
61
211
  let lineKeys = 0;
@@ -64,12 +214,26 @@ function createForm(options) {
64
214
  let autosaveTimer = null;
65
215
  const pending = new Set();
66
216
  const listeners = new Set();
217
+ /** The app's listeners, by event. */
218
+ const events = new Map();
219
+ /** 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. */
220
+ let entering = [];
221
+ /** Whether wizard steps entered are told: from once the form has opened and run its `open` steps. */
222
+ let showing = false;
223
+ /** The wizard step last told as entered. */
224
+ let told = null;
67
225
  let contextCache = null;
226
+ const userRoles = [...(options.user?.roles ?? [])];
68
227
  /** The latest load of each field's choices: an answer to an older one is let go. */
69
228
  const choiceLoads = {};
70
229
  function set(patch) {
71
230
  const was = state.values;
231
+ const shown = state.errors;
72
232
  state = { ...state, ...patch };
233
+ // The problems found inside a saved form are shown on it, beside its own fields.
234
+ if (state.errors !== shown)
235
+ for (const part of attached.values())
236
+ part.inner.show(inside(state.errors, part.name));
73
237
  for (const listener of [...listeners])
74
238
  listener(state);
75
239
  // Choices that change with a value that just changed are asked for again.
@@ -77,6 +241,46 @@ function createForm(options) {
77
241
  for (const name in state.choices)
78
242
  if (fromList(name).dependsOn?.some((other) => was[other] !== state.values[other]))
79
243
  loadChoices(name);
244
+ for (const name in state.definitions)
245
+ if (fromRecord(name).dependsOn?.some((other) => was[other] !== state.values[other]))
246
+ loadDefinitions(name);
247
+ }
248
+ if (showing && state.step !== told)
249
+ tellStep(entering);
250
+ }
251
+ function emit(event, payload) {
252
+ const heard = events.get(event);
253
+ if (heard)
254
+ for (const listener of [...heard])
255
+ listener(payload);
256
+ }
257
+ /** A field written: told to the app, and — a person's change — the page's `change` steps for it run. */
258
+ function changed(field, by) {
259
+ emit('change', { field, value: state.values[field], values: state.values, by });
260
+ const list = page.on?.change?.[field];
261
+ if (by === 'person' && list)
262
+ void track(moment(`on.change.${field}`, list, [], true));
263
+ }
264
+ /** A wizard step entered: told to the app, and its `show` steps run. */
265
+ function tellStep(chain) {
266
+ const step = state.step;
267
+ if (step === told || step === null)
268
+ return;
269
+ told = step;
270
+ emit('step', { step });
271
+ const list = page.on?.show?.[step];
272
+ if (list)
273
+ void track(moment(`on.show.${step}`, list, chain, true));
274
+ }
275
+ /** Do something while `chain` is the run's that does it, so a step or tab it enters knows. */
276
+ function inRun(chain, act) {
277
+ const was = entering;
278
+ entering = chain;
279
+ try {
280
+ act();
281
+ }
282
+ finally {
283
+ entering = was;
80
284
  }
81
285
  }
82
286
  const fromList = (name) => page.fields[name].optionsFrom;
@@ -90,6 +294,24 @@ function createForm(options) {
90
294
  const source = options.dataSource;
91
295
  void track((source?.options ? source.options({ list, values: (0, values_1.structuredCopy)(state.values) }) : Promise.reject(new Error(`No list "${list}"`))).then((loaded) => put({ options: loaded }), (error) => put({ options: shown, error: error.message ?? String(error) })));
92
296
  }
297
+ const fromRecord = (name) => page.fields[name].definitionsFrom;
298
+ const definitionLoads = {};
299
+ function loadDefinitions(name) {
300
+ const { list } = fromRecord(name);
301
+ const load = (definitionLoads[name] = (definitionLoads[name] ?? 0) + 1);
302
+ const put = (definitions) => load === definitionLoads[name] && set({ definitions: { ...state.definitions, [name]: definitions } });
303
+ // What was shown stays while the next ones load.
304
+ const shown = state.definitions[name]?.definitions ?? null;
305
+ put({ definitions: shown, loading: true });
306
+ const source = options.dataSource;
307
+ void track((source?.definitions ? source.definitions({ list, values: (0, values_1.structuredCopy)(state.values) }) : Promise.reject(new Error(`No definitions "${list}"`))).then((loaded) => put({ definitions: loaded }), (error) => put({ definitions: shown, error: error.message ?? String(error) })));
308
+ }
309
+ async function addDefinition(name, definition) {
310
+ const { list } = fromRecord(name);
311
+ const definitions = [...(state.definitions[name]?.definitions ?? []), definition];
312
+ set({ definitions: { ...state.definitions, [name]: { definitions } } });
313
+ await track(options.dataSource?.saveDefinitions?.({ list, values: (0, values_1.structuredCopy)(state.values), definitions }) ?? Promise.resolve());
314
+ }
93
315
  /** A field as its value is checked: a list's choices are those loaded, and until they are, any choice may be one. */
94
316
  function checked(name) {
95
317
  const def = page.fields[name];
@@ -102,32 +324,34 @@ function createForm(options) {
102
324
  promise.then(done, done);
103
325
  return promise;
104
326
  }
105
- /** The values as expressions read them, and what else they read: the lines' rows, today. */
327
+ /** The values as expressions read them, and what else they read: the record's id, the person, the lines' rows, today. */
106
328
  function reading() {
107
- if (contextCache?.values !== state.values) {
329
+ if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editing) {
108
330
  const values = state.values;
109
- contextCache = { values, context: (0, values_1.expressionContext)(values, page.fields), env: (0, env_1.expressionEnv)(values, page.fields, today) };
331
+ const context = (0, env_1.recordContext)(values, page.fields, about());
332
+ contextCache = { values, recordId: state.recordId, editing, context, env: (0, env_1.expressionEnv)(values, page.fields, today) };
110
333
  }
111
334
  return contextCache;
112
335
  }
113
336
  const context = () => reading().context;
114
337
  function fieldDef(name) {
115
- const def = page.fields[name];
338
+ const def = fields[name];
116
339
  if (!def)
117
340
  throw new Error(`The page has no field "${name}"`);
118
341
  return def;
119
342
  }
120
343
  function dirtyFields(values) {
121
- return Object.keys(page.fields).filter((name) => JSON.stringify(values[name] ?? null) !== JSON.stringify(baseline[name] ?? null));
344
+ return Object.keys(fields).filter((name) => JSON.stringify(values[name] ?? null) !== JSON.stringify(baseline[name] ?? null));
122
345
  }
123
346
  function nodeState(id) {
124
347
  const node = index.get(id);
125
348
  if (!node)
126
349
  throw new Error(`The page has no element "${id}"`);
127
350
  const { context: ctx, env } = reading();
128
- let invisible = node.invisible.evaluate(ctx, env);
351
+ const hidden = (at) => (at ? !(0, roles_1.rolesAllow)(at.roles, userRoles) || at.invisible.evaluate(ctx, env) : false);
352
+ let invisible = hidden(node);
129
353
  for (let parent = node.parent; !invisible && parent; parent = index.get(parent)?.parent ?? null) {
130
- invisible = index.get(parent)?.invisible.evaluate(ctx, env) ?? false;
354
+ invisible = hidden(index.get(parent));
131
355
  }
132
356
  // A section read-only right now makes everything inside it read-only too.
133
357
  let readonly = node.readonly.evaluate(ctx, env);
@@ -141,8 +365,8 @@ function createForm(options) {
141
365
  required: node.required.evaluate(ctx, env) || def?.required === true,
142
366
  };
143
367
  }
144
- /** Field nodes inside `root` (or the whole page), visible right now. */
145
- function visibleFieldNodes(root) {
368
+ /** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
369
+ function visibleFieldNodes(root, kind = 'field') {
146
370
  const inside = (node) => {
147
371
  if (!root)
148
372
  return true;
@@ -160,7 +384,7 @@ function createForm(options) {
160
384
  return true;
161
385
  return false;
162
386
  };
163
- return [...index.values()].filter((node) => node.kind === 'field' && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
387
+ return [...index.values()].filter((node) => node.kind === kind && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
164
388
  }
165
389
  /** What a field node's answer rules say of its value now: the first error and the first warning. */
166
390
  function ruleCheck(node) {
@@ -222,18 +446,51 @@ function createForm(options) {
222
446
  for (const line of state.values[name] ?? []) {
223
447
  // A section or note answers only for its text, never for what an item needs.
224
448
  const kind = (0, values_1.lineKind)(def, line.values);
449
+ // The table's own rules: a cell its line hides asks nothing, one it requires asks for a value.
450
+ const ruled = tables.has(node.id) ? lineStateOf(node.id, line.key).cells : {};
225
451
  for (const [sub, subDef] of Object.entries(def.fields)) {
226
452
  if (kind && sub !== def.lineKinds?.text)
227
453
  continue;
228
- const lineMessage = (0, check_1.checkValue)(subDef, line.values[sub], subDef.required === true, messages, today());
454
+ const cell = ruled[sub];
455
+ if (cell?.invisible || (tables.has(node.id) && tables.get(node.id).compiled.hidden(sub, reading().context, reading().env, userRoles)))
456
+ continue;
457
+ const lineMessage = (0, check_1.checkValue)(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today());
229
458
  if (lineMessage)
230
459
  errors[`${name}.${line.key}.${sub}`] = lineMessage;
231
460
  }
232
461
  }
233
462
  }
234
463
  }
464
+ // A saved form placed here answers for its fields by its own page's rules.
465
+ for (const node of visibleFieldNodes(root, 'form')) {
466
+ const part = attached.get(node.id);
467
+ if (part)
468
+ for (const [key, message] of Object.entries(part.inner.errorsNow()))
469
+ errors[`${part.name}.${key}`] = message;
470
+ }
235
471
  return errors;
236
472
  }
473
+ /**
474
+ * Answers changed from outside a saved form — loaded, put back, set — handed
475
+ * to it, and what it holds after taken back: its defaults and worked-out
476
+ * values filled in. A new starting point (`start`) is handed to each.
477
+ */
478
+ function syncParts(values, how) {
479
+ let out = values;
480
+ for (const part of attached.values()) {
481
+ if (how !== 'start' && out[part.name] === part.inner.form.getState().values)
482
+ continue;
483
+ syncing = true;
484
+ try {
485
+ part.inner.take(isRecord(out[part.name]) ? out[part.name] : {}, how);
486
+ }
487
+ finally {
488
+ syncing = false;
489
+ }
490
+ out = { ...out, [part.name]: asValue(part.inner.form.getState().values) };
491
+ }
492
+ return out;
493
+ }
237
494
  /**
238
495
  * Values as they settle after a change: worked out, then set where a
239
496
  * condition started to hold, then worked out again from what was set — a
@@ -258,7 +515,7 @@ function createForm(options) {
258
515
  }
259
516
  /** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
260
517
  function writeValues(written, extra = {}, fresh = false) {
261
- const values = fresh ? startFrom(written) : settle(written);
518
+ const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
262
519
  const errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
263
520
  set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
264
521
  }
@@ -350,10 +607,10 @@ function createForm(options) {
350
607
  // included; a stored one sends only what changed since it was loaded.
351
608
  const creating = state.recordId === null || state.recordId === undefined;
352
609
  const names = creating
353
- ? Object.keys(page.fields).filter((name) => state.dirty.includes(name) || !(0, values_1.isEmpty)(page.fields[name], state.values[name]))
610
+ ? Object.keys(fields).filter((name) => state.dirty.includes(name) || !(0, values_1.isEmpty)(fields[name], state.values[name]))
354
611
  : state.dirty;
355
612
  for (const name of names) {
356
- const def = page.fields[name];
613
+ const def = fields[name];
357
614
  const now = state.values[name];
358
615
  const before = creating ? (0, values_1.emptyValue)(def) : baseline[name];
359
616
  if (def.type === 'one2many')
@@ -372,13 +629,38 @@ function createForm(options) {
372
629
  const name = node.field;
373
630
  shown[name] = (0, values_1.structuredCopy)(state.values[name]);
374
631
  }
632
+ // And each saved form shown, by its page: one not found shows nothing to answer.
633
+ for (const node of visibleFieldNodes(undefined, 'form')) {
634
+ const part = attached.get(node.id);
635
+ if (part)
636
+ shown[part.name] = (0, values_1.structuredCopy)(state.values[part.name]);
637
+ }
375
638
  return shown;
376
639
  }
377
- async function save() {
640
+ async function save(chain = []) {
641
+ return (await saving(chain)) === null;
642
+ }
643
+ /**
644
+ * Save, or send: null once done, or why not. The page's `beforeSave` steps
645
+ * run first — before its own check, so they may fill in what it asks for —
646
+ * and a stop keeps it unsaved; `afterSave` runs once it is saved.
647
+ */
648
+ async function saving(chain) {
649
+ if (page.on?.beforeSave) {
650
+ const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
651
+ if (!before.done) {
652
+ // A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
653
+ if (before.reason !== 'check' && before.reason !== 'save') {
654
+ const problem = { kind: 'stopped', reason: before.reason, message: before.message ?? '' };
655
+ set({ status: 'error', error: problem.message || null, saveProblem: problem });
656
+ }
657
+ return { reason: 'save', ...(before.message !== undefined ? { message: before.message } : {}) };
658
+ }
659
+ }
378
660
  const errors = collectErrors();
379
661
  if (Object.keys(errors).length) {
380
662
  set({ errors });
381
- return false;
663
+ return { reason: 'save', message: Object.values(errors)[0] };
382
664
  }
383
665
  const source = options.dataSource;
384
666
  serverErrors = {};
@@ -387,33 +669,108 @@ function createForm(options) {
387
669
  if (page.data.kind === 'responses') {
388
670
  if (!source?.submit)
389
671
  throw new Error('This page has no data source to send its answers to');
390
- await source.submit({ pageId: page.id, values: shownValues() });
672
+ const sent = shownValues();
673
+ await source.submit({ pageId: page.id, values: sent });
391
674
  forgetDraft();
392
675
  set({ status: 'saved' });
393
- return true;
676
+ emit('send', { values: sent });
677
+ void track(moment('on.afterSave', page.on?.afterSave, chain, false));
678
+ return null;
394
679
  }
395
680
  if (!source?.save)
396
681
  throw new Error('This page has no data source to save to');
397
682
  const result = await source.save({
398
683
  model: page.data.model,
399
684
  id: state.recordId,
400
- fields: page.fields,
685
+ fields,
401
686
  changes: changes(),
402
687
  values: (0, values_1.structuredCopy)(state.values),
403
688
  });
404
689
  forgetDraft();
405
- const values = startFrom(result.values ? { ...state.values, ...result.values } : state.values);
690
+ // Lines the save brings back load again, in their tables' orders, as Flectra's reload after a save.
691
+ const values = syncParts(startFrom(result.values ? ordered({ ...state.values, ...result.values }, Object.keys(result.values)) : state.values), 'start');
406
692
  baseline = (0, values_1.structuredCopy)(values);
407
693
  set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
408
- return true;
694
+ emit('save', { recordId: state.recordId, values: state.values });
695
+ void track(moment('on.afterSave', page.on?.afterSave, chain, false));
696
+ return null;
409
697
  }
410
698
  catch (error) {
411
699
  const problem = (0, data_source_1.saveProblemOf)(error);
412
700
  const fields = problem.kind === 'fields' ? problem.fields ?? {} : {};
413
701
  serverErrors = Object.fromEntries(Object.entries(fields).map(([name, message]) => [name, { message, value: JSON.stringify(state.values[name] ?? null) }]));
414
702
  set({ status: 'error', error: problem.message, saveProblem: problem, errors: { ...fields } });
415
- return false;
703
+ return { reason: 'save', message: problem.message };
704
+ }
705
+ }
706
+ /** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
707
+ async function opening() {
708
+ emit('open', { recordId: state.recordId, values: state.values });
709
+ await moment('on.open', page.on?.open, [], true);
710
+ showing = true;
711
+ tellStep([]);
712
+ }
713
+ /** Write fields — a step's or the app's — worked out once, and tell each. */
714
+ function write(written, by) {
715
+ const names = Object.keys(written);
716
+ names.forEach(fieldDef);
717
+ if (!names.length)
718
+ return;
719
+ writeValues({ ...state.values, ...(0, values_1.structuredCopy)(written) });
720
+ for (const name of names)
721
+ afterEdit(name);
722
+ for (const name of names)
723
+ changed(name, by);
724
+ }
725
+ function addLine(field, values, by) {
726
+ const def = lineField(field);
727
+ const current = state.values[field] ?? [];
728
+ // A key no line holds: lines given (a line's own lines, back from its form) may hold new-… keys already.
729
+ let key = `new-${++lineKeys}`;
730
+ while (current.some((line) => line.key === key))
731
+ key = `new-${++lineKeys}`;
732
+ // Each line field's default, then what is worked out from its defaultFrom, then the record's lineDefaults, then what the line is given.
733
+ const lineFields = def.fields;
734
+ const start = (0, values_1.initialValues)(lineFields, today());
735
+ Object.assign(start, (0, value_map_1.firstValues)(lineFields, lineScope(field, start)));
736
+ if (def.lineDefaults)
737
+ Object.assign(start, (0, value_map_1.readValueMap)(def.lineDefaults, lineFields, recordScope()));
738
+ const line = { key, values: { ...start, ...(0, values_1.structuredCopy)(values) } };
739
+ // A new line comes last, so it is numbered after the last.
740
+ if (def.sequenceField && line.values[def.sequenceField] == null) {
741
+ const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
742
+ line.values[def.sequenceField] = numbers.length ? Math.max(...numbers) + 1 : 1;
416
743
  }
744
+ const lines = [...current, line];
745
+ writeValues({ ...state.values, [field]: lines });
746
+ afterEdit(field);
747
+ changed(field, by);
748
+ return key;
749
+ }
750
+ /** Check the form, or only these fields, showing what is found as sending does. */
751
+ function check(names) {
752
+ names?.forEach(fieldDef);
753
+ const all = collectErrors();
754
+ const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
755
+ set({ errors: found });
756
+ const first = Object.values(found)[0];
757
+ return first === undefined ? null : { reason: 'check', message: first };
758
+ }
759
+ /** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
760
+ function goToStep(target, chain) {
761
+ if (!steps.includes(target))
762
+ return undefined;
763
+ if (!form.steps().includes(target))
764
+ return { reason: 'cannot', message: `The step "${target}" does not apply now` };
765
+ let went = false;
766
+ inRun(chain, () => (went = form.goTo(target)));
767
+ return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
768
+ }
769
+ /** Run steps from the top — a button's, a moment's, the app's — and tell how it ended. */
770
+ async function runTop(list, scope) {
771
+ const result = await runner.runSteps(list, scope);
772
+ emit('run', { id: scope.id, steps: list, result });
773
+ return result;
417
774
  }
418
775
  /** The model a link field's new record belongs to, or null when it cannot make one. */
419
776
  function creatable(def) {
@@ -421,30 +778,26 @@ function createForm(options) {
421
778
  return null;
422
779
  return def.type === 'many2one' || def.type === 'many2many' ? def.relation : null;
423
780
  }
424
- async function runCreate(def, name, typed) {
781
+ async function runCreate(def, name, typed, values = {}) {
425
782
  const model = creatable(def);
426
783
  const source = options.dataSource;
427
784
  if (!model || !source?.create)
428
785
  throw new Error(`"${name}" cannot make records: its data source has no create, or it is not a link`);
429
- return track(source.create({ model, name: typed.trim() }));
786
+ return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
430
787
  }
431
- async function runSearch(def, name, ctx, query, limit, model) {
432
- if (def.type !== 'many2one' && def.type !== 'many2many' && def.type !== 'reference') {
433
- throw new Error(`"${name}" is not a many2one, many2many or reference`);
788
+ async function runSearch(def, name, ctx, query, limit, model, ids) {
789
+ if (def.type !== 'many2one' && def.type !== 'many2many' && def.type !== 'reference' && def.type !== 'json') {
790
+ throw new Error(`"${name}" is not a many2one, many2many, reference or json`);
434
791
  }
435
- if (def.type === 'reference' && !model)
792
+ if ((def.type === 'reference' || def.type === 'json') && !model)
436
793
  throw new Error(`Searching "${name}" needs a model`);
437
794
  const source = options.dataSource;
438
795
  if (!source?.search)
439
796
  return [];
440
797
  // Every valueFrom, in groups too, becomes the value it names before the search goes out.
441
- const resolve = (item) => 'any' in item
442
- ? { any: item.any.map(resolve) }
443
- : 'all' in item
444
- ? { all: item.all.map(resolve) }
445
- : { field: item.field, op: item.op, value: (item.valueFrom !== undefined ? ctx[item.valueFrom] ?? null : item.value ?? null) };
446
- const filter = def.type === 'reference' ? undefined : def.filter?.map(resolve);
447
- const relation = def.type === 'reference' ? model : def.relation;
798
+ const own = def.type === 'reference' || def.type === 'json' || !def.filter ? undefined : (0, filter_1.resolveFilter)(def.filter, ctx);
799
+ const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
800
+ const relation = def.type === 'reference' || def.type === 'json' ? model : def.relation;
448
801
  return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
449
802
  }
450
803
  function lineField(field) {
@@ -460,7 +813,8 @@ function createForm(options) {
460
813
  function optionalStep(id) {
461
814
  return index.get(id)?.source?.optional === true;
462
815
  }
463
- async function load() {
816
+ /** Load the record; `again`, as a step's reload: no draft offered, no `open` steps run a second time. */
817
+ async function load(again = false) {
464
818
  const source = options.dataSource;
465
819
  if (state.recordId == null || page.data.kind !== 'record')
466
820
  return;
@@ -468,16 +822,131 @@ function createForm(options) {
468
822
  set({ status: 'error', error: 'This page has no data source to load from' });
469
823
  return;
470
824
  }
825
+ // Only the latest load is taken: a pager pressed twice shows the record it ended on.
826
+ const seq = ++loads;
471
827
  set({ status: 'loading', error: null });
472
828
  try {
473
- const loaded = await source.load({ model: page.data.model, id: state.recordId, fields: page.fields });
474
- baseline = startFrom({ ...(0, values_1.initialValues)(page.fields, today()), ...loaded });
829
+ const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
830
+ if (seq !== loads)
831
+ return;
832
+ // Lines load in their tables' orders; where a person moves them after, they stay.
833
+ baseline = syncParts(startFrom(ordered({ ...(0, values_1.initialValues)(fields, today()), ...loaded })), 'start');
475
834
  set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
835
+ if (again)
836
+ return;
476
837
  offerDraft();
838
+ void track(opening());
477
839
  }
478
840
  catch (error) {
479
- set({ status: 'error', error: error.message });
841
+ if (seq === loads)
842
+ set({ status: 'error', error: error.message });
843
+ }
844
+ }
845
+ let loads = 0;
846
+ /** Another record in this form's place, or a new one: what was there let go, the new one loaded and opened. */
847
+ async function openRecord(id) {
848
+ if (draftTimer !== null)
849
+ scheduler.clearTimeout(draftTimer);
850
+ if (autosaveTimer !== null)
851
+ scheduler.clearTimeout(autosaveTimer);
852
+ draftTimer = autosaveTimer = null;
853
+ serverErrors = {};
854
+ told = null;
855
+ showing = false;
856
+ const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null };
857
+ if (id != null) {
858
+ set({ ...fresh, recordId: id, status: 'idle' });
859
+ return load();
480
860
  }
861
+ loads++;
862
+ baseline = startFrom(startingValues(true, false));
863
+ const values = syncParts((0, values_1.structuredCopy)(baseline), 'start');
864
+ set({ ...fresh, recordId: null, status: 'ready', values, warnings: withValues(values, collectWarnings) });
865
+ offerDraft();
866
+ await opening();
867
+ }
868
+ /** The record a record step asks the data source about. */
869
+ const record = () => ({ model: page.data.model, id: state.recordId, fields });
870
+ async function post(message) {
871
+ if (options.host?.post) {
872
+ try {
873
+ return (await options.host.post(message)) !== false;
874
+ }
875
+ catch {
876
+ return false;
877
+ }
878
+ }
879
+ if (!events.get('post')?.size)
880
+ return false;
881
+ emit('post', { recordId: state.recordId, message });
882
+ return true;
883
+ }
884
+ const runner = (0, run_1.createRunner)({
885
+ fields,
886
+ values: () => state.values,
887
+ recordId: () => state.recordId,
888
+ reading,
889
+ today,
890
+ write: (values) => write(values, 'step'),
891
+ addLine: (field, values) => void addLine(field, values, 'step'),
892
+ check,
893
+ save: saving,
894
+ async reload() {
895
+ if (state.recordId == null)
896
+ return { reason: 'cannot', message: 'The record is not saved yet: there is nothing to load again' };
897
+ await load(true);
898
+ return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
899
+ },
900
+ reset: () => form.reset(),
901
+ goTo: goToStep,
902
+ showing: inRun,
903
+ host: options.host,
904
+ confirm: options.confirm,
905
+ onAction: options.onAction,
906
+ answered: (request, result) => emit('action', { request, result }),
907
+ changed: () => state.dirty.length > 0,
908
+ ...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
909
+ ...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
910
+ ...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
911
+ async showRecord(id) {
912
+ await openRecord(id);
913
+ return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
914
+ },
915
+ told(event, id) {
916
+ if ('archived' in event)
917
+ emit('archive', { recordId: id, archived: event.archived });
918
+ else if ('copied' in event)
919
+ emit('duplicate', { from: id, recordId: event.copied });
920
+ else {
921
+ emit('delete', { recordId: id });
922
+ // Nobody moved the form on: it starts a new record in the deleted one's place.
923
+ if (state.recordId === id)
924
+ void track(openRecord(null));
925
+ }
926
+ },
927
+ post,
928
+ wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
929
+ });
930
+ const moment = (0, run_1.createMoments)((key, list, chain) => runTop(list, { id: key, chain }));
931
+ /** A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. */
932
+ function press(source, scope) {
933
+ const list = [...(source.steps ?? [])];
934
+ if (source.action !== undefined)
935
+ list.push({ do: 'call', action: source.action, ...('params' in source && source.params ? { params: source.params } : {}) });
936
+ // A built-in item of the gear menu with nothing of its own: its step, asked about first when it archives or deletes.
937
+ const builtin = 'builtin' in source && !list.length ? source.builtin : undefined;
938
+ if (builtin)
939
+ list.push({ do: builtin });
940
+ const asked = builtin === 'archive' ? messages.archiveConfirm : builtin === 'delete' ? messages.deleteConfirm : undefined;
941
+ const confirm = 'confirm' in source && source.confirm !== undefined ? source.confirm : asked;
942
+ return track((async () => {
943
+ if (confirm && !(await runner.ask(confirm))) {
944
+ const result = { done: false, reason: 'no', message: confirm };
945
+ emit('run', { id: scope.id, steps: list, result });
946
+ return result;
947
+ }
948
+ return runTop(list, scope);
949
+ })());
481
950
  }
482
951
  const form = {
483
952
  page,
@@ -487,14 +956,27 @@ function createForm(options) {
487
956
  return () => listeners.delete(listener);
488
957
  },
489
958
  load: () => track(load()),
490
- setValue(name, value) {
959
+ setValue(name, value, how) {
491
960
  fieldDef(name);
492
961
  writeValues({ ...state.values, [name]: (0, values_1.structuredCopy)(value) });
493
962
  afterEdit(name);
963
+ changed(name, how?.by ?? 'person');
494
964
  },
965
+ setValues: (values) => write(values, 'app'),
495
966
  node: nodeState,
967
+ openRecord: (id) => track(openRecord(id)),
968
+ post,
969
+ setEditing(next) {
970
+ if (next === editing)
971
+ return;
972
+ editing = next;
973
+ // Nothing of the record changed; what shows of it may have.
974
+ set({});
975
+ },
496
976
  fieldReadonly: (name) => lockedField(fieldDef(name)),
497
977
  loadChoices,
978
+ loadDefinitions,
979
+ addDefinition,
498
980
  problem(name) {
499
981
  fieldDef(name);
500
982
  // Only a field someone can see is asked anything, and required wherever it is shown so.
@@ -516,24 +998,11 @@ function createForm(options) {
516
998
  forgetDraft();
517
999
  serverErrors = {};
518
1000
  holding = setWhen.holding(baseline);
519
- set({ values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
1001
+ const values = syncParts((0, values_1.structuredCopy)(baseline), 'start');
1002
+ set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
520
1003
  },
521
1004
  changes,
522
- addLine(field, values = {}) {
523
- const def = lineField(field);
524
- const key = `new-${++lineKeys}`;
525
- const current = state.values[field] ?? [];
526
- const line = { key, values: { ...(0, values_1.initialValues)(def.fields, today()), ...(0, values_1.structuredCopy)(values) } };
527
- // A new line comes last, so it is numbered after the last.
528
- if (def.sequenceField && line.values[def.sequenceField] == null) {
529
- const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
530
- line.values[def.sequenceField] = numbers.length ? Math.max(...numbers) + 1 : 1;
531
- }
532
- const lines = [...current, line];
533
- writeValues({ ...state.values, [field]: lines });
534
- afterEdit(field);
535
- return key;
536
- },
1005
+ addLine: (field, values = {}) => addLine(field, values, 'person'),
537
1006
  updateLine(field, key, name, value) {
538
1007
  const def = lineField(field);
539
1008
  if (!def.fields[name])
@@ -541,6 +1010,7 @@ function createForm(options) {
541
1010
  const lines = (state.values[field] ?? []).map((line) => line.key === key ? { ...line, values: { ...line.values, [name]: (0, values_1.structuredCopy)(value) } } : line);
542
1011
  writeValues({ ...state.values, [field]: lines });
543
1012
  afterEdit(field);
1013
+ changed(field, 'person');
544
1014
  },
545
1015
  moveLine(field, key, to) {
546
1016
  const def = lineField(field);
@@ -570,15 +1040,25 @@ function createForm(options) {
570
1040
  });
571
1041
  writeValues({ ...state.values, [field]: renumbered });
572
1042
  afterEdit(field);
1043
+ changed(field, 'person');
573
1044
  },
574
1045
  removeLine(field, key) {
575
1046
  lineField(field);
1047
+ // A table that keeps this line (its lineDelete does not hold on it) refuses, wherever the call comes from.
1048
+ for (const [nodeId, table] of tables) {
1049
+ if (table.field !== field || (index.get(nodeId)?.source).lineDelete === undefined)
1050
+ continue;
1051
+ if ((state.values[field] ?? []).some((line) => line.key === key) && !lineStateOf(nodeId, key).deletable) {
1052
+ throw new Error(`The line "${key}" of "${field}" cannot be deleted now: its table's lineDelete does not hold on it`);
1053
+ }
1054
+ }
576
1055
  const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
577
1056
  writeValues({ ...state.values, [field]: lines });
578
1057
  afterEdit(field);
1058
+ changed(field, 'person');
579
1059
  },
580
1060
  async search(field, query, limit = 8, options = {}) {
581
- return runSearch(fieldDef(field), field, context(), query, limit, options.model);
1061
+ return runSearch(fieldDef(field), field, context(), query, limit, options.model, options.ids);
582
1062
  },
583
1063
  async previewLine(field, key, values) {
584
1064
  lineField(field);
@@ -586,16 +1066,16 @@ function createForm(options) {
586
1066
  const line = lines.find((l) => l.key === key);
587
1067
  if (!line)
588
1068
  throw new Error(`"${field}" has no line "${key}"`);
589
- const merged = computed.line(field, { ...line.values, ...values });
1069
+ const merged = computed.line(field, { ...line.values, ...values }, state.values);
590
1070
  const source = options.dataSource;
591
1071
  if (!source?.onchange || page.data.kind !== 'record')
592
1072
  return merged;
593
1073
  const changed = lines.map((l) => (l.key === key ? { ...l, values: merged } : l));
594
1074
  const result = await source.onchange({ model: page.data.model, id: state.recordId, changed: field, values: (0, values_1.structuredCopy)({ ...state.values, [field]: changed }) });
595
1075
  const after = (result.values?.[field] ?? []).find((l) => l.key === key);
596
- return after ? computed.line(field, { ...merged, ...after.values }) : merged;
1076
+ return after ? computed.line(field, { ...merged, ...after.values }, state.values) : merged;
597
1077
  },
598
- async searchLine(field, key, subfield, query, limit = 8) {
1078
+ async searchLine(field, key, subfield, query, limit = 8, options = {}) {
599
1079
  const def = lineField(field);
600
1080
  const sub = def.fields[subfield];
601
1081
  if (!sub)
@@ -603,12 +1083,13 @@ function createForm(options) {
603
1083
  const line = (state.values[field] ?? []).find((l) => l.key === key);
604
1084
  if (!line)
605
1085
  throw new Error(`"${field}" has no line "${key}"`);
606
- return runSearch(sub, subfield, (0, values_1.expressionContext)(line.values, def.fields), query, limit);
1086
+ return runSearch(sub, subfield, (0, env_1.lineContext)(line.values, def.fields, context(), line.id ?? null), query, limit, options.model, options.ids);
607
1087
  },
608
1088
  canCreate: (field) => creatable(page.fields[field]) !== null,
609
- quickCreate: (field, typed) => runCreate(page.fields[field], field, typed),
1089
+ quickCreate: (field, typed) => runCreate(page.fields[field], field, typed, page.fields[field] ? createValuesOf(field) : {}),
610
1090
  canCreateLine: (field, subfield) => creatable(lineField(field).fields[subfield]) !== null,
611
- quickCreateLine: (field, subfield, typed) => runCreate(lineField(field).fields[subfield], subfield, typed),
1091
+ quickCreateLine: (field, subfield, typed, key) => runCreate(lineField(field).fields[subfield], subfield, typed, key ? createValuesOf(subfield, { lines: field, key }) : {}),
1092
+ createValues: (field, line) => createValuesOf(field, line),
612
1093
  steps: () => steps.filter((id) => !nodeState(id).invisible),
613
1094
  next() {
614
1095
  if (!state.step)
@@ -681,16 +1162,100 @@ function createForm(options) {
681
1162
  if (!node || (node.kind !== 'button' && node.kind !== 'stat'))
682
1163
  throw new Error(`The page has no button "${id}"`);
683
1164
  if (nodeState(id).invisible)
1165
+ return { done: false, reason: 'cannot', message: `The button "${id}" is hidden` };
1166
+ // A button for a table's chosen lines runs with them: runLinesAction.
1167
+ if (node.parent && index.get(node.parent)?.source?.selectedButtons?.some((b) => b.id === id)) {
1168
+ return { done: false, reason: 'cannot', message: `The button "${id}" runs with the lines chosen in its table` };
1169
+ }
1170
+ return press(node.source, { id, chain: [], ...(chosen ? { recordIds: [...chosen.recordIds] } : {}) });
1171
+ },
1172
+ async linkedValues(field, ids, columns) {
1173
+ const def = fieldDef(field);
1174
+ if (def.type !== 'many2many')
1175
+ throw new Error(`"${field}" is not a many2many`);
1176
+ const source = options.dataSource;
1177
+ if (!source?.list || !ids.length)
1178
+ return [];
1179
+ const found = await source.list({ model: def.relation, fields: [...columns], filter: [{ field: 'id', op: 'in', value: [...ids] }], sort: [], offset: 0, limit: ids.length });
1180
+ const byId = new Map(found.records.map((record) => [String(record.id), record]));
1181
+ return ids.flatMap((id) => {
1182
+ const record = byId.get(String(id));
1183
+ return record ? [{ id: record.id, values: record.values }] : [];
1184
+ });
1185
+ },
1186
+ fieldTone(nodeId) {
1187
+ const compiled = fieldTones.get(nodeId);
1188
+ if (compiled) {
1189
+ const { context: ctx, env } = reading();
1190
+ return compiled(ctx, env);
1191
+ }
1192
+ if (index.get(nodeId)?.kind !== 'field')
1193
+ throw new Error(`"${nodeId}" is not a field on this page`);
1194
+ return { tone: null, bold: false };
1195
+ },
1196
+ placeholder(nodeId) {
1197
+ const own = placeholders.get(nodeId);
1198
+ if (!own) {
1199
+ const node = index.get(nodeId);
1200
+ if (node?.kind !== 'field')
1201
+ throw new Error(`"${nodeId}" is not a field on this page`);
1202
+ const source = node.source;
1203
+ return nodeId === '#title' && page.layout.type === 'sheet' ? page.layout.title?.placeholder : source.placeholder;
1204
+ }
1205
+ const { context: ctx, env } = reading();
1206
+ return own.when.find((item) => item.holds.evaluate(ctx, env))?.text ?? own.fixed;
1207
+ },
1208
+ lineState: (nodeId, key) => lineStateOf(nodeId, key),
1209
+ columnHidden(nodeId, column) {
1210
+ const table = tableOf(nodeId);
1211
+ const { context, env } = reading();
1212
+ return table ? table.compiled.hidden(column, context, env, userRoles) : false;
1213
+ },
1214
+ async runRowAction(nodeId, buttonId, key) {
1215
+ const table = tableOf(nodeId);
1216
+ const button = table?.compiled.buttons.find((b) => b.id === buttonId);
1217
+ if (!table || !button)
1218
+ throw new Error(`The table "${nodeId}" has no button "${buttonId}"`);
1219
+ if (!lineStateOf(nodeId, key).buttons[buttonId])
1220
+ return { done: false, reason: 'cannot', message: `The button "${buttonId}" is hidden on this line` };
1221
+ const line = (state.values[table.field] ?? []).find((l) => l.key === key);
1222
+ return press(button, { id: buttonId, chain: [], line: { field: table.field, key, values: (0, values_1.structuredCopy)(line.values) } });
1223
+ },
1224
+ async runLinesAction(nodeId, buttonId, keys) {
1225
+ const node = index.get(nodeId);
1226
+ const source = node?.source;
1227
+ const button = source?.selectedButtons?.find((b) => b.id === buttonId);
1228
+ if (!node || !source || !button)
1229
+ throw new Error(`The table "${nodeId}" has no button "${buttonId}" for its chosen lines`);
1230
+ const all = state.values[source.field] ?? [];
1231
+ const chosen = keys.map((key) => all.find((line) => line.key === key) ?? null);
1232
+ const missing = keys.find((_, i) => !chosen[i]);
1233
+ if (missing !== undefined)
1234
+ throw new Error(`"${source.field}" has no line "${missing}"`);
1235
+ if (nodeState(buttonId).invisible)
1236
+ return { done: false, reason: 'cannot', message: `The button "${buttonId}" is hidden` };
1237
+ if (!keys.length)
1238
+ return { done: false, reason: 'cannot', message: `The button "${buttonId}" runs with lines chosen: none is` };
1239
+ const lines = chosen;
1240
+ return press(button, {
1241
+ id: buttonId,
1242
+ chain: [],
1243
+ lines: { field: source.field, keys: [...keys], ids: lines.flatMap((line) => (line.id === undefined ? [] : [line.id])), values: lines.map((line) => (0, values_1.structuredCopy)(line.values)) },
1244
+ });
1245
+ },
1246
+ run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
1247
+ shown(tab) {
1248
+ // A wizard's steps are told by the form itself, as they are entered.
1249
+ if (steps.includes(tab))
684
1250
  return;
685
- const source = node.source;
686
- await track(Promise.resolve(options.onAction?.({
687
- id,
688
- action: source.action,
689
- ...('params' in source && source.params ? { params: source.params } : {}),
690
- recordId: state.recordId,
691
- values: (0, values_1.structuredCopy)(state.values),
692
- ...(chosen ? { recordIds: [...chosen.recordIds] } : {}),
693
- })));
1251
+ void track(moment(`on.show.${tab}`, page.on?.show?.[tab], entering, true));
1252
+ },
1253
+ on(event, listener) {
1254
+ let heard = events.get(event);
1255
+ if (!heard)
1256
+ events.set(event, (heard = new Set()));
1257
+ heard.add(listener);
1258
+ return () => void heard.delete(listener);
694
1259
  },
695
1260
  restoreDraft() {
696
1261
  const raw = options.drafts?.store.getItem(draftKey());
@@ -707,19 +1272,115 @@ function createForm(options) {
707
1272
  while (pending.size)
708
1273
  await Promise.allSettled([...pending]);
709
1274
  },
1275
+ embed(id, inner) {
1276
+ const known = attached.get(id);
1277
+ if (known)
1278
+ return known.inner.form;
1279
+ const part = parts.get(id);
1280
+ if (!part)
1281
+ throw new Error(`"${id}" is not a saved form placed on this page`);
1282
+ const chain = [...within, page.id];
1283
+ if (chain.includes(inner.id))
1284
+ throw new Error(`a page cannot be placed inside itself: ${[...chain.slice(chain.indexOf(inner.id)), inner.id].join(' → ')}`);
1285
+ const given = state.values[part.name];
1286
+ // A record of no model of its own: its links search and its lists load, but it never loads, saves, sends or recalculates alone.
1287
+ const made = innerForm({
1288
+ page: { ...inner, data: { kind: 'responses' } },
1289
+ dataSource: options.dataSource,
1290
+ values: isRecord(given) ? given : {},
1291
+ onAction: options.onAction,
1292
+ host: options.host,
1293
+ confirm: options.confirm,
1294
+ scheduler,
1295
+ messages,
1296
+ now: options.now,
1297
+ }, chain);
1298
+ attached.set(id, { name: part.name, inner: made });
1299
+ // A change inside it is told as a change of its answers here, whoever made it.
1300
+ made.form.on('change', ({ by }) => {
1301
+ if (!syncing)
1302
+ changed(part.name, by);
1303
+ });
1304
+ // A change made inside it is a change of this form's: shown, kept in a draft, saved.
1305
+ made.form.subscribe((inside) => {
1306
+ if (syncing || inside.values === state.values[part.name])
1307
+ return;
1308
+ writeValues({ ...state.values, [part.name]: asValue(inside.values) });
1309
+ afterEdit(part.name);
1310
+ });
1311
+ // What it starts with, its defaults and worked-out values filled in, is this form's starting point too — unless changed already.
1312
+ const start = asValue(made.form.getState().values);
1313
+ if (!state.dirty.includes(part.name))
1314
+ baseline = { ...baseline, [part.name]: start };
1315
+ const values = { ...state.values, [part.name]: start };
1316
+ set({ values, dirty: dirtyFields(values) });
1317
+ return made.form;
1318
+ },
1319
+ embedded: (id) => attached.get(id)?.inner.form ?? null,
710
1320
  dispose() {
711
1321
  if (draftTimer !== null)
712
1322
  scheduler.clearTimeout(draftTimer);
713
1323
  if (autosaveTimer !== null)
714
1324
  scheduler.clearTimeout(autosaveTimer);
715
1325
  listeners.clear();
1326
+ events.clear();
1327
+ for (const part of attached.values())
1328
+ part.inner.form.dispose();
716
1329
  },
717
1330
  };
718
1331
  // A record's starting values may already break a warning rule; nothing listens yet.
719
1332
  state = { ...state, warnings: collectWarnings() };
720
1333
  if (state.recordId == null)
721
1334
  offerDraft();
722
- return form;
1335
+ // A new form has its values now: it opens once whoever made it has had the chance to listen.
1336
+ if (state.recordId == null)
1337
+ void track(Promise.resolve().then(opening));
1338
+ return {
1339
+ form,
1340
+ take(values, how) {
1341
+ if (how !== 'start')
1342
+ return writeValues({ ...(0, values_1.initialValues)(page.fields, today()), ...values }, {}, how === 'fresh');
1343
+ baseline = startFrom({ ...(0, values_1.initialValues)(page.fields, today()), ...(0, values_1.structuredCopy)(values) });
1344
+ const start = syncParts((0, values_1.structuredCopy)(baseline), 'start');
1345
+ set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null });
1346
+ },
1347
+ errorsNow: () => collectErrors(),
1348
+ show(errors) {
1349
+ if (JSON.stringify(errors) !== JSON.stringify(state.errors))
1350
+ set({ errors });
1351
+ },
1352
+ };
1353
+ }
1354
+ /** The problems found inside a saved form, by its own fields' names. */
1355
+ function inside(errors, name) {
1356
+ const found = {};
1357
+ for (const [key, message] of Object.entries(errors))
1358
+ if (key.startsWith(`${name}.`))
1359
+ found[key.slice(name.length + 1)] = message;
1360
+ return found;
1361
+ }
1362
+ /** One value against another in a table's order: empty and false first, a link by its name, words as people sort them. */
1363
+ function compareValues(a, b) {
1364
+ const empty = (v) => v === null || v === undefined || v === false || v === '';
1365
+ if (empty(a) || empty(b))
1366
+ return empty(a) === empty(b) ? 0 : empty(a) ? -1 : 1;
1367
+ const plain = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) && 'label' in v ? String(v.label) : v);
1368
+ const x = plain(a);
1369
+ const y = plain(b);
1370
+ if (typeof x === 'number' && typeof y === 'number')
1371
+ return x - y;
1372
+ if (typeof x === 'boolean' && typeof y === 'boolean')
1373
+ return Number(x) - Number(y);
1374
+ return String(x).localeCompare(String(y));
1375
+ }
1376
+ /** Two lines in a table's order: by the first field, then the next. */
1377
+ function compareLines(a, b, by) {
1378
+ for (const item of by) {
1379
+ const found = compareValues(a[item.field], b[item.field]);
1380
+ if (found)
1381
+ return item.desc ? -found : found;
1382
+ }
1383
+ return 0;
723
1384
  }
724
1385
  /** A field no one types in: read-only by its definition, or worked out from others. */
725
1386
  function lockedField(def) {
@@ -759,11 +1420,13 @@ function linkOps(before, now) {
759
1420
  function indexLayout(root) {
760
1421
  const index = new Map();
761
1422
  const add = (source, parent, kind, modifiers) => {
1423
+ const roles = source.roles;
762
1424
  index.set(source.id, {
763
1425
  id: source.id,
764
1426
  parent,
765
1427
  kind,
766
1428
  field: modifiers.field,
1429
+ ...(roles?.length ? { roles } : {}),
767
1430
  invisible: (0, modifier_1.compileModifier)(modifiers.invisible),
768
1431
  readonly: (0, modifier_1.compileModifier)(modifiers.readonly),
769
1432
  required: (0, modifier_1.compileModifier)(modifiers.required),
@@ -774,6 +1437,9 @@ function indexLayout(root) {
774
1437
  for (const node of nodes) {
775
1438
  if (node.type === 'field') {
776
1439
  add(node, parent, 'field', { invisible: node.invisible, readonly: node.readonly, required: node.required, field: node.field });
1440
+ // A table's buttons for its chosen lines and in its control row: buttons of the record, inside the table.
1441
+ for (const button of [...(node.selectedButtons ?? []), ...(node.controlButtons ?? [])])
1442
+ add(button, node.id, 'button', { invisible: button.invisible });
777
1443
  }
778
1444
  else if (node.type === 'button') {
779
1445
  add(node, parent, 'button', { invisible: node.invisible });
@@ -789,6 +1455,9 @@ function indexLayout(root) {
789
1455
  add(node, parent, 'other', { invisible: node.invisible, readonly: node.readonly });
790
1456
  walk(node.children, node.id);
791
1457
  }
1458
+ else if (node.type === 'form') {
1459
+ add(node, parent, 'form', { invisible: node.invisible, readonly: node.readonly });
1460
+ }
792
1461
  else {
793
1462
  add(node, parent, 'other', { invisible: node.invisible });
794
1463
  }
@@ -808,6 +1477,8 @@ function indexLayout(root) {
808
1477
  }
809
1478
  else if (root.type === 'sections') {
810
1479
  walk(root.children, root.id);
1480
+ for (const button of root.footer ?? [])
1481
+ add(button, root.id, 'button', { invisible: button.invisible });
811
1482
  }
812
1483
  else if (root.type === 'list') {
813
1484
  // A list holds no fields of its own record: only the buttons for what is selected.
@@ -824,23 +1495,54 @@ function indexLayout(root) {
824
1495
  shown('#title', root.title?.field);
825
1496
  shown('#subtitle', root.title?.subtitleField);
826
1497
  shown('#avatar', root.title?.avatarField);
827
- shown('#statusbar', root.statusbar?.field);
828
- for (const button of root.buttons ?? [])
1498
+ // The statusbar shows and hides by its own condition and roles.
1499
+ if (root.statusbar)
1500
+ add({ id: '#statusbar', ...(root.statusbar.roles ? { roles: root.statusbar.roles } : {}) }, root.id, 'field', { field: root.statusbar.field, invisible: root.statusbar.invisible });
1501
+ for (const button of [...(root.buttons ?? []), ...(root.footer ?? [])])
829
1502
  add(button, root.id, 'button', { invisible: button.invisible });
830
1503
  for (const stat of root.statButtons ?? [])
831
1504
  add(stat, root.id, 'stat', { invisible: stat.invisible });
832
- if (root.ribbon)
833
- add(root.ribbon, root.id, 'other', { invisible: root.ribbon.invisible });
834
- for (const alert of root.alerts ?? [])
1505
+ for (const ribbon of [...(root.ribbon ? [root.ribbon] : []), ...(root.ribbons ?? [])])
1506
+ add(ribbon, root.id, 'other', { invisible: ribbon.invisible });
1507
+ for (const alert of root.alerts ?? []) {
835
1508
  add(alert, root.id, 'other', { invisible: alert.invisible });
1509
+ // An alert's buttons are hidden with it.
1510
+ for (const button of alert.buttons ?? [])
1511
+ add(button, alert.id, 'button', { invisible: button.invisible });
1512
+ }
836
1513
  for (const badge of root.badges ?? [])
837
1514
  add(badge, root.id, 'other', { invisible: badge.invisible });
838
- // Fields over and under the title are field nodes like any other.
839
- walk([...(root.title?.above ?? []), ...(root.title?.below ?? [])], root.id);
1515
+ // Fields over and under the title, and on its line, are field nodes like any other.
1516
+ walk([...(root.title?.above ?? []), ...(root.title?.before ?? []), ...(root.title?.after ?? []), ...(root.title?.below ?? [])], root.id);
840
1517
  walk(root.children, root.id);
841
1518
  if (root.sidePanel)
842
1519
  add(root.sidePanel, root.id, 'other', { invisible: root.sidePanel.invisible });
1520
+ // The gear menu's items are buttons of the record; Archive shows while it is active, Unarchive while it is not.
1521
+ for (const item of root.toolbar?.menu ?? []) {
1522
+ add(item, root.id, 'button', { invisible: item.invisible });
1523
+ if (item.builtin === 'archive' || item.builtin === 'unarchive') {
1524
+ const own = index.get(item.id);
1525
+ const mine = own.invisible;
1526
+ const archived = item.builtin === 'unarchive';
1527
+ own.invisible = { ...mine, evaluate: (context, env) => (context['active'] === false) !== archived || mine.evaluate(context, env) };
1528
+ }
1529
+ }
1530
+ if (root.attachmentPreview)
1531
+ add({ id: '#preview', ...root.attachmentPreview }, root.id, 'other', { invisible: root.attachmentPreview.invisible });
843
1532
  }
844
1533
  return index;
845
1534
  }
1535
+ /** A field's value as words in a posted message: a link's name, a choice's label, a yes or no, a number or a date as written. */
1536
+ function wordsOfValue(def, value) {
1537
+ if (def.type === 'boolean')
1538
+ return value === true ? 'Yes' : 'No';
1539
+ if (value === null || value === undefined || value === false)
1540
+ return '';
1541
+ if (def.type === 'selection')
1542
+ return def.options.find((option) => option.value === value)?.label ?? String(value);
1543
+ if (def.type === 'html')
1544
+ return String(value).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
1545
+ const one = (item) => (item && typeof item === 'object' && 'label' in item ? String(item.label) : item && typeof item === 'object' && 'name' in item ? String(item.name) : String(item));
1546
+ return Array.isArray(value) ? value.map(one).join(', ') : one(value);
1547
+ }
846
1548
  //# sourceMappingURL=form.js.map