@fieldia/core 0.24.0 → 0.26.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 (169) hide show
  1. package/README.md +133 -1
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +983 -329
  4. package/src/index.d.mts +6 -2
  5. package/src/index.d.ts +6 -2
  6. package/src/index.js +16 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +7 -2
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/format/actions.d.mts +31 -1
  11. package/src/lib/format/actions.d.ts +31 -1
  12. package/src/lib/format/actions.js +1 -0
  13. package/src/lib/format/actions.js.map +1 -1
  14. package/src/lib/format/actions.mjs +1 -0
  15. package/src/lib/format/actions.mjs.map +1 -1
  16. package/src/lib/format/duplicate-kinds.d.mts +10 -0
  17. package/src/lib/format/duplicate-kinds.d.ts +10 -0
  18. package/src/lib/format/duplicate-kinds.js +14 -0
  19. package/src/lib/format/duplicate-kinds.js.map +1 -0
  20. package/src/lib/format/duplicate-kinds.mjs +10 -0
  21. package/src/lib/format/duplicate-kinds.mjs.map +1 -0
  22. package/src/lib/format/duplicates.d.mts +22 -0
  23. package/src/lib/format/duplicates.d.ts +22 -0
  24. package/src/lib/format/duplicates.js +24 -0
  25. package/src/lib/format/duplicates.js.map +1 -0
  26. package/src/lib/format/duplicates.mjs +21 -0
  27. package/src/lib/format/duplicates.mjs.map +1 -0
  28. package/src/lib/format/field.d.mts +236 -0
  29. package/src/lib/format/field.d.ts +236 -0
  30. package/src/lib/format/field.js +4 -3
  31. package/src/lib/format/field.js.map +1 -1
  32. package/src/lib/format/field.mjs +2 -1
  33. package/src/lib/format/field.mjs.map +1 -1
  34. package/src/lib/format/head-checks.d.mts +3 -1
  35. package/src/lib/format/head-checks.d.ts +3 -1
  36. package/src/lib/format/head-checks.js +25 -4
  37. package/src/lib/format/head-checks.js.map +1 -1
  38. package/src/lib/format/head-checks.mjs +24 -4
  39. package/src/lib/format/head-checks.mjs.map +1 -1
  40. package/src/lib/format/import-fields.d.mts +18 -0
  41. package/src/lib/format/import-fields.d.ts +18 -0
  42. package/src/lib/format/import-fields.js +26 -0
  43. package/src/lib/format/import-fields.js.map +1 -0
  44. package/src/lib/format/import-fields.mjs +21 -0
  45. package/src/lib/format/import-fields.mjs.map +1 -0
  46. package/src/lib/format/layout.d.mts +7391 -3264
  47. package/src/lib/format/layout.d.ts +7391 -3264
  48. package/src/lib/format/layout.js +100 -37
  49. package/src/lib/format/layout.js.map +1 -1
  50. package/src/lib/format/layout.mjs +99 -36
  51. package/src/lib/format/layout.mjs.map +1 -1
  52. package/src/lib/format/layout.types.d.mts +5 -2
  53. package/src/lib/format/layout.types.d.ts +5 -2
  54. package/src/lib/format/page.d.mts +1018 -118
  55. package/src/lib/format/page.d.ts +1018 -118
  56. package/src/lib/format/page.js +17 -0
  57. package/src/lib/format/page.js.map +1 -1
  58. package/src/lib/format/page.mjs +17 -0
  59. package/src/lib/format/page.mjs.map +1 -1
  60. package/src/lib/format/records.d.mts +23 -0
  61. package/src/lib/format/records.d.ts +23 -0
  62. package/src/lib/format/records.js +28 -0
  63. package/src/lib/format/records.js.map +1 -0
  64. package/src/lib/format/records.mjs +25 -0
  65. package/src/lib/format/records.mjs.map +1 -0
  66. package/src/lib/format/references.d.mts +31 -0
  67. package/src/lib/format/references.d.ts +31 -0
  68. package/src/lib/format/references.js +185 -24
  69. package/src/lib/format/references.js.map +1 -1
  70. package/src/lib/format/references.mjs +185 -24
  71. package/src/lib/format/references.mjs.map +1 -1
  72. package/src/lib/format/translate.js +12 -6
  73. package/src/lib/format/translate.js.map +1 -1
  74. package/src/lib/format/translate.mjs +12 -6
  75. package/src/lib/format/translate.mjs.map +1 -1
  76. package/src/lib/format/upgrade.d.mts +8 -3
  77. package/src/lib/format/upgrade.d.ts +8 -3
  78. package/src/lib/format/upgrade.js +8 -3
  79. package/src/lib/format/upgrade.js.map +1 -1
  80. package/src/lib/format/upgrade.mjs +8 -3
  81. package/src/lib/format/upgrade.mjs.map +1 -1
  82. package/src/lib/format/version.d.mts +2 -2
  83. package/src/lib/format/version.d.ts +2 -2
  84. package/src/lib/format/version.js +2 -2
  85. package/src/lib/format/version.js.map +1 -1
  86. package/src/lib/format/version.mjs +2 -2
  87. package/src/lib/format/version.mjs.map +1 -1
  88. package/src/lib/record/check.js +2 -2
  89. package/src/lib/record/check.js.map +1 -1
  90. package/src/lib/record/check.mjs +3 -3
  91. package/src/lib/record/check.mjs.map +1 -1
  92. package/src/lib/record/data-source.d.mts +121 -0
  93. package/src/lib/record/data-source.d.ts +121 -0
  94. package/src/lib/record/data-source.js +16 -0
  95. package/src/lib/record/data-source.js.map +1 -1
  96. package/src/lib/record/data-source.mjs +15 -0
  97. package/src/lib/record/data-source.mjs.map +1 -1
  98. package/src/lib/record/form.d.mts +51 -3
  99. package/src/lib/record/form.d.ts +51 -3
  100. package/src/lib/record/form.js +78 -17
  101. package/src/lib/record/form.js.map +1 -1
  102. package/src/lib/record/form.mjs +79 -18
  103. package/src/lib/record/form.mjs.map +1 -1
  104. package/src/lib/record/inputs.js +8 -8
  105. package/src/lib/record/inputs.js.map +1 -1
  106. package/src/lib/record/inputs.mjs +9 -9
  107. package/src/lib/record/inputs.mjs.map +1 -1
  108. package/src/lib/record/limits.js +1 -1
  109. package/src/lib/record/limits.js.map +1 -1
  110. package/src/lib/record/limits.mjs +2 -2
  111. package/src/lib/record/limits.mjs.map +1 -1
  112. package/src/lib/record/locales/de.js +2 -0
  113. package/src/lib/record/locales/de.js.map +1 -1
  114. package/src/lib/record/locales/de.mjs +2 -0
  115. package/src/lib/record/locales/de.mjs.map +1 -1
  116. package/src/lib/record/memory-data-source.d.mts +12 -5
  117. package/src/lib/record/memory-data-source.d.ts +12 -5
  118. package/src/lib/record/memory-data-source.js +85 -1
  119. package/src/lib/record/memory-data-source.js.map +1 -1
  120. package/src/lib/record/memory-data-source.mjs +86 -2
  121. package/src/lib/record/memory-data-source.mjs.map +1 -1
  122. package/src/lib/record/memory-duplicates.d.mts +19 -0
  123. package/src/lib/record/memory-duplicates.d.ts +19 -0
  124. package/src/lib/record/memory-duplicates.js +78 -0
  125. package/src/lib/record/memory-duplicates.js.map +1 -0
  126. package/src/lib/record/memory-duplicates.mjs +74 -0
  127. package/src/lib/record/memory-duplicates.mjs.map +1 -0
  128. package/src/lib/record/messages.d.mts +24 -0
  129. package/src/lib/record/messages.d.ts +24 -0
  130. package/src/lib/record/messages.js +25 -0
  131. package/src/lib/record/messages.js.map +1 -1
  132. package/src/lib/record/messages.mjs +24 -0
  133. package/src/lib/record/messages.mjs.map +1 -1
  134. package/src/lib/record/record-words.d.mts +19 -0
  135. package/src/lib/record/record-words.d.ts +19 -0
  136. package/src/lib/record/record-words.js +60 -0
  137. package/src/lib/record/record-words.js.map +1 -0
  138. package/src/lib/record/record-words.mjs +56 -0
  139. package/src/lib/record/record-words.mjs.map +1 -0
  140. package/src/lib/record/rules.js +1 -1
  141. package/src/lib/record/rules.js.map +1 -1
  142. package/src/lib/record/rules.mjs +2 -2
  143. package/src/lib/record/rules.mjs.map +1 -1
  144. package/src/lib/record/run.d.mts +39 -1
  145. package/src/lib/record/run.d.ts +39 -1
  146. package/src/lib/record/run.js +15 -1
  147. package/src/lib/record/run.js.map +1 -1
  148. package/src/lib/record/run.mjs +15 -1
  149. package/src/lib/record/run.mjs.map +1 -1
  150. package/src/lib/record/search.js +2 -1
  151. package/src/lib/record/search.js.map +1 -1
  152. package/src/lib/record/search.mjs +2 -1
  153. package/src/lib/record/search.mjs.map +1 -1
  154. package/src/lib/record/structures.js +3 -3
  155. package/src/lib/record/structures.js.map +1 -1
  156. package/src/lib/record/structures.mjs +4 -4
  157. package/src/lib/record/structures.mjs.map +1 -1
  158. package/src/lib/record/waits.d.mts +32 -0
  159. package/src/lib/record/waits.d.ts +32 -0
  160. package/src/lib/record/waits.js +89 -0
  161. package/src/lib/record/waits.js.map +1 -0
  162. package/src/lib/record/waits.mjs +85 -0
  163. package/src/lib/record/waits.mjs.map +1 -0
  164. package/src/runtime.d.mts +13 -7
  165. package/src/runtime.d.ts +13 -7
  166. package/src/runtime.js +13 -3
  167. package/src/runtime.js.map +1 -1
  168. package/src/runtime.mjs +4 -1
  169. package/src/runtime.mjs.map +1 -1
@@ -12,8 +12,10 @@ import { firstValues, readLooseMap, readValueMap } from './value-map.mjs';
12
12
  import { resolveFilter } from './filter.mjs';
13
13
  import { rolesAllow } from './roles.mjs';
14
14
  import { compileFieldTone, compileTable } from './cells.mjs';
15
- import { fill, MESSAGES } from './messages.mjs';
15
+ import { fillMessage, MESSAGES } from './messages.mjs';
16
+ import { recordSentence } from './record-words.mjs';
16
17
  import { loadedRecordOf, saveProblemOf, } from './data-source.mjs';
18
+ import { waitingOn, workedOutFrom } from './waits.mjs';
17
19
  import { createUploads, fileBytesIn, uploadGoing, withoutFileBytes } from './uploads.mjs';
18
20
  import { hostScheduler } from './scheduler.mjs';
19
21
  import { createMoments, createRunner } from './run.mjs';
@@ -74,7 +76,10 @@ function innerForm(options, within, place, outer) {
74
76
  // Its time zone written into each date and time field naming none, for whatever reads one from its field alone.
75
77
  const page = zonedPage(options.page);
76
78
  const scheduler = options.scheduler ?? hostScheduler;
77
- const messages = options.messages ?? MESSAGES.en;
79
+ // A field's label small inside a message's sentence where the page asks (`lowerLabels`).
80
+ const messages = page.lowerLabels ? { ...(options.messages ?? MESSAGES.en), lowerLabels: true } : (options.messages ?? MESSAGES.en);
81
+ /** One of the form's sentences in the words the page names its records by (`records`), else as the language says it plainly. */
82
+ const named = (noun, plain) => recordSentence(page.records, noun, plain, messages.locale);
78
83
  const index = indexLayout(page.layout);
79
84
  /** Each field node's answer rules, read once. */
80
85
  const nodeRules = new Map();
@@ -158,6 +163,8 @@ function innerForm(options, within, place, outer) {
158
163
  const editingNow = () => editing && !(started && state.can?.edit === false);
159
164
  const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing: editingNow(), context: options.context, parent: parentContext });
160
165
  const computed = compileComputed(page, today, about);
166
+ /** Each value worked out from others — by the page, or by the app as the lines change — and what it waits for. */
167
+ const workedOut = workedOutFrom(page, !!options.dataSource?.onchange && page.data.kind === 'record', !!options.parent);
161
168
  const setWhen = compileSetWhen(page, today, about);
162
169
  /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
163
170
  const parts = new Map();
@@ -317,6 +324,13 @@ function innerForm(options, within, place, outer) {
317
324
  * for is still the one shown, as it was. A save's answer, a call's, an onchange's, a name looked up, a line's
318
325
  * dialog, a record step's — none is laid on another record, nor over the values the person's Discard let go.
319
326
  */
327
+ /** The data source with a member a record's page asks of it, and the page's model: refused where either is missing. */
328
+ function recordSource(member) {
329
+ const source = options.dataSource;
330
+ if (page.data.kind !== 'record' || !source?.[member])
331
+ throw new Error(`The data source has no ${member}()`);
332
+ return [source, page.data.model];
333
+ }
320
334
  function shownNow() {
321
335
  const at = generation;
322
336
  const reset = resets;
@@ -848,7 +862,7 @@ function innerForm(options, within, place, outer) {
848
862
  return undefined;
849
863
  const at = (v) => (def.type === 'datetime' ? Date.parse(v) : Date.parse(v.slice(0, 10)));
850
864
  if (at(value) < at(start))
851
- return fill(messages.endBeforeStart, { end: def.label, start: page.fields[node.field].label });
865
+ return fillMessage(messages, messages.endBeforeStart, { end: def.label, start: page.fields[node.field].label });
852
866
  return undefined;
853
867
  }
854
868
  /** Errors for the visible fields under `root`, without publishing them. */
@@ -1237,13 +1251,13 @@ function innerForm(options, within, place, outer) {
1237
1251
  async function savingNow(chain) {
1238
1252
  // A record its source does not let this person change is refused here, in its words or the form's: no source is asked.
1239
1253
  if (page.data.kind === 'record' && state.can?.edit === false) {
1240
- const problem = { kind: 'rule', message: state.can.reason ?? messages.mayNotEdit };
1254
+ const problem = { kind: 'rule', message: state.can.reason ?? named(messages.mayNotEditNoun, messages.mayNotEdit) };
1241
1255
  set({ status: 'error', error: problem.message, saveProblem: problem, conflict: null });
1242
1256
  return { reason: 'save', message: problem.message };
1243
1257
  }
1244
1258
  // Asked for the record shown now: another shown while it waits below, it saves nothing of that one, nor says anything there.
1245
1259
  const asked = generation;
1246
- const elsewhere = () => ({ reason: 'save', message: messages.recordReplaced });
1260
+ const elsewhere = () => ({ reason: 'save', message: named(messages.recordReplacedNoun, messages.recordReplaced) });
1247
1261
  // What is still typed in a box is read first: it is what is saved, or why nothing is. A name still being looked
1248
1262
  // up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
1249
1263
  const waiting = readTyped();
@@ -1443,6 +1457,16 @@ function innerForm(options, within, place, outer) {
1443
1457
  changed(name, by, { keep });
1444
1458
  }
1445
1459
  /** A line added: the person's, theirs; a step's, none of theirs, and the lines they changed stay theirs. */
1460
+ /** What a line added now starts with: each line field's default, then what is worked out from its defaultFrom, then the record's lineDefaults. */
1461
+ function newLineValues(field) {
1462
+ const def = lineField(field);
1463
+ const lineFields = def.fields;
1464
+ const start = initialValues(lineFields, today());
1465
+ Object.assign(start, firstValues(lineFields, lineScope(field, start)));
1466
+ if (def.lineDefaults)
1467
+ Object.assign(start, readValueMap(def.lineDefaults, lineFields, recordScope()));
1468
+ return start;
1469
+ }
1446
1470
  function addLine(field, values, by) {
1447
1471
  const def = lineField(field);
1448
1472
  const current = state.values[field] ?? [];
@@ -1450,13 +1474,7 @@ function innerForm(options, within, place, outer) {
1450
1474
  let key = `new-${++lineKeys}`;
1451
1475
  while (current.some((line) => line.key === key))
1452
1476
  key = `new-${++lineKeys}`;
1453
- // Each line field's default, then what is worked out from its defaultFrom, then the record's lineDefaults, then what the line is given.
1454
- const lineFields = def.fields;
1455
- const start = initialValues(lineFields, today());
1456
- Object.assign(start, firstValues(lineFields, lineScope(field, start)));
1457
- if (def.lineDefaults)
1458
- Object.assign(start, readValueMap(def.lineDefaults, lineFields, recordScope()));
1459
- const line = { key, values: { ...start, ...structuredCopy(values) } };
1477
+ const line = { key, values: { ...newLineValues(field), ...structuredCopy(values) } };
1460
1478
  // A new line comes last, so it is numbered after the last.
1461
1479
  if (def.sequenceField && line.values[def.sequenceField] == null) {
1462
1480
  const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
@@ -1727,7 +1745,7 @@ function innerForm(options, within, place, outer) {
1727
1745
  return going;
1728
1746
  }
1729
1747
  async function askToLetGo() {
1730
- const message = messages.discardChanges;
1748
+ const message = named(messages.discardChangesNoun, messages.discardChanges);
1731
1749
  const host = options.host;
1732
1750
  let yes = false;
1733
1751
  try {
@@ -1786,7 +1804,7 @@ function innerForm(options, within, place, outer) {
1786
1804
  const go = letGo({}, chain);
1787
1805
  return go === true ? null : go.then((yes) => (yes ? null : kept()));
1788
1806
  }
1789
- const kept = () => ({ reason: 'no', message: messages.discardChanges });
1807
+ const kept = () => ({ reason: 'no', message: named(messages.discardChangesNoun, messages.discardChanges) });
1790
1808
  /**
1791
1809
  * A step's own move — the record loaded again, or another shown — once the check let what is shown go: kept, the
1792
1810
  * person kept their work; replaced, another record was shown before it ended (the run's steps are not for that one).
@@ -1823,7 +1841,7 @@ function innerForm(options, within, place, outer) {
1823
1841
  }
1824
1842
  /** Why a run stops whose record was replaced while it went on — quietly, when the form was closed. */
1825
1843
  function replaced() {
1826
- return disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced };
1844
+ return disposed ? { reason: 'closed' } : { reason: 'cannot', message: named(messages.recordReplacedNoun, messages.recordReplaced) };
1827
1845
  }
1828
1846
  /** The record a record step asks the data source about. */
1829
1847
  const record = () => ({ model: page.data.model, id: state.recordId, fields });
@@ -1933,7 +1951,7 @@ function innerForm(options, within, place, outer) {
1933
1951
  },
1934
1952
  post,
1935
1953
  wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
1936
- barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
1954
+ barred: (right) => (state.can?.[right] === false ? state.can.reason ?? named(messages[`${MAY_NOT[right]}Noun`], messages[MAY_NOT[right]]) : null),
1937
1955
  });
1938
1956
  // A moment's run is on the record it was set off on: one set off again while its run goes, and run after it, is let
1939
1957
  // go when another record is shown by then (its steps were for that one).
@@ -1961,7 +1979,7 @@ function innerForm(options, within, place, outer) {
1961
1979
  const builtin = 'builtin' in source && !list.length ? source.builtin : undefined;
1962
1980
  if (builtin)
1963
1981
  list.push({ do: builtin });
1964
- const asked = builtin === 'archive' ? messages.archiveConfirm : builtin === 'delete' ? messages.deleteConfirm : undefined;
1982
+ const asked = builtin === 'archive' ? named(messages.archiveConfirmNoun, messages.archiveConfirm) : builtin === 'delete' ? named(messages.deleteConfirmNoun, messages.deleteConfirm) : undefined;
1965
1983
  const confirm = 'confirm' in source && source.confirm !== undefined ? source.confirm : asked;
1966
1984
  // On the record it was pressed on: another shown while its question is asked, it does nothing to that one.
1967
1985
  scope.generation ??= generation;
@@ -2020,6 +2038,13 @@ function innerForm(options, within, place, outer) {
2020
2038
  leave: (how) => Promise.resolve(letGo(how)),
2021
2039
  leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
2022
2040
  recordShown: shownNow,
2041
+ relatedFilter(id) {
2042
+ const node = index.get(id)?.source;
2043
+ if (node?.type !== 'related' || state.recordId == null)
2044
+ return null;
2045
+ const related = node;
2046
+ return [{ field: related.field, op: '=', value: state.recordId }, ...(related.filter ? resolveFilter(related.filter, context()) : [])];
2047
+ },
2023
2048
  post,
2024
2049
  setEditing(next) {
2025
2050
  if (next === editing)
@@ -2032,6 +2057,10 @@ function innerForm(options, within, place, outer) {
2032
2057
  loadChoices,
2033
2058
  loadDefinitions,
2034
2059
  addDefinition,
2060
+ waitsFor(name) {
2061
+ const from = workedOut.get(name);
2062
+ return from ? waitingOn(page, from, state.values, state.unread) : null;
2063
+ },
2035
2064
  problem(name) {
2036
2065
  fieldDef(name);
2037
2066
  // Only a field someone can see is asked anything, and required wherever it is shown so.
@@ -2110,6 +2139,7 @@ function innerForm(options, within, place, outer) {
2110
2139
  save: () => track(save()),
2111
2140
  canUpload: () => typeof options.dataSource?.upload === 'function',
2112
2141
  today,
2142
+ recordReads: (id, values, fields) => recordContext(values, fields ?? page.fields, { recordId: id, user: options.user, editing: false, context: options.context }),
2113
2143
  canFindSlots: () => typeof options.dataSource?.slots === 'function',
2114
2144
  findSlots(field, ask) {
2115
2145
  const source = options.dataSource;
@@ -2130,6 +2160,28 @@ function innerForm(options, within, place, outer) {
2130
2160
  values: structuredCopy(state.values),
2131
2161
  });
2132
2162
  },
2163
+ canFindDuplicates: () => !!(page.data.kind === 'record' && page.duplicates?.fields?.length && options.dataSource?.duplicates),
2164
+ async findDuplicates() {
2165
+ const [source, model] = recordSource('duplicates');
2166
+ const fields = (page.duplicates?.fields ?? []).filter((name) => page.fields[name] && !isEmpty(page.fields[name], state.values[name]));
2167
+ const recordId = state.recordId;
2168
+ // Nothing typed in any of them: nothing asked.
2169
+ if (!fields.length)
2170
+ return [];
2171
+ const { matches } = await source.duplicates({ model, recordId, values: structuredCopy(state.values), fields });
2172
+ // Never the record itself, whatever the source says; the likest first where it scores them.
2173
+ const others = (matches ?? []).filter((match) => match && String(match.id) !== String(recordId));
2174
+ return others.every((match) => typeof match.score === 'number') ? others.sort((a, b) => b.score - a.score) : others;
2175
+ },
2176
+ canMerge: () => !!(page.data.kind === 'record' && options.dataSource?.merge && options.dataSource.load),
2177
+ async readRecord(id) {
2178
+ const [source, model] = recordSource('load');
2179
+ return loadedRecordOf(await source.load({ model, id, fields: page.fields })).values;
2180
+ },
2181
+ async merge(request) {
2182
+ const [source, model] = recordSource('merge');
2183
+ return source.merge({ model, keepId: request.keepId, mergeIds: [...request.mergeIds], values: structuredCopy(request.values), fields: page.fields });
2184
+ },
2133
2185
  upload: (field, files, how = {}) => uploads.start(field, files, how),
2134
2186
  uploading: (field) => state.uploads.filter((upload) => upload.field === field && !upload.line),
2135
2187
  cancelUpload(id) {
@@ -2242,7 +2294,7 @@ function innerForm(options, within, place, outer) {
2242
2294
  const sub = def.fields[subfield];
2243
2295
  if (!sub)
2244
2296
  throw new Error(`The lines of "${field}" have no field "${subfield}"`);
2245
- const line = (state.values[field] ?? []).find((l) => l.key === key);
2297
+ const line = key === null ? { values: newLineValues(field), id: null } : (state.values[field] ?? []).find((l) => l.key === key);
2246
2298
  if (!line)
2247
2299
  throw new Error(`"${field}" has no line "${key}"`);
2248
2300
  return runSearch(sub, subfield, lineContext(line.values, def.fields, context(), line.id ?? null), query, limit, options);
@@ -2806,6 +2858,12 @@ function indexLayout(root) {
2806
2858
  else if (node.type === 'form') {
2807
2859
  add(node, parent, 'form', { invisible: node.invisible, readonly: node.readonly });
2808
2860
  }
2861
+ else if (node.type === 'related') {
2862
+ add(node, parent, 'other', { invisible: node.invisible });
2863
+ // A row's buttons: their roles here, hidden with the list, their condition read by it on each row's record.
2864
+ for (const button of node.rowButtons ?? [])
2865
+ add(button, node.id, 'button', {});
2866
+ }
2809
2867
  else {
2810
2868
  add(node, parent, 'other', { invisible: node.invisible });
2811
2869
  }
@@ -2832,6 +2890,9 @@ function indexLayout(root) {
2832
2890
  // A list holds no fields of its own record: only the buttons for what is selected.
2833
2891
  for (const button of root.actions ?? [])
2834
2892
  add(button, root.id, 'button', { invisible: button.invisible });
2893
+ // A row's buttons: their roles here, their condition read by the list on each row's record, which the form does not hold.
2894
+ for (const button of root.rowButtons ?? [])
2895
+ add(button, root.id, 'button', {});
2835
2896
  }
2836
2897
  else {
2837
2898
  // The title and statusbar show fields too, so they are validated like