nexus-shared 3.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 -262
- package/LICENSE +21 -21
- package/README.md +1084 -1000
- package/dist/Client.Index.d.ts +3 -0
- package/dist/Client.Index.js +3 -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 +177 -58
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +37 -5
- package/dist/Components/Forms/ExcelImport.d.ts +23 -7
- package/dist/Components/Forms/ExcelImport.js +189 -60
- package/dist/Components/Forms/Form.js +5 -13
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/NumberBox.js +24 -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/TimePanel.js +4 -3
- 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 +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 +192 -44
- 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 +2 -2
- package/dist/Components/Viewers/DataTableParts.js +3 -3
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/CrudBackend.d.ts +7 -4
- package/dist/Helpers/CrudBackend.js +4 -2
- 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/MessageBuilder.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/RowsStore.d.ts +7 -2
- package/dist/Helpers/RowsStore.js +15 -3
- 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 +6 -3
- package/dist/Helpers/TableHelpers.js +8 -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/CrudInterfaces.d.ts +20 -1
- package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
- package/dist/Interfaces/FormInterfaces.d.ts +33 -5
- package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
- package/dist/Interfaces/PreferenceInterfaces.js +28 -0
- package/dist/Interfaces/TableInterfaces.d.ts +59 -12
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- package/dist/Services/ExcelApi.d.ts +3 -1
- package/dist/Services/ExcelApi.js +1 -1
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +2 -0
- package/dist/Shared.Index.js +2 -0
- package/package.json +85 -85
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +117 -70
- package/src/Styles/Nexus.Button.css +180 -153
- package/src/Styles/Nexus.Calendar.css +273 -275
- package/src/Styles/Nexus.Chat.css +1443 -1489
- package/src/Styles/Nexus.Choice.css +343 -345
- package/src/Styles/Nexus.Crud.css +168 -161
- 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 -370
- package/src/Styles/Nexus.Form.css +279 -321
- package/src/Styles/Nexus.Index.css +37 -36
- package/src/Styles/Nexus.Input.css +602 -637
- package/src/Styles/Nexus.Markdown.css +237 -265
- package/src/Styles/Nexus.Menu.css +237 -259
- package/src/Styles/Nexus.Popup.css +724 -731
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +637 -596
- package/src/Styles/Nexus.Slider.css +276 -278
- package/src/Styles/Nexus.Tab.Buttons.css +432 -419
- package/src/Styles/Nexus.Table.css +1386 -1296
- package/src/Styles/Nexus.Theme.Picker.css +286 -232
- 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
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, 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
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";
|
|
@@ -29,13 +30,14 @@ import { confirmDiscard, FormButtons } from "./SubmitForm.js";
|
|
|
29
30
|
*/
|
|
30
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
36
|
trash: { label: "Trash", icon: iconTrash, empty: () => "The trash is empty." },
|
|
35
37
|
deleted: { label: "Deleted", icon: iconTrashX, empty: plural => `No deleted ${plural}.` },
|
|
36
38
|
};
|
|
37
|
-
/** Actions that
|
|
38
|
-
const ASKED = new Set(["trash", "delete", "trashFromDelete"]);
|
|
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"]);
|
|
39
41
|
/** Actions that take a record out of the active lists, so it is no longer among the pinned records either. */
|
|
40
42
|
const LEFT_ACTIVE = new Set(["trash", "delete", "trashFromDelete"]);
|
|
41
43
|
/** The words of an action that flags some records and unflags others (a mass toggle of a mixed selection). */
|
|
@@ -65,6 +67,65 @@ function CrudFormBody({ aside, details, children }) {
|
|
|
65
67
|
function addsToRow(row, full) {
|
|
66
68
|
return Object.entries(full).some(([name, value]) => JSON.stringify(value) !== JSON.stringify(row[name]));
|
|
67
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
|
+
}
|
|
68
129
|
/**
|
|
69
130
|
* A CRUD page in one component: a data table of a controller's records with its lists, Add and Edit popups, details,
|
|
70
131
|
* and actions on one record or many, each shown only when its endpoint exists and the user may call it.
|
|
@@ -201,13 +262,14 @@ export function Crud(props) {
|
|
|
201
262
|
return new Promise(() => undefined);
|
|
202
263
|
const endpoint = crudListEndpoint(api, mode, paging);
|
|
203
264
|
const sortColumn = query.sort ? latest.current.columns.find(column => column.key === query.sort.key) : undefined;
|
|
204
|
-
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;
|
|
205
267
|
const kept = cache.get(key);
|
|
206
268
|
if (kept)
|
|
207
269
|
return kept;
|
|
208
270
|
const options = { signal, toast: false, subject: plural };
|
|
209
271
|
if (paging === "server")
|
|
210
|
-
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));
|
|
211
273
|
return browserApi.send(endpoint, options).then(answer => {
|
|
212
274
|
if (answer.aborted)
|
|
213
275
|
throw abortError();
|
|
@@ -229,27 +291,43 @@ export function Crud(props) {
|
|
|
229
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
|
|
230
292
|
* server's, so that page is dropped and the next one loads. One whose rows only changed is written in place.
|
|
231
293
|
*/
|
|
232
|
-
function syncCaches(changes) {
|
|
294
|
+
function syncCaches(changes, written) {
|
|
233
295
|
const shown = modeRef.current;
|
|
234
296
|
if (changes.length)
|
|
235
297
|
cache.map((key, value) => patchCachedList(value, crudCacheMode(key), changes, row => table.keyOf(row), member));
|
|
236
298
|
else
|
|
237
299
|
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
|
-
|
|
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)
|
|
240
307
|
cache.update(shown, table.getRows());
|
|
241
308
|
}
|
|
242
|
-
|
|
243
|
-
|
|
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);
|
|
244
320
|
const shown = modeRef.current;
|
|
245
321
|
if (shown)
|
|
246
322
|
latest.current.onChange?.({ action, rows, mode: shown });
|
|
247
323
|
}
|
|
248
324
|
function applyChange(action, rows, options = {}) {
|
|
249
|
-
const { result, mass = false, markOn, values } = options;
|
|
325
|
+
const { result, mass = false, markOn, values, from } = options;
|
|
250
326
|
const shown = modeRef.current;
|
|
251
327
|
if (!shown)
|
|
252
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);
|
|
253
331
|
const answered = rows.length > 1 ? backend.answeredKeys(result, rowKey) : null;
|
|
254
332
|
const done = answered ? rows.filter(row => answered.has(table.keyOf(row))) : rows;
|
|
255
333
|
const changes = backend.answeredChanges(result, rowKey);
|
|
@@ -270,16 +348,18 @@ export function Crud(props) {
|
|
|
270
348
|
next = { ...next, ...change };
|
|
271
349
|
if (one)
|
|
272
350
|
next = { ...next, ...one };
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
351
|
+
if (write) {
|
|
352
|
+
if (leavesList(action, shown, backend.isFlagged(next)))
|
|
353
|
+
leaving.push(key);
|
|
354
|
+
else
|
|
355
|
+
table.upsert(next, { flash: false });
|
|
356
|
+
}
|
|
277
357
|
updated.push(next);
|
|
278
358
|
moved.push({ key, row: next, before: current });
|
|
279
359
|
}
|
|
280
360
|
table.remove(leaving);
|
|
281
361
|
patchPins(updated);
|
|
282
|
-
afterChange(action, updated, moved);
|
|
362
|
+
afterChange(action, updated, moved, write);
|
|
283
363
|
return;
|
|
284
364
|
}
|
|
285
365
|
if (action === "pin") {
|
|
@@ -295,13 +375,13 @@ export function Crud(props) {
|
|
|
295
375
|
if (one)
|
|
296
376
|
next = { ...next, ...one };
|
|
297
377
|
// 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))
|
|
378
|
+
if (write && table.getRow(key))
|
|
299
379
|
table.upsert(next, { flash: false });
|
|
300
380
|
updated.push(next);
|
|
301
381
|
moved.push({ key, row: next, before: current });
|
|
302
382
|
}
|
|
303
383
|
applyPins(updated);
|
|
304
|
-
afterChange(action, updated, moved);
|
|
384
|
+
afterChange(action, updated, moved, write);
|
|
305
385
|
return;
|
|
306
386
|
}
|
|
307
387
|
if (action === "description") {
|
|
@@ -310,12 +390,13 @@ export function Crud(props) {
|
|
|
310
390
|
const key = table.keyOf(row);
|
|
311
391
|
const current = table.getRow(key) ?? row;
|
|
312
392
|
const next = { ...current, ...values };
|
|
313
|
-
|
|
393
|
+
if (write)
|
|
394
|
+
table.upsert(next);
|
|
314
395
|
updated.push(next);
|
|
315
396
|
moved.push({ key, row: next, before: current });
|
|
316
397
|
}
|
|
317
398
|
patchPins(updated);
|
|
318
|
-
afterChange(action, updated, moved);
|
|
399
|
+
afterChange(action, updated, moved, write);
|
|
319
400
|
return;
|
|
320
401
|
}
|
|
321
402
|
// Trash, recover, delete and the move back to the trash take the record to another band, and only the answer
|
|
@@ -334,11 +415,11 @@ export function Crud(props) {
|
|
|
334
415
|
}
|
|
335
416
|
if (moved.length !== done.length)
|
|
336
417
|
moved.length = 0;
|
|
337
|
-
if (leavesList(action, shown))
|
|
418
|
+
if (write && leavesList(action, shown))
|
|
338
419
|
table.remove(keys);
|
|
339
420
|
if (LEFT_ACTIVE.has(action))
|
|
340
421
|
dropPins(keys);
|
|
341
|
-
afterChange(action, updated, moved);
|
|
422
|
+
afterChange(action, updated, moved, write);
|
|
342
423
|
}
|
|
343
424
|
/**
|
|
344
425
|
* Records a page's own action changed, written exactly where the built-in ones write theirs. The handle's `apply`.
|
|
@@ -351,6 +432,10 @@ export function Crud(props) {
|
|
|
351
432
|
const updated = [];
|
|
352
433
|
const leaving = [];
|
|
353
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);
|
|
354
439
|
for (const given of rows) {
|
|
355
440
|
const next = { ...(table.getRow(table.keyOf(given)) ?? {}), ...given };
|
|
356
441
|
const key = table.keyOf(next);
|
|
@@ -360,21 +445,24 @@ export function Crud(props) {
|
|
|
360
445
|
// The record decides whether it is still in the list on screen; a backend that cannot say keeps it there.
|
|
361
446
|
if (gone || member(next, shown) === false)
|
|
362
447
|
leaving.push(key);
|
|
363
|
-
else if (before)
|
|
448
|
+
else if (!before && member(next, shown))
|
|
449
|
+
write = false;
|
|
450
|
+
else if (before && write)
|
|
364
451
|
table.upsert(next, { flash: false });
|
|
365
452
|
if (before && backend.isPinned(next) !== backend.isPinned(before))
|
|
366
453
|
pinned = true;
|
|
367
454
|
updated.push(next);
|
|
368
455
|
moved.push({ key, row: next, before, gone });
|
|
369
456
|
}
|
|
370
|
-
|
|
457
|
+
if (write)
|
|
458
|
+
table.remove(leaving);
|
|
371
459
|
if (gone)
|
|
372
460
|
dropPins(leaving);
|
|
373
461
|
else if (pinned)
|
|
374
462
|
applyPins(updated);
|
|
375
463
|
else
|
|
376
464
|
patchPins(updated);
|
|
377
|
-
syncCaches(moved);
|
|
465
|
+
syncCaches(moved, write);
|
|
378
466
|
}
|
|
379
467
|
/* Row actions */
|
|
380
468
|
async function runRowAction(action, row) {
|
|
@@ -382,6 +470,7 @@ export function Crud(props) {
|
|
|
382
470
|
if (!endpoint)
|
|
383
471
|
return;
|
|
384
472
|
const key = rawKey(row);
|
|
473
|
+
const from = modeRef.current;
|
|
385
474
|
const markOn = action === "flag" ? !backend.isFlagged(row) : action === "pin" ? !backend.isPinned(row) : undefined;
|
|
386
475
|
if (action === "pin" && markOn && pinRoom(pinned?.length) < 1) {
|
|
387
476
|
toast.warning(pinsFull());
|
|
@@ -390,7 +479,7 @@ export function Crud(props) {
|
|
|
390
479
|
const options = { params: { id: key, [rowKey]: key }, subject: labelOf(row), action: crudMessageAction(action, markOn) };
|
|
391
480
|
const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
|
|
392
481
|
if (answer?.ok)
|
|
393
|
-
applyChange(action, [row], { result: answer.result, markOn });
|
|
482
|
+
applyChange(action, [row], { result: answer.result, markOn, from });
|
|
394
483
|
}
|
|
395
484
|
async function runMassAction(action, rows) {
|
|
396
485
|
if (action === "description") {
|
|
@@ -424,10 +513,11 @@ export function Crud(props) {
|
|
|
424
513
|
}
|
|
425
514
|
const messageAction = action === "flag" && markOn === undefined ? TOGGLED_FLAG : crudMessageAction(action, markOn);
|
|
426
515
|
const options = { body: backend.massRequest(action, sent.map(rawKey)), subject, plural: props.subjectPlural, count: sent.length, action: messageAction };
|
|
516
|
+
const from = modeRef.current;
|
|
427
517
|
const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
|
|
428
518
|
if (!answer?.ok)
|
|
429
519
|
return;
|
|
430
|
-
applyChange(action, sent, { result: answer.result, mass: true, markOn });
|
|
520
|
+
applyChange(action, sent, { result: answer.result, mass: true, markOn, from });
|
|
431
521
|
table.clearSelection();
|
|
432
522
|
if (over > 0)
|
|
433
523
|
toast.warning(`${sent.length} of ${sent.length + over} ${plural} pinned: you can pin up to ${maxPins}.`);
|
|
@@ -510,9 +600,10 @@ export function Crud(props) {
|
|
|
510
600
|
return;
|
|
511
601
|
const before = table.getRow(key);
|
|
512
602
|
const next = { ...(before ?? record), ...full };
|
|
513
|
-
|
|
603
|
+
const write = holds(undefined);
|
|
604
|
+
if (write && before)
|
|
514
605
|
table.upsert(next, { flash: false });
|
|
515
|
-
syncCaches([{ key, row: next, before }]);
|
|
606
|
+
syncCaches([{ key, row: next, before }], write);
|
|
516
607
|
}
|
|
517
608
|
function onSaved(action, row, result, values) {
|
|
518
609
|
// The record as the server now has it: what was sent, over the row it came from, with whatever the answer added.
|
|
@@ -523,9 +614,11 @@ export function Crud(props) {
|
|
|
523
614
|
if (action === "edit") {
|
|
524
615
|
const key = table.keyOf(record);
|
|
525
616
|
const before = table.getRow(key);
|
|
526
|
-
|
|
617
|
+
const write = holds(undefined);
|
|
618
|
+
if (write)
|
|
619
|
+
table.upsert(record);
|
|
527
620
|
patchPins([record]);
|
|
528
|
-
afterChange("edit", [record], key ? [{ key, row: record, before }] : []);
|
|
621
|
+
afterChange("edit", [record], key ? [{ key, row: record, before }] : [], write);
|
|
529
622
|
return;
|
|
530
623
|
}
|
|
531
624
|
// A new record belongs to the list of every record. An answer that is only the new key still gives it one, so
|
|
@@ -536,13 +629,11 @@ export function Crud(props) {
|
|
|
536
629
|
record[rowKey] = given;
|
|
537
630
|
}
|
|
538
631
|
const key = table.keyOf(record);
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
}
|
|
545
|
-
afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : []);
|
|
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);
|
|
546
637
|
if (key && !answered && api.details)
|
|
547
638
|
void fillSaved(key, record);
|
|
548
639
|
}
|
|
@@ -553,6 +644,7 @@ export function Crud(props) {
|
|
|
553
644
|
const key = table.keyOf(row);
|
|
554
645
|
const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit");
|
|
555
646
|
const endpoint = api.details;
|
|
647
|
+
const status = detailsLoad(endpoint ? "loading" : "done");
|
|
556
648
|
// The panel's record: the row at once, so it has something to show while the full one loads, and that one after.
|
|
557
649
|
const shown = typeof details?.aside === "function" ? new FormStore() : null;
|
|
558
650
|
shown?.load(row, { draft: false });
|
|
@@ -568,18 +660,28 @@ export function Crud(props) {
|
|
|
568
660
|
// draw: a panel that renders at once (a Copy button) would otherwise take it, and one that loads would not.
|
|
569
661
|
initialFocus: aside ? ".nx-popup__foot button" : undefined,
|
|
570
662
|
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
|
-
|
|
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
|
+
}
|
|
581
681
|
: undefined }) })),
|
|
582
|
-
footer: popup => (_jsxs(
|
|
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: () => {
|
|
583
685
|
popup.close();
|
|
584
686
|
void openForm("edit", table.getRow(key) ?? row);
|
|
585
687
|
}, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: () => popup.close(), children: "Close" })] })),
|
|
@@ -624,9 +726,9 @@ export function Crud(props) {
|
|
|
624
726
|
flag: flagged ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
625
727
|
pin: isPinned ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
|
|
626
728
|
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:
|
|
729
|
+
recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
|
|
628
730
|
delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
|
|
629
|
-
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon:
|
|
731
|
+
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), inline: true },
|
|
630
732
|
};
|
|
631
733
|
for (const action of crudRowActions(mode, candidate => allowed[candidate], trashless)) {
|
|
632
734
|
if (action === "view" && latest.current.details === false)
|
|
@@ -650,9 +752,9 @@ export function Crud(props) {
|
|
|
650
752
|
flag: flagged === rows.length ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: flagged ? "Flag / unflag" : "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
651
753
|
pin: unpinned === 0 ? { label: "Unpin", icon: _jsx(InlineIcon, { icon: iconPinnedOff }) } : { label: "Pin", icon: _jsx(InlineIcon, { icon: iconPin }) },
|
|
652
754
|
trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
|
|
653
|
-
recover: { label: "Recover", icon: _jsx(InlineIcon, { icon:
|
|
755
|
+
recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
|
|
654
756
|
delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
|
|
655
|
-
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon:
|
|
757
|
+
trashFromDelete: { label: "Move back to trash", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }) },
|
|
656
758
|
description: { label: "Description", icon: _jsx(InlineIcon, { icon: iconNotes }) },
|
|
657
759
|
};
|
|
658
760
|
list.push({ ...info[action], id: action, onSelect });
|
|
@@ -691,12 +793,29 @@ export function Crud(props) {
|
|
|
691
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." })] }));
|
|
692
794
|
}
|
|
693
795
|
const modeOptions = modes.map(value => ({ value, label: MODE_INFO[value].label, icon: _jsx(InlineIcon, { icon: MODE_INFO[value].icon }) }));
|
|
694
|
-
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
|
+
: [];
|
|
695
814
|
const selectable = props.massActions !== false && Boolean(mode) && (crudMassActions(mode, action => massAllowed[action], massTrashless).length > 0 || Boolean(props.massActions));
|
|
696
|
-
return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, columns: columns, load: load, loadKey: mode ?? "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "
|
|
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: () => {
|
|
697
816
|
cache.clear();
|
|
698
817
|
reloadPins();
|
|
699
|
-
}, 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 && {
|
|
700
819
|
...tableOptions.excel,
|
|
701
820
|
// A spreadsheet wrote records straight into the database: every list this CRUD kept is out of date.
|
|
702
821
|
onImported: () => {
|
|
@@ -6,7 +6,11 @@ export interface CrudDetailsProps<TRow extends FormValues = FormValues> {
|
|
|
6
6
|
row: TRow;
|
|
7
7
|
/** The fields, as the table's columns describe them; hidden columns included. */
|
|
8
8
|
columns: readonly TableColumn<TRow>[];
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Loads the full record (fields the list leaves out); its values replace the row's when they come. Meanwhile a field
|
|
11
|
+
* the row lacks keeps its line blank, and reads "Not loaded" if the load fails; the details say nothing else about
|
|
12
|
+
* the load (`aria-busy` while it runs), so the popup around them never changes size for it.
|
|
13
|
+
*/
|
|
10
14
|
load?: (signal: AbortSignal) => Promise<Partial<TRow> | null | undefined>;
|
|
11
15
|
/** Draws the details instead of the list of fields. */
|
|
12
16
|
render?: (row: TRow) => ReactNode;
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { motionAllowed } from "../../Helpers/AnimationHelpers.js";
|
|
5
5
|
import { isAbortError } from "../../Helpers/ApiResponses.js";
|
|
6
6
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
7
|
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
8
|
+
import { readPath } from "../../Helpers/TableHelpers.js";
|
|
9
|
+
import { useNumberLocale } from "../../Helpers/UserPreferences.js";
|
|
8
10
|
import { TableCellValue } from "../Viewers/DataTableParts.js";
|
|
11
|
+
/**
|
|
12
|
+
* An element's height as its `height` would set it: unrounded, unlike `offsetHeight`, so an unroll between two of
|
|
13
|
+
* them starts and ends exactly where the details are and the popup never steps by a fraction of a pixel. NaN while
|
|
14
|
+
* not drawn.
|
|
15
|
+
*/
|
|
16
|
+
const usedHeight = (element) => parseFloat(getComputedStyle(element).height);
|
|
9
17
|
/** A record's fields as label and value, two to a line where there is room, in the formats of the table's columns. */
|
|
10
18
|
export function CrudDetails({ row, columns, load, render }) {
|
|
11
19
|
const [record, setRecord] = useState(row);
|
|
12
20
|
const [state, setState] = useState(load ? "loading" : "done");
|
|
21
|
+
const rootRef = useRef(null);
|
|
22
|
+
// The details' height just before the full record went in, kept only when the change may be animated.
|
|
23
|
+
const grownFrom = useRef(null);
|
|
13
24
|
const preferences = useDatePreferences();
|
|
14
25
|
const timeZone = useBrowserTimeZone();
|
|
15
|
-
const
|
|
26
|
+
const locale = useNumberLocale();
|
|
27
|
+
const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined, locale }), [preferences, timeZone, locale]);
|
|
16
28
|
useEffect(() => {
|
|
17
29
|
if (!load)
|
|
18
30
|
return;
|
|
@@ -20,8 +32,11 @@ export function CrudDetails({ row, columns, load, render }) {
|
|
|
20
32
|
load(controller.signal).then(full => {
|
|
21
33
|
if (controller.signal.aborted)
|
|
22
34
|
return;
|
|
23
|
-
if (full)
|
|
35
|
+
if (full) {
|
|
36
|
+
if (motionAllowed() && rootRef.current)
|
|
37
|
+
grownFrom.current = usedHeight(rootRef.current);
|
|
24
38
|
setRecord(current => ({ ...current, ...full }));
|
|
39
|
+
}
|
|
25
40
|
setState("done");
|
|
26
41
|
}, error => {
|
|
27
42
|
if (controller.signal.aborted || isAbortError(error))
|
|
@@ -32,5 +47,22 @@ export function CrudDetails({ row, columns, load, render }) {
|
|
|
32
47
|
// The record loads once per popup.
|
|
33
48
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
49
|
}, []);
|
|
35
|
-
|
|
50
|
+
// A record that makes the details taller (a long note the list did not have) unrolls to its height when motion is
|
|
51
|
+
// allowed, as maximize does; without motion it changes at once. Nothing waits on it.
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
const from = grownFrom.current;
|
|
54
|
+
grownFrom.current = null;
|
|
55
|
+
const root = rootRef.current;
|
|
56
|
+
if (!from || !root || typeof root.animate !== "function")
|
|
57
|
+
return;
|
|
58
|
+
const to = usedHeight(root);
|
|
59
|
+
if (!to || Math.abs(to - from) < 1)
|
|
60
|
+
return;
|
|
61
|
+
const animation = root.animate([{ height: `${from}px`, overflow: "clip" }, { height: `${to}px`, overflow: "clip" }], { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)" });
|
|
62
|
+
return () => animation.cancel();
|
|
63
|
+
}, [record]);
|
|
64
|
+
// A field the list's row does not carry can only be filled by the full record. Columns that draw or work out their
|
|
65
|
+
// own value (`render`, `value`) draw as always, because only they know what they read.
|
|
66
|
+
const awaited = (column) => state !== "done" && !column.render && !column.value && readPath(record, column.key) === undefined;
|
|
67
|
+
return (_jsx("div", { ref: rootRef, className: "nx-crud-details", "aria-busy": state === "loading" || undefined, children: render ? (render(record)) : (_jsx("dl", { className: "nx-crud-details__list", children: columns.map(column => (_jsxs("div", { className: cx("nx-crud-details__item", column.wrap && "nx-crud-details__item--wide"), children: [_jsx("dt", { className: "nx-crud-details__label", children: column.label }), _jsx("dd", { className: "nx-crud-details__value", children: !awaited(column) ? _jsx(TableCellValue, { column: column, row: record, options: options }) : state === "failed" ? _jsx("span", { className: "nx-crud-details__missing", children: "Not loaded" }) : null })] }, column.key))) })) }));
|
|
36
68
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { type ReactNode, type Ref } from "react";
|
|
2
2
|
import type { ExcelEndpoints, ExcelImportAnswer } from "../../Interfaces/ExcelInterfaces.ts";
|
|
3
3
|
import { type ExcelCallOptions } from "../../Services/ExcelApi.ts";
|
|
4
4
|
import { type PopupHandle } from "../Layouts/PopupHost.tsx";
|
|
@@ -18,24 +18,40 @@ export interface ExcelImportProps {
|
|
|
18
18
|
templateDownload?: false;
|
|
19
19
|
/** The tallest each editor grows before its rows scroll. Default 320. */
|
|
20
20
|
maxHeight?: number;
|
|
21
|
+
/**
|
|
22
|
+
* `"file"` (the default): a filled template is sent to be checked, and its rows come back to be gone over.
|
|
23
|
+
* `"bulk"`: no file - the rows are typed here, from one empty row, and imported with the same checks. A sheet with a
|
|
24
|
+
* required list cannot be typed in (its key is picked in the spreadsheet), and the panel says so.
|
|
25
|
+
*/
|
|
26
|
+
mode?: "file" | "bulk";
|
|
27
|
+
/** What closing the panel would lose, so a window can ask first: `openExcelImport` passes one. */
|
|
28
|
+
ref?: Ref<ExcelImportHandle>;
|
|
21
29
|
className?: string;
|
|
22
30
|
}
|
|
31
|
+
/** What `ref` gives on an import panel. */
|
|
32
|
+
export interface ExcelImportHandle {
|
|
33
|
+
/** The rows only this screen has - typed here, or a sheet's row edited here - which closing the panel loses. */
|
|
34
|
+
unsavedRows: () => number;
|
|
35
|
+
}
|
|
23
36
|
/**
|
|
24
|
-
* The three steps of an import in one panel: the template, the file, and the review
|
|
37
|
+
* The three steps of an import in one panel: the template, the file, and the review - or, for a bulk insert, the rows
|
|
38
|
+
* typed in and the import.
|
|
25
39
|
*
|
|
26
40
|
* @example
|
|
27
41
|
* <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
|
|
42
|
+
* <ExcelImport endpoints={REGION_SHEET} subject="region" mode="bulk" onImported={() => table.reload()} />
|
|
28
43
|
*/
|
|
29
|
-
export declare function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight, className }: ExcelImportProps): import("react").JSX.Element;
|
|
44
|
+
export declare function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight, mode, ref, className }: ExcelImportProps): import("react").JSX.Element;
|
|
30
45
|
/** Options of the import popup: the panel's, plus what the window looks like. */
|
|
31
|
-
export interface OpenExcelImportOptions extends Omit<ExcelImportProps, "onClose"> {
|
|
32
|
-
/** The window's title. Default: "Import <subjects> from a spreadsheet". */
|
|
46
|
+
export interface OpenExcelImportOptions extends Omit<ExcelImportProps, "onClose" | "ref"> {
|
|
47
|
+
/** The window's title. Default: "Import <subjects> from a spreadsheet", or "Bulk insert <subjects>". */
|
|
33
48
|
title?: ReactNode;
|
|
34
|
-
/** One popup per key. Default: the import endpoint's path, so one model opens one. */
|
|
49
|
+
/** One popup per key. Default: the import endpoint's path and the mode, so one model opens one of each. */
|
|
35
50
|
popupKey?: string;
|
|
36
51
|
}
|
|
37
52
|
/**
|
|
38
|
-
* Opens the import panel as a window. This is the one line a data table's Import entry needs
|
|
53
|
+
* Opens the import panel as a window. This is the one line a data table's Import entry needs - and, with
|
|
54
|
+
* `mode: "bulk"`, its Bulk insert entry.
|
|
39
55
|
*
|
|
40
56
|
* @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
|
|
41
57
|
*/
|