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,45 +1,72 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { iconEye, iconFlag, iconFlagOff, iconList, iconLock, iconNotes, iconPencil, iconPin, iconPinnedOff, iconPlus, iconRestore, 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
- import { unwrapApiResult } from "../../Helpers/ApiResponses.js";
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
+ import { filterRequest } from "../../Helpers/TableFilters.js";
13
14
  import { columnValue, formatCellText, readPath } from "../../Helpers/TableHelpers.js";
14
15
  import { api as browserApi } from "../../Services/BrowserApi.js";
16
+ import { expireLookups } from "../../Services/JoinSources.js";
15
17
  import { Button } from "../Documents/Button.js";
16
18
  import { TabButtons } from "../Documents/TabButtons.js";
17
19
  import { openPopup } from "../Layouts/PopupHost.js";
18
20
  import { toast } from "../Layouts/Toaster.js";
19
21
  import { DataTable, useTable } from "../Viewers/DataTable.js";
20
22
  import { ApiForm } from "./ApiForm.js";
23
+ import { openAuditHistory } from "./CrudHistory.js";
21
24
  import { CrudDetails } from "./CrudParts.js";
22
25
  import { confirmDiscard, FormButtons } from "./SubmitForm.js";
23
26
  /*
24
27
  * Crud: a module's records in one component. It takes a controller's endpoints, the table's columns, and the form's
25
28
  * inputs, and handles the rest: the lists (all, flagged, trash, deleted) the user may see, the actions they
26
- * may take, the records the user pinned in a section above the list, Add and Edit popups built on ApiForm, a details
27
- * popup, actions on many rows, a cache of lists while the page is open, and rows updated from each answer instead of
28
- * 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.
29
32
  */
30
- 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 })));
31
59
  const MODE_INFO = {
32
- normal: { label: "All", icon: iconList, empty: plural => `No ${plural} yet.` },
60
+ // "Active", not "All": the list leaves out what is in the trash and what is deleted, and "All" said otherwise.
61
+ normal: { label: "Active", icon: iconList, empty: plural => `No ${plural} yet.` },
33
62
  flag: { label: "Flagged", icon: iconFlag, empty: plural => `No flagged ${plural}.` },
34
63
  trash: { label: "Trash", icon: iconTrash, empty: () => "The trash is empty." },
35
64
  deleted: { label: "Deleted", icon: iconTrashX, empty: plural => `No deleted ${plural}.` },
36
65
  };
37
- /** Actions that take a record out of reach are asked about first; flags, pins and recover are undone by a click. */
38
- const ASKED = new Set(["trash", "delete", "trashFromDelete"]);
39
- /** Actions that take a record out of the active lists, so it is no longer among the pinned records either. */
66
+ /** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
67
+ const ASKED = new Set(["trash", "recover", "delete", "trashFromDelete"]);
68
+ /** Actions that take a record out of the active lists, so it leaves the sections of its marks too. */
40
69
  const LEFT_ACTIVE = new Set(["trash", "delete", "trashFromDelete"]);
41
- /** The words of an action that flags some records and unflags others (a mass toggle of a mixed selection). */
42
- const TOGGLED_FLAG = { verb: "change the flags of {object}", done: "updated", doing: "Changing the flags of {object}", object: "the records" };
43
70
  const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
44
71
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
45
72
  function isInputList(inputs) {
@@ -65,6 +92,65 @@ function CrudFormBody({ aside, details, children }) {
65
92
  function addsToRow(row, full) {
66
93
  return Object.entries(full).some(([name, value]) => JSON.stringify(value) !== JSON.stringify(row[name]));
67
94
  }
95
+ /**
96
+ * A details record that lands sooner than this says nothing: the demo answers in about 0.3s and most backends sooner,
97
+ * and a word shown for a frame or two is the flicker this avoids. Longer than the popup's 160ms fade, so the word
98
+ * never lands on a half-faded popup.
99
+ */
100
+ const DETAILS_STATUS_DELAY = 400;
101
+ /** Once "Loading…" shows, it stays at least this long, so a record that lands just after the delay does not blink it. */
102
+ const DETAILS_STATUS_HOLD = 300;
103
+ /** One details popup's load state, shared by the details (which load) and the footer (which says so), as a form popup shares its FormStore with FormButtons. */
104
+ function detailsLoad(initial) {
105
+ let state = initial;
106
+ const listeners = new Set();
107
+ return {
108
+ get: () => state,
109
+ set(next) {
110
+ if (next === state)
111
+ return;
112
+ state = next;
113
+ for (const listener of listeners)
114
+ listener();
115
+ },
116
+ subscribe(listener) {
117
+ listeners.add(listener);
118
+ return () => {
119
+ listeners.delete(listener);
120
+ };
121
+ },
122
+ };
123
+ }
124
+ /**
125
+ * A details popup's footer: a form's bar, its status line on the left and Edit and Close on the right. The line is
126
+ * always there, so what it says never moves anything: "Loading…" only for a slow record, "Not all fields loaded." for
127
+ * one that failed.
128
+ */
129
+ function DetailsFooter({ load, children }) {
130
+ const state = useSyncExternalStore(load.subscribe, load.get, load.get);
131
+ const [said, setSaid] = useState(null);
132
+ const since = useRef(0);
133
+ useEffect(() => {
134
+ if (state === "loading") {
135
+ if (said === "loading")
136
+ return;
137
+ const timer = window.setTimeout(() => {
138
+ since.current = performance.now();
139
+ setSaid("loading");
140
+ }, DETAILS_STATUS_DELAY);
141
+ return () => window.clearTimeout(timer);
142
+ }
143
+ const next = state === "failed" ? "failed" : null;
144
+ const left = said === "loading" ? since.current + DETAILS_STATUS_HOLD - performance.now() : 0;
145
+ if (left <= 0) {
146
+ setSaid(next);
147
+ return;
148
+ }
149
+ const timer = window.setTimeout(() => setSaid(next), left);
150
+ return () => window.clearTimeout(timer);
151
+ }, [state, said]);
152
+ return (_jsxs("div", { className: "nx-form-buttons nx-form-buttons--end nx-crud-details__foot", children: [_jsxs("p", { className: "nx-form-status nx-form-status--muted", "aria-live": "polite", children: [said === "loading" && (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), _jsx("span", { children: "Loading\u2026" })] })), said === "failed" && (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconInfoCircle }), _jsx("span", { children: "Not all fields loaded." })] }))] }), _jsx("div", { className: "nx-form-buttons__actions", children: children })] }));
153
+ }
68
154
  /**
69
155
  * A CRUD page in one component: a data table of a controller's records with its lists, Add and Edit popups, details,
70
156
  * and actions on one record or many, each shown only when its endpoint exists and the user may call it.
@@ -86,7 +172,7 @@ export function Crud(props) {
86
172
  const subject = props.subject ?? endpointSubject(api) ?? "record";
87
173
  const plural = props.subjectPlural ?? pluralize(subject);
88
174
  const name = props.name ?? subject;
89
- const backend = getCrudBackend();
175
+ const backend = props.backend ?? getCrudBackend();
90
176
  // Permissions: every list and action asks, again whenever the user's rights change.
91
177
  const permissionsStamp = useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
92
178
  const permissions = getPermissions();
@@ -103,7 +189,7 @@ export function Crud(props) {
103
189
  }, [ready, api, wantedActions, can]);
104
190
  const massAllowed = useMemo(() => {
105
191
  const map = {};
106
- for (const action of ["flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"])
192
+ for (const action of ["flag", "pin", "favorite", "trash", "recover", "delete", "trashFromDelete", "description"])
107
193
  map[action] = ready && massActionAllowed(action, api, wantedActions, can);
108
194
  return map;
109
195
  }, [ready, api, wantedActions, can]);
@@ -128,86 +214,91 @@ export function Crud(props) {
128
214
  const text = first ? formatCellText(first, columnValue(first, row)) : "";
129
215
  return text || `${capitalize(subject)} ${table.keyOf(row)}`;
130
216
  }, [subject, table]);
131
- /* The records the user pinned: their own endpoint, a section above the list, and a limit every CRUD shares */
132
- const maxPins = getCrudSettings().maxPins;
133
- const pinsEndpoint = api.pins;
134
- const pinsOn = ready && Boolean(pinsEndpoint) && wantedActions.includes("pin") && can(pinsEndpoint);
135
- const [pinned, setPinned] = useState(null);
136
- const [pinsLoading, setPinsLoading] = useState(false);
137
- 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);
138
229
  useEffect(() => {
139
- if (!pinsOn || !pinsEndpoint) {
140
- setPinned(null);
230
+ if (!marksOn || !marksEndpoint) {
231
+ setMarked(null);
232
+ setMarksState("none");
141
233
  return;
142
234
  }
143
235
  const controller = new AbortController();
144
- setPinsLoading(true);
145
- const done = (rows) => {
146
- 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)
147
239
  return;
148
- if (rows)
149
- setPinned(rows);
150
- setPinsLoading(false);
151
- };
152
- void browserApi.send(pinsEndpoint, { signal: controller.signal, toast: false, subject: plural }).then(answer => {
153
- 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");
154
244
  return;
155
- // The rows come from the pinned list, so they are pinned whatever the backend sends on each one.
156
- done(answer.ok ? backend.readList(answer.result).map(row => backend.withPin(row, true)) : []);
157
- },
158
- // The section is a shortcut to records the list holds as well, so a failed load leaves it out quietly.
159
- () => done([]));
160
- return () => controller.abort();
161
- }, [pinsOn, pinsEndpoint, backend, plural, pinsStamp]);
162
- const reloadPins = useCallback(() => setPinsStamp(stamp => stamp + 1), []);
163
- /** The section after a pin or an unpin: records just pinned go on top, unpinned ones go. */
164
- function applyPins(rows) {
165
- setPinned(current => {
166
- if (!current)
167
- return current;
168
- const changed = new Map(rows.map(row => [table.keyOf(row), row]));
169
- const kept = current.map(row => changed.get(table.keyOf(row)) ?? row).filter(row => backend.isPinned(row));
170
- const keys = new Set(kept.map(row => table.keyOf(row)));
171
- 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");
172
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()));
173
278
  }
174
- /** A pinned record's copy in the section, after a change that was not a pin: a flag, a description, a save. */
175
- function patchPins(rows) {
176
- setPinned(current => {
177
- if (!current)
178
- return current;
179
- const changed = new Map(rows.map(row => [table.keyOf(row), row]));
180
- if (!current.some(row => changed.has(table.keyOf(row))))
181
- return current;
182
- // The record is still pinned: only its other values changed, and a list may not send the pin at all.
183
- return current.map(row => {
184
- const next = changed.get(table.keyOf(row));
185
- return next ? backend.withPin(next, true) : row;
186
- });
187
- });
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)));
188
283
  }
189
- /** Records that left the active lists are no longer among the pinned ones. */
190
- function dropPins(keys) {
191
- if (!keys.length)
192
- return;
193
- const gone = new Set(keys);
194
- 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)))));
195
287
  }
196
- /** Nothing can be pinned while the limit is reached: lowering the limit never unpins, it only waits to be met. */
197
- const pinsFull = () => `You can pin up to ${maxPins} ${plural}. Unpin one to pin another.`;
198
288
  /* Loading */
199
289
  const load = useCallback((query, signal) => {
200
290
  if (!mode)
201
291
  return new Promise(() => undefined);
202
292
  const endpoint = crudListEndpoint(api, mode, paging);
203
293
  const sortColumn = query.sort ? latest.current.columns.find(column => column.key === query.sort.key) : undefined;
204
- const key = paging === "server" ? `${mode}|${query.search}|${query.sort ? `${query.sort.key}:${query.sort.direction}` : ""}|${query.page}|${query.pageSize}` : mode;
294
+ const filters = query.filters ? JSON.stringify(query.filters) : "";
295
+ const key = paging === "server" ? `${mode}|${query.search}|${query.sort ? `${query.sort.key}:${query.sort.direction}` : ""}|${query.page}|${query.pageSize}|${filters}` : mode;
205
296
  const kept = cache.get(key);
206
297
  if (kept)
207
298
  return kept;
208
299
  const options = { signal, toast: false, subject: plural };
209
300
  if (paging === "server")
210
- options.body = backend.pageRequest(query, sortColumn?.sortKey ?? sortColumn?.key);
301
+ options.body = backend.pageRequest(query, sortColumn?.sortKey ?? sortColumn?.key, filterRequest(query.filters, latest.current.table?.filterInputs ?? [], latest.current.columns));
211
302
  return browserApi.send(endpoint, options).then(answer => {
212
303
  if (answer.aborted)
213
304
  throw abortError();
@@ -229,27 +320,47 @@ export function Crud(props) {
229
320
  * A server page is not patched when a record joined it or left it: its order, its last row and its counts are the
230
321
  * server's, so that page is dropped and the next one loads. One whose rows only changed is written in place.
231
322
  */
232
- function syncCaches(changes) {
323
+ function syncCaches(changes, written) {
233
324
  const shown = modeRef.current;
234
325
  if (changes.length)
235
326
  cache.map((key, value) => patchCachedList(value, crudCacheMode(key), changes, row => table.keyOf(row), member));
236
327
  else
237
328
  cache.clear();
238
- // The list on screen is the table's own, every row of it, so it is written back whole rather than patched.
239
- if (paging === "client" && shown)
329
+ // The list on screen is the table's own, every row of it, so it is written back whole rather than patched - when
330
+ // the change was written into it. When it was not (see `holds`), the table is read again from the lists just
331
+ // patched, or loaded when it has none, and a load still out for it is stopped: it may have been asked before the
332
+ // change, and would land without it.
333
+ if (!written)
334
+ void table.reload({ quiet: true });
335
+ else if (paging === "client" && shown)
240
336
  cache.update(shown, table.getRows());
241
337
  }
242
- function afterChange(action, rows, changes) {
243
- syncCaches(changes);
338
+ /**
339
+ * Whether the table holds the list an action was asked from, every row of it, so the answer can be written into it.
340
+ * Not when the user moved to another list while a slow answer was out - the table holds that list then - and not
341
+ * while a list is loading: a switch keeps the old list's rows until the new ones land, and writing those back would
342
+ * file one list under another's name.
343
+ */
344
+ function holds(from) {
345
+ return (from === undefined || from === modeRef.current) && table.getState().status === "ready";
346
+ }
347
+ function afterChange(action, rows, changes, written) {
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);
244
353
  const shown = modeRef.current;
245
354
  if (shown)
246
355
  latest.current.onChange?.({ action, rows, mode: shown });
247
356
  }
248
357
  function applyChange(action, rows, options = {}) {
249
- const { result, mass = false, markOn, values } = options;
358
+ const { result, mass = false, markOn, values, from } = options;
250
359
  const shown = modeRef.current;
251
360
  if (!shown)
252
361
  return;
362
+ // Every table write below is for the list the action was asked from; when the table holds another, none is made.
363
+ const write = holds(from);
253
364
  const answered = rows.length > 1 ? backend.answeredKeys(result, rowKey) : null;
254
365
  const done = answered ? rows.filter(row => answered.has(table.keyOf(row))) : rows;
255
366
  const changes = backend.answeredChanges(result, rowKey);
@@ -270,38 +381,18 @@ export function Crud(props) {
270
381
  next = { ...next, ...change };
271
382
  if (one)
272
383
  next = { ...next, ...one };
273
- if (leavesList(action, shown, backend.isFlagged(next)))
274
- leaving.push(key);
275
- else
276
- table.upsert(next, { flash: false });
384
+ if (write) {
385
+ if (leavesList(action, shown, backend.isFlagged(next)))
386
+ leaving.push(key);
387
+ else
388
+ table.upsert(next, { flash: false });
389
+ }
277
390
  updated.push(next);
278
391
  moved.push({ key, row: next, before: current });
279
392
  }
280
393
  table.remove(leaving);
281
- patchPins(updated);
282
- afterChange(action, updated, moved);
283
- return;
284
- }
285
- if (action === "pin") {
286
- const updated = [];
287
- for (const row of done) {
288
- const key = table.keyOf(row);
289
- const current = table.getRow(key) ?? row;
290
- const on = markOn ?? !backend.isPinned(current);
291
- let next = backend.withPin(current, on);
292
- const change = changes.get(key);
293
- if (change)
294
- next = { ...next, ...change };
295
- if (one)
296
- next = { ...next, ...one };
297
- // A pin changes the section above the list, never the list itself: the record stays where it is, on its page.
298
- if (table.getRow(key))
299
- table.upsert(next, { flash: false });
300
- updated.push(next);
301
- moved.push({ key, row: next, before: current });
302
- }
303
- applyPins(updated);
304
- afterChange(action, updated, moved);
394
+ patchMarked(updated);
395
+ afterChange(action, updated, moved, write);
305
396
  return;
306
397
  }
307
398
  if (action === "description") {
@@ -310,12 +401,13 @@ export function Crud(props) {
310
401
  const key = table.keyOf(row);
311
402
  const current = table.getRow(key) ?? row;
312
403
  const next = { ...current, ...values };
313
- table.upsert(next);
404
+ if (write)
405
+ table.upsert(next);
314
406
  updated.push(next);
315
407
  moved.push({ key, row: next, before: current });
316
408
  }
317
- patchPins(updated);
318
- afterChange(action, updated, moved);
409
+ patchMarked(updated);
410
+ afterChange(action, updated, moved, write);
319
411
  return;
320
412
  }
321
413
  // Trash, recover, delete and the move back to the trash take the record to another band, and only the answer
@@ -334,11 +426,39 @@ export function Crud(props) {
334
426
  }
335
427
  if (moved.length !== done.length)
336
428
  moved.length = 0;
337
- if (leavesList(action, shown))
429
+ if (write && leavesList(action, shown))
338
430
  table.remove(keys);
339
431
  if (LEFT_ACTIVE.has(action))
340
- dropPins(keys);
341
- afterChange(action, updated, moved);
432
+ dropMarked(keys);
433
+ else if (action === "recover")
434
+ restoreMarked(updated);
435
+ afterChange(action, updated, moved, write);
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);
342
462
  }
343
463
  /**
344
464
  * Records a page's own action changed, written exactly where the built-in ones write theirs. The handle's `apply`.
@@ -350,7 +470,12 @@ export function Crud(props) {
350
470
  const moved = [];
351
471
  const updated = [];
352
472
  const leaving = [];
353
- 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();
475
+ // The page asked from whichever list it did; the record's band decides, so only a table still loading is not
476
+ // written. A record that joined the list on screen - asked from another list, answered after a switch - is not in
477
+ // the table to be updated, so the table is read again from the patched lists instead.
478
+ let write = holds(undefined);
354
479
  for (const given of rows) {
355
480
  const next = { ...(table.getRow(table.keyOf(given)) ?? {}), ...given };
356
481
  const key = table.keyOf(next);
@@ -360,21 +485,22 @@ export function Crud(props) {
360
485
  // The record decides whether it is still in the list on screen; a backend that cannot say keeps it there.
361
486
  if (gone || member(next, shown) === false)
362
487
  leaving.push(key);
363
- else if (before)
488
+ else if (!before && member(next, shown))
489
+ write = false;
490
+ else if (before && write)
364
491
  table.upsert(next, { flash: false });
365
- if (before && backend.isPinned(next) !== backend.isPinned(before))
366
- pinned = true;
492
+ if (before && backend.marksOf(next) !== backend.marksOf(before))
493
+ stored.set(key, backend.marksOf(next));
367
494
  updated.push(next);
368
495
  moved.push({ key, row: next, before, gone });
369
496
  }
370
- table.remove(leaving);
497
+ if (write)
498
+ table.remove(leaving);
371
499
  if (gone)
372
- dropPins(leaving);
373
- else if (pinned)
374
- applyPins(updated);
500
+ dropMarked(leaving);
375
501
  else
376
- patchPins(updated);
377
- syncCaches(moved);
502
+ setMarked(current => current && patchMarkedRecords(current, updated, stored, row => table.keyOf(row)));
503
+ syncCaches(moved, write);
378
504
  }
379
505
  /* Row actions */
380
506
  async function runRowAction(action, row) {
@@ -382,55 +508,121 @@ export function Crud(props) {
382
508
  if (!endpoint)
383
509
  return;
384
510
  const key = rawKey(row);
385
- const markOn = action === "flag" ? !backend.isFlagged(row) : action === "pin" ? !backend.isPinned(row) : undefined;
386
- if (action === "pin" && markOn && pinRoom(pinned?.length) < 1) {
387
- toast.warning(pinsFull());
388
- return;
389
- }
390
- const options = { params: { id: key, [rowKey]: key }, subject: labelOf(row), action: crudMessageAction(action, markOn) };
511
+ const from = modeRef.current;
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) : {}) };
391
515
  const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
392
516
  if (answer?.ok)
393
- applyChange(action, [row], { result: answer.result, markOn });
517
+ applyChange(action, [row], { result: answer.result, markOn, from });
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);
394
610
  }
395
611
  async function runMassAction(action, rows) {
396
612
  if (action === "description") {
397
613
  openDescription(rows);
398
614
  return;
399
615
  }
400
- const endpoint = api.mass?.[action];
616
+ const endpoint = crudMassEndpoint(api, action);
401
617
  if (!endpoint)
402
618
  return;
403
- let markOn;
404
- let sent = rows;
405
- let over = 0;
406
- if (action === "flag") {
407
- const on = rows.filter(row => backend.isFlagged(row)).length;
408
- markOn = on === 0 ? true : on === rows.length ? false : undefined;
409
- }
410
- if (action === "pin") {
411
- // Every selected record pinned means Unpin; otherwise the ones that are not pinned yet are pinned, as many as
412
- // the limit leaves room for, in the order the user picked them. The rest stay as they are.
413
- const adding = rows.filter(row => !backend.isPinned(row));
414
- markOn = adding.length > 0;
415
- if (markOn) {
416
- const room = pinRoom(pinned?.length);
417
- if (room < 1) {
418
- toast.warning(pinsFull());
419
- return;
420
- }
421
- sent = adding.slice(0, room);
422
- over = adding.length - sent.length;
423
- }
424
- }
425
- const messageAction = action === "flag" && markOn === undefined ? TOGGLED_FLAG : crudMessageAction(action, markOn);
426
- 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) };
620
+ const from = modeRef.current;
427
621
  const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
428
622
  if (!answer?.ok)
429
623
  return;
430
- applyChange(action, sent, { result: answer.result, mass: true, markOn });
624
+ applyChange(action, rows, { result: answer.result, mass: true, from });
431
625
  table.clearSelection();
432
- if (over > 0)
433
- toast.warning(`${sent.length} of ${sent.length + over} ${plural} pinned: you can pin up to ${maxPins}.`);
434
626
  }
435
627
  /* Popups */
436
628
  const addPopup = useRef(null);
@@ -510,9 +702,10 @@ export function Crud(props) {
510
702
  return;
511
703
  const before = table.getRow(key);
512
704
  const next = { ...(before ?? record), ...full };
513
- if (before)
705
+ const write = holds(undefined);
706
+ if (write && before)
514
707
  table.upsert(next, { flash: false });
515
- syncCaches([{ key, row: next, before }]);
708
+ syncCaches([{ key, row: next, before }], write);
516
709
  }
517
710
  function onSaved(action, row, result, values) {
518
711
  // The record as the server now has it: what was sent, over the row it came from, with whatever the answer added.
@@ -523,9 +716,11 @@ export function Crud(props) {
523
716
  if (action === "edit") {
524
717
  const key = table.keyOf(record);
525
718
  const before = table.getRow(key);
526
- table.upsert(record);
527
- patchPins([record]);
528
- afterChange("edit", [record], key ? [{ key, row: record, before }] : []);
719
+ const write = holds(undefined);
720
+ if (write)
721
+ table.upsert(record);
722
+ patchMarked([record]);
723
+ afterChange("edit", [record], key ? [{ key, row: record, before }] : [], write);
529
724
  return;
530
725
  }
531
726
  // A new record belongs to the list of every record. An answer that is only the new key still gives it one, so
@@ -536,16 +731,22 @@ export function Crud(props) {
536
731
  record[rowKey] = given;
537
732
  }
538
733
  const key = table.keyOf(record);
539
- if (shown === "normal") {
540
- if (key)
541
- table.upsert(record);
542
- else
543
- void table.reload({ quiet: true });
544
- }
545
- afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : []);
546
- if (key && !answered && api.details)
734
+ // Without a key the lists are dropped and the one on screen loads again (`syncCaches`).
735
+ const write = Boolean(key) && holds(undefined);
736
+ if (write && shown === "normal")
737
+ table.upsert(record);
738
+ afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : [], write);
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))
547
742
  void fillSaved(key, record);
548
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
+ }
549
750
  function openDetails(row) {
550
751
  const details = latest.current.details;
551
752
  if (details === false)
@@ -553,6 +754,7 @@ export function Crud(props) {
553
754
  const key = table.keyOf(row);
554
755
  const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit");
555
756
  const endpoint = api.details;
757
+ const status = detailsLoad(endpoint ? "loading" : "done");
556
758
  // The panel's record: the row at once, so it has something to show while the full one loads, and that one after.
557
759
  const shown = typeof details?.aside === "function" ? new FormStore() : null;
558
760
  shown?.load(row, { draft: false });
@@ -568,18 +770,28 @@ export function Crud(props) {
568
770
  // draw: a panel that renders at once (a Copy button) would otherwise take it, and one that loads would not.
569
771
  initialFocus: aside ? ".nx-popup__foot button" : undefined,
570
772
  content: (_jsx(CrudFormBody, { aside: aside, details: true, children: _jsx(CrudDetails, { row: row, columns: details?.columns ?? latest.current.columns, render: details?.render, load: endpoint
571
- ? signal => browserApi
572
- .get(endpoint, { params: { id: rawKey(row), [rowKey]: rawKey(row) }, signal, toast: false })
573
- .then(unwrapApiResult)
574
- .then(full => {
575
- // Only when it adds something: a panel that renders from the record would otherwise ask again
576
- // for exactly what it is already showing.
577
- if (shown && isRecord(full) && addsToRow(row, full))
578
- shown.load({ ...row, ...full }, { draft: false });
579
- return full;
580
- })
773
+ ? signal => {
774
+ const loading = browserApi
775
+ .get(endpoint, { params: { id: rawKey(row), [rowKey]: rawKey(row) }, signal, toast: false })
776
+ .then(unwrapApiResult)
777
+ .then(full => {
778
+ // Only when it adds something: a panel that renders from the record would otherwise ask again
779
+ // for exactly what it is already showing.
780
+ if (shown && isRecord(full) && addsToRow(row, full))
781
+ shown.load({ ...row, ...full }, { draft: false });
782
+ return full;
783
+ });
784
+ // The footer follows the very promise the details do, with the same test for a popup closed meanwhile.
785
+ void loading.then(() => status.set("done"), error => {
786
+ if (!signal.aborted && !isAbortError(error))
787
+ status.set("failed");
788
+ });
789
+ return loading;
790
+ }
581
791
  : undefined }) })),
582
- footer: popup => (_jsxs(_Fragment, { children: [canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: () => {
792
+ footer: popup => (_jsxs(DetailsFooter, { load: status, children: [details?.actions
793
+ ?.filter(action => (!action.endpoint || (ready && can(action.endpoint))) && !action.hidden?.(row))
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: () => {
583
795
  popup.close();
584
796
  void openForm("edit", table.getRow(key) ?? row);
585
797
  }, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: () => popup.close(), children: "Close" })] })),
@@ -608,8 +820,8 @@ export function Crud(props) {
608
820
  });
609
821
  }
610
822
  // Row actions and the handle call these through a ref, so the table's callbacks stay the same between renders.
611
- const run = useRef({ runRowAction, runMassAction, openForm, openDetails, applyRows });
612
- 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 };
613
825
  /* The table's callbacks */
614
826
  const rowActions = useCallback((row) => {
615
827
  if (!mode)
@@ -617,49 +829,85 @@ export function Crud(props) {
617
829
  const list = [];
618
830
  const primary = mode === "normal" || mode === "flag";
619
831
  const flagged = backend.isFlagged(row);
620
- 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
+ };
621
839
  const info = {
622
840
  view: { label: `View ${(latest.current.details || undefined)?.label ?? "details"}`, icon: _jsx(InlineIcon, { icon: iconEye }) },
623
841
  edit: { label: "Edit", icon: _jsx(InlineIcon, { icon: iconPencil }), inline: true },
624
842
  flag: flagged ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
625
- 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 }) },
626
846
  trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
627
- recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconRestore }), inline: true },
847
+ recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
628
848
  delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
629
- trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconRestore }), inline: true },
849
+ trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
630
850
  };
631
851
  for (const action of crudRowActions(mode, candidate => allowed[candidate], trashless)) {
632
852
  if (action === "view" && latest.current.details === false)
633
853
  continue;
634
- 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);
635
866
  const entry = info[action];
636
867
  list.push({ ...entry, id: action, inline: entry.inline && (primary ? action === "edit" : true), onSelect });
637
868
  }
638
869
  const extra = latest.current.rowActions?.(row, mode) ?? [];
639
870
  return [...list, ...extra];
640
- }, [mode, allowed, backend, trashless]);
871
+ }, [mode, allowed, backend, trashless, marksOf, marksWait]);
641
872
  const massActionsFor = useCallback((rows) => {
642
873
  if (!mode)
643
874
  return [];
644
875
  const list = [];
645
- const flagged = rows.filter(row => backend.isFlagged(row)).length;
646
- const unpinned = rows.filter(row => !backend.isPinned(row)).length;
876
+ const marks = rows.map(marksOf);
647
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
+ }
648
898
  const onSelect = (selected) => run.current.runMassAction(action, selected);
649
899
  const info = {
650
- flag: flagged === rows.length ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: flagged ? "Flag / unflag" : "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
651
- pin: unpinned === 0 ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
652
900
  trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
653
- recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconRestore }) },
901
+ recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
654
902
  delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
655
- trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconRestore }) },
903
+ trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
656
904
  description: { label: "Description", icon: _jsx(InlineIcon, { icon: iconNotes }) },
657
905
  };
658
906
  list.push({ ...info[action], id: action, onSelect });
659
907
  }
660
908
  const extra = latest.current.massActions ? latest.current.massActions(rows, mode) : [];
661
909
  return [...list, ...extra];
662
- }, [mode, massAllowed, backend, massTrashless]);
910
+ }, [mode, massAllowed, backend, massTrashless, marksOf, marksWait]);
663
911
  const onRowOpen = useCallback((row) => {
664
912
  if (allowed.view && latest.current.details !== false)
665
913
  run.current.openDetails(row);
@@ -674,14 +922,14 @@ export function Crud(props) {
674
922
  setMode: next => setChosenMode(next),
675
923
  reload: () => {
676
924
  cache.clear();
677
- reloadPins();
925
+ reloadMarks();
678
926
  return table.reload({ quiet: true });
679
927
  },
680
928
  apply: (rows, options) => run.current.applyRows(rows, options?.gone ?? false),
681
929
  create: values => void run.current.openForm("create", null, values),
682
930
  edit: row => void run.current.openForm("edit", row),
683
931
  view: row => run.current.openDetails(row),
684
- }), [table, cache, reloadPins]);
932
+ }), [table, cache, reloadMarks]);
685
933
  const tableLabel = props["aria-label"] ?? (props["aria-labelledby"] ? undefined : capitalize(plural));
686
934
  const configured = CRUD_MODES.some(value => wantedModes.includes(value) && crudListEndpoint(api, value, paging));
687
935
  if (!configured) {
@@ -691,17 +939,49 @@ export function Crud(props) {
691
939
  return (_jsxs("div", { id: id, className: cx("nx-crud", "nx-crud--denied", className), style: style, role: "status", children: [_jsx(InlineIcon, { icon: iconLock, className: "nx-crud__denied-icon" }), _jsxs("p", { className: "nx-crud__denied-title", children: ["You do not have permission to see ", plural, "."] }), _jsx("p", { className: "nx-crud__denied-detail", children: "Ask an administrator for access." })] }));
692
940
  }
693
941
  const modeOptions = modes.map(value => ({ value, label: MODE_INFO[value].label, icon: _jsx(InlineIcon, { icon: MODE_INFO[value].icon }) }));
694
- const addButton = allowed.create ? (_jsxs(Button, { variant: "primary", size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: () => void run.current.openForm("create", null), children: ["Add ", subject] })) : null;
942
+ const add = allowed.create ? { label: `Add ${subject}`, onClick: () => void run.current.openForm("create", null) } : undefined;
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
+ : [];
950
+ const selectBy = mode === "normal"
951
+ ? [
952
+ ...markSelects("pin", "Pinned", "Not pinned"),
953
+ ...markSelects("favorite", "Favourites", "Not favourites"),
954
+ ...(massAllowed.flag
955
+ ? [
956
+ { label: "Flagged", icon: _jsx(InlineIcon, { icon: iconFlag }), test: (row) => backend.isFlagged(row) },
957
+ { label: "Not flagged", icon: _jsx(InlineIcon, { icon: iconFlagOff }), test: (row) => !backend.isFlagged(row) },
958
+ ]
959
+ : []),
960
+ ]
961
+ : [];
695
962
  const selectable = props.massActions !== false && Boolean(mode) && (crudMassActions(mode, action => massAllowed[action], massTrashless).length > 0 || Boolean(props.massActions));
696
- 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: "selected", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: _jsxs(_Fragment, { children: [props.toolbar, addButton] }), selectable: selectable, massActions: massActionsFor, 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: () => {
697
975
  cache.clear();
698
- reloadPins();
699
- }, emptyMessage: tableOptions?.emptyMessage ?? (mode ? MODE_INFO[mode].empty(plural) : undefined), emptyAction: mode === "normal" ? addButton : undefined, 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, 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 && {
976
+ reloadMarks();
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 && {
700
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?.() }),
701
981
  // A spreadsheet wrote records straight into the database: every list this CRUD kept is out of date.
702
982
  onImported: () => {
703
983
  cache.clear();
704
- reloadPins();
984
+ reloadMarks();
705
985
  tableOptions.excel?.onImported?.();
706
986
  },
707
987
  } }) }));