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.
- package/CHANGELOG.md +442 -0
- package/README.md +1123 -1000
- package/dist/Client.Index.d.ts +6 -0
- package/dist/Client.Index.js +6 -0
- package/dist/Components/Chats/ChatParts.d.ts +2 -2
- package/dist/Components/Chats/ChatParts.js +3 -7
- package/dist/Components/Chats/ChatThread.js +3 -3
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +21 -9
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +7 -5
- package/dist/Components/Documents/TabButtons.d.ts +5 -2
- package/dist/Components/Documents/TabButtons.js +2 -2
- package/dist/Components/Forms/ApiForm.d.ts +1 -1
- package/dist/Components/Forms/ApiForm.js +1 -1
- package/dist/Components/Forms/Crud.js +486 -206
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +38 -5
- package/dist/Components/Forms/ExcelImport.d.ts +23 -7
- package/dist/Components/Forms/ExcelImport.js +189 -60
- package/dist/Components/Forms/Form.js +9 -14
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- 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/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/InputRenderer.js +3 -0
- package/dist/Components/Inputs/NumberBox.js +26 -2
- package/dist/Components/Inputs/RowsInput.js +2 -2
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.js +42 -10
- package/dist/Components/Inputs/TextArea.js +4 -1
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TimePanel.js +4 -3
- 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/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
- package/dist/Components/Layouts/ThemePicker.js +89 -6
- package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
- package/dist/Components/Layouts/ThemeScript.js +4 -2
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
- package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +252 -62
- package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
- package/dist/Components/Viewers/DataTableColumns.js +25 -68
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
- package/dist/Components/Viewers/DataTableParts.js +42 -13
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- 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 +101 -8
- package/dist/Helpers/CrudBackend.js +172 -11
- 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/ExcelBackend.d.ts +3 -3
- package/dist/Helpers/ExcelBackend.js +8 -2
- package/dist/Helpers/ExcelHelpers.d.ts +36 -8
- package/dist/Helpers/ExcelHelpers.js +55 -13
- package/dist/Helpers/FormStore.d.ts +9 -5
- package/dist/Helpers/FormStore.js +25 -5
- package/dist/Helpers/InputParamsHelpers.js +8 -0
- package/dist/Helpers/MessageBuilder.js +3 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +17 -5
- package/dist/Helpers/PopoverHelpers.js +52 -17
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RequestCache.d.ts +35 -0
- package/dist/Helpers/RequestCache.js +113 -0
- package/dist/Helpers/RowsStore.d.ts +7 -2
- package/dist/Helpers/RowsStore.js +15 -3
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +7 -4
- package/dist/Helpers/TableHelpers.js +15 -6
- package/dist/Helpers/TableJoins.d.ts +70 -0
- package/dist/Helpers/TableJoins.js +250 -0
- package/dist/Helpers/TableStore.d.ts +16 -3
- package/dist/Helpers/TableStore.js +25 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/TreeStore.d.ts +225 -11
- package/dist/Helpers/TreeStore.js +683 -90
- package/dist/Helpers/UserPreferences.d.ts +22 -0
- package/dist/Helpers/UserPreferences.js +116 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
- package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
- package/dist/Interfaces/FormInterfaces.d.ts +37 -6
- package/dist/Interfaces/InputInterfaces.d.ts +24 -0
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
- package/dist/Interfaces/PreferenceInterfaces.js +25 -0
- package/dist/Interfaces/TableInterfaces.d.ts +145 -16
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- 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/ExcelApi.d.ts +3 -1
- package/dist/Services/ExcelApi.js +1 -1
- 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/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +6 -0
- package/dist/Shared.Index.js +6 -0
- package/package.json +4 -2
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +51 -4
- package/src/Styles/Nexus.Button.css +27 -0
- package/src/Styles/Nexus.Calendar.css +4 -6
- package/src/Styles/Nexus.Chat.css +5 -51
- package/src/Styles/Nexus.Choice.css +4 -6
- package/src/Styles/Nexus.Crud.css +16 -9
- package/src/Styles/Nexus.Dropdown.css +12 -61
- package/src/Styles/Nexus.Editor.css +4 -6
- package/src/Styles/Nexus.Excel.css +34 -15
- package/src/Styles/Nexus.Form.css +25 -67
- package/src/Styles/Nexus.Index.css +1 -0
- package/src/Styles/Nexus.Input.css +794 -637
- package/src/Styles/Nexus.Markdown.css +0 -28
- package/src/Styles/Nexus.Menu.css +8 -30
- package/src/Styles/Nexus.Popup.css +22 -29
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +51 -10
- package/src/Styles/Nexus.Slider.css +3 -5
- package/src/Styles/Nexus.Tab.Buttons.css +20 -7
- package/src/Styles/Nexus.Table.css +1387 -1296
- package/src/Styles/Nexus.Theme.Picker.css +342 -232
- package/src/Styles/Nexus.Time.css +9 -12
- package/src/Styles/Nexus.Toast.css +6 -8
- package/src/Styles/Nexus.Tokens.css +17 -0
- 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,
|
|
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,
|
|
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
|
|
27
|
-
* popup, actions on many rows, a cache of lists while the page is open, and rows updated from each
|
|
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
|
-
|
|
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
|
|
38
|
-
const ASKED = new Set(["trash", "delete", "trashFromDelete"]);
|
|
39
|
-
/** Actions that take a record out of the active lists, so it
|
|
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
|
-
/*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
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);
|
|
138
229
|
useEffect(() => {
|
|
139
|
-
if (!
|
|
140
|
-
|
|
230
|
+
if (!marksOn || !marksEndpoint) {
|
|
231
|
+
setMarked(null);
|
|
232
|
+
setMarksState("none");
|
|
141
233
|
return;
|
|
142
234
|
}
|
|
143
235
|
const controller = new AbortController();
|
|
144
|
-
|
|
145
|
-
|
|
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 (
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
() =>
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
/**
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
190
|
-
function
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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 (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
+
if (write)
|
|
405
|
+
table.upsert(next);
|
|
314
406
|
updated.push(next);
|
|
315
407
|
moved.push({ key, row: next, before: current });
|
|
316
408
|
}
|
|
317
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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.
|
|
366
|
-
|
|
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
|
-
|
|
497
|
+
if (write)
|
|
498
|
+
table.remove(leaving);
|
|
371
499
|
if (gone)
|
|
372
|
-
|
|
373
|
-
else if (pinned)
|
|
374
|
-
applyPins(updated);
|
|
500
|
+
dropMarked(leaving);
|
|
375
501
|
else
|
|
376
|
-
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
616
|
+
const endpoint = crudMassEndpoint(api, action);
|
|
401
617
|
if (!endpoint)
|
|
402
618
|
return;
|
|
403
|
-
|
|
404
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (key && !answered
|
|
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 =>
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
shown
|
|
579
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
}, emptyMessage: tableOptions?.emptyMessage ?? (mode ? MODE_INFO[mode].empty(plural) : undefined), emptyAction: mode === "normal" ?
|
|
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
|
-
|
|
984
|
+
reloadMarks();
|
|
705
985
|
tableOptions.excel?.onImported?.();
|
|
706
986
|
},
|
|
707
987
|
} }) }));
|