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,594 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { iconCheck, iconChevronDown, iconGripVertical, iconLayoutGrid, iconPencil, iconPlus, iconRefresh, iconX, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { addableWidgets, createWidgetBatcher, drawableWidgets, drawnItems, mergeHidden, moveItem, neighbourInColumn, normaliseLayout, sameLayout, WIDGET_SIZES, WIDGET_SPANS, widgetAtPoint, widgetDataEmpty, widgetNeedsData, widgetSizeLabel, } from "../../Helpers/DashboardHelpers.js";
|
|
6
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
|
+
import { Button } from "../Documents/Button.js";
|
|
8
|
+
import { Menu } from "../Documents/Menu.js";
|
|
9
|
+
import { SplitButton } from "../Documents/SplitButton.js";
|
|
10
|
+
import { Popup } from "../Layouts/Popup.js";
|
|
11
|
+
import { WidgetFrame, WidgetNavigateContext } from "./WidgetFrame.js";
|
|
12
|
+
const DRAG_THRESHOLD = 4;
|
|
13
|
+
// The board looks for widgets whose refresh time has passed this often, while the page is in view.
|
|
14
|
+
const REFRESH_TICK = 5000;
|
|
15
|
+
const moduleName = (module) => module.charAt(0).toUpperCase() + module.slice(1);
|
|
16
|
+
const errorText = (error) => (error instanceof Error && error.message ? error.message : typeof error === "string" && error ? error : "This could not be read.");
|
|
17
|
+
/**
|
|
18
|
+
* A dashboard of widgets on a 12-column grid, for one place (the Home, a module's dashboard). Each widget takes a size
|
|
19
|
+
* preset (S 3×1, M 6×2, L 6×4, W 12×2, T 12×4), the grid fills every gap (`grid-auto-flow: dense`), and a layout is
|
|
20
|
+
* an ordered list of `{ code, size }`; on a phone every widget is the full width, in order. Widgets load only when near
|
|
21
|
+
* the screen, each module asked once per draw (`loadModule`), kept for the page's life or their `refresh` seconds.
|
|
22
|
+
*
|
|
23
|
+
* Customise turns on edit mode: move by dragging a widget's handle or with the arrow keys on a focused widget, Enter or
|
|
24
|
+
* Space for its size menu (only its sizes), Delete to remove it; Add widget opens a drawer of the widgets this place
|
|
25
|
+
* may hold, by module; Save, Cancel, Reset to default. A save another window beat (`stale`) shows the layout saved
|
|
26
|
+
* there and says so.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* <DashboardBoard place="home" widgets={WIDGETS} canSee={canSee} layout={saved?.items} version={saved?.version}
|
|
30
|
+
* defaultLayout={HOME_DEFAULT} loadModule={loadWidgets} onSave={saveLayout} onReset={resetLayout} />
|
|
31
|
+
*/
|
|
32
|
+
export function DashboardBoard({ place, placeModule = place, widgets, canSee, layout, version = null, defaultLayout, onSave, onReset, canCustomise = true, canSaveCompanyDefault = false, onSaveCompanyDefault, loadModule, initialData, moduleLabel = moduleName, onNavigate, title, actions, loadMargin = "200px", className, }) {
|
|
33
|
+
const drawable = useMemo(() => drawableWidgets(widgets, placeModule, canSee), [widgets, placeModule, canSee]);
|
|
34
|
+
const toLayout = useCallback((items) => normaliseLayout(items, widgets), [widgets]);
|
|
35
|
+
// What is saved: the person's layout, else the default. It follows the props when the app gives a new one.
|
|
36
|
+
const [saved, setSaved] = useState(() => ({
|
|
37
|
+
layout: toLayout(layout ?? defaultLayout),
|
|
38
|
+
version,
|
|
39
|
+
own: Boolean(layout),
|
|
40
|
+
}));
|
|
41
|
+
const propsKey = useRef({ layout, version, defaultLayout });
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const last = propsKey.current;
|
|
44
|
+
if (last.layout === layout && last.version === version && last.defaultLayout === defaultLayout)
|
|
45
|
+
return;
|
|
46
|
+
propsKey.current = { layout, version, defaultLayout };
|
|
47
|
+
setSaved({
|
|
48
|
+
layout: toLayout(layout ?? defaultLayout),
|
|
49
|
+
version,
|
|
50
|
+
own: Boolean(layout),
|
|
51
|
+
});
|
|
52
|
+
}, [layout, version, defaultLayout, toLayout]);
|
|
53
|
+
// Edit mode: the drawn items being edited, and whether Reset was pressed since.
|
|
54
|
+
const [draft, setDraft] = useState(null);
|
|
55
|
+
const [resetting, setResetting] = useState(false);
|
|
56
|
+
const [busy, setBusy] = useState(false);
|
|
57
|
+
const [notice, setNotice] = useState(null);
|
|
58
|
+
const [announce, setAnnounce] = useState("");
|
|
59
|
+
const [adding, setAdding] = useState(false);
|
|
60
|
+
const addedRef = useRef(null);
|
|
61
|
+
const editing = draft !== null;
|
|
62
|
+
const items = editing ? draft : drawnItems(saved.layout, drawable);
|
|
63
|
+
/* Data */
|
|
64
|
+
const loadModuleRef = useRef(loadModule);
|
|
65
|
+
loadModuleRef.current = loadModule;
|
|
66
|
+
const batcher = useMemo(() => {
|
|
67
|
+
const made = createWidgetBatcher((module, codes) => {
|
|
68
|
+
const load = loadModuleRef.current;
|
|
69
|
+
if (!load)
|
|
70
|
+
return Promise.reject(new Error("This board has no loadModule."));
|
|
71
|
+
return load(module, codes);
|
|
72
|
+
});
|
|
73
|
+
for (const [code, data] of Object.entries(initialData ?? {}))
|
|
74
|
+
made.prime(code, data);
|
|
75
|
+
return made;
|
|
76
|
+
// The cache lives as long as the board: initialData is read once.
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, []);
|
|
79
|
+
const [states, setStates] = useState({});
|
|
80
|
+
const statesRef = useRef(states);
|
|
81
|
+
statesRef.current = states;
|
|
82
|
+
const drawableRef = useRef(drawable);
|
|
83
|
+
drawableRef.current = drawable;
|
|
84
|
+
const itemsRef = useRef(items);
|
|
85
|
+
itemsRef.current = items;
|
|
86
|
+
// Widgets near the screen now, kept by the one IntersectionObserver.
|
|
87
|
+
const nearRef = useRef(new Set());
|
|
88
|
+
const setState = useCallback((code, next) => {
|
|
89
|
+
setStates(previous => ({
|
|
90
|
+
...previous,
|
|
91
|
+
[code]: typeof next === "function" ? next(previous[code]) : next,
|
|
92
|
+
}));
|
|
93
|
+
}, []);
|
|
94
|
+
/** The state a widget is drawn in: its own, else ready from data kept (read with the page), else waiting. */
|
|
95
|
+
const stateOf = useCallback((code) => {
|
|
96
|
+
const own = states[code];
|
|
97
|
+
if (own)
|
|
98
|
+
return own;
|
|
99
|
+
const kept = batcher.cached(code);
|
|
100
|
+
return kept ? { status: "ready", data: kept.data, at: kept.at } : { status: "idle" };
|
|
101
|
+
}, [states, batcher]);
|
|
102
|
+
const load = useCallback((code, options = {}) => {
|
|
103
|
+
const widget = drawableRef.current.get(code);
|
|
104
|
+
if (!widget || !widgetNeedsData(widget))
|
|
105
|
+
return;
|
|
106
|
+
const size = itemsRef.current.find(item => item.code === code)?.size ?? widget.sizes[0];
|
|
107
|
+
const had = statesRef.current[code] ??
|
|
108
|
+
(batcher.cached(code)
|
|
109
|
+
? {
|
|
110
|
+
status: "ready",
|
|
111
|
+
data: batcher.cached(code).data,
|
|
112
|
+
at: batcher.cached(code).at,
|
|
113
|
+
}
|
|
114
|
+
: undefined);
|
|
115
|
+
if (!options.force && batcher.isFresh(widget)) {
|
|
116
|
+
const kept = batcher.cached(code);
|
|
117
|
+
if (had?.status !== "ready" || had.at !== kept.at || had.stale)
|
|
118
|
+
setState(code, { status: "ready", data: kept.data, at: kept.at });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const showing = had?.status === "ready";
|
|
122
|
+
setState(code, showing ? { ...had, refreshing: true, stale: false } : { status: "loading" });
|
|
123
|
+
batcher.request(widget, { place, size }, { force: options.force }).then(data => setState(code, {
|
|
124
|
+
status: "ready",
|
|
125
|
+
data,
|
|
126
|
+
at: batcher.cached(code)?.at ?? Date.now(),
|
|
127
|
+
}), error => setState(code, previous => (options.background && previous?.status === "ready" ? { ...previous, refreshing: false } : { status: "failed", message: errorText(error) })));
|
|
128
|
+
}, [batcher, place, setState]);
|
|
129
|
+
// One IntersectionObserver for the board: a widget loads when it comes near the screen.
|
|
130
|
+
const elementsRef = useRef(new Map());
|
|
131
|
+
const observerRef = useRef(null);
|
|
132
|
+
const loadRef = useRef(load);
|
|
133
|
+
loadRef.current = load;
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
136
|
+
// No observer (an old browser, a test page): everything counts as near.
|
|
137
|
+
for (const code of elementsRef.current.keys()) {
|
|
138
|
+
nearRef.current.add(code);
|
|
139
|
+
loadRef.current(code);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const observer = new IntersectionObserver(entries => {
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const code = entry.target.dataset.code;
|
|
146
|
+
if (!code)
|
|
147
|
+
continue;
|
|
148
|
+
if (!entry.isIntersecting) {
|
|
149
|
+
nearRef.current.delete(code);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
nearRef.current.add(code);
|
|
153
|
+
const state = statesRef.current[code];
|
|
154
|
+
if (!state || state.status === "idle" || state.stale)
|
|
155
|
+
loadRef.current(code);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
// scrollMargin widens the scrollers between (an admin shell's page area), where rootMargin reaches only the window.
|
|
159
|
+
{
|
|
160
|
+
rootMargin: `${loadMargin} 0px`,
|
|
161
|
+
scrollMargin: loadMargin,
|
|
162
|
+
});
|
|
163
|
+
observerRef.current = observer;
|
|
164
|
+
for (const element of elementsRef.current.values())
|
|
165
|
+
observer.observe(element);
|
|
166
|
+
return () => {
|
|
167
|
+
observer.disconnect();
|
|
168
|
+
observerRef.current = null;
|
|
169
|
+
};
|
|
170
|
+
}, [loadMargin]);
|
|
171
|
+
const refCallbacks = useRef(new Map());
|
|
172
|
+
const refFor = (code) => {
|
|
173
|
+
let callback = refCallbacks.current.get(code);
|
|
174
|
+
if (!callback) {
|
|
175
|
+
callback = element => {
|
|
176
|
+
const before = elementsRef.current.get(code);
|
|
177
|
+
if (before && before !== element) {
|
|
178
|
+
observerRef.current?.unobserve(before);
|
|
179
|
+
elementsRef.current.delete(code);
|
|
180
|
+
nearRef.current.delete(code);
|
|
181
|
+
}
|
|
182
|
+
if (element) {
|
|
183
|
+
elementsRef.current.set(code, element);
|
|
184
|
+
observerRef.current?.observe(element);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
refCallbacks.current.set(code, callback);
|
|
188
|
+
}
|
|
189
|
+
return callback;
|
|
190
|
+
};
|
|
191
|
+
// A widget whose refresh time has passed is read again while it is near the screen and the page is in view.
|
|
192
|
+
const anyRefresh = items.some(item => (drawable.get(item.code)?.refresh ?? 0) > 0);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!anyRefresh)
|
|
195
|
+
return;
|
|
196
|
+
const timer = window.setInterval(() => {
|
|
197
|
+
if (document.visibilityState === "hidden")
|
|
198
|
+
return;
|
|
199
|
+
for (const code of nearRef.current) {
|
|
200
|
+
const widget = drawableRef.current.get(code);
|
|
201
|
+
const state = statesRef.current[code];
|
|
202
|
+
if (!widget?.refresh || state?.status !== "ready" || state.refreshing || batcher.isFresh(widget))
|
|
203
|
+
continue;
|
|
204
|
+
loadRef.current(code, { background: true });
|
|
205
|
+
}
|
|
206
|
+
}, REFRESH_TICK);
|
|
207
|
+
return () => window.clearInterval(timer);
|
|
208
|
+
}, [anyRefresh, batcher]);
|
|
209
|
+
/** The board's Refresh: everything near the screen read again now, the rest when it comes near. */
|
|
210
|
+
function refreshAll() {
|
|
211
|
+
batcher.clear();
|
|
212
|
+
setStates(previous => {
|
|
213
|
+
const next = { ...previous };
|
|
214
|
+
for (const [code, state] of Object.entries(previous))
|
|
215
|
+
if (!nearRef.current.has(code) && state.status === "ready")
|
|
216
|
+
next[code] = { ...state, stale: true };
|
|
217
|
+
return next;
|
|
218
|
+
});
|
|
219
|
+
for (const code of nearRef.current)
|
|
220
|
+
load(code, { force: true });
|
|
221
|
+
setAnnounce("Refreshing the dashboard.");
|
|
222
|
+
}
|
|
223
|
+
/* Editing */
|
|
224
|
+
function startEditing() {
|
|
225
|
+
setDraft(drawnItems(saved.layout, drawable));
|
|
226
|
+
setResetting(false);
|
|
227
|
+
setNotice(null);
|
|
228
|
+
setAnnounce("Customising: drag a widget's handle, or focus a widget and use the arrow keys. Enter for its size, Delete to remove it.");
|
|
229
|
+
}
|
|
230
|
+
function cancelEditing() {
|
|
231
|
+
setDraft(null);
|
|
232
|
+
setResetting(false);
|
|
233
|
+
setAdding(false);
|
|
234
|
+
setAnnounce("Changes cancelled.");
|
|
235
|
+
}
|
|
236
|
+
function changeDraft(next) {
|
|
237
|
+
setDraft(next);
|
|
238
|
+
setResetting(false);
|
|
239
|
+
}
|
|
240
|
+
function resetDraft() {
|
|
241
|
+
setDraft(drawnItems(toLayout(defaultLayout), drawable));
|
|
242
|
+
setResetting(true);
|
|
243
|
+
setAnnounce("The default layout is shown. Save to keep it.");
|
|
244
|
+
}
|
|
245
|
+
function applyAnswer(result, savedLayout, own) {
|
|
246
|
+
if (result.status === "saved") {
|
|
247
|
+
setSaved({
|
|
248
|
+
layout: toLayout(result.layout ?? savedLayout),
|
|
249
|
+
version: result.version,
|
|
250
|
+
own,
|
|
251
|
+
});
|
|
252
|
+
setNotice(null);
|
|
253
|
+
setAnnounce("Layout saved.");
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (result.status === "stale") {
|
|
257
|
+
setSaved({
|
|
258
|
+
layout: toLayout(result.layout),
|
|
259
|
+
version: result.version,
|
|
260
|
+
own: true,
|
|
261
|
+
});
|
|
262
|
+
setNotice({
|
|
263
|
+
tone: "info",
|
|
264
|
+
text: "Changed in another window: the layout saved there is shown.",
|
|
265
|
+
});
|
|
266
|
+
setAnnounce("Changed in another window: the layout saved there is shown.");
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
setNotice({
|
|
270
|
+
tone: "danger",
|
|
271
|
+
text: result.message || "The layout could not be saved.",
|
|
272
|
+
});
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
async function save() {
|
|
276
|
+
if (!draft || busy)
|
|
277
|
+
return;
|
|
278
|
+
const reset = resetting && sameLayout(draft, drawnItems(toLayout(defaultLayout), drawable));
|
|
279
|
+
const full = reset ? toLayout(defaultLayout) : mergeHidden(saved.layout, draft, drawable);
|
|
280
|
+
setBusy(true);
|
|
281
|
+
try {
|
|
282
|
+
let result;
|
|
283
|
+
if (reset && onReset)
|
|
284
|
+
result = await onReset(saved.version);
|
|
285
|
+
else if (onSave)
|
|
286
|
+
result = await onSave(full, saved.version);
|
|
287
|
+
else
|
|
288
|
+
result = { status: "saved", version: saved.version };
|
|
289
|
+
if (applyAnswer(result, full, !reset || !onReset)) {
|
|
290
|
+
setDraft(null);
|
|
291
|
+
setResetting(false);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
setNotice({
|
|
296
|
+
tone: "danger",
|
|
297
|
+
text: errorText(error) === "This could not be read." ? "The layout could not be saved." : errorText(error),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
setBusy(false);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async function saveCompanyDefault() {
|
|
305
|
+
if (!draft || busy || !onSaveCompanyDefault)
|
|
306
|
+
return;
|
|
307
|
+
setBusy(true);
|
|
308
|
+
try {
|
|
309
|
+
const result = await onSaveCompanyDefault(mergeHidden(saved.layout, draft, drawable));
|
|
310
|
+
if (result && result.status === "failed")
|
|
311
|
+
setNotice({
|
|
312
|
+
tone: "danger",
|
|
313
|
+
text: result.message || "The company's default could not be saved.",
|
|
314
|
+
});
|
|
315
|
+
else {
|
|
316
|
+
setNotice({
|
|
317
|
+
tone: "success",
|
|
318
|
+
text: "Saved as the company's default for this place.",
|
|
319
|
+
});
|
|
320
|
+
setAnnounce("Saved as the company's default for this place.");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
setNotice({ tone: "danger", text: errorText(error) });
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
setBusy(false);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const titleOf = (code) => drawable.get(code)?.title ?? code;
|
|
331
|
+
function moveTo(code, to) {
|
|
332
|
+
if (!draft)
|
|
333
|
+
return;
|
|
334
|
+
const from = draft.findIndex(item => item.code === code);
|
|
335
|
+
if (from < 0 || to === from || to < 0 || to >= draft.length)
|
|
336
|
+
return;
|
|
337
|
+
changeDraft(moveItem(draft, from, to));
|
|
338
|
+
setAnnounce(`${titleOf(code)}: position ${to + 1} of ${draft.length}.`);
|
|
339
|
+
}
|
|
340
|
+
function resize(code, size) {
|
|
341
|
+
if (!draft)
|
|
342
|
+
return;
|
|
343
|
+
changeDraft(draft.map(item => (item.code === code ? { ...item, size } : item)));
|
|
344
|
+
setAnnounce(`${titleOf(code)}: ${widgetSizeLabel(size)}.`);
|
|
345
|
+
}
|
|
346
|
+
function remove(code) {
|
|
347
|
+
if (!draft)
|
|
348
|
+
return;
|
|
349
|
+
const index = draft.findIndex(item => item.code === code);
|
|
350
|
+
const next = draft.filter(item => item.code !== code);
|
|
351
|
+
changeDraft(next);
|
|
352
|
+
setAnnounce(`${titleOf(code)} removed.`);
|
|
353
|
+
// The focus goes to the widget that took its place, else the one before, else Add widget.
|
|
354
|
+
const focusCode = next[Math.min(index, next.length - 1)]?.code;
|
|
355
|
+
requestAnimationFrame(() => {
|
|
356
|
+
const target = focusCode ? elementsRef.current.get(focusCode) : addButtonRef.current;
|
|
357
|
+
target?.focus({ preventScroll: false });
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
function add(widget) {
|
|
361
|
+
if (!draft || draft.some(item => item.code === widget.code))
|
|
362
|
+
return;
|
|
363
|
+
changeDraft([...draft, { code: widget.code, size: widget.sizes[0] }]);
|
|
364
|
+
addedRef.current = widget.code;
|
|
365
|
+
setAnnounce(`${widget.title} added at the end.`);
|
|
366
|
+
}
|
|
367
|
+
function closeAdding() {
|
|
368
|
+
setAdding(false);
|
|
369
|
+
}
|
|
370
|
+
// Once the Add drawer has gone (after its fade, when animations are on), the widget added last is shown, with the
|
|
371
|
+
// focus on it; before that the page behind the drawer cannot take the focus.
|
|
372
|
+
function afterAdding() {
|
|
373
|
+
const code = addedRef.current;
|
|
374
|
+
addedRef.current = null;
|
|
375
|
+
const element = code ? elementsRef.current.get(code) : null;
|
|
376
|
+
if (!element)
|
|
377
|
+
return;
|
|
378
|
+
element.scrollIntoView({ block: "nearest" });
|
|
379
|
+
element.focus({ preventScroll: true });
|
|
380
|
+
}
|
|
381
|
+
/** The boxes of the drawn widgets where the grid puts them, without a drag's offset. */
|
|
382
|
+
function rects() {
|
|
383
|
+
const grid = gridRef.current;
|
|
384
|
+
const box = grid?.getBoundingClientRect();
|
|
385
|
+
return itemsRef.current.map(item => {
|
|
386
|
+
const element = elementsRef.current.get(item.code);
|
|
387
|
+
if (!element || !box)
|
|
388
|
+
return { left: 0, top: 0, width: 0, height: 0 };
|
|
389
|
+
return {
|
|
390
|
+
left: box.left + element.offsetLeft,
|
|
391
|
+
top: box.top + element.offsetTop,
|
|
392
|
+
width: element.offsetWidth,
|
|
393
|
+
height: element.offsetHeight,
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Keyboard, on a focused widget in edit mode: arrows move it, Enter or Space opens its size menu, Delete removes it.
|
|
398
|
+
function onWidgetKeyDown(event, code) {
|
|
399
|
+
if (!draft || event.defaultPrevented)
|
|
400
|
+
return;
|
|
401
|
+
const own = event.currentTarget;
|
|
402
|
+
const onHandle = event.target instanceof Element && event.target.closest(".nx-widget__grip");
|
|
403
|
+
if (event.target !== own && !onHandle)
|
|
404
|
+
return;
|
|
405
|
+
const index = draft.findIndex(item => item.code === code);
|
|
406
|
+
let to = null;
|
|
407
|
+
if (event.key === "ArrowLeft")
|
|
408
|
+
to = index - 1;
|
|
409
|
+
else if (event.key === "ArrowRight")
|
|
410
|
+
to = index + 1;
|
|
411
|
+
else if (event.key === "ArrowUp" || event.key === "ArrowDown")
|
|
412
|
+
to = neighbourInColumn(rects(), index, event.key === "ArrowUp" ? "up" : "down") ?? (event.key === "ArrowUp" ? index - 1 : index + 1);
|
|
413
|
+
else if (event.key === "Home")
|
|
414
|
+
to = 0;
|
|
415
|
+
else if (event.key === "End")
|
|
416
|
+
to = draft.length - 1;
|
|
417
|
+
else if ((event.key === "Enter" || event.key === " ") && !onHandle) {
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
own.querySelector(".nx-widget__size")?.click();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
else if (event.key === "Delete" || event.key === "Backspace") {
|
|
423
|
+
event.preventDefault();
|
|
424
|
+
remove(code);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (to === null)
|
|
428
|
+
return;
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
moveTo(code, to);
|
|
431
|
+
// The widget keeps the focus where it went, in view.
|
|
432
|
+
requestAnimationFrame(() => elementsRef.current.get(code)?.scrollIntoView({ block: "nearest" }));
|
|
433
|
+
}
|
|
434
|
+
/* Drag: a widget's head (its handle) moves it; the others make room as it passes over them. */
|
|
435
|
+
const gridRef = useRef(null);
|
|
436
|
+
const addButtonRef = useRef(null);
|
|
437
|
+
const [dragging, setDragging] = useState(null);
|
|
438
|
+
const stopListeningRef = useRef(null);
|
|
439
|
+
useEffect(() => () => stopListeningRef.current?.(), []);
|
|
440
|
+
const dragRef = useRef(null);
|
|
441
|
+
// Where the dragged widget is drawn: under the pointer, from wherever the grid has put its slot now.
|
|
442
|
+
const placeDragged = useCallback(() => {
|
|
443
|
+
const drag = dragRef.current;
|
|
444
|
+
const grid = gridRef.current;
|
|
445
|
+
if (!drag?.moving || !grid)
|
|
446
|
+
return;
|
|
447
|
+
const element = elementsRef.current.get(drag.code);
|
|
448
|
+
if (!element)
|
|
449
|
+
return;
|
|
450
|
+
const box = grid.getBoundingClientRect();
|
|
451
|
+
element.style.translate = `${drag.pointer.x - drag.grab.x - (box.left + element.offsetLeft)}px ${drag.pointer.y - drag.grab.y - (box.top + element.offsetTop)}px`;
|
|
452
|
+
}, []);
|
|
453
|
+
useLayoutEffect(placeDragged);
|
|
454
|
+
function onHeadPointerDown(event, code) {
|
|
455
|
+
if (!draft || event.button !== 0 || !event.isPrimary || busy)
|
|
456
|
+
return;
|
|
457
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
458
|
+
if (target?.closest("button:not(.nx-widget__grip), a[href], [role=menu], [popover]"))
|
|
459
|
+
return;
|
|
460
|
+
const element = elementsRef.current.get(code);
|
|
461
|
+
if (!element)
|
|
462
|
+
return;
|
|
463
|
+
const box = element.getBoundingClientRect();
|
|
464
|
+
dragRef.current = {
|
|
465
|
+
code,
|
|
466
|
+
pointerId: event.pointerId,
|
|
467
|
+
start: { x: event.clientX, y: event.clientY },
|
|
468
|
+
grab: { x: event.clientX - box.left, y: event.clientY - box.top },
|
|
469
|
+
pointer: { x: event.clientX, y: event.clientY },
|
|
470
|
+
before: draft,
|
|
471
|
+
over: null,
|
|
472
|
+
moving: false,
|
|
473
|
+
};
|
|
474
|
+
// Listened for on the window, not captured: the grid moves the widget in the page as it passes others, and an
|
|
475
|
+
// element moved in the document loses a pointer capture.
|
|
476
|
+
const move = (moved) => onDragMove(moved);
|
|
477
|
+
const up = (ended) => ended.pointerId === dragRef.current?.pointerId && endDrag(false);
|
|
478
|
+
const cancel = (ended) => ended.pointerId === dragRef.current?.pointerId && endDrag(true);
|
|
479
|
+
window.addEventListener("pointermove", move);
|
|
480
|
+
window.addEventListener("pointerup", up);
|
|
481
|
+
window.addEventListener("pointercancel", cancel);
|
|
482
|
+
stopListeningRef.current = () => {
|
|
483
|
+
window.removeEventListener("pointermove", move);
|
|
484
|
+
window.removeEventListener("pointerup", up);
|
|
485
|
+
window.removeEventListener("pointercancel", cancel);
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function onDragMove(event) {
|
|
489
|
+
const drag = dragRef.current;
|
|
490
|
+
if (!drag || event.pointerId !== drag.pointerId)
|
|
491
|
+
return;
|
|
492
|
+
drag.pointer = { x: event.clientX, y: event.clientY };
|
|
493
|
+
if (!drag.moving) {
|
|
494
|
+
if (Math.hypot(event.clientX - drag.start.x, event.clientY - drag.start.y) < DRAG_THRESHOLD)
|
|
495
|
+
return;
|
|
496
|
+
drag.moving = true;
|
|
497
|
+
setDragging(drag.code);
|
|
498
|
+
}
|
|
499
|
+
const order = itemsRef.current;
|
|
500
|
+
const from = order.findIndex(item => item.code === drag.code);
|
|
501
|
+
const over = widgetAtPoint(rects(), event.clientX, event.clientY, from);
|
|
502
|
+
const overCode = over === null ? null : order[over].code;
|
|
503
|
+
// Each widget passed over makes room once: the room it gives can put it under the pointer again.
|
|
504
|
+
if (overCode !== null && overCode !== drag.over) {
|
|
505
|
+
const next = moveItem(order, from, over);
|
|
506
|
+
itemsRef.current = next;
|
|
507
|
+
setDraft(next);
|
|
508
|
+
setResetting(false);
|
|
509
|
+
}
|
|
510
|
+
drag.over = overCode;
|
|
511
|
+
placeDragged();
|
|
512
|
+
}
|
|
513
|
+
function endDrag(cancel) {
|
|
514
|
+
const drag = dragRef.current;
|
|
515
|
+
dragRef.current = null;
|
|
516
|
+
stopListeningRef.current?.();
|
|
517
|
+
stopListeningRef.current = null;
|
|
518
|
+
if (!drag)
|
|
519
|
+
return;
|
|
520
|
+
const element = elementsRef.current.get(drag.code);
|
|
521
|
+
if (element)
|
|
522
|
+
element.style.translate = "";
|
|
523
|
+
if (!drag.moving)
|
|
524
|
+
return;
|
|
525
|
+
setDragging(null);
|
|
526
|
+
if (cancel) {
|
|
527
|
+
setDraft(drag.before);
|
|
528
|
+
setAnnounce("Move cancelled.");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const index = itemsRef.current.findIndex(item => item.code === drag.code);
|
|
532
|
+
setAnnounce(`${titleOf(drag.code)}: position ${index + 1} of ${itemsRef.current.length}.`);
|
|
533
|
+
}
|
|
534
|
+
// Esc during a drag puts the widget back; a scroll keeps it under the pointer.
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
if (!dragging)
|
|
537
|
+
return;
|
|
538
|
+
const key = (event) => {
|
|
539
|
+
if (event.key !== "Escape")
|
|
540
|
+
return;
|
|
541
|
+
event.preventDefault();
|
|
542
|
+
endDrag(true);
|
|
543
|
+
};
|
|
544
|
+
window.addEventListener("keydown", key, true);
|
|
545
|
+
window.addEventListener("scroll", placeDragged, true);
|
|
546
|
+
return () => {
|
|
547
|
+
window.removeEventListener("keydown", key, true);
|
|
548
|
+
window.removeEventListener("scroll", placeDragged, true);
|
|
549
|
+
};
|
|
550
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
551
|
+
}, [dragging]);
|
|
552
|
+
/* Drawing */
|
|
553
|
+
const groups = adding && draft ? addableWidgets(drawable, draft, placeModule) : [];
|
|
554
|
+
const allOnBoard = groups.every(group => group.widgets.every(one => one.onBoard));
|
|
555
|
+
function frameFor(item, index) {
|
|
556
|
+
const widget = drawable.get(item.code);
|
|
557
|
+
const state = stateOf(item.code);
|
|
558
|
+
const needsData = widgetNeedsData(widget);
|
|
559
|
+
const frameState = !needsData ? "ready" : state.status === "ready" ? (widgetDataEmpty(widget, state.data) ? "empty" : "ready") : state.status === "failed" ? "failed" : "loading";
|
|
560
|
+
const context = {
|
|
561
|
+
place,
|
|
562
|
+
size: item.size,
|
|
563
|
+
refresh: () => load(item.code, { force: true }),
|
|
564
|
+
};
|
|
565
|
+
const sizes = WIDGET_SIZES.filter(size => widget.sizes.includes(size));
|
|
566
|
+
const tools = editing ? (_jsxs("span", { className: "nx-widget__tools", children: [sizes.length > 1 && (_jsx(Menu, { kind: "radio", size: "sm", align: "end", "aria-label": `Size of ${widget.title}`, items: sizes.map(size => ({
|
|
567
|
+
value: size,
|
|
568
|
+
label: widgetSizeLabel(size),
|
|
569
|
+
checked: size === item.size,
|
|
570
|
+
})), onSelect: size => resize(item.code, size), children: _jsx(Button, { variant: "ghost", size: "sm", className: "nx-widget__size", iconEnd: _jsx(InlineIcon, { icon: iconChevronDown }), "aria-label": `Size of ${widget.title}: ${widgetSizeLabel(item.size)}`, title: "Size", children: item.size }) })), _jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconX }), "aria-label": `Remove ${widget.title}`, title: "Remove", onClick: () => remove(item.code) })] })) : undefined;
|
|
571
|
+
return (_jsx(WidgetFrame, { ref: refFor(item.code), "data-code": item.code, "data-dragging": dragging === item.code || undefined, title: widget.title, link: editing ? undefined : widget.link, size: item.size, state: frameState, message: frameState === "failed" && state.status === "failed" ? state.message : frameState === "empty" ? widget.empty : undefined, onRetry: () => load(item.code, { force: true }), onRefresh: needsData ? () => load(item.code, { force: true }) : undefined, refreshing: state.status === "ready" && state.refreshing, inertBody: editing, tabIndex: editing ? 0 : undefined, "aria-roledescription": editing ? "widget" : undefined, "aria-label": editing ? `${widget.title}, ${widgetSizeLabel(item.size)}, position ${index + 1} of ${items.length}` : undefined, "aria-keyshortcuts": editing ? "ArrowLeft ArrowRight ArrowUp ArrowDown Enter Delete" : undefined, onKeyDown: editing ? event => onWidgetKeyDown(event, item.code) : undefined, lead: editing ? (_jsx("button", { type: "button", className: "nx-widget__grip", "aria-label": `Move ${widget.title}`, title: "Drag to move, or use the arrow keys", tabIndex: -1, children: _jsx(InlineIcon, { icon: iconGripVertical }) })) : undefined, tools: tools, onPointerDown: editing ? event => event.target instanceof Element && event.target.closest(".nx-widget__head") && onHeadPointerDown(event, item.code) : undefined, children: frameState === "ready" ? widget.render(needsData && state.status === "ready" ? state.data : undefined, context) : null }, item.code));
|
|
572
|
+
}
|
|
573
|
+
const saveLabel = resetting ? "Save the default" : "Save";
|
|
574
|
+
return (_jsx(WidgetNavigateContext, { value: onNavigate ?? null, children: _jsxs("section", { className: cx("nx-dash", className), "data-editing": editing || undefined, "aria-busy": busy || undefined, children: [_jsxs("header", { className: "nx-dash__bar", children: [title && _jsx("div", { className: "nx-dash__title", children: title }), notice && (_jsxs("p", { className: "nx-dash__notice", "data-tone": notice.tone, role: notice.tone === "danger" ? "alert" : "status", children: [_jsx("span", { children: notice.text }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconX }), "aria-label": "Dismiss", onClick: () => setNotice(null) })] })), _jsx("div", { className: "nx-dash__actions", children: editing ? (_jsxs(_Fragment, { children: [_jsx(Button, { ref: addButtonRef, size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: () => setAdding(true), disabled: busy, children: "Add widget" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: resetDraft, disabled: busy, children: "Reset to default" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: cancelEditing, disabled: busy, children: "Cancel" }), canSaveCompanyDefault && onSaveCompanyDefault ? (_jsx(SplitButton, { size: "sm", fixed: true, loading: busy, menuLabel: "More ways to save", options: [
|
|
575
|
+
{
|
|
576
|
+
value: "mine",
|
|
577
|
+
label: saveLabel,
|
|
578
|
+
icon: _jsx(InlineIcon, { icon: iconCheck }),
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
value: "company",
|
|
582
|
+
label: "Save as the company's default",
|
|
583
|
+
description: "Everyone without a layout of their own sees it here.",
|
|
584
|
+
},
|
|
585
|
+
], onAction: value => void (value === "company" ? saveCompanyDefault() : save()) })) : (_jsx(Button, { variant: "primary", size: "sm", icon: _jsx(InlineIcon, { icon: iconCheck }), loading: busy, onClick: () => void save(), children: saveLabel }))] })) : (_jsxs(_Fragment, { children: [actions, _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh }), onClick: refreshAll, children: "Refresh" }), canCustomise && (_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: startEditing, children: "Customise" }))] })) })] }), items.length > 0 ? (_jsx("div", { ref: gridRef, className: "nx-dash__grid", "data-dragging": dragging ? "" : undefined, children: items.map(frameFor) })) : (_jsxs("div", { className: "nx-dash__empty", children: [_jsx(InlineIcon, { icon: iconLayoutGrid }), _jsx("p", { children: editing ? "No widgets on this dashboard. Add one to start." : "Nothing on this dashboard yet." }), editing ? (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: () => setAdding(true), children: "Add widget" })) : (canCustomise && (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: startEditing, children: "Customise" })))] })), _jsx("p", { className: "nx-visually-hidden", "aria-live": "polite", children: announce }), _jsxs(Popup, { open: adding, drawer: true, size: "sm", title: "Add widget", icon: _jsx(InlineIcon, { icon: iconPlus }), onClose: closeAdding, onClosed: afterAdding, footer: _jsx(Button, { variant: "primary", size: "sm", onClick: closeAdding, children: "Done" }), children: [(groups.length === 0 || allOnBoard) && _jsx("p", { className: "nx-dash-add__all", children: "Every widget this dashboard can hold is on it." }), groups.map(group => (_jsxs("section", { className: "nx-dash-add__group", "aria-label": typeof moduleLabel(group.module) === "string" ? moduleLabel(group.module) : group.module, children: [_jsx("h3", { className: "nx-dash-add__module", children: moduleLabel(group.module) }), _jsx("ul", { className: "nx-dash-add__list", children: group.widgets.map(({ widget, onBoard }) => (_jsxs("li", { className: "nx-dash-add__item", "data-code": widget.code, children: [_jsx(WidgetPreview, { size: widget.sizes[0] }), _jsxs("div", { className: "nx-dash-add__text", children: [_jsxs("span", { className: "nx-dash-add__title", children: [widget.icon, widget.title] }), widget.description && _jsx("span", { className: "nx-dash-add__description", children: widget.description }), _jsx("span", { className: "nx-dash-add__sizes", children: widget.sizes.map(size => WIDGET_SPANS[size].label).join(" · ") })] }), onBoard ? (_jsxs("span", { className: "nx-dash-add__on", children: [_jsx(InlineIcon, { icon: iconCheck }), " On the board"] })) : (_jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconPlus }), "aria-label": `Add ${widget.title}`, onClick: () => add(widget), children: "Add" }))] }, widget.code))) })] }, group.module)))] })] }) }));
|
|
586
|
+
}
|
|
587
|
+
/** A widget's shape in the Add list: its default size on a small 12-column grid. */
|
|
588
|
+
function WidgetPreview({ size }) {
|
|
589
|
+
const span = WIDGET_SPANS[size];
|
|
590
|
+
return (_jsx("span", { className: "nx-dash-add__preview", "aria-hidden": "true", children: _jsx("span", { className: "nx-dash-add__shape", style: {
|
|
591
|
+
gridColumn: `span ${span.columns}`,
|
|
592
|
+
gridRow: `span ${span.rows}`,
|
|
593
|
+
} }) }));
|
|
594
|
+
}
|