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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { chatBadgeText } from "./ChatHelpers.js";
|
|
2
|
+
/*
|
|
3
|
+
* How much mail waits, for the bell on every page: one poller per mailbox, outside React, shared by every bell that
|
|
4
|
+
* draws it. It reads `source.unread()` once when the first bell appears, then every minute while the page is in front,
|
|
5
|
+
* at once when the window takes the focus again or the tab is shown again, and never while the tab is hidden. However
|
|
6
|
+
* many bells a page draws, it asks once. When the count goes up after the page has read it once, it words it for a
|
|
7
|
+
* screen reader, so the bell says "3 unread messages" once - not on every read, and not as the page loads.
|
|
8
|
+
*/
|
|
9
|
+
/** How often the bell reads, in milliseconds, while the page is in front: a minute (the domain messages spec). */
|
|
10
|
+
export const MESSAGE_UNREAD_INTERVAL = 60_000;
|
|
11
|
+
/** A wake within this long of the last read reads nothing more: a tab shown again also takes the focus. */
|
|
12
|
+
export const MESSAGE_UNREAD_WAKE_GAP = 2_000;
|
|
13
|
+
/** The browser's: `Date.now`, `setTimeout`, `document.visibilityState`, `focus` and `visibilitychange`. */
|
|
14
|
+
export function browserUnreadEnvironment() {
|
|
15
|
+
return {
|
|
16
|
+
now: () => Date.now(),
|
|
17
|
+
later: (run, ms) => {
|
|
18
|
+
const timer = setTimeout(run, ms);
|
|
19
|
+
return () => clearTimeout(timer);
|
|
20
|
+
},
|
|
21
|
+
visible: () => typeof document === "undefined" || document.visibilityState !== "hidden",
|
|
22
|
+
watch: run => {
|
|
23
|
+
if (typeof window === "undefined")
|
|
24
|
+
return () => { };
|
|
25
|
+
window.addEventListener("focus", run);
|
|
26
|
+
document.addEventListener("visibilitychange", run);
|
|
27
|
+
return () => {
|
|
28
|
+
window.removeEventListener("focus", run);
|
|
29
|
+
document.removeEventListener("visibilitychange", run);
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** "No unread messages", "1 unread message", "3 unread messages", "More than 99 unread messages". */
|
|
35
|
+
export function mailUnreadWords(count, max = 99) {
|
|
36
|
+
if (count <= 0)
|
|
37
|
+
return "No unread messages";
|
|
38
|
+
if (count > max)
|
|
39
|
+
return `More than ${max} unread messages`;
|
|
40
|
+
return count === 1 ? "1 unread message" : `${count} unread messages`;
|
|
41
|
+
}
|
|
42
|
+
/** The badge: "3", "99+" from 100 on (a backend counts to 100 and stops). Empty for none. */
|
|
43
|
+
export function mailUnreadBadge(count, max = 99) {
|
|
44
|
+
return count > 0 ? chatBadgeText(count, max) : "";
|
|
45
|
+
}
|
|
46
|
+
/** A thread's address in the inbox: `href` with `thread=<id>` added to its query, before any `#`. */
|
|
47
|
+
export function mailThreadHref(href, id) {
|
|
48
|
+
const hash = href.indexOf("#");
|
|
49
|
+
const path = hash < 0 ? href : href.slice(0, hash);
|
|
50
|
+
const rest = hash < 0 ? "" : href.slice(hash);
|
|
51
|
+
return `${path}${path.includes("?") ? "&" : "?"}thread=${encodeURIComponent(String(id))}${rest}`;
|
|
52
|
+
}
|
|
53
|
+
const IDLE = { status: "idle", count: 0, newest: [], failed: false };
|
|
54
|
+
/** One mailbox's unread count for the whole page. Every bell of the page on the same source shares it. */
|
|
55
|
+
export class MessageUnreadPoller {
|
|
56
|
+
source;
|
|
57
|
+
env;
|
|
58
|
+
snapshot = IDLE;
|
|
59
|
+
listeners = new Set();
|
|
60
|
+
members = new Map();
|
|
61
|
+
controller = null;
|
|
62
|
+
cancelTimer = null;
|
|
63
|
+
stopWatching = null;
|
|
64
|
+
startedAt = Number.NEGATIVE_INFINITY;
|
|
65
|
+
endedAt = Number.NEGATIVE_INFINITY;
|
|
66
|
+
known = false;
|
|
67
|
+
seq = 0;
|
|
68
|
+
/** A refresh was asked while a read was on its way: one more read follows it. */
|
|
69
|
+
again = false;
|
|
70
|
+
/** Reads started, for a page that shows how many calls it made, and for tests. */
|
|
71
|
+
reads = 0;
|
|
72
|
+
constructor(source, env = browserUnreadEnvironment()) {
|
|
73
|
+
this.source = source;
|
|
74
|
+
this.env = env;
|
|
75
|
+
}
|
|
76
|
+
getSnapshot = () => this.snapshot;
|
|
77
|
+
/** Hears every change. Joining is separate: a bell that only shows a count need not make the page read. */
|
|
78
|
+
subscribe = (listener) => {
|
|
79
|
+
this.listeners.add(listener);
|
|
80
|
+
return () => this.listeners.delete(listener);
|
|
81
|
+
};
|
|
82
|
+
set(next) {
|
|
83
|
+
this.snapshot = { ...this.snapshot, ...next };
|
|
84
|
+
for (const listener of this.listeners)
|
|
85
|
+
listener();
|
|
86
|
+
}
|
|
87
|
+
/** How often the members want a read: the shortest any of them asked for. */
|
|
88
|
+
get interval() {
|
|
89
|
+
return this.members.size ? Math.min(...this.members.values()) : MESSAGE_UNREAD_INTERVAL;
|
|
90
|
+
}
|
|
91
|
+
/** A bell drawn: the first starts the reads. Reads at once unless an answer younger than the interval is held. */
|
|
92
|
+
join(interval = MESSAGE_UNREAD_INTERVAL) {
|
|
93
|
+
const key = Symbol("bell");
|
|
94
|
+
const first = this.members.size === 0;
|
|
95
|
+
this.members.set(key, Math.max(1000, interval));
|
|
96
|
+
if (first)
|
|
97
|
+
this.stopWatching = this.env.watch(() => this.wake());
|
|
98
|
+
if (this.env.now() - this.endedAt >= this.interval)
|
|
99
|
+
this.read();
|
|
100
|
+
else
|
|
101
|
+
this.schedule();
|
|
102
|
+
return {
|
|
103
|
+
leave: () => {
|
|
104
|
+
if (!this.members.delete(key))
|
|
105
|
+
return;
|
|
106
|
+
if (this.members.size === 0)
|
|
107
|
+
this.stop();
|
|
108
|
+
else
|
|
109
|
+
this.schedule();
|
|
110
|
+
},
|
|
111
|
+
speaks: () => this.members.keys().next().value === key,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Reads now: a page that just read a thread, archived some, or a bell opened. With a read on its way - which may have
|
|
116
|
+
* counted before the change - one more read follows it, however many refreshes are asked meanwhile; never two at once.
|
|
117
|
+
* While the page is hidden nothing is read: the read asked for waits for the page to be shown.
|
|
118
|
+
*/
|
|
119
|
+
refresh() {
|
|
120
|
+
if (this.members.size === 0)
|
|
121
|
+
return;
|
|
122
|
+
if (this.controller || !this.env.visible()) {
|
|
123
|
+
this.again = true;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.read();
|
|
127
|
+
}
|
|
128
|
+
/** A read ended: the one asked for meanwhile, now, while the page is in front; else the next one, a minute on. */
|
|
129
|
+
settle() {
|
|
130
|
+
if (this.again && this.members.size > 0 && this.env.visible()) {
|
|
131
|
+
this.again = false;
|
|
132
|
+
this.read();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.schedule();
|
|
136
|
+
}
|
|
137
|
+
/** The window took the focus, or the tab was shown or hidden. */
|
|
138
|
+
wake() {
|
|
139
|
+
if (!this.env.visible()) {
|
|
140
|
+
this.cancelTimer?.();
|
|
141
|
+
this.cancelTimer = null;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (this.controller)
|
|
145
|
+
return;
|
|
146
|
+
// Shown again with a read asked for while hidden: at once, however recent the last read.
|
|
147
|
+
if (this.again) {
|
|
148
|
+
this.again = false;
|
|
149
|
+
this.read();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (this.env.now() - this.startedAt < MESSAGE_UNREAD_WAKE_GAP) {
|
|
153
|
+
this.schedule();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.read();
|
|
157
|
+
}
|
|
158
|
+
read() {
|
|
159
|
+
if (this.controller || !this.env.visible())
|
|
160
|
+
return;
|
|
161
|
+
this.cancelTimer?.();
|
|
162
|
+
this.cancelTimer = null;
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
this.controller = controller;
|
|
165
|
+
this.startedAt = this.env.now();
|
|
166
|
+
this.reads++;
|
|
167
|
+
if (!this.known)
|
|
168
|
+
this.set({ status: "loading" });
|
|
169
|
+
let answer;
|
|
170
|
+
try {
|
|
171
|
+
answer = Promise.resolve(this.source.unread(controller.signal));
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
answer = Promise.reject(error);
|
|
175
|
+
}
|
|
176
|
+
answer.then(unread => {
|
|
177
|
+
if (this.controller !== controller)
|
|
178
|
+
return;
|
|
179
|
+
this.controller = null;
|
|
180
|
+
this.endedAt = this.env.now();
|
|
181
|
+
const count = Math.max(0, Math.floor(Number(unread.count) || 0));
|
|
182
|
+
const before = this.snapshot.count;
|
|
183
|
+
const rose = this.known && count > before;
|
|
184
|
+
this.known = true;
|
|
185
|
+
this.set({
|
|
186
|
+
status: "ready",
|
|
187
|
+
count,
|
|
188
|
+
newest: unread.newest ?? [],
|
|
189
|
+
failed: false,
|
|
190
|
+
error: undefined,
|
|
191
|
+
checkedAt: this.endedAt,
|
|
192
|
+
...(rose && { spoken: { text: mailUnreadWords(count), seq: ++this.seq } }),
|
|
193
|
+
});
|
|
194
|
+
this.settle();
|
|
195
|
+
}, (error) => {
|
|
196
|
+
if (this.controller !== controller)
|
|
197
|
+
return;
|
|
198
|
+
this.controller = null;
|
|
199
|
+
this.endedAt = this.env.now();
|
|
200
|
+
// The count read before stays on the bell; the next minute tries again.
|
|
201
|
+
this.set({ status: this.known ? "ready" : "failed", failed: true, error });
|
|
202
|
+
this.settle();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** The next read, an interval after the last one ended: only while a bell is drawn and the page is in front. */
|
|
206
|
+
schedule() {
|
|
207
|
+
this.cancelTimer?.();
|
|
208
|
+
this.cancelTimer = null;
|
|
209
|
+
if (this.members.size === 0 || this.controller || !this.env.visible())
|
|
210
|
+
return;
|
|
211
|
+
const wait = Math.max(0, this.interval - (this.env.now() - this.endedAt));
|
|
212
|
+
this.cancelTimer = this.env.later(() => {
|
|
213
|
+
this.cancelTimer = null;
|
|
214
|
+
this.read();
|
|
215
|
+
}, wait);
|
|
216
|
+
}
|
|
217
|
+
/** The last bell went: no timer, no watching, the read on its way called off. What was read stays for the next bell. */
|
|
218
|
+
stop() {
|
|
219
|
+
this.again = false;
|
|
220
|
+
this.cancelTimer?.();
|
|
221
|
+
this.cancelTimer = null;
|
|
222
|
+
this.stopWatching?.();
|
|
223
|
+
this.stopWatching = null;
|
|
224
|
+
if (this.controller) {
|
|
225
|
+
this.controller.abort();
|
|
226
|
+
this.controller = null;
|
|
227
|
+
if (!this.known)
|
|
228
|
+
this.set({ status: "idle" });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const pollers = new WeakMap();
|
|
233
|
+
/** The page's poller for a mailbox: one per source object, however many bells draw it. */
|
|
234
|
+
export function messageUnreadPoller(source) {
|
|
235
|
+
let poller = pollers.get(source);
|
|
236
|
+
if (!poller) {
|
|
237
|
+
poller = new MessageUnreadPoller(source);
|
|
238
|
+
pollers.set(source, poller);
|
|
239
|
+
}
|
|
240
|
+
return poller;
|
|
241
|
+
}
|
|
242
|
+
/** Reads the count again now, for every bell on `source`: after the page read a thread or archived some. */
|
|
243
|
+
export function refreshMessageUnread(source) {
|
|
244
|
+
pollers.get(source)?.refresh();
|
|
245
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { ApiClient } from "./ApiClient.ts";
|
|
2
2
|
import { FileAdapter } from "./FileAdapter.ts";
|
|
3
3
|
import { type ApiModuleValue, type ApiResult } from "../Interfaces/ApiInterfaces.ts";
|
|
4
|
-
import type { FileCallOptions, FileCopy, FileOwner, FileUpload, OwnerFiles, StoredFile } from "../Interfaces/FileInterfaces.ts";
|
|
4
|
+
import type { FileCallOptions, FileChange, FileCopy, FileOwner, FileUpload, OwnerFiles, StoredFile } from "../Interfaces/FileInterfaces.ts";
|
|
5
|
+
/**
|
|
6
|
+
* The owner module's refusal, passed on by Drive, of a change to files that may now only be added to (`API_X084`, the
|
|
7
|
+
* core's `NexusFileTickets.WritableAsync`): a posted goods receipt's documents renamed, marked or removed.
|
|
8
|
+
*/
|
|
9
|
+
export declare const FILES_ADD_ONLY = "API_X084";
|
|
5
10
|
/**
|
|
6
11
|
* Why an owner's files are read only when its module refuses a write ticket with a 404 but a read ticket finds it: the
|
|
7
12
|
* owner is not in use now (pending, ended, in the trash or deleted). Its files stay where they are.
|
|
@@ -44,7 +49,18 @@ export declare class NexusDriveAdapter extends FileAdapter {
|
|
|
44
49
|
upload(owner: FileOwner<ApiModuleValue>, upload: FileUpload, options?: FileCallOptions): Promise<ApiResult<StoredFile>>;
|
|
45
50
|
setPrimary(owner: FileOwner<ApiModuleValue>, fileId: string | null, options?: FileCallOptions): Promise<ApiResult<unknown>>;
|
|
46
51
|
rename(owner: FileOwner<ApiModuleValue>, fileId: string, name: string, options?: FileCallOptions): Promise<ApiResult<unknown>>;
|
|
52
|
+
/**
|
|
53
|
+
* Drive's Delete answers the file record: `pendingRemoval: true` when it stays for the review (a picture live on a
|
|
54
|
+
* reviewed gallery; a second Delete changes nothing), else it is gone (`false`, or nothing from an older Drive).
|
|
55
|
+
*/
|
|
47
56
|
remove(owner: FileOwner<ApiModuleValue>, fileId: string, options?: FileCallOptions): Promise<ApiResult<unknown>>;
|
|
57
|
+
/**
|
|
58
|
+
* `PUT File/Keep/{id}` with `{ ticket, id }` (the same id: Drive refuses a body id unlike the route's): the held removal
|
|
59
|
+
* cancelled, the file answered as it is now (`pendingRemoval: false`); a file not waiting is answered unchanged.
|
|
60
|
+
*/
|
|
61
|
+
keep(owner: FileOwner<ApiModuleValue>, fileId: string, options?: FileCallOptions): Promise<ApiResult<StoredFile | null>>;
|
|
62
|
+
/** `API_X084`: the files may now only be added to. */
|
|
63
|
+
changesLeft(answer: Pick<ApiResult, "messageCode" | "errorCode" | "status">): readonly FileChange[] | null;
|
|
48
64
|
fileUrl(file: StoredFile, copy: FileCopy): string;
|
|
49
65
|
/** Reads Drive's pointer file (`Assets/File/nexus-image-editor.txt`), which names the versioned module beside it. */
|
|
50
66
|
editorModuleUrl(signal?: AbortSignal): Promise<string>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFetchResponse } from "./ApiResponses.js";
|
|
2
2
|
import { getApiModules } from "./ApiModules.js";
|
|
3
|
-
import { FileAdapter, fileOwnerKey, pictureLimits } from "./FileAdapter.js";
|
|
3
|
+
import { FileAdapter, fileOwnerKey, pictureLimits, removalHeld } from "./FileAdapter.js";
|
|
4
4
|
import { NexusModule } from "../Interfaces/ApiInterfaces.js";
|
|
5
5
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
6
|
const text = (value) => (typeof value === "string" && value.trim() ? value : null);
|
|
@@ -14,6 +14,11 @@ const NEXUS_FILE_KINDS = [
|
|
|
14
14
|
];
|
|
15
15
|
/** The Drive refusal that says a ticket ran out (DRV_X011): asked again once, with a fresh ticket. */
|
|
16
16
|
const TICKET_RAN_OUT = "DRV_X011";
|
|
17
|
+
/**
|
|
18
|
+
* The owner module's refusal, passed on by Drive, of a change to files that may now only be added to (`API_X084`, the
|
|
19
|
+
* core's `NexusFileTickets.WritableAsync`): a posted goods receipt's documents renamed, marked or removed.
|
|
20
|
+
*/
|
|
21
|
+
export const FILES_ADD_ONLY = "API_X084";
|
|
17
22
|
/**
|
|
18
23
|
* Why an owner's files are read only when its module refuses a write ticket with a 404 but a read ticket finds it: the
|
|
19
24
|
* owner is not in use now (pending, ended, in the trash or deleted). Its files stay where they are.
|
|
@@ -143,8 +148,29 @@ export class NexusDriveAdapter extends FileAdapter {
|
|
|
143
148
|
rename(owner, fileId, name, options = {}) {
|
|
144
149
|
return this.withTicket(owner, options, ticket => this.client.send(this.driveEndpoint("PUT", "File/Rename"), { body: { ticket, id: fileId, fileDisplayName: name }, signal: options.signal, toast: false }));
|
|
145
150
|
}
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Drive's Delete answers the file record: `pendingRemoval: true` when it stays for the review (a picture live on a
|
|
153
|
+
* reviewed gallery; a second Delete changes nothing), else it is gone (`false`, or nothing from an older Drive).
|
|
154
|
+
*/
|
|
155
|
+
async remove(owner, fileId, options = {}) {
|
|
156
|
+
const answer = await this.withTicket(owner, options, ticket => this.client.send(this.driveEndpoint("DELETE", "File/Delete"), { body: { ticket, id: fileId }, signal: options.signal, toast: false }));
|
|
157
|
+
if (!answer.ok || !removalHeld(answer.result))
|
|
158
|
+
return answer;
|
|
159
|
+
return { ...answer, result: readStoredFile(answer.result) ?? { pendingRemoval: true } };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* `PUT File/Keep/{id}` with `{ ticket, id }` (the same id: Drive refuses a body id unlike the route's): the held removal
|
|
163
|
+
* cancelled, the file answered as it is now (`pendingRemoval: false`); a file not waiting is answered unchanged.
|
|
164
|
+
*/
|
|
165
|
+
keep(owner, fileId, options = {}) {
|
|
166
|
+
return this.withTicket(owner, options, async (ticket) => {
|
|
167
|
+
const answer = await this.client.send(this.driveEndpoint("PUT", "File/Keep/{id}"), { params: { id: fileId }, body: { ticket, id: fileId }, signal: options.signal, toast: false });
|
|
168
|
+
return { ...answer, result: answer.ok ? readStoredFile(answer.result) : undefined };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** `API_X084`: the files may now only be added to. */
|
|
172
|
+
changesLeft(answer) {
|
|
173
|
+
return answer.messageCode === FILES_ADD_ONLY ? ["add"] : null;
|
|
148
174
|
}
|
|
149
175
|
fileUrl(file, copy) {
|
|
150
176
|
return getApiModules().browserApiUrl(this.driveEndpoint("GET", "File/View/{id}/{size}"), { params: { id: file.id, size: copy === "thumbnail" ? "Thumbnail" : "Original" } });
|
|
@@ -180,6 +206,8 @@ export function readStoredFile(value) {
|
|
|
180
206
|
hasThumbnail: value.hasThumbnail === true,
|
|
181
207
|
isPrimary: value.isPrimary === true,
|
|
182
208
|
createdAt: text(value.creationTime),
|
|
209
|
+
// Only on a file whose Delete waits for a review (Drive's `removalTime` with it): every other file's record is as before.
|
|
210
|
+
...(value.pendingRemoval === true ? { pendingRemoval: true, ...(text(value.removalTime) ? { removalAt: text(value.removalTime) } : {}) } : {}),
|
|
183
211
|
};
|
|
184
212
|
}
|
|
185
213
|
/** Reads Drive's `FileListResponseContract`. */
|
|
@@ -201,6 +229,7 @@ export function readOwnerFiles(value) {
|
|
|
201
229
|
pictureMaxSide: pictures.maxSide,
|
|
202
230
|
pictureQuality: pictures.quality,
|
|
203
231
|
pictureMaxBytes: pictures.maxBytes,
|
|
232
|
+
...(answer.holdsRemovals === true ? { holdsRemovals: true } : {}),
|
|
204
233
|
};
|
|
205
234
|
const files = (Array.isArray(answer.files) ? answer.files : []).map(readStoredFile).filter((file) => file !== null);
|
|
206
235
|
return { category, files };
|
|
@@ -47,6 +47,24 @@ export interface MoneyFormatOptions {
|
|
|
47
47
|
* @example formatMoney(null, { currency: "Rs.", empty: "-" }) // "-"
|
|
48
48
|
*/
|
|
49
49
|
export declare function formatMoney(value: number | null | undefined, options?: MoneyFormatOptions): string;
|
|
50
|
+
/** How `formatDays` writes a number of days. */
|
|
51
|
+
export interface DaysFormatOptions {
|
|
52
|
+
/** Most digits after the decimal point: half days are 0.5. Default 2. */
|
|
53
|
+
decimals?: number;
|
|
54
|
+
/** Locale of the separators, as `formatNumber` takes it. Default `en-US`. */
|
|
55
|
+
locale?: string;
|
|
56
|
+
/** What an empty count (`null`, `undefined`, `NaN`) writes. Default "". */
|
|
57
|
+
empty?: string;
|
|
58
|
+
}
|
|
59
|
+
/** "day" for exactly one, "days" for any other count: 0, 0.5, 1.5, 2. */
|
|
60
|
+
export declare function dayWord(value: number): string;
|
|
61
|
+
/**
|
|
62
|
+
* A number of days as a person reads it: a leave's length, a balance. Grouped, with no more decimals than it has.
|
|
63
|
+
*
|
|
64
|
+
* @example formatDays(1) // "1 day"
|
|
65
|
+
* @example formatDays(2.5) // "2.5 days"
|
|
66
|
+
*/
|
|
67
|
+
export declare function formatDays(value: number | null | undefined, options?: DaysFormatOptions): string;
|
|
50
68
|
/**
|
|
51
69
|
* Reads a number written in the locale's format: "1,250.50" gives 1250.5, and in `de-DE` "1.250,50" does.
|
|
52
70
|
* Returns `null` for empty text (also a lone minus sign or decimal separator) and `NaN` for text that is not a number.
|
|
@@ -127,6 +145,49 @@ export interface PriceAmounts {
|
|
|
127
145
|
* @example priceAmounts({ price: 500, quantity: 3, discountPercentage: 10, vatPercentage: 13 }).netAmount // 1525.5
|
|
128
146
|
*/
|
|
129
147
|
export declare function priceAmounts(input: PriceAmountsInput, places?: number): PriceAmounts;
|
|
148
|
+
/** What `priceFromNet` is given beside the net amount it is asked for. */
|
|
149
|
+
export interface PriceFromNetOptions {
|
|
150
|
+
/** How many. Missing, null, 0 or NaN: 1, since no price of none comes to anything. */
|
|
151
|
+
quantity?: number | null;
|
|
152
|
+
/** The discount off the amount, in percent. Missing: 0. Below 100. */
|
|
153
|
+
discountPercentage?: number | null;
|
|
154
|
+
/** The VAT on what is left after the discount, in percent. Missing: 0. Above -100. */
|
|
155
|
+
vatPercentage?: number | null;
|
|
156
|
+
/** The places the price is kept at (the price input's `decimals`). Default 2. */
|
|
157
|
+
decimals?: number;
|
|
158
|
+
/** The places money is kept at, as `priceAmounts`'s `places`. Default 2. */
|
|
159
|
+
places?: number;
|
|
160
|
+
}
|
|
161
|
+
/** A price found for a net amount (`priceFromNet`). */
|
|
162
|
+
export interface PriceFromNetFound {
|
|
163
|
+
ok: true;
|
|
164
|
+
/** The price of one, at `decimals` places. */
|
|
165
|
+
price: number;
|
|
166
|
+
/** What that price comes to by the server's rule: the net amount asked for when `exact`, else the nearest one. */
|
|
167
|
+
netAmount: number;
|
|
168
|
+
/** Whether `netAmount` is exactly what was asked for. */
|
|
169
|
+
exact: boolean;
|
|
170
|
+
}
|
|
171
|
+
/** A net amount no price can be found for (`priceFromNet`), and why, in words for the person. */
|
|
172
|
+
export interface PriceFromNetRefused {
|
|
173
|
+
ok: false;
|
|
174
|
+
message: string;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The price that comes to a net amount by the server's own rule (`priceAmounts`): the inverse a person wants when they
|
|
178
|
+
* know the final figure (2,500.00) and the discount and VAT, but not the price. Plain division (2500 / 1.13 / 0.9) is only
|
|
179
|
+
* an estimate, because every step is rounded: it often lands 0.01 off. So the answer is searched among the prices at the
|
|
180
|
+
* price's own places (`decimals`), each worked forward on exact decimals exactly as the server does.
|
|
181
|
+
*
|
|
182
|
+
* The net amount never falls as the price rises (for a discount below 100 % and VAT above -100 %), so the search is
|
|
183
|
+
* exact: **when some price reaches the figure exactly, the smallest such price is answered** (`exact: true`); when none
|
|
184
|
+
* does, the price whose net amount is nearest (a tie goes to the one above, so the total is never short of the figure
|
|
185
|
+
* asked), with that net amount (`exact: false`). A negative figure, a negative quantity, a discount of 100 % or more
|
|
186
|
+
* (every price comes to 0) and VAT of -100 % or less are refused with a message.
|
|
187
|
+
*
|
|
188
|
+
* @example priceFromNet(2500, { discountPercentage: 10, vatPercentage: 13 }) // { ok: true, price: 2458.21, netAmount: 2500, exact: true }
|
|
189
|
+
*/
|
|
190
|
+
export declare function priceFromNet(target: number, options?: PriceFromNetOptions): PriceFromNetFound | PriceFromNetRefused;
|
|
130
191
|
/** The fields a row of money keeps its figures in (`priceLadder`). Defaults: the server's `CounterAmounts`. */
|
|
131
192
|
export interface PriceLadderKeys {
|
|
132
193
|
/** The price of one. Default `unitPrice`. */
|
|
@@ -99,6 +99,23 @@ export function formatMoney(value, options = {}) {
|
|
|
99
99
|
const before = (currency ?? "").trim();
|
|
100
100
|
return before ? `${before} ${figures}` : figures;
|
|
101
101
|
}
|
|
102
|
+
/** "day" for exactly one, "days" for any other count: 0, 0.5, 1.5, 2. */
|
|
103
|
+
export function dayWord(value) {
|
|
104
|
+
return value === 1 || value === -1 ? "day" : "days";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A number of days as a person reads it: a leave's length, a balance. Grouped, with no more decimals than it has.
|
|
108
|
+
*
|
|
109
|
+
* @example formatDays(1) // "1 day"
|
|
110
|
+
* @example formatDays(2.5) // "2.5 days"
|
|
111
|
+
*/
|
|
112
|
+
export function formatDays(value, options = {}) {
|
|
113
|
+
const { decimals = 2, locale, empty = "" } = options;
|
|
114
|
+
if (value === null || value === undefined || !Number.isFinite(value))
|
|
115
|
+
return empty;
|
|
116
|
+
const shown = roundDecimal(value, decimals);
|
|
117
|
+
return `${formatNumber(shown, { decimals, grouping: true, locale })} ${dayWord(shown)}`;
|
|
118
|
+
}
|
|
102
119
|
/**
|
|
103
120
|
* Reads a number written in the locale's format: "1,250.50" gives 1250.5, and in `de-DE` "1.250,50" does.
|
|
104
121
|
* Returns `null` for empty text (also a lone minus sign or decimal separator) and `NaN` for text that is not a number.
|
|
@@ -301,18 +318,117 @@ function exactNumber(value) {
|
|
|
301
318
|
* @example priceAmounts({ price: 500, quantity: 3, discountPercentage: 10, vatPercentage: 13 }).netAmount // 1525.5
|
|
302
319
|
*/
|
|
303
320
|
export function priceAmounts(input, places = 2) {
|
|
304
|
-
const digits =
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
321
|
+
const digits = moneyPlaces(places);
|
|
322
|
+
const figures = priceExact(exactDecimal(input.price, 0), exactDecimal(input.quantity, 1), exactDecimal(input.discountPercentage, 0), exactDecimal(input.vatPercentage, 0), digits);
|
|
323
|
+
return {
|
|
324
|
+
amount: exactNumber(figures.amount),
|
|
325
|
+
discountAmount: exactNumber(figures.discount),
|
|
326
|
+
vatAmount: exactNumber(figures.vat),
|
|
327
|
+
netAmount: exactNumber(figures.net),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** The places money is kept at: a whole number from 0 to `MAX_NUMBER_DIGITS`. */
|
|
331
|
+
function moneyPlaces(places) {
|
|
332
|
+
return Math.max(0, Math.min(MAX_NUMBER_DIGITS, Math.trunc(places) || 0));
|
|
333
|
+
}
|
|
334
|
+
/** A percent of an exact decimal: the product, two places further down. */
|
|
335
|
+
function percentOf(base, rate) {
|
|
336
|
+
return { units: base.units * rate.units, scale: base.scale + rate.scale + 2 };
|
|
337
|
+
}
|
|
338
|
+
/** The server's rule on exact decimals (`priceAmounts`): each figure rounded to `digits`, half away from zero. */
|
|
339
|
+
function priceExact(price, quantity, discountRate, vatRate, digits) {
|
|
311
340
|
const amount = roundExact({ units: price.units * quantity.units, scale: price.scale + quantity.scale }, digits);
|
|
312
341
|
const discount = roundExact(percentOf(amount, discountRate), digits);
|
|
313
342
|
const vat = roundExact(percentOf({ units: amount.units - discount.units, scale: digits }, vatRate), digits);
|
|
314
343
|
const net = { units: amount.units - discount.units + vat.units, scale: digits };
|
|
315
|
-
return { amount
|
|
344
|
+
return { amount, discount, vat, net };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The price that comes to a net amount by the server's own rule (`priceAmounts`): the inverse a person wants when they
|
|
348
|
+
* know the final figure (2,500.00) and the discount and VAT, but not the price. Plain division (2500 / 1.13 / 0.9) is only
|
|
349
|
+
* an estimate, because every step is rounded: it often lands 0.01 off. So the answer is searched among the prices at the
|
|
350
|
+
* price's own places (`decimals`), each worked forward on exact decimals exactly as the server does.
|
|
351
|
+
*
|
|
352
|
+
* The net amount never falls as the price rises (for a discount below 100 % and VAT above -100 %), so the search is
|
|
353
|
+
* exact: **when some price reaches the figure exactly, the smallest such price is answered** (`exact: true`); when none
|
|
354
|
+
* does, the price whose net amount is nearest (a tie goes to the one above, so the total is never short of the figure
|
|
355
|
+
* asked), with that net amount (`exact: false`). A negative figure, a negative quantity, a discount of 100 % or more
|
|
356
|
+
* (every price comes to 0) and VAT of -100 % or less are refused with a message.
|
|
357
|
+
*
|
|
358
|
+
* @example priceFromNet(2500, { discountPercentage: 10, vatPercentage: 13 }) // { ok: true, price: 2458.21, netAmount: 2500, exact: true }
|
|
359
|
+
*/
|
|
360
|
+
export function priceFromNet(target, options = {}) {
|
|
361
|
+
const digits = moneyPlaces(options.places ?? 2);
|
|
362
|
+
const priceDigits = moneyPlaces(options.decimals ?? 2);
|
|
363
|
+
if (typeof target !== "number" || !Number.isFinite(target))
|
|
364
|
+
return { ok: false, message: "Type a figure." };
|
|
365
|
+
const wanted = roundExact(exactDecimal(target, 0), digits).units;
|
|
366
|
+
if (wanted < BIG_ZERO)
|
|
367
|
+
return { ok: false, message: "The amount cannot be below 0." };
|
|
368
|
+
const rawQuantity = options.quantity;
|
|
369
|
+
if (typeof rawQuantity === "number" && rawQuantity < 0)
|
|
370
|
+
return { ok: false, message: "A quantity below 0 has no price." };
|
|
371
|
+
const quantity = exactDecimal(rawQuantity === 0 ? 1 : rawQuantity, 1);
|
|
372
|
+
const discount = options.discountPercentage;
|
|
373
|
+
if (typeof discount === "number" && discount >= 100)
|
|
374
|
+
return wanted === BIG_ZERO ? found(0, 0, true) : { ok: false, message: "With a 100% discount every price comes to 0." };
|
|
375
|
+
const vat = options.vatPercentage;
|
|
376
|
+
if (typeof vat === "number" && vat <= -100)
|
|
377
|
+
return wanted === BIG_ZERO ? found(0, 0, true) : { ok: false, message: "With VAT of -100% every price comes to 0." };
|
|
378
|
+
const discountRate = exactDecimal(discount, 0);
|
|
379
|
+
const vatRate = exactDecimal(vat, 0);
|
|
380
|
+
const netAt = (units) => priceExact({ units, scale: priceDigits }, quantity, discountRate, vatRate, digits).net.units;
|
|
381
|
+
// The estimate, by plain division, as the start of the search.
|
|
382
|
+
const q = Number(rawQuantity) || 1;
|
|
383
|
+
const estimate = target / q / (1 - (Number(discount) || 0) / 100) / (1 + (Number(vat) || 0) / 100);
|
|
384
|
+
const start = Number.isFinite(estimate) && estimate > 0 ? BigInt(Math.round(estimate * 10 ** priceDigits)) : BIG_ZERO;
|
|
385
|
+
// Bracket the smallest price whose net amount reaches the figure: `low` falls short of it (-1: no price below), `high`
|
|
386
|
+
// reaches it. Galloping from the estimate, which is a step or two away, then halving.
|
|
387
|
+
let low = -BIG_ONE;
|
|
388
|
+
let high;
|
|
389
|
+
if (netAt(start) >= wanted) {
|
|
390
|
+
high = start;
|
|
391
|
+
let step = BIG_ONE;
|
|
392
|
+
while (high > BIG_ZERO) {
|
|
393
|
+
const next = high - step < BIG_ZERO ? BIG_ZERO : high - step;
|
|
394
|
+
if (netAt(next) >= wanted)
|
|
395
|
+
high = next;
|
|
396
|
+
else {
|
|
397
|
+
low = next;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
step *= BIG_TWO;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
low = start;
|
|
405
|
+
let step = BIG_ONE;
|
|
406
|
+
for (;;) {
|
|
407
|
+
const next = low + step;
|
|
408
|
+
if (netAt(next) >= wanted) {
|
|
409
|
+
high = next;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
low = next;
|
|
413
|
+
step *= BIG_TWO;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
while (high - low > BIG_ONE) {
|
|
417
|
+
const middle = (low + high) / BIG_TWO;
|
|
418
|
+
if (netAt(middle) >= wanted)
|
|
419
|
+
high = middle;
|
|
420
|
+
else
|
|
421
|
+
low = middle;
|
|
422
|
+
}
|
|
423
|
+
const reached = netAt(high);
|
|
424
|
+
if (reached === wanted || low < BIG_ZERO)
|
|
425
|
+
return found(high, reached, reached === wanted);
|
|
426
|
+
// None reaches it exactly: the nearer of the price just below and the one just above.
|
|
427
|
+
const below = netAt(low);
|
|
428
|
+
return wanted - below < reached - wanted ? found(low, below, false) : found(high, reached, false);
|
|
429
|
+
function found(price, net, exact) {
|
|
430
|
+
return { ok: true, price: exactNumber({ units: BigInt(price), scale: priceDigits }), netAmount: exactNumber({ units: BigInt(net), scale: digits }), exact };
|
|
431
|
+
}
|
|
316
432
|
}
|
|
317
433
|
/** Most places a percentage keeps (12.3456 %), as a price list keeps it; trailing zeros are not written. */
|
|
318
434
|
const PERCENT_PLACES = 4;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DropdownItem, DropdownPage, DropdownPageRequest, OptionValue } from "../Interfaces/InputInterfaces.ts";
|
|
2
|
+
/** What `pagedPicker` needs to know of the items. */
|
|
3
|
+
export interface PagedPickerOptions<TItem extends DropdownItem> {
|
|
4
|
+
/** The field that holds the value. Default `id`. */
|
|
5
|
+
valueKey?: string;
|
|
6
|
+
/** The field shown as the option. Default `name`. */
|
|
7
|
+
labelKey?: string;
|
|
8
|
+
/** A field shown under it. */
|
|
9
|
+
descriptionKey?: string;
|
|
10
|
+
/** Items offered but not picked again: someone who has left. They show tagged when a form holds them. */
|
|
11
|
+
isInactive?: (item: TItem) => boolean;
|
|
12
|
+
/** A lookup by key, for a server that has one: asked before the pages are searched. */
|
|
13
|
+
find?: (keys: readonly string[], signal?: AbortSignal) => Promise<readonly TItem[]>;
|
|
14
|
+
/** How many of the server's first pages are searched for a value nothing named. Default 4. */
|
|
15
|
+
findPages?: number;
|
|
16
|
+
/** Items per page while searching them. Default 50. */
|
|
17
|
+
findPageSize?: number;
|
|
18
|
+
}
|
|
19
|
+
/** The dropdown params `pagedPicker` gives: spread them into a dropdown's input. */
|
|
20
|
+
export interface PagedPickerParams<TItem extends DropdownItem> {
|
|
21
|
+
loadOptions: (request: DropdownPageRequest) => Promise<DropdownPage<TItem>>;
|
|
22
|
+
resolveOptions: (values: OptionValue[]) => Promise<readonly TItem[]>;
|
|
23
|
+
peekOption: (key: string) => TItem | undefined;
|
|
24
|
+
valueKey: string;
|
|
25
|
+
labelKey: string;
|
|
26
|
+
descriptionKey?: string;
|
|
27
|
+
isOptionInactive: (item: TItem) => boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A dropdown over a server's search, paged (`load` takes the dropdown's own request), that names every value it holds:
|
|
31
|
+
* from the pages it has shown, the items it was handed, the server's lookup, or the server's first pages.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const people = pagedPicker(request => mail.people(request), { isInactive: person => person.isActive === false });
|
|
35
|
+
* const inputs = [{ type: EInputTypes.Dropdown, name: "to", label: "To", multiple: true, required: true, ...people }];
|
|
36
|
+
*/
|
|
37
|
+
export declare function pagedPicker<TItem extends DropdownItem>(load: (request: DropdownPageRequest) => Promise<DropdownPage<TItem>>, options?: PagedPickerOptions<TItem>): PagedPickerParams<TItem>;
|
|
38
|
+
/**
|
|
39
|
+
* Hands a picker items the page already holds, so a form that opens with their values names them at once, with no call:
|
|
40
|
+
* the people a new message goes to by default.
|
|
41
|
+
*/
|
|
42
|
+
export declare function holdPickerItems<TItem extends DropdownItem>(picker: PagedPickerParams<TItem>, items: readonly TItem[]): void;
|