nexus-shared 3.1.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 (104) hide show
  1. package/CHANGELOG.md +704 -446
  2. package/LICENSE +21 -21
  3. package/README.md +44 -5
  4. package/dist/Client.Index.d.ts +3 -0
  5. package/dist/Client.Index.js +3 -0
  6. package/dist/Components/Forms/Crud.js +322 -161
  7. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  8. package/dist/Components/Forms/CrudHistory.js +140 -0
  9. package/dist/Components/Forms/CrudParts.js +2 -1
  10. package/dist/Components/Forms/Form.js +4 -1
  11. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  12. package/dist/Components/Inputs/CodeBox.js +316 -0
  13. package/dist/Components/Inputs/DatePicker.js +4 -1
  14. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  15. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  16. package/dist/Components/Inputs/Dropdown.js +4 -1
  17. package/dist/Components/Inputs/InputRenderer.js +3 -0
  18. package/dist/Components/Inputs/NumberBox.js +4 -2
  19. package/dist/Components/Inputs/TextArea.js +4 -1
  20. package/dist/Components/Inputs/TextBox.js +4 -1
  21. package/dist/Components/Inputs/TimePicker.js +4 -1
  22. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  23. package/dist/Components/Inputs/TreeView.js +372 -23
  24. package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
  25. package/dist/Components/Layouts/ThemePicker.js +16 -4
  26. package/dist/Components/Viewers/DataTable.js +63 -21
  27. package/dist/Components/Viewers/DataTableParts.d.ts +26 -8
  28. package/dist/Components/Viewers/DataTableParts.js +39 -10
  29. package/dist/Helpers/ApiModules.d.ts +3 -34
  30. package/dist/Helpers/ApiModules.js +3 -3
  31. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  32. package/dist/Helpers/ApiRoutes.js +31 -27
  33. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  34. package/dist/Helpers/ArrayHelpers.js +2 -0
  35. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  36. package/dist/Helpers/CodeHelpers.js +332 -0
  37. package/dist/Helpers/CrudBackend.d.ts +94 -4
  38. package/dist/Helpers/CrudBackend.js +168 -9
  39. package/dist/Helpers/CrudColumns.d.ts +51 -0
  40. package/dist/Helpers/CrudColumns.js +69 -0
  41. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  42. package/dist/Helpers/CrudHelpers.js +109 -12
  43. package/dist/Helpers/InputParamsHelpers.js +8 -0
  44. package/dist/Helpers/MessageBuilder.js +2 -0
  45. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  46. package/dist/Helpers/NexusHelpers.js +1 -0
  47. package/dist/Helpers/RequestCache.d.ts +35 -0
  48. package/dist/Helpers/RequestCache.js +113 -0
  49. package/dist/Helpers/TableExport.js +12 -12
  50. package/dist/Helpers/TableHelpers.d.ts +1 -1
  51. package/dist/Helpers/TableHelpers.js +7 -2
  52. package/dist/Helpers/TableJoins.d.ts +70 -0
  53. package/dist/Helpers/TableJoins.js +250 -0
  54. package/dist/Helpers/TableStore.d.ts +5 -0
  55. package/dist/Helpers/TableStore.js +9 -0
  56. package/dist/Helpers/TreeStore.d.ts +225 -11
  57. package/dist/Helpers/TreeStore.js +683 -90
  58. package/dist/Helpers/UserPreferences.d.ts +8 -0
  59. package/dist/Helpers/UserPreferences.js +17 -0
  60. package/dist/Interfaces/CrudInterfaces.d.ts +47 -9
  61. package/dist/Interfaces/FormInterfaces.d.ts +4 -1
  62. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  63. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  64. package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
  65. package/dist/Interfaces/PreferenceInterfaces.js +2 -5
  66. package/dist/Interfaces/TableInterfaces.d.ts +86 -4
  67. package/dist/Services/ApiProxy.d.ts +36 -1
  68. package/dist/Services/ApiProxy.js +182 -52
  69. package/dist/Services/ApiProxyRules.d.ts +203 -0
  70. package/dist/Services/ApiProxyRules.js +426 -0
  71. package/dist/Services/JoinSources.d.ts +48 -0
  72. package/dist/Services/JoinSources.js +74 -0
  73. package/dist/Services/ServerApi.d.ts +13 -2
  74. package/dist/Services/ServerApi.js +8 -3
  75. package/dist/Shared.Index.d.ts +4 -0
  76. package/dist/Shared.Index.js +4 -0
  77. package/package.json +87 -85
  78. package/src/Styles/Nexus.Avatar.css +79 -79
  79. package/src/Styles/Nexus.Base.css +117 -117
  80. package/src/Styles/Nexus.Button.css +180 -180
  81. package/src/Styles/Nexus.Calendar.css +273 -273
  82. package/src/Styles/Nexus.Chat.css +1443 -1443
  83. package/src/Styles/Nexus.Choice.css +343 -343
  84. package/src/Styles/Nexus.Crud.css +168 -168
  85. package/src/Styles/Nexus.DateTime.css +81 -81
  86. package/src/Styles/Nexus.Dropdown.css +725 -725
  87. package/src/Styles/Nexus.Editor.css +295 -295
  88. package/src/Styles/Nexus.Excel.css +389 -389
  89. package/src/Styles/Nexus.Form.css +279 -279
  90. package/src/Styles/Nexus.Index.css +37 -37
  91. package/src/Styles/Nexus.Input.css +196 -4
  92. package/src/Styles/Nexus.Markdown.css +237 -237
  93. package/src/Styles/Nexus.Menu.css +237 -237
  94. package/src/Styles/Nexus.Popup.css +724 -724
  95. package/src/Styles/Nexus.Rows.css +637 -637
  96. package/src/Styles/Nexus.Slider.css +276 -276
  97. package/src/Styles/Nexus.Tab.Buttons.css +432 -432
  98. package/src/Styles/Nexus.Table.css +31 -30
  99. package/src/Styles/Nexus.Theme.Picker.css +59 -3
  100. package/src/Styles/Nexus.Themes.css +194 -194
  101. package/src/Styles/Nexus.Time.css +144 -144
  102. package/src/Styles/Nexus.Toast.css +263 -263
  103. package/src/Styles/Nexus.Tokens.css +89 -89
  104. package/src/Styles/Nexus.Tree.css +500 -431
@@ -0,0 +1,332 @@
1
+ import { DEFAULT_FIELD_NAME, requiredMessage } from "./ValidationHelpers.js";
2
+ /*
3
+ * Codes typed one character to a box: a one-time code, a PIN, an SSN, a licence key, a hex colour. Pure functions, so a
4
+ * form checks a code it loaded the way the box checks one typed, without a browser.
5
+ */
6
+ /** The boxes a code has when it names neither `length` nor `groups`. */
7
+ export const DEFAULT_CODE_LENGTH = 6;
8
+ /** One character of each kind, as a regular expression. */
9
+ export const CODE_CHARACTER_PATTERNS = {
10
+ digits: "[0-9]",
11
+ letters: "[A-Za-z]",
12
+ alphanumeric: "[0-9A-Za-z]",
13
+ hex: "[0-9A-Fa-f]",
14
+ };
15
+ /** What messages call the characters: "The code needs all 6 digits." */
16
+ const CHARACTER_NAMES = { digits: "digits", letters: "letters", alphanumeric: "characters", hex: "hex digits" };
17
+ /** What a code takes, for "SSN takes digits only." */
18
+ const ONLY_NAMES = { digits: "digits", letters: "letters", alphanumeric: "letters and digits", hex: "hex digits, 0–9 and A–F" };
19
+ /** The boxes in their groups: `groups` when given (`[3, 2, 4]`), else one group of `length`, 6 by default. */
20
+ export function codeGroups(rules) {
21
+ const groups = (rules.groups ?? []).filter(size => Number.isInteger(size) && size > 0);
22
+ if (groups.length)
23
+ return groups;
24
+ const { length = DEFAULT_CODE_LENGTH } = rules;
25
+ return [Number.isInteger(length) && length > 0 ? length : DEFAULT_CODE_LENGTH];
26
+ }
27
+ /** How many boxes a code has: 9 for `[3, 2, 4]`. */
28
+ export const codeLength = (rules) => codeGroups(rules).reduce((sum, size) => sum + size, 0);
29
+ const characterTests = new Map();
30
+ /** Whether one character is one the code takes. Built once per pattern, since a form may check thousands of codes. */
31
+ function characterTest(rules) {
32
+ const source = rules.allowed || CODE_CHARACTER_PATTERNS[rules.characters ?? "digits"];
33
+ let test = characterTests.get(source);
34
+ if (!test) {
35
+ test = new RegExp(`^(?:${source})$`, "u");
36
+ characterTests.set(source, test);
37
+ }
38
+ return test;
39
+ }
40
+ function withCase(text, letterCase = "upper") {
41
+ return letterCase === "upper" ? text.toUpperCase() : letterCase === "lower" ? text.toLowerCase() : text;
42
+ }
43
+ /**
44
+ * Text as the code holds it: letters in the code's case, every character it does not take dropped, and cut to its
45
+ * length: "123-45-6789" is "123456789" for an SSN. The box never does this to a value on its own: text that arrives in
46
+ * it at once is read by `readCodeText`, which finds the code in a message, and a saved value by `loadCode`, which keeps
47
+ * what does not fit so its check can say so.
48
+ */
49
+ export function normalizeCode(text, rules = {}) {
50
+ if (!text)
51
+ return "";
52
+ const test = characterTest(rules);
53
+ const length = codeLength(rules);
54
+ const kept = [];
55
+ for (const character of withCase(text, rules.letterCase)) {
56
+ if (kept.length === length)
57
+ break;
58
+ if (test.test(character))
59
+ kept.push(character);
60
+ }
61
+ return kept.join("");
62
+ }
63
+ /** One space, dash, slash or dot between the groups of a code written out: "482 913", "123-45-6789", "03/04/1990". */
64
+ const GROUP_JOINER = /^[\s./–-]$/u;
65
+ /**
66
+ * A code as it was saved or given (a form's loaded record, `value`, `defaultValue`), as the box holds it. It loses only
67
+ * what writing a code out adds: letters take the code's case, and separators the code does not take - spaces, dashes,
68
+ * slashes, dots, the field's own `separator`, and a hex code's leading "#" - come out: "123-45-6789" is "123456789".
69
+ * Nothing else changes. A character the code does not take, or one past its boxes, stays, so its check says what is
70
+ * wrong ("SSN takes digits only.", "The code is 6 digits long, not 7.") and a form never saves it back changed.
71
+ */
72
+ export function loadCode(value, shape = {}) {
73
+ if (value === null || value === undefined)
74
+ return "";
75
+ const test = characterTest(shape);
76
+ const separator = shape.separator ?? "";
77
+ const hex = shape.characters === "hex" && !shape.allowed;
78
+ let kept = "";
79
+ for (const character of withCase(String(value), shape.letterCase)) {
80
+ const writtenOut = GROUP_JOINER.test(character) || (separator !== "" && separator.includes(character)) || (hex && character === "#" && kept === "");
81
+ if (writtenOut && !test.test(character))
82
+ continue;
83
+ kept += character;
84
+ }
85
+ return kept;
86
+ }
87
+ /** Whether the boxes can hold a code as it is: every character one the code takes, and no more than the boxes. */
88
+ export function codeFits(value, rules = {}) {
89
+ const characters = [...(value ?? "")];
90
+ if (characters.length > codeLength(rules))
91
+ return false;
92
+ const test = characterTest(rules);
93
+ return characters.every(character => test.test(withCase(character, rules.letterCase)));
94
+ }
95
+ /** What a description calls one character of a code, and several: "6 digits", "1 letter". */
96
+ const DESCRIBED_NAMES = {
97
+ digits: ["digit", "digits"],
98
+ letters: ["letter", "letters"],
99
+ alphanumeric: ["letter or digit", "letters and digits"],
100
+ hex: ["hex digit", "hex digits"],
101
+ };
102
+ /**
103
+ * What a screen reader is told about the boxes it cannot see, after the label: how many characters of which kind, in
104
+ * which groups, and the hint in each box - "6 digits", "9 digits, in groups of 3, 2 and 4", "8 digits: DD/MM/YYYY".
105
+ */
106
+ export function describeCode(shape = {}) {
107
+ const groups = codeGroups(shape);
108
+ const total = codeLength(shape);
109
+ const [one, many] = shape.allowed ? ["character", "characters"] : DESCRIBED_NAMES[shape.characters ?? "digits"];
110
+ const count = `${total} ${total === 1 ? one : many}`;
111
+ const hints = [...(shape.slotPlaceholder ?? "")];
112
+ if (hints.length === total && total > 1)
113
+ return `${count}: ${formatCode(hints.join(""), { groups, separator: shape.separator })}`;
114
+ if (groups.length < 2)
115
+ return count;
116
+ const sizes = groups.map(String);
117
+ return `${count}, in groups of ${sizes.slice(0, -1).join(", ")} and ${sizes[sizes.length - 1]}`;
118
+ }
119
+ /**
120
+ * The code in text that arrives at once - a paste, a drop, a keyboard's suggestion, autofill, an input method's word.
121
+ * It is whole when the text holds one code of exactly as many characters as the boxes: one run of them ("482913" in
122
+ * "Your 2-step code is 482913"), or one run split by single spaces, dashes, slashes or dots ("482 913", "123-45-6789"),
123
+ * or every character the text holds when they number exactly that ("(482) 913"). Otherwise its characters are a part of
124
+ * a code, or `null` when they are more than the boxes ("Codes 4829135 and 1234567"): a message is never guessed at.
125
+ */
126
+ export function readCodeText(text, rules = {}) {
127
+ const test = characterTest(rules);
128
+ const total = codeLength(rules);
129
+ // The runs of characters the code takes, each marked when a single joiner links it to the run before.
130
+ const runs = [];
131
+ let run = null;
132
+ let gap = [];
133
+ for (const character of withCase(text ?? "", rules.letterCase)) {
134
+ if (!test.test(character)) {
135
+ run = null;
136
+ gap.push(character);
137
+ continue;
138
+ }
139
+ if (!run) {
140
+ const joiner = gap.length === 1 ? gap[0] : undefined;
141
+ run = { characters: [], joined: runs.length > 0 && joiner !== undefined && GROUP_JOINER.test(joiner) };
142
+ runs.push(run);
143
+ }
144
+ run.characters.push(character);
145
+ gap = [];
146
+ }
147
+ // Each way of writing a whole code; two different ones are no answer.
148
+ const candidates = new Set();
149
+ for (const run of runs) {
150
+ if (run.characters.length === total)
151
+ candidates.add(run.characters.join(""));
152
+ }
153
+ let group = [];
154
+ let parts = 0;
155
+ for (const [index, run] of runs.entries()) {
156
+ if (index > 0 && !run.joined) {
157
+ if (parts > 1 && group.length === total)
158
+ candidates.add(group.join(""));
159
+ group = [];
160
+ parts = 0;
161
+ }
162
+ group.push(...run.characters);
163
+ parts++;
164
+ }
165
+ if (parts > 1 && group.length === total)
166
+ candidates.add(group.join(""));
167
+ if (candidates.size === 1) {
168
+ const [code = ""] = candidates;
169
+ return { code, whole: true };
170
+ }
171
+ const all = runs.flatMap(run => run.characters);
172
+ if (all.length > total)
173
+ return null;
174
+ return { code: all.join(""), whole: all.length === total };
175
+ }
176
+ /**
177
+ * A full code with its caret at the end selects its last box, so the next key replaces it and Backspace or Delete
178
+ * remove it: the box with the ring is the one they act on. Any other edit is returned as it is.
179
+ */
180
+ export function settleCode(edit, rules = {}) {
181
+ const total = codeLength(rules);
182
+ if (edit.start === edit.end && edit.start >= total && [...edit.value].length === total)
183
+ return { value: edit.value, start: total - 1, end: total };
184
+ return edit;
185
+ }
186
+ /**
187
+ * A code after text arrives at its caret - the same for a key, a phone's keyboard, an input method and a paste. A whole
188
+ * code (see `readCodeText`) replaces everything. Otherwise each character goes in as if typed: over the selection, or at
189
+ * a caret into the box after it - inserted while the code has an empty box, so a gap Backspace or Delete left takes back
190
+ * what was there and the boxes after it move along, and over that box once the code is full. A key at the end of a full
191
+ * code takes the last box; the rest of a paste that finds no box left is not typed. `null` when the text changes nothing.
192
+ */
193
+ export function typeCode(edit, text, rules = {}) {
194
+ const read = readCodeText(text, rules);
195
+ if (!read || !read.code)
196
+ return null;
197
+ const total = codeLength(rules);
198
+ if (read.whole)
199
+ return settleCode({ value: read.code, start: total, end: total }, rules);
200
+ const characters = [...edit.value];
201
+ let start = Math.max(0, Math.min(edit.start, characters.length));
202
+ let end = Math.max(start, Math.min(edit.end, characters.length));
203
+ let typed = 0;
204
+ for (const character of read.code) {
205
+ if (end > start) {
206
+ characters.splice(start, end - start, character);
207
+ }
208
+ else if (characters.length < total) {
209
+ characters.splice(start, 0, character);
210
+ }
211
+ else if (start < total) {
212
+ characters[start] = character;
213
+ }
214
+ else if (typed === 0) {
215
+ start = total - 1;
216
+ characters[start] = character;
217
+ }
218
+ else {
219
+ break;
220
+ }
221
+ start += 1;
222
+ end = start;
223
+ typed += 1;
224
+ }
225
+ return settleCode({ value: characters.join(""), start, end }, rules);
226
+ }
227
+ /**
228
+ * Where ← → Home End take a code's caret: a box at a time, and a filled box it lands on is selected, so a key replaces
229
+ * it and Backspace or Delete remove it. It stops at the first empty box, where the next character goes, or on a full
230
+ * code at the last box. With several boxes selected, ← goes to the first of them and → to the last. End on a saved code
231
+ * longer than its boxes goes to its very end, so Backspace takes what the boxes cannot show.
232
+ */
233
+ export function moveCode(edit, key, rules = {}) {
234
+ const total = codeLength(rules);
235
+ const length = [...edit.value].length;
236
+ if (key === "End" && length > total)
237
+ return { value: edit.value, start: edit.value.length, end: edit.value.length };
238
+ const furthest = Math.min(length, total - 1);
239
+ let box;
240
+ if (key === "Home")
241
+ box = 0;
242
+ else if (key === "End")
243
+ box = furthest;
244
+ else if (edit.end - edit.start > 1)
245
+ box = key === "ArrowLeft" ? edit.start : edit.end - 1;
246
+ else
247
+ box = Math.min(edit.start, total - 1) + (key === "ArrowLeft" ? -1 : 1);
248
+ box = Math.max(0, Math.min(box, furthest));
249
+ return { value: edit.value, start: box, end: box < length ? box + 1 : box };
250
+ }
251
+ /**
252
+ * A code after the browser changed its input itself - a phone's keyboard, an input method's word, autofill, a drop,
253
+ * Backspace or Delete - worked out from the edit before the change and the input as the browser left it. Text that
254
+ * arrived goes in by `typeCode`, so every way of typing follows one model, and text it does not take leaves the code as
255
+ * it was; a removal stands as the browser made it, the boxes after it closed up.
256
+ */
257
+ export function codeAfterInput(before, after, rules = {}) {
258
+ if (after.value === before.value)
259
+ return after;
260
+ const head = before.value.slice(0, before.start);
261
+ const tail = before.value.slice(before.end);
262
+ let start = before.start;
263
+ let end = before.end;
264
+ let arrived;
265
+ if (after.value.length >= head.length + tail.length && after.value.startsWith(head) && after.value.endsWith(tail)) {
266
+ // Text in the selection's place or at the caret: a key, a keyboard's word, an input method's commit.
267
+ arrived = after.value.slice(head.length, after.value.length - tail.length);
268
+ }
269
+ else {
270
+ // Anything else, such as Backspace at a caret or autofill: the part that differs.
271
+ start = 0;
272
+ while (start < before.value.length && start < after.value.length && before.value[start] === after.value[start])
273
+ start++;
274
+ let same = 0;
275
+ while (same < before.value.length - start &&
276
+ same < after.value.length - start &&
277
+ before.value[before.value.length - 1 - same] === after.value[after.value.length - 1 - same]) {
278
+ same++;
279
+ }
280
+ end = before.value.length - same;
281
+ arrived = after.value.slice(start, after.value.length - same);
282
+ }
283
+ if (!arrived)
284
+ return after;
285
+ return typeCode({ value: before.value, start, end }, arrived, rules) ?? before;
286
+ }
287
+ /**
288
+ * Checks a code against its rules and returns the first message, or `null` when it is valid: required, every box
289
+ * filled, and only the characters it takes. Pure and DOM-free, like `validateText`.
290
+ */
291
+ export function validateCode(value, rules, fieldName = DEFAULT_FIELD_NAME) {
292
+ const code = (value ?? "").trim();
293
+ if (code) {
294
+ const kind = rules.characters ?? "digits";
295
+ const test = characterTest(rules);
296
+ const characters = [...code];
297
+ if (characters.some(character => !test.test(withCase(character, rules.letterCase)))) {
298
+ return rules.allowed ? `${fieldName} has a character it does not take.` : `${fieldName} takes ${ONLY_NAMES[kind]} only.`;
299
+ }
300
+ const length = codeLength(rules);
301
+ const names = rules.allowed ? "characters" : CHARACTER_NAMES[kind];
302
+ if (characters.length < length)
303
+ return `${fieldName} needs all ${length} ${names}.`;
304
+ if (characters.length > length)
305
+ return `${fieldName} is ${length} ${names} long, not ${characters.length}.`;
306
+ }
307
+ else if (rules.required) {
308
+ return requiredMessage(fieldName);
309
+ }
310
+ return rules.validate?.(code) || null;
311
+ }
312
+ /**
313
+ * A code as text to read, in its groups: "123-45-6789" with `separator: "-"`, "482 913" for `[3, 3]` with none, and a dot
314
+ * for each character when masked. "" for no code.
315
+ */
316
+ export function formatCode(value, rules = {}) {
317
+ const characters = [...(value ?? "")].map(character => (rules.masked ? "•" : character));
318
+ if (!characters.length)
319
+ return "";
320
+ const parts = [];
321
+ let start = 0;
322
+ for (const size of codeGroups(rules)) {
323
+ if (start >= characters.length)
324
+ break;
325
+ parts.push(characters.slice(start, start + size).join(""));
326
+ start += size;
327
+ }
328
+ // A code longer than its boxes (a value from elsewhere) keeps the rest, so nothing is hidden.
329
+ if (start < characters.length)
330
+ parts.push(characters.slice(start).join(""));
331
+ return parts.join(rules.separator || " ");
332
+ }
@@ -1,6 +1,23 @@
1
- import type { CrudMassActionName, CrudMode } from "../Interfaces/CrudInterfaces.ts";
2
- import type { TableKey, TablePage, TableQuery, TableRow } from "../Interfaces/TableInterfaces.ts";
1
+ import type { ApiQuery } from "../Interfaces/ApiInterfaces.ts";
2
+ import type { CrudMark, CrudMassActionName, CrudMode } from "../Interfaces/CrudInterfaces.ts";
3
+ import type { TableKey, TablePage, TableQuery, TableRow, TableTag } from "../Interfaces/TableInterfaces.ts";
3
4
  type Row = Record<string, unknown>;
5
+ /** What a write of marks sends beside the endpoint's own path: query values, a body, or both. */
6
+ export interface CrudMarksRequest {
7
+ query?: ApiQuery;
8
+ body?: unknown;
9
+ }
10
+ /** The words of a record's history, by the numbers the backend keeps (`CrudBackend.auditWords`). */
11
+ export interface CrudAuditWords {
12
+ /** What a request did, by its command number: "Added", "Moved to the trash". */
13
+ commands: Readonly<Record<number, string>>;
14
+ /** Audit keys that are the whole story, by number: "Flagged", "Made a favourite". */
15
+ directions: Readonly<Record<number, string>>;
16
+ /** The part of the record an update changed, by audit key: "basic information". */
17
+ parts: Readonly<Record<number, string>>;
18
+ /** The command number of a plain update: the one whose audit key names a part. */
19
+ update?: number;
20
+ }
4
21
  export declare abstract class CrudBackend {
5
22
  /**
6
23
  * What a server table sends for one page, from the query. `sortKey` is the sorted column's server name. `filters` are
@@ -16,9 +33,57 @@ export declare abstract class CrudBackend {
16
33
  abstract isFlagged(row: TableRow): boolean;
17
34
  /** The row with its flag set or cleared. */
18
35
  abstract withFlag<TRow extends TableRow>(row: TRow, on: boolean): TRow;
36
+ /** Whether a record can be flagged or unflagged at all. Default yes. */
37
+ canFlag(row: TableRow): boolean;
38
+ /**
39
+ * What flagging one record or many sends beside the key(s): the state wanted, never a toggle. Default the Nexus
40
+ * backend's `?isFlagged=true`. A backend that toggles ignores it, and a CRUD sends only the records a change is for.
41
+ */
42
+ flagRequest(on: boolean): CrudMarksRequest;
19
43
  abstract isPinned(row: TableRow): boolean;
20
44
  /** The row with its pin set or cleared. */
21
45
  abstract withPin<TRow extends TableRow>(row: TRow, on: boolean): TRow;
46
+ /**
47
+ * This user's marks on a record as a set of bits (`CRUD_MARK_BITS`: pinned 1, favourite 2), as a bookmark listing
48
+ * answers them on each record. Bits the package does not know are kept as they are. Default: the pin alone, through
49
+ * `isPinned`.
50
+ */
51
+ marksOf(row: TableRow): number;
52
+ /** The row carrying this set of marks. Default: the pin alone, through `withPin`. */
53
+ withMarks<TRow extends TableRow>(row: TRow, marks: number): TRow;
54
+ /**
55
+ * What setting one record's marks sends beside its key: the **whole set** it is to have, never a toggle. Default the
56
+ * Nexus backend's: `?bookmarkTypes=3`.
57
+ */
58
+ marksRequest(marks: number): CrudMarksRequest;
59
+ /**
60
+ * What putting one mark on many records, or taking it off, sends: the keys, the mark and which way. Each record keeps
61
+ * its other marks. Default the Nexus backend's: the keys as the body, `?bookmarkTypes=2&isAdding=true`.
62
+ */
63
+ massMarksRequest(keys: TableKey[], mark: CrudMark, on: boolean): CrudMarksRequest;
64
+ /** The marks a write left on the record, read from its answer. `null` when it does not say, and the set sent stands. */
65
+ answeredMarks(result: unknown): number | null;
66
+ /** What an export sends to name the list on screen, beside the table's own filters. Default nothing. */
67
+ exportFilters(mode: CrudMode): Record<string, unknown>;
68
+ /** A record status in words, for a status column and the history: "Active", "Flagged", "Trashed". Default the number. */
69
+ statusText(status: number): string;
70
+ /**
71
+ * The short tag a reference to a record carries when that record is no longer available - "Trashed" beside a parent in
72
+ * the trash, "Deleted" beside one deleted - read from its status. `null` for a live record, and for no status at all.
73
+ * A joined column (`crudColumns.dbJoin`, `crudColumns.clientJoin`) draws it after the name. Default: none.
74
+ */
75
+ unavailableTag(status: unknown): TableTag | null;
76
+ /**
77
+ * Whether a record may be picked in a form: what a picker offers, and what it shows tagged and will not offer again when
78
+ * a form already holds it. Read from the record. Default: every record.
79
+ */
80
+ isPickable(record: TableRow): boolean;
81
+ /**
82
+ * The words of a record's history: what each command did (`commands`), the audit keys that are the whole story of a
83
+ * switch (`directions`), and the parts of the record an update names (`parts`, with `update` the command that names
84
+ * one). Numbers an audit row keeps for good, so each backend's own. Default none: "Action 3".
85
+ */
86
+ get auditWords(): CrudAuditWords;
22
87
  /**
23
88
  * Changes an action's answer makes to rows, by key: a mass flag that answers each record's new status. Rows without
24
89
  * an entry take the change the action stands for (a toggled flag). Default: none.
@@ -34,7 +99,7 @@ export declare abstract class CrudBackend {
34
99
  * `null` when it says nothing about it - a bare key, an empty body - and then the caller keeps what it has.
35
100
  *
36
101
  * **This is what spares a page its reload.** A save or a mark whose answer carries the record is written into the
37
- * table, the pinned band and every list kept in the cache, and nothing is loaded again. Only answers of actions on
102
+ * table, the sections above it and every list kept in the cache, and nothing is loaded again. Only answers of actions on
38
103
  * **one** record are read here; a mass answer goes through `answeredChanges`, record by record.
39
104
  */
40
105
  answeredRow<TRow extends TableRow>(result: unknown): Partial<TRow> | null;
@@ -65,16 +130,41 @@ export declare const NEXUS_DELETED_STATUS = 60;
65
130
  /**
66
131
  * The Nexus backend: pages as `{ ...filters, searchKeyword, skipCount, maxCount, sorting, sortType }` answered with
67
132
  * `{ results, totalCount, filterCount }`, newest first unless a column is sorted; mass actions send the ids
68
- * (`{ description, accountIds }` for descriptions); a flagged record has `recordStatus` 25, a pinned one `isPinned`.
133
+ * (`{ description, accountIds }` for descriptions); a flagged record has `recordStatus` 25, and this user's marks are
134
+ * its `bookmarkTypes` (`[Flags] EBookmarkTypes`: 1 pinned, 2 favourite), or `isPinned` from a backend before them.
69
135
  */
70
136
  export declare class NexusCrudBackend extends CrudBackend {
71
137
  pageRequest(query: TableQuery, sortKey: string | undefined, filters?: Readonly<Record<string, unknown>>): unknown;
72
138
  readPage<TRow extends TableRow>(result: unknown): TablePage<TRow>;
73
139
  massRequest(action: CrudMassActionName, keys: TableKey[], values?: Row): unknown;
140
+ /** The whole flag band, 25-29: every status comparison is a range test. */
74
141
  isFlagged(row: TableRow): boolean;
142
+ /**
143
+ * A live record that is not pending (0-29): the backend flags 0-24 and unflags 25-29, and never touches a pending
144
+ * record (30-39), which would lose that status for good. A record with no status yet is not refused here.
145
+ */
146
+ canFlag(row: TableRow): boolean;
75
147
  withFlag<TRow extends TableRow>(row: TRow, on: boolean): TRow;
76
148
  isPinned(row: TableRow): boolean;
77
149
  withPin<TRow extends TableRow>(row: TRow, on: boolean): TRow;
150
+ marksOf(row: TableRow): number;
151
+ /** `bookmarkTypes`, and `isPinned` too on a record that has one, so neither says something the other does not. */
152
+ withMarks<TRow extends TableRow>(row: TRow, marks: number): TRow;
153
+ /** `SwitchBookmarks` answers the marks it stored, as a number. */
154
+ answeredMarks(result: unknown): number | null;
155
+ /** An export names the list on screen as `flag`, by the number `ECommandTypes` gives its listing. */
156
+ exportFilters(mode: CrudMode): Record<string, unknown>;
157
+ /** The members `ERecordStatus` names, then the band of any other number in it. */
158
+ statusText(status: number): string;
159
+ /**
160
+ * 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
161
+ * very short tag"): "Trashed" in the warning color, "Deleted" in the danger color. A flagged or pending record is still
162
+ * live, and carries none.
163
+ */
164
+ unavailableTag(status: unknown): TableTag | null;
165
+ /** Usable, under 30, as a Nexus `ActiveListing` answers: a pending record is live but not one to pick. No status reads as usable. */
166
+ isPickable(record: TableRow): boolean;
167
+ get auditWords(): CrudAuditWords;
78
168
  /** Mass answers list the records they changed, with their new `recordStatus` and `isPinned`. */
79
169
  answeredChanges(result: unknown, rowKey: string): Map<string, Row>;
80
170
  /**