nexus-shared 3.0.0 → 3.2.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 (162) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/README.md +1123 -1000
  3. package/dist/Client.Index.d.ts +6 -0
  4. package/dist/Client.Index.js +6 -0
  5. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  6. package/dist/Components/Chats/ChatParts.js +3 -7
  7. package/dist/Components/Chats/ChatThread.js +3 -3
  8. package/dist/Components/Documents/Button.d.ts +6 -1
  9. package/dist/Components/Documents/Button.js +2 -2
  10. package/dist/Components/Documents/Menu.js +21 -9
  11. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  12. package/dist/Components/Documents/SplitButton.js +7 -5
  13. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  14. package/dist/Components/Documents/TabButtons.js +2 -2
  15. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  16. package/dist/Components/Forms/ApiForm.js +1 -1
  17. package/dist/Components/Forms/Crud.js +486 -206
  18. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  19. package/dist/Components/Forms/CrudHistory.js +140 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  21. package/dist/Components/Forms/CrudParts.js +38 -5
  22. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  23. package/dist/Components/Forms/ExcelImport.js +189 -60
  24. package/dist/Components/Forms/Form.js +9 -14
  25. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  26. package/dist/Components/Forms/SubmitForm.js +23 -12
  27. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  28. package/dist/Components/Inputs/CodeBox.js +316 -0
  29. package/dist/Components/Inputs/DatePicker.js +4 -1
  30. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  31. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  32. package/dist/Components/Inputs/Dropdown.js +4 -1
  33. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  34. package/dist/Components/Inputs/DropdownParts.js +4 -6
  35. package/dist/Components/Inputs/InputRenderer.js +3 -0
  36. package/dist/Components/Inputs/NumberBox.js +26 -2
  37. package/dist/Components/Inputs/RowsInput.js +2 -2
  38. package/dist/Components/Inputs/Slider.js +4 -1
  39. package/dist/Components/Inputs/TabularForm.js +42 -10
  40. package/dist/Components/Inputs/TextArea.js +4 -1
  41. package/dist/Components/Inputs/TextBox.js +4 -1
  42. package/dist/Components/Inputs/TimePanel.js +4 -3
  43. package/dist/Components/Inputs/TimePicker.js +4 -1
  44. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  45. package/dist/Components/Inputs/TreeView.js +372 -23
  46. package/dist/Components/Layouts/Popup.js +1 -4
  47. package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
  48. package/dist/Components/Layouts/ThemePicker.js +89 -6
  49. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  50. package/dist/Components/Layouts/ThemeScript.js +4 -2
  51. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  52. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  53. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  54. package/dist/Components/Viewers/Avatar.js +21 -0
  55. package/dist/Components/Viewers/DataTable.js +252 -62
  56. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  57. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  58. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  59. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  60. package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
  61. package/dist/Components/Viewers/DataTableParts.js +42 -13
  62. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  63. package/dist/Helpers/AnimationHelpers.js +20 -2
  64. package/dist/Helpers/ApiModules.d.ts +3 -34
  65. package/dist/Helpers/ApiModules.js +3 -3
  66. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  67. package/dist/Helpers/ApiRoutes.js +31 -27
  68. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  69. package/dist/Helpers/ArrayHelpers.js +2 -0
  70. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  71. package/dist/Helpers/CodeHelpers.js +332 -0
  72. package/dist/Helpers/CrudBackend.d.ts +101 -8
  73. package/dist/Helpers/CrudBackend.js +172 -11
  74. package/dist/Helpers/CrudColumns.d.ts +51 -0
  75. package/dist/Helpers/CrudColumns.js +69 -0
  76. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  77. package/dist/Helpers/CrudHelpers.js +109 -12
  78. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  79. package/dist/Helpers/ExcelBackend.js +8 -2
  80. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  81. package/dist/Helpers/ExcelHelpers.js +55 -13
  82. package/dist/Helpers/FormStore.d.ts +9 -5
  83. package/dist/Helpers/FormStore.js +25 -5
  84. package/dist/Helpers/InputParamsHelpers.js +8 -0
  85. package/dist/Helpers/MessageBuilder.js +3 -0
  86. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  87. package/dist/Helpers/NexusHelpers.js +1 -0
  88. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  89. package/dist/Helpers/NumberHelpers.js +41 -3
  90. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  91. package/dist/Helpers/PopoverHelpers.js +52 -17
  92. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  93. package/dist/Helpers/PopoverHooks.js +62 -0
  94. package/dist/Helpers/RequestCache.d.ts +35 -0
  95. package/dist/Helpers/RequestCache.js +113 -0
  96. package/dist/Helpers/RowsStore.d.ts +7 -2
  97. package/dist/Helpers/RowsStore.js +15 -3
  98. package/dist/Helpers/TableFilters.d.ts +25 -0
  99. package/dist/Helpers/TableFilters.js +162 -0
  100. package/dist/Helpers/TableHelpers.d.ts +7 -4
  101. package/dist/Helpers/TableHelpers.js +15 -6
  102. package/dist/Helpers/TableJoins.d.ts +70 -0
  103. package/dist/Helpers/TableJoins.js +250 -0
  104. package/dist/Helpers/TableStore.d.ts +16 -3
  105. package/dist/Helpers/TableStore.js +25 -5
  106. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  107. package/dist/Helpers/ThemeHelpers.js +46 -9
  108. package/dist/Helpers/TreeStore.d.ts +225 -11
  109. package/dist/Helpers/TreeStore.js +683 -90
  110. package/dist/Helpers/UserPreferences.d.ts +22 -0
  111. package/dist/Helpers/UserPreferences.js +116 -0
  112. package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
  113. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  114. package/dist/Interfaces/FormInterfaces.d.ts +37 -6
  115. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  116. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  117. package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
  118. package/dist/Interfaces/PreferenceInterfaces.js +25 -0
  119. package/dist/Interfaces/TableInterfaces.d.ts +145 -16
  120. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  121. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  122. package/dist/Services/ApiProxy.d.ts +36 -1
  123. package/dist/Services/ApiProxy.js +182 -52
  124. package/dist/Services/ApiProxyRules.d.ts +203 -0
  125. package/dist/Services/ApiProxyRules.js +426 -0
  126. package/dist/Services/ExcelApi.d.ts +3 -1
  127. package/dist/Services/ExcelApi.js +1 -1
  128. package/dist/Services/JoinSources.d.ts +48 -0
  129. package/dist/Services/JoinSources.js +74 -0
  130. package/dist/Services/ServerApi.d.ts +13 -2
  131. package/dist/Services/ServerApi.js +8 -3
  132. package/dist/Services/ThemeService.d.ts +18 -2
  133. package/dist/Services/ThemeService.js +80 -16
  134. package/dist/Shared.Index.d.ts +6 -0
  135. package/dist/Shared.Index.js +6 -0
  136. package/package.json +4 -2
  137. package/src/Styles/Nexus.Avatar.css +79 -0
  138. package/src/Styles/Nexus.Base.css +51 -4
  139. package/src/Styles/Nexus.Button.css +27 -0
  140. package/src/Styles/Nexus.Calendar.css +4 -6
  141. package/src/Styles/Nexus.Chat.css +5 -51
  142. package/src/Styles/Nexus.Choice.css +4 -6
  143. package/src/Styles/Nexus.Crud.css +16 -9
  144. package/src/Styles/Nexus.Dropdown.css +12 -61
  145. package/src/Styles/Nexus.Editor.css +4 -6
  146. package/src/Styles/Nexus.Excel.css +34 -15
  147. package/src/Styles/Nexus.Form.css +25 -67
  148. package/src/Styles/Nexus.Index.css +1 -0
  149. package/src/Styles/Nexus.Input.css +794 -637
  150. package/src/Styles/Nexus.Markdown.css +0 -28
  151. package/src/Styles/Nexus.Menu.css +8 -30
  152. package/src/Styles/Nexus.Popup.css +22 -29
  153. package/src/Styles/Nexus.RichText.css +2 -32
  154. package/src/Styles/Nexus.Rows.css +51 -10
  155. package/src/Styles/Nexus.Slider.css +3 -5
  156. package/src/Styles/Nexus.Tab.Buttons.css +20 -7
  157. package/src/Styles/Nexus.Table.css +1387 -1296
  158. package/src/Styles/Nexus.Theme.Picker.css +342 -232
  159. package/src/Styles/Nexus.Time.css +9 -12
  160. package/src/Styles/Nexus.Toast.css +6 -8
  161. package/src/Styles/Nexus.Tokens.css +17 -0
  162. package/src/Styles/Nexus.Tree.css +76 -11
@@ -1,3 +1,5 @@
1
+ import { CRUD_MARK_BITS, hasMark, withMark } from "./CrudHelpers.js";
2
+ const NO_AUDIT_WORDS = { commands: {}, directions: {}, parts: {} };
1
3
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2
4
  export class CrudBackend {
3
5
  /** Reads a whole list: an array, or the rows of a page. */
@@ -6,6 +8,83 @@ export class CrudBackend {
6
8
  return result;
7
9
  return [...this.readPage(result).rows];
8
10
  }
11
+ /** Whether a record can be flagged or unflagged at all. Default yes. */
12
+ canFlag(row) {
13
+ void row;
14
+ return true;
15
+ }
16
+ /**
17
+ * What flagging one record or many sends beside the key(s): the state wanted, never a toggle. Default the Nexus
18
+ * backend's `?isFlagged=true`. A backend that toggles ignores it, and a CRUD sends only the records a change is for.
19
+ */
20
+ flagRequest(on) {
21
+ return { query: { isFlagged: on } };
22
+ }
23
+ /**
24
+ * This user's marks on a record as a set of bits (`CRUD_MARK_BITS`: pinned 1, favourite 2), as a bookmark listing
25
+ * answers them on each record. Bits the package does not know are kept as they are. Default: the pin alone, through
26
+ * `isPinned`.
27
+ */
28
+ marksOf(row) {
29
+ return this.isPinned(row) ? CRUD_MARK_BITS.pin : 0;
30
+ }
31
+ /** The row carrying this set of marks. Default: the pin alone, through `withPin`. */
32
+ withMarks(row, marks) {
33
+ return this.withPin(row, hasMark(marks, "pin"));
34
+ }
35
+ /**
36
+ * What setting one record's marks sends beside its key: the **whole set** it is to have, never a toggle. Default the
37
+ * Nexus backend's: `?bookmarkTypes=3`.
38
+ */
39
+ marksRequest(marks) {
40
+ return { query: { bookmarkTypes: marks } };
41
+ }
42
+ /**
43
+ * What putting one mark on many records, or taking it off, sends: the keys, the mark and which way. Each record keeps
44
+ * its other marks. Default the Nexus backend's: the keys as the body, `?bookmarkTypes=2&isAdding=true`.
45
+ */
46
+ massMarksRequest(keys, mark, on) {
47
+ return { body: keys, query: { bookmarkTypes: CRUD_MARK_BITS[mark], isAdding: on } };
48
+ }
49
+ /** The marks a write left on the record, read from its answer. `null` when it does not say, and the set sent stands. */
50
+ answeredMarks(result) {
51
+ void result;
52
+ return null;
53
+ }
54
+ /** What an export sends to name the list on screen, beside the table's own filters. Default nothing. */
55
+ exportFilters(mode) {
56
+ void mode;
57
+ return {};
58
+ }
59
+ /** A record status in words, for a status column and the history: "Active", "Flagged", "Trashed". Default the number. */
60
+ statusText(status) {
61
+ return String(status);
62
+ }
63
+ /**
64
+ * The short tag a reference to a record carries when that record is no longer available - "Trashed" beside a parent in
65
+ * the trash, "Deleted" beside one deleted - read from its status. `null` for a live record, and for no status at all.
66
+ * A joined column (`crudColumns.dbJoin`, `crudColumns.clientJoin`) draws it after the name. Default: none.
67
+ */
68
+ unavailableTag(status) {
69
+ void status;
70
+ return null;
71
+ }
72
+ /**
73
+ * Whether a record may be picked in a form: what a picker offers, and what it shows tagged and will not offer again when
74
+ * a form already holds it. Read from the record. Default: every record.
75
+ */
76
+ isPickable(record) {
77
+ void record;
78
+ return true;
79
+ }
80
+ /**
81
+ * The words of a record's history: what each command did (`commands`), the audit keys that are the whole story of a
82
+ * switch (`directions`), and the parts of the record an update names (`parts`, with `update` the command that names
83
+ * one). Numbers an audit row keeps for good, so each backend's own. Default none: "Action 3".
84
+ */
85
+ get auditWords() {
86
+ return NO_AUDIT_WORDS;
87
+ }
9
88
  /**
10
89
  * Changes an action's answer makes to rows, by key: a mass flag that answers each record's new status. Rows without
11
90
  * an entry take the change the action stands for (a toggled flag). Default: none.
@@ -29,7 +108,7 @@ export class CrudBackend {
29
108
  * `null` when it says nothing about it - a bare key, an empty body - and then the caller keeps what it has.
30
109
  *
31
110
  * **This is what spares a page its reload.** A save or a mark whose answer carries the record is written into the
32
- * table, the pinned band and every list kept in the cache, and nothing is loaded again. Only answers of actions on
111
+ * table, the sections above it and every list kept in the cache, and nothing is loaded again. Only answers of actions on
33
112
  * **one** record are read here; a mass answer goes through `answeredChanges`, record by record.
34
113
  */
35
114
  answeredRow(result) {
@@ -70,10 +149,10 @@ export class CrudBackend {
70
149
  * 40–59 trash
71
150
  * 60+ delete
72
151
  *
73
- * The flag is a **range**, 25–29, and this file checks a single value: the seeded `ERecordStatus.Flagged` is 25, and
74
- * nothing yet defines what 26–29 would mean. A range-aware check belongs with the per-project bands being built on
75
- * `/v2/core-config`, so it is deliberately not here — and this note is here so the single value is not mistaken for
76
- * the final answer.
152
+ * The flag is a **range**, 25–29, and so is every test here (`isFlagged`, `listMembership`), since 2026-10-08: the
153
+ * Regions page already read it as a range, and a list that reads 26 as flagged where the table reads it as not would
154
+ * disagree with itself. A flag written is still 25, the seeded `ERecordStatus.Flagged`. Per-project bands from
155
+ * `/v2/core-config` would change the numbers here, never the shape of the test.
77
156
  */
78
157
  /** The Nexus record status of a flagged record (`ERecordStatus.Flagged`), the bottom of the 25–29 flag band. */
79
158
  export const NEXUS_FLAGGED_STATUS = 25;
@@ -85,14 +164,43 @@ export const NEXUS_TRASH_STATUS = 40;
85
164
  export const NEXUS_DELETED_STATUS = 60;
86
165
  /** Just past the flag band: a flagged record is 25–29, so 30 and up is live but not flagged. */
87
166
  const NEXUS_FLAG_BAND_END = 30;
167
+ /** Each list as the Nexus backend's `ECommandTypes` numbers its listing: what an export names the list on screen by. */
168
+ const NEXUS_LIST_COMMANDS = { normal: 50, flag: 52, trash: 54, deleted: 55 };
169
+ /** The statuses `ERecordStatus` names. Imported (1) is what a spreadsheet import writes unless the person picks another. */
170
+ const NEXUS_STATUS_WORDS = { 0: "Active", 1: "Imported", 25: "Flagged", 30: "Pending", 31: "Unverified", 32: "In review", 33: "Approval required", 40: "Trashed", 60: "Deleted" };
171
+ /** A record's history in words, by the Nexus backend's `ECommandTypes` and `EAuditKeys`. An audit row keeps the numbers for good. */
172
+ const NEXUS_AUDIT_WORDS = {
173
+ commands: {
174
+ 0: "Added",
175
+ 1: "Updated",
176
+ 3: "Marks changed",
177
+ 4: "Flag changed",
178
+ 10: "Moved to the trash",
179
+ 11: "Recovered from the trash",
180
+ 12: "Deleted permanently",
181
+ 13: "Moved back to the trash",
182
+ 20: "Basic information updated",
183
+ 21: "Billing information updated",
184
+ 22: "Address updated",
185
+ 23: "References updated",
186
+ 24: "Contact information updated",
187
+ 25: "Email updated",
188
+ },
189
+ directions: { 7: "Flagged", 8: "Unflagged", 9: "Made a favourite", 10: "Taken out of the favourites" },
190
+ parts: { 1: "basic information", 2: "contact information", 3: "email", 4: "address", 5: "profile picture", 6: "password", 11: "permissions", 12: "billing information", 13: "references" },
191
+ update: 1,
192
+ };
88
193
  /**
89
- * The Nexus backend: pages as `{ searchKeyword, skipCount, maxCount, sorting, sortType }` answered with
194
+ * The Nexus backend: pages as `{ ...filters, searchKeyword, skipCount, maxCount, sorting, sortType }` answered with
90
195
  * `{ results, totalCount, filterCount }`, newest first unless a column is sorted; mass actions send the ids
91
- * (`{ description, accountIds }` for descriptions); a flagged record has `recordStatus` 25, a pinned one `isPinned`.
196
+ * (`{ description, accountIds }` for descriptions); a flagged record has `recordStatus` 25, and this user's marks are
197
+ * its `bookmarkTypes` (`[Flags] EBookmarkTypes`: 1 pinned, 2 favourite), or `isPinned` from a backend before them.
92
198
  */
93
199
  export class NexusCrudBackend extends CrudBackend {
94
- pageRequest(query, sortKey) {
200
+ pageRequest(query, sortKey, filters) {
95
201
  return {
202
+ // Flat and first, so a filter never overwrites a paging field.
203
+ ...filters,
96
204
  searchKeyword: query.search,
97
205
  skipCount: (query.page - 1) * query.pageSize,
98
206
  maxCount: query.pageSize,
@@ -117,17 +225,70 @@ export class NexusCrudBackend extends CrudBackend {
117
225
  massRequest(action, keys, values) {
118
226
  return action === "description" ? { description: values?.description ?? null, accountIds: keys } : keys;
119
227
  }
228
+ /** The whole flag band, 25-29: every status comparison is a range test. */
120
229
  isFlagged(row) {
121
- return Number(row.recordStatus) === NEXUS_FLAGGED_STATUS;
230
+ const status = Number(row.recordStatus);
231
+ return status >= NEXUS_FLAGGED_STATUS && status < NEXUS_FLAG_BAND_END;
232
+ }
233
+ /**
234
+ * A live record that is not pending (0-29): the backend flags 0-24 and unflags 25-29, and never touches a pending
235
+ * record (30-39), which would lose that status for good. A record with no status yet is not refused here.
236
+ */
237
+ canFlag(row) {
238
+ const status = row.recordStatus;
239
+ return typeof status !== "number" || status < NEXUS_FLAG_BAND_END;
122
240
  }
123
241
  withFlag(row, on) {
124
242
  return { ...row, recordStatus: on ? NEXUS_FLAGGED_STATUS : NEXUS_ACTIVE_STATUS };
125
243
  }
126
244
  isPinned(row) {
127
- return row.isPinned === true;
245
+ return hasMark(this.marksOf(row), "pin");
128
246
  }
129
247
  withPin(row, on) {
130
- return { ...row, isPinned: on };
248
+ return this.withMarks(row, withMark(this.marksOf(row), "pin", on));
249
+ }
250
+ marksOf(row) {
251
+ const marks = row.bookmarkTypes;
252
+ if (typeof marks === "number" && Number.isInteger(marks) && marks >= 0)
253
+ return marks;
254
+ return row.isPinned === true ? CRUD_MARK_BITS.pin : 0;
255
+ }
256
+ /** `bookmarkTypes`, and `isPinned` too on a record that has one, so neither says something the other does not. */
257
+ withMarks(row, marks) {
258
+ const next = { ...row, bookmarkTypes: marks };
259
+ if ("isPinned" in row)
260
+ next.isPinned = hasMark(marks, "pin");
261
+ return next;
262
+ }
263
+ /** `SwitchBookmarks` answers the marks it stored, as a number. */
264
+ answeredMarks(result) {
265
+ return typeof result === "number" && Number.isInteger(result) && result >= 0 ? result : null;
266
+ }
267
+ /** An export names the list on screen as `flag`, by the number `ECommandTypes` gives its listing. */
268
+ exportFilters(mode) {
269
+ return { flag: NEXUS_LIST_COMMANDS[mode] };
270
+ }
271
+ /** The members `ERecordStatus` names, then the band of any other number in it. */
272
+ statusText(status) {
273
+ return NEXUS_STATUS_WORDS[status] ?? (status < NEXUS_FLAGGED_STATUS ? "Active" : status < NEXUS_FLAG_BAND_END ? "Flagged" : status < NEXUS_TRASH_STATUS ? "Pending" : status < NEXUS_DELETED_STATUS ? "Trashed" : "Deleted");
274
+ }
275
+ /**
276
+ * Out of the live band, 40 and up (the owner, 2026-10-08: "if status is not active range, like trash or deleted, show a
277
+ * very short tag"): "Trashed" in the warning color, "Deleted" in the danger color. A flagged or pending record is still
278
+ * live, and carries none.
279
+ */
280
+ unavailableTag(status) {
281
+ if (typeof status !== "number" || !Number.isFinite(status) || status < NEXUS_TRASH_STATUS)
282
+ return null;
283
+ return { label: this.statusText(status), tone: status < NEXUS_DELETED_STATUS ? "warning" : "danger" };
284
+ }
285
+ /** Usable, under 30, as a Nexus `ActiveListing` answers: a pending record is live but not one to pick. No status reads as usable. */
286
+ isPickable(record) {
287
+ const status = record.recordStatus;
288
+ return typeof status !== "number" || status < NEXUS_FLAG_BAND_END;
289
+ }
290
+ get auditWords() {
291
+ return NEXUS_AUDIT_WORDS;
131
292
  }
132
293
  /** Mass answers list the records they changed, with their new `recordStatus` and `isPinned`. */
133
294
  answeredChanges(result, rowKey) {
@@ -0,0 +1,51 @@
1
+ import type { ApiEndpoint } from "../Interfaces/ApiInterfaces.ts";
2
+ import type { InputParams } from "../Interfaces/FormInterfaces.ts";
3
+ import type { TableColumn, TableJoin, TableRow } from "../Interfaces/TableInterfaces.ts";
4
+ import { type CrudBackend } from "./CrudBackend.ts";
5
+ /** The longest description a record keeps: the owner's rule for every model (the column holds 4000). */
6
+ export declare const CRUD_DESCRIPTION_MAX = 1000;
7
+ export declare const crudColumns: {
8
+ /** The record's status in words ("Active", "Flagged", "Trashed"), from the backend's own numbers; blank until a new record is read back. */
9
+ status<TRow extends TableRow>(changes?: Partial<TableColumn<TRow>>, backend?: CrudBackend): TableColumn<TRow>;
10
+ /** The description, as Markdown: text in the table, formatted in the details. */
11
+ description<TRow extends TableRow>(changes?: Partial<TableColumn<TRow>>): TableColumn<TRow>;
12
+ /** When the record was added. Hidden until the user shows it. */
13
+ added<TRow extends TableRow>(changes?: Partial<TableColumn<TRow>>): TableColumn<TRow>;
14
+ /** When it was changed last. Hidden until the user shows it. */
15
+ changed<TRow extends TableRow>(changes?: Partial<TableColumn<TRow>>): TableColumn<TRow>;
16
+ /**
17
+ * Another record's name the **database** joined into the row: the row carries the name (`key`) and that record's own
18
+ * status (`status`), and the cell shows the name with a short tag when the record is no longer available ("Trashed",
19
+ * "Deleted": `CrudBackend.unavailableTag`). One call, nothing looked up.
20
+ *
21
+ * @example crudColumns.dbJoin({ key: "regionName", label: "Region", status: "regionRecordStatus" })
22
+ */
23
+ dbJoin<TRow extends TableRow>({ status, ...column }: DbJoinColumn<TRow>, backend?: CrudBackend): TableColumn<TRow>;
24
+ /**
25
+ * Another record's name the **table** joins: the row carries only that record's key (`from`), and the table names it
26
+ * from the other controller's `findListing` - every key the rows hold in one call, kept for every table and form on the
27
+ * page, searched and sorted like any column - with the same short tag when the record is no longer available, and
28
+ * "Not found" for a key with no record. `field` is the looked-up record's field, when it is not the column's `key`.
29
+ *
30
+ * @example crudColumns.clientJoin(SUB_REGIONS, { key: "subRegionName", label: "Sub-region", from: "subRegionId" })
31
+ */
32
+ clientJoin<TRow extends TableRow>(api: JoinController, { from, field, ...column }: ClientJoinColumn<TRow>, backend?: CrudBackend): TableColumn<TRow>;
33
+ };
34
+ /** What `crudColumns.dbJoin` takes: a column, and the row's field that holds the joined record's status. */
35
+ export type DbJoinColumn<TRow extends TableRow> = Partial<TableColumn<TRow>> & Pick<TableColumn<TRow>, "key" | "label"> & {
36
+ status: string;
37
+ };
38
+ /** What `crudColumns.clientJoin` takes: a column, the row's field that holds the other record's key, and its own field. */
39
+ export type ClientJoinColumn<TRow extends TableRow> = Partial<TableColumn<TRow>> & Pick<TableColumn<TRow>, "key" | "label"> & Pick<TableJoin, "from" | "field">;
40
+ /** The controller a client join names its records from: any with a `findListing` (`MODULES.controller(...)`). */
41
+ export interface JoinController {
42
+ findListing: ApiEndpoint;
43
+ }
44
+ export declare const crudInputs: {
45
+ /**
46
+ * The record's description: Markdown, at most `CRUD_DESCRIPTION_MAX` characters, across the form. One definition for
47
+ * every model, its hint included (the owner, 2026-10-08): a page gives none of its own. `changes` is for its place
48
+ * in a form, such as `span`.
49
+ */
50
+ description(changes?: Partial<InputParams>): InputParams;
51
+ };
@@ -0,0 +1,69 @@
1
+ import { getCrudBackend } from "./CrudBackend.js";
2
+ import { readPath } from "./TableHelpers.js";
3
+ /*
4
+ * The columns and inputs every Nexus record has, so a page names them instead of writing them again: its status in
5
+ * words, its Markdown description, and when it was added and changed - and the two ways a page shows another record's
6
+ * name, joined by the database (`dbJoin`) or by the table (`clientJoin`). Each takes changes over its defaults, as a
7
+ * column or an input of the page's own would: `crudColumns.added({ hidden: false })`.
8
+ */
9
+ /** The longest description a record keeps: the owner's rule for every model (the column holds 4000). */
10
+ export const CRUD_DESCRIPTION_MAX = 1000;
11
+ export const crudColumns = {
12
+ /** The record's status in words ("Active", "Flagged", "Trashed"), from the backend's own numbers; blank until a new record is read back. */
13
+ status(changes = {}, backend) {
14
+ return {
15
+ key: "recordStatus",
16
+ label: "Status",
17
+ width: "9rem",
18
+ render: (_row, value) => (typeof value === "number" ? (backend ?? getCrudBackend()).statusText(value) : ""),
19
+ ...changes,
20
+ };
21
+ },
22
+ /** The description, as Markdown: text in the table, formatted in the details. */
23
+ description(changes = {}) {
24
+ return { key: "description", label: "Description", wrap: true, sortable: false, markdown: true, ...changes };
25
+ },
26
+ /** When the record was added. Hidden until the user shows it. */
27
+ added(changes = {}) {
28
+ return { key: "creationTime", label: "Added", format: "date-time", hidden: true, ...changes };
29
+ },
30
+ /** When it was changed last. Hidden until the user shows it. */
31
+ changed(changes = {}) {
32
+ return { key: "modificationTime", label: "Changed", format: "date-time", hidden: true, ...changes };
33
+ },
34
+ /**
35
+ * Another record's name the **database** joined into the row: the row carries the name (`key`) and that record's own
36
+ * status (`status`), and the cell shows the name with a short tag when the record is no longer available ("Trashed",
37
+ * "Deleted": `CrudBackend.unavailableTag`). One call, nothing looked up.
38
+ *
39
+ * @example crudColumns.dbJoin({ key: "regionName", label: "Region", status: "regionRecordStatus" })
40
+ */
41
+ dbJoin({ status, ...column }, backend) {
42
+ return { tag: row => (backend ?? getCrudBackend()).unavailableTag(readPath(row, status)), ...column };
43
+ },
44
+ /**
45
+ * Another record's name the **table** joins: the row carries only that record's key (`from`), and the table names it
46
+ * from the other controller's `findListing` - every key the rows hold in one call, kept for every table and form on the
47
+ * page, searched and sorted like any column - with the same short tag when the record is no longer available, and
48
+ * "Not found" for a key with no record. `field` is the looked-up record's field, when it is not the column's `key`.
49
+ *
50
+ * @example crudColumns.clientJoin(SUB_REGIONS, { key: "subRegionName", label: "Sub-region", from: "subRegionId" })
51
+ */
52
+ clientJoin(api, { from, field, ...column }, backend) {
53
+ return {
54
+ join: { from, field, source: api.findListing },
55
+ tag: (_row, joined) => (joined ? (backend ?? getCrudBackend()).unavailableTag(joined.recordStatus) : null),
56
+ ...column,
57
+ };
58
+ },
59
+ };
60
+ export const crudInputs = {
61
+ /**
62
+ * The record's description: Markdown, at most `CRUD_DESCRIPTION_MAX` characters, across the form. One definition for
63
+ * every model, its hint included (the owner, 2026-10-08): a page gives none of its own. `changes` is for its place
64
+ * in a form, such as `span`.
65
+ */
66
+ description(changes = {}) {
67
+ return { type: "markdown", name: "description", label: "Description", maxLength: CRUD_DESCRIPTION_MAX, height: 160, hint: "Anything worth knowing about it. Markdown.", span: 12, ...changes };
68
+ },
69
+ };
@@ -1,10 +1,10 @@
1
1
  import type { ApiEndpoint } from "../Interfaces/ApiInterfaces.ts";
2
- import type { CrudActionName, CrudEndpoints, CrudMassActionName, CrudMode, CrudRowActionName } from "../Interfaces/CrudInterfaces.ts";
2
+ import type { CrudActionName, CrudEndpoints, CrudMark, CrudMassActionName, CrudMode, CrudRowActionName } from "../Interfaces/CrudInterfaces.ts";
3
3
  import type { MessageActionName } from "../Interfaces/MessageInterfaces.ts";
4
4
  import type { TablePage, TablePaging, TableRow } from "../Interfaces/TableInterfaces.ts";
5
5
  /** Every list, in the order the CRUD shows them. */
6
6
  export declare const CRUD_MODES: readonly CrudMode[];
7
- /** The built-in actions on one record, per list, in their menu order. */
7
+ /** The built-in actions on one record, per list, in their menu order. A record's history is on every list. */
8
8
  export declare const CRUD_ROW_ACTIONS: Readonly<Record<CrudMode, readonly CrudRowActionName[]>>;
9
9
  /** The built-in actions on selected records, per list. */
10
10
  export declare const CRUD_MASS_ACTIONS: Readonly<Record<CrudMode, readonly CrudMassActionName[]>>;
@@ -18,8 +18,13 @@ export declare function crudRowActions(mode: CrudMode, allowed: (action: CrudRow
18
18
  export declare function crudMassActions(mode: CrudMode, allowed: (action: CrudMassActionName) => boolean, trashless?: boolean): CrudMassActionName[];
19
19
  /** The endpoint of a list: its full list for tables that page in the browser, its pages for server tables. */
20
20
  export declare function crudListEndpoint(api: CrudEndpoints, mode: CrudMode, paging: TablePaging): ApiEndpoint | undefined;
21
- /** The endpoint of a row action. `view` needs none of its own: it shows the row (and calls `details` when there is one). */
21
+ /**
22
+ * The endpoint of a row action. `view` needs none of its own: it shows the row (and calls `details` when there is one).
23
+ * Pin and Favourite both set the record's marks through `bookmark`; a backend without it may still pin, through `pin`.
24
+ */
22
25
  export declare function crudRowEndpoint(api: CrudEndpoints, action: CrudRowActionName): ApiEndpoint | undefined;
26
+ /** The endpoint of an action on many records: Pin and Favourite through `mass.bookmark`, or Pin through `mass.pin`. */
27
+ export declare function crudMassEndpoint(api: CrudEndpoints, action: CrudMassActionName): ApiEndpoint | undefined;
23
28
  /** The lists a CRUD can offer: asked for, with an endpoint, and allowed. */
24
29
  export declare function availableModes(api: CrudEndpoints, paging: TablePaging, wanted: readonly CrudMode[], can: (endpoint: ApiEndpoint) => boolean): CrudMode[];
25
30
  /**
@@ -31,8 +36,8 @@ export declare function actionAllowed(action: CrudActionName, api: CrudEndpoints
31
36
  export declare function massActionAllowed(action: CrudMassActionName, api: CrudEndpoints, wanted: readonly CrudActionName[], can: (endpoint: ApiEndpoint) => boolean): boolean;
32
37
  /**
33
38
  * Whether a record leaves the list shown after an action. Trash, recover, delete, and moving back to the trash take it
34
- * to another list, and unflagging leaves the flagged list. Pinning and unpinning never do: a pinned
35
- * record stays in the list it is in. `markOn` is the record's new flag.
39
+ * to another list, and unflagging leaves the flagged list. A pin or a favourite never does: a marked record stays in
40
+ * the list it is in. `markOn` is the record's new flag.
36
41
  */
37
42
  export declare function leavesList(action: CrudRowActionName | CrudMassActionName, mode: CrudMode, markOn?: boolean): boolean;
38
43
  /** The message builder's action for a built-in action: "Guest recovered.", "Unpin 3 guests?" */
@@ -44,6 +49,8 @@ export interface CrudSettings {
44
49
  * pins and can unpin, and may pin again once they are under it.
45
50
  */
46
51
  maxPins: number;
52
+ /** Most records a user may make favourites, kept the same way. Default: whatever `maxPins` is. */
53
+ maxFavorites?: number;
47
54
  }
48
55
  /**
49
56
  * Changes what every CRUD follows, once, where the app registers its API modules: `configureCrud({ maxPins: 50 })`.
@@ -57,6 +64,44 @@ export declare function getCrudSettings(): CrudSettings;
57
64
  * when nothing counts the pins (no `pins` endpoint), and then there is no limit to keep.
58
65
  */
59
66
  export declare function pinRoom(pinned: number | undefined, max?: number): number;
67
+ /** Every mark, in the order their sections show above the list: Pinned, then Favourites. */
68
+ export declare const CRUD_MARKS: readonly CrudMark[];
69
+ /**
70
+ * Each mark's bit in a record's set of marks, as the Nexus backend's `[Flags] EBookmarkTypes` numbers them. A set may
71
+ * hold bits this package does not know - a backend's next mark is its next bit - and a change here keeps them.
72
+ */
73
+ export declare const CRUD_MARK_BITS: Readonly<Record<CrudMark, number>>;
74
+ /** Whether a set of marks holds this mark. */
75
+ export declare function hasMark(marks: number, mark: CrudMark): boolean;
76
+ /** The set of marks with this one put on or taken off, and every other bit as it was. */
77
+ export declare function withMark(marks: number, mark: CrudMark, on: boolean): number;
78
+ /** Most records a user may give a mark: `maxPins`, and for favourites `maxFavorites`, else the same as the pins. */
79
+ export declare function markLimit(mark: CrudMark, settings?: CrudSettings): number;
80
+ /**
81
+ * Which ways a selection can go for one mark, from each selected record's marks: on, when one of them lacks it, and
82
+ * off, when one of them has it. A mixed selection can go both ways, and each is offered on its own (the owner,
83
+ * 2026-10-07): no action that would change nothing.
84
+ */
85
+ export declare function markDirections(marks: readonly number[], mark: CrudMark): {
86
+ on: boolean;
87
+ off: boolean;
88
+ };
89
+ /** This user's marked records, as a CRUD keeps them: every record's marks by key, and the records of each mark's section. */
90
+ export interface CrudMarkedRecords<TRow extends TableRow = TableRow> {
91
+ marks: ReadonlyMap<string, number>;
92
+ rows: Readonly<Record<CrudMark, readonly TRow[]>>;
93
+ }
94
+ /** The marked records as one listing answered them, in its order: `marksOf` reads each record's marks. */
95
+ export declare function markedRecords<TRow extends TableRow>(rows: readonly TRow[], marksOf: (row: TRow) => number, keyOf: (row: TRow) => string): CrudMarkedRecords<TRow>;
96
+ /**
97
+ * The marked records after a change, with no request. `stored` is each changed record's new set of marks (`0` for
98
+ * none). A record in `joining` - by default every record in `stored` - that now has a mark goes on top of that mark's
99
+ * section; one that lost a mark leaves it; every copy of a record in `rows` is replaced. With `joining` empty only the
100
+ * copies change: a flag or a save never moves a record into a section.
101
+ */
102
+ export declare function patchMarkedRecords<TRow extends TableRow>(current: CrudMarkedRecords<TRow>, rows: readonly TRow[], stored: ReadonlyMap<string, number>, keyOf: (row: TRow) => string, joining?: ReadonlySet<string>): CrudMarkedRecords<TRow>;
103
+ /** The marked records with these records out of every section - they left the active lists - keeping their marks. */
104
+ export declare function dropMarkedRows<TRow extends TableRow>(current: CrudMarkedRecords<TRow>, keys: readonly string[], keyOf: (row: TRow) => string): CrudMarkedRecords<TRow>;
60
105
  /** Default time a CRUD keeps a list: 5 minutes. */
61
106
  export declare const CRUD_CACHE_LIFETIME: number;
62
107
  /** Lists kept while a page is open, by key, each for a lifetime. Plain values; nothing is written to storage. */
@@ -1,19 +1,19 @@
1
1
  /*
2
2
  * The rules of the CRUD component, with no React: which lists and actions a controller offers, which actions each list
3
- * shows, which records leave a list after an action, the settings every CRUD follows (how many records may be pinned),
4
- * and the cache of lists kept while the page is open - including how a change is written into the lists kept, so that
5
- * a record saved or marked costs no request on any list.
3
+ * shows, which records leave a list after an action, the settings every CRUD follows (how many records may be pinned
4
+ * or made favourites), a record's marks, and the cache of lists kept while the page is open - including how a change
5
+ * is written into the lists kept, so that a record saved or marked costs no request on any list.
6
6
  */
7
7
  /** Every list, in the order the CRUD shows them. */
8
8
  export const CRUD_MODES = ["normal", "flag", "trash", "deleted"];
9
- const ACTIVE_ROW_ACTIONS = ["view", "edit", "flag", "pin", "trash"];
10
- const ACTIVE_MASS_ACTIONS = ["flag", "pin", "description", "trash"];
11
- /** The built-in actions on one record, per list, in their menu order. */
9
+ const ACTIVE_ROW_ACTIONS = ["view", "edit", "flag", "pin", "favorite", "history", "trash"];
10
+ const ACTIVE_MASS_ACTIONS = ["flag", "pin", "favorite", "description", "trash"];
11
+ /** The built-in actions on one record, per list, in their menu order. A record's history is on every list. */
12
12
  export const CRUD_ROW_ACTIONS = {
13
13
  normal: ACTIVE_ROW_ACTIONS,
14
14
  flag: ACTIVE_ROW_ACTIONS,
15
- trash: ["view", "recover", "delete"],
16
- deleted: ["view", "trashFromDelete"],
15
+ trash: ["view", "history", "recover", "delete"],
16
+ deleted: ["view", "history", "trashFromDelete"],
17
17
  };
18
18
  /** The built-in actions on selected records, per list. */
19
19
  export const CRUD_MASS_ACTIONS = {
@@ -56,14 +56,31 @@ export function crudMassActions(mode, allowed, trashless = false) {
56
56
  export function crudListEndpoint(api, mode, paging) {
57
57
  return paging === "server" ? api.pages?.[mode] : api.lists?.[mode];
58
58
  }
59
- /** The endpoint of a row action. `view` needs none of its own: it shows the row (and calls `details` when there is one). */
59
+ /**
60
+ * The endpoint of a row action. `view` needs none of its own: it shows the row (and calls `details` when there is one).
61
+ * Pin and Favourite both set the record's marks through `bookmark`; a backend without it may still pin, through `pin`.
62
+ */
60
63
  export function crudRowEndpoint(api, action) {
61
64
  if (action === "view")
62
65
  return api.details;
63
66
  if (action === "edit")
64
67
  return api.update;
68
+ if (action === "favorite")
69
+ return api.bookmark;
70
+ if (action === "pin")
71
+ return api.bookmark ?? api.pin;
72
+ if (action === "history")
73
+ return api.audit;
65
74
  return api[action];
66
75
  }
76
+ /** The endpoint of an action on many records: Pin and Favourite through `mass.bookmark`, or Pin through `mass.pin`. */
77
+ export function crudMassEndpoint(api, action) {
78
+ if (action === "favorite")
79
+ return api.mass?.bookmark;
80
+ if (action === "pin")
81
+ return api.mass?.bookmark ?? api.mass?.pin;
82
+ return api.mass?.[action];
83
+ }
67
84
  /** The lists a CRUD can offer: asked for, with an endpoint, and allowed. */
68
85
  export function availableModes(api, paging, wanted, can) {
69
86
  return CRUD_MODES.filter(mode => wanted.includes(mode)).filter(mode => {
@@ -91,13 +108,13 @@ export function actionAllowed(action, api, wanted, can) {
91
108
  export function massActionAllowed(action, api, wanted, can) {
92
109
  if (!wanted.includes(action))
93
110
  return false;
94
- const endpoint = api.mass?.[action];
111
+ const endpoint = crudMassEndpoint(api, action);
95
112
  return Boolean(endpoint) && can(endpoint);
96
113
  }
97
114
  /**
98
115
  * Whether a record leaves the list shown after an action. Trash, recover, delete, and moving back to the trash take it
99
- * to another list, and unflagging leaves the flagged list. Pinning and unpinning never do: a pinned
100
- * record stays in the list it is in. `markOn` is the record's new flag.
116
+ * to another list, and unflagging leaves the flagged list. A pin or a favourite never does: a marked record stays in
117
+ * the list it is in. `markOn` is the record's new flag.
101
118
  */
102
119
  export function leavesList(action, mode, markOn) {
103
120
  switch (action) {
@@ -119,6 +136,8 @@ export function crudMessageAction(action, markOn) {
119
136
  return markOn === false ? "unflag" : "flag";
120
137
  case "pin":
121
138
  return markOn === false ? "unpin" : "pin";
139
+ case "favorite":
140
+ return markOn === false ? "unfavorite" : "favorite";
122
141
  case "trashFromDelete":
123
142
  return "trash";
124
143
  case "description":
@@ -126,6 +145,8 @@ export function crudMessageAction(action, markOn) {
126
145
  case "edit":
127
146
  case "view":
128
147
  return "save";
148
+ case "history":
149
+ return "load";
129
150
  default:
130
151
  return action;
131
152
  }
@@ -153,6 +174,82 @@ export function pinRoom(pinned, max = getCrudSettings().maxPins) {
153
174
  return Number.POSITIVE_INFINITY;
154
175
  return Math.max(0, max - pinned);
155
176
  }
177
+ /* This user's marks on a record */
178
+ /** Every mark, in the order their sections show above the list: Pinned, then Favourites. */
179
+ export const CRUD_MARKS = ["pin", "favorite"];
180
+ /**
181
+ * Each mark's bit in a record's set of marks, as the Nexus backend's `[Flags] EBookmarkTypes` numbers them. A set may
182
+ * hold bits this package does not know - a backend's next mark is its next bit - and a change here keeps them.
183
+ */
184
+ export const CRUD_MARK_BITS = { pin: 1, favorite: 2 };
185
+ /** Whether a set of marks holds this mark. */
186
+ export function hasMark(marks, mark) {
187
+ return (marks & CRUD_MARK_BITS[mark]) !== 0;
188
+ }
189
+ /** The set of marks with this one put on or taken off, and every other bit as it was. */
190
+ export function withMark(marks, mark, on) {
191
+ const bit = CRUD_MARK_BITS[mark];
192
+ return on ? marks | bit : marks & ~bit;
193
+ }
194
+ /** Most records a user may give a mark: `maxPins`, and for favourites `maxFavorites`, else the same as the pins. */
195
+ export function markLimit(mark, settings = getCrudSettings()) {
196
+ return mark === "pin" ? settings.maxPins : (settings.maxFavorites ?? settings.maxPins);
197
+ }
198
+ /**
199
+ * Which ways a selection can go for one mark, from each selected record's marks: on, when one of them lacks it, and
200
+ * off, when one of them has it. A mixed selection can go both ways, and each is offered on its own (the owner,
201
+ * 2026-10-07): no action that would change nothing.
202
+ */
203
+ export function markDirections(marks, mark) {
204
+ return { on: marks.some(set => !hasMark(set, mark)), off: marks.some(set => hasMark(set, mark)) };
205
+ }
206
+ /** The marked records as one listing answered them, in its order: `marksOf` reads each record's marks. */
207
+ export function markedRecords(rows, marksOf, keyOf) {
208
+ const marks = new Map();
209
+ for (const row of rows) {
210
+ const set = marksOf(row);
211
+ if (set)
212
+ marks.set(keyOf(row), set);
213
+ }
214
+ const sections = {};
215
+ for (const mark of CRUD_MARKS)
216
+ sections[mark] = rows.filter(row => hasMark(marks.get(keyOf(row)) ?? 0, mark));
217
+ return { marks, rows: sections };
218
+ }
219
+ /**
220
+ * The marked records after a change, with no request. `stored` is each changed record's new set of marks (`0` for
221
+ * none). A record in `joining` - by default every record in `stored` - that now has a mark goes on top of that mark's
222
+ * section; one that lost a mark leaves it; every copy of a record in `rows` is replaced. With `joining` empty only the
223
+ * copies change: a flag or a save never moves a record into a section.
224
+ */
225
+ export function patchMarkedRecords(current, rows, stored, keyOf, joining = new Set(stored.keys())) {
226
+ const marks = new Map(current.marks);
227
+ for (const [key, set] of stored) {
228
+ if (set)
229
+ marks.set(key, set);
230
+ else
231
+ marks.delete(key);
232
+ }
233
+ const changed = new Map(rows.map(row => [keyOf(row), row]));
234
+ const has = (row, mark) => hasMark(marks.get(keyOf(row)) ?? 0, mark);
235
+ const sections = {};
236
+ for (const mark of CRUD_MARKS) {
237
+ const kept = current.rows[mark].map(row => changed.get(keyOf(row)) ?? row).filter(row => has(row, mark));
238
+ const held = new Set(kept.map(keyOf));
239
+ sections[mark] = [...rows.filter(row => joining.has(keyOf(row)) && has(row, mark) && !held.has(keyOf(row))), ...kept];
240
+ }
241
+ return { marks, rows: sections };
242
+ }
243
+ /** The marked records with these records out of every section - they left the active lists - keeping their marks. */
244
+ export function dropMarkedRows(current, keys, keyOf) {
245
+ const gone = new Set(keys);
246
+ if (!CRUD_MARKS.some(mark => current.rows[mark].some(row => gone.has(keyOf(row)))))
247
+ return current;
248
+ const sections = {};
249
+ for (const mark of CRUD_MARKS)
250
+ sections[mark] = current.rows[mark].filter(row => !gone.has(keyOf(row)));
251
+ return { marks: current.marks, rows: sections };
252
+ }
156
253
  /** Default time a CRUD keeps a list: 5 minutes. */
157
254
  export const CRUD_CACHE_LIFETIME = 5 * 60 * 1000;
158
255
  /** Lists kept while a page is open, by key, each for a lifetime. Plain values; nothing is written to storage. */
@@ -43,8 +43,8 @@ export interface ExcelDownloadHeaders {
43
43
  export declare abstract class ExcelBackend {
44
44
  /** The form field an upload is sent under. */
45
45
  abstract get uploadField(): string;
46
- /** What an import sends: the rows the verify answered with, as the person left them. */
47
- abstract importRequest(rows: readonly ExcelSheetRow[]): unknown;
46
+ /** What an import sends: the rows the verify answered with, as the person left them, and the status they start in. */
47
+ abstract importRequest(rows: readonly ExcelSheetRow[], recordStatus?: number): unknown;
48
48
  /** What an export sends: the search, the sort and the filters the person has on screen. */
49
49
  abstract exportRequest(query: ExcelExportQuery): unknown;
50
50
  /** Reads what the settings endpoint answered. */
@@ -67,7 +67,7 @@ export declare abstract class ExcelBackend {
67
67
  */
68
68
  export declare class NexusExcelBackend extends ExcelBackend {
69
69
  get uploadField(): string;
70
- importRequest(rows: readonly ExcelSheetRow[]): unknown;
70
+ importRequest(rows: readonly ExcelSheetRow[], recordStatus?: number): unknown;
71
71
  /**
72
72
  * The same request a Nexus listing takes, minus `skipCount` and `maxCount`: an export writes every row the search
73
73
  * leaves, up to the backend's own limit, not the page the person happens to be on. With nothing sorted it follows