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