nexus-shared 4.0.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 +795 -0
- package/README.md +220 -14
- package/dist/Client.Index.d.ts +13 -0
- package/dist/Client.Index.js +13 -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/TabButtons.d.ts +6 -0
- package/dist/Components/Documents/TabButtons.js +5 -4
- package/dist/Components/Domains/DomainCard.d.ts +98 -0
- package/dist/Components/Domains/DomainCard.js +182 -0
- package/dist/Components/Forms/ApiForm.js +5 -1
- package/dist/Components/Forms/Crud.js +72 -24
- package/dist/Components/Forms/CrudActions.d.ts +5 -4
- package/dist/Components/Forms/CrudActions.js +42 -12
- package/dist/Components/Forms/CrudHistory.d.ts +6 -1
- package/dist/Components/Forms/CrudHistory.js +14 -6
- package/dist/Components/Forms/CrudParts.d.ts +11 -4
- package/dist/Components/Forms/CrudParts.js +76 -10
- package/dist/Components/Forms/CrudRowForms.js +65 -5
- package/dist/Components/Forms/CrudTree.js +7 -7
- package/dist/Components/Forms/CrudWorkspace.d.ts +15 -5
- package/dist/Components/Forms/CrudWorkspace.js +19 -11
- package/dist/Components/Forms/Form.d.ts +3 -2
- package/dist/Components/Forms/Form.js +33 -26
- package/dist/Components/Forms/FormQuestion.d.ts +6 -0
- package/dist/Components/Forms/FormQuestion.js +28 -0
- package/dist/Components/Forms/SubmitForm.js +2 -15
- package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
- package/dist/Components/Inputs/ComputedInput.js +179 -0
- package/dist/Components/Inputs/Dropdown.js +4 -2
- package/dist/Components/Inputs/GroupForm.js +3 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +5 -1
- package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
- package/dist/Components/Inputs/MarkdownEditor.js +32 -6
- package/dist/Components/Inputs/PictureInput.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +14 -1
- package/dist/Components/Inputs/RowsInput.js +78 -5
- package/dist/Components/Inputs/TabularForm.js +16 -3
- package/dist/Components/Inputs/TreeView.d.ts +24 -1
- package/dist/Components/Inputs/TreeView.js +84 -7
- package/dist/Components/Layouts/Popup.d.ts +22 -2
- package/dist/Components/Layouts/Popup.js +108 -6
- 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 +7 -5
- package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
- package/dist/Components/Viewers/DataTableColumns.js +2 -1
- package/dist/Components/Viewers/DataTableParts.d.ts +3 -1
- package/dist/Components/Viewers/DataTableParts.js +2 -2
- package/dist/Components/Viewers/OwnerFiles.d.ts +57 -5
- package/dist/Components/Viewers/OwnerFiles.js +142 -31
- 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/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/CrudColumns.d.ts +44 -6
- package/dist/Helpers/CrudColumns.js +63 -13
- package/dist/Helpers/CrudHelpers.d.ts +36 -1
- package/dist/Helpers/CrudHelpers.js +61 -0
- package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
- package/dist/Helpers/CrudPickerPlan.js +46 -0
- package/dist/Helpers/CrudRowForms.d.ts +18 -1
- package/dist/Helpers/CrudRowForms.js +35 -2
- 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/FileAdapter.d.ts +82 -2
- package/dist/Helpers/FileAdapter.js +72 -1
- package/dist/Helpers/FormStore.d.ts +11 -1
- package/dist/Helpers/FormStore.js +66 -19
- package/dist/Helpers/InputConditions.d.ts +6 -0
- package/dist/Helpers/InputConditions.js +24 -0
- package/dist/Helpers/InputMemory.js +40 -30
- package/dist/Helpers/InputNarrowing.d.ts +38 -6
- package/dist/Helpers/InputNarrowing.js +102 -21
- 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 +17 -1
- package/dist/Helpers/NexusDriveAdapter.js +32 -3
- package/dist/Helpers/NumberHelpers.d.ts +61 -0
- package/dist/Helpers/NumberHelpers.js +124 -8
- package/dist/Helpers/PagedPicker.d.ts +42 -0
- package/dist/Helpers/PagedPicker.js +85 -0
- package/dist/Helpers/RowsStore.d.ts +24 -0
- package/dist/Helpers/RowsStore.js +79 -10
- package/dist/Helpers/TableHelpers.d.ts +15 -2
- package/dist/Helpers/TableHelpers.js +28 -1
- package/dist/Helpers/TableStore.d.ts +7 -3
- package/dist/Helpers/TableStore.js +9 -4
- package/dist/Helpers/ToolbarFit.d.ts +23 -0
- package/dist/Helpers/ToolbarFit.js +30 -0
- package/dist/Helpers/TreeStore.d.ts +75 -6
- package/dist/Helpers/TreeStore.js +182 -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 +78 -5
- 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 +21 -0
- package/dist/Interfaces/FileInterfaces.js +2 -0
- package/dist/Interfaces/FormInterfaces.d.ts +78 -11
- package/dist/Interfaces/InputInterfaces.d.ts +12 -5
- package/dist/Interfaces/MailInterfaces.d.ts +192 -0
- package/dist/Interfaces/MailInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +28 -2
- package/dist/Services/JoinSources.d.ts +47 -6
- package/dist/Services/JoinSources.js +68 -33
- package/dist/Services/MessageApi.d.ts +18 -0
- package/dist/Services/MessageApi.js +64 -0
- package/dist/Shared.Index.d.ts +12 -0
- package/dist/Shared.Index.js +12 -0
- package/package.json +7 -1
- package/src/Styles/Nexus.Button.css +29 -0
- package/src/Styles/Nexus.Chat.css +114 -22
- package/src/Styles/Nexus.Crud.css +119 -28
- package/src/Styles/Nexus.Dashboard.css +763 -0
- package/src/Styles/Nexus.Domain.css +384 -0
- package/src/Styles/Nexus.Editor.css +20 -0
- package/src/Styles/Nexus.Files.css +27 -0
- package/src/Styles/Nexus.Index.css +3 -0
- package/src/Styles/Nexus.Input.css +31 -0
- package/src/Styles/Nexus.Mail.css +1062 -0
- package/src/Styles/Nexus.Popup.css +58 -0
- package/src/Styles/Nexus.Table.css +1458 -1449
- package/src/Styles/Nexus.Theme.Picker.css +1 -0
- package/src/Styles/Nexus.Tree.css +10 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { iconArchive, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { flushSync } from "react-dom";
|
|
6
|
+
import { chatDomainLabel } from "../../Helpers/ChatBackend.js";
|
|
7
|
+
import { chatDayLabel, chatLocalDate, chatTimeLabel, sameChatId } from "../../Helpers/ChatHelpers.js";
|
|
8
|
+
import { useChat } from "../../Helpers/ChatHooks.js";
|
|
9
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
10
|
+
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
11
|
+
import { formatDateTime } from "../../Helpers/DateTimeHelpers.js";
|
|
12
|
+
import { mailBccNote, mailPeopleLine, MAIL_ROLE_LABELS } from "../../Helpers/MessageHelpers.js";
|
|
13
|
+
import { ChatComposer } from "../Chats/ChatComposer.js";
|
|
14
|
+
import { ChatFailure } from "../Chats/ChatParts.js";
|
|
15
|
+
import { ChatThread } from "../Chats/ChatThread.js";
|
|
16
|
+
import { DomainCard, DomainLine } from "../Domains/DomainCard.js";
|
|
17
|
+
/** Under this width (px) a record's card opens folded to its title and figures, the details a press away. */
|
|
18
|
+
const NARROW_THREAD = 480;
|
|
19
|
+
/** The words of a thread the reader may not open: a stranger learns nothing more. */
|
|
20
|
+
const REFUSED = "This message is not available to you.";
|
|
21
|
+
const errorStatus = (error) => {
|
|
22
|
+
const status = error?.status;
|
|
23
|
+
return typeof status === "number" ? status : undefined;
|
|
24
|
+
};
|
|
25
|
+
const isAbort = (error) => error?.name === "AbortError";
|
|
26
|
+
/** A mail thread: the head with its people and record, the lines, and the reply box. */
|
|
27
|
+
export function MailThread({ source, threadId, userId, head: givenHead, renderDomain, actions, headingLevel = 2, peopleShown = 3, onChanged, onHead, onSummary, pageSize, pollInterval, height, className, }) {
|
|
28
|
+
const [head, setHead] = useState(givenHead && sameChatId(givenHead.id, threadId) ? givenHead : null);
|
|
29
|
+
const [headState, setHeadState] = useState({ status: head ? "ready" : "loading" });
|
|
30
|
+
const [attempt, setAttempt] = useState(0);
|
|
31
|
+
const [revision, setRevision] = useState(0);
|
|
32
|
+
const [replyTo, setReplyTo] = useState(null);
|
|
33
|
+
const latest = useRef({ onChanged, onHead, onSummary, head });
|
|
34
|
+
// How wide the thread is, followed as it changes. A window still laying out reads 0 (its dialog is not open yet), so the
|
|
35
|
+
// record's card waits for a real width before it is drawn - the first one drawn before the browser paints - and then
|
|
36
|
+
// folds or unfolds with the width, until the reader folds or unfolds it themselves.
|
|
37
|
+
const frame = useRef(null);
|
|
38
|
+
const [narrow, setNarrow] = useState(null);
|
|
39
|
+
const [folded, setFolded] = useState(null);
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
const element = frame.current;
|
|
42
|
+
if (!element)
|
|
43
|
+
return;
|
|
44
|
+
let known = false;
|
|
45
|
+
const read = () => {
|
|
46
|
+
const width = element.clientWidth;
|
|
47
|
+
if (width <= 0)
|
|
48
|
+
return;
|
|
49
|
+
const next = width < NARROW_THREAD;
|
|
50
|
+
if (known)
|
|
51
|
+
setNarrow(next);
|
|
52
|
+
else
|
|
53
|
+
flushSync(() => setNarrow(next));
|
|
54
|
+
known = true;
|
|
55
|
+
};
|
|
56
|
+
const width = element.clientWidth;
|
|
57
|
+
if (width > 0) {
|
|
58
|
+
setNarrow(width < NARROW_THREAD);
|
|
59
|
+
known = true;
|
|
60
|
+
}
|
|
61
|
+
const observer = new ResizeObserver(read);
|
|
62
|
+
observer.observe(element);
|
|
63
|
+
return () => observer.disconnect();
|
|
64
|
+
}, []);
|
|
65
|
+
latest.current = { onChanged, onHead, onSummary, head };
|
|
66
|
+
const chatSource = useMemo(() => source.threadSource(threadId), [source, threadId]);
|
|
67
|
+
// A mail thread is not a record's place: the scope only keys the store, and the conversation id keeps the page's
|
|
68
|
+
// count cache (the chat icons' counts) out of it.
|
|
69
|
+
const scope = useMemo(() => ({ domain: "mail", parentId: threadId, userId }), [threadId, userId]);
|
|
70
|
+
const refused = headState.status === "refused";
|
|
71
|
+
const { store, state } = useChat({ scope, conversationId: threadId, source: chatSource, pageSize, pollInterval, enabled: !refused });
|
|
72
|
+
const take = useCallback((next) => {
|
|
73
|
+
setHead(next);
|
|
74
|
+
setHeadState({ status: "ready" });
|
|
75
|
+
latest.current.onHead?.(next);
|
|
76
|
+
}, []);
|
|
77
|
+
// The head, read whenever the thread changes, and again on Retry.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
if (!latest.current.head || !sameChatId(latest.current.head.id, threadId)) {
|
|
81
|
+
setHead(null);
|
|
82
|
+
setHeadState({ status: "loading" });
|
|
83
|
+
}
|
|
84
|
+
source.thread(threadId, controller.signal).then(take, error => {
|
|
85
|
+
if (controller.signal.aborted || isAbort(error))
|
|
86
|
+
return;
|
|
87
|
+
const status = errorStatus(error);
|
|
88
|
+
if (status === 403 || status === 404)
|
|
89
|
+
setHeadState({ status: "refused", message: REFUSED });
|
|
90
|
+
else
|
|
91
|
+
setHeadState({ status: "failed", message: error instanceof Error && error.message ? error.message : "The message could not be loaded." });
|
|
92
|
+
});
|
|
93
|
+
return () => controller.abort();
|
|
94
|
+
}, [source, threadId, attempt, take]);
|
|
95
|
+
// Read again quietly when the people may have changed: the head on screen stays until the new one lands.
|
|
96
|
+
const refreshHead = useCallback(() => {
|
|
97
|
+
source.thread(threadId).then(take, () => {
|
|
98
|
+
// A refresh that failed leaves the head as it was; the next line tries again.
|
|
99
|
+
});
|
|
100
|
+
}, [source, threadId, take]);
|
|
101
|
+
// What arrived since the last look: someone else's lines read the head again and tell the page; a reply of this
|
|
102
|
+
// reader's while copied unseen reads it again too, since it made them a Cc.
|
|
103
|
+
const primed = useRef(false);
|
|
104
|
+
const newest = useRef(null);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
// Another thread starts its own count: its first page is what it holds, not what arrived.
|
|
107
|
+
primed.current = false;
|
|
108
|
+
newest.current = null;
|
|
109
|
+
}, [threadId]);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!state.loaded)
|
|
112
|
+
return;
|
|
113
|
+
const saved = state.messages.filter(message => message.status !== "sending" && message.status !== "failed");
|
|
114
|
+
const last = saved[saved.length - 1];
|
|
115
|
+
const key = last ? String(last.id) : null;
|
|
116
|
+
const before = newest.current;
|
|
117
|
+
newest.current = key;
|
|
118
|
+
if (!primed.current) {
|
|
119
|
+
primed.current = true;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (key === before)
|
|
123
|
+
return;
|
|
124
|
+
const from = before === null ? -1 : saved.findIndex(message => String(message.id) === before);
|
|
125
|
+
const fresh = saved.slice(from + 1);
|
|
126
|
+
const theirs = fresh.filter(message => !sameChatId(message.authorId, userId));
|
|
127
|
+
const mineAsBcc = latest.current.head?.myRole === "bcc" && fresh.some(message => sameChatId(message.authorId, userId));
|
|
128
|
+
if (theirs.length > 0 || mineAsBcc)
|
|
129
|
+
refreshHead();
|
|
130
|
+
if (theirs.length === 0)
|
|
131
|
+
return;
|
|
132
|
+
const notification = theirs.some(message => message.kind === "notification");
|
|
133
|
+
if (notification)
|
|
134
|
+
setRevision(count => count + 1);
|
|
135
|
+
latest.current.onChanged?.({ reason: notification ? "notification" : "reply", messages: theirs });
|
|
136
|
+
}, [state.loaded, state.messages, userId, refreshHead]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
latest.current.onSummary?.(state.summary);
|
|
139
|
+
}, [state.summary]);
|
|
140
|
+
const preferences = useDatePreferences();
|
|
141
|
+
const zone = useBrowserTimeZone();
|
|
142
|
+
const time = useMemo(() => ({ ...preferences, ...(zone && { timeZone: zone }) }), [preferences, zone]);
|
|
143
|
+
const style = height === undefined ? undefined : { "--nx-chat-height": typeof height === "number" ? `${height}px` : height };
|
|
144
|
+
const Heading = `h${headingLevel}`;
|
|
145
|
+
const note = mailBccNote(head);
|
|
146
|
+
if (refused) {
|
|
147
|
+
return (_jsx("div", { className: cx("nx-chat-panel", "nx-mail", className), style: style, children: _jsx("div", { className: "nx-mail__refused", role: "alert", children: _jsx("p", { children: headState.message }) }) }));
|
|
148
|
+
}
|
|
149
|
+
// The head opens the thread's one scroll (its lead): a tall record's card is read by scrolling the thread, not in a box.
|
|
150
|
+
const headBand = (_jsx("header", { className: "nx-mail__head", "aria-busy": headState.status === "loading" || undefined, children: headState.status === "failed" && !head ? (_jsx(ChatFailure, { message: headState.message, onRetry: () => setAttempt(count => count + 1) })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "nx-mail__title", children: [_jsx(Heading, { className: "nx-mail__subject", tabIndex: -1, "data-nx-focus": "", children: head ? head.subject : _jsx("span", { className: "nx-table__bone nx-record__bone nx-mail__bone", style: { width: "16rem" } }) }), head?.archived && (_jsxs("span", { className: "nx-table__tag nx-table__tag--plain nx-mail__archived", children: [_jsx(InlineIcon, { icon: iconArchive }), " Archived"] })), head && _jsx(MailTime, { value: head.createdTime, time: time }), actions && _jsx("span", { className: "nx-mail__actions", children: actions })] }), _jsx("dl", { className: "nx-mail__people", children: head ? (_jsxs(_Fragment, { children: [_jsx(MailPeopleRow, { role: "from", people: [head.from], userId: userId, shown: peopleShown }), _jsx(MailPeopleRow, { role: "to", people: head.to, userId: userId, shown: peopleShown }), head.cc.length > 0 && _jsx(MailPeopleRow, { role: "cc", people: head.cc, userId: userId, shown: peopleShown }), head.bcc.length > 0 && _jsx(MailPeopleRow, { role: "bcc", people: head.bcc, userId: userId, shown: peopleShown })] })) : (_jsxs(_Fragment, { children: [_jsx(MailBoneRow, { label: MAIL_ROLE_LABELS.from, width: "9rem" }), _jsx(MailBoneRow, { label: MAIL_ROLE_LABELS.to, width: "13rem" })] })) }), head?.domain &&
|
|
151
|
+
(renderDomain ? (renderDomain(head.domain, { head, revision })) : (narrow !== null && (_jsx(DomainCard, { domain: head.domain.domainType, id: head.domain.id, label: head.domain.label, revision: revision, viewer: userId, headingLevel: (headingLevel + 1), collapsed: folded ?? narrow, onCollapsedChange: setFolded })))), headState.status === "failed" && head && _jsx(ChatFailure, { message: headState.message, onRetry: () => setAttempt(count => count + 1) })] })) }));
|
|
152
|
+
return (_jsxs("div", { ref: frame, className: cx("nx-chat-panel", "nx-mail", className), style: style, children: [_jsx("div", { className: "nx-chat-panel__body", children: _jsx(ChatThread, { store: store, state: state, userId: userId, onReply: setReplyTo, emptyMessage: "Nothing in this thread yet.", lead: headBand }) }), _jsx(ChatComposer, { store: store, state: state, draftKey: `mail:${String(threadId)}:${String(userId)}`, placeholder: "Write a reply\u2026", replyTo: replyTo, onCancelReply: () => setReplyTo(null), notice: note, readOnlyMessage: "This thread is closed: it can be read, not answered." })] }));
|
|
153
|
+
}
|
|
154
|
+
/** When the thread was started: the day and the time in the reader's settings, the full moment on hover. */
|
|
155
|
+
function MailTime({ value, time }) {
|
|
156
|
+
if (!value)
|
|
157
|
+
return null;
|
|
158
|
+
const day = chatLocalDate(value, time.timeZone);
|
|
159
|
+
return (_jsx("time", { className: "nx-mail__time", dateTime: value, title: formatDateTime(value, time), children: day ? `${chatDayLabel(day, time)} · ${chatTimeLabel(value, time)}` : chatTimeLabel(value, time) }));
|
|
160
|
+
}
|
|
161
|
+
/** A line of the head held while it loads: the role's word and a bone where the names go. */
|
|
162
|
+
function MailBoneRow({ label, width }) {
|
|
163
|
+
return (_jsxs("div", { className: "nx-mail__row", children: [_jsx("dt", { children: label }), _jsx("dd", { children: _jsx("span", { className: "nx-table__bone nx-record__bone nx-mail__bone", style: { width } }) })] }));
|
|
164
|
+
}
|
|
165
|
+
/** One line of the head: a role and its people, folded to a few names and "+2" until it is opened. */
|
|
166
|
+
function MailPeopleRow({ role, people, userId, shown }) {
|
|
167
|
+
return (_jsxs("div", { className: "nx-mail__row", "data-role": role, children: [_jsx("dt", { children: MAIL_ROLE_LABELS[role] }), _jsx("dd", { children: _jsx(MailPeople, { people: people, userId: userId, shown: shown }) })] }));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* A list of people on one line, folded to its first names and a "+2" button that opens the rest in place: a thread sent
|
|
171
|
+
* to fifty people keeps a head of a few lines. Someone who has left keeps their name, muted.
|
|
172
|
+
*/
|
|
173
|
+
export function MailPeople({ people, userId, shown = 3, empty = "Nobody" }) {
|
|
174
|
+
const [open, setOpen] = useState(false);
|
|
175
|
+
const listId = useId();
|
|
176
|
+
const line = mailPeopleLine(people, shown);
|
|
177
|
+
if (people.length === 0)
|
|
178
|
+
return _jsx("span", { className: "nx-mail__nobody", children: empty });
|
|
179
|
+
const visible = open ? people : line.shown;
|
|
180
|
+
return (_jsxs("span", { id: listId, children: [visible.map((person, index) => (_jsxs("span", { className: cx("nx-mail__name", person.isActive === false && "nx-mail__name--inactive"), title: person.isActive === false ? `${person.name} has left` : undefined, children: [person.name, userId !== undefined && sameChatId(person.id, userId) && _jsx("span", { className: "nx-mail__you", children: "you" }), index < visible.length - 1 && ","] }, `${String(person.id)}-${index}`))), line.more > 0 && (_jsx("button", { type: "button", className: "nx-mail__more", "aria-expanded": open, "aria-controls": listId, "aria-label": open ? "Show fewer" : `Show ${line.more} more: ${people.slice(line.shown.length).map(person => person.name).join(", ")}`, onClick: () => setOpen(was => !was), children: open ? "Fewer" : `+${line.more}` }))] }));
|
|
181
|
+
}
|
|
182
|
+
/** The record a thread is about, as its words alone: the kind's name as the app gave it, and the record's own words. */
|
|
183
|
+
export function MailDomainLine({ domain }) {
|
|
184
|
+
return _jsx(DomainLine, { kind: chatDomainLabel(domain.domainType), label: domain.label });
|
|
185
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type IconNode } from "nexus-icons";
|
|
2
|
+
import { type ChatTimeOptions } from "../../Helpers/ChatHelpers.ts";
|
|
3
|
+
import { type MessageUnreadSnapshot } from "../../Helpers/MessageUnread.ts";
|
|
4
|
+
import type { MailInboxRow, MessageSource } from "../../Interfaces/MailInterfaces.ts";
|
|
5
|
+
export interface UseMessageUnreadOptions {
|
|
6
|
+
/** Milliseconds between reads while the page is in front. Default a minute. */
|
|
7
|
+
interval?: number;
|
|
8
|
+
/** Joins the page's reads. False shows what is known without reading. Default true. */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** What `useMessageUnread` gives: the count and the newest, `refresh()`, and whether this one speaks for the page. */
|
|
12
|
+
export interface MessageUnreadHandle extends MessageUnreadSnapshot {
|
|
13
|
+
refresh: () => void;
|
|
14
|
+
/** The first drawn of the page's bells on this source: the one with the live region. */
|
|
15
|
+
speaks: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** The page's unread count for a mailbox, shared by every component that asks: one read however many there are. */
|
|
18
|
+
export declare function useMessageUnread(source: Pick<MessageSource, "unread">, { interval, enabled }?: UseMessageUnreadOptions): MessageUnreadHandle;
|
|
19
|
+
export interface MessageBellProps {
|
|
20
|
+
/** The mailbox: `apiMessageSource()`, or anything with `unread()`. One source object per page shares one poller. */
|
|
21
|
+
source: Pick<MessageSource, "unread">;
|
|
22
|
+
/** The inbox. A thread opens at `href?thread=<id>`. */
|
|
23
|
+
href: string;
|
|
24
|
+
/** A thread's address, instead of `href?thread=<id>`. */
|
|
25
|
+
threadHref?: (row: MailInboxRow) => string;
|
|
26
|
+
/** What the button is called, before its count. Default "Messages". */
|
|
27
|
+
label?: string;
|
|
28
|
+
/** Milliseconds between reads while the page is in front. Default a minute. */
|
|
29
|
+
interval?: number;
|
|
30
|
+
/** Default `md`, the bar's size. */
|
|
31
|
+
size?: "sm" | "md";
|
|
32
|
+
/** Default a bell. */
|
|
33
|
+
icon?: IconNode;
|
|
34
|
+
/** The largest count the badge writes before "99+". Default 99. */
|
|
35
|
+
maxBadge?: number;
|
|
36
|
+
/** The reader's date settings, for "14 min ago". Default their own. */
|
|
37
|
+
time?: ChatTimeOptions;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
/** The unread count on a top bar's button, and the newest unread a click away. See the notes at the top of this file. */
|
|
41
|
+
export declare function MessageBell({ source, href, threadHref, label, interval, size, icon, maxBadge, time, className }: MessageBellProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { iconBell, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
5
|
+
import { chatTimeAgo } from "../../Helpers/ChatHelpers.js";
|
|
6
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
|
+
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
8
|
+
import { MESSAGE_UNREAD_INTERVAL, mailThreadHref, mailUnreadBadge, mailUnreadWords, messageUnreadPoller, } from "../../Helpers/MessageUnread.js";
|
|
9
|
+
import { Button } from "../Documents/Button.js";
|
|
10
|
+
import { Menu } from "../Documents/Menu.js";
|
|
11
|
+
/** The page's unread count for a mailbox, shared by every component that asks: one read however many there are. */
|
|
12
|
+
export function useMessageUnread(source, { interval = MESSAGE_UNREAD_INTERVAL, enabled = true } = {}) {
|
|
13
|
+
const poller = messageUnreadPoller(source);
|
|
14
|
+
const snapshot = useSyncExternalStore(poller.subscribe, poller.getSnapshot, poller.getSnapshot);
|
|
15
|
+
const member = useRef(null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!enabled)
|
|
18
|
+
return;
|
|
19
|
+
const joined = poller.join(interval);
|
|
20
|
+
member.current = joined;
|
|
21
|
+
return () => {
|
|
22
|
+
joined.leave();
|
|
23
|
+
if (member.current === joined)
|
|
24
|
+
member.current = null;
|
|
25
|
+
};
|
|
26
|
+
}, [poller, interval, enabled]);
|
|
27
|
+
return { ...snapshot, refresh: () => poller.refresh(), speaks: member.current?.speaks() ?? false };
|
|
28
|
+
}
|
|
29
|
+
/** What the button says it holds: its label and the count in words, or why there is none. */
|
|
30
|
+
function describe(label, unread, max) {
|
|
31
|
+
if (unread.status === "idle" || unread.status === "loading")
|
|
32
|
+
return `${label}: not checked yet`;
|
|
33
|
+
if (unread.status === "failed")
|
|
34
|
+
return `${label}: could not be checked`;
|
|
35
|
+
return `${label}: ${mailUnreadWords(unread.count, max).toLowerCase()}`;
|
|
36
|
+
}
|
|
37
|
+
/** The unread count on a top bar's button, and the newest unread a click away. See the notes at the top of this file. */
|
|
38
|
+
export function MessageBell({ source, href, threadHref, label = "Messages", interval, size = "md", icon = iconBell, maxBadge = 99, time, className }) {
|
|
39
|
+
const unread = useMessageUnread(source, { interval });
|
|
40
|
+
const preferences = useDatePreferences();
|
|
41
|
+
const zone = useBrowserTimeZone();
|
|
42
|
+
const settings = useMemo(() => ({ ...preferences, ...(zone && { timeZone: zone }), ...time }), [preferences, zone, time]);
|
|
43
|
+
const words = describe(label, unread, maxBadge);
|
|
44
|
+
const badge = unread.status === "ready" ? mailUnreadBadge(unread.count, maxBadge) : "";
|
|
45
|
+
const shown = unread.newest.reduce((total, row) => total + Math.max(0, row.unreadCount), 0);
|
|
46
|
+
const heading = unread.status === "idle" || unread.status === "loading"
|
|
47
|
+
? "Checking for messages"
|
|
48
|
+
: unread.status === "failed"
|
|
49
|
+
? "Could not check for messages"
|
|
50
|
+
: unread.count > 0
|
|
51
|
+
? "Unread"
|
|
52
|
+
: "Nothing unread";
|
|
53
|
+
const items = [
|
|
54
|
+
{ heading },
|
|
55
|
+
...unread.newest.map(row => ({
|
|
56
|
+
value: `thread:${String(row.id)}`,
|
|
57
|
+
label: row.subject,
|
|
58
|
+
// Who wrote the newest line and when: the line that made it unread (the thread's sender wrote the first).
|
|
59
|
+
description: [row.lastAuthorName || row.fromName, row.domainLabel, chatTimeAgo(row.lastMessageTime, settings)].filter(Boolean).join(" · "),
|
|
60
|
+
href: threadHref ? threadHref(row) : mailThreadHref(href, row.id),
|
|
61
|
+
title: row.subject,
|
|
62
|
+
})),
|
|
63
|
+
...(unread.failed ? [{ value: "retry", label: "Try again", description: unread.status === "ready" ? "The count shown is from before." : undefined }] : []),
|
|
64
|
+
{ separator: true },
|
|
65
|
+
{
|
|
66
|
+
value: "inbox",
|
|
67
|
+
label: "Open inbox",
|
|
68
|
+
// What the list leaves out: the threads past the newest eight.
|
|
69
|
+
description: unread.count > shown && shown > 0 ? `${mailUnreadWords(unread.count, maxBadge)} in all` : undefined,
|
|
70
|
+
href,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
// Said once when the count goes up, by the first bell of the page; an invisible space tells two same words apart.
|
|
74
|
+
const spoken = unread.spoken ? `${unread.spoken.text}${unread.spoken.seq % 2 ? String.fromCharCode(160) : ""}` : "";
|
|
75
|
+
return (_jsxs(_Fragment, { children: [_jsx(Menu, { items: items, align: "end", "aria-label": unread.status === "ready" ? `${label}: ${mailUnreadWords(unread.count, maxBadge).toLowerCase()}` : label, className: "nx-message-bell__menu", onOpenChange: open => open && unread.refresh(), onSelect: value => value === "retry" && unread.refresh(), children: _jsx(Button, { variant: "ghost", size: size, className: cx("nx-message-bell", className), icon: _jsx(InlineIcon, { icon: icon }), badge: badge, "aria-label": words, title: words }) }), unread.speaks && (_jsx("span", { className: "nx-visually-hidden", "aria-live": "polite", children: spoken }))] }));
|
|
76
|
+
}
|
|
@@ -10,7 +10,7 @@ import { fitColumnWidth, headLeafCells, startColumnDrag, startColumnResize } fro
|
|
|
10
10
|
import { exportTableCsv, printTable } from "../../Helpers/TableExport.js";
|
|
11
11
|
import { asFilterInput, filterNames, filterRequest } from "../../Helpers/TableFilters.js";
|
|
12
12
|
import { joinKeys, joinSourceName, tableJoins } from "../../Helpers/TableJoins.js";
|
|
13
|
-
import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, headBands, MIN_COLUMN_WIDTH, orderColumns, releasedPins, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
|
|
13
|
+
import { canPinColumn, columnAlign, columnValue, cssLength, DEFAULT_MAX_SELECTION, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZES, formatCellText, headBands, leadColumns, namingColumn, MIN_COLUMN_WIDTH, orderColumns, releasedPins, TABLE_ACTIONS_COLUMN, TABLE_INDEX_COLUMN, viewColumns, } from "../../Helpers/TableHelpers.js";
|
|
14
14
|
import { TableStore } from "../../Helpers/TableStore.js";
|
|
15
15
|
import { readUserPreferences, useNumberLocale, useUserPreferences } from "../../Helpers/UserPreferences.js";
|
|
16
16
|
import { Button } from "../Documents/Button.js";
|
|
@@ -106,7 +106,7 @@ const sameNumbers = (a, b) => {
|
|
|
106
106
|
* />
|
|
107
107
|
*/
|
|
108
108
|
export function DataTable(props) {
|
|
109
|
-
const { columns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", caption, toolbar, actions, add, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, selectBy, rowActions, onRowOpen, openOnClick: ownOpenOnClick, onPick, sections: ownSections, rowMarks, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, excel, pageSize: ownPageSize, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, currency, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
|
|
109
|
+
const { columns: givenColumns, rows: givenRows, load, loadKey, rowKey = "id", rowLabel, table: givenTable, searchable = true, searchPlaceholder = "Search…", caption, toolbar, actions, add, columnsMenu = true, selectable = false, maxSelection = DEFAULT_MAX_SELECTION, onSelectionChange, massActions, selectBy, rowActions, onRowOpen, openOnClick: ownOpenOnClick, onPick, sections: ownSections, rowMarks, rowStrong, pinnedRows, pinnedLoading = false, pinnedLabel = "Pinned", pinnedMax, onReload, reorderable = true, resizable = true, pinnable = true, moreActions, exportable = true, printable = true, exportName, onImport, excel, pageSize: ownPageSize, pageSizes = DEFAULT_PAGE_SIZES, defaultSort = null, stateKey, currency, emptyMessage = "No records yet.", emptyAction, maxHeight, fill = false, size = "md", id, className, style, ref, } = props;
|
|
110
110
|
const paging = givenRows ? "client" : (props.paging ?? "client");
|
|
111
111
|
// A server's pages cannot all be shown at once: such a table keeps its paging in the footer instead.
|
|
112
112
|
const pager = props.pager === "none" && paging === "server" ? "footer" : (props.pager ?? "bar");
|
|
@@ -131,6 +131,8 @@ export function DataTable(props) {
|
|
|
131
131
|
const table = givenTable ?? own;
|
|
132
132
|
// Prepared once for the store and the panel: the same params without what would make them filter on their own.
|
|
133
133
|
const filterInputs = useMemo(() => props.filterInputs?.map(asFilterInput), [props.filterInputs]);
|
|
134
|
+
// A picture or a logo (`lead`) first of the page's columns, as every table draws it until the user moves it.
|
|
135
|
+
const columns = useMemo(() => leadColumns(givenColumns), [givenColumns]);
|
|
134
136
|
table.configure({ columns, rowKey, rows: givenRows, load, paging, maxSelection, onSelectionChange, onPickChange: onPick, filterInputs });
|
|
135
137
|
useImperativeHandle(ref, () => table, [table]);
|
|
136
138
|
const state = useSyncExternalStore(table.subscribe, table.getState, table.getState);
|
|
@@ -274,7 +276,7 @@ export function DataTable(props) {
|
|
|
274
276
|
const labelOf = useCallback((row) => {
|
|
275
277
|
if (rowLabel)
|
|
276
278
|
return rowLabel(row);
|
|
277
|
-
const first = columns
|
|
279
|
+
const first = namingColumn(columns);
|
|
278
280
|
const text = first ? formatCellText(first, columnValue(first, row), cellOptions, row) : "";
|
|
279
281
|
return text || `row ${table.keyOf(row)}`;
|
|
280
282
|
}, [rowLabel, columns, cellOptions, table]);
|
|
@@ -675,7 +677,7 @@ export function DataTable(props) {
|
|
|
675
677
|
body = rows.map((row, index) => {
|
|
676
678
|
const key = pageKeys[index];
|
|
677
679
|
const selected = state.selected.has(key);
|
|
678
|
-
return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, marks: rowMarks?.(row), frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick, onPick: pickable ? pickRow : undefined, picked: key === pickedKey, pickName: pickable ? pickName : undefined }, key || index));
|
|
680
|
+
return (_jsx(TableRowView, { row: row, rowKey: key, number: state.offset + index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, marks: rowMarks?.(row), strong: rowStrong?.(row) === true, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick, onPick: pickable ? pickRow : undefined, picked: key === pickedKey, pickName: pickable ? pickName : undefined }, key || index));
|
|
679
681
|
});
|
|
680
682
|
}
|
|
681
683
|
/* The sections above the page: the records pinned, the favourites, each folded open or shut, remembered on this device */
|
|
@@ -697,7 +699,7 @@ export function DataTable(props) {
|
|
|
697
699
|
const key = table.keyOf(row);
|
|
698
700
|
const selected = state.selected.has(key);
|
|
699
701
|
const marks = rowMarks?.(row);
|
|
700
|
-
return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, marks: marks?.length ? marks : own, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick,
|
|
702
|
+
return (_jsx(TableRowView, { row: row, rowKey: key, number: index + 1, label: labelOf(row), columns: views, cellOptions: cellOptions, selectable: selectable, selected: selected, selectionFull: selectionFull && !selected, busy: state.busy.has(key), flashed: state.flashed.has(key), numbered: numbered, marks: marks?.length ? marks : own, strong: rowStrong?.(row) === true, frame: frame, sized: sized, actions: hasActions ? (actionsOf(row) ?? []) : null, onToggle: onToggle, onAction: onAction, onOpen: onRowOpen, openOnClick: openOnClick,
|
|
701
703
|
// A click picks a section's row too; its radio button is the one in the list below, where the arrow keys move.
|
|
702
704
|
onPick: pickable ? pickRow : undefined, picked: key === pickedKey }, key || index));
|
|
703
705
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { type Ref, type RefObject } from "react";
|
|
2
2
|
import { type PopoverPanelHandle } from "../../Helpers/PopoverHooks.ts";
|
|
3
|
-
import type { TableColumn,
|
|
3
|
+
import type { TableColumn, TablePins, TableRow } from "../../Interfaces/TableInterfaces.ts";
|
|
4
4
|
export interface TableColumnsPanelProps<TRow extends TableRow> {
|
|
5
5
|
/** Every column the table has, in the order they are in now - hidden ones among them. */
|
|
6
6
|
columns: readonly TableColumn<TRow>[];
|
|
7
7
|
hidden: ReadonlySet<string>;
|
|
8
|
-
pinned:
|
|
8
|
+
pinned: TablePins;
|
|
9
9
|
/** False while the table is not reorderable: the panel then only shows and hides. */
|
|
10
10
|
reorderable?: boolean;
|
|
11
11
|
/** Default `md`. */
|
|
@@ -5,6 +5,7 @@ import { useEffect, useId, useRef, useState } from "react";
|
|
|
5
5
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
6
|
import { isPopoverOpen } from "../../Helpers/PopoverHelpers.js";
|
|
7
7
|
import { usePopoverPanel } from "../../Helpers/PopoverHooks.js";
|
|
8
|
+
import { columnPin } from "../../Helpers/TableHelpers.js";
|
|
8
9
|
import { Button } from "../Documents/Button.js";
|
|
9
10
|
/** The rows of the panel: a band for every run of neighbours that name the same group, as the header draws them. */
|
|
10
11
|
function panelRows(columns, groups) {
|
|
@@ -69,7 +70,7 @@ export function TableColumnsPanel({ columns, hidden, pinned, reorderable = true,
|
|
|
69
70
|
key: column.key,
|
|
70
71
|
label: column.label,
|
|
71
72
|
shown: !hidden.has(column.key),
|
|
72
|
-
pin:
|
|
73
|
+
pin: columnPin(column, pinned),
|
|
73
74
|
movable: reorderable && column.movable !== false,
|
|
74
75
|
hideable: column.hideable !== false,
|
|
75
76
|
}));
|
|
@@ -77,6 +77,8 @@ export interface TableRowViewProps<TRow extends TableRow> {
|
|
|
77
77
|
numbered: boolean;
|
|
78
78
|
/** The row's marks (pinned, a favourite), worn one under the other where a row without any shows its number. */
|
|
79
79
|
marks?: readonly TableRowMark[];
|
|
80
|
+
/** The row reads strong (`rowStrong`): a thread with unread lines. */
|
|
81
|
+
strong?: boolean;
|
|
80
82
|
/** Where the table's own columns sit; `null` for the ones it does not have. */
|
|
81
83
|
frame: TableFrameColumns;
|
|
82
84
|
/** The user gave a column a width, so the table lays its columns out from the `<colgroup>`. */
|
|
@@ -95,7 +97,7 @@ export interface TableRowViewProps<TRow extends TableRow> {
|
|
|
95
97
|
pickName?: string;
|
|
96
98
|
}
|
|
97
99
|
/** One row. It renders again only when its row, its state, or the columns change. */
|
|
98
|
-
declare function TableRowViewInner<TRow extends TableRow>({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, marks, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, onPick, picked, pickName, }: TableRowViewProps<TRow>): import("react").JSX.Element;
|
|
100
|
+
declare function TableRowViewInner<TRow extends TableRow>({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, marks, strong, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, onPick, picked, pickName, }: TableRowViewProps<TRow>): import("react").JSX.Element;
|
|
99
101
|
export declare const TableRowView: typeof TableRowViewInner;
|
|
100
102
|
/**
|
|
101
103
|
* The head of a section above the page: one row across the table with a button that folds the section's records open
|
|
@@ -129,13 +129,13 @@ function opensRow(event, single) {
|
|
|
129
129
|
return !(selection && !selection.isCollapsed && event.currentTarget.contains(selection.anchorNode));
|
|
130
130
|
}
|
|
131
131
|
/** One row. It renders again only when its row, its state, or the columns change. */
|
|
132
|
-
function TableRowViewInner({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, marks, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, onPick, picked = false, pickName, }) {
|
|
132
|
+
function TableRowViewInner({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, marks, strong = false, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, onPick, picked = false, pickName, }) {
|
|
133
133
|
const inline = actions?.filter(action => action.inline && action.icon) ?? [];
|
|
134
134
|
const inMenu = actions?.filter(action => !(action.inline && action.icon)) ?? [];
|
|
135
135
|
const items = inMenu.map(action => ({ value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled }));
|
|
136
136
|
// A click picks a row where rows are picked; opening it is then the double-click's.
|
|
137
137
|
const clickOpens = Boolean(onOpen && openOnClick && !onPick);
|
|
138
|
-
return (_jsxs("tr", { className: "nx-table__row", "data-key": rowKey, "data-selected": selected || undefined, "data-picked": picked || undefined, "data-busy": busy || undefined, "data-flash": flashed || undefined, "aria-busy": busy || undefined, "data-open": clickOpens || undefined, "data-pick": onPick ? "" : undefined,
|
|
138
|
+
return (_jsxs("tr", { className: "nx-table__row", "data-key": rowKey, "data-selected": selected || undefined, "data-picked": picked || undefined, "data-strong": strong || undefined, "data-busy": busy || undefined, "data-flash": flashed || undefined, "aria-busy": busy || undefined, "data-open": clickOpens || undefined, "data-pick": onPick ? "" : undefined,
|
|
139
139
|
// One way in: with a single click the row is already open by the time a double-click would arrive.
|
|
140
140
|
onClick: onPick ? event => opensRow(event, true) && onPick(row) : clickOpens ? event => opensRow(event, true) && onOpen?.(row) : undefined, onDoubleClick: onOpen && !clickOpens ? event => opensRow(event, false) && onOpen(row) : undefined, children: [frame.index && (_jsx(TableIndexCell, { own: frame.index, number: marks?.length ? (_jsx("span", { className: "nx-table__marks", children: marks.map(mark => (_jsx(InlineIcon, { icon: mark.icon, className: "nx-table__mark", label: mark.label }, mark.label))) })) : (formatCount(number)), numbered: numbered || Boolean(marks?.length), selectable: selectable, checkbox: selectable && (_jsx("label", { className: "nx-choice nx-choice--sm nx-table__check", title: selectionFull ? "No more rows can be selected." : undefined, children: _jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: selected, disabled: selectionFull && !selected, "aria-label": `Select ${label}`, onClick: event => onToggle(row, event.currentTarget.checked, event), onChange: () => undefined }) })), pick: onPick &&
|
|
141
141
|
pickName && (_jsx("input", { type: "radio", className: "nx-visually-hidden nx-table__pick", name: pickName, checked: picked, "aria-label": label, onChange: () => onPick(row) })) })), columns.map(view => (_jsx("td", { className: cx("nx-table__cell", `nx-table__cell--${columnAlign(view.column)}`, view.column.wrap && "nx-table__cell--wrap"), "data-col": view.column.key, "data-pin": view.pin ?? undefined, "data-pin-edge": (view.pin && view.edge) || undefined, style: columnStyle(view, sized), children: _jsx("span", { className: "nx-table__value", children: _jsx(TableCellValue, { column: view.column, row: row, options: cellOptions }) }) }, view.column.key))), sized && _jsx("td", { className: "nx-table__cell nx-table__cell--filler" }), frame.actions && (_jsx("td", { className: "nx-table__cell nx-table__cell--actions", "data-col": TABLE_ACTIONS_COLUMN, "data-pin": "right", "data-pin-edge": frame.actions.edge || undefined, style: ownStyle(frame.actions, "right"), children: _jsxs("div", { className: "nx-table__actions", children: [inline.map(action => (_jsx(Button, { variant: "ghost", size: "sm", icon: action.icon, hoverTone: action.tone === "danger" ? "danger" : undefined, "aria-label": `${action.label}: ${label}`, title: action.label, "data-action": action.id, disabled: busy || action.disabled, onClick: () => onAction(row, action) }, action.id))), items.length > 0 && (_jsx(Menu, { items: items, align: "end", "aria-label": `Actions: ${label}`, onSelect: value => onAction(row, inMenu.find(action => action.id === value)), children: _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconDotsVertical }), loading: busy, "aria-label": `More actions: ${label}`, title: "More actions" }) })), items.length === 0 && busy && _jsx(Button, { variant: "ghost", size: "sm", loading: true, "aria-label": `Working: ${label}` })] }) }))] }));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode, type RefObject } from "react";
|
|
2
|
+
import { type OwnerFileActions } from "../../Helpers/FileAdapter.ts";
|
|
2
3
|
import type { ApiModuleValue, ApiResult, AppModule } from "../../Interfaces/ApiInterfaces.ts";
|
|
3
|
-
import { type FileOwner, type OwnerFiles, type PictureType, type StoredFile } from "../../Interfaces/FileInterfaces.ts";
|
|
4
|
+
import { type FileChange, type FileOwner, type OwnerFiles, type PictureType, type StoredFile } from "../../Interfaces/FileInterfaces.ts";
|
|
4
5
|
import type { TableColumn, TableRow } from "../../Interfaces/TableInterfaces.ts";
|
|
5
6
|
import { type PopupHandle } from "../Layouts/PopupHost.tsx";
|
|
6
7
|
/** What a page declares for an owner's pictures: whose they are, and how a picture is made. */
|
|
@@ -31,8 +32,51 @@ export interface OwnerFilesParams<TModule extends ApiModuleValue = AppModule> ex
|
|
|
31
32
|
* @example lockReason: row?.isClosed ? "The branch is closed: open it again to change its picture." : null
|
|
32
33
|
*/
|
|
33
34
|
lockReason?: string | null;
|
|
35
|
+
/**
|
|
36
|
+
* The changes a person may make here, from the record's state, within what the backend allows: `["add"]` for a record
|
|
37
|
+
* whose files may only be added to - a posted goods receipt's documents - keeps Upload and offers no Rename, Delete,
|
|
38
|
+
* Make main or Remove (nor Replace or an Edit, which delete or mark). Default every change. `readOnly` and `lockReason`
|
|
39
|
+
* still show the files with none. A change the backend refuses as "only added to now" narrows them the same way for
|
|
40
|
+
* the rest of the page's life, under the backend's words, so a page read before the record changed stops offering it.
|
|
41
|
+
*
|
|
42
|
+
* @example changes: receipt.documentState === POSTED ? ["add"] : undefined
|
|
43
|
+
*/
|
|
44
|
+
changes?: readonly FileChange[] | null;
|
|
45
|
+
/**
|
|
46
|
+
* Why only those changes, said under the files while `changes` leaves some out: "Posted: a document can be added, as a
|
|
47
|
+
* bill scanned later; none is removed."
|
|
48
|
+
*/
|
|
49
|
+
changesReason?: string | null;
|
|
50
|
+
/**
|
|
51
|
+
* The category's Delete may wait for a review (a company's public gallery), told before the files are read - the
|
|
52
|
+
* backend says so too (`FileCategoryInfo.holdsRemovals`), and either is enough. The Delete then asks "If the public
|
|
53
|
+
* sees this picture, it stays until Nexus support approves its removal.", and the answer decides: a file answered
|
|
54
|
+
* `pendingRemoval` stays, shown "Removal waiting for review" with Keep in place of Delete; any other is gone at once.
|
|
55
|
+
*/
|
|
56
|
+
holdsRemovals?: boolean;
|
|
57
|
+
/** Who reviews a held removal, in those sentences. Default `REMOVAL_REVIEWER` ("Nexus support"). */
|
|
58
|
+
removalReviewer?: string;
|
|
34
59
|
/** After a change on this page: the owner's picture now (null for none). */
|
|
35
60
|
onChange?: (primary: StoredFile | null) => void;
|
|
61
|
+
/**
|
|
62
|
+
* After every change made here that the backend took - an upload, an edit saved, a main picture chosen (or none), a
|
|
63
|
+
* rename, a delete, a delete held for a review, a Keep - with the owner's files as read again after it (as they were,
|
|
64
|
+
* when that read failed) and what changed. A page whose own record follows its files (a company's profile request,
|
|
65
|
+
* which a held Delete or a Keep changes on the server) reads it again here.
|
|
66
|
+
*
|
|
67
|
+
* @example onFilesChange: (files, change) => { if (change.kind === "hold" || change.kind === "keep") void reloadRequest(); }
|
|
68
|
+
*/
|
|
69
|
+
onFilesChange?: (files: StoredFile[], change: OwnerFilesChange) => void;
|
|
70
|
+
}
|
|
71
|
+
/** What changed an owner's files, for `onFilesChange`: a delete held for a review is `hold`, its Keep `keep`. */
|
|
72
|
+
export type OwnerFilesChangeKind = "upload" | "edit" | "primary" | "rename" | "delete" | "hold" | "keep";
|
|
73
|
+
/**
|
|
74
|
+
* One change to an owner's files: what it was, and the file - the stored one (an upload's, an edit's last saved), the one
|
|
75
|
+
* made main (null for none), renamed, deleted, held or kept.
|
|
76
|
+
*/
|
|
77
|
+
export interface OwnerFilesChange {
|
|
78
|
+
kind: OwnerFilesChangeKind;
|
|
79
|
+
file: StoredFile | null;
|
|
36
80
|
}
|
|
37
81
|
/** What a person the backend lets only look at an owner's files is told, when it gave no words of its own. */
|
|
38
82
|
export declare const READ_ONLY_REASON = "You can see these files, but you may not change them.";
|
|
@@ -60,10 +104,14 @@ export declare function useOwnerFiles(params: Omit<OwnerFilesParams<ApiModuleVal
|
|
|
60
104
|
busy: string | null;
|
|
61
105
|
canWrite: boolean;
|
|
62
106
|
readOnlyReason: string | null;
|
|
107
|
+
changes: readonly FileChange[] | null;
|
|
108
|
+
changesNote: string | null;
|
|
109
|
+
offer: (file?: StoredFile | null) => OwnerFileActions;
|
|
63
110
|
pictures: boolean;
|
|
111
|
+
holdsRemovals: boolean;
|
|
64
112
|
editorProblem: string | null;
|
|
65
113
|
actions: {
|
|
66
|
-
reload: () => Promise<void |
|
|
114
|
+
reload: () => Promise<void | OwnerFiles>;
|
|
67
115
|
/**
|
|
68
116
|
* Chooses a picture, edits it, and makes it the owner's picture (the picture input's Upload). In a category that
|
|
69
117
|
* keeps one file, the new picture takes the place of the one kept (Replace).
|
|
@@ -76,9 +124,11 @@ export declare function useOwnerFiles(params: Omit<OwnerFilesParams<ApiModuleVal
|
|
|
76
124
|
* that keeps one file, the edited picture takes its place.
|
|
77
125
|
*/
|
|
78
126
|
edit: (file: StoredFile) => Promise<void>;
|
|
79
|
-
makePrimary: (file: StoredFile | null) => Promise<void> | null;
|
|
127
|
+
makePrimary: (file: StoredFile | null) => false | Promise<void> | null;
|
|
80
128
|
rename: (file: StoredFile) => Promise<void>;
|
|
81
129
|
remove: (file: StoredFile) => Promise<void>;
|
|
130
|
+
/** Keeps a file whose removal waits for a review: the removal is cancelled. */
|
|
131
|
+
keep: (file: StoredFile) => Promise<void>;
|
|
82
132
|
/** Shows the pictures in the editor module's viewer, from `file`. */
|
|
83
133
|
view: (file: StoredFile) => Promise<void>;
|
|
84
134
|
};
|
|
@@ -149,7 +199,7 @@ export interface OpenPictureGalleryOptions extends PictureGalleryProps {
|
|
|
149
199
|
* "Documents".
|
|
150
200
|
*/
|
|
151
201
|
export declare function openPictureGallery({ popupTitle, ...props }: OpenPictureGalleryOptions): PopupHandle;
|
|
152
|
-
export interface PictureColumnParams<TRow extends TableRow> extends Omit<OwnerFilesParams<ApiModuleValue>, "ownerId" | "title" | "onChange" | "lockReason">, Partial<Omit<TableColumn<TRow>, "render" | "value">> {
|
|
202
|
+
export interface PictureColumnParams<TRow extends TableRow> extends Omit<OwnerFilesParams<ApiModuleValue>, "ownerId" | "title" | "onChange" | "onFilesChange" | "lockReason">, Partial<Omit<TableColumn<TRow>, "render" | "value">> {
|
|
153
203
|
/** The row's field that holds the owner's id. Default `id`. */
|
|
154
204
|
ownerKey?: string;
|
|
155
205
|
/** Whose the row's pictures are, for the window's title: `row => row.branchName`. */
|
|
@@ -167,9 +217,11 @@ export interface PictureColumnParams<TRow extends TableRow> extends Omit<OwnerFi
|
|
|
167
217
|
/**
|
|
168
218
|
* A column with each row's picture: every visible row's read in one call per page, and a click opens the row's
|
|
169
219
|
* gallery. A change there redraws the row's picture without a reload.
|
|
220
|
+
* It is a `lead` column: every table draws it first of the page's columns (after the row number), pinned on the left
|
|
221
|
+
* when the page pins others there, wherever it stands in `columns`, until the user moves, unpins or hides it.
|
|
170
222
|
*
|
|
171
223
|
* @example
|
|
172
224
|
* columns: [pictureColumn({ module: NexusModule.Enterprise, category: "branch-picture", rowTitle: row => row.branchName }), ...]
|
|
173
225
|
*/
|
|
174
|
-
export declare function pictureColumn<TRow extends TableRow>({ module, category, ownerKey, rowTitle, nameKey, lockReason, onChange, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, view, ...column }: PictureColumnParams<TRow> & Pick<PictureGalleryProps, "view">): TableColumn<TRow>;
|
|
226
|
+
export declare function pictureColumn<TRow extends TableRow>({ module, category, ownerKey, rowTitle, nameKey, lockReason, onChange, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, changes, changesReason, holdsRemovals, removalReviewer, view, ...column }: PictureColumnParams<TRow> & Pick<PictureGalleryProps, "view">): TableColumn<TRow>;
|
|
175
227
|
export {};
|