nexus-shared 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +122 -25
  3. package/dist/Client.Index.d.ts +11 -0
  4. package/dist/Client.Index.js +11 -0
  5. package/dist/Components/Chats/Chat.d.ts +28 -0
  6. package/dist/Components/Chats/Chat.js +19 -0
  7. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  8. package/dist/Components/Chats/ChatButton.js +41 -0
  9. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  10. package/dist/Components/Chats/ChatComposer.js +177 -0
  11. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  12. package/dist/Components/Chats/ChatConversations.js +38 -0
  13. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  14. package/dist/Components/Chats/ChatPanel.js +94 -0
  15. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  16. package/dist/Components/Chats/ChatParts.js +100 -0
  17. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  18. package/dist/Components/Chats/ChatThread.js +96 -0
  19. package/dist/Components/Documents/Menu.js +24 -20
  20. package/dist/Components/Documents/SplitButton.js +5 -3
  21. package/dist/Components/Documents/TabButtons.d.ts +24 -6
  22. package/dist/Components/Documents/TabButtons.js +23 -4
  23. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  24. package/dist/Components/Forms/ApiForm.js +15 -14
  25. package/dist/Components/Forms/Crud.js +202 -50
  26. package/dist/Components/Forms/ExcelImport.d.ts +42 -0
  27. package/dist/Components/Forms/ExcelImport.js +190 -0
  28. package/dist/Components/Forms/Form.js +5 -1
  29. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  30. package/dist/Components/Inputs/GroupForm.js +4 -2
  31. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  32. package/dist/Components/Inputs/InputRenderer.js +1 -1
  33. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  34. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  35. package/dist/Components/Inputs/RowsInput.js +53 -4
  36. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  37. package/dist/Components/Inputs/TabularForm.js +85 -20
  38. package/dist/Components/Inputs/TimePicker.js +2 -1
  39. package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
  40. package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
  41. package/dist/Components/Viewers/DataTable.js +88 -34
  42. package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
  43. package/dist/Components/Viewers/DataTableColumns.js +243 -0
  44. package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
  45. package/dist/Components/Viewers/DataTableParts.js +21 -9
  46. package/dist/Helpers/ApiClient.d.ts +22 -0
  47. package/dist/Helpers/ApiClient.js +4 -1
  48. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  49. package/dist/Helpers/ApiFormHelpers.js +73 -9
  50. package/dist/Helpers/ApiModules.d.ts +0 -4
  51. package/dist/Helpers/ApiModules.js +1 -0
  52. package/dist/Helpers/ApiResponses.d.ts +14 -4
  53. package/dist/Helpers/ApiResponses.js +116 -25
  54. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  55. package/dist/Helpers/ApiRoutes.js +2 -10
  56. package/dist/Helpers/ChatBackend.d.ts +134 -0
  57. package/dist/Helpers/ChatBackend.js +332 -0
  58. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  59. package/dist/Helpers/ChatHelpers.js +486 -0
  60. package/dist/Helpers/ChatHooks.d.ts +81 -0
  61. package/dist/Helpers/ChatHooks.js +175 -0
  62. package/dist/Helpers/ChatStore.d.ts +132 -0
  63. package/dist/Helpers/ChatStore.js +394 -0
  64. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  65. package/dist/Helpers/ChatSummaries.js +118 -0
  66. package/dist/Helpers/CrudBackend.d.ts +47 -5
  67. package/dist/Helpers/CrudBackend.js +99 -4
  68. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  69. package/dist/Helpers/CrudHelpers.js +76 -11
  70. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  71. package/dist/Helpers/ExcelBackend.js +290 -0
  72. package/dist/Helpers/ExcelHelpers.d.ts +98 -0
  73. package/dist/Helpers/ExcelHelpers.js +302 -0
  74. package/dist/Helpers/FormStore.d.ts +2 -0
  75. package/dist/Helpers/FormStore.js +19 -1
  76. package/dist/Helpers/MessageBuilder.js +0 -2
  77. package/dist/Helpers/PopoverHelpers.d.ts +31 -6
  78. package/dist/Helpers/PopoverHelpers.js +116 -12
  79. package/dist/Helpers/RowsStore.d.ts +7 -1
  80. package/dist/Helpers/RowsStore.js +22 -0
  81. package/dist/Helpers/TableColumns.d.ts +7 -0
  82. package/dist/Helpers/TableColumns.js +41 -13
  83. package/dist/Helpers/TableHelpers.d.ts +9 -1
  84. package/dist/Helpers/TableHelpers.js +20 -0
  85. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  86. package/dist/Interfaces/ApiInterfaces.js +16 -10
  87. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  88. package/dist/Interfaces/ChatInterfaces.js +1 -0
  89. package/dist/Interfaces/CrudInterfaces.d.ts +73 -12
  90. package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
  91. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  92. package/dist/Interfaces/FormInterfaces.d.ts +52 -2
  93. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  94. package/dist/Interfaces/TableInterfaces.d.ts +62 -2
  95. package/dist/Services/BrowserApi.d.ts +18 -1
  96. package/dist/Services/BrowserApi.js +18 -0
  97. package/dist/Services/ChatApi.d.ts +37 -0
  98. package/dist/Services/ChatApi.js +98 -0
  99. package/dist/Services/ExcelApi.d.ts +46 -0
  100. package/dist/Services/ExcelApi.js +196 -0
  101. package/dist/Services/ServerApi.d.ts +6 -1
  102. package/dist/Services/ServerApi.js +3 -0
  103. package/dist/Shared.Index.d.ts +8 -0
  104. package/dist/Shared.Index.js +8 -0
  105. package/package.json +6 -3
  106. package/src/Styles/Nexus.Button.css +2 -0
  107. package/src/Styles/Nexus.Chat.css +1489 -0
  108. package/src/Styles/Nexus.Crud.css +77 -0
  109. package/src/Styles/Nexus.Excel.css +370 -0
  110. package/src/Styles/Nexus.Form.css +23 -0
  111. package/src/Styles/Nexus.Index.css +2 -0
  112. package/src/Styles/Nexus.Menu.css +12 -2
  113. package/src/Styles/Nexus.Rows.css +78 -48
  114. package/src/Styles/Nexus.Tab.Buttons.css +55 -0
  115. package/src/Styles/Nexus.Table.css +286 -4
  116. package/src/Styles/Nexus.Theme.Picker.css +75 -2
@@ -0,0 +1,100 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconAlertCircle, iconCheck, iconChecks, iconCornerUpLeft, iconFile, iconLoader2, iconRefresh, iconTrash, iconX, InlineIcon } from "nexus-icons";
4
+ import { useState } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { chatDeliveryState, chatFileSize, chatTimeLabel, sameChatId, CHAT_DELIVERY_LABELS } from "../../Helpers/ChatHelpers.js";
7
+ import { avatarTone, initials } from "../../Helpers/DropdownHelpers.js";
8
+ import { markdownToPlainText } from "../../Helpers/MarkdownHelpers.js";
9
+ import { MarkdownView } from "../Viewers/MarkdownView.js";
10
+ /*
11
+ * The pieces a chat is drawn from: the avatar, one message, the lines that break a thread into days and into read and
12
+ * unread, and the participants. Each one draws what it is given and nothing else — the state is in `ChatStore` — so
13
+ * the same pieces serve the panel, a page-wide chat, and a demo with messages in memory.
14
+ */
15
+ /** A round photo, or the initials of the name on a tone picked from it — the same look and tones as a dropdown's. */
16
+ export function ChatAvatar({ name, src, size = "md" }) {
17
+ const url = typeof src === "string" ? src.trim() : "";
18
+ const [failed, setFailed] = useState(null);
19
+ const photo = url !== "" && failed !== url;
20
+ return (_jsx("span", { className: cx("nx-chat-avatar", size === "sm" && "nx-chat-avatar--sm"), "data-tone": photo ? undefined : avatarTone(name), "aria-hidden": "true", children: photo ? _jsx("img", { src: url, alt: "", loading: "lazy", decoding: "async", onError: () => setFailed(url) }) : initials(name) }));
21
+ }
22
+ /** The day a run of messages was written: "Today", "Yesterday", or the date in the user's calendar. */
23
+ export function ChatDayDivider({ label }) {
24
+ return (_jsx("div", { className: "nx-chat-day", role: "separator", children: _jsx("span", { children: label }) }));
25
+ }
26
+ /** Where reading stopped last time. It stays where it was put, so it does not slide away as the thread is read. */
27
+ export function ChatUnreadLine({ count, label = "New messages" }) {
28
+ return (_jsx("div", { className: "nx-chat-unread-line", role: "separator", children: _jsx("span", { children: count && count > 0 ? `${label} · ${count}` : label }) }));
29
+ }
30
+ /**
31
+ * How far a message of this user's has got, in the one place the time sits: a turning spinner while it is on its way,
32
+ * one tick once the server has it, two once it has reached somebody, and two in the accent once it has been read. The
33
+ * mark is the same size in every state, so the line under a message does not shift as it settles.
34
+ */
35
+ export function ChatReadMark({ message, participants, userId }) {
36
+ const state = chatDeliveryState(message, userId, participants);
37
+ const read = (message.readBy ?? []).filter(id => !sameChatId(id, userId));
38
+ const label = state === "read" && read.length > 1 ? `Read by ${read.length}` : CHAT_DELIVERY_LABELS[state];
39
+ const icon = state === "sending" ? iconLoader2 : state === "failed" ? iconAlertCircle : state === "sent" ? iconCheck : iconChecks;
40
+ // The word is on the wrapper, so it is both the tooltip on hover and the name a screen reader reads for the mark.
41
+ return (_jsx("span", { className: cx("nx-chat-mark", `nx-chat-mark--${state}`, (state === "read" || state === "read-all") && "nx-chat-mark--seen", state === "sending" && "nx-spin"), role: "img", "aria-label": label, title: label, "data-state": state, children: _jsx(InlineIcon, { icon: icon }) }));
42
+ }
43
+ /** Files on a message: the name, the size, and a link when the file has a URL. */
44
+ export function ChatAttachments({ attachments }) {
45
+ return (_jsx("ul", { className: "nx-chat-files", children: attachments.map((file, index) => {
46
+ const size = chatFileSize(file.size);
47
+ const body = (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconFile }), _jsx("span", { className: "nx-chat-file__name", children: file.name }), size && _jsx("span", { className: "nx-chat-file__size", children: size })] }));
48
+ return (_jsx("li", { className: "nx-chat-file", children: file.url ? (_jsx("a", { href: file.url, target: "_blank", rel: "noreferrer", children: body })) : (_jsx("span", { children: body })) }, `${String(file.id ?? file.name)}-${index}`));
49
+ }) }));
50
+ }
51
+ /** The message a reply answers, above the reply itself. */
52
+ export function ChatQuote({ message, onOpen }) {
53
+ if (!message)
54
+ return _jsx("div", { className: "nx-chat-quote nx-chat-quote--gone", children: "The message this answers is no longer here." });
55
+ const body = (_jsxs(_Fragment, { children: [_jsx("span", { className: "nx-chat-quote__author", children: message.authorName || "Unknown" }), _jsx("span", { className: "nx-chat-quote__body", children: markdownToPlainText(message.body) })] }));
56
+ if (!onOpen)
57
+ return _jsx("div", { className: "nx-chat-quote", children: body });
58
+ return (_jsx("button", { type: "button", className: "nx-chat-quote", onClick: () => onOpen(message.id), children: body }));
59
+ }
60
+ /** A note the system wrote, not a person: no side, no avatar, no actions. */
61
+ export function ChatSystemNote({ message, time }) {
62
+ return (_jsxs("div", { className: "nx-chat-note", children: [_jsx("span", { children: markdownToPlainText(message.body) }), _jsx("time", { dateTime: message.createdAt, children: chatTimeLabel(message.createdAt, time) })] }));
63
+ }
64
+ /**
65
+ * One message. Its own messages sit on the right in the primary tint, everyone else's on the left on a surface. The
66
+ * actions are one row of icon buttons that appear on hover or keyboard focus — and never move the message, so a long
67
+ * thread does not jump under the pointer.
68
+ */
69
+ export function ChatMessageItem({ message, mine, first, last, time, participants, userId, quoted, onReply, onRemove, onRetry, onDiscard, onOpenQuoted }) {
70
+ const failed = message.status === "failed";
71
+ const waiting = message.status === "sending";
72
+ const actions = [];
73
+ if (onReply && !failed && !waiting)
74
+ actions.push(_jsx(ChatAction, { icon: iconCornerUpLeft, label: "Reply", onClick: () => onReply(message) }, "reply"));
75
+ if (onRemove && mine && !failed && !waiting)
76
+ actions.push(_jsx(ChatAction, { icon: iconTrash, label: "Delete", tone: "danger", onClick: () => onRemove(message) }, "remove"));
77
+ return (_jsxs("div", { className: cx("nx-chat-msg", mine ? "nx-chat-msg--mine" : "nx-chat-msg--theirs", first && "nx-chat-msg--first", last && "nx-chat-msg--last", failed && "nx-chat-msg--failed"), children: [_jsxs("div", { className: "nx-chat-msg__bubble", children: [first && !mine && _jsx("span", { className: "nx-chat-msg__author", children: message.authorName || "Unknown" }), message.replyToId !== null && message.replyToId !== undefined && _jsx(ChatQuote, { message: quoted, onOpen: onOpenQuoted }), message.body && _jsx(MarkdownView, { className: "nx-chat-msg__body", value: message.body }), message.attachments && message.attachments.length > 0 && _jsx(ChatAttachments, { attachments: message.attachments }), _jsxs("span", { className: "nx-chat-msg__meta", children: [message.editedAt && _jsx("span", { className: "nx-chat-msg__edited", children: "edited" }), _jsx("time", { dateTime: message.createdAt, children: chatTimeLabel(message.createdAt, time) }), mine && _jsx(ChatReadMark, { message: message, participants: participants, userId: userId })] }), actions.length > 0 && _jsx("span", { className: "nx-chat-msg__actions", children: actions })] }), failed && (_jsxs("div", { className: "nx-chat-msg__failure", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: message.error || "Not sent." }), message.clientId && onRetry && (_jsxs("button", { type: "button", onClick: () => onRetry(message.clientId), children: [_jsx(InlineIcon, { icon: iconRefresh }), " Retry"] })), message.clientId && onDiscard && (_jsxs("button", { type: "button", onClick: () => onDiscard(message.clientId), children: [_jsx(InlineIcon, { icon: iconX }), " Discard"] }))] }))] }));
78
+ }
79
+ /** One icon button in a message's action row. */
80
+ function ChatAction({ icon, label, tone, onClick }) {
81
+ return (_jsx("button", { type: "button", className: cx("nx-chat-msg__action", tone === "danger" && "nx-chat-msg__action--danger"), onClick: onClick, "aria-label": label, title: label, children: _jsx(InlineIcon, { icon: icon }) }));
82
+ }
83
+ /** Everyone in a conversation, with what each one is here. */
84
+ export function ChatParticipantList({ participants, userId, empty = "Nobody else yet." }) {
85
+ if (participants.length === 0)
86
+ return _jsx("p", { className: "nx-chat-aside__empty", children: empty });
87
+ return (_jsx("ul", { className: "nx-chat-people", children: participants.map(person => (_jsxs("li", { className: cx("nx-chat-person", person.inactive && "nx-chat-person--inactive"), children: [_jsx(ChatAvatar, { name: person.name, src: person.avatar, size: "sm" }), _jsxs("span", { className: "nx-chat-person__text", children: [_jsxs("span", { className: "nx-chat-person__name", children: [person.name, sameChatId(person.id, userId) && _jsx("span", { className: "nx-chat-person__you", children: "you" })] }), (person.role || person.readOnly) && _jsx("span", { className: "nx-chat-person__role", children: person.readOnly ? [person.role, "reads only"].filter(Boolean).join(" · ") : person.role })] })] }, String(person.id)))) }));
88
+ }
89
+ /** A thread with nothing in it yet. */
90
+ export function ChatEmpty({ message = "No messages yet.", hint }) {
91
+ return (_jsxs("div", { className: "nx-chat-empty", children: [_jsx("p", { children: message }), hint && _jsx("p", { className: "nx-chat-empty__hint", children: hint })] }));
92
+ }
93
+ /** Something that did not load, with a way to try again. */
94
+ export function ChatFailure({ message, onRetry }) {
95
+ return (_jsxs("div", { className: "nx-chat-failure", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: message }), onRetry && (_jsxs("button", { type: "button", onClick: onRetry, children: [_jsx(InlineIcon, { icon: iconRefresh }), " Try again"] }))] }));
96
+ }
97
+ /** Messages on their way in. */
98
+ export function ChatLoading({ label = "Loading…" }) {
99
+ return (_jsxs("div", { className: "nx-chat-loading", "aria-live": "polite", children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), _jsx("span", { children: label })] }));
100
+ }
@@ -0,0 +1,22 @@
1
+ import { type ChatTimeOptions } from "../../Helpers/ChatHelpers.ts";
2
+ import type { ChatSnapshot, ChatStore } from "../../Helpers/ChatStore.ts";
3
+ import type { ChatId, ChatMessage } from "../../Interfaces/ChatInterfaces.ts";
4
+ export interface ChatThreadProps {
5
+ store: ChatStore;
6
+ state: ChatSnapshot;
7
+ /** Who is reading: their messages sit on the right. */
8
+ userId: ChatId;
9
+ /** The user's date settings and zone. Default: their own, from local storage. */
10
+ time?: ChatTimeOptions;
11
+ /** A message can be answered. Default true. */
12
+ replies?: boolean;
13
+ /** A reply was started: the panel puts the message in the composer. */
14
+ onReply?: (message: ChatMessage) => void;
15
+ /** Reading the newest message marks the conversation read. Default true. */
16
+ marksRead?: boolean;
17
+ /** Shown when there is not a single message. */
18
+ emptyMessage?: string;
19
+ className?: string;
20
+ }
21
+ /** The messages of a conversation, in days and runs, with the lines that break them up. */
22
+ export declare function ChatThread({ store, state, userId, time, replies, onReply, marksRead, emptyMessage, className }: ChatThreadProps): import("react").JSX.Element;
@@ -0,0 +1,96 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconArrowDown, iconMessageCircle, InlineIcon } from "nexus-icons";
4
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { chatBadgeText, chatMessageKey, groupChatMessages, sameChatId } from "../../Helpers/ChatHelpers.js";
7
+ import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
8
+ import { Button } from "../Documents/Button.js";
9
+ import { ChatAvatar, ChatDayDivider, ChatEmpty, ChatFailure, ChatLoading, ChatMessageItem, ChatSystemNote, ChatUnreadLine } from "./ChatParts.js";
10
+ /*
11
+ * The thread: the conversation itself, scrolling. It opens at the newest message, keeps itself at the bottom while the
12
+ * reader is already there, and when they are not, it leaves the scroll alone and offers a button down to the newest
13
+ * with the number of messages that arrived. Reaching the top loads the page above and holds the reader's place.
14
+ *
15
+ * Reading marks the conversation read, but only once the newest message is actually on screen: a panel opened and left
16
+ * scrolled up has not been read.
17
+ */
18
+ /** How close to an edge counts as being at it. */
19
+ const EDGE = 32;
20
+ /** How near the top loading the page above starts. */
21
+ const TOP_EDGE = 120;
22
+ /** The messages of a conversation, in days and runs, with the lines that break them up. */
23
+ export function ChatThread({ store, state, userId, time, replies = true, onReply, marksRead = true, emptyMessage, className }) {
24
+ const preferences = useDatePreferences();
25
+ const zone = useBrowserTimeZone();
26
+ const settings = useMemo(() => ({ ...preferences, ...(zone && { timeZone: zone }), ...time }), [preferences, zone, time]);
27
+ const listRef = useRef(null);
28
+ const [atBottom, setAtBottom] = useState(true);
29
+ const [missed, setMissed] = useState(0);
30
+ // What the scroll looked like before a page of older messages arrived, so the reader's place can be given back.
31
+ const anchor = useRef(null);
32
+ const lastKey = useRef(null);
33
+ const count = useRef(0);
34
+ const days = useMemo(() => groupChatMessages(state.messages, { userId, unreadFrom: state.unreadFrom, ...settings }), [state.messages, state.unreadFrom, userId, settings]);
35
+ const byId = useMemo(() => new Map(state.messages.map(message => [String(message.id), message])), [state.messages]);
36
+ const scrollToEnd = useCallback((smooth) => {
37
+ const list = listRef.current;
38
+ if (!list)
39
+ return;
40
+ // Windows with its animations off reports reduced motion, and a smooth scroll there may never run: jump instead.
41
+ const reduced = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
42
+ list.scrollTo({ top: list.scrollHeight, behavior: smooth && !reduced ? "smooth" : "auto" });
43
+ }, []);
44
+ // New messages: stay at the bottom when already there, otherwise hold the place and count what came in.
45
+ useLayoutEffect(() => {
46
+ const list = listRef.current;
47
+ if (!list)
48
+ return;
49
+ if (anchor.current) {
50
+ // A page of older messages: the scroll keeps the message it was on, rather than jumping to the new top.
51
+ list.scrollTop = anchor.current.top + (list.scrollHeight - anchor.current.height);
52
+ anchor.current = null;
53
+ count.current = state.messages.length;
54
+ return;
55
+ }
56
+ const newest = state.messages[state.messages.length - 1];
57
+ const key = newest ? chatMessageKey(newest) : null;
58
+ const grew = state.messages.length > count.current;
59
+ const changed = key !== lastKey.current;
60
+ count.current = state.messages.length;
61
+ lastKey.current = key;
62
+ if (!grew || !changed)
63
+ return;
64
+ if (atBottom || sameChatId(newest?.authorId, userId))
65
+ scrollToEnd(lastKey.current !== null && missed === 0);
66
+ else
67
+ setMissed(value => value + 1);
68
+ // `missed` is read, not depended on: a second message must not re-run the first one's scroll.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [state.messages, atBottom, userId, scrollToEnd]);
71
+ // The conversation counts as read once its newest message has been on screen.
72
+ useEffect(() => {
73
+ if (!marksRead || !atBottom || state.loading || state.summary.unreadCount === 0)
74
+ return;
75
+ void store.markRead();
76
+ }, [marksRead, atBottom, state.loading, state.summary.unreadCount, store]);
77
+ const onScroll = useCallback(() => {
78
+ const list = listRef.current;
79
+ if (!list)
80
+ return;
81
+ const bottom = list.scrollHeight - list.scrollTop - list.clientHeight <= EDGE;
82
+ setAtBottom(bottom);
83
+ if (bottom)
84
+ setMissed(0);
85
+ if (list.scrollTop <= TOP_EDGE && state.hasMore && !state.loadingOlder) {
86
+ anchor.current = { height: list.scrollHeight, top: list.scrollTop };
87
+ void store.loadOlder();
88
+ }
89
+ }, [state.hasMore, state.loadingOlder, store]);
90
+ const quiet = !state.loading && state.messages.length === 0;
91
+ return (_jsxs("div", { className: cx("nx-chat-thread", className), children: [_jsxs("div", { className: "nx-chat-thread__list", ref: listRef, onScroll: onScroll, role: "log", "aria-live": "polite", "aria-relevant": "additions", tabIndex: 0, children: [state.loadingOlder && _jsx(ChatLoading, { label: "Loading earlier messages\u2026" }), !state.loadingOlder && state.hasMore && (_jsx("div", { className: "nx-chat-thread__older", children: _jsx(Button, { size: "sm", variant: "ghost", onClick: () => void store.loadOlder(), children: "Earlier messages" }) })), state.loading && state.messages.length === 0 && _jsx(ChatLoading, { label: "Loading the conversation\u2026" }), state.error && _jsx(ChatFailure, { message: state.error, onRetry: () => void store.refresh() }), quiet && !state.error && _jsx(ChatEmpty, { message: emptyMessage ?? "No messages yet.", hint: _jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconMessageCircle }), " Write the first one below."] }) }), days.map(day => (_jsxs("section", { className: "nx-chat-thread__day", children: [_jsx(ChatDayDivider, { label: day.label }), day.runs.map((run, runIndex) => (_jsxs(Fragment, { children: [run.startsUnread && _jsx(ChatUnreadLine, { count: state.summary.unreadCount }), _jsx("div", { className: cx("nx-chat-run", run.mine && "nx-chat-run--mine", run.system && "nx-chat-run--system"), children: run.system ? (run.messages.map(message => _jsx(ChatSystemNote, { message: message, time: settings }, chatMessageKey(message)))) : (_jsxs(_Fragment, { children: [!run.mine && _jsx(ChatAvatarColumn, { name: run.authorName, src: run.authorAvatar }), _jsx("div", { className: "nx-chat-run__messages", children: run.messages.map((message, index) => (_jsx(ChatMessageItem, { message: message, mine: run.mine, first: index === 0, last: index === run.messages.length - 1, time: settings, participants: state.participants, userId: userId, quoted: message.replyToId ? byId.get(String(message.replyToId)) : undefined, onReply: replies && onReply ? onReply : undefined, onRemove: run.mine && state.features.remove ? message => void store.remove(message.id) : undefined, onRetry: store.retry, onDiscard: store.discard }, chatMessageKey(message)))) })] })) })] }, `${day.date}-${runIndex}`)))] }, day.date)))] }), !atBottom && (_jsxs("button", { type: "button", className: "nx-chat-thread__jump", onClick: () => scrollToEnd(true), "aria-label": missed > 0 ? `${missed} new, go to the newest` : "Go to the newest message", children: [_jsx(InlineIcon, { icon: iconArrowDown }), missed > 0 && _jsx("span", { className: "nx-chat-thread__jump-count", children: chatBadgeText(missed) })] }))] }));
92
+ }
93
+ /** The avatar beside a run of somebody else's messages, kept out of the bubbles so they line up under each other. */
94
+ function ChatAvatarColumn({ name, src }) {
95
+ return (_jsx("div", { className: "nx-chat-run__avatar", children: _jsx(ChatAvatar, { name: name, src: src }) }));
96
+ }
@@ -101,25 +101,27 @@ export function MenuPanel({ id, items, onSelect, kind = "menu", size = "md", clo
101
101
  const role = ITEM_ROLE[kind];
102
102
  // When any item has an icon, every item keeps the icon column, so the labels line up.
103
103
  const hasIcons = items.some(entry => isMenuItem(entry) && entry.icon);
104
- return (_jsx("div", { ref: ref, id: id, popover: "auto", role: "menu", tabIndex: -1, className: cx("nx-popover", "nx-menu", `nx-menu--${size}`, className), onToggle: onToggle, onKeyDown: onKeyDown, ...labelling, children: items.map((entry, index) => {
105
- if (!isMenuItem(entry)) {
106
- if ("separator" in entry)
107
- return _jsx("div", { role: "separator", className: "nx-menu__separator" }, index);
108
- return (_jsx("div", { role: "presentation", className: "nx-menu__heading", children: entry.heading }, index));
109
- }
110
- const item = entry;
111
- const content = (_jsxs(_Fragment, { children: [kind !== "menu" && _jsx("span", { className: "nx-menu__check", children: item.checked && _jsx(InlineIcon, { icon: iconCheck }) }), hasIcons && _jsx("span", { className: "nx-menu__icon", children: item.icon }), _jsxs("span", { className: "nx-menu__body", children: [_jsx("span", { className: "nx-menu__label", children: item.label }), item.description && _jsx("span", { className: "nx-menu__description", children: item.description })] }), item.shortcut && _jsx("kbd", { className: "nx-menu__shortcut", children: item.shortcut })] }));
112
- const shared = {
113
- role,
114
- className: cx("nx-menu__item", item.tone === "danger" && "nx-menu__item--danger", item.checked && "nx-menu__item--checked"),
115
- tabIndex: -1,
116
- title: item.title,
117
- "aria-disabled": item.disabled || undefined,
118
- "aria-checked": kind === "menu" ? undefined : Boolean(item.checked),
119
- onPointerMove,
120
- };
121
- return item.href && !item.disabled ? (_jsx("a", { ...shared, href: item.href, target: item.target, rel: item.target === "_blank" ? "noopener" : undefined, onClick: event => choose(item, event), children: content }, item.value)) : (_jsx("button", { ...shared, type: "button", onClick: event => choose(item, event), children: content }, item.value));
122
- }) }));
104
+ return (_jsx("div", { ref: ref, id: id, popover: "auto", role: "menu", tabIndex: -1, className: cx("nx-popover", "nx-menu", `nx-menu--${size}`, className), onToggle: onToggle, onKeyDown: onKeyDown, ...labelling, children: _jsx("div", { role: "none", className: "nx-menu__list", children: items.map((entry, index) => {
105
+ // Items are keyed by their value, so a separator keyed by its index alone collided with an item whose value
106
+ // was that number ("5" in a menu of projects). The prefix keeps the two apart.
107
+ if (!isMenuItem(entry)) {
108
+ if ("separator" in entry)
109
+ return _jsx("div", { role: "separator", className: "nx-menu__separator" }, `nx-at-${index}`);
110
+ return (_jsx("div", { role: "presentation", className: "nx-menu__heading", children: entry.heading }, `nx-at-${index}`));
111
+ }
112
+ const item = entry;
113
+ const content = (_jsxs(_Fragment, { children: [kind !== "menu" && _jsx("span", { className: "nx-menu__check", children: item.checked && _jsx(InlineIcon, { icon: iconCheck }) }), hasIcons && _jsx("span", { className: "nx-menu__icon", children: item.icon }), _jsxs("span", { className: "nx-menu__body", children: [_jsx("span", { className: "nx-menu__label", children: item.label }), item.description && _jsx("span", { className: "nx-menu__description", children: item.description })] }), item.shortcut && _jsx("kbd", { className: "nx-menu__shortcut", children: item.shortcut })] }));
114
+ const shared = {
115
+ role,
116
+ className: cx("nx-menu__item", item.tone === "danger" && "nx-menu__item--danger", item.checked && "nx-menu__item--checked"),
117
+ tabIndex: -1,
118
+ title: item.title,
119
+ "aria-disabled": item.disabled || undefined,
120
+ "aria-checked": kind === "menu" ? undefined : Boolean(item.checked),
121
+ onPointerMove,
122
+ };
123
+ return item.href && !item.disabled ? (_jsx("a", { ...shared, href: item.href, target: item.target, rel: item.target === "_blank" ? "noopener" : undefined, onClick: event => choose(item, event), children: content }, item.value)) : (_jsx("button", { ...shared, type: "button", onClick: event => choose(item, event), children: content }, item.value));
124
+ }) }) }));
123
125
  }
124
126
  /**
125
127
  * A dropdown menu: a button that opens a list of actions in a native popover under it (above it when there is no
@@ -212,7 +214,9 @@ export function ContextMenu({ children, "aria-label": ariaLabel = "Context menu"
212
214
  const panelId = useId();
213
215
  const panelRef = useRef(null);
214
216
  // When the last press happened: a contextmenu event right after one comes from the mouse or a long touch.
215
- const pressedAtRef = useRef(0);
217
+ // When a pointer was last pressed. Never pressed has to be long ago, not zero: zero is 1.5 seconds after the page
218
+ // loaded, and a context menu opened from the keyboard in that moment would be placed as a pointer's.
219
+ const pressedAtRef = useRef(Number.NEGATIVE_INFINITY);
216
220
  function onContextMenu(event) {
217
221
  const panel = panelRef.current;
218
222
  if (disabled || !canShow(panel))
@@ -58,9 +58,11 @@ export function SplitButton({ options, onAction, defaultValue, value, onValueCha
58
58
  return null;
59
59
  function place() {
60
60
  const panel = panelRef.current;
61
- const anchor = toggleRef.current?.parentElement;
62
- if (panel && anchor)
63
- placeUnderAnchor(panel, anchor.getBoundingClientRect(), { align });
61
+ const toggle = toggleRef.current;
62
+ const anchor = toggle?.parentElement;
63
+ // The menu lines up with the whole button, and its arrow points at the half that opened it.
64
+ if (panel && toggle && anchor)
65
+ placeUnderAnchor(panel, anchor.getBoundingClientRect(), { align, arrowAt: toggle.getBoundingClientRect() });
64
66
  }
65
67
  function show(focusFirst) {
66
68
  const panel = panelRef.current;
@@ -19,9 +19,6 @@ export type TabButtonLabels = "all" | "selected";
19
19
  export type TabButtonDesign = "nav" | "list";
20
20
  interface TabButtonsBaseProps<T extends string> {
21
21
  options: readonly TabButtonOption<T>[];
22
- /** The selected option. A value that matches no option leaves every option unselected. */
23
- value: T;
24
- onChange: (value: T) => void;
25
22
  /** Default `nav`. `list` stacks the options as full-width rows with a bar at the selected one. */
26
23
  design?: TabButtonDesign;
27
24
  /** Default `md`. */
@@ -35,7 +32,7 @@ interface TabButtonsBaseProps<T extends string> {
35
32
  labels?: TabButtonLabels;
36
33
  /** Fills the width of its container. With `labels="all"`, options share the width equally. A `list` always fills it. */
37
34
  block?: boolean;
38
- /** Radio group name, for forms. Default: a unique id. */
35
+ /** Radio (or checkbox) group name, for forms. Default: a unique id. */
39
36
  name?: string;
40
37
  /**
41
38
  * Id of the form the choice belongs to, as the HTML `form` attribute. An id that matches no form keeps the choice out
@@ -44,8 +41,26 @@ interface TabButtonsBaseProps<T extends string> {
44
41
  form?: string;
45
42
  className?: string;
46
43
  }
44
+ /** One option at a time: radio buttons, and an indicator that slides to the selection. */
45
+ interface TabButtonsOneProps<T extends string> {
46
+ multiple?: false;
47
+ /** The selected option. A value that matches no option leaves every option unselected. */
48
+ value: T;
49
+ onChange: (value: T) => void;
50
+ }
51
+ /**
52
+ * Several options at once: checkboxes, each chosen option raised on its own, and the options wrap onto more rows when
53
+ * they do not fit, so a picker of 26 letters or 33 symbols stays one control on a phone.
54
+ */
55
+ interface TabButtonsManyProps<T extends string> {
56
+ multiple: true;
57
+ /** The chosen options. Values that match no option are ignored, and never handed back. */
58
+ value: readonly T[];
59
+ /** The chosen values after each change, in option order. */
60
+ onChange: (values: T[]) => void;
61
+ }
47
62
  /** A label is required: pass `aria-label`, or `aria-labelledby` pointing at a visible heading. */
48
- export type TabButtonsProps<T extends string = string> = TabButtonsBaseProps<T> & ({
63
+ export type TabButtonsProps<T extends string = string> = TabButtonsBaseProps<T> & (TabButtonsOneProps<T> | TabButtonsManyProps<T>) & ({
49
64
  "aria-label": string;
50
65
  "aria-labelledby"?: never;
51
66
  } | {
@@ -57,6 +72,9 @@ export type TabButtonsProps<T extends string = string> = TabButtonsBaseProps<T>
57
72
  * The `nav` design slides an indicator to the selection; the `list` design stacks the options as rows, each with an
58
73
  * icon, a label, a description, and a badge, for settings pages and side panels. Built on native radio buttons, so
59
74
  * arrow keys move the selection and screen readers announce it. To switch between page panels, use tabs with panels instead.
75
+ *
76
+ * `multiple` picks several at once, such as the weekdays a rule runs on: native checkboxes (Tab moves between them,
77
+ * Space toggles one), every chosen option raised as the single choice's indicator is, and the options wrap.
60
78
  */
61
- export declare function TabButtons<T extends string>({ options, value, onChange, design, size, tone, labels, block, name, form, className, ...labelling }: TabButtonsProps<T>): import("react").JSX.Element;
79
+ export declare function TabButtons<T extends string>(props: TabButtonsProps<T>): import("react").JSX.Element;
62
80
  export {};
@@ -7,13 +7,32 @@ import { cx } from "../../Helpers/ClassHelpers.js";
7
7
  * The `nav` design slides an indicator to the selection; the `list` design stacks the options as rows, each with an
8
8
  * icon, a label, a description, and a badge, for settings pages and side panels. Built on native radio buttons, so
9
9
  * arrow keys move the selection and screen readers announce it. To switch between page panels, use tabs with panels instead.
10
+ *
11
+ * `multiple` picks several at once, such as the weekdays a rule runs on: native checkboxes (Tab moves between them,
12
+ * Space toggles one), every chosen option raised as the single choice's indicator is, and the options wrap.
10
13
  */
11
- export function TabButtons({ options, value, onChange, design = "nav", size = "md", tone = "neutral", labels = "all", block = false, name, form, className, ...labelling }) {
14
+ export function TabButtons(props) {
15
+ const { options, design = "nav", size = "md", tone = "neutral", labels = "all", block = false, name, form, className, multiple: _multiple, value: _value, onChange: _onChange, ...labelling } = props;
12
16
  const generatedName = useId();
13
17
  const groupName = name ?? generatedName;
14
- const index = options.findIndex(option => option.value === value);
15
- const style = { "--nx-tabs-count": Math.max(options.length, 1), "--nx-tabs-index": Math.max(index, 0) };
16
18
  const list = design === "list";
17
19
  const labelsSelected = !list && labels === "selected";
18
- return (_jsxs("div", { role: "radiogroup", "aria-orientation": list ? "vertical" : undefined, ...labelling, className: cx("nx-tabs", `nx-tabs--${size}`, list && "nx-tabs--list", tone === "primary" && "nx-tabs--primary", labelsSelected && "nx-tabs--labels-selected", block && "nx-tabs--block", className), style: list ? undefined : style, children: [index >= 0 && !list && !labelsSelected && _jsx("span", { className: "nx-tabs__indicator", "aria-hidden": "true" }), options.map(option => (_jsxs("label", { className: "nx-tabs__option", title: option.title ?? (labelsSelected && typeof option.label === "string" ? option.label : undefined), children: [_jsx("input", { type: "radio", className: "nx-visually-hidden", name: groupName, form: form, value: option.value, checked: option.value === value, disabled: option.disabled, onChange: () => onChange(option.value) }), option.icon, _jsxs("span", { className: "nx-tabs__label", children: [_jsx("span", { className: "nx-tabs__text", children: option.label }), list && option.description && _jsx("span", { className: "nx-tabs__description", children: option.description })] }), list && option.badge !== undefined && option.badge !== null && _jsx("span", { className: "nx-tabs__badge", children: option.badge })] }, option.value)))] }));
20
+ const chosen = props.multiple ? new Set(props.value) : null;
21
+ // One choice slides one indicator; several have no single place for it, so each chosen option draws its own.
22
+ const index = chosen ? -1 : options.findIndex(option => option.value === props.value);
23
+ const style = { "--nx-tabs-count": Math.max(options.length, 1), "--nx-tabs-index": Math.max(index, 0) };
24
+ const isChecked = (option) => (chosen ? chosen.has(option.value) : option.value === props.value);
25
+ const toggle = (option, checked) => {
26
+ if (!props.multiple) {
27
+ props.onChange(option.value);
28
+ return;
29
+ }
30
+ const next = new Set(props.value);
31
+ if (checked)
32
+ next.add(option.value);
33
+ else
34
+ next.delete(option.value);
35
+ props.onChange(options.filter(one => next.has(one.value)).map(one => one.value));
36
+ };
37
+ return (_jsxs("div", { role: chosen ? "group" : "radiogroup", "aria-orientation": list && !chosen ? "vertical" : undefined, ...labelling, className: cx("nx-tabs", `nx-tabs--${size}`, list && "nx-tabs--list", tone === "primary" && "nx-tabs--primary", labelsSelected && "nx-tabs--labels-selected", chosen && !list && "nx-tabs--multiple", block && "nx-tabs--block", className), style: list || chosen ? undefined : style, children: [index >= 0 && !list && !labelsSelected && _jsx("span", { className: "nx-tabs__indicator", "aria-hidden": "true" }), options.map(option => (_jsxs("label", { className: "nx-tabs__option", title: option.title ?? (labelsSelected && typeof option.label === "string" ? option.label : undefined), children: [_jsx("input", { type: chosen ? "checkbox" : "radio", className: "nx-visually-hidden", name: groupName, form: form, value: option.value, checked: isChecked(option), disabled: option.disabled, onChange: event => toggle(option, event.currentTarget.checked) }), option.icon, _jsxs("span", { className: "nx-tabs__label", children: [_jsx("span", { className: "nx-tabs__text", children: option.label }), list && option.description && _jsx("span", { className: "nx-tabs__description", children: option.description })] }), list && option.badge !== undefined && option.badge !== null && _jsx("span", { className: "nx-tabs__badge", children: option.badge })] }, option.value)))] }));
19
38
  }
@@ -2,10 +2,12 @@ import type { ApiFormProps, FormValues } from "../../Interfaces/FormInterfaces.t
2
2
  /**
3
3
  * A `SubmitForm` that sends its values to an API: an endpoint (`ROOMS.update`, or `{ module, path, method }` in place),
4
4
  * a URL outside the app's modules (`{ url, method }`, as JSON), both sent through the app's `api` without its toasts, or
5
- * a function that sends them with a client of the app's own. The answer is read as Nexus API responses (`{ isSuccess, message,
6
- * inputName, result }`), ASP.NET Core problem details, or any JSON by its status. On success a toast says so and the
7
- * values become the form's own; on failure the server's messages show on the inputs they name, the rest under the form,
8
- * and the first input with a message takes focus. `loadValue` loads the record to edit, with placeholders and Retry.
5
+ * a function that sends them with a client of the app's own. The answer is read as Nexus API responses
6
+ * (`{ isSuccess, errorMessages: [{ message, inputName }], result }`, or one message at the top level), ASP.NET Core problem
7
+ * details, or any JSON by its status. On success a toast says so and the values become the form's own; on failure the
8
+ * server's own words show - every message it sent, on the inputs they name and the rest under the form, with our message
9
+ * by status only for an answer that explains nothing - and the first input with a message takes focus. `loadValue` loads
10
+ * the record to edit, with placeholders and Retry.
9
11
  *
10
12
  * @example
11
13
  * const ROOMS = apiController(NexusModule.Reservation, "Room", "room");
@@ -2,12 +2,12 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { iconAlertCircle, iconRefresh, InlineIcon } from "nexus-icons";
4
4
  import { useEffect, useRef, useState } from "react";
5
- import { abortError, apiRequestOptions, matchFieldErrors, toApiResult } from "../../Helpers/ApiFormHelpers.js";
6
- import { apiStatusMessage, errorMessage, isAbortError, readApiResponse } from "../../Helpers/ApiResponses.js";
5
+ import { abortError, apiRequestOptions, formErrorsFromResult, toApiResult } from "../../Helpers/ApiFormHelpers.js";
6
+ import { errorMessage, isAbortError, readApiResponse } from "../../Helpers/ApiResponses.js";
7
7
  import { isApiEndpoint } from "../../Helpers/ApiRoutes.js";
8
8
  import { cx } from "../../Helpers/ClassHelpers.js";
9
9
  import { messages } from "../../Helpers/MessageBuilder.js";
10
- import { api as browserApi } from "../../Services/BrowserApi.js";
10
+ import { api as browserApi, getApiReader } from "../../Services/BrowserApi.js";
11
11
  import { Button } from "../Documents/Button.js";
12
12
  import { toast } from "../Layouts/Toaster.js";
13
13
  import { useFormStore } from "./Form.js";
@@ -15,10 +15,12 @@ import { SubmitForm } from "./SubmitForm.js";
15
15
  /**
16
16
  * A `SubmitForm` that sends its values to an API: an endpoint (`ROOMS.update`, or `{ module, path, method }` in place),
17
17
  * a URL outside the app's modules (`{ url, method }`, as JSON), both sent through the app's `api` without its toasts, or
18
- * a function that sends them with a client of the app's own. The answer is read as Nexus API responses (`{ isSuccess, message,
19
- * inputName, result }`), ASP.NET Core problem details, or any JSON by its status. On success a toast says so and the
20
- * values become the form's own; on failure the server's messages show on the inputs they name, the rest under the form,
21
- * and the first input with a message takes focus. `loadValue` loads the record to edit, with placeholders and Retry.
18
+ * a function that sends them with a client of the app's own. The answer is read as Nexus API responses
19
+ * (`{ isSuccess, errorMessages: [{ message, inputName }], result }`, or one message at the top level), ASP.NET Core problem
20
+ * details, or any JSON by its status. On success a toast says so and the values become the form's own; on failure the
21
+ * server's own words show - every message it sent, on the inputs they name and the rest under the form, with our message
22
+ * by status only for an answer that explains nothing - and the first input with a message takes focus. `loadValue` loads
23
+ * the record to edit, with placeholders and Retry.
22
24
  *
23
25
  * @example
24
26
  * const ROOMS = apiController(NexusModule.Reservation, "Room", "room");
@@ -58,12 +60,13 @@ export function ApiForm(props) {
58
60
  return () => controller.abort();
59
61
  }, [store, attempt]);
60
62
  async function send(values, context) {
61
- const { api, transform, readResponse = readApiResponse } = latest.current;
63
+ // No `readResponse` means the client's own reader: the app's (`configureApi({ read })`), else the built-in one.
64
+ const { api, transform, readResponse } = latest.current;
62
65
  answerRef.current = null;
63
66
  const body = transform ? await transform(values, context) : values;
64
67
  let answer;
65
68
  if (typeof api === "function")
66
- answer = await toApiResult(await api(body, context), readResponse);
69
+ answer = await toApiResult(await api(body, context), readResponse ?? getApiReader() ?? readApiResponse);
67
70
  else {
68
71
  // The form shows its own messages, so the call shows no toasts.
69
72
  const options = isApiEndpoint(api) ? { body, signal: context.signal, read: readResponse } : apiRequestOptions(api, body, { signal: context.signal, read: readResponse });
@@ -76,12 +79,10 @@ export function ApiForm(props) {
76
79
  return { ok: true };
77
80
  }
78
81
  latest.current.onError?.(answer, values);
82
+ // Every message the server sent: the ones it put on inputs (Nexus `inputName`) show there, the rest under the form.
79
83
  const names = store.inputs.filter(params => !params.hidden).map(params => params.name);
80
- const { fields, others } = matchFieldErrors(answer.errors, names);
81
- // A message the server put on inputs (Nexus `inputName`) shows there, not twice.
82
- const onInputs = Boolean(answer.message) && Object.values(fields).includes(answer.message);
83
- const message = [onInputs ? undefined : answer.message, ...others].filter(Boolean).join(" ");
84
- return { ok: false, errors: fields, message: message || (Object.keys(fields).length ? null : apiStatusMessage(answer.status)) };
84
+ const { errors, message } = formErrorsFromResult(answer, names);
85
+ return { ok: false, errors, message };
85
86
  }
86
87
  function saved(values) {
87
88
  const answer = answerRef.current;