nexus-shared 2.0.0 → 3.0.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 +262 -0
- package/README.md +122 -25
- package/dist/Client.Index.d.ts +11 -0
- package/dist/Client.Index.js +11 -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 +100 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Menu.js +24 -20
- package/dist/Components/Documents/SplitButton.js +5 -3
- package/dist/Components/Documents/TabButtons.d.ts +24 -6
- package/dist/Components/Documents/TabButtons.js +23 -4
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +202 -50
- package/dist/Components/Forms/ExcelImport.d.ts +42 -0
- package/dist/Components/Forms/ExcelImport.js +190 -0
- package/dist/Components/Forms/Form.js +5 -1
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- 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/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +53 -4
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +85 -20
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
- package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
- package/dist/Components/Viewers/DataTable.js +88 -34
- package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
- package/dist/Components/Viewers/DataTableColumns.js +243 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
- package/dist/Components/Viewers/DataTableParts.js +21 -9
- 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 +47 -5
- package/dist/Helpers/CrudBackend.js +99 -4
- 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 +290 -0
- package/dist/Helpers/ExcelHelpers.d.ts +98 -0
- package/dist/Helpers/ExcelHelpers.js +302 -0
- package/dist/Helpers/FormStore.d.ts +2 -0
- package/dist/Helpers/FormStore.js +19 -1
- package/dist/Helpers/MessageBuilder.js +0 -2
- package/dist/Helpers/PopoverHelpers.d.ts +31 -6
- package/dist/Helpers/PopoverHelpers.js +116 -12
- package/dist/Helpers/RowsStore.d.ts +7 -1
- package/dist/Helpers/RowsStore.js +22 -0
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableHelpers.d.ts +9 -1
- package/dist/Helpers/TableHelpers.js +20 -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 +73 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +52 -2
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/TableInterfaces.d.ts +62 -2
- 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 +46 -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/Shared.Index.d.ts +8 -0
- package/dist/Shared.Index.js +8 -0
- package/package.json +6 -3
- package/src/Styles/Nexus.Button.css +2 -0
- package/src/Styles/Nexus.Chat.css +1489 -0
- package/src/Styles/Nexus.Crud.css +77 -0
- package/src/Styles/Nexus.Excel.css +370 -0
- package/src/Styles/Nexus.Form.css +23 -0
- package/src/Styles/Nexus.Index.css +2 -0
- package/src/Styles/Nexus.Menu.css +12 -2
- package/src/Styles/Nexus.Rows.css +78 -48
- package/src/Styles/Nexus.Tab.Buttons.css +55 -0
- package/src/Styles/Nexus.Table.css +286 -4
- package/src/Styles/Nexus.Theme.Picker.css +75 -2
|
@@ -1,11 +1,11 @@
|
|
|
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 { iconEye, iconFlag, iconFlagOff, iconList, iconLock, iconNotes, iconPencil, iconPin, iconPinnedOff, iconPlus, iconRestore, iconTrash, iconTrashX, InlineIcon, } from "nexus-icons";
|
|
4
4
|
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
5
|
import { abortError } from "../../Helpers/ApiFormHelpers.js";
|
|
6
6
|
import { 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";
|
|
@@ -22,23 +22,22 @@ import { CrudDetails } from "./CrudParts.js";
|
|
|
22
22
|
import { confirmDiscard, FormButtons } from "./SubmitForm.js";
|
|
23
23
|
/*
|
|
24
24
|
* 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,
|
|
25
|
+
* inputs, and handles the rest: the lists (all, flagged, trash, deleted) the user may see, the actions they
|
|
26
26
|
* may take, the records the user pinned in a section above the list, Add and Edit popups built on ApiForm, a details
|
|
27
27
|
* popup, actions on many rows, a cache of lists while the page is open, and rows updated from each answer instead of
|
|
28
28
|
* reloading.
|
|
29
29
|
*/
|
|
30
|
-
const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "
|
|
30
|
+
const ALL_ACTIONS = ["create", "view", "edit", "flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"];
|
|
31
31
|
const MODE_INFO = {
|
|
32
32
|
normal: { label: "All", icon: iconList, empty: plural => `No ${plural} yet.` },
|
|
33
33
|
flag: { label: "Flagged", icon: iconFlag, empty: plural => `No flagged ${plural}.` },
|
|
34
|
-
archive: { label: "Archived", icon: iconArchive, empty: () => "The archive is empty." },
|
|
35
34
|
trash: { label: "Trash", icon: iconTrash, empty: () => "The trash is empty." },
|
|
36
35
|
deleted: { label: "Deleted", icon: iconTrashX, empty: plural => `No deleted ${plural}.` },
|
|
37
36
|
};
|
|
38
|
-
/** Actions that take a record out of reach are asked about first; flags, pins
|
|
39
|
-
const ASKED = new Set(["
|
|
37
|
+
/** Actions that take a record out of reach are asked about first; flags, pins and recover are undone by a click. */
|
|
38
|
+
const ASKED = new Set(["trash", "delete", "trashFromDelete"]);
|
|
40
39
|
/** 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(["
|
|
40
|
+
const LEFT_ACTIVE = new Set(["trash", "delete", "trashFromDelete"]);
|
|
42
41
|
/** The words of an action that flags some records and unflags others (a mass toggle of a mixed selection). */
|
|
43
42
|
const TOGGLED_FLAG = { verb: "change the flags of {object}", done: "updated", doing: "Changing the flags of {object}", object: "the records" };
|
|
44
43
|
const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
@@ -51,6 +50,21 @@ function endpointSubject(api) {
|
|
|
51
50
|
const endpoints = [api.create, api.update, api.details, ...Object.values(api.lists ?? {}), ...Object.values(api.pages ?? {})];
|
|
52
51
|
return endpoints.find(endpoint => endpoint?.subject)?.subject;
|
|
53
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The body of an Add, Edit or details popup. Without an `aside` it is the form (or the details) and nothing else, so
|
|
55
|
+
* a popup that wants no panel carries no wrapper at all. With one, the two become columns that scroll on their own,
|
|
56
|
+
* 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
|
|
57
|
+
* popup is marked, because there the panel is what the popup is for and takes the larger share.
|
|
58
|
+
*/
|
|
59
|
+
function CrudFormBody({ aside, details, children }) {
|
|
60
|
+
if (!aside)
|
|
61
|
+
return children;
|
|
62
|
+
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 })] }));
|
|
63
|
+
}
|
|
64
|
+
/** Whether the details endpoint's record says anything the list's row did not. Plain JSON, compared field by field. */
|
|
65
|
+
function addsToRow(row, full) {
|
|
66
|
+
return Object.entries(full).some(([name, value]) => JSON.stringify(value) !== JSON.stringify(row[name]));
|
|
67
|
+
}
|
|
54
68
|
/**
|
|
55
69
|
* A CRUD page in one component: a data table of a controller's records with its lists, Add and Edit popups, details,
|
|
56
70
|
* and actions on one record or many, each shown only when its endpoint exists and the user may call it.
|
|
@@ -89,7 +103,7 @@ export function Crud(props) {
|
|
|
89
103
|
}, [ready, api, wantedActions, can]);
|
|
90
104
|
const massAllowed = useMemo(() => {
|
|
91
105
|
const map = {};
|
|
92
|
-
for (const action of ["flag", "pin", "
|
|
106
|
+
for (const action of ["flag", "pin", "trash", "recover", "delete", "trashFromDelete", "description"])
|
|
93
107
|
map[action] = ready && massActionAllowed(action, api, wantedActions, can);
|
|
94
108
|
return map;
|
|
95
109
|
}, [ready, api, wantedActions, can]);
|
|
@@ -204,27 +218,46 @@ export function Crud(props) {
|
|
|
204
218
|
return result;
|
|
205
219
|
});
|
|
206
220
|
}, [mode, api, paging, cache, backend, plural]);
|
|
207
|
-
/**
|
|
208
|
-
|
|
221
|
+
/** Which list a record belongs in, as this backend reads the record. */
|
|
222
|
+
const member = (row, of) => backend.listMembership(row, of);
|
|
223
|
+
/**
|
|
224
|
+
* The lists kept in the cache, after a change: **every one of them is patched**, from the record the answer
|
|
225
|
+
* carried. A record flagged joins the flagged list, a record trashed leaves the active list and joins the trash,
|
|
226
|
+
* and coming back to any of them costs no request. Only a list the record cannot answer for (`changes` empty, or
|
|
227
|
+
* a backend with no `listMembership`) is dropped, and that one loads when it is shown.
|
|
228
|
+
*
|
|
229
|
+
* 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
|
+
* server's, so that page is dropped and the next one loads. One whose rows only changed is written in place.
|
|
231
|
+
*/
|
|
232
|
+
function syncCaches(changes) {
|
|
209
233
|
const shown = modeRef.current;
|
|
210
|
-
if (
|
|
211
|
-
cache.
|
|
212
|
-
cache.update(shown, table.getRows());
|
|
213
|
-
}
|
|
234
|
+
if (changes.length)
|
|
235
|
+
cache.map((key, value) => patchCachedList(value, crudCacheMode(key), changes, row => table.keyOf(row), member));
|
|
214
236
|
else
|
|
215
237
|
cache.clear();
|
|
238
|
+
// The list on screen is the table's own, every row of it, so it is written back whole rather than patched.
|
|
239
|
+
if (paging === "client" && shown)
|
|
240
|
+
cache.update(shown, table.getRows());
|
|
241
|
+
}
|
|
242
|
+
function afterChange(action, rows, changes) {
|
|
243
|
+
syncCaches(changes);
|
|
244
|
+
const shown = modeRef.current;
|
|
216
245
|
if (shown)
|
|
217
246
|
latest.current.onChange?.({ action, rows, mode: shown });
|
|
218
247
|
}
|
|
219
|
-
|
|
220
|
-
|
|
248
|
+
function applyChange(action, rows, options = {}) {
|
|
249
|
+
const { result, mass = false, markOn, values } = options;
|
|
221
250
|
const shown = modeRef.current;
|
|
222
251
|
if (!shown)
|
|
223
252
|
return;
|
|
224
253
|
const answered = rows.length > 1 ? backend.answeredKeys(result, rowKey) : null;
|
|
225
254
|
const done = answered ? rows.filter(row => answered.has(table.keyOf(row))) : rows;
|
|
255
|
+
const changes = backend.answeredChanges(result, rowKey);
|
|
256
|
+
// What the answer said about the one record acted on: the record, or the fields that changed - a mark's new
|
|
257
|
+
// status. It is what lets the other lists be patched rather than thrown away.
|
|
258
|
+
const one = mass ? null : backend.answeredRow(result);
|
|
259
|
+
const moved = [];
|
|
226
260
|
if (action === "flag") {
|
|
227
|
-
const changes = backend.answeredChanges(result, rowKey);
|
|
228
261
|
const leaving = [];
|
|
229
262
|
const updated = [];
|
|
230
263
|
for (const row of done) {
|
|
@@ -235,19 +268,21 @@ export function Crud(props) {
|
|
|
235
268
|
const change = changes.get(key);
|
|
236
269
|
if (change)
|
|
237
270
|
next = { ...next, ...change };
|
|
271
|
+
if (one)
|
|
272
|
+
next = { ...next, ...one };
|
|
238
273
|
if (leavesList(action, shown, backend.isFlagged(next)))
|
|
239
274
|
leaving.push(key);
|
|
240
275
|
else
|
|
241
276
|
table.upsert(next, { flash: false });
|
|
242
277
|
updated.push(next);
|
|
278
|
+
moved.push({ key, row: next, before: current });
|
|
243
279
|
}
|
|
244
280
|
table.remove(leaving);
|
|
245
281
|
patchPins(updated);
|
|
246
|
-
afterChange(action, updated);
|
|
282
|
+
afterChange(action, updated, moved);
|
|
247
283
|
return;
|
|
248
284
|
}
|
|
249
285
|
if (action === "pin") {
|
|
250
|
-
const changes = backend.answeredChanges(result, rowKey);
|
|
251
286
|
const updated = [];
|
|
252
287
|
for (const row of done) {
|
|
253
288
|
const key = table.keyOf(row);
|
|
@@ -257,29 +292,89 @@ export function Crud(props) {
|
|
|
257
292
|
const change = changes.get(key);
|
|
258
293
|
if (change)
|
|
259
294
|
next = { ...next, ...change };
|
|
295
|
+
if (one)
|
|
296
|
+
next = { ...next, ...one };
|
|
260
297
|
// A pin changes the section above the list, never the list itself: the record stays where it is, on its page.
|
|
261
298
|
if (table.getRow(key))
|
|
262
299
|
table.upsert(next, { flash: false });
|
|
263
300
|
updated.push(next);
|
|
301
|
+
moved.push({ key, row: next, before: current });
|
|
264
302
|
}
|
|
265
303
|
applyPins(updated);
|
|
266
|
-
afterChange(action, updated);
|
|
304
|
+
afterChange(action, updated, moved);
|
|
267
305
|
return;
|
|
268
306
|
}
|
|
269
307
|
if (action === "description") {
|
|
270
|
-
const updated =
|
|
271
|
-
for (const row of
|
|
272
|
-
table.
|
|
308
|
+
const updated = [];
|
|
309
|
+
for (const row of done) {
|
|
310
|
+
const key = table.keyOf(row);
|
|
311
|
+
const current = table.getRow(key) ?? row;
|
|
312
|
+
const next = { ...current, ...values };
|
|
313
|
+
table.upsert(next);
|
|
314
|
+
updated.push(next);
|
|
315
|
+
moved.push({ key, row: next, before: current });
|
|
316
|
+
}
|
|
273
317
|
patchPins(updated);
|
|
274
|
-
afterChange(action, updated);
|
|
318
|
+
afterChange(action, updated, moved);
|
|
275
319
|
return;
|
|
276
320
|
}
|
|
321
|
+
// Trash, recover, delete and the move back to the trash take the record to another band, and only the answer
|
|
322
|
+
// says which. Every record has to say so for the other lists to be patched; one that does not leaves `moved`
|
|
323
|
+
// empty, and they load again, which is what they all did before.
|
|
277
324
|
const keys = done.map(row => table.keyOf(row));
|
|
325
|
+
const updated = [];
|
|
326
|
+
for (const row of done) {
|
|
327
|
+
const key = table.keyOf(row);
|
|
328
|
+
const current = table.getRow(key) ?? row;
|
|
329
|
+
const change = changes.get(key) ?? one;
|
|
330
|
+
const next = change ? { ...current, ...change } : current;
|
|
331
|
+
if (change)
|
|
332
|
+
moved.push({ key, row: next, before: current });
|
|
333
|
+
updated.push(next);
|
|
334
|
+
}
|
|
335
|
+
if (moved.length !== done.length)
|
|
336
|
+
moved.length = 0;
|
|
278
337
|
if (leavesList(action, shown))
|
|
279
338
|
table.remove(keys);
|
|
280
339
|
if (LEFT_ACTIVE.has(action))
|
|
281
340
|
dropPins(keys);
|
|
282
|
-
afterChange(action,
|
|
341
|
+
afterChange(action, updated, moved);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Records a page's own action changed, written exactly where the built-in ones write theirs. The handle's `apply`.
|
|
345
|
+
*/
|
|
346
|
+
function applyRows(rows, gone = false) {
|
|
347
|
+
const shown = modeRef.current;
|
|
348
|
+
if (!shown || rows.length === 0)
|
|
349
|
+
return;
|
|
350
|
+
const moved = [];
|
|
351
|
+
const updated = [];
|
|
352
|
+
const leaving = [];
|
|
353
|
+
let pinned = false;
|
|
354
|
+
for (const given of rows) {
|
|
355
|
+
const next = { ...(table.getRow(table.keyOf(given)) ?? {}), ...given };
|
|
356
|
+
const key = table.keyOf(next);
|
|
357
|
+
if (!key)
|
|
358
|
+
continue;
|
|
359
|
+
const before = table.getRow(key);
|
|
360
|
+
// The record decides whether it is still in the list on screen; a backend that cannot say keeps it there.
|
|
361
|
+
if (gone || member(next, shown) === false)
|
|
362
|
+
leaving.push(key);
|
|
363
|
+
else if (before)
|
|
364
|
+
table.upsert(next, { flash: false });
|
|
365
|
+
if (before && backend.isPinned(next) !== backend.isPinned(before))
|
|
366
|
+
pinned = true;
|
|
367
|
+
updated.push(next);
|
|
368
|
+
moved.push({ key, row: next, before, gone });
|
|
369
|
+
}
|
|
370
|
+
table.remove(leaving);
|
|
371
|
+
if (gone)
|
|
372
|
+
dropPins(leaving);
|
|
373
|
+
else if (pinned)
|
|
374
|
+
applyPins(updated);
|
|
375
|
+
else
|
|
376
|
+
patchPins(updated);
|
|
377
|
+
syncCaches(moved);
|
|
283
378
|
}
|
|
284
379
|
/* Row actions */
|
|
285
380
|
async function runRowAction(action, row) {
|
|
@@ -295,7 +390,7 @@ export function Crud(props) {
|
|
|
295
390
|
const options = { params: { id: key, [rowKey]: key }, subject: labelOf(row), action: crudMessageAction(action, markOn) };
|
|
296
391
|
const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
|
|
297
392
|
if (answer?.ok)
|
|
298
|
-
applyChange(action, [row], answer.result, markOn);
|
|
393
|
+
applyChange(action, [row], { result: answer.result, markOn });
|
|
299
394
|
}
|
|
300
395
|
async function runMassAction(action, rows) {
|
|
301
396
|
if (action === "description") {
|
|
@@ -332,7 +427,7 @@ export function Crud(props) {
|
|
|
332
427
|
const answer = ASKED.has(action) ? await browserApi.confirm(endpoint, options) : await browserApi.send(endpoint, options);
|
|
333
428
|
if (!answer?.ok)
|
|
334
429
|
return;
|
|
335
|
-
applyChange(action, sent, answer.result, markOn);
|
|
430
|
+
applyChange(action, sent, { result: answer.result, mass: true, markOn });
|
|
336
431
|
table.clearSelection();
|
|
337
432
|
if (over > 0)
|
|
338
433
|
toast.warning(`${sent.length} of ${sent.length + over} ${plural} pinned: you can pin up to ${maxPins}.`);
|
|
@@ -384,10 +479,10 @@ export function Crud(props) {
|
|
|
384
479
|
form.dropDraft();
|
|
385
480
|
return discard;
|
|
386
481
|
},
|
|
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
|
-
|
|
482
|
+
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) => {
|
|
483
|
+
onSaved(action, row, result, saved);
|
|
484
|
+
popup.close();
|
|
485
|
+
} }) })),
|
|
391
486
|
footer: popup => _jsx(FormButtons, { form: form, submitLabel: action === "create" ? `Add ${subject}` : "Save", onCancel: () => popup.close() }),
|
|
392
487
|
});
|
|
393
488
|
if (action === "create") {
|
|
@@ -399,23 +494,57 @@ export function Crud(props) {
|
|
|
399
494
|
});
|
|
400
495
|
}
|
|
401
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* The record a `Create` answered only the key of, read back on its own: the row goes in at once with what the form
|
|
499
|
+
* sent, and the fields the server owns - its status, its times, anything it worked out - land a moment later. One
|
|
500
|
+
* small request, where loading the list again was a request for every record on it.
|
|
501
|
+
*/
|
|
502
|
+
async function fillSaved(key, record) {
|
|
503
|
+
const endpoint = api.details;
|
|
504
|
+
if (!endpoint)
|
|
505
|
+
return;
|
|
506
|
+
const id = readPath(record, rowKey);
|
|
507
|
+
const answer = await browserApi.get(endpoint, { params: { id, [rowKey]: id }, toast: false });
|
|
508
|
+
const full = unwrapApiResult(answer);
|
|
509
|
+
if (!isRecord(full))
|
|
510
|
+
return;
|
|
511
|
+
const before = table.getRow(key);
|
|
512
|
+
const next = { ...(before ?? record), ...full };
|
|
513
|
+
if (before)
|
|
514
|
+
table.upsert(next, { flash: false });
|
|
515
|
+
syncCaches([{ key, row: next, before }]);
|
|
516
|
+
}
|
|
402
517
|
function onSaved(action, row, result, values) {
|
|
403
|
-
|
|
518
|
+
// The record as the server now has it: what was sent, over the row it came from, with whatever the answer added.
|
|
519
|
+
// A backend that answers the record it wrote leaves nothing to load at all.
|
|
520
|
+
const answered = backend.answeredRow(result);
|
|
521
|
+
const record = { ...row, ...values, ...answered };
|
|
404
522
|
const shown = modeRef.current;
|
|
405
523
|
if (action === "edit") {
|
|
524
|
+
const key = table.keyOf(record);
|
|
525
|
+
const before = table.getRow(key);
|
|
406
526
|
table.upsert(record);
|
|
407
527
|
patchPins([record]);
|
|
408
|
-
afterChange("edit", [record]);
|
|
528
|
+
afterChange("edit", [record], key ? [{ key, row: record, before }] : []);
|
|
409
529
|
return;
|
|
410
530
|
}
|
|
411
|
-
// A new record belongs to the list of every record
|
|
531
|
+
// A new record belongs to the list of every record. An answer that is only the new key still gives it one, so
|
|
532
|
+
// the row goes in and the record is read back on its own; without even a key the list has to load again.
|
|
533
|
+
if (!answered && !readPath(record, rowKey)) {
|
|
534
|
+
const given = backend.answeredKey(result);
|
|
535
|
+
if (given !== null && !rowKey.includes("."))
|
|
536
|
+
record[rowKey] = given;
|
|
537
|
+
}
|
|
538
|
+
const key = table.keyOf(record);
|
|
412
539
|
if (shown === "normal") {
|
|
413
|
-
if (
|
|
540
|
+
if (key)
|
|
414
541
|
table.upsert(record);
|
|
415
542
|
else
|
|
416
543
|
void table.reload({ quiet: true });
|
|
417
544
|
}
|
|
418
|
-
afterChange("create", [record]);
|
|
545
|
+
afterChange("create", [record], key ? [{ key, row: record, before: undefined }] : []);
|
|
546
|
+
if (key && !answered && api.details)
|
|
547
|
+
void fillSaved(key, record);
|
|
419
548
|
}
|
|
420
549
|
function openDetails(row) {
|
|
421
550
|
const details = latest.current.details;
|
|
@@ -424,14 +553,32 @@ export function Crud(props) {
|
|
|
424
553
|
const key = table.keyOf(row);
|
|
425
554
|
const canEdit = allowed.edit && modeRef.current !== undefined && CRUD_ROW_ACTIONS[modeRef.current].includes("edit");
|
|
426
555
|
const endpoint = api.details;
|
|
556
|
+
// The panel's record: the row at once, so it has something to show while the full one loads, and that one after.
|
|
557
|
+
const shown = typeof details?.aside === "function" ? new FormStore() : null;
|
|
558
|
+
shown?.load(row, { draft: false });
|
|
559
|
+
const aside = typeof details?.aside === "function" ? details.aside({ form: shown, row }) : details?.aside;
|
|
427
560
|
openPopup({
|
|
428
561
|
key: `${name}-view-${key}`,
|
|
429
562
|
reopen: "restore",
|
|
430
|
-
title: `${capitalize(subject)} details`,
|
|
563
|
+
title: `${capitalize(subject)} ${details?.label ?? "details"}`,
|
|
431
564
|
description: labelOf(row),
|
|
432
565
|
icon: _jsx(InlineIcon, { icon: iconEye }),
|
|
433
566
|
size: details?.size ?? "md",
|
|
434
|
-
|
|
567
|
+
// Focus goes where it goes without a panel, the footer, rather than to the first button the panel happens to
|
|
568
|
+
// draw: a panel that renders at once (a Copy button) would otherwise take it, and one that loads would not.
|
|
569
|
+
initialFocus: aside ? ".nx-popup__foot button" : undefined,
|
|
570
|
+
content: (_jsx(CrudFormBody, { aside: aside, details: true, children: _jsx(CrudDetails, { row: row, columns: details?.columns ?? latest.current.columns, render: details?.render, load: endpoint
|
|
571
|
+
? signal => browserApi
|
|
572
|
+
.get(endpoint, { params: { id: rawKey(row), [rowKey]: rawKey(row) }, signal, toast: false })
|
|
573
|
+
.then(unwrapApiResult)
|
|
574
|
+
.then(full => {
|
|
575
|
+
// Only when it adds something: a panel that renders from the record would otherwise ask again
|
|
576
|
+
// for exactly what it is already showing.
|
|
577
|
+
if (shown && isRecord(full) && addsToRow(row, full))
|
|
578
|
+
shown.load({ ...row, ...full }, { draft: false });
|
|
579
|
+
return full;
|
|
580
|
+
})
|
|
581
|
+
: undefined }) })),
|
|
435
582
|
footer: popup => (_jsxs(_Fragment, { children: [canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: () => {
|
|
436
583
|
popup.close();
|
|
437
584
|
void openForm("edit", table.getRow(key) ?? row);
|
|
@@ -453,7 +600,7 @@ export function Crud(props) {
|
|
|
453
600
|
closeOnBackdrop: false,
|
|
454
601
|
beforeClose: () => confirmDiscard(form),
|
|
455
602
|
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,
|
|
603
|
+
applyChange("description", rows, { result, mass: true, values });
|
|
457
604
|
table.clearSelection();
|
|
458
605
|
popup.close();
|
|
459
606
|
} })),
|
|
@@ -461,8 +608,8 @@ export function Crud(props) {
|
|
|
461
608
|
});
|
|
462
609
|
}
|
|
463
610
|
// 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 };
|
|
611
|
+
const run = useRef({ runRowAction, runMassAction, openForm, openDetails, applyRows });
|
|
612
|
+
run.current = { runRowAction, runMassAction, openForm, openDetails, applyRows };
|
|
466
613
|
/* The table's callbacks */
|
|
467
614
|
const rowActions = useCallback((row) => {
|
|
468
615
|
if (!mode)
|
|
@@ -472,12 +619,10 @@ export function Crud(props) {
|
|
|
472
619
|
const flagged = backend.isFlagged(row);
|
|
473
620
|
const isPinned = backend.isPinned(row);
|
|
474
621
|
const info = {
|
|
475
|
-
view: { label:
|
|
622
|
+
view: { label: `View ${(latest.current.details || undefined)?.label ?? "details"}`, icon: _jsx(InlineIcon, { icon: iconEye }) },
|
|
476
623
|
edit: { label: "Edit", icon: _jsx(InlineIcon, { icon: iconPencil }), inline: true },
|
|
477
624
|
flag: flagged ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
478
625
|
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
626
|
trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
|
|
482
627
|
recover: { label: "Recover", description: "Back to the active list", icon: _jsx(InlineIcon, { icon: iconRestore }), inline: true },
|
|
483
628
|
delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
|
|
@@ -504,8 +649,6 @@ export function Crud(props) {
|
|
|
504
649
|
const info = {
|
|
505
650
|
flag: flagged === rows.length ? { label: "Unflag", icon: _jsx(InlineIcon, { icon: iconFlagOff }) } : { label: flagged ? "Flag / unflag" : "Flag", icon: _jsx(InlineIcon, { icon: iconFlag }) },
|
|
506
651
|
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
652
|
trash: { label: "Move to trash", icon: _jsx(InlineIcon, { icon: iconTrash }), tone: "danger" },
|
|
510
653
|
recover: { label: "Recover", icon: _jsx(InlineIcon, { icon: iconRestore }) },
|
|
511
654
|
delete: { label: "Delete permanently", icon: _jsx(InlineIcon, { icon: iconTrashX }), tone: "danger" },
|
|
@@ -534,6 +677,7 @@ export function Crud(props) {
|
|
|
534
677
|
reloadPins();
|
|
535
678
|
return table.reload({ quiet: true });
|
|
536
679
|
},
|
|
680
|
+
apply: (rows, options) => run.current.applyRows(rows, options?.gone ?? false),
|
|
537
681
|
create: values => void run.current.openForm("create", null, values),
|
|
538
682
|
edit: row => void run.current.openForm("edit", row),
|
|
539
683
|
view: row => run.current.openDetails(row),
|
|
@@ -549,8 +693,16 @@ export function Crud(props) {
|
|
|
549
693
|
const modeOptions = modes.map(value => ({ value, label: MODE_INFO[value].label, icon: _jsx(InlineIcon, { icon: MODE_INFO[value].icon }) }));
|
|
550
694
|
const addButton = allowed.create ? (_jsxs(Button, { variant: "primary", size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: () => void run.current.openForm("create", null), children: ["Add ", subject] })) : null;
|
|
551
695
|
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: "selected", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: _jsxs(_Fragment, { children: [props.toolbar, addButton] }), selectable: selectable, massActions: massActionsFor, rowActions: rowActions, pinnedRows: mode === "normal" && pinsOn ? (pinned ?? undefined) : undefined, pinnedLoading: pinsLoading, pinnedLabel: `Pinned ${plural}`, pinnedMax: maxPins, onRowOpen: onRowOpen, onReload: () => {
|
|
696
|
+
return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, columns: columns, load: load, loadKey: mode ?? "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "selected", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: _jsxs(_Fragment, { children: [props.toolbar, addButton] }), selectable: selectable, massActions: massActionsFor, rowActions: rowActions, pinnedRows: mode === "normal" && pinsOn ? (pinned ?? undefined) : undefined, pinnedLoading: pinsLoading, pinnedLabel: `Pinned ${plural}`, pinnedMax: maxPins, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, onReload: () => {
|
|
553
697
|
cache.clear();
|
|
554
698
|
reloadPins();
|
|
555
|
-
}, emptyMessage: tableOptions?.emptyMessage ?? (mode ? MODE_INFO[mode].empty(plural) : undefined), emptyAction: mode === "normal" ? addButton : undefined, searchPlaceholder: tableOptions?.searchPlaceholder ?? `Search ${plural}…`, stateKey: tableOptions?.stateKey ?? `crud:${name}`, pageSize: tableOptions?.pageSize, pageSizes: tableOptions?.pageSizes, defaultSort: tableOptions?.defaultSort, numbered: tableOptions?.numbered, maxHeight: tableOptions?.maxHeight, size: tableOptions?.size, maxSelection: tableOptions?.maxSelection, columnsMenu: tableOptions?.columnsMenu, searchDelay: tableOptions?.searchDelay, reorderable: tableOptions?.reorderable, resizable: tableOptions?.resizable, pinnable: tableOptions?.pinnable, moreActions: tableOptions?.moreActions, exportable: tableOptions?.exportable, printable: tableOptions?.printable, exportName: tableOptions?.exportName, onImport: tableOptions?.onImport
|
|
699
|
+
}, emptyMessage: tableOptions?.emptyMessage ?? (mode ? MODE_INFO[mode].empty(plural) : undefined), emptyAction: mode === "normal" ? addButton : undefined, searchPlaceholder: tableOptions?.searchPlaceholder ?? `Search ${plural}…`, stateKey: tableOptions?.stateKey ?? `crud:${name}`, pageSize: tableOptions?.pageSize, pageSizes: tableOptions?.pageSizes, defaultSort: tableOptions?.defaultSort, numbered: tableOptions?.numbered, maxHeight: tableOptions?.maxHeight, fill: tableOptions?.fill, size: tableOptions?.size, maxSelection: tableOptions?.maxSelection, columnsMenu: tableOptions?.columnsMenu, searchDelay: tableOptions?.searchDelay, reorderable: tableOptions?.reorderable, resizable: tableOptions?.resizable, pinnable: tableOptions?.pinnable, moreActions: tableOptions?.moreActions, exportable: tableOptions?.exportable, printable: tableOptions?.printable, exportName: tableOptions?.exportName, onImport: tableOptions?.onImport, excel: tableOptions?.excel && {
|
|
700
|
+
...tableOptions.excel,
|
|
701
|
+
// A spreadsheet wrote records straight into the database: every list this CRUD kept is out of date.
|
|
702
|
+
onImported: () => {
|
|
703
|
+
cache.clear();
|
|
704
|
+
reloadPins();
|
|
705
|
+
tableOptions.excel?.onImported?.();
|
|
706
|
+
},
|
|
707
|
+
} }) }));
|
|
556
708
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { ExcelEndpoints, ExcelImportAnswer } from "../../Interfaces/ExcelInterfaces.ts";
|
|
3
|
+
import { type ExcelCallOptions } from "../../Services/ExcelApi.ts";
|
|
4
|
+
import { type PopupHandle } from "../Layouts/PopupHost.tsx";
|
|
5
|
+
/** What an import panel needs. */
|
|
6
|
+
export interface ExcelImportProps {
|
|
7
|
+
/** The model's spreadsheet endpoints: `excelEndpoints(CITIES)`, or the app's own four. */
|
|
8
|
+
endpoints: ExcelEndpoints;
|
|
9
|
+
/** What one row is: "city". Default: what the settings say. */
|
|
10
|
+
subject?: string;
|
|
11
|
+
/** Path values the calls need, such as a parent id. */
|
|
12
|
+
params?: ExcelCallOptions["params"];
|
|
13
|
+
/** After an import wrote something, to reload the list behind the panel. */
|
|
14
|
+
onImported?: (answer: ExcelImportAnswer) => void;
|
|
15
|
+
/** The Close button, and what it does. Without it, none shows (a popup has its own). */
|
|
16
|
+
onClose?: () => void;
|
|
17
|
+
/** No template download is offered, for a model whose file comes from somewhere else. */
|
|
18
|
+
templateDownload?: false;
|
|
19
|
+
/** The tallest each editor grows before its rows scroll. Default 320. */
|
|
20
|
+
maxHeight?: number;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The three steps of an import in one panel: the template, the file, and the review.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
|
|
28
|
+
*/
|
|
29
|
+
export declare function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight, className }: ExcelImportProps): import("react").JSX.Element;
|
|
30
|
+
/** 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". */
|
|
33
|
+
title?: ReactNode;
|
|
34
|
+
/** One popup per key. Default: the import endpoint's path, so one model opens one. */
|
|
35
|
+
popupKey?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Opens the import panel as a window. This is the one line a data table's Import entry needs.
|
|
39
|
+
*
|
|
40
|
+
* @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
|
|
41
|
+
*/
|
|
42
|
+
export declare function openExcelImport({ title, popupKey, ...props }: OpenExcelImportOptions): PopupHandle;
|