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.
- package/CHANGELOG.md +262 -0
- package/README.md +122 -25
- package/dist/Client.Index.d.ts +11 -0
- package/dist/Client.Index.js +11 -0
- package/dist/Components/Chats/Chat.d.ts +28 -0
- package/dist/Components/Chats/Chat.js +19 -0
- package/dist/Components/Chats/ChatButton.d.ts +26 -0
- package/dist/Components/Chats/ChatButton.js +41 -0
- package/dist/Components/Chats/ChatComposer.d.ts +41 -0
- package/dist/Components/Chats/ChatComposer.js +177 -0
- package/dist/Components/Chats/ChatConversations.d.ts +35 -0
- package/dist/Components/Chats/ChatConversations.js +38 -0
- package/dist/Components/Chats/ChatPanel.d.ts +66 -0
- package/dist/Components/Chats/ChatPanel.js +94 -0
- package/dist/Components/Chats/ChatParts.d.ts +87 -0
- package/dist/Components/Chats/ChatParts.js +100 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Menu.js +24 -20
- package/dist/Components/Documents/SplitButton.js +5 -3
- package/dist/Components/Documents/TabButtons.d.ts +24 -6
- package/dist/Components/Documents/TabButtons.js +23 -4
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +202 -50
- package/dist/Components/Forms/ExcelImport.d.ts +42 -0
- package/dist/Components/Forms/ExcelImport.js +190 -0
- package/dist/Components/Forms/Form.js +5 -1
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/GroupForm.js +4 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
- package/dist/Components/Inputs/InputRenderer.js +1 -1
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +53 -4
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +85 -20
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
- package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
- package/dist/Components/Viewers/DataTable.js +88 -34
- package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
- package/dist/Components/Viewers/DataTableColumns.js +243 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
- package/dist/Components/Viewers/DataTableParts.js +21 -9
- package/dist/Helpers/ApiClient.d.ts +22 -0
- package/dist/Helpers/ApiClient.js +4 -1
- package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
- package/dist/Helpers/ApiFormHelpers.js +73 -9
- package/dist/Helpers/ApiModules.d.ts +0 -4
- package/dist/Helpers/ApiModules.js +1 -0
- package/dist/Helpers/ApiResponses.d.ts +14 -4
- package/dist/Helpers/ApiResponses.js +116 -25
- package/dist/Helpers/ApiRoutes.d.ts +0 -10
- package/dist/Helpers/ApiRoutes.js +2 -10
- package/dist/Helpers/ChatBackend.d.ts +134 -0
- package/dist/Helpers/ChatBackend.js +332 -0
- package/dist/Helpers/ChatHelpers.d.ts +189 -0
- package/dist/Helpers/ChatHelpers.js +486 -0
- package/dist/Helpers/ChatHooks.d.ts +81 -0
- package/dist/Helpers/ChatHooks.js +175 -0
- package/dist/Helpers/ChatStore.d.ts +132 -0
- package/dist/Helpers/ChatStore.js +394 -0
- package/dist/Helpers/ChatSummaries.d.ts +39 -0
- package/dist/Helpers/ChatSummaries.js +118 -0
- package/dist/Helpers/CrudBackend.d.ts +47 -5
- package/dist/Helpers/CrudBackend.js +99 -4
- package/dist/Helpers/CrudHelpers.d.ts +39 -4
- package/dist/Helpers/CrudHelpers.js +76 -11
- package/dist/Helpers/ExcelBackend.d.ts +106 -0
- package/dist/Helpers/ExcelBackend.js +290 -0
- package/dist/Helpers/ExcelHelpers.d.ts +98 -0
- package/dist/Helpers/ExcelHelpers.js +302 -0
- package/dist/Helpers/FormStore.d.ts +2 -0
- package/dist/Helpers/FormStore.js +19 -1
- package/dist/Helpers/MessageBuilder.js +0 -2
- package/dist/Helpers/PopoverHelpers.d.ts +31 -6
- package/dist/Helpers/PopoverHelpers.js +116 -12
- package/dist/Helpers/RowsStore.d.ts +7 -1
- package/dist/Helpers/RowsStore.js +22 -0
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableHelpers.d.ts +9 -1
- package/dist/Helpers/TableHelpers.js +20 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
- package/dist/Interfaces/ApiInterfaces.js +16 -10
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/dist/Interfaces/ChatInterfaces.js +1 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +73 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +52 -2
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/TableInterfaces.d.ts +62 -2
- package/dist/Services/BrowserApi.d.ts +18 -1
- package/dist/Services/BrowserApi.js +18 -0
- package/dist/Services/ChatApi.d.ts +37 -0
- package/dist/Services/ChatApi.js +98 -0
- package/dist/Services/ExcelApi.d.ts +46 -0
- package/dist/Services/ExcelApi.js +196 -0
- package/dist/Services/ServerApi.d.ts +6 -1
- package/dist/Services/ServerApi.js +3 -0
- package/dist/Shared.Index.d.ts +8 -0
- package/dist/Shared.Index.js +8 -0
- package/package.json +6 -3
- package/src/Styles/Nexus.Button.css +2 -0
- package/src/Styles/Nexus.Chat.css +1489 -0
- package/src/Styles/Nexus.Crud.css +77 -0
- package/src/Styles/Nexus.Excel.css +370 -0
- package/src/Styles/Nexus.Form.css +23 -0
- package/src/Styles/Nexus.Index.css +2 -0
- package/src/Styles/Nexus.Menu.css +12 -2
- package/src/Styles/Nexus.Rows.css +78 -48
- package/src/Styles/Nexus.Tab.Buttons.css +55 -0
- package/src/Styles/Nexus.Table.css +286 -4
- package/src/Styles/Nexus.Theme.Picker.css +75 -2
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cells of the header a column stands in, from left to right. The header is one row, or — when any column names a
|
|
3
|
+
* group — a band of group headings over a row holding the headings of the grouped columns only, where a column with no
|
|
4
|
+
* group keeps its heading in the band row across both (`rowSpan`). A band says how many headings it covers with its
|
|
5
|
+
* `colSpan`, so the two rows read back as one list: the table measures the columns from it, and a drag drops among them.
|
|
6
|
+
*/
|
|
7
|
+
export declare function headLeafCells(head: HTMLTableSectionElement | null | undefined): HTMLElement[];
|
|
1
8
|
export interface ColumnDragOptions {
|
|
2
9
|
/** The heading pressed. */
|
|
3
10
|
heading: HTMLElement;
|
|
@@ -9,18 +9,46 @@ const DRAG_THRESHOLD = 4;
|
|
|
9
9
|
/** Pixels from the edge of the scroller where a drag scrolls the columns. */
|
|
10
10
|
const EDGE = 52;
|
|
11
11
|
const KEY = "data-col";
|
|
12
|
+
const GROUP = "data-group";
|
|
12
13
|
const DROP_BEFORE = "data-drop-before";
|
|
13
14
|
const DROP_AFTER = "data-drop-after";
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The cells of the header a column stands in, from left to right. The header is one row, or — when any column names a
|
|
17
|
+
* group — a band of group headings over a row holding the headings of the grouped columns only, where a column with no
|
|
18
|
+
* group keeps its heading in the band row across both (`rowSpan`). A band says how many headings it covers with its
|
|
19
|
+
* `colSpan`, so the two rows read back as one list: the table measures the columns from it, and a drag drops among them.
|
|
20
|
+
*/
|
|
21
|
+
export function headLeafCells(head) {
|
|
22
|
+
const band = head?.rows[0];
|
|
23
|
+
if (!band)
|
|
18
24
|
return [];
|
|
25
|
+
const top = [...band.cells];
|
|
26
|
+
const leaves = head?.rows[1];
|
|
27
|
+
if (!leaves)
|
|
28
|
+
return top;
|
|
29
|
+
const under = [...leaves.cells];
|
|
30
|
+
const cells = [];
|
|
31
|
+
let at = 0;
|
|
32
|
+
for (const cell of top) {
|
|
33
|
+
if (!cell.hasAttribute(GROUP))
|
|
34
|
+
cells.push(cell);
|
|
35
|
+
else
|
|
36
|
+
for (let covered = 0; covered < cell.colSpan; covered++)
|
|
37
|
+
if (under[at])
|
|
38
|
+
cells.push(under[at++]);
|
|
39
|
+
}
|
|
40
|
+
return cells;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The headings a dragged one can be dropped between: the ones pinned to the same side, in the order they are drawn.
|
|
44
|
+
* They are taken from the whole header, so a column under a band and one beside it are the same list.
|
|
45
|
+
*/
|
|
46
|
+
function dropTargets(head, heading) {
|
|
19
47
|
const pin = heading.getAttribute("data-pin") ?? "";
|
|
20
|
-
return
|
|
48
|
+
return headLeafCells(head).filter(cell => cell.hasAttribute(KEY) && (cell.getAttribute("data-pin") ?? "") === pin);
|
|
21
49
|
}
|
|
22
|
-
function clearDropMarks(
|
|
23
|
-
for (const cell of
|
|
50
|
+
function clearDropMarks(head) {
|
|
51
|
+
for (const cell of head?.querySelectorAll(`[${DROP_BEFORE}], [${DROP_AFTER}]`) ?? []) {
|
|
24
52
|
cell.removeAttribute(DROP_BEFORE);
|
|
25
53
|
cell.removeAttribute(DROP_AFTER);
|
|
26
54
|
}
|
|
@@ -31,8 +59,8 @@ function clearDropMarks(row) {
|
|
|
31
59
|
*/
|
|
32
60
|
export function startColumnDrag(event, { heading, scroller, root, onDrop }) {
|
|
33
61
|
const key = heading.getAttribute(KEY) ?? "";
|
|
34
|
-
const
|
|
35
|
-
if (!key || !
|
|
62
|
+
const head = heading.closest("thead");
|
|
63
|
+
if (!key || !head)
|
|
36
64
|
return () => undefined;
|
|
37
65
|
const pointerId = event.pointerId;
|
|
38
66
|
const startX = event.clientX;
|
|
@@ -42,8 +70,8 @@ export function startColumnDrag(event, { heading, scroller, root, onDrop }) {
|
|
|
42
70
|
let scrollBy = 0;
|
|
43
71
|
let frame = 0;
|
|
44
72
|
function place(x) {
|
|
45
|
-
const targets = dropTargets(heading);
|
|
46
|
-
clearDropMarks(
|
|
73
|
+
const targets = dropTargets(head, heading);
|
|
74
|
+
clearDropMarks(head);
|
|
47
75
|
target = null;
|
|
48
76
|
for (let index = 0; index < targets.length; index++) {
|
|
49
77
|
const cell = targets[index];
|
|
@@ -63,7 +91,7 @@ export function startColumnDrag(event, { heading, scroller, root, onDrop }) {
|
|
|
63
91
|
}
|
|
64
92
|
// A drop where the column already sits changes nothing, so no line shows there.
|
|
65
93
|
if (target.before === key || cellKey === key) {
|
|
66
|
-
clearDropMarks(
|
|
94
|
+
clearDropMarks(head);
|
|
67
95
|
target = null;
|
|
68
96
|
}
|
|
69
97
|
return;
|
|
@@ -144,7 +172,7 @@ export function startColumnDrag(event, { heading, scroller, root, onDrop }) {
|
|
|
144
172
|
cancelAnimationFrame(frame);
|
|
145
173
|
if (!dragging)
|
|
146
174
|
return;
|
|
147
|
-
clearDropMarks(
|
|
175
|
+
clearDropMarks(head);
|
|
148
176
|
heading.removeAttribute("data-dragging");
|
|
149
177
|
root?.removeAttribute("data-col-drag");
|
|
150
178
|
if (heading.hasPointerCapture?.(pointerId))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DatePreferences } from "../Interfaces/DateInterfaces.ts";
|
|
2
|
-
import type { TableColumn, TableColumnOption, TableKey, TablePage, TablePinSide, TableQuery, TableRow, TableSort, TableViewColumn } from "../Interfaces/TableInterfaces.ts";
|
|
2
|
+
import type { TableColumn, TableColumnOption, TableHeadBand, TableKey, TablePage, TablePinSide, TableQuery, TableRow, TableSort, TableViewColumn } from "../Interfaces/TableInterfaces.ts";
|
|
3
3
|
/** Rows per page when nothing says otherwise. */
|
|
4
4
|
export declare const DEFAULT_PAGE_SIZE = 10;
|
|
5
5
|
/** Choices of rows per page. */
|
|
@@ -100,3 +100,11 @@ export interface ViewColumnsOptions extends ColumnRights {
|
|
|
100
100
|
export declare function viewColumns<TRow extends TableRow>(columns: readonly TableColumn<TRow>[], options?: ViewColumnsOptions): TableViewColumn<TRow>[];
|
|
101
101
|
/** Whether the user may pin this column to a side. */
|
|
102
102
|
export declare function canPinColumn(column: Pick<TableColumn, "pinnable">, rights: ColumnRights): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* The columns cut into bands: each run of columns next to each other that name the same group, and one band of its own
|
|
105
|
+
* for every column that names none. The runs come from the order on screen, so a column moved out of its run keeps its
|
|
106
|
+
* group's heading where it landed and the run it left draws that heading again.
|
|
107
|
+
*
|
|
108
|
+
* A run also ends where the pinned side changes: a heading cannot stay on the left and on the right at once.
|
|
109
|
+
*/
|
|
110
|
+
export declare function headBands<TRow extends TableRow>(columns: readonly TableViewColumn<TRow>[]): TableHeadBand<TRow>[];
|
|
@@ -279,3 +279,23 @@ export function viewColumns(columns, options = {}) {
|
|
|
279
279
|
export function canPinColumn(column, rights) {
|
|
280
280
|
return (rights.pinnable ?? true) && column.pinnable !== false;
|
|
281
281
|
}
|
|
282
|
+
/* The bands above the headings: a group's heading over the columns next to each other that name it */
|
|
283
|
+
/**
|
|
284
|
+
* The columns cut into bands: each run of columns next to each other that name the same group, and one band of its own
|
|
285
|
+
* for every column that names none. The runs come from the order on screen, so a column moved out of its run keeps its
|
|
286
|
+
* group's heading where it landed and the run it left draws that heading again.
|
|
287
|
+
*
|
|
288
|
+
* A run also ends where the pinned side changes: a heading cannot stay on the left and on the right at once.
|
|
289
|
+
*/
|
|
290
|
+
export function headBands(columns) {
|
|
291
|
+
const bands = [];
|
|
292
|
+
for (const view of columns) {
|
|
293
|
+
const group = view.column.group?.trim() || null;
|
|
294
|
+
const last = bands[bands.length - 1];
|
|
295
|
+
if (group && last && last.group === group && last.columns[0].pin === view.pin)
|
|
296
|
+
last.columns.push(view);
|
|
297
|
+
else
|
|
298
|
+
bands.push({ group, columns: [view] });
|
|
299
|
+
}
|
|
300
|
+
return bands;
|
|
301
|
+
}
|
|
@@ -4,6 +4,11 @@ export type ApiModuleValue = string | number;
|
|
|
4
4
|
/**
|
|
5
5
|
* The Nexus backend modules, the modules of `NexusApiModules`. The values are the backend's `EModules` flags, so they
|
|
6
6
|
* can be combined and compared with permissions. Another backend has an enum of its own and its own `ApiModules` class.
|
|
7
|
+
*
|
|
8
|
+
* **These numbers are the backend's, not this package's.** A permission is a bitmask of them, so a value that differs
|
|
9
|
+
* from the backend by one bit does not fail loudly: it silently grants or denies the wrong module. `Chat` was added
|
|
10
|
+
* at bit 6 in the backend on 2026-09-25 and every module after it moved up one bit; this list moved with it, which
|
|
11
|
+
* changed the value of `Accounting` through `Portfolio`. See CHANGELOG.md.
|
|
7
12
|
*/
|
|
8
13
|
export declare const NexusModule: {
|
|
9
14
|
/** The app's own API routes (`/api/...`), not a backend module. */
|
|
@@ -14,6 +19,7 @@ export declare const NexusModule: {
|
|
|
14
19
|
readonly Enterprise: number;
|
|
15
20
|
readonly Drive: number;
|
|
16
21
|
readonly Message: number;
|
|
22
|
+
readonly Chat: number;
|
|
17
23
|
readonly Accounting: number;
|
|
18
24
|
readonly Report: number;
|
|
19
25
|
readonly Requisition: number;
|
|
@@ -79,6 +85,20 @@ export interface ApiEndpoint<TModule extends ApiModuleValue = AppModule> {
|
|
|
79
85
|
}
|
|
80
86
|
/** Where a call goes: an endpoint, or a URL ("/api/demo-rooms", "https://…"). */
|
|
81
87
|
export type ApiTarget = ApiEndpoint | string;
|
|
88
|
+
/**
|
|
89
|
+
* One problem in a failed answer, as the backend explains it: plain words for the user, the codes behind them, and the
|
|
90
|
+
* input it is about. A backend answers every wrong field at once, so a failure can carry several.
|
|
91
|
+
*/
|
|
92
|
+
export interface ApiErrorMessage {
|
|
93
|
+
/** What the user is shown: the server's own sentence ("This Region name already exists."). */
|
|
94
|
+
message: string;
|
|
95
|
+
/** The code of that exact refusal ("RGN_X009"), for the log and for a page to react to. Never shown to the user. */
|
|
96
|
+
messageCode?: string;
|
|
97
|
+
/** The kind of problem as a number (Nexus `errorCode`). Never shown to the user. */
|
|
98
|
+
errorCode?: number;
|
|
99
|
+
/** The input the message is about, as the server names it ("regionName"); several named with "|". */
|
|
100
|
+
inputName?: string;
|
|
101
|
+
}
|
|
82
102
|
/** A server's answer, read as success or failure. Every call answers with one; it never throws for a failed request. */
|
|
83
103
|
export interface ApiResult<T = unknown> {
|
|
84
104
|
ok: boolean;
|
|
@@ -86,12 +106,14 @@ export interface ApiResult<T = unknown> {
|
|
|
86
106
|
status?: number;
|
|
87
107
|
/** What the server sent back: the record, the list, the saved id. */
|
|
88
108
|
result?: T;
|
|
89
|
-
/** The server's message, or one built for the failure. */
|
|
109
|
+
/** The server's own message (its problems joined, in its order), or one built for the failure by status. */
|
|
90
110
|
message?: string;
|
|
91
|
-
/** The
|
|
111
|
+
/** The first problem's code for its message, such as "RGN_X009" (Nexus `messageCode`). Never shown as the sentence. */
|
|
92
112
|
messageCode?: string;
|
|
93
|
-
/** The
|
|
113
|
+
/** The first problem's error number (Nexus `errorCode`). Never shown as the sentence. */
|
|
94
114
|
errorCode?: number;
|
|
115
|
+
/** Every problem the server explained, in its order. A failure it explained carries at least one. */
|
|
116
|
+
errorMessages?: ApiErrorMessage[];
|
|
95
117
|
/** Messages by field name, as the server names them ("Email", "addresses[1].city"); matched to inputs without case. */
|
|
96
118
|
errors?: Record<string, string>;
|
|
97
119
|
/** Nothing reached the server: offline, or the server is down. */
|
|
@@ -136,7 +158,10 @@ export interface ApiCallOptions {
|
|
|
136
158
|
/** Tries again after a network failure or a 502, 503, or 504. Default 1 for GET, 0 for the rest (a save must not run twice). */
|
|
137
159
|
retries?: number;
|
|
138
160
|
credentials?: RequestCredentials;
|
|
139
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Reads the answer. Default: the app's reader (`configureApi({ read })` / `configureServerApi({ read })`), else
|
|
163
|
+
* `readApiResponse`: Nexus answers, ASP.NET Core problem details, any JSON by its status.
|
|
164
|
+
*/
|
|
140
165
|
read?: (body: unknown, status: number | undefined) => ApiResult;
|
|
141
166
|
/** What the call does, for its messages. Default: the endpoint's, or by method. */
|
|
142
167
|
action?: MessageAction;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Nexus backend modules, the modules of `NexusApiModules`. The values are the backend's `EModules` flags, so they
|
|
3
3
|
* can be combined and compared with permissions. Another backend has an enum of its own and its own `ApiModules` class.
|
|
4
|
+
*
|
|
5
|
+
* **These numbers are the backend's, not this package's.** A permission is a bitmask of them, so a value that differs
|
|
6
|
+
* from the backend by one bit does not fail loudly: it silently grants or denies the wrong module. `Chat` was added
|
|
7
|
+
* at bit 6 in the backend on 2026-09-25 and every module after it moved up one bit; this list moved with it, which
|
|
8
|
+
* changed the value of `Accounting` through `Portfolio`. See CHANGELOG.md.
|
|
4
9
|
*/
|
|
5
10
|
export const NexusModule = {
|
|
6
11
|
/** The app's own API routes (`/api/...`), not a backend module. */
|
|
@@ -11,14 +16,15 @@ export const NexusModule = {
|
|
|
11
16
|
Enterprise: 1 << 3,
|
|
12
17
|
Drive: 1 << 4,
|
|
13
18
|
Message: 1 << 5,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
Chat: 1 << 6,
|
|
20
|
+
Accounting: 1 << 7,
|
|
21
|
+
Report: 1 << 8,
|
|
22
|
+
Requisition: 1 << 9,
|
|
23
|
+
Tenant: 1 << 10,
|
|
24
|
+
Subscription: 1 << 11,
|
|
25
|
+
Reservation: 1 << 12,
|
|
26
|
+
School: 1 << 13,
|
|
27
|
+
Pharmacy: 1 << 14,
|
|
28
|
+
Staffing: 1 << 15,
|
|
29
|
+
Portfolio: 1 << 16,
|
|
24
30
|
};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { DropdownPage, DropdownPageRequest } from "./InputInterfaces.ts";
|
|
2
|
+
/** An id, as the backend hands it out: a number, or text (a GUID, an encrypted id). */
|
|
3
|
+
export type ChatId = string | number;
|
|
4
|
+
/**
|
|
5
|
+
* A domain's value in an app's own enum: a number (`EDomainTypes.Leave`) or a string (`"leave"`). The backend will
|
|
6
|
+
* own the enum; until it does, a plain number is the value, and the components never read it — they only pass it on.
|
|
7
|
+
*/
|
|
8
|
+
export type ChatDomainValue = string | number;
|
|
9
|
+
/**
|
|
10
|
+
* Where an app names its domain enum for the types, once, next to its other registrations:
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* export enum EDomainTypes { Leave = 1, Procurement = 2, Applicant = 3 }
|
|
14
|
+
* declare module "nexus-shared" {
|
|
15
|
+
* interface ChatRegister { domain: EDomainTypes }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Every chat component, scope, and store then takes only those domains. Without it, any number.
|
|
19
|
+
*/
|
|
20
|
+
export interface ChatRegister {
|
|
21
|
+
}
|
|
22
|
+
/** The app's domain type: the one named in `ChatRegister`, else any number. A component can also take one as `<Chat<EDomainTypes> …>`. */
|
|
23
|
+
export type AppChatDomain = ChatRegister extends {
|
|
24
|
+
domain: infer TDomain extends ChatDomainValue;
|
|
25
|
+
} ? TDomain : number;
|
|
26
|
+
/**
|
|
27
|
+
* Which conversation this is: the kind of record, the record, and who is reading it. Everything else a chat needs —
|
|
28
|
+
* where its messages come from, who may write — follows from these three.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const scope = { domain: EDomainTypes.Leave, parentId: leave.id, userId: me.id };
|
|
32
|
+
*/
|
|
33
|
+
export interface ChatScope<TDomain extends ChatDomainValue = AppChatDomain> {
|
|
34
|
+
/** Which kind of record the conversation hangs on: a leave request, a purchase order, an applicant, a tender. */
|
|
35
|
+
domain: TDomain;
|
|
36
|
+
/** The record's own id. The domain and the id together are the conversation. */
|
|
37
|
+
parentId: ChatId;
|
|
38
|
+
/** Who is reading and writing. Their messages sit on the right, and the unread count is theirs. */
|
|
39
|
+
userId: ChatId;
|
|
40
|
+
}
|
|
41
|
+
/** One domain's name, for the panel's heading and messages. An app registers its own with `defineChatDomains`. */
|
|
42
|
+
export interface ChatDomainInfo<TDomain extends ChatDomainValue = AppChatDomain> {
|
|
43
|
+
domain: TDomain;
|
|
44
|
+
/** Its name in a URL or a cache key: `leave`. Letters, digits, dots, dashes, and underscores. */
|
|
45
|
+
key: string;
|
|
46
|
+
/** Its name on screen: "Leave request". */
|
|
47
|
+
label: string;
|
|
48
|
+
}
|
|
49
|
+
/** `sent`: the server has it. `sending`: on its way. `failed`: it did not arrive, and the text is kept to send again. */
|
|
50
|
+
export type ChatMessageStatus = "sent" | "sending" | "failed";
|
|
51
|
+
/** A file on a message. The chat shows and links it; uploading is the app's `ChatSource`. */
|
|
52
|
+
export interface ChatAttachment {
|
|
53
|
+
id?: ChatId;
|
|
54
|
+
name: string;
|
|
55
|
+
/** Bytes, shown as "248 KB". */
|
|
56
|
+
size?: number;
|
|
57
|
+
/** Where it opens or downloads. Without one, the name shows but does not link. */
|
|
58
|
+
url?: string | null;
|
|
59
|
+
contentType?: string | null;
|
|
60
|
+
}
|
|
61
|
+
/** One message in a conversation. A backend maps its own rows to this in `ChatBackend.readMessage`. */
|
|
62
|
+
export interface ChatMessage {
|
|
63
|
+
/** The server's id. A message still on its way carries its `clientId` here until the answer brings the real one. */
|
|
64
|
+
id: ChatId;
|
|
65
|
+
/** Who wrote it. Compared with the scope's `userId` to decide which side it sits on. */
|
|
66
|
+
authorId: ChatId;
|
|
67
|
+
/** Their name, for the avatar and the line above the message. Without one, the chat says "Unknown". */
|
|
68
|
+
authorName?: string | null;
|
|
69
|
+
/** A photo URL. Without one, the initials of the name on a tone picked from it. */
|
|
70
|
+
authorAvatar?: string | null;
|
|
71
|
+
/** What they wrote, as plain text. Newlines are kept; nothing is parsed as HTML or Markdown. */
|
|
72
|
+
body: string;
|
|
73
|
+
/** When it was written, as a UTC moment (`2026-10-01T03:21:04.000Z`). Shown in the reader's own zone. */
|
|
74
|
+
createdAt: string;
|
|
75
|
+
/** When it was last changed, as a UTC moment. Shown as "edited". */
|
|
76
|
+
editedAt?: string | null;
|
|
77
|
+
/** Default `sent`. `sending` and `failed` belong to messages this browser has not had answered yet. */
|
|
78
|
+
status?: ChatMessageStatus;
|
|
79
|
+
/** The message this one answers, quoted above it. */
|
|
80
|
+
replyToId?: ChatId | null;
|
|
81
|
+
attachments?: readonly ChatAttachment[];
|
|
82
|
+
/** Written by the system, not a person ("Sita approved the request."): a centered note, with no avatar or side. */
|
|
83
|
+
isSystem?: boolean;
|
|
84
|
+
/** Who it has reached. Two ticks once anyone has it, filled once anyone has read it. */
|
|
85
|
+
deliveredTo?: readonly ChatId[];
|
|
86
|
+
/** Who has read it. */
|
|
87
|
+
readBy?: readonly ChatId[];
|
|
88
|
+
/** The people named with @ in the body, for the backend to tell them. */
|
|
89
|
+
mentions?: readonly ChatId[];
|
|
90
|
+
/** Set while a message is on its way or failed, so Retry can send the very same draft again. */
|
|
91
|
+
clientId?: string;
|
|
92
|
+
/** Why it failed, under the message, beside Retry. */
|
|
93
|
+
error?: string | null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* What the trigger icon draws itself from, and the only thing a list of 500 rows needs per row. The three states are
|
|
97
|
+
* `messageCount === 0` (quiet), messages but none unread (highlighted), and `unreadCount > 0` (highlighted, badged).
|
|
98
|
+
*/
|
|
99
|
+
export interface ChatSummary {
|
|
100
|
+
/** Messages in the conversation. 0 leaves the icon quiet. */
|
|
101
|
+
messageCount: number;
|
|
102
|
+
/** Messages this user has not read. */
|
|
103
|
+
unreadCount: number;
|
|
104
|
+
/** When the last message was written, as a UTC moment, for the trigger's tooltip. */
|
|
105
|
+
lastMessageAt?: string | null;
|
|
106
|
+
/** The last message's text, cut to a line, for the tooltip. */
|
|
107
|
+
lastMessageText?: string | null;
|
|
108
|
+
/** Who wrote the last message, for the tooltip. */
|
|
109
|
+
lastMessageBy?: string | null;
|
|
110
|
+
/** How many take part. */
|
|
111
|
+
participantCount?: number;
|
|
112
|
+
/** How many conversations the record holds, when it holds more than one. */
|
|
113
|
+
conversationCount?: number;
|
|
114
|
+
}
|
|
115
|
+
/** The three looks of the trigger icon, in the order the owner described them. */
|
|
116
|
+
export type ChatHighlight = "quiet" | "active" | "unread";
|
|
117
|
+
/** One page of history: the newest messages first, then older ones with `before`, or only the new ones with `after`. */
|
|
118
|
+
export interface ChatHistoryRequest {
|
|
119
|
+
/** Messages older than this one, for the page above. */
|
|
120
|
+
before?: ChatId | null;
|
|
121
|
+
/** Messages newer than this one, for a refresh. `before` and `after` are never both set. */
|
|
122
|
+
after?: ChatId | null;
|
|
123
|
+
/** How many to send. */
|
|
124
|
+
take: number;
|
|
125
|
+
/** Aborted when the conversation changes or the panel unmounts. */
|
|
126
|
+
signal?: AbortSignal;
|
|
127
|
+
}
|
|
128
|
+
/** What a history request answers with. Messages may come in any order; the store sorts them oldest first. */
|
|
129
|
+
export interface ChatHistory {
|
|
130
|
+
messages: readonly ChatMessage[];
|
|
131
|
+
/** Whether older messages remain. Default: the page was full (`messages.length >= take`). */
|
|
132
|
+
hasMore?: boolean;
|
|
133
|
+
/** The conversation's counts, when the answer carries them, so one call fills the header too. */
|
|
134
|
+
summary?: ChatSummary;
|
|
135
|
+
/** Everyone taking part, when the answer carries them, so the panel needs no second call. */
|
|
136
|
+
participants?: readonly ChatParticipant[];
|
|
137
|
+
/** Whether this user may write here. Default true. */
|
|
138
|
+
canSend?: boolean;
|
|
139
|
+
}
|
|
140
|
+
/** What the composer sends. */
|
|
141
|
+
export interface ChatDraft {
|
|
142
|
+
body: string;
|
|
143
|
+
replyToId?: ChatId | null;
|
|
144
|
+
/** The people named with @ while writing it. */
|
|
145
|
+
mentions?: readonly ChatId[];
|
|
146
|
+
/** Files picked in the composer. A source that cannot take files leaves them out of its request. */
|
|
147
|
+
files?: readonly File[];
|
|
148
|
+
/** This browser's id for the message, so the answer replaces the right one and Retry sends the same draft. */
|
|
149
|
+
clientId: string;
|
|
150
|
+
}
|
|
151
|
+
/** Someone in a conversation, or someone who could be added to it. */
|
|
152
|
+
export interface ChatParticipant {
|
|
153
|
+
id: ChatId;
|
|
154
|
+
name: string;
|
|
155
|
+
avatar?: string | null;
|
|
156
|
+
/** What they are here: "Manager", "Co-worker", "Requested by". Shown under the name. */
|
|
157
|
+
role?: string | null;
|
|
158
|
+
/** They read but cannot write. */
|
|
159
|
+
readOnly?: boolean;
|
|
160
|
+
/** How far they have read, as a UTC moment. */
|
|
161
|
+
lastReadAt?: string | null;
|
|
162
|
+
/** Someone who has left or is no longer active: shown, never offered again. */
|
|
163
|
+
inactive?: boolean;
|
|
164
|
+
}
|
|
165
|
+
/** What a participant search receives: the same request a dropdown's `loadOptions` takes, so one loader serves both. */
|
|
166
|
+
export type ChatParticipantQuery = DropdownPageRequest;
|
|
167
|
+
/** What a participant search answers: the same page a dropdown takes. */
|
|
168
|
+
export type ChatParticipantPage = DropdownPage<ChatParticipant>;
|
|
169
|
+
/**
|
|
170
|
+
* Where one conversation's data comes from. The store calls only this, so it never knows about HTTP: the API source
|
|
171
|
+
* (`apiChatSource`) talks to the app's `ChatBackend`, and `localChatSource` keeps everything in memory for a demo or
|
|
172
|
+
* a test. Only `history` and `send` are required.
|
|
173
|
+
*/
|
|
174
|
+
export interface ChatSource {
|
|
175
|
+
/** One page of messages. */
|
|
176
|
+
history(request: ChatHistoryRequest): Promise<ChatHistory>;
|
|
177
|
+
/** Sends one message, and answers with the saved one: its real id, its server time. */
|
|
178
|
+
send(draft: ChatDraft, signal?: AbortSignal): Promise<ChatMessage>;
|
|
179
|
+
/** Everyone in the conversation, and the people who could join, searched. Without it, the panel shows no participants. */
|
|
180
|
+
participants?(query: ChatParticipantQuery): Promise<ChatParticipantPage>;
|
|
181
|
+
/** Adds people to the conversation. Without it, the panel offers no Add. */
|
|
182
|
+
addParticipants?(ids: readonly ChatId[], signal?: AbortSignal): Promise<readonly ChatParticipant[]>;
|
|
183
|
+
/** Tells the server the user has read up to a message. Without it, unread counts clear in the browser only. */
|
|
184
|
+
markRead?(upTo: ChatId, signal?: AbortSignal): Promise<void>;
|
|
185
|
+
/** Removes a message. Without it, the panel offers no Delete. */
|
|
186
|
+
remove?(id: ChatId, signal?: AbortSignal): Promise<void>;
|
|
187
|
+
/**
|
|
188
|
+
* Pushes new messages in, from a socket or an event stream, and returns the function that stops it. While a source
|
|
189
|
+
* watches, the store does not poll.
|
|
190
|
+
*/
|
|
191
|
+
watch?(onMessages: (messages: readonly ChatMessage[]) => void): () => void;
|
|
192
|
+
}
|
|
193
|
+
/** `one-to-one`: two people, named after the other one. `group`: three or more, or any conversation with a name. */
|
|
194
|
+
export type ChatConversationType = "one-to-one" | "group";
|
|
195
|
+
/** One conversation at a place. One of these shows on its own; several offer a switch between them. */
|
|
196
|
+
export interface ChatConversation {
|
|
197
|
+
id: ChatId;
|
|
198
|
+
type: ChatConversationType;
|
|
199
|
+
/** A group's name. A one-to-one has none: it is called after the other person. */
|
|
200
|
+
title?: string | null;
|
|
201
|
+
/** Everyone in it. Needed to name a one-to-one, and to fill the people column without a second call. */
|
|
202
|
+
participants?: readonly ChatParticipant[];
|
|
203
|
+
/** Its own counts, so the switch can show which conversation is waiting. */
|
|
204
|
+
summary?: ChatSummary;
|
|
205
|
+
/** When it was started, as a UTC moment. */
|
|
206
|
+
createdAt?: string | null;
|
|
207
|
+
createdBy?: ChatId | null;
|
|
208
|
+
/** This user may write in it. Default true. */
|
|
209
|
+
canSend?: boolean;
|
|
210
|
+
/** Nobody writes in it any more: it is read, never written to. */
|
|
211
|
+
closed?: boolean;
|
|
212
|
+
}
|
|
213
|
+
/** What starting a conversation sends. The backend settles the type; this is what the user asked for. */
|
|
214
|
+
export interface NewChatRequest {
|
|
215
|
+
/** One other person makes a one-to-one, more make a group. */
|
|
216
|
+
type: ChatConversationType;
|
|
217
|
+
/** Everyone to put in it, the starter aside. */
|
|
218
|
+
userIds: readonly ChatId[];
|
|
219
|
+
/** A group's name. */
|
|
220
|
+
title?: string | null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The conversations at one place: what there is, and how to start another. A panel without one shows the single
|
|
224
|
+
* conversation its `ChatSource` serves, which is what a page that already knows its conversation wants.
|
|
225
|
+
*/
|
|
226
|
+
export interface ChatPlaceSource {
|
|
227
|
+
/** Every conversation here this user may see, the one with the newest message first. */
|
|
228
|
+
conversations(signal?: AbortSignal): Promise<readonly ChatConversation[]>;
|
|
229
|
+
/** Starts one. A one-to-one that already exists comes back instead of a second one. Without it, no New chat. */
|
|
230
|
+
create?(request: NewChatRequest, signal?: AbortSignal): Promise<ChatConversation>;
|
|
231
|
+
/** Who can be put in a conversation here. Without it, New chat has nobody to pick. */
|
|
232
|
+
participants?(query: ChatParticipantQuery): Promise<ChatParticipantPage>;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A scope of any domain. Helpers that only pass the domain on — a cache key, the counts of a record — take this, so a
|
|
236
|
+
* component generic over the app's own enum can hand them its scope without a cast.
|
|
237
|
+
*/
|
|
238
|
+
export type AnyChatScope = ChatScope<ChatDomainValue>;
|
|
239
|
+
/** A conversation's key in a cache: `"1:482"`, the domain and the parent id. */
|
|
240
|
+
export type ChatScopeKey = string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|