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,175 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { apiChatPlace, apiChatSource, chatSummaries } from "../Services/ChatApi.js";
4
+ import { chatPlaceSummary, chatScopeKey } from "./ChatHelpers.js";
5
+ import { ChatStore } from "./ChatStore.js";
6
+ /** Whether the page is in front: a background tab stops polling and starts again when it comes back. */
7
+ const pageIsActive = () => typeof document === "undefined" || document.visibilityState !== "hidden";
8
+ /**
9
+ * One conversation, kept for as long as the scope stays the same.
10
+ *
11
+ * @example
12
+ * const { store, state } = useChat({ scope, enabled: open });
13
+ * state.messages.map(…);
14
+ * store.send(text);
15
+ */
16
+ export function useChat(options) {
17
+ const { scope, conversationId, source, pageSize, pollInterval, summary, participants, enabled = true } = options;
18
+ // The reader is part of the key: the same record read by somebody else is another conversation's unread count. So is
19
+ // the conversation, because one record can hold several and each has its own messages, counts and unread line.
20
+ const key = `${chatScopeKey(scope)}:${String(scope.userId)}${conversationId === undefined ? "" : `:${String(conversationId)}`}`;
21
+ const summaries = chatSummaries();
22
+ // Read while rendering, so a store built below starts with what the page already knows.
23
+ const latest = useRef({ scope, source, summary, participants });
24
+ latest.current = { scope, source, summary, participants };
25
+ const store = useMemo(() => {
26
+ const { scope: at, source: from, summary: counts, participants: people } = latest.current;
27
+ const made = new ChatStore({
28
+ source: from ?? apiChatSource(at, conversationId === undefined ? undefined : { conversationId }),
29
+ userId: at.userId,
30
+ pageSize,
31
+ pollInterval,
32
+ ...(counts && { summary: counts }),
33
+ isActive: pageIsActive,
34
+ // The icon in the list behind the panel follows the panel: a message sent, a conversation read. A record with
35
+ // several conversations is counted by the place instead (`useChatPlace`), since one icon stands for all of them.
36
+ onSummary: next => {
37
+ if (conversationId === undefined)
38
+ summaries.set(at, next);
39
+ },
40
+ });
41
+ if (people)
42
+ made.setParticipants(people);
43
+ return made;
44
+ // A new store only for another conversation. `source`, `summary`, and `participants` reach it below.
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ }, [key, pageSize, pollInterval, summaries, conversationId]);
47
+ useEffect(() => {
48
+ if (source)
49
+ store.setSource(source);
50
+ }, [store, source]);
51
+ useEffect(() => {
52
+ if (participants)
53
+ store.setParticipants(participants);
54
+ }, [store, participants]);
55
+ useEffect(() => {
56
+ if (!enabled)
57
+ return;
58
+ void store.load();
59
+ store.start();
60
+ return () => store.stop();
61
+ }, [store, enabled]);
62
+ useEffect(() => () => store.dispose(), [store]);
63
+ const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
64
+ return { store, state };
65
+ }
66
+ /**
67
+ * The conversations one record holds. A leave request can carry several — the manager and the applicant in one, the
68
+ * managers between themselves in another — and this is the list behind the switch, the New chat form, and the one
69
+ * count the trigger icon shows for the record as a whole.
70
+ */
71
+ export function useChatPlace(options) {
72
+ const { scope, place, conversations: given, enabled = true } = options;
73
+ const key = `${chatScopeKey(scope)}:${String(scope.userId)}`;
74
+ const summaries = chatSummaries();
75
+ const [conversations, setConversations] = useState(given ?? []);
76
+ const [loading, setLoading] = useState(false);
77
+ const [creating, setCreating] = useState(false);
78
+ const [error, setError] = useState(null);
79
+ const latest = useRef({ scope, place, given });
80
+ latest.current = { scope, place, given };
81
+ const source = useMemo(() => latest.current.place ?? apiChatPlace(latest.current.scope), [key, place]); // eslint-disable-line react-hooks/exhaustive-deps
82
+ useEffect(() => {
83
+ if (given)
84
+ setConversations(given);
85
+ }, [given]);
86
+ // The record's one count is every conversation in it added up, which is what the icon in a list stands for.
87
+ useEffect(() => {
88
+ if (conversations.length > 0)
89
+ summaries.set(latest.current.scope, chatPlaceSummary(conversations));
90
+ }, [summaries, conversations]);
91
+ const reload = useCallback(async () => {
92
+ if (latest.current.given)
93
+ return;
94
+ setLoading(true);
95
+ try {
96
+ const list = await source.conversations();
97
+ setConversations(list);
98
+ setError(null);
99
+ }
100
+ catch (failure) {
101
+ setError(failure instanceof Error ? failure.message : "The conversations could not be read.");
102
+ }
103
+ finally {
104
+ setLoading(false);
105
+ }
106
+ }, [source]);
107
+ useEffect(() => {
108
+ if (enabled)
109
+ void reload();
110
+ }, [enabled, reload]);
111
+ const create = useCallback(async (request) => {
112
+ if (!source.create)
113
+ return null;
114
+ setCreating(true);
115
+ try {
116
+ const made = await source.create(request);
117
+ // A one-to-one that already existed comes back instead of a second one, so it is put in place, not added twice.
118
+ setConversations(held => [made, ...held.filter(one => String(one.id) !== String(made.id))]);
119
+ setError(null);
120
+ return made;
121
+ }
122
+ catch (failure) {
123
+ setError(failure instanceof Error ? failure.message : "The conversation could not be started.");
124
+ return null;
125
+ }
126
+ finally {
127
+ setCreating(false);
128
+ }
129
+ }, [source]);
130
+ const apply = useCallback((conversationId, summary) => {
131
+ setConversations(held => {
132
+ const at = held.findIndex(one => String(one.id) === String(conversationId));
133
+ if (at === -1)
134
+ return held;
135
+ const before = held[at].summary;
136
+ if (before && before.messageCount === summary.messageCount && before.unreadCount === summary.unreadCount && before.lastMessageAt === summary.lastMessageAt)
137
+ return held;
138
+ const next = [...held];
139
+ next[at] = { ...held[at], summary };
140
+ return next;
141
+ });
142
+ }, []);
143
+ return { conversations, loading, error, canCreate: typeof source.create === "function", creating, reload, create, searchParticipants: source.participants, apply };
144
+ }
145
+ /**
146
+ * The counts one trigger icon draws itself from. Without `given`, it asks the page's cache, which collects every icon
147
+ * that mounted in the same moment and fills them in one call per domain. With `given` — a list whose rows already
148
+ * carry the counts — nothing is asked at all.
149
+ */
150
+ export function useChatSummary(scope, given) {
151
+ const summaries = chatSummaries();
152
+ const key = `${chatScopeKey(scope)}:${String(scope.userId)}`;
153
+ const at = useRef(scope);
154
+ at.current = scope;
155
+ const subscribe = useCallback((listener) => summaries.subscribe(at.current, listener), [summaries, key]); // eslint-disable-line react-hooks/exhaustive-deps
156
+ const read = useCallback(() => summaries.get(at.current), [summaries, key]); // eslint-disable-line react-hooks/exhaustive-deps
157
+ const server = useCallback(() => undefined, []);
158
+ const held = useSyncExternalStore(subscribe, read, server);
159
+ // Counts the page knows are put into the cache, so the icon starts right and then follows the panel: reading a
160
+ // conversation clears its badge even though the row behind it still carries the number it was loaded with. They are
161
+ // put in again only when the page's own numbers change, never on a render that merely repeated them.
162
+ const seeded = useRef(null);
163
+ useEffect(() => {
164
+ if (!given) {
165
+ summaries.want(at.current);
166
+ return;
167
+ }
168
+ const before = seeded.current;
169
+ if (before && before.key === key && before.messageCount === given.messageCount && before.unreadCount === given.unreadCount)
170
+ return;
171
+ seeded.current = { key, messageCount: given.messageCount, unreadCount: given.unreadCount };
172
+ summaries.set(at.current, given);
173
+ }, [summaries, key, given]);
174
+ return { summary: held ?? given, state: held ? "ready" : given ? "ready" : summaries.stateOf(scope) };
175
+ }
@@ -0,0 +1,132 @@
1
+ import type { ChatDraft, ChatHistory, ChatId, ChatMessage, ChatParticipant, ChatParticipantPage, ChatParticipantQuery, ChatSource, ChatSummary } from "../Interfaces/ChatInterfaces.ts";
2
+ export interface ChatStoreOptions {
3
+ source: ChatSource;
4
+ /** Who is reading: their messages sit on the right, and the unread count is theirs. */
5
+ userId: ChatId;
6
+ /** Messages per page. Default 30. */
7
+ pageSize?: number;
8
+ /** Milliseconds between checks for new messages, while `start()` is on. 0 turns it off; a source that watches never polls. Default 15,000. */
9
+ pollInterval?: number;
10
+ /** The counts the page already had, so the panel opens with its header filled. */
11
+ summary?: ChatSummary;
12
+ /** Every time the counts change, so the trigger icon in the list behind the panel follows them. */
13
+ onSummary?: (summary: ChatSummary) => void;
14
+ /** Whether polling should happen now; the panel passes the tab's visibility. Default: always. */
15
+ isActive?: () => boolean;
16
+ /** The moment a message being sent is drawn with. Default: now. For tests. */
17
+ now?: () => string;
18
+ }
19
+ /**
20
+ * What the conversation's source can do. A panel hides what a source cannot serve, rather than offering a button that
21
+ * answers nothing: no Delete without `remove`, no people column without `participants`.
22
+ */
23
+ export interface ChatFeatures {
24
+ participants: boolean;
25
+ addParticipants: boolean;
26
+ markRead: boolean;
27
+ remove: boolean;
28
+ watch: boolean;
29
+ }
30
+ /** What a source can do, read from the source itself. */
31
+ export declare function chatFeaturesOf(source: ChatSource): ChatFeatures;
32
+ /** Everything the panel draws, replaced as a whole whenever any of it changes. */
33
+ export interface ChatSnapshot {
34
+ messages: readonly ChatMessage[];
35
+ participants: readonly ChatParticipant[];
36
+ summary: ChatSummary;
37
+ /** Whether this user may write here. */
38
+ canSend: boolean;
39
+ /** What the source can do. */
40
+ features: ChatFeatures;
41
+ /** The first page is on its way. */
42
+ loading: boolean;
43
+ /** A page has arrived at least once, so the counts here are this conversation's and not the empty ones it started with. */
44
+ loaded: boolean;
45
+ /** A page of older messages is on its way. */
46
+ loadingOlder: boolean;
47
+ /** Older messages remain above. */
48
+ hasMore: boolean;
49
+ /** Why the last read failed, shown in the thread with Retry. Sending failures sit on their own message. */
50
+ error: string | null;
51
+ /** How many messages are on their way. */
52
+ sending: number;
53
+ /** How many failed and are waiting to be sent again or dropped. */
54
+ failed: number;
55
+ /** The message the "New messages" line goes before. Set when the conversation opens, and left alone while it is read. */
56
+ unreadFrom: ChatId | null;
57
+ /** Grows on every change, so a snapshot is never equal to the one before it. */
58
+ revision: number;
59
+ }
60
+ export declare class ChatStore {
61
+ private options;
62
+ private listeners;
63
+ private snapshot;
64
+ private messages;
65
+ private pending;
66
+ private revision;
67
+ private loaded;
68
+ private poll;
69
+ private unwatch;
70
+ private running;
71
+ private readPast;
72
+ private aborts;
73
+ private clientSequence;
74
+ constructor(options: ChatStoreOptions);
75
+ readonly subscribe: (listener: () => void) => (() => void);
76
+ readonly getSnapshot: () => ChatSnapshot;
77
+ /** The newest message the server knows about: what the read mark and a refresh count from. */
78
+ private newestSaved;
79
+ private oldest;
80
+ private set;
81
+ /** Puts the messages and the ones still on their way together, newest last, and tells the snapshot. */
82
+ private commit;
83
+ private announce;
84
+ /** The counts after the messages changed: the server's, with what this browser knows added. */
85
+ private recount;
86
+ private track;
87
+ /** The newest page. Call it when the conversation opens, and again to start over. */
88
+ load(): Promise<void>;
89
+ /** One page of older messages, above the ones held. */
90
+ loadOlder(): Promise<void>;
91
+ /** Whatever was written since the newest message held. Polling and the Retry button both call it. */
92
+ refresh(): Promise<void>;
93
+ /** Messages that arrived on their own: a poll, or a source that watches. */
94
+ readonly take: (incoming: readonly ChatMessage[], page?: Pick<ChatHistory, "summary" | "participants">) => void;
95
+ private pageSize;
96
+ /** Sends a message. It is on screen before the call starts; if the call fails it stays, with Retry beside it. */
97
+ send(body: string, extras?: {
98
+ replyToId?: ChatId | null;
99
+ files?: readonly File[];
100
+ }): Promise<boolean>;
101
+ /** Sends a failed message again, with the text it was written with. */
102
+ readonly retry: (clientId: string) => Promise<boolean>;
103
+ /** Drops a failed message. Nothing was saved, so nothing is deleted. */
104
+ readonly discard: (clientId: string) => void;
105
+ /** The text of a failed message, so the composer can put it back for editing. */
106
+ draftOf(clientId: string): ChatDraft | undefined;
107
+ private deliver;
108
+ /** Removes a message the user may remove. */
109
+ remove(messageId: ChatId): Promise<boolean>;
110
+ /** Says the user has read everything held. Clears the count; the line stays until the conversation is opened again. */
111
+ markRead(): Promise<void>;
112
+ /** Takes the "New messages" line away, once it has been seen. */
113
+ readonly clearUnreadLine: () => void;
114
+ /** Searches the people in the conversation and the ones who could join. Empty when the source has no search. */
115
+ readonly searchParticipants: (query: ChatParticipantQuery) => Promise<ChatParticipantPage>;
116
+ /** Adds people, and keeps the ones the source answers with. */
117
+ addParticipants(ids: readonly ChatId[]): Promise<boolean>;
118
+ /** Participants the page already has, so the panel needs no call. */
119
+ readonly setParticipants: (participants: readonly ChatParticipant[]) => void;
120
+ /**
121
+ * Points the store at another source, keeping the messages it holds. A page that builds its source while it renders
122
+ * hands in a new object every time; this takes it without starting the conversation over, and keeps the store's own
123
+ * reading of what the source can do (a search, a read mark, Delete) true.
124
+ */
125
+ readonly setSource: (source: ChatSource) => void;
126
+ /** Starts watching for new messages: the source's own watch, else a poll. */
127
+ start(): void;
128
+ /** Stops watching. The messages stay. */
129
+ stop(): void;
130
+ /** Stops everything and drops the calls on their way. The messages and unsent drafts stay, for a panel that reopens. */
131
+ dispose(): void;
132
+ }