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,22 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { type QrLevel } from "../../Helpers/QrCode.ts";
|
|
3
|
+
export interface QrCodeImageProps {
|
|
4
|
+
/** What the code holds: an authenticator's otpauth:// address, a link, any text, as UTF-8. */
|
|
5
|
+
value: string;
|
|
6
|
+
/** What a screen reader says in place of the picture: "QR code to add your account to an authenticator app". */
|
|
7
|
+
label: string;
|
|
8
|
+
/** The error correction level, M unless told: L, M, Q and H restore about 7%, 15%, 25% and 30% of a damaged code. */
|
|
9
|
+
level?: QrLevel;
|
|
10
|
+
/** The page's own class, for where the code sits: a grid area, a margin. */
|
|
11
|
+
className?: string;
|
|
12
|
+
style?: CSSProperties;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A QR code as an image: one SVG path of black modules on a white square that holds the quiet zone, 9rem wide unless a
|
|
16
|
+
* class of the page's says otherwise (`nx-qr`). It stays black on white in a dark theme and in forced colours, since
|
|
17
|
+
* that is what a phone's camera reads. Nothing in it needs the browser, so a server component can render it. A text
|
|
18
|
+
* longer than a QR code holds throws, with how much it holds (`encodeQr`).
|
|
19
|
+
*
|
|
20
|
+
* @example <QrCodeImage value={setup.otpauthUri} label="QR code to add your account to an authenticator app" />
|
|
21
|
+
*/
|
|
22
|
+
export declare function QrCodeImage({ value, label, level, className, style }: QrCodeImageProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
3
|
+
import { encodeQr, qrPath } from "../../Helpers/QrCode.js";
|
|
4
|
+
/**
|
|
5
|
+
* A QR code as an image: one SVG path of black modules on a white square that holds the quiet zone, 9rem wide unless a
|
|
6
|
+
* class of the page's says otherwise (`nx-qr`). It stays black on white in a dark theme and in forced colours, since
|
|
7
|
+
* that is what a phone's camera reads. Nothing in it needs the browser, so a server component can render it. A text
|
|
8
|
+
* longer than a QR code holds throws, with how much it holds (`encodeQr`).
|
|
9
|
+
*
|
|
10
|
+
* @example <QrCodeImage value={setup.otpauthUri} label="QR code to add your account to an authenticator app" />
|
|
11
|
+
*/
|
|
12
|
+
export function QrCodeImage({ value, label, level, className, style }) {
|
|
13
|
+
const { viewBox, d } = qrPath(encodeQr(value, level));
|
|
14
|
+
return (_jsxs("svg", { role: "img", "aria-label": label, viewBox: viewBox, shapeRendering: "crispEdges", className: cx("nx-qr", className), style: style, children: [_jsx("rect", { width: "100%", height: "100%", fill: "#fff" }), _jsx("path", { d: d, fill: "#000" })] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RecordCardsProps } from "../../Interfaces/RecordInterfaces.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A record's page of cards, declared by params: the `sources` it reads, a `hero`, and its `cards` (fields, items, or its
|
|
4
|
+
* own). See `RecordCardsProps` and the interfaces it names.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* <RecordCards
|
|
8
|
+
* sources={{ profile: ACCOUNT_API.profile, emails: ACCOUNT_API.emails }}
|
|
9
|
+
* hero={{ title: p => p.fullName, lines: p => [`@${p.username}`] }}
|
|
10
|
+
* cards={[
|
|
11
|
+
* { id: "personal", title: "Personal details", fields: [FULL_NAME, DATE_OF_BIRTH], edit: { submit: { url: ACCOUNT_API.saveProfile } } },
|
|
12
|
+
* { id: "emails", source: "emails", title: "Emails", items: "items", itemKey: "id", item: e => ({ title: e.address }), add: { ... } },
|
|
13
|
+
* ]}
|
|
14
|
+
* />
|
|
15
|
+
*/
|
|
16
|
+
export declare function RecordCards(props: RecordCardsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { iconAlertCircle, iconPencil, iconPlus, iconRefresh, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
|
+
import { useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
7
|
+
import { getPermissions, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
|
|
8
|
+
import { recordActionName, recordActionState, recordBusyKeys, recordCardKind, recordCount, recordField, recordFieldHeld, recordHeldLines, recordFieldInputs, recordFieldWide, recordItemKey, recordItems, recordLimit, recordLimitReason, RecordSources, recordSessionEnded, recordValueView, recordWrite, } from "../../Helpers/RecordCards.js";
|
|
9
|
+
import { api as browserApi } from "../../Services/BrowserApi.js";
|
|
10
|
+
import { Button } from "../Documents/Button.js";
|
|
11
|
+
import { ProfilePicture } from "../Forms/CrudWorkspace.js";
|
|
12
|
+
import { openRecordFlow, runRecordConfirm } from "../Forms/RecordFlows.js";
|
|
13
|
+
import { useReadOnlyNotice } from "../Inputs/ReadOnlyNotice.js";
|
|
14
|
+
import { toast } from "../Layouts/Toaster.js";
|
|
15
|
+
import { MarkdownView } from "./MarkdownView.js";
|
|
16
|
+
import { RichTextView } from "./RichTextView.js";
|
|
17
|
+
/*
|
|
18
|
+
* A record's page of cards: a hero across the top, then cards of fields, cards of items and the page's own cards, each
|
|
19
|
+
* drawn from one of the sources the page reads. Nothing on the page saves: a card's Edit, a list's Add and an item's
|
|
20
|
+
* icon actions open popups (RecordFlows.tsx), and their answers are written into the source at once, so every card on
|
|
21
|
+
* it shows the change. Loading draws the cards as they will be, held; a source that fails says so in each of its cards,
|
|
22
|
+
* with Try again; read-only draws no buttons; an ended session leaves the page's own words and its way back in.
|
|
23
|
+
*/
|
|
24
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
25
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
|
+
const shown = (node) => node !== undefined && node !== null && node !== false && node !== "";
|
|
27
|
+
/** Reads a source: its own function, else a GET of its endpoint or URL, without the client's toasts. */
|
|
28
|
+
const readSource = (read, signal) => (typeof read === "function" ? read(signal) : browserApi.get(read, { signal, toast: false }));
|
|
29
|
+
/** One placeholder line while a source loads: the table's bone. */
|
|
30
|
+
/** How long after the page opens a link to a card is still followed again once the cards have their records. */
|
|
31
|
+
const FOLLOW_WITHIN_MS = 5000;
|
|
32
|
+
/** Tells one mounted page from another: a popup belongs to the page that opened it, never to the next one drawn. */
|
|
33
|
+
let pages = 0;
|
|
34
|
+
const Bone = ({ width = "60%" }) => _jsx("span", { className: "nx-table__bone nx-record__bone", style: { width }, "aria-hidden": "true" });
|
|
35
|
+
/**
|
|
36
|
+
* A record's page of cards, declared by params: the `sources` it reads, a `hero`, and its `cards` (fields, items, or its
|
|
37
|
+
* own). See `RecordCardsProps` and the interfaces it names.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* <RecordCards
|
|
41
|
+
* sources={{ profile: ACCOUNT_API.profile, emails: ACCOUNT_API.emails }}
|
|
42
|
+
* hero={{ title: p => p.fullName, lines: p => [`@${p.username}`] }}
|
|
43
|
+
* cards={[
|
|
44
|
+
* { id: "personal", title: "Personal details", fields: [FULL_NAME, DATE_OF_BIRTH], edit: { submit: { url: ACCOUNT_API.saveProfile } } },
|
|
45
|
+
* { id: "emails", source: "emails", title: "Emails", items: "items", itemKey: "id", item: e => ({ title: e.address }), add: { ... } },
|
|
46
|
+
* ]}
|
|
47
|
+
* />
|
|
48
|
+
*/
|
|
49
|
+
export function RecordCards(props) {
|
|
50
|
+
const { hero, cards, readOnly = false, headingLevel = 2, className } = props;
|
|
51
|
+
const latest = useRef(props);
|
|
52
|
+
latest.current = props;
|
|
53
|
+
const popups = useRef(new Set());
|
|
54
|
+
const endRef = useRef(() => false);
|
|
55
|
+
const [store] = useState(() => new RecordSources(props.sources, async (read, signal) => {
|
|
56
|
+
const answer = await readSource(read, signal);
|
|
57
|
+
if (!answer.ok)
|
|
58
|
+
endRef.current(answer);
|
|
59
|
+
return answer;
|
|
60
|
+
}));
|
|
61
|
+
const states = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
|
|
62
|
+
useSyncExternalStore(subscribePermissions, permissionsVersion, permissionsVersion);
|
|
63
|
+
const permissions = getPermissions();
|
|
64
|
+
const can = permissions.ready ? permissions.can.bind(permissions) : () => false;
|
|
65
|
+
const preferences = useDatePreferences();
|
|
66
|
+
const [busy, setBusy] = useState({});
|
|
67
|
+
// Each mounted page keys its popups apart: a page drawn afresh (another visit, a new `key`) never brings back a popup
|
|
68
|
+
// bound to the page before it, whose data is gone.
|
|
69
|
+
const [page] = useState(() => ++pages);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
store.start();
|
|
72
|
+
return () => store.stop();
|
|
73
|
+
}, [store]);
|
|
74
|
+
// The page's popups go with it: their answers would be written into data no card shows any more.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const open = popups.current;
|
|
77
|
+
return () => {
|
|
78
|
+
const closing = [...open];
|
|
79
|
+
open.clear();
|
|
80
|
+
for (const popup of closing)
|
|
81
|
+
popup.close();
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
// A link to a card (`/dashboard/profile#emails`) is followed while the cards are held; the cards above it may grow as
|
|
85
|
+
// their records come (a list longer than its held rows) and push it away. Once every source has answered, the card
|
|
86
|
+
// the address names is brought back into view - once, and only while the person has not moved on: a wheel, a touch,
|
|
87
|
+
// a key or a press since the page opened (or more than a few seconds gone) leaves the page where they are.
|
|
88
|
+
const followed = useRef(false);
|
|
89
|
+
const movedOn = useRef(false);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const moved = () => {
|
|
92
|
+
movedOn.current = true;
|
|
93
|
+
};
|
|
94
|
+
const events = ["wheel", "touchstart", "keydown", "pointerdown"];
|
|
95
|
+
for (const name of events)
|
|
96
|
+
window.addEventListener(name, moved, { capture: true, passive: true });
|
|
97
|
+
const late = window.setTimeout(moved, FOLLOW_WITHIN_MS);
|
|
98
|
+
return () => {
|
|
99
|
+
window.clearTimeout(late);
|
|
100
|
+
for (const name of events)
|
|
101
|
+
window.removeEventListener(name, moved, { capture: true });
|
|
102
|
+
};
|
|
103
|
+
}, []);
|
|
104
|
+
const settled = Object.values(states).every(state => state.status !== "loading");
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!settled || followed.current)
|
|
107
|
+
return;
|
|
108
|
+
followed.current = true;
|
|
109
|
+
if (movedOn.current)
|
|
110
|
+
return;
|
|
111
|
+
let id = "";
|
|
112
|
+
try {
|
|
113
|
+
id = decodeURIComponent(window.location.hash.slice(1));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!id)
|
|
119
|
+
return;
|
|
120
|
+
const target = document.getElementById(id);
|
|
121
|
+
if (!target?.closest(".nx-record"))
|
|
122
|
+
return;
|
|
123
|
+
// Already where the link put it (its top in view): left as it is.
|
|
124
|
+
const top = target.getBoundingClientRect().top;
|
|
125
|
+
if (top >= 0 && top < window.innerHeight / 2)
|
|
126
|
+
return;
|
|
127
|
+
target.scrollIntoView({ block: "start" });
|
|
128
|
+
}, [settled]);
|
|
129
|
+
/** An answer that ended the session, when the page says what to do then: the page's words, its popups closed. */
|
|
130
|
+
const ended = useCallback((answer) => {
|
|
131
|
+
const { signedOut, isSessionEnded = recordSessionEnded } = latest.current;
|
|
132
|
+
if (!signedOut || answer.ok || answer.aborted || !isSessionEnded(answer))
|
|
133
|
+
return false;
|
|
134
|
+
if (store.ended)
|
|
135
|
+
return true;
|
|
136
|
+
store.end(answer);
|
|
137
|
+
const closing = [...popups.current];
|
|
138
|
+
popups.current.clear();
|
|
139
|
+
for (const popup of closing)
|
|
140
|
+
popup.close();
|
|
141
|
+
// A closing popup hands the focus back to what opened it, which went with the cards: Sign in again takes it.
|
|
142
|
+
void Promise.all(closing.map(popup => popup.closed)).then(() => {
|
|
143
|
+
const active = document.activeElement;
|
|
144
|
+
if (!active || active === document.body)
|
|
145
|
+
document.getElementById(`${latest.current.name ?? "record"}-sign-in`)?.focus();
|
|
146
|
+
});
|
|
147
|
+
signedOut.onEnded?.(answer);
|
|
148
|
+
return true;
|
|
149
|
+
}, [store]);
|
|
150
|
+
endRef.current = ended;
|
|
151
|
+
const sourceOf = useCallback((card) => card.source ?? store.names[0] ?? "", [store]);
|
|
152
|
+
/** Reads a source again for a step: its data, or null - said in a toast while the cards still show the data. */
|
|
153
|
+
const reload = useCallback(async (source) => {
|
|
154
|
+
const answer = await store.read(source);
|
|
155
|
+
if (answer.ok)
|
|
156
|
+
return store.get(source).data;
|
|
157
|
+
if (!answer.aborted && !store.ended && store.get(source).status === "ready")
|
|
158
|
+
toast.error(`Not read again: ${answer.message ?? "the server did not answer."}`, "The page shows what was read before.");
|
|
159
|
+
return null;
|
|
160
|
+
}, [store]);
|
|
161
|
+
/** Puts the focus back in a card when what held it went away: the item's first button, the card's, or its title. */
|
|
162
|
+
const focusHome = useCallback((cardId, itemKey) => {
|
|
163
|
+
const root = document.getElementById(cardId);
|
|
164
|
+
if (!root)
|
|
165
|
+
return;
|
|
166
|
+
const row = itemKey !== undefined ? root.querySelector(`[data-key="${CSS.escape(itemKey)}"]`) : null;
|
|
167
|
+
const target = row?.querySelector("button") ?? root.querySelector(".nx-record-card__actions button, .nx-record-card__body button") ?? root.querySelector(".nx-record-card__title");
|
|
168
|
+
target?.focus({ preventScroll: true });
|
|
169
|
+
}, []);
|
|
170
|
+
const contextOf = useCallback((card, item) => {
|
|
171
|
+
const source = sourceOf(card);
|
|
172
|
+
const state = store.get(source);
|
|
173
|
+
const now = item && card.itemKey ? recordItems(state.data, card.items).find(one => recordItemKey(one, card.itemKey) === item.key) : undefined;
|
|
174
|
+
return {
|
|
175
|
+
data: state.data,
|
|
176
|
+
item: item ? (now ?? item.value) : undefined,
|
|
177
|
+
readOnly: latest.current.readOnly === true || card.readOnly === true,
|
|
178
|
+
reload: () => reload(source),
|
|
179
|
+
write: data => store.write(source, data),
|
|
180
|
+
run: (actionId, target) => {
|
|
181
|
+
const action = card.itemActions?.find(one => one.id === actionId);
|
|
182
|
+
if (action && card.itemKey)
|
|
183
|
+
runItemRef.current(card, action, target);
|
|
184
|
+
},
|
|
185
|
+
// A popup of the page's own: its values sent as they are (or merged over the item), its answer written as said.
|
|
186
|
+
open: flow => {
|
|
187
|
+
const named = flow.label ?? (typeof flow.title === "string" ? flow.title : "open");
|
|
188
|
+
const label = item ? (card.itemLabel?.(item.value) ?? item.key) : undefined;
|
|
189
|
+
openFlowRef.current(card, { id: `open-${named}`, label: named }, flow, item ? "item" : "add", item && label !== undefined ? { ...item, label } : undefined);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}, [store, sourceOf, reload]);
|
|
193
|
+
const hostOf = useCallback((card, item) => ({
|
|
194
|
+
context: () => contextOf(card, item),
|
|
195
|
+
subscribe: store.subscribe,
|
|
196
|
+
snapshot: store.snapshot,
|
|
197
|
+
write: async ({ writes, result, values, fallback }) => {
|
|
198
|
+
const source = sourceOf(card);
|
|
199
|
+
const context = contextOf(card, item);
|
|
200
|
+
const outcome = recordWrite({ writes, result, data: store.get(source).data, values, fallback, items: card.items, itemKey: card.itemKey, item: context.item });
|
|
201
|
+
if (outcome.kind === "write")
|
|
202
|
+
store.write(source, outcome.data);
|
|
203
|
+
else if (outcome.kind === "reload")
|
|
204
|
+
await reload(source);
|
|
205
|
+
},
|
|
206
|
+
ended,
|
|
207
|
+
track: popup => {
|
|
208
|
+
popups.current.add(popup);
|
|
209
|
+
void popup.closed.then(() => {
|
|
210
|
+
popups.current.delete(popup);
|
|
211
|
+
const active = document.activeElement;
|
|
212
|
+
if (!active || active === document.body)
|
|
213
|
+
focusHome(card.id, item?.key);
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
}), [store, contextOf, sourceOf, reload, ended, focusHome]);
|
|
217
|
+
const name = props.name ?? "record";
|
|
218
|
+
const openFlow = useCallback((card, action, flow, kind, item) => {
|
|
219
|
+
openRecordFlow({
|
|
220
|
+
flow,
|
|
221
|
+
host: hostOf(card, item),
|
|
222
|
+
key: `${name}:${page}:${card.id}:${action.id}${item ? `:${item.key}` : ""}`,
|
|
223
|
+
title: kind === "item" ? action.label : (card.title ?? action.label),
|
|
224
|
+
description: item?.label ?? (kind === "add" ? action.label : undefined),
|
|
225
|
+
kind,
|
|
226
|
+
inputs: kind === "edit" && card.fields ? recordFieldInputs({ fields: card.fields }) : undefined,
|
|
227
|
+
});
|
|
228
|
+
}, [hostOf, name, page]);
|
|
229
|
+
const openFlowRef = useRef(openFlow);
|
|
230
|
+
openFlowRef.current = openFlow;
|
|
231
|
+
/**
|
|
232
|
+
* A question, then the call: the item (or the card) busy meanwhile. The question hands the focus back to the button,
|
|
233
|
+
* which the answer may take away (a Remove takes its row): the focus then comes home, once the change is drawn.
|
|
234
|
+
*/
|
|
235
|
+
const confirmThen = useCallback((card, confirm, busyKey, item) => {
|
|
236
|
+
void runRecordConfirm(confirm, hostOf(card, item), on => setBusy(now => ({ ...now, [card.id]: recordBusyKeys(now[card.id], busyKey, on) }))).then(() => window.requestAnimationFrame(() => {
|
|
237
|
+
const active = document.activeElement;
|
|
238
|
+
if (!active || active === document.body)
|
|
239
|
+
focusHome(card.id, item?.key);
|
|
240
|
+
}));
|
|
241
|
+
}, [hostOf, focusHome]);
|
|
242
|
+
const runCard = useCallback((card, action, kind) => {
|
|
243
|
+
if (action.flow)
|
|
244
|
+
openFlow(card, action, action.flow, kind);
|
|
245
|
+
else if (action.confirm)
|
|
246
|
+
confirmThen(card, action.confirm, "card");
|
|
247
|
+
else
|
|
248
|
+
action.onSelect?.(contextOf(card));
|
|
249
|
+
}, [openFlow, confirmThen, contextOf]);
|
|
250
|
+
const runItem = useCallback((card, action, value) => {
|
|
251
|
+
const key = recordItemKey(value, card.itemKey);
|
|
252
|
+
const label = card.itemLabel?.(value) ?? itemTitle(card, value, store.get(sourceOf(card)).data, key);
|
|
253
|
+
const item = { key, value, label };
|
|
254
|
+
const actionLabel = typeof action.label === "function" ? action.label(value, store.get(sourceOf(card)).data) : action.label;
|
|
255
|
+
if (action.flow)
|
|
256
|
+
openFlow(card, { id: action.id, label: actionLabel }, action.flow, "item", item);
|
|
257
|
+
else if (action.confirm)
|
|
258
|
+
confirmThen(card, action.confirm, key, item);
|
|
259
|
+
else
|
|
260
|
+
action.onSelect?.(contextOf(card, item));
|
|
261
|
+
}, [store, sourceOf, openFlow, confirmThen, contextOf]);
|
|
262
|
+
const runItemRef = useRef(runItem);
|
|
263
|
+
runItemRef.current = runItem;
|
|
264
|
+
const retry = useCallback((source, cardId) => {
|
|
265
|
+
const had = document.getElementById(cardId)?.contains(document.activeElement) ?? false;
|
|
266
|
+
void store.read(source).then(() => {
|
|
267
|
+
if (had && (!document.activeElement || document.activeElement === document.body))
|
|
268
|
+
focusHome(cardId);
|
|
269
|
+
});
|
|
270
|
+
}, [store, focusHome]);
|
|
271
|
+
const endedAnswer = store.ended;
|
|
272
|
+
const { signedOut } = props;
|
|
273
|
+
if (endedAnswer && signedOut) {
|
|
274
|
+
const Heading = headingLevel === 3 ? "h3" : "h2";
|
|
275
|
+
const message = typeof signedOut.message === "function" ? signedOut.message(endedAnswer) : (signedOut.message ?? endedAnswer.message ?? "Your session has ended. Sign in again.");
|
|
276
|
+
return (_jsx("div", { className: cx("nx-record", className), children: _jsxs("section", { className: "nx-record-card nx-record-card--wide", "aria-labelledby": `${name}-ended`, children: [_jsx("header", { className: "nx-record-card__head", children: _jsx("div", { className: "nx-record-card__text", children: _jsx(Heading, { id: `${name}-ended`, className: "nx-record-card__title", children: signedOut.title ?? "Your session has ended" }) }) }), _jsxs("div", { className: "nx-record-card__body", children: [_jsx("p", { role: "alert", className: "nx-record-card__message", children: message }), _jsx(Button, { id: `${name}-sign-in`, variant: "primary", autoFocus: true, onClick: signedOut.onSignIn, children: signedOut.label ?? "Sign in again" })] })] }) }));
|
|
277
|
+
}
|
|
278
|
+
const runtime = {
|
|
279
|
+
store,
|
|
280
|
+
readOnly,
|
|
281
|
+
level: headingLevel,
|
|
282
|
+
can,
|
|
283
|
+
preferences,
|
|
284
|
+
busy,
|
|
285
|
+
sourceOf,
|
|
286
|
+
loudFor: loudParts(hero ? [{ id: `${name}-hero`, source: sourceOf(hero) }, ...cards.map(card => ({ id: card.id, source: sourceOf(card) }))] : cards.map(card => ({ id: card.id, source: sourceOf(card) })), states),
|
|
287
|
+
retry,
|
|
288
|
+
runCard,
|
|
289
|
+
runItem,
|
|
290
|
+
contextOf,
|
|
291
|
+
};
|
|
292
|
+
return (_jsxs("div", { className: cx("nx-record", className), role: props["aria-label"] ? "region" : undefined, "aria-label": props["aria-label"], children: [hero && _jsx(RecordHero, { hero: hero, id: `${name}-hero`, state: states[sourceOf(hero)] ?? store.get(sourceOf(hero)), runtime: runtime }), _jsx("div", { className: "nx-record__cards", children: cards.map(card => (_jsx(RecordCardView, { card: card, state: states[sourceOf(card)] ?? store.get(sourceOf(card)), runtime: runtime }, card.id))) })] }));
|
|
293
|
+
}
|
|
294
|
+
/** The first part (in the page's order) that shows each failed source: it alone says the failure aloud. */
|
|
295
|
+
function loudParts(parts, states) {
|
|
296
|
+
const loud = new Map();
|
|
297
|
+
for (const part of parts)
|
|
298
|
+
if (states[part.source]?.status === "failed" && !loud.has(part.source))
|
|
299
|
+
loud.set(part.source, part.id);
|
|
300
|
+
return loud;
|
|
301
|
+
}
|
|
302
|
+
/** An item's name for a screen reader and a popup's line: its title when that is text, else its key. */
|
|
303
|
+
function itemTitle(card, item, data, key) {
|
|
304
|
+
const title = card.item(item, data).title;
|
|
305
|
+
return typeof title === "string" || typeof title === "number" ? String(title) : key;
|
|
306
|
+
}
|
|
307
|
+
/** The failure of a source, in a card: the server's words and Try again. The first part to show it says it aloud. */
|
|
308
|
+
function Failed({ state, source, cardId, runtime }) {
|
|
309
|
+
const loud = runtime.loudFor.get(source) === cardId;
|
|
310
|
+
return (_jsxs("div", { className: "nx-form-load-failed nx-record__failed", role: loud ? "alert" : undefined, children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsxs("p", { children: [_jsx("strong", { children: "This could not be read." }), " ", state.answer.message ?? "The server did not answer."] }), _jsx("div", { className: "nx-form-load-failed__actions", children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh }), onClick: () => runtime.retry(source, cardId), children: "Try again" }) })] }));
|
|
311
|
+
}
|
|
312
|
+
/** The marks after a name: the table's tags, each with its tone. */
|
|
313
|
+
function Marks({ marks, className }) {
|
|
314
|
+
if (!marks?.length)
|
|
315
|
+
return null;
|
|
316
|
+
return (_jsx("span", { className: className, children: marks.map((mark, index) => (_jsx("span", { className: "nx-table__tag", "data-tone": mark.tone, children: mark.label }, index))) }));
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The buttons of a card's heading, the hero's, or an item's right side, with the note a held one says when pressed (the
|
|
320
|
+
* read-only fields' and the table's held Add's): one note for the whole part, saying the pressed button's reason.
|
|
321
|
+
*/
|
|
322
|
+
function useHeldNote(rootRef) {
|
|
323
|
+
const [reason, setReason] = useState("");
|
|
324
|
+
const notice = useReadOnlyNotice(true, rootRef, reason, { anchor: target => target.closest(".nx-btn"), typing: false, lock: "label" });
|
|
325
|
+
return {
|
|
326
|
+
note: notice.notice,
|
|
327
|
+
onKeyDown: (event) => notice.onKeyDown(event),
|
|
328
|
+
say: (target, why) => {
|
|
329
|
+
setReason(why);
|
|
330
|
+
notice.show(target);
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** The text a card's title says, for a button named after it: its text, or the heading's own words once drawn. */
|
|
335
|
+
const titleText = (title) => (typeof title === "string" || typeof title === "number" ? String(title).trim() || null : null);
|
|
336
|
+
/**
|
|
337
|
+
* A heading's buttons: Edit or Add first, then the card's own; held ones say why when pressed. Each is named with its
|
|
338
|
+
* card to a screen reader ("Edit: Personal details"), as an item's icons are with their item: three Edits on a page are
|
|
339
|
+
* three different buttons.
|
|
340
|
+
*/
|
|
341
|
+
function HeaderActions({ actions, card, runtime, say, busy }) {
|
|
342
|
+
const visible = actions.filter(entry => entry.state.kind !== "hidden");
|
|
343
|
+
if (!visible.length)
|
|
344
|
+
return null;
|
|
345
|
+
const of = titleText(card.title);
|
|
346
|
+
return (_jsx("div", { className: "nx-record-card__actions", children: visible.map(({ action, kind, state }) => {
|
|
347
|
+
const held = state.kind === "held" ? state.reason : null;
|
|
348
|
+
return (_jsx(Button, { size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "data-action": action.id, "data-held": held !== null || undefined, "aria-label": of ? recordActionName(action.label, of) : undefined, "aria-disabled": held !== null || busy || undefined, title: held ?? undefined, onClick: event => {
|
|
349
|
+
if (held !== null)
|
|
350
|
+
return say(event.currentTarget, held);
|
|
351
|
+
if (!busy)
|
|
352
|
+
runtime.runCard(card, action, kind);
|
|
353
|
+
}, children: action.label }, action.id));
|
|
354
|
+
}) }));
|
|
355
|
+
}
|
|
356
|
+
/** The card across the top: the picture that opens the pictures window, the name, its lines and marks. */
|
|
357
|
+
function RecordHero({ hero, id, state, runtime }) {
|
|
358
|
+
const rootRef = useRef(null);
|
|
359
|
+
const held = useHeldNote(rootRef);
|
|
360
|
+
const data = state.data ?? {};
|
|
361
|
+
const loading = state.status === "loading";
|
|
362
|
+
const source = runtime.sourceOf(hero);
|
|
363
|
+
const Heading = runtime.level === 3 ? "h3" : "h2";
|
|
364
|
+
const title = hero.title(data);
|
|
365
|
+
const picture = hero.picture?.(data);
|
|
366
|
+
const lines = hero.lines?.(data) ?? [];
|
|
367
|
+
const card = { id, source: hero.source, title };
|
|
368
|
+
const actions = (hero.actions ?? []).map(action => ({
|
|
369
|
+
action,
|
|
370
|
+
kind: "edit",
|
|
371
|
+
// While the record is read its rules are not asked: each is held (or not drawn yet, `loading: "hidden"`).
|
|
372
|
+
state: state.status === "failed" ? { kind: "hidden" } : recordActionState(action, state.status === "ready" ? [data] : null, { readOnly: runtime.readOnly, can: runtime.can }),
|
|
373
|
+
}));
|
|
374
|
+
return (_jsxs("section", { ref: rootRef, id: id, className: "nx-record-hero", "aria-labelledby": `${id}-title`, "aria-busy": loading || undefined, onKeyDown: held.onKeyDown, children: [shown(picture) && (_jsx("div", { className: "nx-record-hero__picture", children: _jsx(ProfilePicture, { picture: picture, title: title, size: "var(--nx-record-hero-picture)", readOnly: runtime.readOnly }) })), _jsxs("div", { className: "nx-record-hero__text", children: [_jsx(Heading, { id: `${id}-title`, className: "nx-record-hero__title", children: shown(title) ? title : _jsx(Bone, { width: "12rem" }) }), lines.map((line, index) => shown(line) ? (_jsx("p", { className: "nx-record-hero__line", children: line }, index)) : loading ? (_jsx("p", { className: "nx-record-hero__line", "aria-hidden": "true", children: "\u00A0" }, index)) : null), state.status === "failed" ? (_jsx(Failed, { state: state, source: source, cardId: id, runtime: runtime })) : loading && hero.marks && !hero.marks(data).length ? (_jsx("span", { className: "nx-record-hero__marks", "aria-hidden": "true", children: _jsx("span", { className: "nx-table__tag nx-table__tag--plain nx-record__bone-tag", children: "\u00A0" }) })) : (_jsx(Marks, { marks: hero.marks?.(data), className: "nx-record-hero__marks" }))] }), _jsx(HeaderActions, { actions: actions, card: card, runtime: runtime, say: held.say, busy: false }), held.note] }));
|
|
375
|
+
}
|
|
376
|
+
/** One card: its heading (icon, title, count, sentence, buttons) and its body (fields, items, or its own). */
|
|
377
|
+
function RecordCardView({ card, state, runtime }) {
|
|
378
|
+
const rootRef = useRef(null);
|
|
379
|
+
const held = useHeldNote(rootRef);
|
|
380
|
+
const kind = recordCardKind(card);
|
|
381
|
+
const source = runtime.sourceOf(card);
|
|
382
|
+
const data = state.data;
|
|
383
|
+
const ready = state.status === "ready";
|
|
384
|
+
if (ready && card.hidden?.(data))
|
|
385
|
+
return null;
|
|
386
|
+
const readOnly = runtime.readOnly || card.readOnly === true;
|
|
387
|
+
const busy = runtime.busy[card.id] ?? [];
|
|
388
|
+
const Heading = runtime.level === 3 ? "h3" : "h2";
|
|
389
|
+
const titleId = `${card.id}-title`;
|
|
390
|
+
const description = typeof card.description === "function" ? (ready ? card.description(data) : null) : card.description;
|
|
391
|
+
// The heading's buttons: Edit or Add first, then the card's own. Held while the source loads; none once it failed.
|
|
392
|
+
const items = kind === "items" ? card : null;
|
|
393
|
+
const list = items && ready ? recordItems(data, items.items) : [];
|
|
394
|
+
const limit = items && ready ? recordLimit(data, items.limit) : null;
|
|
395
|
+
// The rules typed on the data are asked once it is read; until then each button is held, saying so.
|
|
396
|
+
const stateOf = (action, extra) => {
|
|
397
|
+
if (state.status === "failed")
|
|
398
|
+
return { kind: "hidden" };
|
|
399
|
+
const own = recordActionState(action, ready ? [data] : null, { readOnly, can: runtime.can });
|
|
400
|
+
if (own.kind !== "ready")
|
|
401
|
+
return own;
|
|
402
|
+
return extra ? { kind: "held", reason: extra } : own;
|
|
403
|
+
};
|
|
404
|
+
const header = [];
|
|
405
|
+
if (kind === "fields") {
|
|
406
|
+
const edit = card.edit;
|
|
407
|
+
if (edit) {
|
|
408
|
+
const action = { id: "edit", label: edit.label ?? "Edit", icon: edit.icon ?? _jsx(InlineIcon, { icon: iconPencil }), flow: edit };
|
|
409
|
+
header.push({ action, kind: "edit", state: stateOf(action) });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (items?.add) {
|
|
413
|
+
const add = items.add;
|
|
414
|
+
const action = { id: "add", label: add.label ?? "Add", icon: add.icon ?? _jsx(InlineIcon, { icon: iconPlus }), flow: add };
|
|
415
|
+
const full = limit !== null && list.length >= limit ? recordLimitReason(limit, items.limitReason) : null;
|
|
416
|
+
header.push({ action, kind: "add", state: stateOf(action, full) });
|
|
417
|
+
}
|
|
418
|
+
for (const action of card.actions ?? [])
|
|
419
|
+
header.push({ action, kind: "edit", state: stateOf(action) });
|
|
420
|
+
let body;
|
|
421
|
+
if (state.status === "failed")
|
|
422
|
+
body = _jsx(Failed, { state: state, source: source, cardId: card.id, runtime: runtime });
|
|
423
|
+
else if (kind === "fields")
|
|
424
|
+
body = _jsx(FieldsBody, { card: card, data: data, loading: !ready, preferences: runtime.preferences });
|
|
425
|
+
else if (kind === "items")
|
|
426
|
+
body = _jsx(ItemsBody, { card: items, data: data, loading: !ready, runtime: runtime, readOnly: readOnly, busy: busy, say: held.say });
|
|
427
|
+
else if (!ready)
|
|
428
|
+
body = _jsx(HeldLines, {});
|
|
429
|
+
else
|
|
430
|
+
body = card.render(runtime.contextOf(card));
|
|
431
|
+
return (_jsxs("section", { ref: rootRef, id: card.id, className: cx("nx-record-card", `nx-record-card--${kind}`, card.wide && "nx-record-card--wide"), "aria-labelledby": titleId, "aria-busy": state.status === "loading" || busy.length > 0 || undefined, "data-state": state.status, onKeyDown: held.onKeyDown, children: [_jsxs("header", { className: "nx-record-card__head", children: [card.icon && (_jsx("span", { className: "nx-record-card__icon", "aria-hidden": "true", children: card.icon })), _jsxs("div", { className: "nx-record-card__main", children: [_jsxs("div", { className: "nx-record-card__text", children: [_jsxs(Heading, { id: titleId, className: "nx-record-card__title", tabIndex: -1, children: [card.title, items && limit !== null && _jsx("span", { className: "nx-record-card__count", children: recordCount(list.length, limit) })] }), shown(description) && _jsx("p", { className: "nx-record-card__lead", children: description })] }), _jsx(HeaderActions, { actions: header, card: card, runtime: runtime, say: held.say, busy: busy.length > 0 })] })] }), _jsx("div", { className: "nx-record-card__body", children: body }), held.note] }));
|
|
432
|
+
}
|
|
433
|
+
/** Two held lines of text, for a card of the page's own while its source loads: each a line box, as a paragraph's are. */
|
|
434
|
+
const HeldLines = () => (_jsxs("div", { className: "nx-record__held", "aria-hidden": "true", children: [_jsx("span", { className: "nx-record__held-line", children: _jsx(Bone, { width: "80%" }) }), _jsx("span", { className: "nx-record__held-line", children: _jsx(Bone, { width: "55%" }) })] }));
|
|
435
|
+
/** A fields card's body: label over value, from the inputs its Edit uses. */
|
|
436
|
+
function FieldsBody({ card, data, loading, preferences }) {
|
|
437
|
+
const fields = card.fields.map(recordField);
|
|
438
|
+
return (_jsx("dl", { className: "nx-record-fields", children: fields.map(field => {
|
|
439
|
+
if (!loading && field.hidden?.(data))
|
|
440
|
+
return null;
|
|
441
|
+
const label = field.label ?? field.input.label ?? field.input.name;
|
|
442
|
+
return (_jsxs("div", { className: cx("nx-record-field", recordFieldWide(field) && "nx-record-field--wide"), children: [_jsx("dt", { className: "nx-record-field__label", children: label }), _jsx("dd", { className: "nx-record-field__value", children: loading ? _jsx(HeldValue, { field: field }) : _jsx(FieldValue, { field: field, data: data, empty: card.empty ?? "Not said", preferences: preferences }) })] }, field.input.name));
|
|
443
|
+
}) }));
|
|
444
|
+
}
|
|
445
|
+
/** A value held while its source loads, as tall as it will be: two lines for long text, a chip's height for chips. */
|
|
446
|
+
function HeldValue({ field }) {
|
|
447
|
+
const held = recordFieldHeld(field);
|
|
448
|
+
// Each bone on a line of its own, as the text will be (`heldLines`; long text 2); a chip as the chips will be.
|
|
449
|
+
if (held === "lines" || field.heldLines !== undefined) {
|
|
450
|
+
const lines = recordHeldLines(field);
|
|
451
|
+
return (_jsx(_Fragment, { children: Array.from({ length: lines }, (_, index) => (_jsx("span", { className: "nx-record__held-line", children: _jsx(Bone, { width: index === lines - 1 && lines > 1 ? "55%" : "85%" }) }, index))) }));
|
|
452
|
+
}
|
|
453
|
+
if (held === "chips")
|
|
454
|
+
return (_jsx("ul", { className: "nx-record-chips", "aria-hidden": "true", children: _jsx("li", { className: "nx-table__tag nx-table__tag--plain nx-record__bone-tag", children: "\u00A0" }) }));
|
|
455
|
+
return _jsx(Bone, { width: "60%" });
|
|
456
|
+
}
|
|
457
|
+
/** One value as its input draws it: a choice by its label, a date in the person's format, chips, lines, Markdown. */
|
|
458
|
+
function FieldValue({ field, data, empty, preferences }) {
|
|
459
|
+
const value = isRecord(data) ? data[field.input.name] : undefined;
|
|
460
|
+
const none = _jsx("span", { className: "nx-record-field__empty", children: empty });
|
|
461
|
+
if (field.show) {
|
|
462
|
+
const own = field.show(value, data);
|
|
463
|
+
return shown(own) ? _jsx(_Fragment, { children: own }) : none;
|
|
464
|
+
}
|
|
465
|
+
const view = recordValueView(field.input, value, { look: field.look, preferences });
|
|
466
|
+
switch (view.kind) {
|
|
467
|
+
case "empty":
|
|
468
|
+
return none;
|
|
469
|
+
case "text":
|
|
470
|
+
return _jsx(_Fragment, { children: view.text });
|
|
471
|
+
case "lines":
|
|
472
|
+
return _jsx("span", { className: "nx-record-field__lines", children: view.text });
|
|
473
|
+
case "chips":
|
|
474
|
+
return (_jsx("ul", { className: "nx-record-chips", children: view.items.map((item, index) => (_jsx("li", { className: "nx-table__tag nx-table__tag--plain", children: item }, `${index}:${item}`))) }));
|
|
475
|
+
case "markdown":
|
|
476
|
+
return _jsx(MarkdownView, { value: view.text });
|
|
477
|
+
case "rich":
|
|
478
|
+
return _jsx(RichTextView, { value: view.html });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/** An items card's body: the rows, each with its picture or icon, its name and marks, lines, and icon actions. */
|
|
482
|
+
function ItemsBody({ card, data, loading, runtime, readOnly, busy, say, }) {
|
|
483
|
+
if (loading) {
|
|
484
|
+
return (_jsx("ul", { className: "nx-record-items", "aria-hidden": "true", children: Array.from({ length: card.heldRows ?? 2 }, (_, index) => (_jsxs("li", { className: "nx-record-item", children: [_jsx("span", { className: "nx-record-item__media nx-record-item__media--held" }), _jsxs("span", { className: "nx-record-item__text", children: [_jsx("span", { className: "nx-record-item__title nx-record__held-line", children: _jsx(Bone, { width: "45%" }) }), Array.from({ length: recordHeldLines({ heldLines: card.heldRowLines ?? 1 }) }, (_, line) => (_jsx("span", { className: "nx-record-item__line nx-record__held-line", children: _jsx(Bone, { width: line ? "40%" : "30%" }) }, line)))] })] }, index))) }));
|
|
485
|
+
}
|
|
486
|
+
const items = recordItems(data, card.items);
|
|
487
|
+
if (!items.length)
|
|
488
|
+
return _jsx("p", { className: "nx-record-card__empty", children: card.empty ?? "Nothing here yet." });
|
|
489
|
+
return (_jsx("ul", { className: "nx-record-items", children: items.map(item => {
|
|
490
|
+
const key = recordItemKey(item, card.itemKey);
|
|
491
|
+
const view = card.item(item, data);
|
|
492
|
+
const label = card.itemLabel?.(item) ?? itemTitle(card, item, data, key);
|
|
493
|
+
const working = busy.includes(key) || busy.includes("card");
|
|
494
|
+
const actions = (card.itemActions ?? [])
|
|
495
|
+
.map(action => ({ action, state: recordActionState(action, [item, data], { readOnly, can: runtime.can }) }))
|
|
496
|
+
.filter(entry => entry.state.kind !== "hidden");
|
|
497
|
+
const media = view.picture ?? view.icon;
|
|
498
|
+
return (_jsxs("li", { className: "nx-record-item", "data-key": key, "aria-busy": working || undefined, children: [shown(media) && _jsx("span", { className: cx("nx-record-item__media", !view.picture && "nx-record-item__media--icon"), children: media }), _jsxs("div", { className: "nx-record-item__text", children: [_jsxs("p", { className: "nx-record-item__title", children: [_jsx("span", { className: "nx-record-item__name", children: view.title }), _jsx(Marks, { marks: view.marks, className: "nx-record-item__marks" })] }), (view.lines ?? []).filter(shown).map((line, index) => (_jsx("p", { className: "nx-record-item__line", children: line }, index)))] }), actions.length > 0 && (_jsx("div", { className: "nx-record-item__actions", children: actions.map(({ action, state }) => {
|
|
499
|
+
const name = typeof action.label === "function" ? action.label(item, data) : action.label;
|
|
500
|
+
const held = state.kind === "held" ? state.reason : null;
|
|
501
|
+
return (_jsx(Button, { variant: "ghost", size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "aria-label": recordActionName(name, label), title: held ? `${name}. ${held}` : name, "data-action": action.id, "data-held": held !== null || undefined, "aria-disabled": held !== null || working || undefined, onClick: event => {
|
|
502
|
+
if (held !== null)
|
|
503
|
+
return say(event.currentTarget, held);
|
|
504
|
+
if (!working)
|
|
505
|
+
runtime.runItem(card, action, item);
|
|
506
|
+
} }, action.id));
|
|
507
|
+
}) }))] }, key));
|
|
508
|
+
}) }));
|
|
509
|
+
}
|
|
@@ -117,6 +117,9 @@ export const NEXUS_MODULES = [
|
|
|
117
117
|
{ module: NexusModule.Message, key: "message", label: "Message", env: "API_MESSAGE" },
|
|
118
118
|
{ module: NexusModule.Chat, key: "chat", label: "Chat", env: "API_CHAT" },
|
|
119
119
|
{ module: NexusModule.Accounting, key: "accounting", label: "Accounting", env: "API_ACCOUNTING" },
|
|
120
|
+
// Retired on 2026-11-11 (`NexusModule.Report` is deprecated): kept so the list's published entries do not change in a
|
|
121
|
+
// minor and an old proxy URL (/api/app/report/...) is still known. An app sets no API_REPORT, and a list a person
|
|
122
|
+
// picks a module from leaves it out (`info.module !== NexusModule.Report`).
|
|
120
123
|
{ module: NexusModule.Report, key: "report", label: "Report", env: "API_REPORT" },
|
|
121
124
|
{ module: NexusModule.Requisition, key: "requisition", label: "Requisition", env: "API_REQUISITION" },
|
|
122
125
|
{ module: NexusModule.Tenant, key: "tenant", label: "Tenant", env: "API_TENANT" },
|
|
@@ -126,6 +129,7 @@ export const NEXUS_MODULES = [
|
|
|
126
129
|
{ module: NexusModule.Pharmacy, key: "pharmacy", label: "Pharmacy", env: "API_PHARMACY" },
|
|
127
130
|
{ module: NexusModule.Staffing, key: "staffing", label: "Staffing", env: "API_STAFFING" },
|
|
128
131
|
{ module: NexusModule.Portfolio, key: "portfolio", label: "Portfolio", env: "API_PORTFOLIO" },
|
|
132
|
+
{ module: NexusModule.Inventory, key: "inventory", label: "Inventory", env: "API_INVENTORY" },
|
|
129
133
|
// The app's own API routes (/api/...), never through the proxy.
|
|
130
134
|
{ module: NexusModule.None, key: "app", label: "App" },
|
|
131
135
|
];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** An entry as far as its person goes: their token, and their name when the history's answer carries it. */
|
|
2
|
+
export interface AuditActor {
|
|
3
|
+
creatorUserId?: string | null;
|
|
4
|
+
creatorName?: string | null;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A token, short. It names nobody and only tells two apart, so its first characters are enough. A token is the same
|
|
8
|
+
* text for one id on every entry this reader is given (the id is encrypted for the reader, not at random).
|
|
9
|
+
*/
|
|
10
|
+
export declare const shortToken: (token: string) => string;
|
|
11
|
+
/**
|
|
12
|
+
* Names the people behind a history's tokens: given the tokens of entries the answer names nobody for, the app answers
|
|
13
|
+
* `{ [token]: "Sita Rai (sita.rai)" }` - the whole text the line shows after "by". A token it leaves out keeps its short
|
|
14
|
+
* form.
|
|
15
|
+
*/
|
|
16
|
+
export type AuditActorNamesResolver = (tokens: string[], signal: AbortSignal) => Promise<Record<string, string>>;
|
|
17
|
+
/**
|
|
18
|
+
* Sets, once for the app, how a record's history (`AuditHistory`) names the person behind an entry its answer names
|
|
19
|
+
* nobody for (no `creatorName`). The history collects the tokens of every page it shows (the first and each "Show
|
|
20
|
+
* more"), asks once per page for those it has had no answer for, and says "by Sita Rai (sita.rai)" for each one
|
|
21
|
+
* answered. A token is the same text for one person on every entry this reader is given, so the app can look it up (its
|
|
22
|
+
* own list of people, or a call to the server that made it). A call that fails keeps the line's "User 3fA9b2…" and is
|
|
23
|
+
* asked again by the next history; a token left out of the answer keeps it for the page's life. Answers are kept for the
|
|
24
|
+
* page's life; `null` stops asking and forgets them.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* setAuditActorNames(async (tokens, signal) => {
|
|
28
|
+
* const answer = await api.post<Record<string, string>>(USERS.names, tokens, { signal, toast: false });
|
|
29
|
+
* return answer.ok ? answer.result : {};
|
|
30
|
+
* });
|
|
31
|
+
*/
|
|
32
|
+
export declare function setAuditActorNames(resolver: AuditActorNamesResolver | null): void;
|
|
33
|
+
/** The name the app gave for a token, once it has given one. */
|
|
34
|
+
export declare const knownAuditActorName: (token: string | null | undefined) => string | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The tokens of these entries the app is still to be asked for: entries with no name in the answer, whose token has had
|
|
37
|
+
* no answer yet, each token once. Empty without a resolver.
|
|
38
|
+
*/
|
|
39
|
+
export declare function unnamedAuditActors(entries: readonly AuditActor[]): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Asks the app, in one call, for the names of these entries' unnamed people (`unnamedAuditActors`) and keeps the answer.
|
|
42
|
+
* Resolves `true` when it learnt a name. A failed or aborted call keeps nothing, so a later history asks again.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveAuditActors(entries: readonly AuditActor[], signal: AbortSignal): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Who made a change, as the entry's line says it: "by Sita Rai" when the history's answer names them (`creatorName`) or
|
|
47
|
+
* the app has named their token (`setAuditActorNames`), else the start of their token ("User 3fA9b2…"), which still
|
|
48
|
+
* tells two people apart; `null` when it says neither.
|
|
49
|
+
*/
|
|
50
|
+
export declare function auditActorText(entry: AuditActor): string | null;
|