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.
- package/CHANGELOG.md +704 -446
- package/LICENSE +21 -21
- package/README.md +44 -5
- package/dist/Client.Index.d.ts +3 -0
- package/dist/Client.Index.js +3 -0
- package/dist/Components/Forms/Crud.js +322 -161
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudParts.js +2 -1
- package/dist/Components/Forms/Form.js +4 -1
- package/dist/Components/Inputs/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.js +4 -1
- package/dist/Components/Inputs/InputRenderer.js +3 -0
- package/dist/Components/Inputs/NumberBox.js +4 -2
- package/dist/Components/Inputs/TextArea.js +4 -1
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +55 -5
- package/dist/Components/Inputs/TreeView.js +372 -23
- package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
- package/dist/Components/Layouts/ThemePicker.js +16 -4
- package/dist/Components/Viewers/DataTable.js +63 -21
- package/dist/Components/Viewers/DataTableParts.d.ts +26 -8
- package/dist/Components/Viewers/DataTableParts.js +39 -10
- package/dist/Helpers/ApiModules.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +94 -4
- package/dist/Helpers/CrudBackend.js +168 -9
- package/dist/Helpers/CrudColumns.d.ts +51 -0
- package/dist/Helpers/CrudColumns.js +69 -0
- package/dist/Helpers/CrudHelpers.d.ts +50 -5
- package/dist/Helpers/CrudHelpers.js +109 -12
- package/dist/Helpers/InputParamsHelpers.js +8 -0
- package/dist/Helpers/MessageBuilder.js +2 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/RequestCache.d.ts +35 -0
- package/dist/Helpers/RequestCache.js +113 -0
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableHelpers.d.ts +1 -1
- package/dist/Helpers/TableHelpers.js +7 -2
- package/dist/Helpers/TableJoins.d.ts +70 -0
- package/dist/Helpers/TableJoins.js +250 -0
- package/dist/Helpers/TableStore.d.ts +5 -0
- package/dist/Helpers/TableStore.js +9 -0
- package/dist/Helpers/TreeStore.d.ts +225 -11
- package/dist/Helpers/TreeStore.js +683 -90
- package/dist/Helpers/UserPreferences.d.ts +8 -0
- package/dist/Helpers/UserPreferences.js +17 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +47 -9
- package/dist/Interfaces/FormInterfaces.d.ts +4 -1
- package/dist/Interfaces/InputInterfaces.d.ts +24 -0
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
- package/dist/Interfaces/PreferenceInterfaces.js +2 -5
- package/dist/Interfaces/TableInterfaces.d.ts +86 -4
- package/dist/Services/ApiProxy.d.ts +36 -1
- package/dist/Services/ApiProxy.js +182 -52
- package/dist/Services/ApiProxyRules.d.ts +203 -0
- package/dist/Services/ApiProxyRules.js +426 -0
- package/dist/Services/JoinSources.d.ts +48 -0
- package/dist/Services/JoinSources.js +74 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Shared.Index.d.ts +4 -0
- package/dist/Shared.Index.js +4 -0
- package/package.json +87 -85
- package/src/Styles/Nexus.Avatar.css +79 -79
- package/src/Styles/Nexus.Base.css +117 -117
- package/src/Styles/Nexus.Button.css +180 -180
- package/src/Styles/Nexus.Calendar.css +273 -273
- package/src/Styles/Nexus.Chat.css +1443 -1443
- package/src/Styles/Nexus.Choice.css +343 -343
- package/src/Styles/Nexus.Crud.css +168 -168
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -725
- package/src/Styles/Nexus.Editor.css +295 -295
- package/src/Styles/Nexus.Excel.css +389 -389
- package/src/Styles/Nexus.Form.css +279 -279
- package/src/Styles/Nexus.Index.css +37 -37
- package/src/Styles/Nexus.Input.css +196 -4
- package/src/Styles/Nexus.Markdown.css +237 -237
- package/src/Styles/Nexus.Menu.css +237 -237
- package/src/Styles/Nexus.Popup.css +724 -724
- package/src/Styles/Nexus.Rows.css +637 -637
- package/src/Styles/Nexus.Slider.css +276 -276
- package/src/Styles/Nexus.Tab.Buttons.css +432 -432
- package/src/Styles/Nexus.Table.css +31 -30
- package/src/Styles/Nexus.Theme.Picker.css +59 -3
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -144
- package/src/Styles/Nexus.Toast.css +263 -263
- package/src/Styles/Nexus.Tokens.css +89 -89
- 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,
|
|
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
|
|
28
|
-
* popup, actions on many rows, a cache of lists while the page is open, and rows updated from each
|
|
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
|
|
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
|
-
/*
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
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 (!
|
|
201
|
-
|
|
230
|
+
if (!marksOn || !marksEndpoint) {
|
|
231
|
+
setMarked(null);
|
|
232
|
+
setMarksState("none");
|
|
202
233
|
return;
|
|
203
234
|
}
|
|
204
235
|
const controller = new AbortController();
|
|
205
|
-
|
|
206
|
-
|
|
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 (
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
() =>
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
/**
|
|
236
|
-
function
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
251
|
-
function
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
453
|
-
|
|
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
|
-
|
|
461
|
-
else if (pinned)
|
|
462
|
-
applyPins(updated);
|
|
500
|
+
dropMarked(leaving);
|
|
463
501
|
else
|
|
464
|
-
|
|
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) :
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
616
|
+
const endpoint = crudMassEndpoint(api, action);
|
|
490
617
|
if (!endpoint)
|
|
491
618
|
return;
|
|
492
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
984
|
+
reloadMarks();
|
|
824
985
|
tableOptions.excel?.onImported?.();
|
|
825
986
|
},
|
|
826
987
|
} }) }));
|