@fieldia/core 0.11.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 (197) hide show
  1. package/README.md +5 -0
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +3996 -904
  4. package/src/index.d.mts +1 -1
  5. package/src/index.d.ts +1 -1
  6. package/src/index.js +4 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +1 -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 +486 -2
  51. package/src/lib/format/actions.d.ts +486 -2
  52. package/src/lib/format/actions.js +9 -0
  53. package/src/lib/format/actions.js.map +1 -1
  54. package/src/lib/format/actions.mjs +9 -0
  55. package/src/lib/format/actions.mjs.map +1 -1
  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 +101 -14
  65. package/src/lib/format/field.js.map +1 -1
  66. package/src/lib/format/field.mjs +100 -13
  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 +4184 -352
  75. package/src/lib/format/layout.d.ts +4184 -352
  76. package/src/lib/format/layout.js +131 -20
  77. package/src/lib/format/layout.js.map +1 -1
  78. package/src/lib/format/layout.mjs +130 -19
  79. package/src/lib/format/layout.mjs.map +1 -1
  80. package/src/lib/format/page.d.mts +2957 -229
  81. package/src/lib/format/page.d.ts +2957 -229
  82. package/src/lib/format/page.js +2 -0
  83. package/src/lib/format/page.js.map +1 -1
  84. package/src/lib/format/page.mjs +3 -1
  85. package/src/lib/format/page.mjs.map +1 -1
  86. package/src/lib/format/references.d.mts +35 -2
  87. package/src/lib/format/references.d.ts +35 -2
  88. package/src/lib/format/references.js +321 -43
  89. package/src/lib/format/references.js.map +1 -1
  90. package/src/lib/format/references.mjs +321 -43
  91. package/src/lib/format/references.mjs.map +1 -1
  92. package/src/lib/format/translate.js +5 -3
  93. package/src/lib/format/translate.js.map +1 -1
  94. package/src/lib/format/translate.mjs +5 -3
  95. package/src/lib/format/translate.mjs.map +1 -1
  96. package/src/lib/format/words.d.mts +9 -0
  97. package/src/lib/format/words.d.ts +9 -0
  98. package/src/lib/format/words.js +35 -0
  99. package/src/lib/format/words.js.map +1 -0
  100. package/src/lib/format/words.mjs +30 -0
  101. package/src/lib/format/words.mjs.map +1 -0
  102. package/src/lib/record/cells.d.mts +37 -0
  103. package/src/lib/record/cells.d.ts +37 -0
  104. package/src/lib/record/cells.js +59 -0
  105. package/src/lib/record/cells.js.map +1 -0
  106. package/src/lib/record/cells.mjs +54 -0
  107. package/src/lib/record/cells.mjs.map +1 -0
  108. package/src/lib/record/compute.d.mts +5 -4
  109. package/src/lib/record/compute.d.ts +5 -4
  110. package/src/lib/record/compute.js +11 -10
  111. package/src/lib/record/compute.js.map +1 -1
  112. package/src/lib/record/compute.mjs +12 -11
  113. package/src/lib/record/compute.mjs.map +1 -1
  114. package/src/lib/record/data-source.d.mts +32 -2
  115. package/src/lib/record/data-source.d.ts +32 -2
  116. package/src/lib/record/data-source.js.map +1 -1
  117. package/src/lib/record/data-source.mjs.map +1 -1
  118. package/src/lib/record/env.d.mts +38 -1
  119. package/src/lib/record/env.d.ts +38 -1
  120. package/src/lib/record/env.js +41 -3
  121. package/src/lib/record/env.js.map +1 -1
  122. package/src/lib/record/env.mjs +38 -3
  123. package/src/lib/record/env.mjs.map +1 -1
  124. package/src/lib/record/filter.d.mts +10 -0
  125. package/src/lib/record/filter.d.ts +10 -0
  126. package/src/lib/record/filter.js +49 -0
  127. package/src/lib/record/filter.js.map +1 -1
  128. package/src/lib/record/filter.mjs +48 -0
  129. package/src/lib/record/filter.mjs.map +1 -1
  130. package/src/lib/record/form.d.mts +141 -8
  131. package/src/lib/record/form.d.ts +141 -8
  132. package/src/lib/record/form.js +462 -57
  133. package/src/lib/record/form.js.map +1 -1
  134. package/src/lib/record/form.mjs +464 -59
  135. package/src/lib/record/form.mjs.map +1 -1
  136. package/src/lib/record/locales/ar.js +3 -0
  137. package/src/lib/record/locales/ar.js.map +1 -1
  138. package/src/lib/record/locales/ar.mjs +3 -0
  139. package/src/lib/record/locales/ar.mjs.map +1 -1
  140. package/src/lib/record/locales/de.js +3 -0
  141. package/src/lib/record/locales/de.js.map +1 -1
  142. package/src/lib/record/locales/de.mjs +3 -0
  143. package/src/lib/record/locales/de.mjs.map +1 -1
  144. package/src/lib/record/locales/fr.js +3 -0
  145. package/src/lib/record/locales/fr.js.map +1 -1
  146. package/src/lib/record/locales/fr.mjs +3 -0
  147. package/src/lib/record/locales/fr.mjs.map +1 -1
  148. package/src/lib/record/memory-data-source.d.mts +26 -2
  149. package/src/lib/record/memory-data-source.d.ts +26 -2
  150. package/src/lib/record/memory-data-source.js +98 -7
  151. package/src/lib/record/memory-data-source.js.map +1 -1
  152. package/src/lib/record/memory-data-source.mjs +98 -7
  153. package/src/lib/record/memory-data-source.mjs.map +1 -1
  154. package/src/lib/record/messages.d.mts +5 -0
  155. package/src/lib/record/messages.d.ts +5 -0
  156. package/src/lib/record/messages.js +3 -0
  157. package/src/lib/record/messages.js.map +1 -1
  158. package/src/lib/record/messages.mjs +3 -0
  159. package/src/lib/record/messages.mjs.map +1 -1
  160. package/src/lib/record/roles.d.mts +10 -0
  161. package/src/lib/record/roles.d.ts +10 -0
  162. package/src/lib/record/roles.js +21 -0
  163. package/src/lib/record/roles.js.map +1 -0
  164. package/src/lib/record/roles.mjs +18 -0
  165. package/src/lib/record/roles.mjs.map +1 -0
  166. package/src/lib/record/rules.js +30 -0
  167. package/src/lib/record/rules.js.map +1 -1
  168. package/src/lib/record/rules.mjs +31 -1
  169. package/src/lib/record/rules.mjs.map +1 -1
  170. package/src/lib/record/run.d.mts +79 -0
  171. package/src/lib/record/run.d.ts +79 -0
  172. package/src/lib/record/run.js +105 -0
  173. package/src/lib/record/run.js.map +1 -1
  174. package/src/lib/record/run.mjs +105 -0
  175. package/src/lib/record/run.mjs.map +1 -1
  176. package/src/lib/record/set-when.d.mts +5 -2
  177. package/src/lib/record/set-when.d.ts +5 -2
  178. package/src/lib/record/set-when.js +28 -12
  179. package/src/lib/record/set-when.js.map +1 -1
  180. package/src/lib/record/set-when.mjs +30 -14
  181. package/src/lib/record/set-when.mjs.map +1 -1
  182. package/src/lib/record/value-map.d.mts +41 -0
  183. package/src/lib/record/value-map.d.ts +41 -0
  184. package/src/lib/record/value-map.js +80 -0
  185. package/src/lib/record/value-map.js.map +1 -0
  186. package/src/lib/record/value-map.mjs +73 -0
  187. package/src/lib/record/value-map.mjs.map +1 -0
  188. package/src/lib/record/values.d.mts +10 -0
  189. package/src/lib/record/values.d.ts +10 -0
  190. package/src/lib/record/values.js.map +1 -1
  191. package/src/lib/record/values.mjs.map +1 -1
  192. package/src/runtime.d.mts +10 -6
  193. package/src/runtime.d.ts +10 -6
  194. package/src/runtime.js +11 -1
  195. package/src/runtime.js.map +1 -1
  196. package/src/runtime.mjs +3 -0
  197. package/src/runtime.mjs.map +1 -1
@@ -9,6 +9,10 @@ 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");
@@ -38,13 +42,68 @@ function innerForm(options, within) {
38
42
  if (node.kind === 'field' && rules)
39
43
  nodeRules.set(node.id, (0, rules_1.compileRules)(rules));
40
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);
41
94
  /** Whether any rule only warns: without one, there are never warnings to look for. */
42
95
  const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
43
96
  const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
44
97
  const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
45
98
  const today = () => (0, functions_1.localDay)(options.now?.() ?? new Date());
46
- const computed = (0, compute_1.compileComputed)(page, today);
47
- const setWhen = (0, set_when_1.compileSetWhen)(page, today);
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);
48
107
  /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
49
108
  const parts = new Map();
50
109
  for (const node of index.values())
@@ -58,7 +117,75 @@ function innerForm(options, within) {
58
117
  fields[part.name] = { type: 'json', label: part.title || part.name };
59
118
  /** True while answers are handed to an inner form: what it says back then is no change of its own. */
60
119
  let syncing = false;
61
- let baseline = computed.apply({ ...(0, values_1.initialValues)(fields, today()), ...(0, values_1.structuredCopy)(options.values ?? {}) });
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());
62
189
  /** The setWhen conditions holding for the values as they last settled: one starts to hold only against these. */
63
190
  let holding = setWhen.holding(baseline);
64
191
  let state = {
@@ -76,7 +203,9 @@ function innerForm(options, within) {
76
203
  skipped: [],
77
204
  saveProblem: null,
78
205
  choices: {},
206
+ definitions: {},
79
207
  };
208
+ started = true;
80
209
  /** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
81
210
  let serverErrors = {};
82
211
  let lineKeys = 0;
@@ -94,6 +223,7 @@ function innerForm(options, within) {
94
223
  /** The wizard step last told as entered. */
95
224
  let told = null;
96
225
  let contextCache = null;
226
+ const userRoles = [...(options.user?.roles ?? [])];
97
227
  /** The latest load of each field's choices: an answer to an older one is let go. */
98
228
  const choiceLoads = {};
99
229
  function set(patch) {
@@ -111,6 +241,9 @@ function innerForm(options, within) {
111
241
  for (const name in state.choices)
112
242
  if (fromList(name).dependsOn?.some((other) => was[other] !== state.values[other]))
113
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);
114
247
  }
115
248
  if (showing && state.step !== told)
116
249
  tellStep(entering);
@@ -161,6 +294,24 @@ function innerForm(options, within) {
161
294
  const source = options.dataSource;
162
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) })));
163
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
+ }
164
315
  /** A field as its value is checked: a list's choices are those loaded, and until they are, any choice may be one. */
165
316
  function checked(name) {
166
317
  const def = page.fields[name];
@@ -173,11 +324,12 @@ function innerForm(options, within) {
173
324
  promise.then(done, done);
174
325
  return promise;
175
326
  }
176
- /** 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. */
177
328
  function reading() {
178
- if (contextCache?.values !== state.values) {
329
+ if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editing) {
179
330
  const values = state.values;
180
- 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) };
181
333
  }
182
334
  return contextCache;
183
335
  }
@@ -196,9 +348,10 @@ function innerForm(options, within) {
196
348
  if (!node)
197
349
  throw new Error(`The page has no element "${id}"`);
198
350
  const { context: ctx, env } = reading();
199
- 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);
200
353
  for (let parent = node.parent; !invisible && parent; parent = index.get(parent)?.parent ?? null) {
201
- invisible = index.get(parent)?.invisible.evaluate(ctx, env) ?? false;
354
+ invisible = hidden(index.get(parent));
202
355
  }
203
356
  // A section read-only right now makes everything inside it read-only too.
204
357
  let readonly = node.readonly.evaluate(ctx, env);
@@ -293,10 +446,15 @@ function innerForm(options, within) {
293
446
  for (const line of state.values[name] ?? []) {
294
447
  // A section or note answers only for its text, never for what an item needs.
295
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 : {};
296
451
  for (const [sub, subDef] of Object.entries(def.fields)) {
297
452
  if (kind && sub !== def.lineKinds?.text)
298
453
  continue;
299
- 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());
300
458
  if (lineMessage)
301
459
  errors[`${name}.${line.key}.${sub}`] = lineMessage;
302
460
  }
@@ -529,7 +687,8 @@ function innerForm(options, within) {
529
687
  values: (0, values_1.structuredCopy)(state.values),
530
688
  });
531
689
  forgetDraft();
532
- const values = syncParts(startFrom(result.values ? { ...state.values, ...result.values } : state.values), 'start');
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');
533
692
  baseline = (0, values_1.structuredCopy)(values);
534
693
  set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
535
694
  emit('save', { recordId: state.recordId, values: state.values });
@@ -565,9 +724,18 @@ function innerForm(options, within) {
565
724
  }
566
725
  function addLine(field, values, by) {
567
726
  const def = lineField(field);
568
- const key = `new-${++lineKeys}`;
569
727
  const current = state.values[field] ?? [];
570
- const line = { key, values: { ...(0, values_1.initialValues)(def.fields, today()), ...(0, values_1.structuredCopy)(values) } };
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) } };
571
739
  // A new line comes last, so it is numbered after the last.
572
740
  if (def.sequenceField && line.values[def.sequenceField] == null) {
573
741
  const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
@@ -610,30 +778,26 @@ function innerForm(options, within) {
610
778
  return null;
611
779
  return def.type === 'many2one' || def.type === 'many2many' ? def.relation : null;
612
780
  }
613
- async function runCreate(def, name, typed) {
781
+ async function runCreate(def, name, typed, values = {}) {
614
782
  const model = creatable(def);
615
783
  const source = options.dataSource;
616
784
  if (!model || !source?.create)
617
785
  throw new Error(`"${name}" cannot make records: its data source has no create, or it is not a link`);
618
- return track(source.create({ model, name: typed.trim() }));
786
+ return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
619
787
  }
620
- async function runSearch(def, name, ctx, query, limit, model) {
621
- if (def.type !== 'many2one' && def.type !== 'many2many' && def.type !== 'reference') {
622
- 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`);
623
791
  }
624
- if (def.type === 'reference' && !model)
792
+ if ((def.type === 'reference' || def.type === 'json') && !model)
625
793
  throw new Error(`Searching "${name}" needs a model`);
626
794
  const source = options.dataSource;
627
795
  if (!source?.search)
628
796
  return [];
629
797
  // Every valueFrom, in groups too, becomes the value it names before the search goes out.
630
- const resolve = (item) => 'any' in item
631
- ? { any: item.any.map(resolve) }
632
- : 'all' in item
633
- ? { all: item.all.map(resolve) }
634
- : { field: item.field, op: item.op, value: (item.valueFrom !== undefined ? ctx[item.valueFrom] ?? null : item.value ?? null) };
635
- const filter = def.type === 'reference' ? undefined : def.filter?.map(resolve);
636
- 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;
637
801
  return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
638
802
  }
639
803
  function lineField(field) {
@@ -649,7 +813,8 @@ function innerForm(options, within) {
649
813
  function optionalStep(id) {
650
814
  return index.get(id)?.source?.optional === true;
651
815
  }
652
- 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) {
653
818
  const source = options.dataSource;
654
819
  if (state.recordId == null || page.data.kind !== 'record')
655
820
  return;
@@ -657,18 +822,65 @@ function innerForm(options, within) {
657
822
  set({ status: 'error', error: 'This page has no data source to load from' });
658
823
  return;
659
824
  }
825
+ // Only the latest load is taken: a pager pressed twice shows the record it ended on.
826
+ const seq = ++loads;
660
827
  set({ status: 'loading', error: null });
661
828
  try {
662
829
  const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
663
- baseline = syncParts(startFrom({ ...(0, values_1.initialValues)(fields, today()), ...loaded }), 'start');
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');
664
834
  set({ status: 'ready', values: (0, values_1.structuredCopy)(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
835
+ if (again)
836
+ return;
665
837
  offerDraft();
666
838
  void track(opening());
667
839
  }
668
840
  catch (error) {
669
- set({ status: 'error', error: error.message });
841
+ if (seq === loads)
842
+ set({ status: 'error', error: error.message });
670
843
  }
671
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();
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
+ }
672
884
  const runner = (0, run_1.createRunner)({
673
885
  fields,
674
886
  values: () => state.values,
@@ -679,6 +891,12 @@ function innerForm(options, within) {
679
891
  addLine: (field, values) => void addLine(field, values, 'step'),
680
892
  check,
681
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
+ },
682
900
  reset: () => form.reset(),
683
901
  goTo: goToStep,
684
902
  showing: inRun,
@@ -686,8 +904,50 @@ function innerForm(options, within) {
686
904
  confirm: options.confirm,
687
905
  onAction: options.onAction,
688
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]),
689
929
  });
690
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
+ })());
950
+ }
691
951
  const form = {
692
952
  page,
693
953
  getState: () => state,
@@ -704,8 +964,19 @@ function innerForm(options, within) {
704
964
  },
705
965
  setValues: (values) => write(values, 'app'),
706
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
+ },
707
976
  fieldReadonly: (name) => lockedField(fieldDef(name)),
708
977
  loadChoices,
978
+ loadDefinitions,
979
+ addDefinition,
709
980
  problem(name) {
710
981
  fieldDef(name);
711
982
  // Only a field someone can see is asked anything, and required wherever it is shown so.
@@ -773,13 +1044,21 @@ function innerForm(options, within) {
773
1044
  },
774
1045
  removeLine(field, key) {
775
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
+ }
776
1055
  const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
777
1056
  writeValues({ ...state.values, [field]: lines });
778
1057
  afterEdit(field);
779
1058
  changed(field, 'person');
780
1059
  },
781
1060
  async search(field, query, limit = 8, options = {}) {
782
- return runSearch(fieldDef(field), field, context(), query, limit, options.model);
1061
+ return runSearch(fieldDef(field), field, context(), query, limit, options.model, options.ids);
783
1062
  },
784
1063
  async previewLine(field, key, values) {
785
1064
  lineField(field);
@@ -787,16 +1066,16 @@ function innerForm(options, within) {
787
1066
  const line = lines.find((l) => l.key === key);
788
1067
  if (!line)
789
1068
  throw new Error(`"${field}" has no line "${key}"`);
790
- const merged = computed.line(field, { ...line.values, ...values });
1069
+ const merged = computed.line(field, { ...line.values, ...values }, state.values);
791
1070
  const source = options.dataSource;
792
1071
  if (!source?.onchange || page.data.kind !== 'record')
793
1072
  return merged;
794
1073
  const changed = lines.map((l) => (l.key === key ? { ...l, values: merged } : l));
795
1074
  const result = await source.onchange({ model: page.data.model, id: state.recordId, changed: field, values: (0, values_1.structuredCopy)({ ...state.values, [field]: changed }) });
796
1075
  const after = (result.values?.[field] ?? []).find((l) => l.key === key);
797
- return after ? computed.line(field, { ...merged, ...after.values }) : merged;
1076
+ return after ? computed.line(field, { ...merged, ...after.values }, state.values) : merged;
798
1077
  },
799
- async searchLine(field, key, subfield, query, limit = 8) {
1078
+ async searchLine(field, key, subfield, query, limit = 8, options = {}) {
800
1079
  const def = lineField(field);
801
1080
  const sub = def.fields[subfield];
802
1081
  if (!sub)
@@ -804,12 +1083,13 @@ function innerForm(options, within) {
804
1083
  const line = (state.values[field] ?? []).find((l) => l.key === key);
805
1084
  if (!line)
806
1085
  throw new Error(`"${field}" has no line "${key}"`);
807
- 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);
808
1087
  },
809
1088
  canCreate: (field) => creatable(page.fields[field]) !== null,
810
- quickCreate: (field, typed) => runCreate(page.fields[field], field, typed),
1089
+ quickCreate: (field, typed) => runCreate(page.fields[field], field, typed, page.fields[field] ? createValuesOf(field) : {}),
811
1090
  canCreateLine: (field, subfield) => creatable(lineField(field).fields[subfield]) !== null,
812
- 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),
813
1093
  steps: () => steps.filter((id) => !nodeState(id).invisible),
814
1094
  next() {
815
1095
  if (!state.step)
@@ -883,21 +1163,85 @@ function innerForm(options, within) {
883
1163
  throw new Error(`The page has no button "${id}"`);
884
1164
  if (nodeState(id).invisible)
885
1165
  return { done: false, reason: 'cannot', message: `The button "${id}" is hidden` };
886
- const source = node.source;
887
- // Its steps, then its app action as a final call.
888
- const list = [...(source.steps ?? [])];
889
- if (source.action !== undefined)
890
- list.push({ do: 'call', action: source.action, ...('params' in source && source.params ? { params: source.params } : {}) });
891
- const scope = { id, chain: [], ...(chosen ? { recordIds: [...chosen.recordIds] } : {}) };
892
- const confirm = 'confirm' in source ? source.confirm : undefined;
893
- return track((async () => {
894
- if (confirm && !(await runner.ask(confirm))) {
895
- const result = { done: false, reason: 'no', message: confirm };
896
- emit('run', { id, steps: list, result });
897
- return result;
898
- }
899
- return runTop(list, scope);
900
- })());
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
+ });
901
1245
  },
902
1246
  run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
903
1247
  shown(tab) {
@@ -1015,6 +1359,29 @@ function inside(errors, name) {
1015
1359
  found[key.slice(name.length + 1)] = message;
1016
1360
  return found;
1017
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;
1384
+ }
1018
1385
  /** A field no one types in: read-only by its definition, or worked out from others. */
1019
1386
  function lockedField(def) {
1020
1387
  return def.readonly === true || def.compute !== undefined;
@@ -1053,11 +1420,13 @@ function linkOps(before, now) {
1053
1420
  function indexLayout(root) {
1054
1421
  const index = new Map();
1055
1422
  const add = (source, parent, kind, modifiers) => {
1423
+ const roles = source.roles;
1056
1424
  index.set(source.id, {
1057
1425
  id: source.id,
1058
1426
  parent,
1059
1427
  kind,
1060
1428
  field: modifiers.field,
1429
+ ...(roles?.length ? { roles } : {}),
1061
1430
  invisible: (0, modifier_1.compileModifier)(modifiers.invisible),
1062
1431
  readonly: (0, modifier_1.compileModifier)(modifiers.readonly),
1063
1432
  required: (0, modifier_1.compileModifier)(modifiers.required),
@@ -1068,6 +1437,9 @@ function indexLayout(root) {
1068
1437
  for (const node of nodes) {
1069
1438
  if (node.type === 'field') {
1070
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 });
1071
1443
  }
1072
1444
  else if (node.type === 'button') {
1073
1445
  add(node, parent, 'button', { invisible: node.invisible });
@@ -1105,6 +1477,8 @@ function indexLayout(root) {
1105
1477
  }
1106
1478
  else if (root.type === 'sections') {
1107
1479
  walk(root.children, root.id);
1480
+ for (const button of root.footer ?? [])
1481
+ add(button, root.id, 'button', { invisible: button.invisible });
1108
1482
  }
1109
1483
  else if (root.type === 'list') {
1110
1484
  // A list holds no fields of its own record: only the buttons for what is selected.
@@ -1121,23 +1495,54 @@ function indexLayout(root) {
1121
1495
  shown('#title', root.title?.field);
1122
1496
  shown('#subtitle', root.title?.subtitleField);
1123
1497
  shown('#avatar', root.title?.avatarField);
1124
- shown('#statusbar', root.statusbar?.field);
1125
- 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 ?? [])])
1126
1502
  add(button, root.id, 'button', { invisible: button.invisible });
1127
1503
  for (const stat of root.statButtons ?? [])
1128
1504
  add(stat, root.id, 'stat', { invisible: stat.invisible });
1129
- if (root.ribbon)
1130
- add(root.ribbon, root.id, 'other', { invisible: root.ribbon.invisible });
1131
- 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 ?? []) {
1132
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
+ }
1133
1513
  for (const badge of root.badges ?? [])
1134
1514
  add(badge, root.id, 'other', { invisible: badge.invisible });
1135
- // Fields over and under the title are field nodes like any other.
1136
- 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);
1137
1517
  walk(root.children, root.id);
1138
1518
  if (root.sidePanel)
1139
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 });
1140
1532
  }
1141
1533
  return index;
1142
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
+ }
1143
1548
  //# sourceMappingURL=form.js.map