@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
@@ -4,7 +4,8 @@ import type { Page } from '../format/page.mjs';
4
4
  import type { JsonValue } from '../format/json.mjs';
5
5
  import { type FieldTone, type LineState } from './cells.mjs';
6
6
  import { type Messages } from './messages.mjs';
7
- import { type DataSource, type RecordChanges, type RecordPermissions, type RecordVersion, type SaveProblem, type BotReport, type SlotsResult, type UploadFile } from './data-source.mjs';
7
+ import { type DataSource, type RecordChanges, type RecordPermissions, type RecordVersion, type ResolvedFilter, type SaveProblem, type BotReport, type SlotsResult, type UploadFile, type DuplicateMatch, type MergeResult } from './data-source.mjs';
8
+ import { type WaitsFor } from './waits.mjs';
8
9
  import { type FileUpload, type UploadHow } from './uploads.mjs';
9
10
  import { type Scheduler } from './scheduler.mjs';
10
11
  import { type ActionHost, type ActionRequest, type ActionResult, type OnAction, type PostedMessage, type RunContext, type RunResult } from './run.mjs';
@@ -327,6 +328,14 @@ export interface Form {
327
328
  fieldReadonly(name: string): boolean;
328
329
  /** What a field would be told if the form were checked now, or null when it passes. Nothing is shown. */
329
330
  problem(field: string): string | null;
331
+ /**
332
+ * What a value worked out from others waits for while one of them holds text that does not read (`unread`): a
333
+ * total of the page's own (`compute`) over a table whose line says "abc" in its Quantity, or an amount the app
334
+ * works out as the lines change (read-only, from a source with an onchange) — the line, as people count them, and
335
+ * its cell. Shown, it is a dash said in words, as the table's own total waits: never an amount worked out from the
336
+ * value that text replaced. Null when it shows its value.
337
+ */
338
+ waitsFor(field: string): WaitsFor | null;
330
339
  /**
331
340
  * Keep text a person left in a box that does not read (`FormState.unread`), by its path — `field`, or a line's
332
341
  * `field.lineKey.subfield` — with why in words; null once it reads, or is let go. Nothing is shown until the box
@@ -424,6 +433,12 @@ export interface Form {
424
433
  * (docs/plans/wave1-contract.md, section 10, part 2).
425
434
  */
426
435
  recordShown(): () => boolean;
436
+ /**
437
+ * What a related list (a `related` part, by its id) asks the data source's `list` for, on the record shown: the
438
+ * records whose link is this record, and that its own `filter` lets through, each `valueFrom` read from this record.
439
+ * Null before the record's first save — nothing points at it yet — and for no related list of that id.
440
+ */
441
+ relatedFilter(id: string): ResolvedFilter[] | null;
427
442
  /**
428
443
  * Post words in the record's conversation, as a `post` step does: through
429
444
  * the form's host when it has a way to, else told to the form's own
@@ -452,8 +467,12 @@ export interface Form {
452
467
  * exactly the query (`SearchRequest.exact`).
453
468
  */
454
469
  search(field: string, query: string, limit?: number, options?: SearchOptions): Promise<RelatedRecord[]>;
455
- /** The same, for a relation inside a one2many line, filtered by that line's values — or structured data of a line naming records of a model (`options.model`). */
456
- searchLine(field: string, key: string, subfield: string, query: string, limit?: number, options?: SearchOptions): Promise<RelatedRecord[]>;
470
+ /**
471
+ * The same, for a relation inside a one2many line, filtered by that line's values — or structured data of a line
472
+ * naming records of a model (`options.model`). `key` null: a line not added yet, by the values one added now would
473
+ * start with (a row pasted past the last line, its names read before the line is made).
474
+ */
475
+ searchLine(field: string, key: string | null, subfield: string, query: string, limit?: number, options?: SearchOptions): Promise<RelatedRecord[]>;
457
476
  /**
458
477
  * The values of records a many2many links to, for a table of them: `fields`
459
478
  * of the records with these ids, in the order asked, read with the data
@@ -532,6 +551,13 @@ export interface Form {
532
551
  canUpload(): boolean;
533
552
  /** Today, as 2026-10-15, on the clock of the page's time zone — the browser's own where it names none — by the form's clock (`now`). */
534
553
  today(): string;
554
+ /**
555
+ * A record of the page's model as conditions read it, the form's own way: its values (a link as its id), its `id`,
556
+ * the person using the form (`user`), the app's `context`, and `editing` false — for what reads conditions on records
557
+ * the form does not hold, such as a list's rows (their `rowTones`, `rowBold` and `rowButtons`). Of another model — a
558
+ * related list's rows — with that model's `fields` (the related list's own).
559
+ */
560
+ recordReads(id: RecordId | null, values: Values, fields?: Record<string, Field | LineField>): Record<string, unknown>;
535
561
  /** Whether a date and time drawn as booking slots can ask for its free times: the data source has `slots`. */
536
562
  canFindSlots(): boolean;
537
563
  /**
@@ -546,6 +572,28 @@ export interface Form {
546
572
  length?: number;
547
573
  between?: number;
548
574
  }): Promise<SlotsResult>;
575
+ /** Whether the page asks for possible duplicates: a record's page naming fields in `duplicates`, its source with `duplicates`. */
576
+ canFindDuplicates(): boolean;
577
+ /**
578
+ * Records like the one shown, asked of the data source's `duplicates` with the fields the page names that hold a
579
+ * value now — none, and nothing is asked — and every value of the form: the matches, the likest first where the
580
+ * source scores them, never the record itself. Rejects where the source has no `duplicates`. Its answer is for the
581
+ * record shown as it was asked (`recordShown`).
582
+ */
583
+ findDuplicates(): Promise<DuplicateMatch[]>;
584
+ /** Whether records of the page's model can be merged: its source has `merge`, and `load` to read the others. */
585
+ canMerge(): boolean;
586
+ /** Another record of the page's model, as its source loads it, its values alone: the form is left as it is. For a merge's screen. */
587
+ readRecord(id: RecordId): Promise<Values>;
588
+ /**
589
+ * Merge records of the page's model into one through the source's `merge`: `keepId` kept with `values`, `mergeIds`
590
+ * gone. The form is left as it is: show the record kept (`openRecord`, `load`) once it is done.
591
+ */
592
+ merge(request: {
593
+ keepId: RecordId;
594
+ mergeIds: RecordId[];
595
+ values: Values;
596
+ }): Promise<MergeResult | void>;
549
597
  /**
550
598
  * Store files picked for a file or picture field — or a field on a table's
551
599
  * line (`how.line`) — through the data source's `upload`, each as soon as
@@ -4,7 +4,8 @@ import type { Page } from '../format/page';
4
4
  import type { JsonValue } from '../format/json';
5
5
  import { type FieldTone, type LineState } from './cells';
6
6
  import { type Messages } from './messages';
7
- import { type DataSource, type RecordChanges, type RecordPermissions, type RecordVersion, type SaveProblem, type BotReport, type SlotsResult, type UploadFile } from './data-source';
7
+ import { type DataSource, type RecordChanges, type RecordPermissions, type RecordVersion, type ResolvedFilter, type SaveProblem, type BotReport, type SlotsResult, type UploadFile, type DuplicateMatch, type MergeResult } from './data-source';
8
+ import { type WaitsFor } from './waits';
8
9
  import { type FileUpload, type UploadHow } from './uploads';
9
10
  import { type Scheduler } from './scheduler';
10
11
  import { type ActionHost, type ActionRequest, type ActionResult, type OnAction, type PostedMessage, type RunContext, type RunResult } from './run';
@@ -327,6 +328,14 @@ export interface Form {
327
328
  fieldReadonly(name: string): boolean;
328
329
  /** What a field would be told if the form were checked now, or null when it passes. Nothing is shown. */
329
330
  problem(field: string): string | null;
331
+ /**
332
+ * What a value worked out from others waits for while one of them holds text that does not read (`unread`): a
333
+ * total of the page's own (`compute`) over a table whose line says "abc" in its Quantity, or an amount the app
334
+ * works out as the lines change (read-only, from a source with an onchange) — the line, as people count them, and
335
+ * its cell. Shown, it is a dash said in words, as the table's own total waits: never an amount worked out from the
336
+ * value that text replaced. Null when it shows its value.
337
+ */
338
+ waitsFor(field: string): WaitsFor | null;
330
339
  /**
331
340
  * Keep text a person left in a box that does not read (`FormState.unread`), by its path — `field`, or a line's
332
341
  * `field.lineKey.subfield` — with why in words; null once it reads, or is let go. Nothing is shown until the box
@@ -424,6 +433,12 @@ export interface Form {
424
433
  * (docs/plans/wave1-contract.md, section 10, part 2).
425
434
  */
426
435
  recordShown(): () => boolean;
436
+ /**
437
+ * What a related list (a `related` part, by its id) asks the data source's `list` for, on the record shown: the
438
+ * records whose link is this record, and that its own `filter` lets through, each `valueFrom` read from this record.
439
+ * Null before the record's first save — nothing points at it yet — and for no related list of that id.
440
+ */
441
+ relatedFilter(id: string): ResolvedFilter[] | null;
427
442
  /**
428
443
  * Post words in the record's conversation, as a `post` step does: through
429
444
  * the form's host when it has a way to, else told to the form's own
@@ -452,8 +467,12 @@ export interface Form {
452
467
  * exactly the query (`SearchRequest.exact`).
453
468
  */
454
469
  search(field: string, query: string, limit?: number, options?: SearchOptions): Promise<RelatedRecord[]>;
455
- /** The same, for a relation inside a one2many line, filtered by that line's values — or structured data of a line naming records of a model (`options.model`). */
456
- searchLine(field: string, key: string, subfield: string, query: string, limit?: number, options?: SearchOptions): Promise<RelatedRecord[]>;
470
+ /**
471
+ * The same, for a relation inside a one2many line, filtered by that line's values — or structured data of a line
472
+ * naming records of a model (`options.model`). `key` null: a line not added yet, by the values one added now would
473
+ * start with (a row pasted past the last line, its names read before the line is made).
474
+ */
475
+ searchLine(field: string, key: string | null, subfield: string, query: string, limit?: number, options?: SearchOptions): Promise<RelatedRecord[]>;
457
476
  /**
458
477
  * The values of records a many2many links to, for a table of them: `fields`
459
478
  * of the records with these ids, in the order asked, read with the data
@@ -532,6 +551,13 @@ export interface Form {
532
551
  canUpload(): boolean;
533
552
  /** Today, as 2026-10-15, on the clock of the page's time zone — the browser's own where it names none — by the form's clock (`now`). */
534
553
  today(): string;
554
+ /**
555
+ * A record of the page's model as conditions read it, the form's own way: its values (a link as its id), its `id`,
556
+ * the person using the form (`user`), the app's `context`, and `editing` false — for what reads conditions on records
557
+ * the form does not hold, such as a list's rows (their `rowTones`, `rowBold` and `rowButtons`). Of another model — a
558
+ * related list's rows — with that model's `fields` (the related list's own).
559
+ */
560
+ recordReads(id: RecordId | null, values: Values, fields?: Record<string, Field | LineField>): Record<string, unknown>;
535
561
  /** Whether a date and time drawn as booking slots can ask for its free times: the data source has `slots`. */
536
562
  canFindSlots(): boolean;
537
563
  /**
@@ -546,6 +572,28 @@ export interface Form {
546
572
  length?: number;
547
573
  between?: number;
548
574
  }): Promise<SlotsResult>;
575
+ /** Whether the page asks for possible duplicates: a record's page naming fields in `duplicates`, its source with `duplicates`. */
576
+ canFindDuplicates(): boolean;
577
+ /**
578
+ * Records like the one shown, asked of the data source's `duplicates` with the fields the page names that hold a
579
+ * value now — none, and nothing is asked — and every value of the form: the matches, the likest first where the
580
+ * source scores them, never the record itself. Rejects where the source has no `duplicates`. Its answer is for the
581
+ * record shown as it was asked (`recordShown`).
582
+ */
583
+ findDuplicates(): Promise<DuplicateMatch[]>;
584
+ /** Whether records of the page's model can be merged: its source has `merge`, and `load` to read the others. */
585
+ canMerge(): boolean;
586
+ /** Another record of the page's model, as its source loads it, its values alone: the form is left as it is. For a merge's screen. */
587
+ readRecord(id: RecordId): Promise<Values>;
588
+ /**
589
+ * Merge records of the page's model into one through the source's `merge`: `keepId` kept with `values`, `mergeIds`
590
+ * gone. The form is left as it is: show the record kept (`openRecord`, `load`) once it is done.
591
+ */
592
+ merge(request: {
593
+ keepId: RecordId;
594
+ mergeIds: RecordId[];
595
+ values: Values;
596
+ }): Promise<MergeResult | void>;
549
597
  /**
550
598
  * Store files picked for a file or picture field — or a field on a table's
551
599
  * line (`how.line`) — through the data source's `upload`, each as soon as
@@ -19,7 +19,9 @@ const filter_1 = require("./filter");
19
19
  const roles_1 = require("./roles");
20
20
  const cells_1 = require("./cells");
21
21
  const messages_1 = require("./messages");
22
+ const record_words_1 = require("./record-words");
22
23
  const data_source_1 = require("./data-source");
24
+ const waits_1 = require("./waits");
23
25
  const uploads_1 = require("./uploads");
24
26
  const scheduler_1 = require("./scheduler");
25
27
  const run_1 = require("./run");
@@ -80,7 +82,10 @@ function innerForm(options, within, place, outer) {
80
82
  // Its time zone written into each date and time field naming none, for whatever reads one from its field alone.
81
83
  const page = (0, zoned_1.zonedPage)(options.page);
82
84
  const scheduler = options.scheduler ?? scheduler_1.hostScheduler;
83
- const messages = options.messages ?? messages_1.MESSAGES.en;
85
+ // A field's label small inside a message's sentence where the page asks (`lowerLabels`).
86
+ const messages = page.lowerLabels ? { ...(options.messages ?? messages_1.MESSAGES.en), lowerLabels: true } : (options.messages ?? messages_1.MESSAGES.en);
87
+ /** One of the form's sentences in the words the page names its records by (`records`), else as the language says it plainly. */
88
+ const named = (noun, plain) => (0, record_words_1.recordSentence)(page.records, noun, plain, messages.locale);
84
89
  const index = indexLayout(page.layout);
85
90
  /** Each field node's answer rules, read once. */
86
91
  const nodeRules = new Map();
@@ -164,6 +169,8 @@ function innerForm(options, within, place, outer) {
164
169
  const editingNow = () => editing && !(started && state.can?.edit === false);
165
170
  const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing: editingNow(), context: options.context, parent: parentContext });
166
171
  const computed = (0, compute_1.compileComputed)(page, today, about);
172
+ /** Each value worked out from others — by the page, or by the app as the lines change — and what it waits for. */
173
+ const workedOut = (0, waits_1.workedOutFrom)(page, !!options.dataSource?.onchange && page.data.kind === 'record', !!options.parent);
167
174
  const setWhen = (0, set_when_1.compileSetWhen)(page, today, about);
168
175
  /** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
169
176
  const parts = new Map();
@@ -323,6 +330,13 @@ function innerForm(options, within, place, outer) {
323
330
  * 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
324
331
  * dialog, a record step's — none is laid on another record, nor over the values the person's Discard let go.
325
332
  */
333
+ /** The data source with a member a record's page asks of it, and the page's model: refused where either is missing. */
334
+ function recordSource(member) {
335
+ const source = options.dataSource;
336
+ if (page.data.kind !== 'record' || !source?.[member])
337
+ throw new Error(`The data source has no ${member}()`);
338
+ return [source, page.data.model];
339
+ }
326
340
  function shownNow() {
327
341
  const at = generation;
328
342
  const reset = resets;
@@ -854,7 +868,7 @@ function innerForm(options, within, place, outer) {
854
868
  return undefined;
855
869
  const at = (v) => (def.type === 'datetime' ? Date.parse(v) : Date.parse(v.slice(0, 10)));
856
870
  if (at(value) < at(start))
857
- return (0, messages_1.fill)(messages.endBeforeStart, { end: def.label, start: page.fields[node.field].label });
871
+ return (0, messages_1.fillMessage)(messages, messages.endBeforeStart, { end: def.label, start: page.fields[node.field].label });
858
872
  return undefined;
859
873
  }
860
874
  /** Errors for the visible fields under `root`, without publishing them. */
@@ -1243,13 +1257,13 @@ function innerForm(options, within, place, outer) {
1243
1257
  async function savingNow(chain) {
1244
1258
  // A record its source does not let this person change is refused here, in its words or the form's: no source is asked.
1245
1259
  if (page.data.kind === 'record' && state.can?.edit === false) {
1246
- const problem = { kind: 'rule', message: state.can.reason ?? messages.mayNotEdit };
1260
+ const problem = { kind: 'rule', message: state.can.reason ?? named(messages.mayNotEditNoun, messages.mayNotEdit) };
1247
1261
  set({ status: 'error', error: problem.message, saveProblem: problem, conflict: null });
1248
1262
  return { reason: 'save', message: problem.message };
1249
1263
  }
1250
1264
  // Asked for the record shown now: another shown while it waits below, it saves nothing of that one, nor says anything there.
1251
1265
  const asked = generation;
1252
- const elsewhere = () => ({ reason: 'save', message: messages.recordReplaced });
1266
+ const elsewhere = () => ({ reason: 'save', message: named(messages.recordReplacedNoun, messages.recordReplaced) });
1253
1267
  // What is still typed in a box is read first: it is what is saved, or why nothing is. A name still being looked
1254
1268
  // up is waited for, so the record it names is what is saved — or, named by none, why nothing is.
1255
1269
  const waiting = readTyped();
@@ -1449,6 +1463,16 @@ function innerForm(options, within, place, outer) {
1449
1463
  changed(name, by, { keep });
1450
1464
  }
1451
1465
  /** A line added: the person's, theirs; a step's, none of theirs, and the lines they changed stay theirs. */
1466
+ /** 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. */
1467
+ function newLineValues(field) {
1468
+ const def = lineField(field);
1469
+ const lineFields = def.fields;
1470
+ const start = (0, values_1.initialValues)(lineFields, today());
1471
+ Object.assign(start, (0, value_map_1.firstValues)(lineFields, lineScope(field, start)));
1472
+ if (def.lineDefaults)
1473
+ Object.assign(start, (0, value_map_1.readValueMap)(def.lineDefaults, lineFields, recordScope()));
1474
+ return start;
1475
+ }
1452
1476
  function addLine(field, values, by) {
1453
1477
  const def = lineField(field);
1454
1478
  const current = state.values[field] ?? [];
@@ -1456,13 +1480,7 @@ function innerForm(options, within, place, outer) {
1456
1480
  let key = `new-${++lineKeys}`;
1457
1481
  while (current.some((line) => line.key === key))
1458
1482
  key = `new-${++lineKeys}`;
1459
- // Each line field's default, then what is worked out from its defaultFrom, then the record's lineDefaults, then what the line is given.
1460
- const lineFields = def.fields;
1461
- const start = (0, values_1.initialValues)(lineFields, today());
1462
- Object.assign(start, (0, value_map_1.firstValues)(lineFields, lineScope(field, start)));
1463
- if (def.lineDefaults)
1464
- Object.assign(start, (0, value_map_1.readValueMap)(def.lineDefaults, lineFields, recordScope()));
1465
- const line = { key, values: { ...start, ...(0, values_1.structuredCopy)(values) } };
1483
+ const line = { key, values: { ...newLineValues(field), ...(0, values_1.structuredCopy)(values) } };
1466
1484
  // A new line comes last, so it is numbered after the last.
1467
1485
  if (def.sequenceField && line.values[def.sequenceField] == null) {
1468
1486
  const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
@@ -1733,7 +1751,7 @@ function innerForm(options, within, place, outer) {
1733
1751
  return going;
1734
1752
  }
1735
1753
  async function askToLetGo() {
1736
- const message = messages.discardChanges;
1754
+ const message = named(messages.discardChangesNoun, messages.discardChanges);
1737
1755
  const host = options.host;
1738
1756
  let yes = false;
1739
1757
  try {
@@ -1792,7 +1810,7 @@ function innerForm(options, within, place, outer) {
1792
1810
  const go = letGo({}, chain);
1793
1811
  return go === true ? null : go.then((yes) => (yes ? null : kept()));
1794
1812
  }
1795
- const kept = () => ({ reason: 'no', message: messages.discardChanges });
1813
+ const kept = () => ({ reason: 'no', message: named(messages.discardChangesNoun, messages.discardChanges) });
1796
1814
  /**
1797
1815
  * A step's own move — the record loaded again, or another shown — once the check let what is shown go: kept, the
1798
1816
  * person kept their work; replaced, another record was shown before it ended (the run's steps are not for that one).
@@ -1829,7 +1847,7 @@ function innerForm(options, within, place, outer) {
1829
1847
  }
1830
1848
  /** Why a run stops whose record was replaced while it went on — quietly, when the form was closed. */
1831
1849
  function replaced() {
1832
- return disposed ? { reason: 'closed' } : { reason: 'cannot', message: messages.recordReplaced };
1850
+ return disposed ? { reason: 'closed' } : { reason: 'cannot', message: named(messages.recordReplacedNoun, messages.recordReplaced) };
1833
1851
  }
1834
1852
  /** The record a record step asks the data source about. */
1835
1853
  const record = () => ({ model: page.data.model, id: state.recordId, fields });
@@ -1939,7 +1957,7 @@ function innerForm(options, within, place, outer) {
1939
1957
  },
1940
1958
  post,
1941
1959
  wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
1942
- barred: (right) => (state.can?.[right] === false ? state.can.reason ?? messages[MAY_NOT[right]] : null),
1960
+ barred: (right) => (state.can?.[right] === false ? state.can.reason ?? named(messages[`${MAY_NOT[right]}Noun`], messages[MAY_NOT[right]]) : null),
1943
1961
  });
1944
1962
  // 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
1945
1963
  // go when another record is shown by then (its steps were for that one).
@@ -1967,7 +1985,7 @@ function innerForm(options, within, place, outer) {
1967
1985
  const builtin = 'builtin' in source && !list.length ? source.builtin : undefined;
1968
1986
  if (builtin)
1969
1987
  list.push({ do: builtin });
1970
- const asked = builtin === 'archive' ? messages.archiveConfirm : builtin === 'delete' ? messages.deleteConfirm : undefined;
1988
+ const asked = builtin === 'archive' ? named(messages.archiveConfirmNoun, messages.archiveConfirm) : builtin === 'delete' ? named(messages.deleteConfirmNoun, messages.deleteConfirm) : undefined;
1971
1989
  const confirm = 'confirm' in source && source.confirm !== undefined ? source.confirm : asked;
1972
1990
  // On the record it was pressed on: another shown while its question is asked, it does nothing to that one.
1973
1991
  scope.generation ??= generation;
@@ -2026,6 +2044,13 @@ function innerForm(options, within, place, outer) {
2026
2044
  leave: (how) => Promise.resolve(letGo(how)),
2027
2045
  leaveNow: (how = {}) => (outer ? outer.leaveNow(how) : atOnce(how)),
2028
2046
  recordShown: shownNow,
2047
+ relatedFilter(id) {
2048
+ const node = index.get(id)?.source;
2049
+ if (node?.type !== 'related' || state.recordId == null)
2050
+ return null;
2051
+ const related = node;
2052
+ return [{ field: related.field, op: '=', value: state.recordId }, ...(related.filter ? (0, filter_1.resolveFilter)(related.filter, context()) : [])];
2053
+ },
2029
2054
  post,
2030
2055
  setEditing(next) {
2031
2056
  if (next === editing)
@@ -2038,6 +2063,10 @@ function innerForm(options, within, place, outer) {
2038
2063
  loadChoices,
2039
2064
  loadDefinitions,
2040
2065
  addDefinition,
2066
+ waitsFor(name) {
2067
+ const from = workedOut.get(name);
2068
+ return from ? (0, waits_1.waitingOn)(page, from, state.values, state.unread) : null;
2069
+ },
2041
2070
  problem(name) {
2042
2071
  fieldDef(name);
2043
2072
  // Only a field someone can see is asked anything, and required wherever it is shown so.
@@ -2116,6 +2145,7 @@ function innerForm(options, within, place, outer) {
2116
2145
  save: () => track(save()),
2117
2146
  canUpload: () => typeof options.dataSource?.upload === 'function',
2118
2147
  today,
2148
+ recordReads: (id, values, fields) => (0, env_1.recordContext)(values, fields ?? page.fields, { recordId: id, user: options.user, editing: false, context: options.context }),
2119
2149
  canFindSlots: () => typeof options.dataSource?.slots === 'function',
2120
2150
  findSlots(field, ask) {
2121
2151
  const source = options.dataSource;
@@ -2136,6 +2166,28 @@ function innerForm(options, within, place, outer) {
2136
2166
  values: (0, values_1.structuredCopy)(state.values),
2137
2167
  });
2138
2168
  },
2169
+ canFindDuplicates: () => !!(page.data.kind === 'record' && page.duplicates?.fields?.length && options.dataSource?.duplicates),
2170
+ async findDuplicates() {
2171
+ const [source, model] = recordSource('duplicates');
2172
+ const fields = (page.duplicates?.fields ?? []).filter((name) => page.fields[name] && !(0, values_1.isEmpty)(page.fields[name], state.values[name]));
2173
+ const recordId = state.recordId;
2174
+ // Nothing typed in any of them: nothing asked.
2175
+ if (!fields.length)
2176
+ return [];
2177
+ const { matches } = await source.duplicates({ model, recordId, values: (0, values_1.structuredCopy)(state.values), fields });
2178
+ // Never the record itself, whatever the source says; the likest first where it scores them.
2179
+ const others = (matches ?? []).filter((match) => match && String(match.id) !== String(recordId));
2180
+ return others.every((match) => typeof match.score === 'number') ? others.sort((a, b) => b.score - a.score) : others;
2181
+ },
2182
+ canMerge: () => !!(page.data.kind === 'record' && options.dataSource?.merge && options.dataSource.load),
2183
+ async readRecord(id) {
2184
+ const [source, model] = recordSource('load');
2185
+ return (0, data_source_1.loadedRecordOf)(await source.load({ model, id, fields: page.fields })).values;
2186
+ },
2187
+ async merge(request) {
2188
+ const [source, model] = recordSource('merge');
2189
+ return source.merge({ model, keepId: request.keepId, mergeIds: [...request.mergeIds], values: (0, values_1.structuredCopy)(request.values), fields: page.fields });
2190
+ },
2139
2191
  upload: (field, files, how = {}) => uploads.start(field, files, how),
2140
2192
  uploading: (field) => state.uploads.filter((upload) => upload.field === field && !upload.line),
2141
2193
  cancelUpload(id) {
@@ -2248,7 +2300,7 @@ function innerForm(options, within, place, outer) {
2248
2300
  const sub = def.fields[subfield];
2249
2301
  if (!sub)
2250
2302
  throw new Error(`The lines of "${field}" have no field "${subfield}"`);
2251
- const line = (state.values[field] ?? []).find((l) => l.key === key);
2303
+ const line = key === null ? { values: newLineValues(field), id: null } : (state.values[field] ?? []).find((l) => l.key === key);
2252
2304
  if (!line)
2253
2305
  throw new Error(`"${field}" has no line "${key}"`);
2254
2306
  return runSearch(sub, subfield, (0, env_1.lineContext)(line.values, def.fields, context(), line.id ?? null), query, limit, options);
@@ -2812,6 +2864,12 @@ function indexLayout(root) {
2812
2864
  else if (node.type === 'form') {
2813
2865
  add(node, parent, 'form', { invisible: node.invisible, readonly: node.readonly });
2814
2866
  }
2867
+ else if (node.type === 'related') {
2868
+ add(node, parent, 'other', { invisible: node.invisible });
2869
+ // A row's buttons: their roles here, hidden with the list, their condition read by it on each row's record.
2870
+ for (const button of node.rowButtons ?? [])
2871
+ add(button, node.id, 'button', {});
2872
+ }
2815
2873
  else {
2816
2874
  add(node, parent, 'other', { invisible: node.invisible });
2817
2875
  }
@@ -2838,6 +2896,9 @@ function indexLayout(root) {
2838
2896
  // A list holds no fields of its own record: only the buttons for what is selected.
2839
2897
  for (const button of root.actions ?? [])
2840
2898
  add(button, root.id, 'button', { invisible: button.invisible });
2899
+ // A row's buttons: their roles here, their condition read by the list on each row's record, which the form does not hold.
2900
+ for (const button of root.rowButtons ?? [])
2901
+ add(button, root.id, 'button', {});
2841
2902
  }
2842
2903
  else {
2843
2904
  // The title and statusbar show fields too, so they are validated like