nexus-shared 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +795 -0
- package/README.md +220 -14
- package/dist/Client.Index.d.ts +13 -0
- package/dist/Client.Index.js +13 -0
- package/dist/Components/Chats/ChatComposer.d.ts +7 -1
- package/dist/Components/Chats/ChatComposer.js +9 -6
- package/dist/Components/Chats/ChatParts.d.ts +15 -2
- package/dist/Components/Chats/ChatParts.js +15 -4
- package/dist/Components/Chats/ChatPopup.d.ts +46 -0
- package/dist/Components/Chats/ChatPopup.js +34 -0
- package/dist/Components/Chats/ChatThread.d.ts +9 -1
- package/dist/Components/Chats/ChatThread.js +54 -6
- package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
- package/dist/Components/Dashboards/DashboardBoard.js +594 -0
- package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
- package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
- package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
- package/dist/Components/Dashboards/WidgetFrame.js +45 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/TabButtons.d.ts +6 -0
- package/dist/Components/Documents/TabButtons.js +5 -4
- package/dist/Components/Domains/DomainCard.d.ts +98 -0
- package/dist/Components/Domains/DomainCard.js +182 -0
- package/dist/Components/Forms/ApiForm.js +5 -1
- package/dist/Components/Forms/Crud.js +72 -24
- package/dist/Components/Forms/CrudActions.d.ts +5 -4
- package/dist/Components/Forms/CrudActions.js +42 -12
- package/dist/Components/Forms/CrudHistory.d.ts +6 -1
- package/dist/Components/Forms/CrudHistory.js +14 -6
- package/dist/Components/Forms/CrudParts.d.ts +11 -4
- package/dist/Components/Forms/CrudParts.js +76 -10
- package/dist/Components/Forms/CrudRowForms.js +65 -5
- package/dist/Components/Forms/CrudTree.js +7 -7
- package/dist/Components/Forms/CrudWorkspace.d.ts +15 -5
- package/dist/Components/Forms/CrudWorkspace.js +19 -11
- package/dist/Components/Forms/Form.d.ts +3 -2
- package/dist/Components/Forms/Form.js +33 -26
- package/dist/Components/Forms/FormQuestion.d.ts +6 -0
- package/dist/Components/Forms/FormQuestion.js +28 -0
- package/dist/Components/Forms/SubmitForm.js +2 -15
- package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
- package/dist/Components/Inputs/ComputedInput.js +179 -0
- package/dist/Components/Inputs/Dropdown.js +4 -2
- package/dist/Components/Inputs/GroupForm.js +3 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +5 -1
- package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
- package/dist/Components/Inputs/MarkdownEditor.js +32 -6
- package/dist/Components/Inputs/PictureInput.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +14 -1
- package/dist/Components/Inputs/RowsInput.js +78 -5
- package/dist/Components/Inputs/TabularForm.js +16 -3
- package/dist/Components/Inputs/TreeView.d.ts +24 -1
- package/dist/Components/Inputs/TreeView.js +84 -7
- package/dist/Components/Layouts/Popup.d.ts +22 -2
- package/dist/Components/Layouts/Popup.js +108 -6
- package/dist/Components/Messages/MailClient.d.ts +50 -0
- package/dist/Components/Messages/MailClient.js +410 -0
- package/dist/Components/Messages/MailColumns.d.ts +21 -0
- package/dist/Components/Messages/MailColumns.js +92 -0
- package/dist/Components/Messages/MailCompose.d.ts +46 -0
- package/dist/Components/Messages/MailCompose.js +154 -0
- package/dist/Components/Messages/MailReader.d.ts +43 -0
- package/dist/Components/Messages/MailReader.js +207 -0
- package/dist/Components/Messages/MailThread.d.ts +71 -0
- package/dist/Components/Messages/MailThread.js +185 -0
- package/dist/Components/Messages/MessageBell.d.ts +41 -0
- package/dist/Components/Messages/MessageBell.js +76 -0
- package/dist/Components/Viewers/DataTable.js +7 -5
- package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
- package/dist/Components/Viewers/DataTableColumns.js +2 -1
- package/dist/Components/Viewers/DataTableParts.d.ts +3 -1
- package/dist/Components/Viewers/DataTableParts.js +2 -2
- package/dist/Components/Viewers/OwnerFiles.d.ts +57 -5
- package/dist/Components/Viewers/OwnerFiles.js +142 -31
- package/dist/Helpers/ApiModules.js +4 -0
- package/dist/Helpers/AuditEntries.d.ts +50 -0
- package/dist/Helpers/AuditEntries.js +96 -0
- package/dist/Helpers/ChatBackend.d.ts +8 -1
- package/dist/Helpers/ChatBackend.js +18 -0
- package/dist/Helpers/ChatHelpers.d.ts +2 -0
- package/dist/Helpers/ChatHelpers.js +4 -1
- package/dist/Helpers/CrudColumns.d.ts +44 -6
- package/dist/Helpers/CrudColumns.js +63 -13
- package/dist/Helpers/CrudHelpers.d.ts +36 -1
- package/dist/Helpers/CrudHelpers.js +61 -0
- package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
- package/dist/Helpers/CrudPickerPlan.js +46 -0
- package/dist/Helpers/CrudRowForms.d.ts +18 -1
- package/dist/Helpers/CrudRowForms.js +35 -2
- package/dist/Helpers/DashboardHelpers.d.ts +115 -0
- package/dist/Helpers/DashboardHelpers.js +305 -0
- package/dist/Helpers/DomainCards.d.ts +140 -0
- package/dist/Helpers/DomainCards.js +370 -0
- package/dist/Helpers/FileAdapter.d.ts +82 -2
- package/dist/Helpers/FileAdapter.js +72 -1
- package/dist/Helpers/FormStore.d.ts +11 -1
- package/dist/Helpers/FormStore.js +66 -19
- package/dist/Helpers/InputConditions.d.ts +6 -0
- package/dist/Helpers/InputConditions.js +24 -0
- package/dist/Helpers/InputMemory.js +40 -30
- package/dist/Helpers/InputNarrowing.d.ts +38 -6
- package/dist/Helpers/InputNarrowing.js +102 -21
- package/dist/Helpers/MailClientHelpers.d.ts +96 -0
- package/dist/Helpers/MailClientHelpers.js +211 -0
- package/dist/Helpers/MessageBackend.d.ts +152 -0
- package/dist/Helpers/MessageBackend.js +329 -0
- package/dist/Helpers/MessageHelpers.d.ts +160 -0
- package/dist/Helpers/MessageHelpers.js +573 -0
- package/dist/Helpers/MessageUnread.d.ts +95 -0
- package/dist/Helpers/MessageUnread.js +245 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +17 -1
- package/dist/Helpers/NexusDriveAdapter.js +32 -3
- package/dist/Helpers/NumberHelpers.d.ts +61 -0
- package/dist/Helpers/NumberHelpers.js +124 -8
- package/dist/Helpers/PagedPicker.d.ts +42 -0
- package/dist/Helpers/PagedPicker.js +85 -0
- package/dist/Helpers/RowsStore.d.ts +24 -0
- package/dist/Helpers/RowsStore.js +79 -10
- package/dist/Helpers/TableHelpers.d.ts +15 -2
- package/dist/Helpers/TableHelpers.js +28 -1
- package/dist/Helpers/TableStore.d.ts +7 -3
- package/dist/Helpers/TableStore.js +9 -4
- package/dist/Helpers/ToolbarFit.d.ts +23 -0
- package/dist/Helpers/ToolbarFit.js +30 -0
- package/dist/Helpers/TreeStore.d.ts +75 -6
- package/dist/Helpers/TreeStore.js +182 -18
- package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
- package/dist/Interfaces/ApiInterfaces.js +10 -1
- package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +78 -5
- package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
- package/dist/Interfaces/DashboardInterfaces.js +1 -0
- package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
- package/dist/Interfaces/DomainInterfaces.js +1 -0
- package/dist/Interfaces/FileInterfaces.d.ts +21 -0
- package/dist/Interfaces/FileInterfaces.js +2 -0
- package/dist/Interfaces/FormInterfaces.d.ts +78 -11
- package/dist/Interfaces/InputInterfaces.d.ts +12 -5
- package/dist/Interfaces/MailInterfaces.d.ts +192 -0
- package/dist/Interfaces/MailInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +28 -2
- package/dist/Services/JoinSources.d.ts +47 -6
- package/dist/Services/JoinSources.js +68 -33
- package/dist/Services/MessageApi.d.ts +18 -0
- package/dist/Services/MessageApi.js +64 -0
- package/dist/Shared.Index.d.ts +12 -0
- package/dist/Shared.Index.js +12 -0
- package/package.json +7 -1
- package/src/Styles/Nexus.Button.css +29 -0
- package/src/Styles/Nexus.Chat.css +114 -22
- package/src/Styles/Nexus.Crud.css +119 -28
- package/src/Styles/Nexus.Dashboard.css +763 -0
- package/src/Styles/Nexus.Domain.css +384 -0
- package/src/Styles/Nexus.Editor.css +20 -0
- package/src/Styles/Nexus.Files.css +27 -0
- package/src/Styles/Nexus.Index.css +3 -0
- package/src/Styles/Nexus.Input.css +31 -0
- package/src/Styles/Nexus.Mail.css +1062 -0
- package/src/Styles/Nexus.Popup.css +58 -0
- package/src/Styles/Nexus.Table.css +1458 -1449
- package/src/Styles/Nexus.Theme.Picker.css +1 -0
- package/src/Styles/Nexus.Tree.css +10 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { iconEye, iconFile, iconFileText, iconPencil, iconPhoto, iconStar, iconTrash, iconUpload, iconX, InlineIcon } from "nexus-icons";
|
|
3
|
+
import { iconArrowBackUp, iconEye, iconFile, iconFileText, iconPencil, iconPhoto, iconStar, iconTrash, iconUpload, iconX, InlineIcon } from "nexus-icons";
|
|
4
4
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
5
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
6
|
import { formatDateTime } from "../../Helpers/DateTimeHelpers.js";
|
|
7
7
|
import { avatarTone, initials } from "../../Helpers/DropdownHelpers.js";
|
|
8
8
|
import { useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
9
|
-
import { acceptOf, fileKindName, fileKindsSentence, fileRefusal, fileSizeText, PICTURE_LIMITS, pictureLimits, pictureName, roomLeft, takesDocuments, typedFile } from "../../Helpers/FileAdapter.js";
|
|
9
|
+
import { acceptOf, allowsFileChange, fileKindName, fileChangesNote, fileKindsSentence, fileRefusal, fileSizeText, narrowFileChanges, ownerFileActions, PENDING_REMOVAL_TAG, PICTURE_LIMITS, pictureLimits, pictureName, removalHeld, removalHeldQuestion, removalHeldSentence, roomLeft, takesDocuments, typedFile, } from "../../Helpers/FileAdapter.js";
|
|
10
10
|
import { readPath } from "../../Helpers/TableHelpers.js";
|
|
11
11
|
import { isPictureType } from "../../Interfaces/FileInterfaces.js";
|
|
12
12
|
import { getFileAdapter, primaryPictures } from "../../Services/FileApi.js";
|
|
@@ -86,6 +86,8 @@ export function useOwnerFiles(params) {
|
|
|
86
86
|
const [busy, setBusy] = useState(null);
|
|
87
87
|
// Why the image editor could not be opened, the last time it was tried: the input says so until it opens.
|
|
88
88
|
const [editorProblem, setEditorProblem] = useState(null);
|
|
89
|
+
// The changes a refusal said are left for an owner (`FileAdapter.changesLeft`), with its words; another owner forgets it.
|
|
90
|
+
const [learned, setLearned] = useState(null);
|
|
89
91
|
const latest = useRef(params);
|
|
90
92
|
latest.current = params;
|
|
91
93
|
const { module, category, ownerId } = params;
|
|
@@ -101,9 +103,8 @@ export function useOwnerFiles(params) {
|
|
|
101
103
|
if (!answer.ok || !answer.result)
|
|
102
104
|
return setStatus({ kind: "failed", message: answer.message ?? "The files could not be loaded." });
|
|
103
105
|
setStatus({ kind: "ready", data: answer.result });
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return primary;
|
|
106
|
+
primaryPictures.set(ownerOf(current), answer.result.files.find(file => file.isPrimary) ?? null);
|
|
107
|
+
return answer.result;
|
|
107
108
|
}, [ownerKey]);
|
|
108
109
|
useEffect(() => {
|
|
109
110
|
if (!ownerKey) {
|
|
@@ -121,20 +122,60 @@ export function useOwnerFiles(params) {
|
|
|
121
122
|
/** Why nothing can be changed, when it is the page's or the backend's rule (not a form shown read only). */
|
|
122
123
|
const readOnlyReason = locked ?? (data && !data.category.canWrite ? data.readOnlyReason || READ_ONLY_REASON : null);
|
|
123
124
|
const pictures = data ? !takesDocuments(data.category) : true;
|
|
125
|
+
/** A Delete here waits for a review: the page said so, or the backend did. */
|
|
126
|
+
const holdsRemovals = Boolean(params.holdsRemovals || data?.category.holdsRemovals);
|
|
127
|
+
// The changes allowed: the page's, narrowed by what a refusal said of this owner ("only added to now").
|
|
128
|
+
const learnedNow = learned && learned.key === ownerKey ? learned : null;
|
|
129
|
+
const changes = narrowFileChanges(params.changes, learnedNow?.changes);
|
|
130
|
+
/** Why only some changes, while some are left out (`fileChangesNote`). */
|
|
131
|
+
const changesNote = canWrite ? fileChangesNote({ changes: params.changes, reason: params.changesReason }, learnedNow) : null;
|
|
132
|
+
/** The buttons offered for the owner's files, and for one file when given. */
|
|
133
|
+
const offer = (file) => ownerFileActions({ canWrite, changes, maxCount: data?.category.maxCount ?? 0, kept: data?.files.length ?? 0, holdsRemovals, keeps: typeof getFileAdapter().keep === "function" }, file);
|
|
134
|
+
/** "The picture stays until Nexus support approves its removal." for this file. */
|
|
135
|
+
const nounOf = (file) => (isPictureType(file.contentType) ? "picture" : "file");
|
|
136
|
+
const heldSentence = (file) => removalHeldSentence(nounOf(file), params.removalReviewer);
|
|
137
|
+
/** "If the public sees this picture, it stays until Nexus support approves its removal.": the answer decides. */
|
|
138
|
+
const heldQuestion = (file) => removalHeldQuestion(nounOf(file), params.removalReviewer);
|
|
139
|
+
/** A Delete held for a review: the file stays, marked, until the list is read again; the person is told once. */
|
|
140
|
+
function held(file, answered) {
|
|
141
|
+
const now = typeof answered === "object" && answered !== null && answered.id === file.id ? answered : null;
|
|
142
|
+
setStatus(current => current.kind !== "ready" ? current : { kind: "ready", data: { ...current.data, files: current.data.files.map(item => (item.id === file.id ? { ...item, ...now, pendingRemoval: true } : item)) } });
|
|
143
|
+
toast("Removal sent for review", { tone: "info", message: heldSentence(file) });
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Says why a change failed, once; a refusal that says the files may now be changed only some ways (a receipt posted
|
|
147
|
+
* since this page read it) also narrows what is offered for this owner, under its words.
|
|
148
|
+
*/
|
|
149
|
+
function refused(title, answer) {
|
|
150
|
+
toastFileFailure(title, answer);
|
|
151
|
+
const left = getFileAdapter().changesLeft(answer);
|
|
152
|
+
if (left && ownerKey)
|
|
153
|
+
setLearned({ key: ownerKey, changes: left, reason: answer.message ?? null });
|
|
154
|
+
}
|
|
155
|
+
// What the change under way did, for `onFilesChange` (the last, when it did several: an upload of three files).
|
|
156
|
+
const done = useRef(null);
|
|
157
|
+
const noted = (kind, file) => {
|
|
158
|
+
done.current = { kind, file };
|
|
159
|
+
};
|
|
124
160
|
/** Runs one change with its line of progress, then reads the files again and tells the page. */
|
|
125
161
|
async function change(label, work) {
|
|
126
162
|
if (busy)
|
|
127
163
|
return;
|
|
128
164
|
setBusy(label);
|
|
165
|
+
done.current = null;
|
|
166
|
+
const before = data?.files ?? [];
|
|
129
167
|
try {
|
|
130
168
|
const changed = await work();
|
|
131
169
|
if (changed) {
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
latest.current.onChange?.(
|
|
170
|
+
const read = await load();
|
|
171
|
+
if (read)
|
|
172
|
+
latest.current.onChange?.(read.files.find(file => file.isPrimary) ?? null);
|
|
173
|
+
if (done.current)
|
|
174
|
+
latest.current.onFilesChange?.(read ? read.files : before, done.current);
|
|
135
175
|
}
|
|
136
176
|
}
|
|
137
177
|
finally {
|
|
178
|
+
done.current = null;
|
|
138
179
|
setBusy(null);
|
|
139
180
|
}
|
|
140
181
|
}
|
|
@@ -150,7 +191,7 @@ export function useOwnerFiles(params) {
|
|
|
150
191
|
* `replacing`: the file a one-file category keeps, deleted once the editor has saved, since the category has no room
|
|
151
192
|
* for both.
|
|
152
193
|
*/
|
|
153
|
-
async function editAndStore(src, name, primary, replacing) {
|
|
194
|
+
async function editAndStore(src, name, primary, replacing, edited = false) {
|
|
154
195
|
if (!owner || !data)
|
|
155
196
|
return false;
|
|
156
197
|
const module = await editor(setEditorProblem);
|
|
@@ -168,9 +209,16 @@ export function useOwnerFiles(params) {
|
|
|
168
209
|
if (replacing) {
|
|
169
210
|
const removed = await getFileAdapter().remove(owner, replacing.id);
|
|
170
211
|
if (!removed.ok) {
|
|
171
|
-
|
|
212
|
+
refused("The picture was not replaced", removed);
|
|
172
213
|
return false;
|
|
173
214
|
}
|
|
215
|
+
// Held for a review (a category not known to hold removals before): it keeps its place, so nothing is uploaded.
|
|
216
|
+
if (removalHeld(removed.result)) {
|
|
217
|
+
held(replacing, removed.result);
|
|
218
|
+
noted("hold", replacing);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
noted("delete", replacing);
|
|
174
222
|
}
|
|
175
223
|
const stored = await getFileAdapter().upload(owner, { file: saved.blob, name: pictureName(saved.name || name, saved.blob.type), thumbnail: saved.thumbnail });
|
|
176
224
|
if (!stored.ok || !stored.result) {
|
|
@@ -178,11 +226,13 @@ export function useOwnerFiles(params) {
|
|
|
178
226
|
// A replaced file is gone: the list is read again either way.
|
|
179
227
|
return Boolean(replacing);
|
|
180
228
|
}
|
|
181
|
-
|
|
229
|
+
noted(edited ? "edit" : "upload", stored.result);
|
|
230
|
+
// A picture may be added where the main one may not be chosen: it is kept, and stays as it is.
|
|
231
|
+
if (primary && !stored.result.isPrimary && allowsFileChange(changes, "primary")) {
|
|
182
232
|
const made = await getFileAdapter().setPrimary(owner, stored.result.id);
|
|
183
233
|
if (!made.ok) {
|
|
184
234
|
// Stored all the same: the list is read again, and the person is told once, not also "saved".
|
|
185
|
-
|
|
235
|
+
refused("The picture was saved, but it is not the main picture yet", made);
|
|
186
236
|
return true;
|
|
187
237
|
}
|
|
188
238
|
}
|
|
@@ -265,8 +315,10 @@ export function useOwnerFiles(params) {
|
|
|
265
315
|
}
|
|
266
316
|
}
|
|
267
317
|
const answer = await getFileAdapter().upload(owner, { file: blob, name, thumbnail });
|
|
268
|
-
if (answer.ok)
|
|
318
|
+
if (answer.ok) {
|
|
269
319
|
stored++;
|
|
320
|
+
noted("upload", answer.result ?? null);
|
|
321
|
+
}
|
|
270
322
|
else
|
|
271
323
|
toastFileFailure(`"${file.name}" was not uploaded`, answer);
|
|
272
324
|
}
|
|
@@ -283,7 +335,7 @@ export function useOwnerFiles(params) {
|
|
|
283
335
|
* keeps one file, the new picture takes the place of the one kept (Replace).
|
|
284
336
|
*/
|
|
285
337
|
replace: async () => {
|
|
286
|
-
if (!data)
|
|
338
|
+
if (!data || !offer().upload)
|
|
287
339
|
return;
|
|
288
340
|
const replacing = data.category.maxCount === 1 ? data.files[0] : undefined;
|
|
289
341
|
if (!replacing && !hasRoom(data))
|
|
@@ -294,7 +346,7 @@ export function useOwnerFiles(params) {
|
|
|
294
346
|
},
|
|
295
347
|
/** Chooses several files and stores them: each picture through the editor, one at a time (the gallery's Upload). */
|
|
296
348
|
upload: async () => {
|
|
297
|
-
if (!data || !hasRoom(data))
|
|
349
|
+
if (!data || !offer().upload || !hasRoom(data))
|
|
298
350
|
return;
|
|
299
351
|
const chosen = await chooseFiles(acceptOf(data.category), data.category.maxCount > 1);
|
|
300
352
|
if (chosen.length > 0)
|
|
@@ -305,22 +357,25 @@ export function useOwnerFiles(params) {
|
|
|
305
357
|
* that keeps one file, the edited picture takes its place.
|
|
306
358
|
*/
|
|
307
359
|
edit: async (file) => {
|
|
308
|
-
if (!data)
|
|
360
|
+
if (!data || !offer(file).edit)
|
|
309
361
|
return;
|
|
310
362
|
const replacing = data.category.maxCount === 1 && data.files.length === 1 ? file : undefined;
|
|
311
363
|
if (!replacing && !hasRoom(data))
|
|
312
364
|
return;
|
|
313
|
-
await change("Saving", () => editAndStore(getFileAdapter().fileUrl(file, "original"), file.name, file.isPrimary || Boolean(replacing), replacing));
|
|
365
|
+
await change("Saving", () => editAndStore(getFileAdapter().fileUrl(file, "original"), file.name, file.isPrimary || Boolean(replacing), replacing, true));
|
|
314
366
|
},
|
|
315
367
|
makePrimary: (file) => owner &&
|
|
368
|
+
(file ? offer(file).makePrimary : offer().clearPrimary) &&
|
|
316
369
|
change("Saving", async () => {
|
|
317
370
|
const answer = await getFileAdapter().setPrimary(owner, file?.id ?? null);
|
|
318
371
|
if (!answer.ok)
|
|
319
|
-
|
|
372
|
+
refused(file ? "This picture could not be made the main one" : "The picture could not be removed", answer);
|
|
373
|
+
else
|
|
374
|
+
noted("primary", file);
|
|
320
375
|
return answer.ok;
|
|
321
376
|
}),
|
|
322
377
|
rename: async (file) => {
|
|
323
|
-
if (!owner)
|
|
378
|
+
if (!owner || !offer(file).rename)
|
|
324
379
|
return;
|
|
325
380
|
const name = await messageBox.prompt({ title: "Rename", label: "Name", defaultValue: file.name.replace(/\.[A-Za-z][A-Za-z0-9]{0,3}$/, ""), confirmLabel: "Rename" });
|
|
326
381
|
if (name === null || !name.trim())
|
|
@@ -328,25 +383,53 @@ export function useOwnerFiles(params) {
|
|
|
328
383
|
await change("Saving", async () => {
|
|
329
384
|
const answer = await getFileAdapter().rename(owner, file.id, name.trim());
|
|
330
385
|
if (!answer.ok)
|
|
331
|
-
|
|
386
|
+
refused("The file was not renamed", answer);
|
|
387
|
+
else
|
|
388
|
+
noted("rename", file);
|
|
332
389
|
return answer.ok;
|
|
333
390
|
});
|
|
334
391
|
},
|
|
335
392
|
remove: async (file) => {
|
|
336
|
-
if (!owner)
|
|
393
|
+
if (!owner || !offer(file).remove)
|
|
337
394
|
return;
|
|
338
395
|
const yes = await messageBox.confirm({
|
|
339
396
|
title: `Delete "${file.name}"?`,
|
|
340
|
-
message:
|
|
341
|
-
|
|
397
|
+
message: holdsRemovals
|
|
398
|
+
? heldQuestion(file)
|
|
399
|
+
: file.isPrimary && (data?.files.length ?? 0) > 1
|
|
400
|
+
? "It is the main picture now; the next one takes its place. It cannot be brought back."
|
|
401
|
+
: "It cannot be brought back.",
|
|
402
|
+
tone: holdsRemovals ? "warning" : "danger",
|
|
342
403
|
confirmLabel: "Delete",
|
|
343
404
|
});
|
|
344
405
|
if (!yes)
|
|
345
406
|
return;
|
|
346
|
-
await change("Deleting", async () => {
|
|
407
|
+
await change(holdsRemovals ? "Sending the removal for review" : "Deleting", async () => {
|
|
347
408
|
const answer = await getFileAdapter().remove(owner, file.id);
|
|
348
409
|
if (!answer.ok)
|
|
349
|
-
|
|
410
|
+
refused("The file was not deleted", answer);
|
|
411
|
+
else if (removalHeld(answer.result)) {
|
|
412
|
+
held(file, answer.result);
|
|
413
|
+
noted("hold", file);
|
|
414
|
+
}
|
|
415
|
+
else
|
|
416
|
+
noted("delete", file);
|
|
417
|
+
return answer.ok;
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
/** Keeps a file whose removal waits for a review: the removal is cancelled. */
|
|
421
|
+
keep: async (file) => {
|
|
422
|
+
const adapter = getFileAdapter();
|
|
423
|
+
if (!owner || !adapter.keep || !offer(file).keep)
|
|
424
|
+
return;
|
|
425
|
+
await change("Keeping", async () => {
|
|
426
|
+
const answer = await adapter.keep(owner, file.id);
|
|
427
|
+
if (!answer.ok)
|
|
428
|
+
refused("The removal was not cancelled", answer);
|
|
429
|
+
else {
|
|
430
|
+
toast("Removal cancelled", { tone: "success", message: `"${file.name}" is kept.` });
|
|
431
|
+
noted("keep", answer.result ?? file);
|
|
432
|
+
}
|
|
350
433
|
return answer.ok;
|
|
351
434
|
});
|
|
352
435
|
},
|
|
@@ -365,7 +448,7 @@ export function useOwnerFiles(params) {
|
|
|
365
448
|
});
|
|
366
449
|
},
|
|
367
450
|
};
|
|
368
|
-
return { status, data, busy, canWrite, readOnlyReason, pictures, editorProblem, actions };
|
|
451
|
+
return { status, data, busy, canWrite, readOnlyReason, changes, changesNote, offer, pictures, holdsRemovals, editorProblem, actions };
|
|
369
452
|
}
|
|
370
453
|
const FOCUSABLE = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
371
454
|
/**
|
|
@@ -450,6 +533,18 @@ export function OwnerPicture({ owner, size = 28, aspect = 1, shape = "rect", lab
|
|
|
450
533
|
}
|
|
451
534
|
return (_jsx("button", { type: "button", className: classes, style: style, "data-tone": tone, "aria-label": label, title: label, onClick: onClick, onPointerEnter: preloadImageEditor, children: face }));
|
|
452
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* "Removal waiting for review" on a file whose Delete waits for a review, a warning tag; when it was asked in the
|
|
538
|
+
* person's date-time format beside it where there is room (`withTime`), else in its tooltip.
|
|
539
|
+
*/
|
|
540
|
+
function PendingRemoval({ file, when, withTime, className }) {
|
|
541
|
+
const asked = file.removalAt ? formatDateTime(file.removalAt, when) : "";
|
|
542
|
+
return (_jsx("span", { className: cx("nx-table__tag", className), "data-tone": "warning", title: asked ? `Removal asked ${asked}` : undefined, children: withTime && asked ? `${PENDING_REMOVAL_TAG} since ${asked}` : PENDING_REMOVAL_TAG }));
|
|
543
|
+
}
|
|
544
|
+
/** Keep, in place of Delete on a file whose removal waits: cancels the removal. */
|
|
545
|
+
function KeepButton({ file, busy, keep, size = "sm" }) {
|
|
546
|
+
return (_jsx(Button, { size: size, variant: "ghost", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), "aria-label": `Keep ${file.name}`, title: "Keep it: cancel its removal", ...busyProps(busy, () => void keep(file)), children: "Keep" }));
|
|
547
|
+
}
|
|
453
548
|
/** The held tiles a gallery draws while its pictures are read, so the grid is there at once. */
|
|
454
549
|
const HELD_TILES = [0, 1, 2];
|
|
455
550
|
/** "2 of 20 · PNG, JPEG or WebP, up to 300 KB each": how many are kept, of how many, what kinds and how large. */
|
|
@@ -482,7 +577,7 @@ function noRoomReason(owned, replaces = true) {
|
|
|
482
577
|
export function PictureGallery(props) {
|
|
483
578
|
const files = useOwnerFiles(props);
|
|
484
579
|
const preferences = useDatePreferences();
|
|
485
|
-
const { status, data, busy,
|
|
580
|
+
const { status, data, busy, readOnlyReason, changesNote, offer, editorProblem, actions } = files;
|
|
486
581
|
const rootRef = useRef(null);
|
|
487
582
|
useFocusThroughChange(rootRef, busy);
|
|
488
583
|
// The main picture's original that could not be shown (a file gone since the list was read): the icon stands in.
|
|
@@ -509,7 +604,11 @@ export function PictureGallery(props) {
|
|
|
509
604
|
const owned = data;
|
|
510
605
|
// Held at the limit with its reason, as the pictures' Upload is; pressed, it says the refusal (`hasRoom`).
|
|
511
606
|
const full = noRoomReason(owned, false);
|
|
512
|
-
|
|
607
|
+
const offered = offer();
|
|
608
|
+
return (_jsxs("div", { ref: rootRef, className: "nx-files", "data-state": "ready", "aria-busy": busy ? "true" : undefined, children: [_jsxs("div", { className: "nx-files__bar", children: [_jsx("p", { className: "nx-files__note", "aria-live": "polite", children: busy ? `${busy}…` : limitsLine(owned) }), offered.upload && (_jsx(Button, { variant: "primary", size: "sm", icon: _jsx(InlineIcon, { icon: iconUpload }), "aria-disabled": busy || full ? true : undefined, title: full ?? undefined, onClick: () => (busy ? undefined : void actions.upload()), onPointerEnter: preloadImageEditor, children: "Upload" }))] }), readOnlyReason && _jsx("p", { className: "nx-files__note", children: readOnlyReason }), changesNote && _jsx("p", { className: "nx-files__note", children: changesNote }), editorProblem && !busy && (_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: editorProblem })), owned.files.length === 0 ? (_jsxs("div", { className: "nx-files__empty", children: [_jsx(InlineIcon, { icon: iconFileText }), _jsxs("p", { children: ["No ", noun, "s yet."] }), offered.upload && _jsxs("p", { className: "nx-files__note", children: ["Upload ", owned.category.maxCount > 1 ? `up to ${owned.category.maxCount}` : "one", "."] })] })) : (_jsx("ul", { className: "nx-files__list", children: owned.files.map(file => {
|
|
609
|
+
const mine = offer(file);
|
|
610
|
+
return (_jsxs("li", { className: "nx-files__row", children: [isPictureType(file.contentType) ? (_jsx("button", { type: "button", className: "nx-picture", style: { "--nx-picture-h": "40px", "--nx-picture-ratio": 1 }, "aria-label": `View ${file.name}`, onClick: () => void actions.view(file), children: _jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }) })) : (_jsx("span", { className: "nx-picture nx-picture--empty", style: { "--nx-picture-h": "40px", "--nx-picture-ratio": 1 }, "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconFile }) })), _jsxs("div", { className: "nx-files__row-text", children: [_jsx("a", { className: "nx-files__name", href: getFileAdapter().fileUrl(file, "original"), target: "_blank", rel: "noopener", children: file.name }), _jsxs("span", { className: "nx-files__note", children: [fileKindName(file.contentType), " \u00B7 ", fileSizeText(file.size), file.createdAt && ` · ${formatDateTime(file.createdAt, preferences)}`] }), file.pendingRemoval && _jsx(PendingRemoval, { file: file, when: preferences, withTime: true })] }), (mine.rename || mine.remove || mine.keep) && (_jsxs("div", { className: "nx-files__actions", children: [mine.rename && _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Rename ${file.name}`, title: "Rename", ...busyProps(busy, () => void actions.rename(file)) }), mine.remove && _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${file.name}`, title: "Delete", ...busyProps(busy, () => void actions.remove(file)) }), mine.keep && _jsx(KeepButton, { file: file, busy: busy, keep: actions.keep })] }))] }, file.id));
|
|
611
|
+
}) }))] }));
|
|
513
612
|
}
|
|
514
613
|
const owned = loading ? null : data;
|
|
515
614
|
const kept = owned?.files ?? [];
|
|
@@ -520,7 +619,16 @@ export function PictureGallery(props) {
|
|
|
520
619
|
const coverUrl = primary ? getFileAdapter().fileUrl(primary, "original") : null;
|
|
521
620
|
const coverShown = coverUrl && coverFailed !== coverUrl;
|
|
522
621
|
const editHeld = busy || noRoom ? true : undefined;
|
|
523
|
-
|
|
622
|
+
const offered = owned ? offer() : null;
|
|
623
|
+
// Beside Upload: Delete in a one-file category (Keep while its removal waits), else Remove (no main picture, the pictures kept).
|
|
624
|
+
const mainOffer = primary ? offer(primary) : null;
|
|
625
|
+
const sideKeep = Boolean(single && mainOffer?.keep);
|
|
626
|
+
const sideRemove = Boolean(primary && offered && (single ? mainOffer?.remove : offered.clearPrimary));
|
|
627
|
+
return (_jsx("div", { ref: rootRef, className: "nx-files nx-files--panes", "data-state": loading ? "loading" : "ready", "aria-busy": loading || busy ? "true" : undefined, children: _jsxs("div", { className: "nx-files__frame", children: [_jsxs("div", { className: "nx-files__side", children: [_jsx("span", { className: cx("nx-picture", "nx-files__cover", !coverShown && "nx-picture--empty", loading && "nx-picture--loading"), style: shape, role: "img", "aria-label": primary ? `The main picture: ${primary.name}` : loading ? "The main picture" : "No main picture", children: coverShown ? _jsx("img", { src: coverUrl, alt: "", decoding: "async", onError: () => setCoverFailed(coverUrl) }) : _jsx(InlineIcon, { icon: iconPhoto }) }), _jsxs("div", { className: "nx-files__about", children: [offered && (offered.upload || sideRemove || sideKeep) && (_jsxs("div", { className: "nx-files__side-actions", children: [offered.upload && (_jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconUpload }), "aria-disabled": busy || noRoom ? true : undefined, title: noRoom ?? undefined, onClick: () => (busy ? undefined : void (single && kept.length > 0 ? actions.replace() : actions.upload())), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor, children: single && kept.length > 0 ? "Replace" : "Upload" })), sideRemove && primary && (_jsx(Button, { hoverTone: "danger", icon: _jsx(InlineIcon, { icon: single ? iconTrash : iconX }), title: single ? undefined : "Show no picture: the pictures are kept", ...busyProps(busy, () => void (single ? actions.remove(primary) : actions.makePrimary(null))), children: single ? "Delete" : "Remove" })), sideKeep && primary && _jsx(KeepButton, { file: primary, busy: busy, keep: actions.keep, size: "md" })] })), primary?.pendingRemoval && _jsx(PendingRemoval, { file: primary, when: preferences, withTime: true }), _jsx("p", { className: "nx-files__note", "aria-live": "polite", children: loading ? `Loading the ${noun}s…` : busy ? `${busy}…` : limitsLine(owned) }), readOnlyReason && _jsx("p", { className: "nx-files__note", children: readOnlyReason }), changesNote && _jsx("p", { className: "nx-files__note", children: changesNote }), editorProblem && !busy && (_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: editorProblem }))] })] }), _jsx("div", { className: "nx-files__pane", children: loading ? (_jsx("ul", { className: "nx-files__grid", style: shape, "aria-hidden": "true", children: HELD_TILES.map(index => (_jsx("li", { children: _jsx("span", { className: "nx-table__bone nx-files__bone" }) }, index))) })) : kept.length === 0 ? (_jsxs("div", { className: "nx-files__empty", children: [_jsx(InlineIcon, { icon: iconPhoto }), _jsxs("p", { children: ["No ", noun, "s yet."] }), offered?.upload && _jsxs("p", { className: "nx-files__note", children: ["Upload ", owned.category.maxCount > 1 ? `up to ${owned.category.maxCount}` : "one", "."] })] })) : (_jsx("ul", { className: "nx-files__grid", style: shape, "aria-label": props.title ? `${props.title}: pictures` : "Pictures", children: kept.map(file => {
|
|
628
|
+
const mine = offer(file);
|
|
629
|
+
const about = [file.name, file.isPrimary && "the main picture", file.pendingRemoval && PENDING_REMOVAL_TAG.toLowerCase()].filter(Boolean).join(", ");
|
|
630
|
+
return (_jsxs("li", { className: "nx-files__tile", "data-primary": file.isPrimary || undefined, "data-pending": file.pendingRemoval || undefined, children: [_jsxs("span", { className: "nx-files__thumb", children: [_jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }), file.pendingRemoval && _jsx(PendingRemoval, { file: file, when: preferences, className: "nx-files__pending" })] }), file.isPrimary && (_jsx("span", { className: "nx-table__tag nx-files__mark", "data-tone": "primary", children: "Main" })), _jsxs("div", { className: "nx-files__tools", role: "group", "aria-label": about, children: [_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconEye }), "aria-label": `View ${file.name}`, title: "View", onClick: () => void actions.view(file), onPointerEnter: preloadImageEditor }), mine.makePrimary && _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconStar }), "aria-label": `Make ${file.name} the main picture`, title: "Make it the main picture", ...busyProps(busy, () => void actions.makePrimary(file)) }), mine.edit && (_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Edit ${file.name}`, title: noRoom ? `Edit. ${noRoom}` : "Edit: saved as a new picture", "aria-disabled": editHeld, onClick: () => (busy ? undefined : void actions.edit(file)), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor })), mine.remove && !file.isPrimary && _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${file.name}`, title: "Delete", ...busyProps(busy, () => void actions.remove(file)) }), mine.keep && _jsx(KeepButton, { file: file, busy: busy, keep: actions.keep })] })] }, file.id));
|
|
631
|
+
}) })) })] }) }));
|
|
524
632
|
}
|
|
525
633
|
/** An owner's documents as a list: `PictureGallery` with `view="documents"`. */
|
|
526
634
|
export function DocumentList(props) {
|
|
@@ -558,11 +666,13 @@ export function openPictureGallery({ popupTitle, ...props }) {
|
|
|
558
666
|
/**
|
|
559
667
|
* A column with each row's picture: every visible row's read in one call per page, and a click opens the row's
|
|
560
668
|
* gallery. A change there redraws the row's picture without a reload.
|
|
669
|
+
* It is a `lead` column: every table draws it first of the page's columns (after the row number), pinned on the left
|
|
670
|
+
* when the page pins others there, wherever it stands in `columns`, until the user moves, unpins or hides it.
|
|
561
671
|
*
|
|
562
672
|
* @example
|
|
563
673
|
* columns: [pictureColumn({ module: NexusModule.Enterprise, category: "branch-picture", rowTitle: row => row.branchName }), ...]
|
|
564
674
|
*/
|
|
565
|
-
export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nameKey, lockReason, onChange, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, view, ...column }) {
|
|
675
|
+
export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nameKey, lockReason, onChange, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, changes, changesReason, holdsRemovals, removalReviewer, view, ...column }) {
|
|
566
676
|
return {
|
|
567
677
|
key: "picture",
|
|
568
678
|
label: "Picture",
|
|
@@ -571,6 +681,7 @@ export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nam
|
|
|
571
681
|
sortable: false,
|
|
572
682
|
searchable: false,
|
|
573
683
|
resizable: false,
|
|
684
|
+
lead: true,
|
|
574
685
|
...column,
|
|
575
686
|
value: () => null,
|
|
576
687
|
render: row => {
|
|
@@ -582,7 +693,7 @@ export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nam
|
|
|
582
693
|
const owner = { module, category, ownerId };
|
|
583
694
|
const named = nameKey ? readPath(row, nameKey) : null;
|
|
584
695
|
const locked = typeof lockReason === "function" ? lockReason(row) : lockReason;
|
|
585
|
-
return (_jsx(OwnerPicture, { owner: owner, aspect: aspect, shape: shape, name: typeof named === "string" ? named : null, label: title ? `${title}: pictures` : "Pictures", onClick: () => openPictureGallery({ ...owner, title, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, lockReason: locked ?? null, view, onChange: primary => onChange?.(row, primary) }) }));
|
|
696
|
+
return (_jsx(OwnerPicture, { owner: owner, aspect: aspect, shape: shape, name: typeof named === "string" ? named : null, label: title ? `${title}: pictures` : "Pictures", onClick: () => openPictureGallery({ ...owner, title, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, changes, changesReason, holdsRemovals, removalReviewer, lockReason: locked ?? null, view, onChange: primary => onChange?.(row, primary) }) }));
|
|
586
697
|
},
|
|
587
698
|
text: () => "",
|
|
588
699
|
};
|
|
@@ -117,6 +117,9 @@ export const NEXUS_MODULES = [
|
|
|
117
117
|
{ module: NexusModule.Message, key: "message", label: "Message", env: "API_MESSAGE" },
|
|
118
118
|
{ module: NexusModule.Chat, key: "chat", label: "Chat", env: "API_CHAT" },
|
|
119
119
|
{ module: NexusModule.Accounting, key: "accounting", label: "Accounting", env: "API_ACCOUNTING" },
|
|
120
|
+
// Retired on 2026-11-11 (`NexusModule.Report` is deprecated): kept so the list's published entries do not change in a
|
|
121
|
+
// minor and an old proxy URL (/api/app/report/...) is still known. An app sets no API_REPORT, and a list a person
|
|
122
|
+
// picks a module from leaves it out (`info.module !== NexusModule.Report`).
|
|
120
123
|
{ module: NexusModule.Report, key: "report", label: "Report", env: "API_REPORT" },
|
|
121
124
|
{ module: NexusModule.Requisition, key: "requisition", label: "Requisition", env: "API_REQUISITION" },
|
|
122
125
|
{ module: NexusModule.Tenant, key: "tenant", label: "Tenant", env: "API_TENANT" },
|
|
@@ -126,6 +129,7 @@ export const NEXUS_MODULES = [
|
|
|
126
129
|
{ module: NexusModule.Pharmacy, key: "pharmacy", label: "Pharmacy", env: "API_PHARMACY" },
|
|
127
130
|
{ module: NexusModule.Staffing, key: "staffing", label: "Staffing", env: "API_STAFFING" },
|
|
128
131
|
{ module: NexusModule.Portfolio, key: "portfolio", label: "Portfolio", env: "API_PORTFOLIO" },
|
|
132
|
+
{ module: NexusModule.Inventory, key: "inventory", label: "Inventory", env: "API_INVENTORY" },
|
|
129
133
|
// The app's own API routes (/api/...), never through the proxy.
|
|
130
134
|
{ module: NexusModule.None, key: "app", label: "App" },
|
|
131
135
|
];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** An entry as far as its person goes: their token, and their name when the history's answer carries it. */
|
|
2
|
+
export interface AuditActor {
|
|
3
|
+
creatorUserId?: string | null;
|
|
4
|
+
creatorName?: string | null;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A token, short. It names nobody and only tells two apart, so its first characters are enough. A token is the same
|
|
8
|
+
* text for one id on every entry this reader is given (the id is encrypted for the reader, not at random).
|
|
9
|
+
*/
|
|
10
|
+
export declare const shortToken: (token: string) => string;
|
|
11
|
+
/**
|
|
12
|
+
* Names the people behind a history's tokens: given the tokens of entries the answer names nobody for, the app answers
|
|
13
|
+
* `{ [token]: "Sita Rai (sita.rai)" }` - the whole text the line shows after "by". A token it leaves out keeps its short
|
|
14
|
+
* form.
|
|
15
|
+
*/
|
|
16
|
+
export type AuditActorNamesResolver = (tokens: string[], signal: AbortSignal) => Promise<Record<string, string>>;
|
|
17
|
+
/**
|
|
18
|
+
* Sets, once for the app, how a record's history (`AuditHistory`) names the person behind an entry its answer names
|
|
19
|
+
* nobody for (no `creatorName`). The history collects the tokens of every page it shows (the first and each "Show
|
|
20
|
+
* more"), asks once per page for those it has had no answer for, and says "by Sita Rai (sita.rai)" for each one
|
|
21
|
+
* answered. A token is the same text for one person on every entry this reader is given, so the app can look it up (its
|
|
22
|
+
* own list of people, or a call to the server that made it). A call that fails keeps the line's "User 3fA9b2…" and is
|
|
23
|
+
* asked again by the next history; a token left out of the answer keeps it for the page's life. Answers are kept for the
|
|
24
|
+
* page's life; `null` stops asking and forgets them.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* setAuditActorNames(async (tokens, signal) => {
|
|
28
|
+
* const answer = await api.post<Record<string, string>>(USERS.names, tokens, { signal, toast: false });
|
|
29
|
+
* return answer.ok ? answer.result : {};
|
|
30
|
+
* });
|
|
31
|
+
*/
|
|
32
|
+
export declare function setAuditActorNames(resolver: AuditActorNamesResolver | null): void;
|
|
33
|
+
/** The name the app gave for a token, once it has given one. */
|
|
34
|
+
export declare const knownAuditActorName: (token: string | null | undefined) => string | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The tokens of these entries the app is still to be asked for: entries with no name in the answer, whose token has had
|
|
37
|
+
* no answer yet, each token once. Empty without a resolver.
|
|
38
|
+
*/
|
|
39
|
+
export declare function unnamedAuditActors(entries: readonly AuditActor[]): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Asks the app, in one call, for the names of these entries' unnamed people (`unnamedAuditActors`) and keeps the answer.
|
|
42
|
+
* Resolves `true` when it learnt a name. A failed or aborted call keeps nothing, so a later history asks again.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveAuditActors(entries: readonly AuditActor[], signal: AbortSignal): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Who made a change, as the entry's line says it: "by Sita Rai" when the history's answer names them (`creatorName`) or
|
|
47
|
+
* the app has named their token (`setAuditActorNames`), else the start of their token ("User 3fA9b2…"), which still
|
|
48
|
+
* tells two people apart; `null` when it says neither.
|
|
49
|
+
*/
|
|
50
|
+
export declare function auditActorText(entry: AuditActor): string | null;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The words of a record's history entry (Components/Forms/CrudHistory.tsx) that are worked out rather than drawn, so Node
|
|
3
|
+
* tests run them, and the app's way of naming the people behind the entries' tokens. Only `setAuditActorNames` and its
|
|
4
|
+
* type are exported from the package.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* A token, short. It names nobody and only tells two apart, so its first characters are enough. A token is the same
|
|
8
|
+
* text for one id on every entry this reader is given (the id is encrypted for the reader, not at random).
|
|
9
|
+
*/
|
|
10
|
+
export const shortToken = (token) => (token.length > 8 ? `${token.slice(0, 6)}…` : token);
|
|
11
|
+
let actorResolver = null;
|
|
12
|
+
/** What the app answered, by token, for the page's life: a second history asks only for the people it has not seen. */
|
|
13
|
+
const actorNames = new Map();
|
|
14
|
+
/** Tokens the app answered nothing for: not asked again, so a history full of one stranger never asks on every page. */
|
|
15
|
+
const actorUnknown = new Set();
|
|
16
|
+
/**
|
|
17
|
+
* Sets, once for the app, how a record's history (`AuditHistory`) names the person behind an entry its answer names
|
|
18
|
+
* nobody for (no `creatorName`). The history collects the tokens of every page it shows (the first and each "Show
|
|
19
|
+
* more"), asks once per page for those it has had no answer for, and says "by Sita Rai (sita.rai)" for each one
|
|
20
|
+
* answered. A token is the same text for one person on every entry this reader is given, so the app can look it up (its
|
|
21
|
+
* own list of people, or a call to the server that made it). A call that fails keeps the line's "User 3fA9b2…" and is
|
|
22
|
+
* asked again by the next history; a token left out of the answer keeps it for the page's life. Answers are kept for the
|
|
23
|
+
* page's life; `null` stops asking and forgets them.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* setAuditActorNames(async (tokens, signal) => {
|
|
27
|
+
* const answer = await api.post<Record<string, string>>(USERS.names, tokens, { signal, toast: false });
|
|
28
|
+
* return answer.ok ? answer.result : {};
|
|
29
|
+
* });
|
|
30
|
+
*/
|
|
31
|
+
export function setAuditActorNames(resolver) {
|
|
32
|
+
actorResolver = resolver;
|
|
33
|
+
actorNames.clear();
|
|
34
|
+
actorUnknown.clear();
|
|
35
|
+
}
|
|
36
|
+
/** The name the app gave for a token, once it has given one. */
|
|
37
|
+
export const knownAuditActorName = (token) => (token ? actorNames.get(token) : undefined);
|
|
38
|
+
/**
|
|
39
|
+
* The tokens of these entries the app is still to be asked for: entries with no name in the answer, whose token has had
|
|
40
|
+
* no answer yet, each token once. Empty without a resolver.
|
|
41
|
+
*/
|
|
42
|
+
export function unnamedAuditActors(entries) {
|
|
43
|
+
if (!actorResolver)
|
|
44
|
+
return [];
|
|
45
|
+
const tokens = new Set();
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const token = entry.creatorUserId;
|
|
48
|
+
if (!token || entry.creatorName?.trim() || actorNames.has(token) || actorUnknown.has(token))
|
|
49
|
+
continue;
|
|
50
|
+
tokens.add(token);
|
|
51
|
+
}
|
|
52
|
+
return [...tokens];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Asks the app, in one call, for the names of these entries' unnamed people (`unnamedAuditActors`) and keeps the answer.
|
|
56
|
+
* Resolves `true` when it learnt a name. A failed or aborted call keeps nothing, so a later history asks again.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveAuditActors(entries, signal) {
|
|
59
|
+
const resolver = actorResolver;
|
|
60
|
+
const tokens = unnamedAuditActors(entries);
|
|
61
|
+
if (!resolver || tokens.length === 0)
|
|
62
|
+
return false;
|
|
63
|
+
let answer;
|
|
64
|
+
try {
|
|
65
|
+
answer = await resolver(tokens, signal);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
// The history went away, or the app set another resolver meanwhile: this answer is not kept.
|
|
71
|
+
if (signal.aborted || resolver !== actorResolver)
|
|
72
|
+
return false;
|
|
73
|
+
let learnt = false;
|
|
74
|
+
for (const token of tokens) {
|
|
75
|
+
const given = answer?.[token];
|
|
76
|
+
const name = typeof given === "string" ? given.trim() : "";
|
|
77
|
+
if (name) {
|
|
78
|
+
actorNames.set(token, name);
|
|
79
|
+
learnt = true;
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
actorUnknown.add(token);
|
|
83
|
+
}
|
|
84
|
+
return learnt;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Who made a change, as the entry's line says it: "by Sita Rai" when the history's answer names them (`creatorName`) or
|
|
88
|
+
* the app has named their token (`setAuditActorNames`), else the start of their token ("User 3fA9b2…"), which still
|
|
89
|
+
* tells two people apart; `null` when it says neither.
|
|
90
|
+
*/
|
|
91
|
+
export function auditActorText(entry) {
|
|
92
|
+
const name = entry.creatorName?.trim() || knownAuditActorName(entry.creatorUserId);
|
|
93
|
+
if (name)
|
|
94
|
+
return `by ${name}`;
|
|
95
|
+
return entry.creatorUserId ? `User ${shortToken(entry.creatorUserId)}` : null;
|
|
96
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ApiEndpoint, type ApiModuleValue } from "../Interfaces/ApiInterfaces.ts";
|
|
2
|
-
import type { AppChatDomain, ChatConversation, ChatDomainInfo, ChatDomainValue, ChatDraft, ChatHistory, ChatHistoryRequest, ChatId, ChatMessage, ChatParticipant, ChatParticipantPage, ChatParticipantQuery, ChatScope, ChatSummary, NewChatRequest } from "../Interfaces/ChatInterfaces.ts";
|
|
2
|
+
import type { AppChatDomain, ChatConversation, ChatDomainInfo, ChatDomainValue, ChatDraft, ChatHistory, ChatHistoryRequest, ChatId, ChatMessage, ChatMessageKind, ChatParticipant, ChatParticipantPage, ChatParticipantQuery, ChatScope, ChatSummary, NewChatRequest } from "../Interfaces/ChatInterfaces.ts";
|
|
3
3
|
/** The actions a chat needs from a backend. A backend that does not serve one takes that feature off the panel. */
|
|
4
4
|
export declare const CHAT_ACTIONS: {
|
|
5
5
|
/** The conversations a record holds, for this reader. */
|
|
@@ -106,6 +106,11 @@ export declare class NexusChatBackend<TDomain extends ChatDomainValue = AppChatD
|
|
|
106
106
|
protected toConversation(row: Row): ChatConversation;
|
|
107
107
|
/** One message row. The names are the ones a Nexus contract uses; override this alone for a backend that differs. */
|
|
108
108
|
protected toMessage(row: Row): ChatMessage;
|
|
109
|
+
/**
|
|
110
|
+
* What a message is, from the backend's `EChatMessageTypes` (0 a chat line, 1 the mail a thread opens with, 2 a
|
|
111
|
+
* notification) or a word. Anything else is a chat line.
|
|
112
|
+
*/
|
|
113
|
+
protected toKind(value: unknown): ChatMessageKind;
|
|
109
114
|
/** One set of counts. */
|
|
110
115
|
protected toSummary(row: Row): ChatSummary;
|
|
111
116
|
/** One participant row. */
|
|
@@ -125,6 +130,8 @@ export declare function getChatBackend(): ChatBackend<any>;
|
|
|
125
130
|
* { domain: EDomainTypes.Leave, key: "leave", label: "Leave request" },
|
|
126
131
|
* { domain: EDomainTypes.Procurement, key: "procurement", label: "Purchase order" },
|
|
127
132
|
* ]);
|
|
133
|
+
*
|
|
134
|
+
* A row's `card` shows a message's record as a card read live from its module (`domainCard`, `DomainCard`).
|
|
128
135
|
*/
|
|
129
136
|
export declare function defineChatDomains<TDomain extends ChatDomainValue>(list: readonly ChatDomainInfo<TDomain>[]): readonly ChatDomainInfo<TDomain>[];
|
|
130
137
|
/** The registered domains. Empty until an app calls `defineChatDomains`. */
|
|
@@ -254,10 +254,23 @@ export class NexusChatBackend extends ChatBackend {
|
|
|
254
254
|
})),
|
|
255
255
|
}),
|
|
256
256
|
...(row.isSystem === true && { isSystem: true }),
|
|
257
|
+
...((row.messageType ?? row.kind) !== undefined && { kind: this.toKind(row.messageType ?? row.kind) }),
|
|
257
258
|
...(Array.isArray(row.deliveredTo) && { deliveredTo: row.deliveredTo.map(id).filter((value) => value !== null) }),
|
|
258
259
|
...(Array.isArray(row.readBy) && { readBy: row.readBy.map(id).filter((value) => value !== null) }),
|
|
259
260
|
};
|
|
260
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* What a message is, from the backend's `EChatMessageTypes` (0 a chat line, 1 the mail a thread opens with, 2 a
|
|
264
|
+
* notification) or a word. Anything else is a chat line.
|
|
265
|
+
*/
|
|
266
|
+
toKind(value) {
|
|
267
|
+
const written = String(value).toLowerCase();
|
|
268
|
+
if (written === "1" || written === "mail")
|
|
269
|
+
return "mail";
|
|
270
|
+
if (written === "2" || written === "notification")
|
|
271
|
+
return "notification";
|
|
272
|
+
return "chat";
|
|
273
|
+
}
|
|
261
274
|
/** One set of counts. */
|
|
262
275
|
toSummary(row) {
|
|
263
276
|
const lastText = text(row.lastMessageText ?? row.lastMessage ?? row.lastBody);
|
|
@@ -309,6 +322,8 @@ let domains = [];
|
|
|
309
322
|
* { domain: EDomainTypes.Leave, key: "leave", label: "Leave request" },
|
|
310
323
|
* { domain: EDomainTypes.Procurement, key: "procurement", label: "Purchase order" },
|
|
311
324
|
* ]);
|
|
325
|
+
*
|
|
326
|
+
* A row's `card` shows a message's record as a card read live from its module (`domainCard`, `DomainCard`).
|
|
312
327
|
*/
|
|
313
328
|
export function defineChatDomains(list) {
|
|
314
329
|
const seen = new Set();
|
|
@@ -317,6 +332,9 @@ export function defineChatDomains(list) {
|
|
|
317
332
|
if (seen.has(key))
|
|
318
333
|
throw new Error(`Two chat domains have the value ${key}.`);
|
|
319
334
|
seen.add(key);
|
|
335
|
+
if (info.card && (typeof info.card.load !== "function" || typeof info.card.view !== "function")) {
|
|
336
|
+
throw new Error(`The chat domain ${key}'s card needs a load and a view.`);
|
|
337
|
+
}
|
|
320
338
|
}
|
|
321
339
|
domains = list;
|
|
322
340
|
return list;
|