@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
@@ -5,12 +5,16 @@ 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
16
  import { createMoments, createRunner } from './run.mjs';
13
- import { emptyValue, expressionContext, initialValues, isEmpty, lineKind, structuredCopy, } from './values.mjs';
17
+ import { emptyValue, initialValues, isEmpty, lineKind, structuredCopy, } from './values.mjs';
14
18
  /**
15
19
  * The record state behind a page: values, what changed, what is visible and
16
20
  * required, errors, loading and saving. No DOM and no framework — every
@@ -35,13 +39,68 @@ function innerForm(options, within) {
35
39
  if (node.kind === 'field' && rules)
36
40
  nodeRules.set(node.id, compileRules(rules));
37
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);
38
91
  /** Whether any rule only warns: without one, there are never warnings to look for. */
39
92
  const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
40
93
  const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
41
94
  const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
42
95
  const today = () => localDay(options.now?.() ?? new Date());
43
- const computed = compileComputed(page, today);
44
- const setWhen = compileSetWhen(page, today);
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);
45
104
  /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
46
105
  const parts = new Map();
47
106
  for (const node of index.values())
@@ -55,7 +114,75 @@ function innerForm(options, within) {
55
114
  fields[part.name] = { type: 'json', label: part.title || part.name };
56
115
  /** True while answers are handed to an inner form: what it says back then is no change of its own. */
57
116
  let syncing = false;
58
- let baseline = computed.apply({ ...initialValues(fields, today()), ...structuredCopy(options.values ?? {}) });
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());
59
186
  /** The setWhen conditions holding for the values as they last settled: one starts to hold only against these. */
60
187
  let holding = setWhen.holding(baseline);
61
188
  let state = {
@@ -73,7 +200,9 @@ function innerForm(options, within) {
73
200
  skipped: [],
74
201
  saveProblem: null,
75
202
  choices: {},
203
+ definitions: {},
76
204
  };
205
+ started = true;
77
206
  /** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
78
207
  let serverErrors = {};
79
208
  let lineKeys = 0;
@@ -91,6 +220,7 @@ function innerForm(options, within) {
91
220
  /** The wizard step last told as entered. */
92
221
  let told = null;
93
222
  let contextCache = null;
223
+ const userRoles = [...(options.user?.roles ?? [])];
94
224
  /** The latest load of each field's choices: an answer to an older one is let go. */
95
225
  const choiceLoads = {};
96
226
  function set(patch) {
@@ -108,6 +238,9 @@ function innerForm(options, within) {
108
238
  for (const name in state.choices)
109
239
  if (fromList(name).dependsOn?.some((other) => was[other] !== state.values[other]))
110
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);
111
244
  }
112
245
  if (showing && state.step !== told)
113
246
  tellStep(entering);
@@ -158,6 +291,24 @@ function innerForm(options, within) {
158
291
  const source = options.dataSource;
159
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) })));
160
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
+ }
161
312
  /** A field as its value is checked: a list's choices are those loaded, and until they are, any choice may be one. */
162
313
  function checked(name) {
163
314
  const def = page.fields[name];
@@ -170,11 +321,12 @@ function innerForm(options, within) {
170
321
  promise.then(done, done);
171
322
  return promise;
172
323
  }
173
- /** 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. */
174
325
  function reading() {
175
- if (contextCache?.values !== state.values) {
326
+ if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editing) {
176
327
  const values = state.values;
177
- 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) };
178
330
  }
179
331
  return contextCache;
180
332
  }
@@ -193,9 +345,10 @@ function innerForm(options, within) {
193
345
  if (!node)
194
346
  throw new Error(`The page has no element "${id}"`);
195
347
  const { context: ctx, env } = reading();
196
- 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);
197
350
  for (let parent = node.parent; !invisible && parent; parent = index.get(parent)?.parent ?? null) {
198
- invisible = index.get(parent)?.invisible.evaluate(ctx, env) ?? false;
351
+ invisible = hidden(index.get(parent));
199
352
  }
200
353
  // A section read-only right now makes everything inside it read-only too.
201
354
  let readonly = node.readonly.evaluate(ctx, env);
@@ -290,10 +443,15 @@ function innerForm(options, within) {
290
443
  for (const line of state.values[name] ?? []) {
291
444
  // A section or note answers only for its text, never for what an item needs.
292
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 : {};
293
448
  for (const [sub, subDef] of Object.entries(def.fields)) {
294
449
  if (kind && sub !== def.lineKinds?.text)
295
450
  continue;
296
- 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());
297
455
  if (lineMessage)
298
456
  errors[`${name}.${line.key}.${sub}`] = lineMessage;
299
457
  }
@@ -526,7 +684,8 @@ function innerForm(options, within) {
526
684
  values: structuredCopy(state.values),
527
685
  });
528
686
  forgetDraft();
529
- const values = syncParts(startFrom(result.values ? { ...state.values, ...result.values } : state.values), 'start');
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');
530
689
  baseline = structuredCopy(values);
531
690
  set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
532
691
  emit('save', { recordId: state.recordId, values: state.values });
@@ -562,9 +721,18 @@ function innerForm(options, within) {
562
721
  }
563
722
  function addLine(field, values, by) {
564
723
  const def = lineField(field);
565
- const key = `new-${++lineKeys}`;
566
724
  const current = state.values[field] ?? [];
567
- const line = { key, values: { ...initialValues(def.fields, today()), ...structuredCopy(values) } };
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) } };
568
736
  // A new line comes last, so it is numbered after the last.
569
737
  if (def.sequenceField && line.values[def.sequenceField] == null) {
570
738
  const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
@@ -607,30 +775,26 @@ function innerForm(options, within) {
607
775
  return null;
608
776
  return def.type === 'many2one' || def.type === 'many2many' ? def.relation : null;
609
777
  }
610
- async function runCreate(def, name, typed) {
778
+ async function runCreate(def, name, typed, values = {}) {
611
779
  const model = creatable(def);
612
780
  const source = options.dataSource;
613
781
  if (!model || !source?.create)
614
782
  throw new Error(`"${name}" cannot make records: its data source has no create, or it is not a link`);
615
- return track(source.create({ model, name: typed.trim() }));
783
+ return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
616
784
  }
617
- async function runSearch(def, name, ctx, query, limit, model) {
618
- if (def.type !== 'many2one' && def.type !== 'many2many' && def.type !== 'reference') {
619
- 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`);
620
788
  }
621
- if (def.type === 'reference' && !model)
789
+ if ((def.type === 'reference' || def.type === 'json') && !model)
622
790
  throw new Error(`Searching "${name}" needs a model`);
623
791
  const source = options.dataSource;
624
792
  if (!source?.search)
625
793
  return [];
626
794
  // Every valueFrom, in groups too, becomes the value it names before the search goes out.
627
- const resolve = (item) => 'any' in item
628
- ? { any: item.any.map(resolve) }
629
- : 'all' in item
630
- ? { all: item.all.map(resolve) }
631
- : { field: item.field, op: item.op, value: (item.valueFrom !== undefined ? ctx[item.valueFrom] ?? null : item.value ?? null) };
632
- const filter = def.type === 'reference' ? undefined : def.filter?.map(resolve);
633
- 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;
634
798
  return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
635
799
  }
636
800
  function lineField(field) {
@@ -646,7 +810,8 @@ function innerForm(options, within) {
646
810
  function optionalStep(id) {
647
811
  return index.get(id)?.source?.optional === true;
648
812
  }
649
- 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) {
650
815
  const source = options.dataSource;
651
816
  if (state.recordId == null || page.data.kind !== 'record')
652
817
  return;
@@ -654,18 +819,65 @@ function innerForm(options, within) {
654
819
  set({ status: 'error', error: 'This page has no data source to load from' });
655
820
  return;
656
821
  }
822
+ // Only the latest load is taken: a pager pressed twice shows the record it ended on.
823
+ const seq = ++loads;
657
824
  set({ status: 'loading', error: null });
658
825
  try {
659
826
  const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
660
- baseline = syncParts(startFrom({ ...initialValues(fields, today()), ...loaded }), 'start');
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');
661
831
  set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
832
+ if (again)
833
+ return;
662
834
  offerDraft();
663
835
  void track(opening());
664
836
  }
665
837
  catch (error) {
666
- set({ status: 'error', error: error.message });
838
+ if (seq === loads)
839
+ set({ status: 'error', error: error.message });
667
840
  }
668
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();
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
+ }
669
881
  const runner = createRunner({
670
882
  fields,
671
883
  values: () => state.values,
@@ -676,6 +888,12 @@ function innerForm(options, within) {
676
888
  addLine: (field, values) => void addLine(field, values, 'step'),
677
889
  check,
678
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
+ },
679
897
  reset: () => form.reset(),
680
898
  goTo: goToStep,
681
899
  showing: inRun,
@@ -683,8 +901,50 @@ function innerForm(options, within) {
683
901
  confirm: options.confirm,
684
902
  onAction: options.onAction,
685
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]),
686
926
  });
687
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
+ })());
947
+ }
688
948
  const form = {
689
949
  page,
690
950
  getState: () => state,
@@ -701,8 +961,19 @@ function innerForm(options, within) {
701
961
  },
702
962
  setValues: (values) => write(values, 'app'),
703
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
+ },
704
973
  fieldReadonly: (name) => lockedField(fieldDef(name)),
705
974
  loadChoices,
975
+ loadDefinitions,
976
+ addDefinition,
706
977
  problem(name) {
707
978
  fieldDef(name);
708
979
  // Only a field someone can see is asked anything, and required wherever it is shown so.
@@ -770,13 +1041,21 @@ function innerForm(options, within) {
770
1041
  },
771
1042
  removeLine(field, key) {
772
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
+ }
773
1052
  const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
774
1053
  writeValues({ ...state.values, [field]: lines });
775
1054
  afterEdit(field);
776
1055
  changed(field, 'person');
777
1056
  },
778
1057
  async search(field, query, limit = 8, options = {}) {
779
- return runSearch(fieldDef(field), field, context(), query, limit, options.model);
1058
+ return runSearch(fieldDef(field), field, context(), query, limit, options.model, options.ids);
780
1059
  },
781
1060
  async previewLine(field, key, values) {
782
1061
  lineField(field);
@@ -784,16 +1063,16 @@ function innerForm(options, within) {
784
1063
  const line = lines.find((l) => l.key === key);
785
1064
  if (!line)
786
1065
  throw new Error(`"${field}" has no line "${key}"`);
787
- const merged = computed.line(field, { ...line.values, ...values });
1066
+ const merged = computed.line(field, { ...line.values, ...values }, state.values);
788
1067
  const source = options.dataSource;
789
1068
  if (!source?.onchange || page.data.kind !== 'record')
790
1069
  return merged;
791
1070
  const changed = lines.map((l) => (l.key === key ? { ...l, values: merged } : l));
792
1071
  const result = await source.onchange({ model: page.data.model, id: state.recordId, changed: field, values: structuredCopy({ ...state.values, [field]: changed }) });
793
1072
  const after = (result.values?.[field] ?? []).find((l) => l.key === key);
794
- return after ? computed.line(field, { ...merged, ...after.values }) : merged;
1073
+ return after ? computed.line(field, { ...merged, ...after.values }, state.values) : merged;
795
1074
  },
796
- async searchLine(field, key, subfield, query, limit = 8) {
1075
+ async searchLine(field, key, subfield, query, limit = 8, options = {}) {
797
1076
  const def = lineField(field);
798
1077
  const sub = def.fields[subfield];
799
1078
  if (!sub)
@@ -801,12 +1080,13 @@ function innerForm(options, within) {
801
1080
  const line = (state.values[field] ?? []).find((l) => l.key === key);
802
1081
  if (!line)
803
1082
  throw new Error(`"${field}" has no line "${key}"`);
804
- 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);
805
1084
  },
806
1085
  canCreate: (field) => creatable(page.fields[field]) !== null,
807
- quickCreate: (field, typed) => runCreate(page.fields[field], field, typed),
1086
+ quickCreate: (field, typed) => runCreate(page.fields[field], field, typed, page.fields[field] ? createValuesOf(field) : {}),
808
1087
  canCreateLine: (field, subfield) => creatable(lineField(field).fields[subfield]) !== null,
809
- 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),
810
1090
  steps: () => steps.filter((id) => !nodeState(id).invisible),
811
1091
  next() {
812
1092
  if (!state.step)
@@ -880,21 +1160,85 @@ function innerForm(options, within) {
880
1160
  throw new Error(`The page has no button "${id}"`);
881
1161
  if (nodeState(id).invisible)
882
1162
  return { done: false, reason: 'cannot', message: `The button "${id}" is hidden` };
883
- const source = node.source;
884
- // Its steps, then its app action as a final call.
885
- const list = [...(source.steps ?? [])];
886
- if (source.action !== undefined)
887
- list.push({ do: 'call', action: source.action, ...('params' in source && source.params ? { params: source.params } : {}) });
888
- const scope = { id, chain: [], ...(chosen ? { recordIds: [...chosen.recordIds] } : {}) };
889
- const confirm = 'confirm' in source ? source.confirm : undefined;
890
- return track((async () => {
891
- if (confirm && !(await runner.ask(confirm))) {
892
- const result = { done: false, reason: 'no', message: confirm };
893
- emit('run', { id, steps: list, result });
894
- return result;
895
- }
896
- return runTop(list, scope);
897
- })());
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
+ });
898
1242
  },
899
1243
  run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
900
1244
  shown(tab) {
@@ -1012,6 +1356,29 @@ function inside(errors, name) {
1012
1356
  found[key.slice(name.length + 1)] = message;
1013
1357
  return found;
1014
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;
1381
+ }
1015
1382
  /** A field no one types in: read-only by its definition, or worked out from others. */
1016
1383
  function lockedField(def) {
1017
1384
  return def.readonly === true || def.compute !== undefined;
@@ -1050,11 +1417,13 @@ function linkOps(before, now) {
1050
1417
  function indexLayout(root) {
1051
1418
  const index = new Map();
1052
1419
  const add = (source, parent, kind, modifiers) => {
1420
+ const roles = source.roles;
1053
1421
  index.set(source.id, {
1054
1422
  id: source.id,
1055
1423
  parent,
1056
1424
  kind,
1057
1425
  field: modifiers.field,
1426
+ ...(roles?.length ? { roles } : {}),
1058
1427
  invisible: compileModifier(modifiers.invisible),
1059
1428
  readonly: compileModifier(modifiers.readonly),
1060
1429
  required: compileModifier(modifiers.required),
@@ -1065,6 +1434,9 @@ function indexLayout(root) {
1065
1434
  for (const node of nodes) {
1066
1435
  if (node.type === 'field') {
1067
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 });
1068
1440
  }
1069
1441
  else if (node.type === 'button') {
1070
1442
  add(node, parent, 'button', { invisible: node.invisible });
@@ -1102,6 +1474,8 @@ function indexLayout(root) {
1102
1474
  }
1103
1475
  else if (root.type === 'sections') {
1104
1476
  walk(root.children, root.id);
1477
+ for (const button of root.footer ?? [])
1478
+ add(button, root.id, 'button', { invisible: button.invisible });
1105
1479
  }
1106
1480
  else if (root.type === 'list') {
1107
1481
  // A list holds no fields of its own record: only the buttons for what is selected.
@@ -1118,23 +1492,54 @@ function indexLayout(root) {
1118
1492
  shown('#title', root.title?.field);
1119
1493
  shown('#subtitle', root.title?.subtitleField);
1120
1494
  shown('#avatar', root.title?.avatarField);
1121
- shown('#statusbar', root.statusbar?.field);
1122
- 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 ?? [])])
1123
1499
  add(button, root.id, 'button', { invisible: button.invisible });
1124
1500
  for (const stat of root.statButtons ?? [])
1125
1501
  add(stat, root.id, 'stat', { invisible: stat.invisible });
1126
- if (root.ribbon)
1127
- add(root.ribbon, root.id, 'other', { invisible: root.ribbon.invisible });
1128
- 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 ?? []) {
1129
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
+ }
1130
1510
  for (const badge of root.badges ?? [])
1131
1511
  add(badge, root.id, 'other', { invisible: badge.invisible });
1132
- // Fields over and under the title are field nodes like any other.
1133
- 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);
1134
1514
  walk(root.children, root.id);
1135
1515
  if (root.sidePanel)
1136
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 });
1137
1529
  }
1138
1530
  return index;
1139
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
+ }
1140
1545
  //# sourceMappingURL=form.mjs.map