nexus-shared 3.2.0 → 4.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 +2359 -0
- package/README.md +1911 -1123
- package/dist/Client.Index.d.ts +27 -0
- package/dist/Client.Index.js +27 -0
- package/dist/Components/Chats/ChatComposer.d.ts +7 -1
- package/dist/Components/Chats/ChatComposer.js +9 -6
- package/dist/Components/Chats/ChatParts.d.ts +15 -2
- package/dist/Components/Chats/ChatParts.js +15 -4
- package/dist/Components/Chats/ChatPopup.d.ts +46 -0
- package/dist/Components/Chats/ChatPopup.js +34 -0
- package/dist/Components/Chats/ChatThread.d.ts +9 -1
- package/dist/Components/Chats/ChatThread.js +54 -6
- package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
- package/dist/Components/Dashboards/DashboardBoard.js +594 -0
- package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
- package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
- package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
- package/dist/Components/Dashboards/WidgetFrame.js +45 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/TabButtons.d.ts +6 -0
- package/dist/Components/Documents/TabButtons.js +5 -4
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Domains/DomainCard.d.ts +98 -0
- package/dist/Components/Domains/DomainCard.js +182 -0
- package/dist/Components/Forms/ApiForm.js +24 -3
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +628 -247
- package/dist/Components/Forms/CrudActions.d.ts +137 -0
- package/dist/Components/Forms/CrudActions.js +214 -0
- package/dist/Components/Forms/CrudHistory.d.ts +6 -1
- package/dist/Components/Forms/CrudHistory.js +14 -6
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +37 -2
- package/dist/Components/Forms/CrudParts.js +123 -7
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +174 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +73 -0
- package/dist/Components/Forms/CrudWorkspace.js +177 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +6 -5
- package/dist/Components/Forms/Form.js +134 -21
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/FormQuestion.d.ts +6 -0
- package/dist/Components/Forms/FormQuestion.js +28 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +182 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
- package/dist/Components/Inputs/ComputedInput.js +179 -0
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -6
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +12 -12
- package/dist/Components/Inputs/InputRenderer.d.ts +13 -6
- package/dist/Components/Inputs/InputRenderer.js +37 -32
- package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
- package/dist/Components/Inputs/MarkdownEditor.js +32 -6
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +75 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +15 -2
- package/dist/Components/Inputs/RowsInput.js +96 -8
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +45 -32
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +11 -2
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TreeView.d.ts +76 -2
- package/dist/Components/Inputs/TreeView.js +227 -16
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +34 -7
- package/dist/Components/Layouts/Popup.js +143 -13
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Messages/MailClient.d.ts +50 -0
- package/dist/Components/Messages/MailClient.js +410 -0
- package/dist/Components/Messages/MailColumns.d.ts +21 -0
- package/dist/Components/Messages/MailColumns.js +92 -0
- package/dist/Components/Messages/MailCompose.d.ts +46 -0
- package/dist/Components/Messages/MailCompose.js +154 -0
- package/dist/Components/Messages/MailReader.d.ts +43 -0
- package/dist/Components/Messages/MailReader.js +207 -0
- package/dist/Components/Messages/MailThread.d.ts +71 -0
- package/dist/Components/Messages/MailThread.js +185 -0
- package/dist/Components/Messages/MessageBell.d.ts +41 -0
- package/dist/Components/Messages/MessageBell.js +76 -0
- package/dist/Components/Viewers/DataTable.js +153 -49
- package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
- package/dist/Components/Viewers/DataTableColumns.js +2 -1
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +23 -5
- package/dist/Components/Viewers/DataTableParts.js +26 -14
- package/dist/Components/Viewers/OwnerFiles.d.ts +227 -0
- package/dist/Components/Viewers/OwnerFiles.js +700 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- package/dist/Helpers/ApiModules.js +4 -0
- package/dist/Helpers/AuditEntries.d.ts +50 -0
- package/dist/Helpers/AuditEntries.js +96 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/ChatBackend.d.ts +8 -1
- package/dist/Helpers/ChatBackend.js +18 -0
- package/dist/Helpers/ChatHelpers.d.ts +2 -0
- package/dist/Helpers/ChatHelpers.js +4 -1
- package/dist/Helpers/CrudBackend.d.ts +20 -2
- package/dist/Helpers/CrudBackend.js +46 -2
- package/dist/Helpers/CrudColumns.d.ts +91 -6
- package/dist/Helpers/CrudColumns.js +148 -7
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +116 -2
- package/dist/Helpers/CrudHelpers.js +256 -0
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
- package/dist/Helpers/CrudPickerPlan.js +46 -0
- package/dist/Helpers/CrudRowForms.d.ts +32 -0
- package/dist/Helpers/CrudRowForms.js +76 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DashboardHelpers.d.ts +115 -0
- package/dist/Helpers/DashboardHelpers.js +305 -0
- package/dist/Helpers/DomainCards.d.ts +140 -0
- package/dist/Helpers/DomainCards.js +370 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +158 -0
- package/dist/Helpers/FileAdapter.js +173 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +100 -5
- package/dist/Helpers/FormStore.js +368 -25
- package/dist/Helpers/InputConditions.d.ts +56 -0
- package/dist/Helpers/InputConditions.js +192 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +395 -0
- package/dist/Helpers/InputNarrowing.d.ts +50 -0
- package/dist/Helpers/InputNarrowing.js +148 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +157 -74
- package/dist/Helpers/MailClientHelpers.d.ts +96 -0
- package/dist/Helpers/MailClientHelpers.js +211 -0
- package/dist/Helpers/MessageBackend.d.ts +152 -0
- package/dist/Helpers/MessageBackend.js +329 -0
- package/dist/Helpers/MessageHelpers.d.ts +160 -0
- package/dist/Helpers/MessageHelpers.js +573 -0
- package/dist/Helpers/MessageUnread.d.ts +95 -0
- package/dist/Helpers/MessageUnread.js +245 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +71 -0
- package/dist/Helpers/NexusDriveAdapter.js +236 -0
- package/dist/Helpers/NumberHelpers.d.ts +156 -0
- package/dist/Helpers/NumberHelpers.js +242 -0
- package/dist/Helpers/PagedPicker.d.ts +42 -0
- package/dist/Helpers/PagedPicker.js +85 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +14 -2
- package/dist/Helpers/RequestCache.js +22 -3
- package/dist/Helpers/RowsStore.d.ts +61 -2
- package/dist/Helpers/RowsStore.js +203 -16
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +9 -3
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +38 -4
- package/dist/Helpers/TableHelpers.js +78 -10
- package/dist/Helpers/TableJoins.d.ts +13 -0
- package/dist/Helpers/TableJoins.js +34 -0
- package/dist/Helpers/TableStore.d.ts +28 -4
- package/dist/Helpers/TableStore.js +73 -11
- package/dist/Helpers/ToolbarFit.d.ts +23 -0
- package/dist/Helpers/ToolbarFit.js +30 -0
- package/dist/Helpers/TreeStore.d.ts +156 -6
- package/dist/Helpers/TreeStore.js +547 -18
- package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
- package/dist/Interfaces/ApiInterfaces.js +10 -1
- package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +539 -6
- package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
- package/dist/Interfaces/DashboardInterfaces.js +1 -0
- package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
- package/dist/Interfaces/DomainInterfaces.js +1 -0
- package/dist/Interfaces/FileInterfaces.d.ts +183 -0
- package/dist/Interfaces/FileInterfaces.js +6 -0
- package/dist/Interfaces/FormInterfaces.d.ts +460 -32
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +22 -3
- package/dist/Interfaces/MailInterfaces.d.ts +192 -0
- package/dist/Interfaces/MailInterfaces.js +1 -0
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +102 -3
- package/dist/Services/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +79 -6
- package/dist/Services/JoinSources.js +132 -21
- package/dist/Services/MessageApi.d.ts +18 -0
- package/dist/Services/MessageApi.js +64 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Shared.Index.d.ts +28 -0
- package/dist/Shared.Index.js +28 -0
- package/package.json +13 -2
- package/src/Styles/Nexus.Avatar.css +6 -5
- package/src/Styles/Nexus.Button.css +39 -4
- package/src/Styles/Nexus.Chat.css +114 -22
- package/src/Styles/Nexus.Choice.css +16 -0
- package/src/Styles/Nexus.Crud.css +486 -21
- package/src/Styles/Nexus.Dashboard.css +763 -0
- package/src/Styles/Nexus.Domain.css +384 -0
- package/src/Styles/Nexus.Dropdown.css +8 -0
- package/src/Styles/Nexus.Editor.css +20 -0
- package/src/Styles/Nexus.Files.css +437 -0
- package/src/Styles/Nexus.Form.css +5 -2
- package/src/Styles/Nexus.Index.css +7 -0
- package/src/Styles/Nexus.Input.css +73 -0
- package/src/Styles/Nexus.Mail.css +1062 -0
- package/src/Styles/Nexus.Menu.css +10 -1
- package/src/Styles/Nexus.Popup.css +135 -1
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Tab.Buttons.css +87 -22
- package/src/Styles/Nexus.Table.css +1458 -1387
- package/src/Styles/Nexus.Theme.Picker.css +1 -0
- package/src/Styles/Nexus.Themes.css +12 -6
- package/src/Styles/Nexus.Tokens.css +8 -3
- package/src/Styles/Nexus.Tree.css +41 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { unwrapApiResult } from "../../Helpers/ApiResponses.js";
|
|
5
|
+
import { getCrudBackend } from "../../Helpers/CrudBackend.js";
|
|
6
|
+
import { actionAllowed, crudJoinedNames, crudJoinsMissing, crudRowActions, crudSavedCount, crudSavedRecord, crudTreeChanges } from "../../Helpers/CrudHelpers.js";
|
|
7
|
+
import { pluralize } from "../../Helpers/MessageBuilder.js";
|
|
8
|
+
import { getPermissions, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
|
|
9
|
+
import { columnValue, formatCellText, namingColumn, readPath } from "../../Helpers/TableHelpers.js";
|
|
10
|
+
import { tableJoins } from "../../Helpers/TableJoins.js";
|
|
11
|
+
import { TREE_DEFAULT_KEYS, writeTreeChanges } from "../../Helpers/TreeStore.js";
|
|
12
|
+
import { api as browserApi } from "../../Services/BrowserApi.js";
|
|
13
|
+
import { expireLookups, joinSource } from "../../Services/JoinSources.js";
|
|
14
|
+
import { Button } from "../Documents/Button.js";
|
|
15
|
+
import { TreeView } from "../Inputs/TreeView.js";
|
|
16
|
+
import { CRUD_ACTION_WORDS, crudActionEntry, crudSubject, openCrudForm, sendCrudRowAction } from "./CrudActions.js";
|
|
17
|
+
import { openAuditHistory } from "./CrudHistory.js";
|
|
18
|
+
import { CrudDetails } from "./CrudParts.js";
|
|
19
|
+
/*
|
|
20
|
+
* CrudTree: a controller's records as a tree whose levels load from the server when they are opened (TreeView
|
|
21
|
+
* `loadChildren`), changed where they are shown - Add New under an item, Edit, the trash and back, the history - through
|
|
22
|
+
* the very form and calls the data table's Crud uses. Every change is written into the tree and into the pages it keeps
|
|
23
|
+
* (`writeTreeChanges`), and nothing is read again for it: a record added goes under its parent, one edited is written in
|
|
24
|
+
* place or moves, one sent to the trash leaves with its parent counting one fewer.
|
|
25
|
+
*/
|
|
26
|
+
/** The built-in actions a tree of records offers, by default. Flags, pins and favourites stay on the table. */
|
|
27
|
+
const TREE_ACTIONS = ["create", "edit", "history", "trash", "recover", "delete", "trashFromDelete"];
|
|
28
|
+
/** The panel's buttons, in their order: what a record's list offers, the history last. */
|
|
29
|
+
const PANEL_ORDER = ["edit", "recover", "trashFromDelete", "trash", "delete", "history"];
|
|
30
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
32
|
+
/**
|
|
33
|
+
* A controller's records as a tree, with Add New, Edit, Move to trash, Recover, Delete and History where they are shown:
|
|
34
|
+
* an icon button on each row for Add New under it, Edit and Move to trash (Insert, F2 and Delete on a focused row, all
|
|
35
|
+
* three in its context menu), Add New in the tree's toolbar, and every action of the picked record on the panel beside
|
|
36
|
+
* the tree. The forms and the calls are the data table's; a change is written into the tree and its kept pages, so no
|
|
37
|
+
* level is read again for it.
|
|
38
|
+
*
|
|
39
|
+
* A click or Enter on a row selects it - marked in the tree, and staying so while focus, the keys and the chevrons move
|
|
40
|
+
* on - and a record's row shows that record in the panel; a row that is not a record (a country above its regions)
|
|
41
|
+
* empties the panel and opens.
|
|
42
|
+
*
|
|
43
|
+
* With `place.placeKeys` the tree fixes where a record sits: every Add goes inside a row - the toolbar's inside the
|
|
44
|
+
* selected row, the one the panel shows, and saying why it cannot until there is one - and the Add and Edit forms show
|
|
45
|
+
* those fields read-only, saying why.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* <AdminSplit>
|
|
49
|
+
* <CrudTree api={REGIONS} inputs={REGION_INPUTS} columns={REGION_COLUMNS} place={REGION_TREE} form={REGION_FORM}
|
|
50
|
+
* tree={{ loadChildren, fill: true, "aria-label": "Regions" }}
|
|
51
|
+
* details={({ row, body, actions }) => <AdminPanel title={row?.name ?? "Details"} action={actions}>{body}</AdminPanel>} />
|
|
52
|
+
* </AdminSplit>
|
|
53
|
+
*/
|
|
54
|
+
export function CrudTree(props) {
|
|
55
|
+
const { api, place, columns, rowKey = "id", tree: treeProps, ref } = props;
|
|
56
|
+
const latest = useRef(props);
|
|
57
|
+
latest.current = props;
|
|
58
|
+
const subject = props.subject ?? crudSubject(api) ?? "record";
|
|
59
|
+
const name = props.name ?? subject;
|
|
60
|
+
const backend = props.backend ?? getCrudBackend();
|
|
61
|
+
const valueKey = place.rules.valueKey ?? TREE_DEFAULT_KEYS.value;
|
|
62
|
+
const labelKey = treeProps.labelKey ?? TREE_DEFAULT_KEYS.label;
|
|
63
|
+
// The fields that say where a record sits: fixed in every form the tree opens, when the placement names them.
|
|
64
|
+
const placeKeys = place.placeKeys;
|
|
65
|
+
// Permissions: each action asks, again whenever the user's rights change.
|
|
66
|
+
const permissionsStamp = useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
|
|
67
|
+
const permissions = getPermissions();
|
|
68
|
+
const wanted = props.actions ?? TREE_ACTIONS;
|
|
69
|
+
const allowed = useMemo(() => {
|
|
70
|
+
const map = {};
|
|
71
|
+
for (const action of TREE_ACTIONS)
|
|
72
|
+
map[action] = permissions.ready && actionAllowed(action, api, wanted, endpoint => permissions.can(endpoint));
|
|
73
|
+
return map;
|
|
74
|
+
}, [permissions, permissionsStamp, api, wanted]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
75
|
+
// A controller without a trash deletes from the tree; a trash the user may not use never becomes Delete.
|
|
76
|
+
const trashless = !api.trash || !wanted.includes("trash");
|
|
77
|
+
const treeRef = useRef(null);
|
|
78
|
+
const actionsRef = useRef(null);
|
|
79
|
+
// The record the panel shows, and the selected row - its row, or one that is no record (a country): where the
|
|
80
|
+
// toolbar's Add New adds, with `placeKeys`. Focus, the keys and the chevrons move neither.
|
|
81
|
+
const [picked, setPicked] = useState(null);
|
|
82
|
+
const [selected, setSelected] = useState(null);
|
|
83
|
+
// Draws the panel's fields again for the same record once it changed.
|
|
84
|
+
const [stamp, setStamp] = useState(0);
|
|
85
|
+
const [busy, setBusy] = useState(false);
|
|
86
|
+
// An action pressed on the panel took its own button away: focus goes to the panel's first button once it is drawn.
|
|
87
|
+
const refocusRef = useRef(false);
|
|
88
|
+
const keyOf = (row) => String(readPath(row, rowKey) ?? "");
|
|
89
|
+
const valueOf = (row) => place.itemOf(row)[valueKey];
|
|
90
|
+
const itemLabel = (item) => String(item[labelKey] ?? "");
|
|
91
|
+
function labelOf(row) {
|
|
92
|
+
const custom = latest.current.rowLabel;
|
|
93
|
+
if (custom)
|
|
94
|
+
return custom(row);
|
|
95
|
+
const first = namingColumn(latest.current.columns);
|
|
96
|
+
const text = first ? formatCellText(first, columnValue(first, row), {}, row) : "";
|
|
97
|
+
return text || `${capitalize(subject)} ${keyOf(row)}`;
|
|
98
|
+
}
|
|
99
|
+
/** The list a record is in now: the active one, unless its status puts it in the trash or among the deleted. */
|
|
100
|
+
const modeOf = (row) => (backend.listMembership(row, "deleted") ? "deleted" : backend.listMembership(row, "trash") ? "trash" : "normal");
|
|
101
|
+
/** Whether the tree shows a record: an active one, or one whose status does not say. */
|
|
102
|
+
const inTree = (row) => backend.listMembership(row, "normal") !== false;
|
|
103
|
+
/** Shows a record in the panel and selects its row (or the row of an item that is no record, with an empty panel). */
|
|
104
|
+
function show(row, item = row ? place.itemOf(row) : null) {
|
|
105
|
+
setPicked(row);
|
|
106
|
+
setSelected(item);
|
|
107
|
+
setStamp(count => count + 1);
|
|
108
|
+
}
|
|
109
|
+
/** Writes changes into the tree and its kept pages, marks the lookups of these records old, and tells the page. */
|
|
110
|
+
function changed(change, treeChanges = crudTreeChanges(change, place, backend)) {
|
|
111
|
+
writeTreeChanges(place.cacheKey, treeChanges, place.rules);
|
|
112
|
+
expireLookups(api);
|
|
113
|
+
latest.current.onChange?.(change);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Shows a record where it went, its parents opened and its row scrolled into view - where the tree placed it. Under a
|
|
117
|
+
* parent never opened it was only counted, and opening that parent would read its page: it stays closed, with its new
|
|
118
|
+
* count, and the panel shows the record.
|
|
119
|
+
*/
|
|
120
|
+
function revealInTree(row) {
|
|
121
|
+
const tree = treeRef.current;
|
|
122
|
+
const value = valueOf(row);
|
|
123
|
+
if (tree && inTree(row) && tree.getItem(value) !== undefined)
|
|
124
|
+
tree.reveal(value, false);
|
|
125
|
+
}
|
|
126
|
+
/** The record of the item a form names as the parent: kept for the pickers that look these records up, so none asks. */
|
|
127
|
+
function rememberParent(parent) {
|
|
128
|
+
if (!api.findListing || parent === null)
|
|
129
|
+
return;
|
|
130
|
+
const item = treeRef.current?.getItem(parent);
|
|
131
|
+
const record = item ? place.recordOf(item) : null;
|
|
132
|
+
if (record)
|
|
133
|
+
tableJoins.remember(joinSource(api.findListing, backend), [record]);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The row the toolbar's Add New adds inside, with `placeKeys`: the selected one, as the tree holds it now - unless it is
|
|
137
|
+
* a record out of the tree (moved to the trash, deleted), which nothing can be added inside. `null` while there is none.
|
|
138
|
+
*/
|
|
139
|
+
function addTarget() {
|
|
140
|
+
if (!selected)
|
|
141
|
+
return null;
|
|
142
|
+
const value = selected[valueKey];
|
|
143
|
+
const item = treeRef.current?.getItem(value) ?? selected;
|
|
144
|
+
const record = place.recordOf(item);
|
|
145
|
+
return record && !inTree(record) ? null : item;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Opens the Add form under an item. With `placeKeys` every Add goes inside one - without one given, inside the selected
|
|
149
|
+
* row, and nothing opens while there is none - and the fields that say where are fixed to it: what `childValues` gives,
|
|
150
|
+
* and empty for a field it leaves out.
|
|
151
|
+
*/
|
|
152
|
+
function create(parent, values) {
|
|
153
|
+
if (!allowed.create)
|
|
154
|
+
return;
|
|
155
|
+
const inside = placeKeys && !parent ? addTarget() : parent;
|
|
156
|
+
if (placeKeys && !inside)
|
|
157
|
+
return;
|
|
158
|
+
if (inside)
|
|
159
|
+
rememberParent(inside[valueKey]);
|
|
160
|
+
const under = inside && place.childValues ? place.childValues(inside) : {};
|
|
161
|
+
const fixed = {};
|
|
162
|
+
const locked = {};
|
|
163
|
+
for (const key of placeKeys ?? []) {
|
|
164
|
+
fixed[key] = under[key] ?? null;
|
|
165
|
+
locked[key] = `Added inside ${itemLabel(inside)}: pick another row in the tree to add elsewhere.`;
|
|
166
|
+
}
|
|
167
|
+
void openCrudForm({
|
|
168
|
+
api,
|
|
169
|
+
action: "create",
|
|
170
|
+
row: null,
|
|
171
|
+
values: { ...under, ...values, ...fixed },
|
|
172
|
+
locked: placeKeys ? locked : undefined,
|
|
173
|
+
inputs: latest.current.inputs,
|
|
174
|
+
form: latest.current.form,
|
|
175
|
+
subject,
|
|
176
|
+
name,
|
|
177
|
+
rowKey,
|
|
178
|
+
// Says where, in the title bar and on the dock: two Adds minimized inside two rows read apart.
|
|
179
|
+
label: inside ? `Inside ${itemLabel(inside)}` : undefined,
|
|
180
|
+
onSaved: (result, sent, joined) => void afterSave(null, result, sent, joined),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** Opens a record's Edit form; with `placeKeys` its place stays as it is, fixed, and moves only where it is offered. */
|
|
184
|
+
function edit(row) {
|
|
185
|
+
if (!allowed.edit)
|
|
186
|
+
return;
|
|
187
|
+
rememberParent(place.parentOf(row));
|
|
188
|
+
void openCrudForm({
|
|
189
|
+
api,
|
|
190
|
+
action: "edit",
|
|
191
|
+
row,
|
|
192
|
+
locked: placeKeys ? Object.fromEntries(placeKeys.map(key => [key, "Its place cannot be changed in the tree."])) : undefined,
|
|
193
|
+
inputs: latest.current.inputs,
|
|
194
|
+
form: latest.current.form,
|
|
195
|
+
subject,
|
|
196
|
+
name,
|
|
197
|
+
rowKey,
|
|
198
|
+
key: keyOf(row),
|
|
199
|
+
label: labelOf(row),
|
|
200
|
+
onSaved: (result, sent, joined) => void afterSave(row, result, sent, joined),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A record saved through the form, finished with no call and written in where it now belongs, shown in the panel and
|
|
205
|
+
* in the tree: the fields only the server keeps as the backend writes them (`savedRow`), the names the database joins
|
|
206
|
+
* from the records the form picked, the page holds, or the tree holds - the parent it sits under - and the record as
|
|
207
|
+
* the tree counts it (`crudSavedCount`: a new one has no children, an edited one the count the tree keeps now). An
|
|
208
|
+
* edited record is finished from the row as the tree holds it now, not as the form opened it.
|
|
209
|
+
*/
|
|
210
|
+
async function afterSave(row, result, sent, joined) {
|
|
211
|
+
const action = row ? "edit" : "create";
|
|
212
|
+
const answered = backend.answeredRow(result);
|
|
213
|
+
let record = crudSavedRecord(row, sent, answered);
|
|
214
|
+
if (!answered && !readPath(record, rowKey)) {
|
|
215
|
+
const given = backend.answeredKey(result);
|
|
216
|
+
if (given !== null && !rowKey.includes("."))
|
|
217
|
+
record[rowKey] = given;
|
|
218
|
+
}
|
|
219
|
+
if (!keyOf(record))
|
|
220
|
+
return;
|
|
221
|
+
const heldItem = row ? treeRef.current?.getItem(valueOf(row)) : undefined;
|
|
222
|
+
const held = row ? ((heldItem ? place.recordOf(heldItem) : null) ?? row) : undefined;
|
|
223
|
+
record = backend.savedRow(action, record, held);
|
|
224
|
+
const parentValue = place.parentOf(record);
|
|
225
|
+
const parentItem = parentValue === null ? undefined : treeRef.current?.getItem(parentValue);
|
|
226
|
+
const parent = parentItem ? place.recordOf(parentItem) : null;
|
|
227
|
+
record = crudJoinedNames(record, held, latest.current.columns, (from, key) => joined(from, key, parent ? [parent] : []));
|
|
228
|
+
record = crudSavedCount(record, held, place);
|
|
229
|
+
changed({ action, rows: [record], mode: "normal", before: [held] });
|
|
230
|
+
show(record);
|
|
231
|
+
revealInTree(record);
|
|
232
|
+
// Read back only what nothing here can finish: a list unknown (never the Nexus backend's: `savedRow`), or a name.
|
|
233
|
+
if (api.details && (backend.listMembership(record, "normal") === undefined || crudJoinsMissing(record, held, latest.current.columns)))
|
|
234
|
+
await fill(record);
|
|
235
|
+
}
|
|
236
|
+
/** Reads one record back, and writes it in place: into the tree, its kept pages, and the panel if it shows it. */
|
|
237
|
+
async function fill(record) {
|
|
238
|
+
if (!api.details)
|
|
239
|
+
return;
|
|
240
|
+
const id = readPath(record, rowKey);
|
|
241
|
+
const answer = await browserApi.get(api.details, { params: { id, [rowKey]: id }, toast: false });
|
|
242
|
+
const full = unwrapApiResult(answer);
|
|
243
|
+
if (!isRecord(full))
|
|
244
|
+
return;
|
|
245
|
+
const next = { ...record, ...full };
|
|
246
|
+
writeTreeChanges(place.cacheKey, crudTreeChanges({ action: "edit", rows: [next], mode: "normal", before: [record] }, place, backend), place.rules);
|
|
247
|
+
setPicked(current => (current && keyOf(current) === keyOf(next) ? next : current));
|
|
248
|
+
setStamp(count => count + 1);
|
|
249
|
+
}
|
|
250
|
+
/** Moves a record to the trash or back, or deletes it: the call the table makes, then the tree and the panel follow. */
|
|
251
|
+
async function run(action, row) {
|
|
252
|
+
const fromPanel = Boolean(actionsRef.current?.contains(document.activeElement));
|
|
253
|
+
setBusy(true);
|
|
254
|
+
try {
|
|
255
|
+
const answer = await sendCrudRowAction(api, action, row, { rowKey, label: labelOf(row), backend });
|
|
256
|
+
if (!answer?.ok)
|
|
257
|
+
return;
|
|
258
|
+
const one = backend.answeredRow(answer.result);
|
|
259
|
+
const next = (one ? { ...row, ...one } : row);
|
|
260
|
+
// An answer that does not say where the record went: where the action takes it.
|
|
261
|
+
const wasIn = inTree(row);
|
|
262
|
+
const nowIn = backend.listMembership(next, "normal") ?? action === "recover";
|
|
263
|
+
const treeChanges = [];
|
|
264
|
+
if (wasIn && !nowIn)
|
|
265
|
+
treeChanges.push({ type: "remove", value: valueOf(row), parent: place.parentOf(row) });
|
|
266
|
+
else if (!wasIn && nowIn)
|
|
267
|
+
treeChanges.push({ type: "put", item: place.itemOf(next), parent: place.parentOf(next), before: false });
|
|
268
|
+
changed({ action, rows: [next], mode: modeOf(row), before: [row] }, treeChanges);
|
|
269
|
+
refocusRef.current = fromPanel;
|
|
270
|
+
show(next);
|
|
271
|
+
if (!wasIn && nowIn)
|
|
272
|
+
revealInTree(next);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
setBusy(false);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function openHistory(row) {
|
|
279
|
+
if (!api.audit)
|
|
280
|
+
return;
|
|
281
|
+
openAuditHistory({ endpoint: api.audit, id: readPath(row, rowKey), subject, subjectPlural: props.subjectPlural ?? pluralize(subject), label: labelOf(row), backend });
|
|
282
|
+
}
|
|
283
|
+
/** A click or Enter on an item selects it: a record shows in the panel, anything else empties it and opens. */
|
|
284
|
+
function pick(item) {
|
|
285
|
+
const record = place.recordOf(item);
|
|
286
|
+
if (!record) {
|
|
287
|
+
show(null, item);
|
|
288
|
+
treeRef.current?.expand(item[valueKey]);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
show(record, item);
|
|
292
|
+
// A record whose list cannot be told - written by a backend whose saves leave no status, never the Nexus backend's - is read once.
|
|
293
|
+
if (api.details && backend.listMembership(record, "normal") === undefined)
|
|
294
|
+
void fill(record);
|
|
295
|
+
}
|
|
296
|
+
/** The actions on a row of the tree: Add New under it, and on an active record Edit and Move to trash. */
|
|
297
|
+
function rowActions(item) {
|
|
298
|
+
const list = [];
|
|
299
|
+
if (allowed.create)
|
|
300
|
+
list.push({ id: "create", ...crudActionEntry("create"), inline: true, shortcut: "Insert", onSelect: () => create(item) });
|
|
301
|
+
const record = place.recordOf(item);
|
|
302
|
+
if (!record || modeOf(record) !== "normal")
|
|
303
|
+
return list;
|
|
304
|
+
if (allowed.edit)
|
|
305
|
+
list.push({ id: "edit", ...crudActionEntry("edit"), inline: true, shortcut: "F2", onSelect: () => edit(record) });
|
|
306
|
+
const out = trashless ? "delete" : "trash";
|
|
307
|
+
if (allowed[out])
|
|
308
|
+
list.push({ id: out, ...crudActionEntry(out), inline: true, shortcut: "Delete", onSelect: () => run(out, record) });
|
|
309
|
+
return list;
|
|
310
|
+
}
|
|
311
|
+
// The panel's first button takes focus after an action pressed there took its own away (Move to trash became Recover).
|
|
312
|
+
useLayoutEffect(() => {
|
|
313
|
+
if (!refocusRef.current)
|
|
314
|
+
return;
|
|
315
|
+
refocusRef.current = false;
|
|
316
|
+
actionsRef.current?.querySelector("button:not(:disabled)")?.focus();
|
|
317
|
+
}, [stamp, busy]);
|
|
318
|
+
useImperativeHandle(ref, () => ({
|
|
319
|
+
get tree() {
|
|
320
|
+
return treeRef.current;
|
|
321
|
+
},
|
|
322
|
+
create: (parent, values) => create(parent ?? null, values),
|
|
323
|
+
edit,
|
|
324
|
+
pick: show,
|
|
325
|
+
}));
|
|
326
|
+
let body = null;
|
|
327
|
+
let actions = null;
|
|
328
|
+
if (picked) {
|
|
329
|
+
const mode = modeOf(picked);
|
|
330
|
+
const offered = crudRowActions(mode, action => Boolean(allowed[action]), trashless);
|
|
331
|
+
const ids = [];
|
|
332
|
+
if (mode === "normal" && allowed.create)
|
|
333
|
+
ids.push("create");
|
|
334
|
+
for (const action of PANEL_ORDER)
|
|
335
|
+
if (offered.includes(action))
|
|
336
|
+
ids.push(action);
|
|
337
|
+
const record = picked;
|
|
338
|
+
const label = labelOf(record);
|
|
339
|
+
body = _jsx(CrudDetails, { row: record, columns: columns }, `${keyOf(record)}:${stamp}`);
|
|
340
|
+
actions =
|
|
341
|
+
ids.length > 0 ? (_jsx("div", { ref: actionsRef, className: "nx-crud-tree__actions", role: "group", "aria-label": `${capitalize(subject)}: ${label}`, children: ids.map(id => {
|
|
342
|
+
const entry = crudActionEntry(id);
|
|
343
|
+
const onClick = id === "create"
|
|
344
|
+
? () => create(place.itemOf(record))
|
|
345
|
+
: id === "edit"
|
|
346
|
+
? () => edit(record)
|
|
347
|
+
: id === "history"
|
|
348
|
+
? () => openHistory(record)
|
|
349
|
+
: () => void run(id, record);
|
|
350
|
+
return (_jsx(Button, { size: "sm", icon: entry.icon, hoverTone: entry.tone, disabled: busy, "data-action": id, title: id === "create" ? `${entry.label}: inside ${label}` : undefined, onClick: onClick, children: entry.label }, id));
|
|
351
|
+
}) })) : null;
|
|
352
|
+
}
|
|
353
|
+
// The toolbar's Add New: at the top level, or - with place keys - inside the selected row, and saying why not without one.
|
|
354
|
+
let add;
|
|
355
|
+
if (allowed.create && !placeKeys)
|
|
356
|
+
add = { label: CRUD_ACTION_WORDS.create.label, onClick: () => create(null) };
|
|
357
|
+
else if (allowed.create) {
|
|
358
|
+
const target = addTarget();
|
|
359
|
+
const description = target ? `Adds a new ${subject} inside ${itemLabel(target)}.` : `Select a row in the tree to add a new ${subject} inside it.`;
|
|
360
|
+
add = { label: CRUD_ACTION_WORDS.create.label, onClick: () => create(null), disabled: !target, description };
|
|
361
|
+
}
|
|
362
|
+
return (_jsxs(_Fragment, { children: [_jsx(TreeView, { ...treeProps, ref: treeRef, valueKey: valueKey, hasChildrenKey: place.rules.hasChildrenKey, cacheKey: place.cacheKey, rowActions: rowActions, add: add, selectedValue: selected ? selected[valueKey] : null, onItemClick: pick }), props.details?.({ row: picked, body, actions })] }));
|
|
363
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type CrudRecordRead } from "../../Helpers/CrudDetailsTabs.ts";
|
|
3
|
+
import type { FormStore } from "../../Helpers/FormStore.ts";
|
|
4
|
+
import type { CrudDetailsOptions, CrudDetailsProfile } from "../../Interfaces/CrudInterfaces.ts";
|
|
5
|
+
import type { FormValues } from "../../Interfaces/FormInterfaces.ts";
|
|
6
|
+
import type { TableColumn } from "../../Interfaces/TableInterfaces.ts";
|
|
7
|
+
import type { PopupHandle } from "../Layouts/PopupHost.tsx";
|
|
8
|
+
import { type FormLeaveGuard } from "./FormLeaveGuard.ts";
|
|
9
|
+
export interface CrudWorkspaceProps<TRow extends FormValues = FormValues> {
|
|
10
|
+
/** The page's details options: `tabs`, `profile`, `remember`. */
|
|
11
|
+
options: CrudDetailsOptions<TRow>;
|
|
12
|
+
/** The record: the row at once, then the details endpoint's record, then each refresh's. */
|
|
13
|
+
form: FormStore<TRow>;
|
|
14
|
+
/** The row as the list has it now. */
|
|
15
|
+
current: () => TRow;
|
|
16
|
+
popup: PopupHandle;
|
|
17
|
+
/** The details' fields (`columns`, else the table's): what a string in the profile's `fields` names. */
|
|
18
|
+
columns: readonly TableColumn<TRow>[];
|
|
19
|
+
/** The currency of a `money` field without its own, as the table writes it. */
|
|
20
|
+
currency?: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* The built-in details: the fields, with the `aside` beside them, less the fields the profile shows (`fields`), given
|
|
23
|
+
* the workspace's `refresh` (a section's own Edit reads the record again with it once saved).
|
|
24
|
+
*/
|
|
25
|
+
details: (without: readonly (TableColumn<TRow> | string)[] | undefined, refresh: () => Promise<TRow | null>) => ReactNode;
|
|
26
|
+
/** Starts reading the record from the details endpoint, once: called when the workspace is first drawn. */
|
|
27
|
+
load?: () => void;
|
|
28
|
+
/**
|
|
29
|
+
* How that read is coming along (the footer's own state): a profile field the row lacks is held while it is read and
|
|
30
|
+
* says "Not loaded" when it failed. Without it the record counts as read.
|
|
31
|
+
*/
|
|
32
|
+
read?: {
|
|
33
|
+
get: () => CrudRecordRead;
|
|
34
|
+
subscribe: (listener: () => void) => () => void;
|
|
35
|
+
};
|
|
36
|
+
refresh: () => Promise<TRow | null>;
|
|
37
|
+
/** What the tabs are named for screen readers: "Guest". */
|
|
38
|
+
subject: string;
|
|
39
|
+
/**
|
|
40
|
+
* The forms the tabs draw (every `SubmitForm` and `ApiForm` joins it): a switch to another tab, and the popup's
|
|
41
|
+
* closing, ask "Discard your changes?" while one has changes.
|
|
42
|
+
*/
|
|
43
|
+
guard: FormLeaveGuard;
|
|
44
|
+
/**
|
|
45
|
+
* Told the chosen tab when the workspace opens and on every switch: the Crud shows the popup's footer (View history,
|
|
46
|
+
* Edit, Close) only while the built-in Details tab is chosen.
|
|
47
|
+
*/
|
|
48
|
+
onTab?: (tab: string) => void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Asks "Discard your changes?" when a form in a place that is about to go away (a `FormLeaveGuard`: a tab of a record's
|
|
52
|
+
* workspace, its popup) has changes. Resolves `true` to go on - nothing changed, or the user chose Discard, which drops
|
|
53
|
+
* the form's draft - and `false` to stay.
|
|
54
|
+
*/
|
|
55
|
+
export declare function confirmLeaving(guard: FormLeaveGuard): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* A record's picture where it is shown large (a workspace's profile, a record page's hero): by its Drive params, a button
|
|
58
|
+
* that opens the pictures window, the record's initials standing in; or what the page draws in its place. `size` is a
|
|
59
|
+
* CSS length. Read-only shows the pictures with no way to change them. `round` draws it as a circle whatever the
|
|
60
|
+
* picture's own shape (a workspace's profile); the pictures window keeps the picture's shape and aspect.
|
|
61
|
+
*/
|
|
62
|
+
export declare function ProfilePicture({ picture, title, size, readOnly, round }: {
|
|
63
|
+
picture: CrudDetailsProfile["picture"];
|
|
64
|
+
title: ReactNode;
|
|
65
|
+
size: string;
|
|
66
|
+
readOnly?: boolean;
|
|
67
|
+
round?: boolean;
|
|
68
|
+
}): ReactNode;
|
|
69
|
+
/**
|
|
70
|
+
* The workspace: profile and tabs on the left, the chosen tab on the right. Only the chosen tab is drawn; a switch
|
|
71
|
+
* keeps the focus on the tabs, or puts it on the chosen tab when the panel that held it went away.
|
|
72
|
+
*/
|
|
73
|
+
export declare function CrudWorkspace<TRow extends FormValues>({ options, form, current, popup, columns, currency, details, load, read, refresh, subject, guard, onTab }: CrudWorkspaceProps<TRow>): import("react").JSX.Element;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { iconInfoCircle, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
|
+
import { CRUD_DETAILS_TAB, crudProfileFields, crudTabStorageKey, isAwaitedCrudField, isCrudPicture, isCrudProfile, offeredCrudTabs, openingCrudTab, readCrudTab, writeCrudTab } from "../../Helpers/CrudDetailsTabs.js";
|
|
7
|
+
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
8
|
+
import { getPermissions, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
|
|
9
|
+
import { useNumberLocale } from "../../Helpers/UserPreferences.js";
|
|
10
|
+
import { TabPanel, Tabs } from "../Documents/Tabs.js";
|
|
11
|
+
import { TableCellValue } from "../Viewers/DataTableParts.js";
|
|
12
|
+
import { openPictureGallery, OwnerPicture } from "../Viewers/OwnerFiles.js";
|
|
13
|
+
import { FormLeaveGuardContext } from "./FormLeaveGuard.js";
|
|
14
|
+
import { confirmDiscard } from "./SubmitForm.js";
|
|
15
|
+
/*
|
|
16
|
+
* A record's workspace in its details popup (`CrudDetailsOptions.tabs` and `profile`): who the record is and its tabs
|
|
17
|
+
* in a column on the left, the chosen tab on the right. The Crud owns the record (the FormStore), its loading and the
|
|
18
|
+
* lists a `refresh()` writes into; this draws them and keeps which tab is chosen.
|
|
19
|
+
*/
|
|
20
|
+
const subscribeNothing = () => () => { };
|
|
21
|
+
const readDone = () => "done";
|
|
22
|
+
/** This browser's local storage, or nothing where it cannot be reached (a server draw, storage switched off). */
|
|
23
|
+
function browserStorage() {
|
|
24
|
+
try {
|
|
25
|
+
return typeof window === "undefined" ? null : window.localStorage;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Asks "Discard your changes?" when a form in a place that is about to go away (a `FormLeaveGuard`: a tab of a record's
|
|
33
|
+
* workspace, its popup) has changes. Resolves `true` to go on - nothing changed, or the user chose Discard, which drops
|
|
34
|
+
* the form's draft - and `false` to stay.
|
|
35
|
+
*/
|
|
36
|
+
export async function confirmLeaving(guard) {
|
|
37
|
+
const form = guard.changed();
|
|
38
|
+
if (!form)
|
|
39
|
+
return true;
|
|
40
|
+
if (!(await confirmDiscard(form)))
|
|
41
|
+
return false;
|
|
42
|
+
form.dropDraft();
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A record's picture where it is shown large (a workspace's profile, a record page's hero): by its Drive params, a button
|
|
47
|
+
* that opens the pictures window, the record's initials standing in; or what the page draws in its place. `size` is a
|
|
48
|
+
* CSS length. Read-only shows the pictures with no way to change them. `round` draws it as a circle whatever the
|
|
49
|
+
* picture's own shape (a workspace's profile); the pictures window keeps the picture's shape and aspect.
|
|
50
|
+
*/
|
|
51
|
+
export function ProfilePicture({ picture, title, size, readOnly = false, round = false }) {
|
|
52
|
+
if (!isCrudPicture(picture))
|
|
53
|
+
return picture ?? null;
|
|
54
|
+
const named = typeof title === "string" ? title : undefined;
|
|
55
|
+
const { name, module, category, ownerId, aspect, shape, ...gallery } = picture;
|
|
56
|
+
const owner = { module, category, ownerId };
|
|
57
|
+
return (_jsx(OwnerPicture, { owner: owner, size: size, aspect: round ? 1 : aspect, shape: round ? "circle" : shape, name: name ?? named ?? null, label: named ? `${named}: pictures` : "Pictures", onClick: () => openPictureGallery({ ...gallery, ...owner, aspect, shape, title: gallery.title ?? named, readOnly: gallery.readOnly || readOnly }) }));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A few of the record's fields under the profile's tags (`profile.fields`): label and value, the value drawn as the
|
|
61
|
+
* table and the details draw it (the person's date and number formats, an option's tone, the column's tag after it).
|
|
62
|
+
*/
|
|
63
|
+
function CrudProfileFields({ fields, row, currency, read }) {
|
|
64
|
+
const preferences = useDatePreferences();
|
|
65
|
+
const timeZone = useBrowserTimeZone();
|
|
66
|
+
const locale = useNumberLocale();
|
|
67
|
+
const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined, locale, currency }), [preferences, timeZone, locale, currency]);
|
|
68
|
+
const awaiting = fields.some(column => isAwaitedCrudField(column, row, read));
|
|
69
|
+
return (_jsx("dl", { className: "nx-crud-profile__fields", "aria-busy": (awaiting && read === "loading") || undefined, children: fields.map(column => (_jsxs("div", { className: "nx-crud-profile__field", children: [_jsx("dt", { className: "nx-crud-profile__label", children: column.label }), " ", _jsx("dd", { className: "nx-crud-profile__value", children: !isAwaitedCrudField(column, row, read) ? (_jsx(TableCellValue, { column: column, row: row, options: options })) : read === "failed" ? (_jsx("span", { className: "nx-crud-details__missing", children: "Not loaded" })) : (_jsx("span", { className: "nx-table__bone nx-record__bone nx-crud-profile__bone", "aria-hidden": "true" })) })] }, column.key))) }));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The profile at the top of the column: the picture in a circle (a button that opens the pictures), the name, a line,
|
|
73
|
+
* tags, and a few of the record's fields. A profile of a picture and a name alone is those two, in the middle.
|
|
74
|
+
*/
|
|
75
|
+
function CrudProfile({ profile, row, columns, currency, read }) {
|
|
76
|
+
const { picture, title, subtitle, tags } = profile;
|
|
77
|
+
const fields = crudProfileFields(profile.fields, columns, row, read);
|
|
78
|
+
return (_jsxs("div", { className: "nx-crud-profile", children: [picture !== undefined && picture !== null && picture !== false && (_jsx("div", { className: "nx-crud-profile__picture", children: _jsx(ProfilePicture, { picture: picture, title: title, size: "var(--nx-crud-profile-picture)", round: true }) })), _jsxs("div", { className: "nx-crud-profile__text", children: [_jsx("p", { className: "nx-crud-profile__title", children: title }), subtitle !== undefined && subtitle !== null && subtitle !== "" && _jsx("p", { className: "nx-crud-profile__subtitle", children: subtitle }), tags && tags.length > 0 && (_jsx("p", { className: "nx-crud-profile__tags", children: tags.map((tag, index) => (_jsx("span", { className: "nx-table__tag", "data-tone": tag.tone, children: tag.label }, index))) })), fields.length > 0 && _jsx(CrudProfileFields, { fields: fields, row: row, currency: currency, read: read })] })] }));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The workspace: profile and tabs on the left, the chosen tab on the right. Only the chosen tab is drawn; a switch
|
|
82
|
+
* keeps the focus on the tabs, or puts it on the chosen tab when the panel that held it went away.
|
|
83
|
+
*/
|
|
84
|
+
export function CrudWorkspace({ options, form, current, popup, columns, currency, details, load, read, refresh, subject, guard, onTab }) {
|
|
85
|
+
const id = useId();
|
|
86
|
+
const recordRead = useSyncExternalStore(read?.subscribe ?? subscribeNothing, read?.get ?? readDone, read?.get ?? readDone);
|
|
87
|
+
// The record as it is now: the profile and the tabs' rules follow it.
|
|
88
|
+
const record = useSyncExternalStore(form.subscribeValue, () => form.getValue(), () => form.getValue());
|
|
89
|
+
useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
|
|
90
|
+
const permissions = getPermissions();
|
|
91
|
+
const can = permissions.ready ? permissions.can.bind(permissions) : () => false;
|
|
92
|
+
const offered = options.tabs ? offeredCrudTabs(options.tabs, record, can) : [];
|
|
93
|
+
const storage = useMemo(() => crudTabStorageKey(options.remember), [options.remember?.key, options.remember?.viewer]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
94
|
+
const [asked, setAsked] = useState(() => readCrudTab(browserStorage(), storage));
|
|
95
|
+
const chosen = openingCrudTab(offered, asked) ?? CRUD_DETAILS_TAB;
|
|
96
|
+
// Bumped by each refresh, so the built-in details are drawn again from the record read again (in place: what has the
|
|
97
|
+
// focus keeps it).
|
|
98
|
+
const [, setVersion] = useState(0);
|
|
99
|
+
const rootRef = useRef(null);
|
|
100
|
+
// Set when a switch took the panel that held the focus: the chosen tab takes it once drawn.
|
|
101
|
+
const refocus = useRef(false);
|
|
102
|
+
// A switch takes the chosen tab away: a form in it with changes asks first. One question at a time.
|
|
103
|
+
const asking = useRef(false);
|
|
104
|
+
async function select(tab) {
|
|
105
|
+
if (!offered.some(one => one.id === tab) || tab === chosen || asking.current)
|
|
106
|
+
return;
|
|
107
|
+
if (guard.changed()) {
|
|
108
|
+
asking.current = true;
|
|
109
|
+
const leave = await confirmLeaving(guard).finally(() => (asking.current = false));
|
|
110
|
+
if (!leave)
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const active = document.activeElement;
|
|
114
|
+
const panel = rootRef.current?.querySelector(".nx-crud-workspace__panel");
|
|
115
|
+
// The focus in the panel, or in the popup's footer, which only the Details tab has: the chosen tab takes it.
|
|
116
|
+
refocus.current = !active || active === document.body || Boolean(panel?.contains(active)) || Boolean(active.closest(".nx-popup__foot"));
|
|
117
|
+
setAsked(tab);
|
|
118
|
+
writeCrudTab(browserStorage(), storage, tab);
|
|
119
|
+
}
|
|
120
|
+
// Before the paint, so the footer of a tab that does not have one is never drawn.
|
|
121
|
+
useLayoutEffect(() => {
|
|
122
|
+
onTab?.(chosen);
|
|
123
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
124
|
+
}, [chosen]);
|
|
125
|
+
useLayoutEffect(() => {
|
|
126
|
+
if (!refocus.current)
|
|
127
|
+
return;
|
|
128
|
+
refocus.current = false;
|
|
129
|
+
rootRef.current?.querySelector('[role="tab"][aria-selected="true"]')?.focus();
|
|
130
|
+
}, [chosen]);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
load?.();
|
|
133
|
+
// The record is read once per popup.
|
|
134
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
135
|
+
}, []);
|
|
136
|
+
// A panel that went away under the focus (a tab hidden by a refresh) leaves it on the chosen tab, never on the page.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const root = rootRef.current;
|
|
139
|
+
if (!root)
|
|
140
|
+
return;
|
|
141
|
+
const onOut = (event) => {
|
|
142
|
+
if (event.relatedTarget)
|
|
143
|
+
return;
|
|
144
|
+
window.requestAnimationFrame(() => {
|
|
145
|
+
if (document.activeElement === document.body && root.isConnected)
|
|
146
|
+
root.querySelector('[role="tab"][aria-selected="true"], .nx-crud-workspace__panel')?.focus();
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
root.addEventListener("focusout", onOut);
|
|
150
|
+
return () => root.removeEventListener("focusout", onOut);
|
|
151
|
+
}, []);
|
|
152
|
+
async function refreshed() {
|
|
153
|
+
const next = await refresh();
|
|
154
|
+
setVersion(stamp => stamp + 1);
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
const given = options.profile?.(record);
|
|
158
|
+
let profile;
|
|
159
|
+
// The fields the profile shows: the Details tab leaves them out.
|
|
160
|
+
let profileFields;
|
|
161
|
+
if (isCrudProfile(given)) {
|
|
162
|
+
profile = _jsx(CrudProfile, { profile: given, row: record, columns: columns, currency: currency, read: recordRead });
|
|
163
|
+
profileFields = given.fields;
|
|
164
|
+
}
|
|
165
|
+
else
|
|
166
|
+
profile = given;
|
|
167
|
+
const tab = offered.find(one => one.id === chosen);
|
|
168
|
+
const row = current();
|
|
169
|
+
let body;
|
|
170
|
+
if (!tab || tab.builtIn)
|
|
171
|
+
body = _jsx("div", { children: details(profileFields, refreshed) });
|
|
172
|
+
else
|
|
173
|
+
body = tab.render?.({ row, form, popup, tab: tab.id, select: next => void select(next), refresh: refreshed });
|
|
174
|
+
const shownProfile = profile !== undefined && profile !== null && profile !== false;
|
|
175
|
+
const tabbed = offered.length > 1;
|
|
176
|
+
return (_jsx(FormLeaveGuardContext, { value: guard, children: _jsx("div", { ref: rootRef, className: "nx-crud-workspace", children: _jsxs("div", { className: cx("nx-crud-workspace__frame", !shownProfile && !tabbed && "nx-crud-workspace__frame--alone"), children: [(shownProfile || tabbed) && (_jsxs("div", { className: "nx-crud-workspace__side", children: [shownProfile && _jsx("div", { className: "nx-crud-workspace__profile", children: profile }), tabbed && (_jsx("div", { className: "nx-crud-workspace__nav", children: _jsx(Tabs, { id: id, design: "auto", "aria-label": `${subject} tabs`, value: chosen, onChange: next => void select(next), options: offered.map(one => ({ value: one.id, label: one.label, icon: one.icon ?? (one.builtIn ? _jsx(InlineIcon, { icon: iconInfoCircle }) : undefined), badge: typeof one.badge === "function" ? one.badge(row) : one.badge })) }) }))] })), tabbed ? (_jsx(TabPanel, { tabs: id, value: chosen, className: "nx-crud-workspace__panel", children: body })) : (_jsx("div", { className: "nx-crud-workspace__panel", children: body }))] }) }) }));
|
|
177
|
+
}
|
|
@@ -277,9 +277,9 @@ export function openExcelImport({ title, popupKey, ...props }) {
|
|
|
277
277
|
const bulk = props.mode === "bulk";
|
|
278
278
|
// A key of its own for a bulk insert, so opening one never closes an import whose review is half gone over.
|
|
279
279
|
const key = popupKey ?? `${bulk ? "nx-excel-bulk" : "nx-excel"}:${String(props.endpoints.import.module)}:${props.endpoints.import.path}`;
|
|
280
|
-
// Picked again while its window is still open - minimized
|
|
281
|
-
// back as it was, as a CRUD's Add form with changes
|
|
282
|
-
// replaced by a fresh panel.
|
|
280
|
+
// Picked again while its window is still open - minimized by a page's rule (it has no minimize button of its own), or
|
|
281
|
+
// from code - a window holding rows typed or changed in it comes back as it was, as a CRUD's Add form with changes
|
|
282
|
+
// does; an untouched one, or one showing an import's report, is replaced by a fresh panel.
|
|
283
283
|
const open = openPanels.get(key);
|
|
284
284
|
if (open && (open.panel.current?.unsavedRows() ?? 0) > 0) {
|
|
285
285
|
open.handle.restore();
|