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,243 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { iconArrowBackUp, iconColumns3, iconGripVertical, iconPin, InlineIcon } from "nexus-icons";
4
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { followAnchor, isPopoverOpen, placeUnderAnchor } from "../../Helpers/PopoverHelpers.js";
7
+ import { Button } from "../Documents/Button.js";
8
+ /** The rows of the panel: a band for every run of neighbours that name the same group, as the header draws them. */
9
+ function panelRows(columns, groups) {
10
+ const rows = [];
11
+ for (const column of columns) {
12
+ const group = groups.get(column.key);
13
+ const last = rows[rows.length - 1];
14
+ if (group && last && last.band === group)
15
+ last.columns.push(column);
16
+ else if (group)
17
+ rows.push({ band: group, columns: [column] });
18
+ else
19
+ rows.push({ band: null, column });
20
+ }
21
+ return rows;
22
+ }
23
+ /** The keys of a row, in order: one column, or every column of a band. */
24
+ function rowKeys(row) {
25
+ return row.band === null ? [row.column.key] : row.columns.map(column => column.key);
26
+ }
27
+ /** Moves `from` to `to` in a list. */
28
+ function moveItem(items, from, to) {
29
+ const next = [...items];
30
+ const [item] = next.splice(from, 1);
31
+ if (item === undefined)
32
+ return next;
33
+ next.splice(to, 0, item);
34
+ return next;
35
+ }
36
+ /** Which place the pointer is over, by the middle of each row: the index the row held would take. */
37
+ function dropIndex(boxes, y, from) {
38
+ for (let index = 0; index < boxes.length; index++) {
39
+ const box = boxes[index];
40
+ if (y < box.top + box.height / 2)
41
+ return index > from ? index - 1 : index;
42
+ }
43
+ return boxes.length - 1;
44
+ }
45
+ /** The room kept between the panel and the window's edge, and the least height it is ever given. */
46
+ const PANEL_MARGIN = 8;
47
+ const PANEL_MIN_HEIGHT = 160;
48
+ /** The text of a label that may be a node, for an accessible name. */
49
+ function textOf(label) {
50
+ return typeof label === "string" || typeof label === "number" ? String(label) : "the column";
51
+ }
52
+ /**
53
+ * The button that opens the Columns panel, and the panel itself: a checkbox per column, bands with their columns under
54
+ * them, and a handle to drag a row into another place. The panel is a popover under the button, so it closes on Esc,
55
+ * on a click outside, and on Tab.
56
+ *
57
+ * @example
58
+ * <TableColumnsPanel columns={columns} hidden={state.hidden} pinned={state.pinned}
59
+ * onToggle={(key, hide) => table.setHidden(key, hide)} onOrder={keys => table.setOrder(keys)} />
60
+ */
61
+ export function TableColumnsPanel({ columns, hidden, pinned, reorderable = true, size = "md", changed = false, onToggle, onOrder, onReset, "aria-label": ariaLabel = "Columns", }) {
62
+ const id = useId();
63
+ const panelId = `${id}-columns`;
64
+ const triggerId = `${id}-columns-trigger`;
65
+ const triggerRef = useRef(null);
66
+ const panelRef = useRef(null);
67
+ const [open, setOpen] = useState(false);
68
+ // Set on pointer down while the panel is open: that press closes it, and its click must not open it again.
69
+ const dismissing = useRef(false);
70
+ // While a row is held: its key, the list it may move in, and where it would land.
71
+ const [drag, setDrag] = useState(null);
72
+ const [said, setSaid] = useState("");
73
+ // The columns of a band that were already hidden when its checkbox was cleared: shown again, the band keeps them off.
74
+ const bandMemory = useRef(new Map());
75
+ const all = columns.map(column => ({
76
+ key: column.key,
77
+ label: column.label,
78
+ shown: !hidden.has(column.key),
79
+ pin: pinned[column.key] ?? column.pinned ?? null,
80
+ movable: reorderable && column.movable !== false,
81
+ hideable: column.hideable !== false,
82
+ }));
83
+ const groups = new Map(columns.map(column => [column.key, column.group]));
84
+ const rows = panelRows(all, groups);
85
+ const shownCount = all.filter(column => column.shown).length;
86
+ const bands = rows.some(row => row.band !== null);
87
+ function place() {
88
+ const panel = panelRef.current;
89
+ const trigger = triggerRef.current;
90
+ if (!panel || !trigger)
91
+ return;
92
+ // Measured at its own height, then capped to the room on the side it landed on: a table with many columns keeps
93
+ // its heading, its note and Reset in the window, and the list of columns scrolls instead.
94
+ panel.style.maxHeight = "";
95
+ placeUnderAnchor(panel, trigger.getBoundingClientRect(), { align: "end" });
96
+ const top = panel.getBoundingClientRect().top;
97
+ panel.style.maxHeight = `${Math.max(PANEL_MIN_HEIGHT, window.innerHeight - top - PANEL_MARGIN)}px`;
98
+ }
99
+ // The panel is fixed, so it follows the button while the page or a scroller moves.
100
+ useLayoutEffect(() => {
101
+ if (!open)
102
+ return;
103
+ return followAnchor(place);
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [open]);
106
+ // A panel opened again starts without the last move's message, so a screen reader is not told about an old move.
107
+ useEffect(() => {
108
+ if (!open)
109
+ setSaid("");
110
+ }, [open]);
111
+ /** The whole order after moving a row of `list` ("" for the panel itself, a band's name for its own columns). */
112
+ function moved(list, from, to) {
113
+ if (list === "") {
114
+ const next = moveItem(rows, from, to);
115
+ return next.flatMap(rowKeys);
116
+ }
117
+ const band = rows.find(row => row.band === list);
118
+ if (!band || band.band === null)
119
+ return null;
120
+ const next = moveItem(band.columns, from, to);
121
+ return rows.flatMap(row => (row.band === list ? next.map(column => column.key) : rowKeys(row)));
122
+ }
123
+ function move(list, from, to, label) {
124
+ const length = list === "" ? rows.length : rows.find(row => row.band === list)?.columns.length;
125
+ if (!length)
126
+ return;
127
+ const target = Math.min(Math.max(to, 0), length - 1);
128
+ if (target === from)
129
+ return;
130
+ const order = moved(list, from, target);
131
+ if (!order)
132
+ return;
133
+ onOrder(order);
134
+ setSaid(list === "" ? `${label} is now ${target + 1} of ${length}.` : `${label} is now ${target + 1} of ${length} in ${list}.`);
135
+ }
136
+ function onHandleKeyDown(event, list, index, label) {
137
+ const length = list === "" ? rows.length : (rows.find(row => row.band === list)?.columns.length ?? 0);
138
+ const step = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : event.key === "Home" ? -index : event.key === "End" ? length - 1 - index : 0;
139
+ if (!step)
140
+ return;
141
+ event.preventDefault();
142
+ move(list, index, index + step, label);
143
+ }
144
+ function onHandlePointerDown(event, list, index, key, label) {
145
+ if (event.button !== 0)
146
+ return;
147
+ // A row moves inside its own list only: a band among the bands, a column of a band among its siblings.
148
+ const owner = event.currentTarget.closest("[data-list]");
149
+ if (!owner)
150
+ return;
151
+ const handle = event.currentTarget;
152
+ const boxes = [...owner.children].map(child => child.getBoundingClientRect());
153
+ handle.setPointerCapture(event.pointerId);
154
+ setDrag({ key, list, to: index });
155
+ const end = (done, y) => {
156
+ handle.removeEventListener("pointermove", onMove);
157
+ handle.removeEventListener("pointerup", up);
158
+ handle.removeEventListener("pointercancel", cancel);
159
+ handle.removeEventListener("keydown", escape);
160
+ setDrag(null);
161
+ if (done && y !== undefined)
162
+ move(list, index, dropIndex(boxes, y, index), label);
163
+ };
164
+ const onMove = (moved) => setDrag({ key, list, to: dropIndex(boxes, moved.clientY, index) });
165
+ const up = (moved) => {
166
+ if (handle.hasPointerCapture(moved.pointerId))
167
+ handle.releasePointerCapture(moved.pointerId);
168
+ end(true, moved.clientY);
169
+ };
170
+ const cancel = () => end(false);
171
+ // Esc puts the row back where it was.
172
+ const escape = (key) => {
173
+ if (key.key !== "Escape")
174
+ return;
175
+ key.preventDefault();
176
+ key.stopPropagation();
177
+ end(false);
178
+ };
179
+ handle.addEventListener("pointermove", onMove);
180
+ handle.addEventListener("pointerup", up);
181
+ handle.addEventListener("pointercancel", cancel);
182
+ handle.addEventListener("keydown", escape);
183
+ }
184
+ /** The band's checkbox: off hides every column under it, on brings back the ones it was not already hiding. */
185
+ function toggleBand(band, columnsOfBand, show) {
186
+ if (show) {
187
+ const kept = new Set(bandMemory.current.get(band) ?? []);
188
+ bandMemory.current.delete(band);
189
+ for (const column of columnsOfBand)
190
+ if (!column.shown && !kept.has(column.key))
191
+ onToggle(column.key, false);
192
+ return;
193
+ }
194
+ bandMemory.current.set(band, columnsOfBand.filter(column => !column.shown).map(column => column.key));
195
+ for (const column of columnsOfBand)
196
+ if (column.shown)
197
+ onToggle(column.key, true);
198
+ }
199
+ function hidePopover(panel) {
200
+ if (isPopoverOpen(panel))
201
+ panel.hidePopover();
202
+ }
203
+ /** One column's row: the handle, the checkbox, the label, and a mark when it is pinned. */
204
+ function columnRow(column, list, index, inBand, bandOff) {
205
+ const lastShown = column.shown && shownCount <= 1;
206
+ return (_jsxs("li", { className: cx("nx-columns__row", inBand && "nx-columns__row--child"), "data-held": drag?.key === column.key ? "" : undefined, "data-drop": drag && drag.list === list && drag.key !== column.key && drag.to === index ? "" : undefined, children: [column.movable && !bandOff ? (_jsx("button", { type: "button", className: "nx-columns__handle", "aria-label": `Move ${textOf(column.label)}`, title: inBand ? `Drag to move it inside ${list}, or use the arrow keys` : "Drag to move, or use the arrow keys", onPointerDown: event => onHandlePointerDown(event, list, index, column.key, textOf(column.label)), onKeyDown: event => onHandleKeyDown(event, list, index, textOf(column.label)), children: _jsx(InlineIcon, { icon: iconGripVertical }) })) : (_jsx("span", { className: "nx-columns__handle nx-columns__handle--fixed", "aria-hidden": "true" })), _jsxs("label", { className: "nx-choice nx-choice--sm nx-columns__choice", children: [_jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: column.shown && !bandOff, disabled: bandOff || !column.hideable || lastShown, title: bandOff ? `${list} is hidden` : column.hideable ? undefined : "Always shown", onChange: event => onToggle(column.key, !event.currentTarget.checked) }), _jsx("span", { className: "nx-choice__text", children: _jsx("span", { className: "nx-choice__label nx-columns__label", children: column.label }) })] }), column.pin && (_jsxs("span", { className: "nx-columns__pin", title: `Pinned to the ${column.pin === "left" ? "left" : "right"}`, children: [_jsx(InlineIcon, { icon: iconPin }), _jsx("span", { className: "nx-visually-hidden", children: `Pinned to the ${column.pin === "left" ? "left" : "right"}` })] }))] }, column.key));
207
+ }
208
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { ref: triggerRef, id: triggerId, size: size === "sm" ? "sm" : "md", icon: _jsx(InlineIcon, { icon: iconColumns3 }), "aria-label": ariaLabel, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": panelId, title: "Columns: show, hide, and arrange", onPointerDown: () => {
209
+ dismissing.current = isPopoverOpen(panelRef.current);
210
+ }, onClick: () => {
211
+ if (dismissing.current) {
212
+ dismissing.current = false;
213
+ return;
214
+ }
215
+ const panel = panelRef.current;
216
+ if (!panel)
217
+ return;
218
+ if (isPopoverOpen(panel))
219
+ hidePopover(panel);
220
+ else {
221
+ panel.showPopover();
222
+ place();
223
+ panel.focus();
224
+ }
225
+ } }), _jsxs("div", { ref: panelRef, id: panelId, popover: "auto", role: "dialog", tabIndex: -1, "aria-labelledby": `${panelId}-title`, className: cx("nx-popover", "nx-columns", size === "sm" && "nx-columns--sm"), "data-dragging": drag ? "" : undefined, onToggle: (event) => setOpen(event.newState === "open"), onKeyDown: event => {
226
+ if (event.key === "Tab")
227
+ hidePopover(event.currentTarget);
228
+ }, children: [_jsxs("div", { className: "nx-columns__head", children: [_jsx("span", { className: "nx-columns__title", id: `${panelId}-title`, children: "Columns" }), _jsxs("span", { className: "nx-columns__count", children: [shownCount, " of ", all.length, " shown"] })] }), _jsx("ul", { className: "nx-columns__list", "data-list": "", children: rows.map((row, index) => {
229
+ if (row.band === null)
230
+ return columnRow(row.column, "", index, false, false);
231
+ const band = row.band;
232
+ // A band whose columns were split into two runs is drawn twice, so a row is known by its first column, never
233
+ // by the band's name: two rows under one name would be one row to React.
234
+ const bandId = `band:${row.columns[0].key}`;
235
+ const shown = row.columns.filter(column => column.shown).length;
236
+ const movable = row.columns.every(column => column.movable);
237
+ const last = shown > 0 && shownCount - shown < 1;
238
+ return (_jsxs("li", { className: "nx-columns__row nx-columns__row--band", "data-held": drag?.key === bandId ? "" : undefined, "data-drop": drag && drag.list === "" && drag.key !== bandId && drag.to === index ? "" : undefined, children: [_jsxs("div", { className: "nx-columns__band", children: [movable ? (_jsx("button", { type: "button", className: "nx-columns__handle", "aria-label": `Move ${band}`, title: "Drag to move the whole band, or use the arrow keys", onPointerDown: event => onHandlePointerDown(event, "", index, bandId, band), onKeyDown: event => onHandleKeyDown(event, "", index, band), children: _jsx(InlineIcon, { icon: iconGripVertical }) })) : (_jsx("span", { className: "nx-columns__handle nx-columns__handle--fixed", "aria-hidden": "true" })), _jsxs("label", { className: "nx-choice nx-choice--sm nx-columns__choice", children: [_jsx("input", { type: "checkbox", className: "nx-choice__control nx-choice__control--checkbox", checked: shown > 0, disabled: last, ref: input => {
239
+ if (input)
240
+ input.indeterminate = shown > 0 && shown < row.columns.length;
241
+ }, "aria-label": `${band}: ${shown} of ${row.columns.length} shown`, onChange: event => toggleBand(band, row.columns, event.currentTarget.checked) }), _jsx("span", { className: "nx-choice__text", children: _jsx("span", { className: "nx-choice__label nx-columns__label", children: band }) })] }), _jsxs("span", { className: "nx-columns__band-count", children: [shown, " / ", row.columns.length] })] }), _jsx("ul", { className: "nx-columns__children", "data-list": band, children: row.columns.map((column, childIndex) => columnRow(column, band, childIndex, true, shown === 0)) })] }, bandId));
242
+ }) }), _jsxs("p", { className: "nx-columns__note", children: [reorderable ? "Drag a handle, or move it with the arrow keys." : "This table keeps the order of its columns.", bands ? " A column under a band moves inside it; the band moves as one." : ""] }), onReset && (_jsx("div", { className: "nx-columns__foot", children: _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), disabled: !changed, onClick: () => onReset(), children: "Reset columns" }) })), _jsx("span", { className: "nx-visually-hidden", "aria-live": "polite", children: said })] })] }));
243
+ }
@@ -38,8 +38,10 @@ export declare function TableColumnGroup<TRow extends TableRow>({ columns, width
38
38
  colRefs: Map<string, HTMLTableColElement>;
39
39
  }): import("react").JSX.Element;
40
40
  /** The number of a row, its checkbox, or both: the number until the pointer is over the row or something is selected. */
41
- export declare function TableIndexCell({ heading, own, number, numbered, selectable, checkbox, }: {
41
+ export declare function TableIndexCell({ heading, rowSpan, own, number, numbered, selectable, checkbox, }: {
42
42
  heading?: boolean;
43
+ /** 2 in the heading while the header has a band of group headings above the columns' own. */
44
+ rowSpan?: number;
43
45
  own: {
44
46
  offset: number;
45
47
  edge: boolean;
@@ -74,9 +76,11 @@ export interface TableRowViewProps<TRow extends TableRow> {
74
76
  onToggle: (row: TRow, on: boolean, event: MouseEvent<HTMLInputElement>) => void;
75
77
  onAction: (row: TRow, action: TableRowAction<TRow>) => void;
76
78
  onOpen?: (row: TRow) => void;
79
+ /** A single click opens the row, as a double-click does. */
80
+ openOnClick?: boolean;
77
81
  }
78
82
  /** One row. It renders again only when its row, its state, or the columns change. */
79
- declare function TableRowViewInner<TRow extends TableRow>({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, }: TableRowViewProps<TRow>): import("react").JSX.Element;
83
+ declare function TableRowViewInner<TRow extends TableRow>({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, }: TableRowViewProps<TRow>): import("react").JSX.Element;
80
84
  export declare const TableRowView: typeof TableRowViewInner;
81
85
  /**
82
86
  * The head of the pinned section: one row across the table with a button that folds the pinned records open and shut,
@@ -75,7 +75,7 @@ export function TableColumnGroup({ columns, widths, frame, colRefs, }) {
75
75
  return (_jsxs("colgroup", { children: [own(TABLE_INDEX_COLUMN, Boolean(frame.index)), columns.map(view => (_jsx("col", { ref: hold(view.column.key), style: { width: widths[view.column.key] ?? cssLength(view.column.width) ?? FALLBACK_COLUMN_WIDTH } }, view.column.key))), _jsx("col", { className: "nx-table__col-filler" }), own(TABLE_ACTIONS_COLUMN, Boolean(frame.actions))] }));
76
76
  }
77
77
  /** The number of a row, its checkbox, or both: the number until the pointer is over the row or something is selected. */
78
- export function TableIndexCell({ heading, own, number, numbered, selectable, checkbox, }) {
78
+ export function TableIndexCell({ heading, rowSpan, own, number, numbered, selectable, checkbox, }) {
79
79
  const shared = {
80
80
  className: cx(heading ? "nx-table__heading" : "nx-table__cell", "nx-table__cell--index"),
81
81
  "data-col": TABLE_INDEX_COLUMN,
@@ -86,19 +86,31 @@ export function TableIndexCell({ heading, own, number, numbered, selectable, che
86
86
  style: ownStyle(own, "left"),
87
87
  };
88
88
  const content = (_jsxs(_Fragment, { children: [numbered && (_jsx("span", { className: "nx-table__number", "aria-hidden": selectable || undefined, children: number })), checkbox] }));
89
- return heading ? (_jsx("th", { scope: "col", ...shared, children: content })) : (_jsx("td", { ...shared, children: content }));
89
+ return heading ? (_jsx("th", { scope: "col", rowSpan: rowSpan, ...shared, children: content })) : (_jsx("td", { ...shared, children: content }));
90
+ }
91
+ /** What in a row is a control of its own: a click there does what the control does, and never opens the row. */
92
+ const ROW_CONTROLS = "button, a, input, select, textarea, label, [role=menu], [role=menuitem]";
93
+ /**
94
+ * Whether a click on a row should open it. Never on one of its controls. A single click also does not when it ends a
95
+ * text selection inside the row — somebody dragging across a cell to copy a code is not asking for a popup — but a
96
+ * double-click is not asked that, because a double-click selects the word under the pointer on its way in.
97
+ */
98
+ function opensRow(event, single) {
99
+ if (event.target.closest(ROW_CONTROLS))
100
+ return false;
101
+ if (!single)
102
+ return true;
103
+ const selection = window.getSelection();
104
+ return !(selection && !selection.isCollapsed && event.currentTarget.contains(selection.anchorNode));
90
105
  }
91
106
  /** One row. It renders again only when its row, its state, or the columns change. */
92
- function TableRowViewInner({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, }) {
107
+ function TableRowViewInner({ row, rowKey, number, label, columns, cellOptions, selectable, selected, selectionFull, busy, flashed, numbered, pinned, frame, sized, actions, onToggle, onAction, onOpen, openOnClick, }) {
93
108
  const inline = actions?.filter(action => action.inline && action.icon) ?? [];
94
109
  const inMenu = actions?.filter(action => !(action.inline && action.icon)) ?? [];
95
110
  const items = inMenu.map(action => ({ value: action.id, label: action.label, icon: action.icon, description: action.description, tone: action.tone, disabled: action.disabled }));
96
- return (_jsxs("tr", { className: "nx-table__row", "data-key": rowKey, "data-pinned": pinned || undefined, "data-selected": selected || undefined, "data-busy": busy || undefined, "data-flash": flashed || undefined, "aria-busy": busy || undefined, onDoubleClick: onOpen &&
97
- (event => {
98
- if (event.target.closest("button, a, input, label, [role=menu]"))
99
- return;
100
- onOpen(row);
101
- }), children: [frame.index && (_jsx(TableIndexCell, { own: frame.index, number: pinned ? _jsx(InlineIcon, { icon: iconPin, className: "nx-table__pin-mark" }) : formatCount(number), numbered: numbered || Boolean(pinned), 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 }) })) })), 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}` })] }) }))] }));
111
+ return (_jsxs("tr", { className: "nx-table__row", "data-key": rowKey, "data-pinned": pinned || undefined, "data-selected": selected || undefined, "data-busy": busy || undefined, "data-flash": flashed || undefined, "aria-busy": busy || undefined, "data-open": (onOpen && openOnClick) || undefined,
112
+ // One way in: with a single click the row is already open by the time a double-click would arrive.
113
+ onClick: onOpen && openOnClick ? event => opensRow(event, true) && onOpen(row) : undefined, onDoubleClick: onOpen && !openOnClick ? event => opensRow(event, false) && onOpen(row) : undefined, children: [frame.index && (_jsx(TableIndexCell, { own: frame.index, number: pinned ? _jsx(InlineIcon, { icon: iconPin, className: "nx-table__pin-mark" }) : formatCount(number), numbered: numbered || Boolean(pinned), 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 }) })) })), 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}` })] }) }))] }));
102
114
  }
103
115
  export const TableRowView = memo(TableRowViewInner);
104
116
  /**
@@ -12,6 +12,22 @@ export interface ApiCall<TOptions = object> {
12
12
  /** What the call does and acts on, for its messages. */
13
13
  context: MessageContext;
14
14
  }
15
+ /**
16
+ * Reads a server's answer as an `ApiResult`. A client's `read` is the app's default for every call that does not bring
17
+ * its own (`ApiCallOptions.read`); `call` says what was asked for, and is left out for an answer that came from
18
+ * somewhere other than a call (an app's own send function).
19
+ *
20
+ * @example
21
+ * // A backend that answers { success, message, data, error: { code, message, fields } }
22
+ * configureApi({
23
+ * read: (body, status) => {
24
+ * const answer = body as { success?: boolean; message?: string; data?: unknown; error?: { code: string; fields?: Record<string, string[]> } };
25
+ * if (answer?.success) return { ok: true, status, result: answer.data, message: answer.message };
26
+ * return { ok: false, status, message: answer?.message, messageCode: answer?.error?.code, errors: … };
27
+ * },
28
+ * });
29
+ */
30
+ export type ApiResponseReader<TOptions = object> = (body: unknown, status: number | undefined, call?: ApiCall<TOptions>) => ApiResult;
15
31
  export interface ApiClientConfig<TOptions = object> {
16
32
  /** The full URL of an endpoint, given its filled path: through the proxy in the browser, the module's root on a server. */
17
33
  resolveUrl: (endpoint: ApiEndpoint, path: string, call: ApiCall<TOptions>) => string | Promise<string>;
@@ -24,6 +40,12 @@ export interface ApiClientConfig<TOptions = object> {
24
40
  /** Milliseconds to wait for an answer. Default 60,000. */
25
41
  timeout?: number;
26
42
  credentials?: RequestCredentials;
43
+ /**
44
+ * Reads every answer the call itself did not bring a reader for. Default `readApiResponse`: Nexus API responses,
45
+ * ASP.NET Core problem details, and any JSON by its status. An app on another backend registers its own once, so
46
+ * components that call inside themselves (`Crud`, `ApiForm`) read that backend's envelope too.
47
+ */
48
+ read?: ApiResponseReader<TOptions>;
27
49
  /** Changes the request just before it is sent, such as Next.js caching on a server. */
28
50
  prepare?: (init: RequestInit, call: ApiCall<TOptions>) => RequestInit;
29
51
  /**
@@ -99,7 +99,10 @@ export function createApiClient(config) {
99
99
  controller.abort();
100
100
  }, timeout)
101
101
  : undefined;
102
- const read = options.read ?? ((answer, status) => readApiResponse(answer, status, call.context));
102
+ // The call's own reader, else the app's (`configureApi({ read })`), else the built-in one. The app's is looked up
103
+ // on every call, so registering it after a module was imported still takes effect.
104
+ const appRead = config.read;
105
+ const read = options.read ?? (appRead ? (answer, status) => appRead(answer, status, call) : (answer, status) => readApiResponse(answer, status, call.context));
103
106
  const stopped = () => caller?.aborted ? aborted() : timedOut ? { ok: false, timeout: true, message: buildMessage("reason", { ...call.context, timeout: true }) } : undefined;
104
107
  let init = { method, headers, body, credentials: options.credentials ?? config.credentials, signal: controller.signal };
105
108
  if (config.prepare)
@@ -4,7 +4,10 @@ export interface SendApiOptions {
4
4
  signal?: AbortSignal;
5
5
  read?: (body: unknown, status: number | undefined) => ApiResult;
6
6
  }
7
- /** The call options of a form's `{ url, method, headers, bodyType, credentials }` request with its values. */
7
+ /**
8
+ * The call options of a form's `{ url, method, headers, bodyType, credentials }` request with its values. Without a
9
+ * `read` the client's own reader is used: the app's (`configureApi({ read })`), else the built-in one.
10
+ */
8
11
  export declare function apiRequestOptions(request: ApiRequest, body: unknown, { signal, read }?: SendApiOptions): ApiCallOptions;
9
12
  /** An abort as the error `fetch` throws, for code that expects a stopped request to reject. */
10
13
  export declare function abortError(): Error;
@@ -16,11 +19,31 @@ export declare function sendApiRequest(request: ApiRequest, body: unknown, optio
16
19
  /** What an `ApiSend` function answered, read the same way: a `Response`, an `ApiResult`, or a body. */
17
20
  export declare function toApiResult(answer: unknown, read?: (body: unknown, status: number | undefined) => ApiResult): Promise<ApiResult>;
18
21
  /**
19
- * Puts a server's messages on the inputs they name. Names match without case, and a path matches its first part that is
20
- * an input ("Addresses[1].City" and "$.addresses" go to "addresses"; "dto.Email" to "email"). Messages that match no
21
- * input are returned in `others`, for the form's message.
22
+ * The input a server's field name belongs to, or `undefined` when the form has no such input. Names match without case,
23
+ * and a path matches its first part that is an input ("Addresses[1].City" and "$.addresses" go to "addresses";
24
+ * "dto.Email" to "email"). The one rule every server message is matched by.
25
+ */
26
+ export declare function matchInputName(name: string, inputNames: readonly string[]): string | undefined;
27
+ /**
28
+ * Puts a server's messages on the inputs they name, by `matchInputName`. Messages that match no input are returned in
29
+ * `others`, for the form's message.
22
30
  */
23
31
  export declare function matchFieldErrors(errors: Record<string, string> | undefined, inputNames: readonly string[]): {
24
32
  fields: FormErrors;
25
33
  others: string[];
26
34
  };
35
+ /**
36
+ * A failed answer as what a form shows, through the one path a 400's field errors already take: every message the server
37
+ * put on an input (`inputName`, several named with "|") goes to that input, every other message - including one naming an
38
+ * input this form does not have - goes under the form, and nothing is shown twice. `message` is `null` when the inputs
39
+ * carry it all. An answer that explains nothing falls back to a message for its HTTP status; that is the only time our
40
+ * own wording is shown.
41
+ *
42
+ * @example
43
+ * const { errors, message } = formErrorsFromResult(answer, store.inputs.map(params => params.name));
44
+ * store.setErrors(errors, message);
45
+ */
46
+ export declare function formErrorsFromResult(answer: ApiResult, inputNames: readonly string[]): {
47
+ errors: FormErrors;
48
+ message: string | null;
49
+ };
@@ -1,7 +1,10 @@
1
1
  import { createApiClient } from "./ApiClient.js";
2
- import { isApiResult, readApiResponse, readFetchResponse } from "./ApiResponses.js";
3
- /** The call options of a form's `{ url, method, headers, bodyType, credentials }` request with its values. */
4
- export function apiRequestOptions(request, body, { signal, read = readApiResponse } = {}) {
2
+ import { apiStatusMessage, isApiResult, readApiResponse, readFetchResponse } from "./ApiResponses.js";
3
+ /**
4
+ * The call options of a form's `{ url, method, headers, bodyType, credentials }` request with its values. Without a
5
+ * `read` the client's own reader is used: the app's (`configureApi({ read })`), else the built-in one.
6
+ */
7
+ export function apiRequestOptions(request, body, { signal, read } = {}) {
5
8
  const { method = "POST", headers, bodyType = "json", credentials } = request;
6
9
  return { method, headers, bodyType, credentials, signal, read, body: body ?? {} };
7
10
  }
@@ -33,20 +36,33 @@ export async function toApiResult(answer, read = readApiResponse) {
33
36
  return answer;
34
37
  return read(answer, undefined);
35
38
  }
39
+ function inputsByName(inputNames) {
40
+ return new Map(inputNames.map(name => [name.toLowerCase(), name]));
41
+ }
42
+ function matchIn(key, byName) {
43
+ const parts = key.replace(/^\$\.?/, "").split(/[.[\]]+/).filter(Boolean);
44
+ return parts.slice(0, 2).map(part => byName.get(part.toLowerCase())).find(Boolean);
45
+ }
36
46
  /**
37
- * Puts a server's messages on the inputs they name. Names match without case, and a path matches its first part that is
38
- * an input ("Addresses[1].City" and "$.addresses" go to "addresses"; "dto.Email" to "email"). Messages that match no
39
- * input are returned in `others`, for the form's message.
47
+ * The input a server's field name belongs to, or `undefined` when the form has no such input. Names match without case,
48
+ * and a path matches its first part that is an input ("Addresses[1].City" and "$.addresses" go to "addresses";
49
+ * "dto.Email" to "email"). The one rule every server message is matched by.
50
+ */
51
+ export function matchInputName(name, inputNames) {
52
+ return matchIn(name, inputsByName(inputNames));
53
+ }
54
+ /**
55
+ * Puts a server's messages on the inputs they name, by `matchInputName`. Messages that match no input are returned in
56
+ * `others`, for the form's message.
40
57
  */
41
58
  export function matchFieldErrors(errors, inputNames) {
42
- const byName = new Map(inputNames.map(name => [name.toLowerCase(), name]));
59
+ const byName = inputsByName(inputNames);
43
60
  const fields = {};
44
61
  const others = [];
45
62
  for (const [key, message] of Object.entries(errors ?? {})) {
46
63
  if (!message)
47
64
  continue;
48
- const parts = key.replace(/^\$\.?/, "").split(/[.[\]]+/).filter(Boolean);
49
- const name = parts.slice(0, 2).map(part => byName.get(part.toLowerCase())).find(Boolean);
65
+ const name = matchIn(key, byName);
50
66
  if (name)
51
67
  fields[name] = fields[name] && fields[name] !== message ? `${fields[name]} ${message}` : message;
52
68
  else if (!others.includes(message))
@@ -54,3 +70,51 @@ export function matchFieldErrors(errors, inputNames) {
54
70
  }
55
71
  return { fields, others };
56
72
  }
73
+ /**
74
+ * A failed answer as what a form shows, through the one path a 400's field errors already take: every message the server
75
+ * put on an input (`inputName`, several named with "|") goes to that input, every other message - including one naming an
76
+ * input this form does not have - goes under the form, and nothing is shown twice. `message` is `null` when the inputs
77
+ * carry it all. An answer that explains nothing falls back to a message for its HTTP status; that is the only time our
78
+ * own wording is shown.
79
+ *
80
+ * @example
81
+ * const { errors, message } = formErrorsFromResult(answer, store.inputs.map(params => params.name));
82
+ * store.setErrors(errors, message);
83
+ */
84
+ export function formErrorsFromResult(answer, inputNames) {
85
+ const byName = inputsByName(inputNames);
86
+ const fields = {};
87
+ const others = [];
88
+ const place = (message, key) => {
89
+ const sentence = message?.trim();
90
+ if (!sentence)
91
+ return;
92
+ const name = key ? matchIn(key, byName) : undefined;
93
+ if (name)
94
+ fields[name] = fields[name] && fields[name] !== sentence ? `${fields[name]} ${sentence}` : sentence;
95
+ else if (!others.includes(sentence))
96
+ others.push(sentence);
97
+ };
98
+ if (answer.errorMessages?.length) {
99
+ for (const problem of answer.errorMessages) {
100
+ const named = (problem.inputName?.split("|") ?? []).map(part => part.trim()).filter(name => name && matchIn(name, byName));
101
+ if (named.length)
102
+ for (const name of named)
103
+ place(problem.message, name);
104
+ // No input of this form: shown with the others, never swallowed.
105
+ else
106
+ place(problem.message);
107
+ }
108
+ }
109
+ else {
110
+ for (const [key, message] of Object.entries(answer.errors ?? {}))
111
+ place(message, key);
112
+ // The answer's own message, when an input does not already show it.
113
+ if (!Object.values(fields).includes(answer.message ?? ""))
114
+ place(answer.message);
115
+ }
116
+ const message = others.join(" ");
117
+ if (message)
118
+ return { errors: fields, message };
119
+ return { errors: fields, message: Object.keys(fields).length ? null : apiStatusMessage(answer.status) };
120
+ }
@@ -84,8 +84,6 @@ export declare abstract class ApiModules<TModule extends ApiModuleValue = ApiMod
84
84
  delete: ApiEndpoint<TModule>;
85
85
  trash: ApiEndpoint<TModule>;
86
86
  trashFromDelete: ApiEndpoint<TModule>;
87
- archive: ApiEndpoint<TModule>;
88
- restore: ApiEndpoint<TModule>;
89
87
  recover: ApiEndpoint<TModule>;
90
88
  flag: ApiEndpoint<TModule>;
91
89
  pin: ApiEndpoint<TModule>;
@@ -93,14 +91,12 @@ export declare abstract class ApiModules<TModule extends ApiModuleValue = ApiMod
93
91
  accessActions: ApiEndpoint<TModule>;
94
92
  mass: {
95
93
  description: ApiEndpoint<TModule>;
96
- archive: ApiEndpoint<TModule>;
97
94
  trash: ApiEndpoint<TModule>;
98
95
  trashFromDelete: ApiEndpoint<TModule>;
99
96
  flag: ApiEndpoint<TModule>;
100
97
  pin: ApiEndpoint<TModule>;
101
98
  delete: ApiEndpoint<TModule>;
102
99
  recover: ApiEndpoint<TModule>;
103
- restore: ApiEndpoint<TModule>;
104
100
  };
105
101
  action: (method: import("../Shared.Index.ts").HttpMethod, action: string, extras?: Partial<Omit<ApiEndpoint<number>, "path" | "method" | "module">> | undefined) => ApiEndpoint<TModule>;
106
102
  };
@@ -115,6 +115,7 @@ export const NEXUS_MODULES = [
115
115
  { module: NexusModule.Enterprise, key: "enterprise", label: "Enterprise", env: "API_ENTERPRISE" },
116
116
  { module: NexusModule.Drive, key: "drive", label: "Drive", env: "API_DRIVE" },
117
117
  { module: NexusModule.Message, key: "message", label: "Message", env: "API_MESSAGE" },
118
+ { module: NexusModule.Chat, key: "chat", label: "Chat", env: "API_CHAT" },
118
119
  { module: NexusModule.Accounting, key: "accounting", label: "Accounting", env: "API_ACCOUNTING" },
119
120
  { module: NexusModule.Report, key: "report", label: "Report", env: "API_REPORT" },
120
121
  { module: NexusModule.Requisition, key: "requisition", label: "Requisition", env: "API_REQUISITION" },
@@ -1,17 +1,27 @@
1
- import type { ApiResult } from "../Interfaces/ApiInterfaces.ts";
1
+ import type { ApiErrorMessage, ApiResult } from "../Interfaces/ApiInterfaces.ts";
2
2
  import type { MessageAction, MessageContext } from "../Interfaces/MessageInterfaces.ts";
3
3
  /** Shown when nothing reached the server (the default wording; `messages.reason({ network: true })` follows `configureMessages`). */
4
4
  export declare const NETWORK_ERROR_MESSAGE: string;
5
5
  /** A message for a failed request that came without one of its own, by HTTP status and what the request did. Default action `save`. */
6
6
  export declare function apiStatusMessage(status: number | undefined, action?: MessageAction): string;
7
+ /**
8
+ * Every problem a body lists, in its order, however it listed them: `errorMessages` as a list or as one message object,
9
+ * `errorMessage` either way, or an `errors` list (an `errors` map by field name is read by `readErrorMap` instead).
10
+ * Plain sentences count. Empty for a body that lists nothing; `fallback` is then read as the one problem, which is how
11
+ * the message and codes at the top level of a Nexus answer are read.
12
+ */
13
+ export declare function readErrorMessages(body: unknown, fallback?: unknown): ApiErrorMessage[];
7
14
  /**
8
15
  * Reads a server's answer as success or failure:
9
- * - Nexus API responses, `{ isSuccess, message, messageCode, errorCode, inputName, result }`: `inputName` names the
10
- * inputs the message is about, several joined with "|".
16
+ * - Nexus API responses, `{ isSuccess, code, errorMessages: [{ message, messageCode, errorCode, inputName }], result }`:
17
+ * every problem is kept, in the server's order, and `inputName` names the inputs a message is about (several with "|").
18
+ * `errorMessages` may be one message object instead of a list, and an older answer carries its one message and codes at
19
+ * the top level (`{ isSuccess, message, messageCode, errorCode, inputName }`); all of them are read the same way.
11
20
  * - ASP.NET Core problem details and validation errors, `{ title, detail, errors: { Email: ["…"] } }`.
12
21
  * - Anything else by its HTTP status; the body is the result.
13
22
  *
14
- * A failure without a message of its own gets one by status, worded for what the call did (`context`, default: a save).
23
+ * The server's own words are the message whenever it sent any. Only a failure that explains nothing gets a message by
24
+ * status, worded for what the call did (`context`, default: a save).
15
25
  */
16
26
  export declare function readApiResponse(body: unknown, status?: number, context?: MessageContext): ApiResult;
17
27
  /** Reads a `fetch` response: its JSON (or text) through `read`. */