nexus-shared 2.0.0 → 3.1.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 +446 -0
- package/LICENSE +21 -21
- package/README.md +1084 -903
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -0
- package/dist/Components/Chats/Chat.d.ts +28 -0
- package/dist/Components/Chats/Chat.js +19 -0
- package/dist/Components/Chats/ChatButton.d.ts +26 -0
- package/dist/Components/Chats/ChatButton.js +41 -0
- package/dist/Components/Chats/ChatComposer.d.ts +41 -0
- package/dist/Components/Chats/ChatComposer.js +177 -0
- package/dist/Components/Chats/ChatConversations.d.ts +35 -0
- package/dist/Components/Chats/ChatConversations.js +38 -0
- package/dist/Components/Chats/ChatPanel.d.ts +66 -0
- package/dist/Components/Chats/ChatPanel.js +94 -0
- package/dist/Components/Chats/ChatParts.d.ts +87 -0
- package/dist/Components/Chats/ChatParts.js +96 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +44 -28
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +12 -8
- package/dist/Components/Documents/TabButtons.d.ts +29 -8
- package/dist/Components/Documents/TabButtons.js +24 -5
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +342 -71
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +37 -5
- package/dist/Components/Forms/ExcelImport.d.ts +58 -0
- package/dist/Components/Forms/ExcelImport.js +319 -0
- package/dist/Components/Forms/Form.js +9 -13
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/GroupForm.js +4 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
- package/dist/Components/Inputs/InputRenderer.js +1 -1
- package/dist/Components/Inputs/NumberBox.js +24 -2
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +55 -6
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +117 -20
- package/dist/Components/Inputs/TimePanel.js +4 -3
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
- package/dist/Components/Layouts/ThemePicker.js +77 -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 +39 -18
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +262 -60
- package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
- package/dist/Components/Viewers/DataTableColumns.js +200 -0
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
- package/dist/Components/Viewers/DataTableParts.js +24 -12
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/ApiClient.d.ts +22 -0
- package/dist/Helpers/ApiClient.js +4 -1
- package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
- package/dist/Helpers/ApiFormHelpers.js +73 -9
- package/dist/Helpers/ApiModules.d.ts +0 -4
- package/dist/Helpers/ApiModules.js +1 -0
- package/dist/Helpers/ApiResponses.d.ts +14 -4
- package/dist/Helpers/ApiResponses.js +116 -25
- package/dist/Helpers/ApiRoutes.d.ts +0 -10
- package/dist/Helpers/ApiRoutes.js +2 -10
- package/dist/Helpers/ChatBackend.d.ts +134 -0
- package/dist/Helpers/ChatBackend.js +332 -0
- package/dist/Helpers/ChatHelpers.d.ts +189 -0
- package/dist/Helpers/ChatHelpers.js +486 -0
- package/dist/Helpers/ChatHooks.d.ts +81 -0
- package/dist/Helpers/ChatHooks.js +175 -0
- package/dist/Helpers/ChatStore.d.ts +132 -0
- package/dist/Helpers/ChatStore.js +394 -0
- package/dist/Helpers/ChatSummaries.d.ts +39 -0
- package/dist/Helpers/ChatSummaries.js +118 -0
- package/dist/Helpers/CrudBackend.d.ts +54 -9
- package/dist/Helpers/CrudBackend.js +103 -6
- package/dist/Helpers/CrudHelpers.d.ts +39 -4
- package/dist/Helpers/CrudHelpers.js +76 -11
- package/dist/Helpers/ExcelBackend.d.ts +106 -0
- package/dist/Helpers/ExcelBackend.js +296 -0
- package/dist/Helpers/ExcelHelpers.d.ts +126 -0
- package/dist/Helpers/ExcelHelpers.js +344 -0
- package/dist/Helpers/FormStore.d.ts +11 -5
- package/dist/Helpers/FormStore.js +44 -6
- package/dist/Helpers/MessageBuilder.js +1 -2
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +46 -9
- package/dist/Helpers/PopoverHelpers.js +163 -24
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RowsStore.d.ts +14 -3
- package/dist/Helpers/RowsStore.js +37 -3
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +15 -4
- package/dist/Helpers/TableHelpers.js +28 -4
- package/dist/Helpers/TableStore.d.ts +11 -3
- package/dist/Helpers/TableStore.js +16 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/UserPreferences.d.ts +14 -0
- package/dist/Helpers/UserPreferences.js +99 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
- package/dist/Interfaces/ApiInterfaces.js +16 -10
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/dist/Interfaces/ChatInterfaces.js +1 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +85 -7
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
- package/dist/Interfaces/PreferenceInterfaces.js +28 -0
- package/dist/Interfaces/TableInterfaces.d.ts +116 -9
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- package/dist/Services/BrowserApi.d.ts +18 -1
- package/dist/Services/BrowserApi.js +18 -0
- package/dist/Services/ChatApi.d.ts +37 -0
- package/dist/Services/ChatApi.js +98 -0
- package/dist/Services/ExcelApi.d.ts +48 -0
- package/dist/Services/ExcelApi.js +196 -0
- package/dist/Services/ServerApi.d.ts +6 -1
- package/dist/Services/ServerApi.js +3 -0
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +10 -0
- package/dist/Shared.Index.js +10 -0
- package/package.json +85 -82
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +117 -70
- package/src/Styles/Nexus.Button.css +180 -151
- package/src/Styles/Nexus.Calendar.css +273 -275
- package/src/Styles/Nexus.Chat.css +1443 -0
- package/src/Styles/Nexus.Choice.css +343 -345
- package/src/Styles/Nexus.Crud.css +168 -84
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -774
- package/src/Styles/Nexus.Editor.css +295 -297
- package/src/Styles/Nexus.Excel.css +389 -0
- package/src/Styles/Nexus.Form.css +279 -298
- package/src/Styles/Nexus.Index.css +37 -34
- package/src/Styles/Nexus.Input.css +602 -637
- package/src/Styles/Nexus.Markdown.css +237 -265
- package/src/Styles/Nexus.Menu.css +237 -249
- package/src/Styles/Nexus.Popup.css +724 -731
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +637 -566
- package/src/Styles/Nexus.Slider.css +276 -278
- package/src/Styles/Nexus.Tab.Buttons.css +432 -364
- package/src/Styles/Nexus.Table.css +1386 -1014
- package/src/Styles/Nexus.Theme.Picker.css +286 -159
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -147
- package/src/Styles/Nexus.Toast.css +263 -265
- package/src/Styles/Nexus.Tokens.css +89 -72
- package/src/Styles/Nexus.Tree.css +431 -435
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
3
|
-
import {
|
|
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";
|
|
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, crudListEndpoint, crudMassActions, crudMessageAction, crudRowActions, crudRowEndpoint, getCrudSettings, leavesList, massActionAllowed, pinRoom, } from "../../Helpers/CrudHelpers.js";
|
|
8
|
+
import { actionAllowed, availableModes, CRUD_CACHE_LIFETIME, CRUD_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudListEndpoint, crudMassActions, crudMessageAction, crudRowActions, crudRowEndpoint, getCrudSettings, leavesList, massActionAllowed, patchCachedList, pinRoom, } from "../../Helpers/CrudHelpers.js";
|
|
9
9
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
10
10
|
import { FormStore } from "../../Helpers/FormStore.js";
|
|
11
11
|
import { 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";
|
|
15
16
|
import { Button } from "../Documents/Button.js";
|
|
@@ -22,23 +23,23 @@ import { CrudDetails } from "./CrudParts.js";
|
|
|
22
23
|
import { confirmDiscard, FormButtons } from "./SubmitForm.js";
|
|
23
24
|
/*
|
|
24
25
|
* Crud: a module's records in one component. It takes a controller's endpoints, the table's columns, and the form's
|
|
25
|
-
* inputs, and handles the rest: the lists (all, flagged,
|
|
26
|
+
* inputs, and handles the rest: the lists (all, flagged, trash, deleted) the user may see, the actions they
|
|
26
27
|
* may take, the records the user pinned in a section above the list, Add and Edit popups built on ApiForm, a details
|
|
27
28
|
* popup, actions on many rows, a cache of lists while the page is open, and rows updated from each answer instead of
|
|
28
29
|
* reloading.
|
|
29
30
|
*/
|
|
30
|
-
const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "
|
|
31
|
+
const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"];
|
|
31
32
|
const MODE_INFO = {
|
|
32
|
-
|
|
33
|
+
// "Active", not "All": the list leaves out what is in the trash and what is deleted, and "All" said otherwise.
|
|
34
|
+
normal: { label: "Active", icon: iconList, empty: plural => `No ${plural} yet.` },
|
|
33
35
|
flag: { label: "Flagged", icon: iconFlag, empty: plural => `No flagged ${plural}.` },
|
|
34
|
-
archive: { label: "Archived", icon: iconArchive, empty: () => "The archive is empty." },
|
|
35
36
|
trash: { label: "Trash", icon: iconTrash, empty: () => "The trash is empty." },
|
|
36
37
|
deleted: { label: "Deleted", icon: iconTrashX, empty: plural => `No deleted ${plural}.` },
|
|
37
38
|
};
|
|
38
|
-
/** Actions that
|
|
39
|
-
const ASKED = new Set(["
|
|
39
|
+
/** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
|
|
40
|
+
const ASKED = new Set(["trash", "recover", "delete", "trashFromDelete"]);
|
|
40
41
|
/** Actions that take a record out of the active lists, so it is no longer among the pinned records either. */
|
|
41
|
-
const LEFT_ACTIVE = new Set(["
|
|
42
|
+
const LEFT_ACTIVE = new Set(["trash", "delete", "trashFromDelete"]);
|
|
42
43
|
/** The words of an action that flags some records and unflags others (a mass toggle of a mixed selection). */
|
|
43
44
|
const TOGGLED_FLAG = { verb: "change the flags of {object}", done: "updated", doing: "Changing the flags of {object}", object: "the records" };
|
|
44
45
|
const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
@@ -51,6 +52,80 @@ function endpointSubject(api) {
|
|
|
51
52
|
const endpoints = [api.create, api.update, api.details, ...Object.values(api.lists ?? {}), ...Object.values(api.pages ?? {})];
|
|
52
53
|
return endpoints.find(endpoint => endpoint?.subject)?.subject;
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The body of an Add, Edit or details popup. Without an `aside` it is the form (or the details) and nothing else, so
|
|
57
|
+
* a popup that wants no panel carries no wrapper at all. With one, the two become columns that scroll on their own,
|
|
58
|
+
* and the form is first in the DOM — which is also the order they stack in when there is no room for two. A details
|
|
59
|
+
* popup is marked, because there the panel is what the popup is for and takes the larger share.
|
|
60
|
+
*/
|
|
61
|
+
function CrudFormBody({ aside, details, children }) {
|
|
62
|
+
if (!aside)
|
|
63
|
+
return children;
|
|
64
|
+
return (_jsxs("div", { className: cx("nx-crud-aside", details && "nx-crud-aside--details"), children: [_jsx("div", { className: "nx-crud-aside__main", children: children }), _jsx("aside", { className: "nx-crud-aside__side", children: aside })] }));
|
|
65
|
+
}
|
|
66
|
+
/** Whether the details endpoint's record says anything the list's row did not. Plain JSON, compared field by field. */
|
|
67
|
+
function addsToRow(row, full) {
|
|
68
|
+
return Object.entries(full).some(([name, value]) => JSON.stringify(value) !== JSON.stringify(row[name]));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A details record that lands sooner than this says nothing: the demo answers in about 0.3s and most backends sooner,
|
|
72
|
+
* and a word shown for a frame or two is the flicker this avoids. Longer than the popup's 160ms fade, so the word
|
|
73
|
+
* never lands on a half-faded popup.
|
|
74
|
+
*/
|
|
75
|
+
const DETAILS_STATUS_DELAY = 400;
|
|
76
|
+
/** Once "Loading…" shows, it stays at least this long, so a record that lands just after the delay does not blink it. */
|
|
77
|
+
const DETAILS_STATUS_HOLD = 300;
|
|
78
|
+
/** 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. */
|
|
79
|
+
function detailsLoad(initial) {
|
|
80
|
+
let state = initial;
|
|
81
|
+
const listeners = new Set();
|
|
82
|
+
return {
|
|
83
|
+
get: () => state,
|
|
84
|
+
set(next) {
|
|
85
|
+
if (next === state)
|
|
86
|
+
return;
|
|
87
|
+
state = next;
|
|
88
|
+
for (const listener of listeners)
|
|
89
|
+
listener();
|
|
90
|
+
},
|
|
91
|
+
subscribe(listener) {
|
|
92
|
+
listeners.add(listener);
|
|
93
|
+
return () => {
|
|
94
|
+
listeners.delete(listener);
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 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
|
|
101
|
+
* always there, so what it says never moves anything: "Loading…" only for a slow record, "Not all fields loaded." for
|
|
102
|
+
* one that failed.
|
|
103
|
+
*/
|
|
104
|
+
function DetailsFooter({ load, children }) {
|
|
105
|
+
const state = useSyncExternalStore(load.subscribe, load.get, load.get);
|
|
106
|
+
const [said, setSaid] = useState(null);
|
|
107
|
+
const since = useRef(0);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (state === "loading") {
|
|
110
|
+
if (said === "loading")
|
|
111
|
+
return;
|
|
112
|
+
const timer = window.setTimeout(() => {
|
|
113
|
+
since.current = performance.now();
|
|
114
|
+
setSaid("loading");
|
|
115
|
+
}, DETAILS_STATUS_DELAY);
|
|
116
|
+
return () => window.clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
const next = state === "failed" ? "failed" : null;
|
|
119
|
+
const left = said === "loading" ? since.current + DETAILS_STATUS_HOLD - performance.now() : 0;
|
|
120
|
+
if (left <= 0) {
|
|
121
|
+
setSaid(next);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const timer = window.setTimeout(() => setSaid(next), left);
|
|
125
|
+
return () => window.clearTimeout(timer);
|
|
126
|
+
}, [state, said]);
|
|
127
|
+
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 })] }));
|
|
128
|
+
}
|
|
54
129
|
/**
|
|
55
130
|
* A CRUD page in one component: a data table of a controller's records with its lists, Add and Edit popups, details,
|
|
56
131
|
* and actions on one record or many, each shown only when its endpoint exists and the user may call it.
|
|
@@ -89,7 +164,7 @@ export function Crud(props) {
|
|
|
89
164
|
}, [ready, api, wantedActions, can]);
|
|
90
165
|
const massAllowed = useMemo(() => {
|
|
91
166
|
const map = {};
|
|
92
|
-
for (const action of ["flag", "pin", "
|
|
167
|
+
for (const action of ["flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"])
|
|
93
168
|
map[action] = ready && massActionAllowed(action, api, wantedActions, can);
|
|
94
169
|
return map;
|
|
95
170
|
}, [ready, api, wantedActions, can]);
|
|
@@ -187,13 +262,14 @@ export function Crud(props) {
|
|
|
187
262
|
return new Promise(() => undefined);
|
|
188
263
|
const endpoint = crudListEndpoint(api, mode, paging);
|
|
189
264
|
const sortColumn = query.sort ? latest.current.columns.find(column => column.key === query.sort.key) : undefined;
|
|
190
|
-
const
|
|
265
|
+
const filters = query.filters ? JSON.stringify(query.filters) : "";
|
|
266
|
+
const key = paging === "server" ? `${mode}|${query.search}|${query.sort ? `${query.sort.key}:${query.sort.direction}` : ""}|${query.page}|${query.pageSize}|${filters}` : mode;
|
|
191
267
|
const kept = cache.get(key);
|
|
192
268
|
if (kept)
|
|
193
269
|
return kept;
|
|
194
270
|
const options = { signal, toast: false, subject: plural };
|
|
195
271
|
if (paging === "server")
|
|
196
|
-
options.body = backend.pageRequest(query, sortColumn?.sortKey ?? sortColumn?.key);
|
|
272
|
+
options.body = backend.pageRequest(query, sortColumn?.sortKey ?? sortColumn?.key, filterRequest(query.filters, latest.current.table?.filterInputs ?? [], latest.current.columns));
|
|
197
273
|
return browserApi.send(endpoint, options).then(answer => {
|
|
198
274
|
if (answer.aborted)
|
|
199
275
|
throw abortError();
|
|
@@ -204,27 +280,62 @@ export function Crud(props) {
|
|
|
204
280
|
return result;
|
|
205
281
|
});
|
|
206
282
|
}, [mode, api, paging, cache, backend, plural]);
|
|
207
|
-
/**
|
|
208
|
-
|
|
283
|
+
/** Which list a record belongs in, as this backend reads the record. */
|
|
284
|
+
const member = (row, of) => backend.listMembership(row, of);
|
|
285
|
+
/**
|
|
286
|
+
* The lists kept in the cache, after a change: **every one of them is patched**, from the record the answer
|
|
287
|
+
* carried. A record flagged joins the flagged list, a record trashed leaves the active list and joins the trash,
|
|
288
|
+
* and coming back to any of them costs no request. Only a list the record cannot answer for (`changes` empty, or
|
|
289
|
+
* a backend with no `listMembership`) is dropped, and that one loads when it is shown.
|
|
290
|
+
*
|
|
291
|
+
* A server page is not patched when a record joined it or left it: its order, its last row and its counts are the
|
|
292
|
+
* server's, so that page is dropped and the next one loads. One whose rows only changed is written in place.
|
|
293
|
+
*/
|
|
294
|
+
function syncCaches(changes, written) {
|
|
209
295
|
const shown = modeRef.current;
|
|
210
|
-
if (
|
|
211
|
-
cache.
|
|
212
|
-
cache.update(shown, table.getRows());
|
|
213
|
-
}
|
|
296
|
+
if (changes.length)
|
|
297
|
+
cache.map((key, value) => patchCachedList(value, crudCacheMode(key), changes, row => table.keyOf(row), member));
|
|
214
298
|
else
|
|
215
299
|
cache.clear();
|
|
300
|
+
// The list on screen is the table's own, every row of it, so it is written back whole rather than patched - when
|
|
301
|
+
// the change was written into it. When it was not (see `holds`), the table is read again from the lists just
|
|
302
|
+
// patched, or loaded when it has none, and a load still out for it is stopped: it may have been asked before the
|
|
303
|
+
// change, and would land without it.
|
|
304
|
+
if (!written)
|
|
305
|
+
void table.reload({ quiet: true });
|
|
306
|
+
else if (paging === "client" && shown)
|
|
307
|
+
cache.update(shown, table.getRows());
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Whether the table holds the list an action was asked from, every row of it, so the answer can be written into it.
|
|
311
|
+
* Not when the user moved to another list while a slow answer was out - the table holds that list then - and not
|
|
312
|
+
* while a list is loading: a switch keeps the old list's rows until the new ones land, and writing those back would
|
|
313
|
+
* file one list under another's name.
|
|
314
|
+
*/
|
|
315
|
+
function holds(from) {
|
|
316
|
+
return (from === undefined || from === modeRef.current) && table.getState().status === "ready";
|
|
317
|
+
}
|
|
318
|
+
function afterChange(action, rows, changes, written) {
|
|
319
|
+
syncCaches(changes, written);
|
|
320
|
+
const shown = modeRef.current;
|
|
216
321
|
if (shown)
|
|
217
322
|
latest.current.onChange?.({ action, rows, mode: shown });
|
|
218
323
|
}
|
|
219
|
-
|
|
220
|
-
|
|
324
|
+
function applyChange(action, rows, options = {}) {
|
|
325
|
+
const { result, mass = false, markOn, values, from } = options;
|
|
221
326
|
const shown = modeRef.current;
|
|
222
327
|
if (!shown)
|
|
223
328
|
return;
|
|
329
|
+
// Every table write below is for the list the action was asked from; when the table holds another, none is made.
|
|
330
|
+
const write = holds(from);
|
|
224
331
|
const answered = rows.length > 1 ? backend.answeredKeys(result, rowKey) : null;
|
|
225
332
|
const done = answered ? rows.filter(row => answered.has(table.keyOf(row))) : rows;
|
|
333
|
+
const changes = backend.answeredChanges(result, rowKey);
|
|
334
|
+
// What the answer said about the one record acted on: the record, or the fields that changed - a mark's new
|
|
335
|
+
// status. It is what lets the other lists be patched rather than thrown away.
|
|
336
|
+
const one = mass ? null : backend.answeredRow(result);
|
|
337
|
+
const moved = [];
|
|
226
338
|
if (action === "flag") {
|
|
227
|
-
const changes = backend.answeredChanges(result, rowKey);
|
|
228
339
|
const leaving = [];
|
|
229
340
|
const updated = [];
|
|
230
341
|
for (const row of done) {
|
|
@@ -235,19 +346,23 @@ export function Crud(props) {
|
|
|
235
346
|
const change = changes.get(key);
|
|
236
347
|
if (change)
|
|
237
348
|
next = { ...next, ...change };
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
349
|
+
if (one)
|
|
350
|
+
next = { ...next, ...one };
|
|
351
|
+
if (write) {
|
|
352
|
+
if (leavesList(action, shown, backend.isFlagged(next)))
|
|
353
|
+
leaving.push(key);
|
|
354
|
+
else
|
|
355
|
+
table.upsert(next, { flash: false });
|
|
356
|
+
}
|
|
242
357
|
updated.push(next);
|
|
358
|
+
moved.push({ key, row: next, before: current });
|
|
243
359
|
}
|
|
244
360
|
table.remove(leaving);
|
|
245
361
|
patchPins(updated);
|
|
246
|
-
afterChange(action, updated);
|
|
362
|
+
afterChange(action, updated, moved, write);
|
|
247
363
|
return;
|
|
248
364
|
}
|
|
249
365
|
if (action === "pin") {
|
|
250
|
-
const changes = backend.answeredChanges(result, rowKey);
|
|
251
366
|
const updated = [];
|
|
252
367
|
for (const row of done) {
|
|
253
368
|
const key = table.keyOf(row);
|
|
@@ -257,29 +372,97 @@ export function Crud(props) {
|
|
|
257
372
|
const change = changes.get(key);
|
|
258
373
|
if (change)
|
|
259
374
|
next = { ...next, ...change };
|
|
375
|
+
if (one)
|
|
376
|
+
next = { ...next, ...one };
|
|
260
377
|
// A pin changes the section above the list, never the list itself: the record stays where it is, on its page.
|
|
261
|
-
if (table.getRow(key))
|
|
378
|
+
if (write && table.getRow(key))
|
|
262
379
|
table.upsert(next, { flash: false });
|
|
263
380
|
updated.push(next);
|
|
381
|
+
moved.push({ key, row: next, before: current });
|
|
264
382
|
}
|
|
265
383
|
applyPins(updated);
|
|
266
|
-
afterChange(action, updated);
|
|
384
|
+
afterChange(action, updated, moved, write);
|
|
267
385
|
return;
|
|
268
386
|
}
|
|
269
387
|
if (action === "description") {
|
|
270
|
-
const updated =
|
|
271
|
-
for (const row of
|
|
272
|
-
table.
|
|
388
|
+
const updated = [];
|
|
389
|
+
for (const row of done) {
|
|
390
|
+
const key = table.keyOf(row);
|
|
391
|
+
const current = table.getRow(key) ?? row;
|
|
392
|
+
const next = { ...current, ...values };
|
|
393
|
+
if (write)
|
|
394
|
+
table.upsert(next);
|
|
395
|
+
updated.push(next);
|
|
396
|
+
moved.push({ key, row: next, before: current });
|
|
397
|
+
}
|
|
273
398
|
patchPins(updated);
|
|
274
|
-
afterChange(action, updated);
|
|
399
|
+
afterChange(action, updated, moved, write);
|
|
275
400
|
return;
|
|
276
401
|
}
|
|
402
|
+
// Trash, recover, delete and the move back to the trash take the record to another band, and only the answer
|
|
403
|
+
// says which. Every record has to say so for the other lists to be patched; one that does not leaves `moved`
|
|
404
|
+
// empty, and they load again, which is what they all did before.
|
|
277
405
|
const keys = done.map(row => table.keyOf(row));
|
|
278
|
-
|
|
406
|
+
const updated = [];
|
|
407
|
+
for (const row of done) {
|
|
408
|
+
const key = table.keyOf(row);
|
|
409
|
+
const current = table.getRow(key) ?? row;
|
|
410
|
+
const change = changes.get(key) ?? one;
|
|
411
|
+
const next = change ? { ...current, ...change } : current;
|
|
412
|
+
if (change)
|
|
413
|
+
moved.push({ key, row: next, before: current });
|
|
414
|
+
updated.push(next);
|
|
415
|
+
}
|
|
416
|
+
if (moved.length !== done.length)
|
|
417
|
+
moved.length = 0;
|
|
418
|
+
if (write && leavesList(action, shown))
|
|
279
419
|
table.remove(keys);
|
|
280
420
|
if (LEFT_ACTIVE.has(action))
|
|
281
421
|
dropPins(keys);
|
|
282
|
-
afterChange(action,
|
|
422
|
+
afterChange(action, updated, moved, write);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Records a page's own action changed, written exactly where the built-in ones write theirs. The handle's `apply`.
|
|
426
|
+
*/
|
|
427
|
+
function applyRows(rows, gone = false) {
|
|
428
|
+
const shown = modeRef.current;
|
|
429
|
+
if (!shown || rows.length === 0)
|
|
430
|
+
return;
|
|
431
|
+
const moved = [];
|
|
432
|
+
const updated = [];
|
|
433
|
+
const leaving = [];
|
|
434
|
+
let pinned = false;
|
|
435
|
+
// The page asked from whichever list it did; the record's band decides, so only a table still loading is not
|
|
436
|
+
// written. A record that joined the list on screen - asked from another list, answered after a switch - is not in
|
|
437
|
+
// the table to be updated, so the table is read again from the patched lists instead.
|
|
438
|
+
let write = holds(undefined);
|
|
439
|
+
for (const given of rows) {
|
|
440
|
+
const next = { ...(table.getRow(table.keyOf(given)) ?? {}), ...given };
|
|
441
|
+
const key = table.keyOf(next);
|
|
442
|
+
if (!key)
|
|
443
|
+
continue;
|
|
444
|
+
const before = table.getRow(key);
|
|
445
|
+
// The record decides whether it is still in the list on screen; a backend that cannot say keeps it there.
|
|
446
|
+
if (gone || member(next, shown) === false)
|
|
447
|
+
leaving.push(key);
|
|
448
|
+
else if (!before && member(next, shown))
|
|
449
|
+
write = false;
|
|
450
|
+
else if (before && write)
|
|
451
|
+
table.upsert(next, { flash: false });
|
|
452
|
+
if (before && backend.isPinned(next) !== backend.isPinned(before))
|
|
453
|
+
pinned = true;
|
|
454
|
+
updated.push(next);
|
|
455
|
+
moved.push({ key, row: next, before, gone });
|
|
456
|
+
}
|
|
457
|
+
if (write)
|
|
458
|
+
table.remove(leaving);
|
|
459
|
+
if (gone)
|
|
460
|
+
dropPins(leaving);
|
|
461
|
+
else if (pinned)
|
|
462
|
+
applyPins(updated);
|
|
463
|
+
else
|
|
464
|
+
patchPins(updated);
|
|
465
|
+
syncCaches(moved, write);
|
|
283
466
|
}
|
|
284
467
|
/* Row actions */
|
|
285
468
|
async function runRowAction(action, row) {
|
|
@@ -287,6 +470,7 @@ export function Crud(props) {
|
|
|
287
470
|
if (!endpoint)
|
|
288
471
|
return;
|
|
289
472
|
const key = rawKey(row);
|
|
473
|
+
const from = modeRef.current;
|
|
290
474
|
const markOn = action === "flag" ? !backend.isFlagged(row) : action === "pin" ? !backend.isPinned(row) : undefined;
|
|
291
475
|
if (action === "pin" && markOn && pinRoom(pinned?.length) < 1) {
|
|
292
476
|
toast.warning(pinsFull());
|
|
@@ -295,7 +479,7 @@ export function Crud(props) {
|
|
|
295
479
|
const options = { params: { id: key, [rowKey]: key }, subject: labelOf(row), action: crudMessageAction(action, markOn) };
|
|
296
480
|
const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
|
|
297
481
|
if (answer?.ok)
|
|
298
|
-
applyChange(action, [row], answer.result, markOn);
|
|
482
|
+
applyChange(action, [row], { result: answer.result, markOn, from });
|
|
299
483
|
}
|
|
300
484
|
async function runMassAction(action, rows) {
|
|
301
485
|
if (action === "description") {
|
|
@@ -329,10 +513,11 @@ export function Crud(props) {
|
|
|
329
513
|
}
|
|
330
514
|
const messageAction = action === "flag" && markOn === undefined ? TOGGLED_FLAG : crudMessageAction(action, markOn);
|
|
331
515
|
const options = { body: backend.massRequest(action, sent.map(rawKey)), subject, plural: props.subjectPlural, count: sent.length, action: messageAction };
|
|
516
|
+
const from = modeRef.current;
|
|
332
517
|
const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
|
|
333
518
|
if (!answer?.ok)
|
|
334
519
|
return;
|
|
335
|
-
applyChange(action, sent, answer.result, markOn);
|
|
520
|
+
applyChange(action, sent, { result: answer.result, mass: true, markOn, from });
|
|
336
521
|
table.clearSelection();
|
|
337
522
|
if (over > 0)
|
|
338
523
|
toast.warning(`${sent.length} of ${sent.length + over} ${plural} pinned: you can pin up to ${maxPins}.`);
|
|
@@ -384,10 +569,10 @@ export function Crud(props) {
|
|
|
384
569
|
form.dropDraft();
|
|
385
570
|
return discard;
|
|
386
571
|
},
|
|
387
|
-
content: popup => (_jsx(ApiForm, { form: form, inputs: inputs, buttons: false, defaultValue: (action === "create" ? { ...newValues, ...values } : (row ?? undefined)), loadValue: loadValue, api: endpoint, transform: options.transform ? formValues => options.transform(formValues, { action, row }) : undefined, validate: options.validate, onFieldChange: options.onFieldChange, design: options.design, draftKey: options.draftKey ? `${options.draftKey}:${name}:${action === "create" ? "new" : key}` : undefined, "aria-label": action === "create" ? `Add ${subject}` : `Edit ${label}`, onSuccess: (result, saved) => {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
572
|
+
content: popup => (_jsx(CrudFormBody, { aside: typeof options.aside === "function" ? options.aside({ form, action, row }) : options.aside, children: _jsx(ApiForm, { form: form, inputs: inputs, buttons: false, defaultValue: (action === "create" ? { ...newValues, ...values } : (row ?? undefined)), loadValue: loadValue, api: endpoint, transform: options.transform ? formValues => options.transform(formValues, { action, row }) : undefined, validate: options.validate, onFieldChange: options.onFieldChange, design: options.design, draftKey: options.draftKey ? `${options.draftKey}:${name}:${action === "create" ? "new" : key}` : undefined, "aria-label": action === "create" ? `Add ${subject}` : `Edit ${label}`, onSuccess: (result, saved) => {
|
|
573
|
+
onSaved(action, row, result, saved);
|
|
574
|
+
popup.close();
|
|
575
|
+
} }) })),
|
|
391
576
|
footer: popup => _jsx(FormButtons, { form: form, submitLabel: action === "create" ? `Add ${subject}` : "Save", onCancel: () => popup.close() }),
|
|
392
577
|
});
|
|
393
578
|
if (action === "create") {
|
|
@@ -399,23 +584,58 @@ export function Crud(props) {
|
|
|
399
584
|
});
|
|
400
585
|
}
|
|
401
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* The record a `Create` answered only the key of, read back on its own: the row goes in at once with what the form
|
|
589
|
+
* sent, and the fields the server owns - its status, its times, anything it worked out - land a moment later. One
|
|
590
|
+
* small request, where loading the list again was a request for every record on it.
|
|
591
|
+
*/
|
|
592
|
+
async function fillSaved(key, record) {
|
|
593
|
+
const endpoint = api.details;
|
|
594
|
+
if (!endpoint)
|
|
595
|
+
return;
|
|
596
|
+
const id = readPath(record, rowKey);
|
|
597
|
+
const answer = await browserApi.get(endpoint, { params: { id, [rowKey]: id }, toast: false });
|
|
598
|
+
const full = unwrapApiResult(answer);
|
|
599
|
+
if (!isRecord(full))
|
|
600
|
+
return;
|
|
601
|
+
const before = table.getRow(key);
|
|
602
|
+
const next = { ...(before ?? record), ...full };
|
|
603
|
+
const write = holds(undefined);
|
|
604
|
+
if (write && before)
|
|
605
|
+
table.upsert(next, { flash: false });
|
|
606
|
+
syncCaches([{ key, row: next, before }], write);
|
|
607
|
+
}
|
|
402
608
|
function onSaved(action, row, result, values) {
|
|
403
|
-
|
|
609
|
+
// The record as the server now has it: what was sent, over the row it came from, with whatever the answer added.
|
|
610
|
+
// A backend that answers the record it wrote leaves nothing to load at all.
|
|
611
|
+
const answered = backend.answeredRow(result);
|
|
612
|
+
const record = { ...row, ...values, ...answered };
|
|
404
613
|
const shown = modeRef.current;
|
|
405
614
|
if (action === "edit") {
|
|
406
|
-
table.
|
|
615
|
+
const key = table.keyOf(record);
|
|
616
|
+
const before = table.getRow(key);
|
|
617
|
+
const write = holds(undefined);
|
|
618
|
+
if (write)
|
|
619
|
+
table.upsert(record);
|
|
407
620
|
patchPins([record]);
|
|
408
|
-
afterChange("edit", [record]);
|
|
621
|
+
afterChange("edit", [record], key ? [{ key, row: record, before }] : [], write);
|
|
409
622
|
return;
|
|
410
623
|
}
|
|
411
|
-
// A new record belongs to the list of every record
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
624
|
+
// A new record belongs to the list of every record. An answer that is only the new key still gives it one, so
|
|
625
|
+
// the row goes in and the record is read back on its own; without even a key the list has to load again.
|
|
626
|
+
if (!answered && !readPath(record, rowKey)) {
|
|
627
|
+
const given = backend.answeredKey(result);
|
|
628
|
+
if (given !== null && !rowKey.includes("."))
|
|
629
|
+
record[rowKey] = given;
|
|
417
630
|
}
|
|
418
|
-
|
|
631
|
+
const key = table.keyOf(record);
|
|
632
|
+
// Without a key the lists are dropped and the one on screen loads again (`syncCaches`).
|
|
633
|
+
const write = Boolean(key) && holds(undefined);
|
|
634
|
+
if (write && shown === "normal")
|
|
635
|
+
table.upsert(record);
|
|
636
|
+
afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : [], write);
|
|
637
|
+
if (key && !answered && api.details)
|
|
638
|
+
void fillSaved(key, record);
|
|
419
639
|
}
|
|
420
640
|
function openDetails(row) {
|
|
421
641
|
const details = latest.current.details;
|
|
@@ -424,15 +644,44 @@ export function Crud(props) {
|
|
|
424
644
|
const key = table.keyOf(row);
|
|
425
645
|
const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit");
|
|
426
646
|
const endpoint = api.details;
|
|
647
|
+
const status = detailsLoad(endpoint ? "loading" : "done");
|
|
648
|
+
// The panel's record: the row at once, so it has something to show while the full one loads, and that one after.
|
|
649
|
+
const shown = typeof details?.aside === "function" ? new FormStore() : null;
|
|
650
|
+
shown?.load(row, { draft: false });
|
|
651
|
+
const aside = typeof details?.aside === "function" ? details.aside({ form: shown, row }) : details?.aside;
|
|
427
652
|
openPopup({
|
|
428
653
|
key: `${name}-view-${key}`,
|
|
429
654
|
reopen: "restore",
|
|
430
|
-
title: `${capitalize(subject)} details`,
|
|
655
|
+
title: `${capitalize(subject)} ${details?.label ?? "details"}`,
|
|
431
656
|
description: labelOf(row),
|
|
432
657
|
icon: _jsx(InlineIcon, { icon: iconEye }),
|
|
433
658
|
size: details?.size ?? "md",
|
|
434
|
-
|
|
435
|
-
|
|
659
|
+
// Focus goes where it goes without a panel, the footer, rather than to the first button the panel happens to
|
|
660
|
+
// draw: a panel that renders at once (a Copy button) would otherwise take it, and one that loads would not.
|
|
661
|
+
initialFocus: aside ? ".nx-popup__foot button" : undefined,
|
|
662
|
+
content: (_jsx(CrudFormBody, { aside: aside, details: true, children: _jsx(CrudDetails, { row: row, columns: details?.columns ?? latest.current.columns, render: details?.render, load: endpoint
|
|
663
|
+
? signal => {
|
|
664
|
+
const loading = browserApi
|
|
665
|
+
.get(endpoint, { params: { id: rawKey(row), [rowKey]: rawKey(row) }, signal, toast: false })
|
|
666
|
+
.then(unwrapApiResult)
|
|
667
|
+
.then(full => {
|
|
668
|
+
// Only when it adds something: a panel that renders from the record would otherwise ask again
|
|
669
|
+
// for exactly what it is already showing.
|
|
670
|
+
if (shown && isRecord(full) && addsToRow(row, full))
|
|
671
|
+
shown.load({ ...row, ...full }, { draft: false });
|
|
672
|
+
return full;
|
|
673
|
+
});
|
|
674
|
+
// The footer follows the very promise the details do, with the same test for a popup closed meanwhile.
|
|
675
|
+
void loading.then(() => status.set("done"), error => {
|
|
676
|
+
if (!signal.aborted && !isAbortError(error))
|
|
677
|
+
status.set("failed");
|
|
678
|
+
});
|
|
679
|
+
return loading;
|
|
680
|
+
}
|
|
681
|
+
: undefined }) })),
|
|
682
|
+
footer: popup => (_jsxs(DetailsFooter, { load: status, children: [details?.actions
|
|
683
|
+
?.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: () => {
|
|
436
685
|
popup.close();
|
|
437
686
|
void openForm("edit", table.getRow(key) ?? row);
|
|
438
687
|
}, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: () => popup.close(), children: "Close" })] })),
|
|
@@ -453,7 +702,7 @@ export function Crud(props) {
|
|
|
453
702
|
closeOnBackdrop: false,
|
|
454
703
|
beforeClose: () => confirmDiscard(form),
|
|
455
704
|
content: popup => (_jsx(ApiForm, { form: form, buttons: false, inputs: [{ type: "textarea", name: "description", label: "Description", maxLength: 500, rows: 4, showCount: true }], api: endpoint, transform: values => backend.massRequest("description", rows.map(rawKey), values), "aria-label": `Description of ${count} ${count === 1 ? subject : plural}`, onSuccess: (result, values) => {
|
|
456
|
-
applyChange("description", rows, result,
|
|
705
|
+
applyChange("description", rows, { result, mass: true, values });
|
|
457
706
|
table.clearSelection();
|
|
458
707
|
popup.close();
|
|
459
708
|
} })),
|
|
@@ -461,8 +710,8 @@ export function Crud(props) {
|
|
|
461
710
|
});
|
|
462
711
|
}
|
|
463
712
|
// Row actions and the handle call these through a ref, so the table's callbacks stay the same between renders.
|
|
464
|
-
const run = useRef({ runRowAction, runMassAction, openForm, openDetails });
|
|
465
|
-
run.current = { runRowAction, runMassAction, openForm, openDetails };
|
|
713
|
+
const run = useRef({ runRowAction, runMassAction, openForm, openDetails, applyRows });
|
|
714
|
+
run.current = { runRowAction, runMassAction, openForm, openDetails, applyRows };
|
|
466
715
|
/* The table's callbacks */
|
|
467
716
|
const rowActions = useCallback((row) => {
|
|
468
717
|
if (!mode)
|
|
@@ -472,16 +721,14 @@ export function Crud(props) {
|
|
|
472
721
|
const flagged = backend.isFlagged(row);
|
|
473
722
|
const isPinned = backend.isPinned(row);
|
|
474
723
|
const info = {
|
|
475
|
-
view: { label:
|
|
724
|
+
view: { label: `View ${(latest.current.details || undefined)?.label ?? "details"}`, icon: _jsx(InlineIcon, { icon: iconEye }) },
|
|
476
725
|
edit: { label: "Edit", icon: _jsx(InlineIcon, { icon: iconPencil }), inline: true },
|
|
477
726
|
flag: flagged ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
478
727
|
pin: isPinned ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
|
|
479
|
-
archive: { label: "Archive", icon: _jsx(InlineIcon, { icon: iconArchive }) },
|
|
480
|
-
restore: { label: "Restore", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconArchiveOff }), inline: true },
|
|
481
728
|
trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
|
|
482
|
-
recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon:
|
|
729
|
+
recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
|
|
483
730
|
delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
|
|
484
|
-
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon:
|
|
731
|
+
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
|
|
485
732
|
};
|
|
486
733
|
for (const action of crudRowActions(mode, candidate => allowed[candidate], trashless)) {
|
|
487
734
|
if (action === "view" && latest.current.details === false)
|
|
@@ -504,12 +751,10 @@ export function Crud(props) {
|
|
|
504
751
|
const info = {
|
|
505
752
|
flag: flagged === rows.length ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: flagged ? "Flag / unflag" : "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
506
753
|
pin: unpinned === 0 ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
|
|
507
|
-
archive: { label: "Archive", icon: _jsx(InlineIcon, { icon: iconArchive }) },
|
|
508
|
-
restore: { label: "Restore", icon: _jsx(InlineIcon, { icon: iconArchiveOff }) },
|
|
509
754
|
trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
|
|
510
|
-
recover: { label: "Recover", icon: _jsx(InlineIcon, { icon:
|
|
755
|
+
recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
|
|
511
756
|
delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
|
|
512
|
-
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon:
|
|
757
|
+
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
|
|
513
758
|
description: { label: "Description", icon: _jsx(InlineIcon, { icon: iconNotes }) },
|
|
514
759
|
};
|
|
515
760
|
list.push({ ...info[action], id: action, onSelect });
|
|
@@ -534,6 +779,7 @@ export function Crud(props) {
|
|
|
534
779
|
reloadPins();
|
|
535
780
|
return table.reload({ quiet: true });
|
|
536
781
|
},
|
|
782
|
+
apply: (rows, options) => run.current.applyRows(rows, options?.gone ?? false),
|
|
537
783
|
create: values => void run.current.openForm("create", null, values),
|
|
538
784
|
edit: row => void run.current.openForm("edit", row),
|
|
539
785
|
view: row => run.current.openDetails(row),
|
|
@@ -547,10 +793,35 @@ export function Crud(props) {
|
|
|
547
793
|
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." })] }));
|
|
548
794
|
}
|
|
549
795
|
const modeOptions = modes.map(value => ({ value, label: MODE_INFO[value].label, icon: _jsx(InlineIcon, { icon: MODE_INFO[value].icon }) }));
|
|
550
|
-
const
|
|
796
|
+
const add = allowed.create ? { label: `Add ${subject}`, onClick: () => void run.current.openForm("create", null) } : undefined;
|
|
797
|
+
// In the main list, select again by pin and by flag, the way the mass actions act on them.
|
|
798
|
+
const selectBy = mode === "normal"
|
|
799
|
+
? [
|
|
800
|
+
...(massAllowed.pin
|
|
801
|
+
? [
|
|
802
|
+
{ label: "Pinned", icon: _jsx(InlineIcon, { icon: iconPin }), test: (row) => backend.isPinned(row) },
|
|
803
|
+
{ label: "Not pinned", icon: _jsx(InlineIcon, { icon: iconPinnedOff }), test: (row) => !backend.isPinned(row) },
|
|
804
|
+
]
|
|
805
|
+
: []),
|
|
806
|
+
...(massAllowed.flag
|
|
807
|
+
? [
|
|
808
|
+
{ label: "Flagged", icon: _jsx(InlineIcon, { icon: iconFlag }), test: (row) => backend.isFlagged(row) },
|
|
809
|
+
{ label: "Not flagged", icon: _jsx(InlineIcon, { icon: iconFlagOff }), test: (row) => !backend.isFlagged(row) },
|
|
810
|
+
]
|
|
811
|
+
: []),
|
|
812
|
+
]
|
|
813
|
+
: [];
|
|
551
814
|
const selectable = props.massActions !== false && Boolean(mode) && (crudMassActions(mode, action => massAllowed[action], massTrashless).length > 0 || Boolean(props.massActions));
|
|
552
|
-
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: "
|
|
815
|
+
return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, columns: columns, load: load, loadKey: mode ?? "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "auto", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: props.toolbar, add: add, selectable: selectable, massActions: massActionsFor, selectBy: selectBy, rowActions: rowActions, pinnedRows: mode === "normal" && pinsOn ? (pinned ?? undefined) : undefined, pinnedLoading: pinsLoading, pinnedLabel: `Pinned ${plural}`, pinnedMax: maxPins, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, onReload: () => {
|
|
553
816
|
cache.clear();
|
|
554
817
|
reloadPins();
|
|
555
|
-
}, emptyMessage: tableOptions?.emptyMessage ?? (mode ? MODE_INFO[mode].empty(plural) : undefined), emptyAction: mode === "normal" ?
|
|
818
|
+
}, 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
|
+
...tableOptions.excel,
|
|
820
|
+
// A spreadsheet wrote records straight into the database: every list this CRUD kept is out of date.
|
|
821
|
+
onImported: () => {
|
|
822
|
+
cache.clear();
|
|
823
|
+
reloadPins();
|
|
824
|
+
tableOptions.excel?.onImported?.();
|
|
825
|
+
},
|
|
826
|
+
} }) }));
|
|
556
827
|
}
|