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
@@ -1,34 +1,61 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { iconArrowBackUp, iconEye, iconFlag, iconFlagOff, iconInfoCircle, iconList, iconLoader2, iconLock, iconNotes, iconPencil, iconPin, iconPinnedOff, iconPlus, iconTrash, iconTrashX, InlineIcon, } from "nexus-icons";
3
+ import { iconArrowBackUp, iconEye, iconFlag, iconFlagOff, iconHistory, iconInfoCircle, iconList, iconLoader2, iconLock, iconNotes, iconPencil, iconPin, iconPinnedOff, iconPlus, iconStar, iconStarOff, iconTrash, iconTrashX, InlineIcon, } from "nexus-icons";
4
4
  import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { abortError } from "../../Helpers/ApiFormHelpers.js";
6
6
  import { isAbortError, unwrapApiResult } from "../../Helpers/ApiResponses.js";
7
7
  import { getCrudBackend } from "../../Helpers/CrudBackend.js";
8
- import { actionAllowed, availableModes, CRUD_CACHE_LIFETIME, CRUD_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudListEndpoint, crudMassActions, crudMessageAction, crudRowActions, crudRowEndpoint, getCrudSettings, leavesList, massActionAllowed, patchCachedList, pinRoom, } from "../../Helpers/CrudHelpers.js";
8
+ import { actionAllowed, availableModes, CRUD_CACHE_LIFETIME, CRUD_MARK_BITS, CRUD_MARKS, CRUD_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudListEndpoint, crudMassActions, crudMassEndpoint, crudMessageAction, crudRowActions, crudRowEndpoint, dropMarkedRows, hasMark, leavesList, markDirections, markedRecords, markLimit, massActionAllowed, patchCachedList, patchMarkedRecords, pinRoom, withMark, } from "../../Helpers/CrudHelpers.js";
9
9
  import { cx } from "../../Helpers/ClassHelpers.js";
10
10
  import { FormStore } from "../../Helpers/FormStore.js";
11
- import { pluralize } from "../../Helpers/MessageBuilder.js";
11
+ import { buildMessage, pluralize } from "../../Helpers/MessageBuilder.js";
12
12
  import { getPermissions, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
13
13
  import { filterRequest } from "../../Helpers/TableFilters.js";
14
14
  import { columnValue, formatCellText, readPath } from "../../Helpers/TableHelpers.js";
15
15
  import { api as browserApi } from "../../Services/BrowserApi.js";
16
+ import { expireLookups } from "../../Services/JoinSources.js";
16
17
  import { Button } from "../Documents/Button.js";
17
18
  import { TabButtons } from "../Documents/TabButtons.js";
18
19
  import { openPopup } from "../Layouts/PopupHost.js";
19
20
  import { toast } from "../Layouts/Toaster.js";
20
21
  import { DataTable, useTable } from "../Viewers/DataTable.js";
21
22
  import { ApiForm } from "./ApiForm.js";
23
+ import { openAuditHistory } from "./CrudHistory.js";
22
24
  import { CrudDetails } from "./CrudParts.js";
23
25
  import { confirmDiscard, FormButtons } from "./SubmitForm.js";
24
26
  /*
25
27
  * Crud: a module's records in one component. It takes a controller's endpoints, the table's columns, and the form's
26
28
  * inputs, and handles the rest: the lists (all, flagged, trash, deleted) the user may see, the actions they
27
- * may take, the records the user pinned in a section above the list, Add and Edit popups built on ApiForm, a details
28
- * popup, actions on many rows, a cache of lists while the page is open, and rows updated from each answer instead of
29
- * reloading.
29
+ * may take, the records the user pinned and their favourites in sections above the list, Add and Edit popups built on
30
+ * ApiForm, a details popup, actions on many rows, a cache of lists while the page is open, and rows updated from each
31
+ * answer instead of reloading.
30
32
  */
31
- const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"];
33
+ const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "favorite", "history", "trash", "recover", "delete", "trashFromDelete", "description"];
34
+ /** How each mark shows: its section above the list, the actions that put it on and take it off, and what its limit says. */
35
+ const MARK_INFO = {
36
+ pin: {
37
+ section: "pins",
38
+ title: "Pinned",
39
+ icon: iconPin,
40
+ offIcon: iconPinnedOff,
41
+ on: "Pin",
42
+ off: "Unpin",
43
+ full: (max, plural) => `You can pin up to ${max} ${plural}. Unpin one to pin another.`,
44
+ over: (sent, asked, max, plural) => `${sent} of ${asked} ${plural} pinned: you can pin up to ${max}.`,
45
+ },
46
+ favorite: {
47
+ section: "favorites",
48
+ title: "Favourite",
49
+ icon: iconStar,
50
+ offIcon: iconStarOff,
51
+ on: "Add to favourites",
52
+ off: "Remove from favourites",
53
+ full: (max, plural) => `You can have up to ${max} favourite ${plural}. Remove one from the favourites to add another.`,
54
+ over: (sent, asked, max, plural) => `${sent} of ${asked} ${plural} added to the favourites: you can have up to ${max}.`,
55
+ },
56
+ };
57
+ /** What a set of marks draws in a row's first column, by its two known bits: nothing, the pin, the star, both. */
58
+ const MARK_ICONS = [0, 1, 2, 3].map(set => CRUD_MARKS.filter(mark => hasMark(set, mark)).map(mark => ({ icon: MARK_INFO[mark].icon, label: MARK_INFO[mark].title })));
32
59
  const MODE_INFO = {
33
60
  // "Active", not "All": the list leaves out what is in the trash and what is deleted, and "All" said otherwise.
34
61
  normal: { label: "Active", icon: iconList, empty: plural => `No ${plural} yet.` },
@@ -38,10 +65,8 @@ const MODE_INFO = {
38
65
  };
39
66
  /** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
40
67
  const ASKED = new Set(["trash", "recover", "delete", "trashFromDelete"]);
41
- /** Actions that take a record out of the active lists, so it is no longer among the pinned records either. */
68
+ /** Actions that take a record out of the active lists, so it leaves the sections of its marks too. */
42
69
  const LEFT_ACTIVE = new Set(["trash", "delete", "trashFromDelete"]);
43
- /** The words of an action that flags some records and unflags others (a mass toggle of a mixed selection). */
44
- const TOGGLED_FLAG = { verb: "change the flags of {object}", done: "updated", doing: "Changing the flags of {object}", object: "the records" };
45
70
  const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
46
71
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
47
72
  function isInputList(inputs) {
@@ -147,7 +172,7 @@ export function Crud(props) {
147
172
  const subject = props.subject ?? endpointSubject(api) ?? "record";
148
173
  const plural = props.subjectPlural ?? pluralize(subject);
149
174
  const name = props.name ?? subject;
150
- const backend = getCrudBackend();
175
+ const backend = props.backend ?? getCrudBackend();
151
176
  // Permissions: every list and action asks, again whenever the user's rights change.
152
177
  const permissionsStamp = useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
153
178
  const permissions = getPermissions();
@@ -164,7 +189,7 @@ export function Crud(props) {
164
189
  }, [ready, api, wantedActions, can]);
165
190
  const massAllowed = useMemo(() => {
166
191
  const map = {};
167
- for (const action of ["flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"])
192
+ for (const action of ["flag", "pin", "favorite", "trash", "recover", "delete", "trashFromDelete", "description"])
168
193
  map[action] = ready && massActionAllowed(action, api, wantedActions, can);
169
194
  return map;
170
195
  }, [ready, api, wantedActions, can]);
@@ -189,73 +214,77 @@ export function Crud(props) {
189
214
  const text = first ? formatCellText(first, columnValue(first, row)) : "";
190
215
  return text || `${capitalize(subject)} ${table.keyOf(row)}`;
191
216
  }, [subject, table]);
192
- /* The records the user pinned: their own endpoint, a section above the list, and a limit every CRUD shares */
193
- const maxPins = getCrudSettings().maxPins;
194
- const pinsEndpoint = api.pins;
195
- const pinsOn = ready && Boolean(pinsEndpoint) && wantedActions.includes("pin") && can(pinsEndpoint);
196
- const [pinned, setPinned] = useState(null);
197
- const [pinsLoading, setPinsLoading] = useState(false);
198
- const [pinsStamp, setPinsStamp] = useState(0);
217
+ /*
218
+ * This user's marks: the records they pinned and their favourites, read in one call (`bookmarks`), shown in a section
219
+ * each above the active list, with a limit each that every CRUD shares
220
+ */
221
+ // A backend that keeps no favourites lists its pins alone (`pins`), and every record it answers is pinned.
222
+ const fromBookmarks = Boolean(api.bookmarks);
223
+ const marksEndpoint = api.bookmarks ?? api.pins;
224
+ const shownMarks = CRUD_MARKS.filter(mark => wantedActions.includes(mark) && (mark === "pin" || fromBookmarks));
225
+ const marksOn = ready && Boolean(marksEndpoint) && shownMarks.length > 0 && can(marksEndpoint);
226
+ const [marked, setMarked] = useState(null);
227
+ const [marksState, setMarksState] = useState("none");
228
+ const [marksStamp, setMarksStamp] = useState(0);
199
229
  useEffect(() => {
200
- if (!pinsOn || !pinsEndpoint) {
201
- setPinned(null);
230
+ if (!marksOn || !marksEndpoint) {
231
+ setMarked(null);
232
+ setMarksState("none");
202
233
  return;
203
234
  }
204
235
  const controller = new AbortController();
205
- setPinsLoading(true);
206
- const done = (rows) => {
207
- if (controller.signal.aborted)
236
+ setMarksState("loading");
237
+ void browserApi.send(marksEndpoint, { signal: controller.signal, toast: false, subject: plural }).then(answer => {
238
+ if (answer.aborted || controller.signal.aborted)
208
239
  return;
209
- if (rows)
210
- setPinned(rows);
211
- setPinsLoading(false);
212
- };
213
- void browserApi.send(pinsEndpoint, { signal: controller.signal, toast: false, subject: plural }).then(answer => {
214
- if (answer.aborted)
240
+ if (!answer.ok) {
241
+ // The sections are shortcuts to records the list holds as well, so they are left out quietly; the actions
242
+ // that need the marks say why they wait.
243
+ setMarksState("failed");
215
244
  return;
216
- // The rows come from the pinned list, so they are pinned whatever the backend sends on each one.
217
- done(answer.ok ? backend.readList(answer.result).map(row => backend.withPin(row, true)) : []);
218
- },
219
- // The section is a shortcut to records the list holds as well, so a failed load leaves it out quietly.
220
- () => done([]));
221
- return () => controller.abort();
222
- }, [pinsOn, pinsEndpoint, backend, plural, pinsStamp]);
223
- const reloadPins = useCallback(() => setPinsStamp(stamp => stamp + 1), []);
224
- /** The section after a pin or an unpin: records just pinned go on top, unpinned ones go. */
225
- function applyPins(rows) {
226
- setPinned(current => {
227
- if (!current)
228
- return current;
229
- const changed = new Map(rows.map(row => [table.keyOf(row), row]));
230
- const kept = current.map(row => changed.get(table.keyOf(row)) ?? row).filter(row => backend.isPinned(row));
231
- const keys = new Set(kept.map(row => table.keyOf(row)));
232
- return [...rows.filter(row => backend.isPinned(row) && !keys.has(table.keyOf(row))), ...kept];
245
+ }
246
+ const rows = backend.readList(answer.result);
247
+ setMarked(markedRecords(rows, row => (fromBookmarks ? backend.marksOf(row) : CRUD_MARK_BITS.pin), row => table.keyOf(row)));
248
+ setMarksState("ready");
249
+ }, () => {
250
+ if (!controller.signal.aborted)
251
+ setMarksState("failed");
233
252
  });
253
+ return () => controller.abort();
254
+ }, [marksOn, marksEndpoint, fromBookmarks, backend, plural, table, marksStamp]);
255
+ const reloadMarks = useCallback(() => setMarksStamp(stamp => stamp + 1), []);
256
+ /**
257
+ * A record's marks: from the listing when there is one - a list's own rows need not carry them, and the Nexus lists
258
+ * do not - else from the record itself.
259
+ */
260
+ const marksOf = useCallback((row) => marked?.marks.get(table.keyOf(row)) ?? (marked && fromBookmarks ? 0 : backend.marksOf(row)), [marked, fromBookmarks, backend, table]);
261
+ /**
262
+ * Why a mark cannot be put on or taken off yet. A switch stores the record's **whole** set of marks, so one sent
263
+ * before the set is known could take off a mark the page never read.
264
+ */
265
+ const marksWait = fromBookmarks && marksOn && marksState !== "ready" ? (marksState === "failed" ? "Your pins and favourites could not be read. Reload the list to try again." : "Reading your pins and favourites…") : undefined;
266
+ /**
267
+ * The marks in each row's first column, where its number would be: on the live lists, once they are known, and
268
+ * only the marks this CRUD offers.
269
+ */
270
+ const markMask = CRUD_MARKS.filter(mark => (marksOn && shownMarks.includes(mark)) || allowed[mark]).reduce((mask, mark) => mask | CRUD_MARK_BITS[mark], 0);
271
+ const rowMarks = useCallback((row) => MARK_ICONS[marksOf(row) & markMask], [marksOf, markMask]);
272
+ const marksShown = (mode === "normal" || mode === "flag") && markMask !== 0 && !marksWait;
273
+ /** How many more records may take a mark: lowering a limit never takes a mark off, it only waits to be met. */
274
+ const markRoom = (mark) => pinRoom(marked ? marked.rows[mark].length : undefined, markLimit(mark));
275
+ /** Copies of marked records, after a change that was not a mark: a flag, a description, a save. */
276
+ function patchMarked(rows) {
277
+ setMarked(current => current && patchMarkedRecords(current, rows, new Map(), row => table.keyOf(row), new Set()));
234
278
  }
235
- /** A pinned record's copy in the section, after a change that was not a pin: a flag, a description, a save. */
236
- function patchPins(rows) {
237
- setPinned(current => {
238
- if (!current)
239
- return current;
240
- const changed = new Map(rows.map(row => [table.keyOf(row), row]));
241
- if (!current.some(row => changed.has(table.keyOf(row))))
242
- return current;
243
- // The record is still pinned: only its other values changed, and a list may not send the pin at all.
244
- return current.map(row => {
245
- const next = changed.get(table.keyOf(row));
246
- return next ? backend.withPin(next, true) : row;
247
- });
248
- });
279
+ /** Records that left the active lists leave every section; their marks stay, for when they come back. */
280
+ function dropMarked(keys) {
281
+ if (keys.length)
282
+ setMarked(current => current && dropMarkedRows(current, keys, row => table.keyOf(row)));
249
283
  }
250
- /** Records that left the active lists are no longer among the pinned ones. */
251
- function dropPins(keys) {
252
- if (!keys.length)
253
- return;
254
- const gone = new Set(keys);
255
- setPinned(current => (current && current.some(row => gone.has(table.keyOf(row))) ? current.filter(row => !gone.has(table.keyOf(row))) : current));
284
+ /** Records back in the active list take their place in the sections of the marks they kept. */
285
+ function restoreMarked(rows) {
286
+ setMarked(current => current && patchMarkedRecords(current, rows, new Map(), row => table.keyOf(row), new Set(rows.map(row => table.keyOf(row)))));
256
287
  }
257
- /** Nothing can be pinned while the limit is reached: lowering the limit never unpins, it only waits to be met. */
258
- const pinsFull = () => `You can pin up to ${maxPins} ${plural}. Unpin one to pin another.`;
259
288
  /* Loading */
260
289
  const load = useCallback((query, signal) => {
261
290
  if (!mode)
@@ -317,6 +346,10 @@ export function Crud(props) {
317
346
  }
318
347
  function afterChange(action, rows, changes, written) {
319
348
  syncCaches(changes, written);
349
+ // What other tables and pickers looked up from this controller may now be wrong - a name, a status - so it is marked
350
+ // old, and is asked again where it is next shown. A pin or a favourite is this user's own and changes neither.
351
+ if (action !== "pin" && action !== "favorite")
352
+ expireLookups(api);
320
353
  const shown = modeRef.current;
321
354
  if (shown)
322
355
  latest.current.onChange?.({ action, rows, mode: shown });
@@ -358,29 +391,7 @@ export function Crud(props) {
358
391
  moved.push({ key, row: next, before: current });
359
392
  }
360
393
  table.remove(leaving);
361
- patchPins(updated);
362
- afterChange(action, updated, moved, write);
363
- return;
364
- }
365
- if (action === "pin") {
366
- const updated = [];
367
- for (const row of done) {
368
- const key = table.keyOf(row);
369
- const current = table.getRow(key) ?? row;
370
- const on = markOn ?? !backend.isPinned(current);
371
- let next = backend.withPin(current, on);
372
- const change = changes.get(key);
373
- if (change)
374
- next = { ...next, ...change };
375
- if (one)
376
- next = { ...next, ...one };
377
- // A pin changes the section above the list, never the list itself: the record stays where it is, on its page.
378
- if (write && table.getRow(key))
379
- table.upsert(next, { flash: false });
380
- updated.push(next);
381
- moved.push({ key, row: next, before: current });
382
- }
383
- applyPins(updated);
394
+ patchMarked(updated);
384
395
  afterChange(action, updated, moved, write);
385
396
  return;
386
397
  }
@@ -395,7 +406,7 @@ export function Crud(props) {
395
406
  updated.push(next);
396
407
  moved.push({ key, row: next, before: current });
397
408
  }
398
- patchPins(updated);
409
+ patchMarked(updated);
399
410
  afterChange(action, updated, moved, write);
400
411
  return;
401
412
  }
@@ -418,9 +429,37 @@ export function Crud(props) {
418
429
  if (write && leavesList(action, shown))
419
430
  table.remove(keys);
420
431
  if (LEFT_ACTIVE.has(action))
421
- dropPins(keys);
432
+ dropMarked(keys);
433
+ else if (action === "recover")
434
+ restoreMarked(updated);
422
435
  afterChange(action, updated, moved, write);
423
436
  }
437
+ /**
438
+ * Marks a write stored, written in with no request: every record's set of marks, the sections (a record that took a
439
+ * mark goes on top of its section, one that lost it leaves), and the record itself in the table and every list kept.
440
+ * A mark never moves a record between lists: it stays where it is, on its page.
441
+ */
442
+ function applyMarks(mark, rows, stored) {
443
+ const write = holds(undefined);
444
+ const updated = [];
445
+ const moved = [];
446
+ for (const row of rows) {
447
+ const key = table.keyOf(row);
448
+ const marks = stored.get(key);
449
+ if (marks === undefined)
450
+ continue;
451
+ const current = table.getRow(key) ?? row;
452
+ const next = backend.withMarks(current, marks);
453
+ if (write && table.getRow(key))
454
+ table.upsert(next, { flash: false });
455
+ updated.push(next);
456
+ moved.push({ key, row: next, before: current });
457
+ }
458
+ if (!updated.length)
459
+ return;
460
+ setMarked(current => current && patchMarkedRecords(current, updated, stored, row => table.keyOf(row)));
461
+ afterChange(mark, updated, moved, write);
462
+ }
424
463
  /**
425
464
  * Records a page's own action changed, written exactly where the built-in ones write theirs. The handle's `apply`.
426
465
  */
@@ -431,7 +470,8 @@ export function Crud(props) {
431
470
  const moved = [];
432
471
  const updated = [];
433
472
  const leaving = [];
434
- let pinned = false;
473
+ // Records whose marks the page's own action changed, as the records it gave say them.
474
+ const stored = new Map();
435
475
  // The page asked from whichever list it did; the record's band decides, so only a table still loading is not
436
476
  // written. A record that joined the list on screen - asked from another list, answered after a switch - is not in
437
477
  // the table to be updated, so the table is read again from the patched lists instead.
@@ -449,19 +489,17 @@ export function Crud(props) {
449
489
  write = false;
450
490
  else if (before && write)
451
491
  table.upsert(next, { flash: false });
452
- if (before && backend.isPinned(next) !== backend.isPinned(before))
453
- pinned = true;
492
+ if (before && backend.marksOf(next) !== backend.marksOf(before))
493
+ stored.set(key, backend.marksOf(next));
454
494
  updated.push(next);
455
495
  moved.push({ key, row: next, before, gone });
456
496
  }
457
497
  if (write)
458
498
  table.remove(leaving);
459
499
  if (gone)
460
- dropPins(leaving);
461
- else if (pinned)
462
- applyPins(updated);
500
+ dropMarked(leaving);
463
501
  else
464
- patchPins(updated);
502
+ setMarked(current => current && patchMarkedRecords(current, updated, stored, row => table.keyOf(row)));
465
503
  syncCaches(moved, write);
466
504
  }
467
505
  /* Row actions */
@@ -471,56 +509,120 @@ export function Crud(props) {
471
509
  return;
472
510
  const key = rawKey(row);
473
511
  const from = modeRef.current;
474
- const markOn = action === "flag" ? !backend.isFlagged(row) : action === "pin" ? !backend.isPinned(row) : undefined;
475
- if (action === "pin" && markOn && pinRoom(pinned?.length) < 1) {
476
- toast.warning(pinsFull());
477
- return;
478
- }
479
- const options = { params: { id: key, [rowKey]: key }, subject: labelOf(row), action: crudMessageAction(action, markOn) };
512
+ const markOn = action === "flag" ? !backend.isFlagged(row) : undefined;
513
+ // A flag carries the way it goes: never a toggle the server works out.
514
+ const options = { params: { id: key, [rowKey]: key }, subject: labelOf(row), action: crudMessageAction(action, markOn), ...(markOn !== undefined ? backend.flagRequest(markOn) : {}) };
480
515
  const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
481
516
  if (answer?.ok)
482
517
  applyChange(action, [row], { result: answer.result, markOn, from });
483
518
  }
519
+ /**
520
+ * Puts a mark on one record, or takes it off: the way the record's marks say, never a toggle the server works out.
521
+ * Through `bookmark` it sends the record's whole set of marks with this one changed; a backend with only `pin`
522
+ * toggles its pin.
523
+ */
524
+ async function switchMark(mark, row) {
525
+ const endpoint = crudRowEndpoint(api, mark);
526
+ if (!endpoint || marksWait)
527
+ return;
528
+ const before = marksOf(row);
529
+ const on = !hasMark(before, mark);
530
+ if (on && markRoom(mark) < 1) {
531
+ toast.warning(MARK_INFO[mark].full(markLimit(mark), plural));
532
+ return;
533
+ }
534
+ const wanted = withMark(before, mark, on);
535
+ const whole = Boolean(api.bookmark);
536
+ const id = rawKey(row);
537
+ const answer = await browserApi.send(endpoint, { params: { id, [rowKey]: id }, subject: labelOf(row), action: crudMessageAction(mark, on), ...(whole ? backend.marksRequest(wanted) : {}) });
538
+ if (!answer.ok)
539
+ return;
540
+ applyMarks(mark, [row], new Map([[table.keyOf(row), (whole ? backend.answeredMarks(answer.result) : null) ?? wanted]]));
541
+ }
542
+ /**
543
+ * Puts a mark on the selected records, or takes it off. Only the records the change is for are sent - the rest are
544
+ * already as asked - and, putting it on, only as many as the limit leaves room for, in the order they were picked.
545
+ */
546
+ async function switchMarkMany(mark, rows, on) {
547
+ const endpoint = crudMassEndpoint(api, mark);
548
+ if (!endpoint || marksWait)
549
+ return;
550
+ const wanted = rows.filter(row => hasMark(marksOf(row), mark) !== on);
551
+ let sent = wanted;
552
+ if (on) {
553
+ const room = markRoom(mark);
554
+ if (room < 1) {
555
+ toast.warning(MARK_INFO[mark].full(markLimit(mark), plural));
556
+ return;
557
+ }
558
+ sent = wanted.slice(0, room);
559
+ }
560
+ if (!sent.length)
561
+ return;
562
+ const keys = sent.map(rawKey);
563
+ const request = api.mass?.bookmark ? backend.massMarksRequest(keys, mark, on) : { body: backend.massRequest(mark, keys) };
564
+ const action = crudMessageAction(mark, on);
565
+ const answer = await browserApi.send(endpoint, { ...request, subject, plural: props.subjectPlural, count: sent.length, action, toast: { success: false } });
566
+ if (!answer.ok)
567
+ return;
568
+ const done = changedOf(sent, answer.result);
569
+ applyMarks(mark, done, new Map(done.map(row => [table.keyOf(row), withMark(marksOf(row), mark, on)])));
570
+ table.clearSelection();
571
+ sayMassDone(action, sent.length, done.length);
572
+ if (sent.length < wanted.length)
573
+ toast.warning(MARK_INFO[mark].over(sent.length, wanted.length, markLimit(mark), plural));
574
+ }
575
+ /** The records a mass answer says it changed: the ones it lists, or every one sent when it lists none. */
576
+ function changedOf(sent, result) {
577
+ const changed = backend.answeredKeys(result, rowKey);
578
+ return changed ? sent.filter(row => changed.has(table.keyOf(row))) : sent;
579
+ }
580
+ /**
581
+ * How a directed switch on a selection went, said plainly: one already as asked is skipped, not refused, so "3
582
+ * regions pinned" when two of them already were is the one thing not to say.
583
+ */
584
+ function sayMassDone(action, sent, changed) {
585
+ const done = buildMessage("success", { action, subject, plural: props.subjectPlural, count: changed });
586
+ if (changed === sent)
587
+ toast.success(done);
588
+ else if (changed > 0)
589
+ toast.success(`${done.replace(/\.$/, "")}; the other ${sent - changed} already were.`);
590
+ else
591
+ toast.info(`Nothing changed: every one of the ${sent} already was.`);
592
+ }
593
+ /** Flags the selected records, or unflags them: only the ones the change is for, and the call says which way. */
594
+ async function switchFlagMany(rows, on) {
595
+ const endpoint = crudMassEndpoint(api, "flag");
596
+ if (!endpoint)
597
+ return;
598
+ const sent = rows.filter(row => backend.canFlag(row) && backend.isFlagged(row) !== on);
599
+ if (!sent.length)
600
+ return;
601
+ const action = crudMessageAction("flag", on);
602
+ const from = modeRef.current;
603
+ const answer = await browserApi.send(endpoint, { body: backend.massRequest("flag", sent.map(rawKey)), ...backend.flagRequest(on), subject, plural: props.subjectPlural, count: sent.length, action, toast: { success: false } });
604
+ if (!answer.ok)
605
+ return;
606
+ const done = changedOf(sent, answer.result);
607
+ applyChange("flag", done, { result: answer.result, mass: true, markOn: on, from });
608
+ table.clearSelection();
609
+ sayMassDone(action, sent.length, done.length);
610
+ }
484
611
  async function runMassAction(action, rows) {
485
612
  if (action === "description") {
486
613
  openDescription(rows);
487
614
  return;
488
615
  }
489
- const endpoint = api.mass?.[action];
616
+ const endpoint = crudMassEndpoint(api, action);
490
617
  if (!endpoint)
491
618
  return;
492
- let markOn;
493
- let sent = rows;
494
- let over = 0;
495
- if (action === "flag") {
496
- const on = rows.filter(row => backend.isFlagged(row)).length;
497
- markOn = on === 0 ? true : on === rows.length ? false : undefined;
498
- }
499
- if (action === "pin") {
500
- // Every selected record pinned means Unpin; otherwise the ones that are not pinned yet are pinned, as many as
501
- // the limit leaves room for, in the order the user picked them. The rest stay as they are.
502
- const adding = rows.filter(row => !backend.isPinned(row));
503
- markOn = adding.length > 0;
504
- if (markOn) {
505
- const room = pinRoom(pinned?.length);
506
- if (room < 1) {
507
- toast.warning(pinsFull());
508
- return;
509
- }
510
- sent = adding.slice(0, room);
511
- over = adding.length - sent.length;
512
- }
513
- }
514
- const messageAction = action === "flag" && markOn === undefined ? TOGGLED_FLAG : crudMessageAction(action, markOn);
515
- const options = { body: backend.massRequest(action, sent.map(rawKey)), subject, plural: props.subjectPlural, count: sent.length, action: messageAction };
619
+ const options = { body: backend.massRequest(action, rows.map(rawKey)), subject, plural: props.subjectPlural, count: rows.length, action: crudMessageAction(action) };
516
620
  const from = modeRef.current;
517
621
  const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
518
622
  if (!answer?.ok)
519
623
  return;
520
- applyChange(action, sent, { result: answer.result, mass: true, markOn, from });
624
+ applyChange(action, rows, { result: answer.result, mass: true, from });
521
625
  table.clearSelection();
522
- if (over > 0)
523
- toast.warning(`${sent.length} of ${sent.length + over} ${plural} pinned: you can pin up to ${maxPins}.`);
524
626
  }
525
627
  /* Popups */
526
628
  const addPopup = useRef(null);
@@ -617,7 +719,7 @@ export function Crud(props) {
617
719
  const write = holds(undefined);
618
720
  if (write)
619
721
  table.upsert(record);
620
- patchPins([record]);
722
+ patchMarked([record]);
621
723
  afterChange("edit", [record], key ? [{ key, row: record, before }] : [], write);
622
724
  return;
623
725
  }
@@ -634,9 +736,17 @@ export function Crud(props) {
634
736
  if (write && shown === "normal")
635
737
  table.upsert(record);
636
738
  afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : [], write);
637
- if (key && !answered && api.details)
739
+ // The fields only the server knows - the status, the times - are read once, for this one record, when the answer
740
+ // did not carry them: a bare key, or the payload sent back (no list can place a record without its status).
741
+ if (key && api.details && (!answered || member(record, "normal") === undefined))
638
742
  void fillSaved(key, record);
639
743
  }
744
+ /** A record's history, in a popup of its own: one per record, brought back if it is open. */
745
+ function openHistory(row) {
746
+ if (!api.audit)
747
+ return;
748
+ openAuditHistory({ endpoint: api.audit, id: rawKey(row), subject, subjectPlural: props.subjectPlural, label: labelOf(row), backend });
749
+ }
640
750
  function openDetails(row) {
641
751
  const details = latest.current.details;
642
752
  if (details === false)
@@ -681,7 +791,7 @@ export function Crud(props) {
681
791
  : undefined }) })),
682
792
  footer: popup => (_jsxs(DetailsFooter, { load: status, children: [details?.actions
683
793
  ?.filter(action => (!action.endpoint || (ready && can(action.endpoint))) && !action.hidden?.(row))
684
- .map(action => (_jsx(Button, { icon: action.icon, "data-action": action.id, onClick: () => action.onSelect(table.getRow(key) ?? row, popup), children: action.label }, action.id))), canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: () => {
794
+ .map(action => (_jsx(Button, { icon: action.icon, "data-action": action.id, onClick: () => action.onSelect(table.getRow(key) ?? row, popup), children: action.label }, action.id))), allowed.history && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconHistory }), "data-action": "history", onClick: () => openHistory(table.getRow(key) ?? row), children: "View history" })), canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: () => {
685
795
  popup.close();
686
796
  void openForm("edit", table.getRow(key) ?? row);
687
797
  }, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: () => popup.close(), children: "Close" })] })),
@@ -710,8 +820,8 @@ export function Crud(props) {
710
820
  });
711
821
  }
712
822
  // Row actions and the handle call these through a ref, so the table's callbacks stay the same between renders.
713
- const run = useRef({ runRowAction, runMassAction, openForm, openDetails, applyRows });
714
- run.current = { runRowAction, runMassAction, openForm, openDetails, applyRows };
823
+ const run = useRef({ runRowAction, runMassAction, switchMark, switchMarkMany, switchFlagMany, openForm, openDetails, openHistory, applyRows });
824
+ run.current = { runRowAction, runMassAction, switchMark, switchMarkMany, switchFlagMany, openForm, openDetails, openHistory, applyRows };
715
825
  /* The table's callbacks */
716
826
  const rowActions = useCallback((row) => {
717
827
  if (!mode)
@@ -719,12 +829,20 @@ export function Crud(props) {
719
829
  const list = [];
720
830
  const primary = mode === "normal" || mode === "flag";
721
831
  const flagged = backend.isFlagged(row);
722
- const isPinned = backend.isPinned(row);
832
+ const marks = marksOf(row);
833
+ // A mark goes the way the record's marks say; while they are not known yet, it waits and says why.
834
+ const markEntry = (mark) => {
835
+ const info = MARK_INFO[mark];
836
+ const off = hasMark(marks, mark);
837
+ return { label: off ? info.off : info.on, icon: _jsx(InlineIcon, { icon: off ? info.offIcon : info.icon }), disabled: Boolean(marksWait), description: marksWait };
838
+ };
723
839
  const info = {
724
840
  view: { label: `View ${(latest.current.details || undefined)?.label ?? "details"}`, icon: _jsx(InlineIcon, { icon: iconEye }) },
725
841
  edit: { label: "Edit", icon: _jsx(InlineIcon, { icon: iconPencil }), inline: true },
726
842
  flag: flagged ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
727
- pin: isPinned ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
843
+ pin: markEntry("pin"),
844
+ favorite: markEntry("favorite"),
845
+ history: { label: "History", icon: _jsx(InlineIcon, { icon: iconHistory }) },
728
846
  trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
729
847
  recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
730
848
  delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
@@ -733,24 +851,52 @@ export function Crud(props) {
733
851
  for (const action of crudRowActions(mode, candidate => allowed[candidate], trashless)) {
734
852
  if (action === "view" && latest.current.details === false)
735
853
  continue;
736
- const onSelect = action === "view" ? () => run.current.openDetails(row) : action === "edit" ? () => void run.current.openForm("edit", row) : () => run.current.runRowAction(action, row);
854
+ // A record the backend will not flag either way (a pending one) is offered neither.
855
+ if (action === "flag" && !backend.canFlag(row))
856
+ continue;
857
+ const onSelect = action === "view"
858
+ ? () => run.current.openDetails(row)
859
+ : action === "edit"
860
+ ? () => void run.current.openForm("edit", row)
861
+ : action === "pin" || action === "favorite"
862
+ ? () => run.current.switchMark(action, row)
863
+ : action === "history"
864
+ ? () => run.current.openHistory(row)
865
+ : () => run.current.runRowAction(action, row);
737
866
  const entry = info[action];
738
867
  list.push({ ...entry, id: action, inline: entry.inline && (primary ? action === "edit" : true), onSelect });
739
868
  }
740
869
  const extra = latest.current.rowActions?.(row, mode) ?? [];
741
870
  return [...list, ...extra];
742
- }, [mode, allowed, backend, trashless]);
871
+ }, [mode, allowed, backend, trashless, marksOf, marksWait]);
743
872
  const massActionsFor = useCallback((rows) => {
744
873
  if (!mode)
745
874
  return [];
746
875
  const list = [];
747
- const flagged = rows.filter(row => backend.isFlagged(row)).length;
748
- const unpinned = rows.filter(row => !backend.isPinned(row)).length;
876
+ const marks = rows.map(marksOf);
749
877
  for (const action of crudMassActions(mode, candidate => massAllowed[candidate], massTrashless)) {
878
+ if (action === "flag") {
879
+ // The same for the flag: Flag while one of them can be flagged, Unflag while one of them is.
880
+ const flaggable = rows.filter(row => backend.canFlag(row));
881
+ if (flaggable.some(row => !backend.isFlagged(row)))
882
+ list.push({ id: "flag", label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }), onSelect: selected => run.current.switchFlagMany(selected, true) });
883
+ if (flaggable.some(row => backend.isFlagged(row)))
884
+ list.push({ id: "unflag", label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }), onSelect: selected => run.current.switchFlagMany(selected, false) });
885
+ continue;
886
+ }
887
+ if (action === "pin" || action === "favorite") {
888
+ // Only a way some selected record can still go (the owner, 2026-10-07); a mixed selection offers both. While the
889
+ // marks are not known, which way it can go is not known either: both show, and neither runs yet.
890
+ const info = MARK_INFO[action];
891
+ const ways = marksWait ? { on: true, off: true } : markDirections(marks, action);
892
+ if (ways.on)
893
+ list.push({ id: action, label: info.on, icon: _jsx(InlineIcon, { icon: info.icon }), disabled: Boolean(marksWait), onSelect: selected => run.current.switchMarkMany(action, selected, true) });
894
+ if (ways.off)
895
+ list.push({ id: `un${action}`, label: info.off, icon: _jsx(InlineIcon, { icon: info.offIcon }), disabled: Boolean(marksWait), onSelect: selected => run.current.switchMarkMany(action, selected, false) });
896
+ continue;
897
+ }
750
898
  const onSelect = (selected) => run.current.runMassAction(action, selected);
751
899
  const info = {
752
- flag: flagged === rows.length ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: flagged ? "Flag / unflag" : "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
753
- pin: unpinned === 0 ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
754
900
  trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
755
901
  recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
756
902
  delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
@@ -761,7 +907,7 @@ export function Crud(props) {
761
907
  }
762
908
  const extra = latest.current.massActions ? latest.current.massActions(rows, mode) : [];
763
909
  return [...list, ...extra];
764
- }, [mode, massAllowed, backend, massTrashless]);
910
+ }, [mode, massAllowed, backend, massTrashless, marksOf, marksWait]);
765
911
  const onRowOpen = useCallback((row) => {
766
912
  if (allowed.view && latest.current.details !== false)
767
913
  run.current.openDetails(row);
@@ -776,14 +922,14 @@ export function Crud(props) {
776
922
  setMode: next => setChosenMode(next),
777
923
  reload: () => {
778
924
  cache.clear();
779
- reloadPins();
925
+ reloadMarks();
780
926
  return table.reload({ quiet: true });
781
927
  },
782
928
  apply: (rows, options) => run.current.applyRows(rows, options?.gone ?? false),
783
929
  create: values => void run.current.openForm("create", null, values),
784
930
  edit: row => void run.current.openForm("edit", row),
785
931
  view: row => run.current.openDetails(row),
786
- }), [table, cache, reloadPins]);
932
+ }), [table, cache, reloadMarks]);
787
933
  const tableLabel = props["aria-label"] ?? (props["aria-labelledby"] ? undefined : capitalize(plural));
788
934
  const configured = CRUD_MODES.some(value => wantedModes.includes(value) && crudListEndpoint(api, value, paging));
789
935
  if (!configured) {
@@ -794,15 +940,17 @@ export function Crud(props) {
794
940
  }
795
941
  const modeOptions = modes.map(value => ({ value, label: MODE_INFO[value].label, icon: _jsx(InlineIcon, { icon: MODE_INFO[value].icon }) }));
796
942
  const add = allowed.create ? { label: `Add ${subject}`, onClick: () => void run.current.openForm("create", null) } : undefined;
797
- // In the main list, select again by pin and by flag, the way the mass actions act on them.
943
+ // In the main list, select again by mark and by flag, the way the mass actions act on them - marks once they are known.
944
+ const markSelects = (mark, label, otherwise) => massAllowed[mark] && !marksWait
945
+ ? [
946
+ { label, icon: _jsx(InlineIcon, { icon: MARK_INFO[mark].icon }), test: (row) => hasMark(marksOf(row), mark) },
947
+ { label: otherwise, icon: _jsx(InlineIcon, { icon: MARK_INFO[mark].offIcon }), test: (row) => !hasMark(marksOf(row), mark) },
948
+ ]
949
+ : [];
798
950
  const selectBy = mode === "normal"
799
951
  ? [
800
- ...(massAllowed.pin
801
- ? [
802
- { label: "Pinned", icon: _jsx(InlineIcon, { icon: iconPin }), test: (row) => backend.isPinned(row) },
803
- { label: "Not pinned", icon: _jsx(InlineIcon, { icon: iconPinnedOff }), test: (row) => !backend.isPinned(row) },
804
- ]
805
- : []),
952
+ ...markSelects("pin", "Pinned", "Not pinned"),
953
+ ...markSelects("favorite", "Favourites", "Not favourites"),
806
954
  ...(massAllowed.flag
807
955
  ? [
808
956
  { label: "Flagged", icon: _jsx(InlineIcon, { icon: iconFlag }), test: (row) => backend.isFlagged(row) },
@@ -812,15 +960,28 @@ export function Crud(props) {
812
960
  ]
813
961
  : [];
814
962
  const selectable = props.massActions !== false && Boolean(mode) && (crudMassActions(mode, action => massAllowed[action], massTrashless).length > 0 || Boolean(props.massActions));
815
- return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, columns: columns, load: load, loadKey: mode ?? "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "auto", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: props.toolbar, add: add, selectable: selectable, massActions: massActionsFor, selectBy: selectBy, rowActions: rowActions, pinnedRows: mode === "normal" && pinsOn ? (pinned ?? undefined) : undefined, pinnedLoading: pinsLoading, pinnedLabel: `Pinned ${plural}`, pinnedMax: maxPins, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, onReload: () => {
963
+ // The sections above the active list: one per mark this CRUD shows, Pinned first.
964
+ const sections = mode === "normal" && marksOn && marksState !== "failed"
965
+ ? shownMarks.map(mark => ({
966
+ key: MARK_INFO[mark].section,
967
+ label: `${MARK_INFO[mark].title} ${plural}`,
968
+ icon: MARK_INFO[mark].icon,
969
+ rows: marked?.rows[mark] ?? [],
970
+ loading: marksState === "loading",
971
+ max: markLimit(mark),
972
+ }))
973
+ : undefined;
974
+ return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, columns: columns, load: load, loadKey: mode ?? "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "auto", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: props.toolbar, add: add, selectable: selectable, massActions: massActionsFor, selectBy: selectBy, rowActions: rowActions, sections: sections, rowMarks: marksShown ? rowMarks : undefined, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, onReload: () => {
816
975
  cache.clear();
817
- reloadPins();
976
+ reloadMarks();
818
977
  }, emptyMessage: tableOptions?.emptyMessage ?? (mode ? MODE_INFO[mode].empty(plural) : undefined), emptyAction: mode === "normal" ? undefined : null, searchPlaceholder: tableOptions?.searchPlaceholder ?? `Search ${plural}…`, stateKey: tableOptions?.stateKey ?? `crud:${name}`, pageSize: tableOptions?.pageSize, pageSizes: tableOptions?.pageSizes, defaultSort: tableOptions?.defaultSort, numbered: tableOptions?.numbered, maxHeight: tableOptions?.maxHeight, fill: tableOptions?.fill, size: tableOptions?.size, maxSelection: tableOptions?.maxSelection, columnsMenu: tableOptions?.columnsMenu, searchDelay: tableOptions?.searchDelay, filterInputs: tableOptions?.filterInputs, reorderable: tableOptions?.reorderable, resizable: tableOptions?.resizable, pinnable: tableOptions?.pinnable, moreActions: tableOptions?.moreActions, exportable: tableOptions?.exportable, printable: tableOptions?.printable, exportName: tableOptions?.exportName, onImport: tableOptions?.onImport, excel: tableOptions?.excel && {
819
978
  ...tableOptions.excel,
979
+ // The export writes the list on screen, as the backend names that list; the page's own filters win a clash.
980
+ filters: () => ({ ...backend.exportFilters(modeRef.current ?? "normal"), ...tableOptions.excel?.filters?.() }),
820
981
  // A spreadsheet wrote records straight into the database: every list this CRUD kept is out of date.
821
982
  onImported: () => {
822
983
  cache.clear();
823
- reloadPins();
984
+ reloadMarks();
824
985
  tableOptions.excel?.onImported?.();
825
986
  },
826
987
  } }) }));