nexus-shared 3.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2268 -446
- package/LICENSE +21 -21
- package/README.md +1705 -1084
- package/dist/Client.Index.d.ts +17 -0
- package/dist/Client.Index.js +17 -0
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Forms/ApiForm.js +19 -2
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +871 -377
- package/dist/Components/Forms/CrudActions.d.ts +136 -0
- package/dist/Components/Forms/CrudActions.js +184 -0
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +30 -2
- package/dist/Components/Forms/CrudParts.js +56 -5
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +114 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
- package/dist/Components/Forms/CrudWorkspace.js +169 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +4 -4
- package/dist/Components/Forms/Form.js +125 -16
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +195 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- package/dist/Components/Inputs/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -5
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +10 -11
- package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
- package/dist/Components/Inputs/InputRenderer.js +39 -31
- package/dist/Components/Inputs/NumberBox.js +4 -2
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +70 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +1 -1
- package/dist/Components/Inputs/RowsInput.js +18 -3
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +29 -29
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +14 -2
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +107 -6
- package/dist/Components/Inputs/TreeView.js +513 -30
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +13 -6
- package/dist/Components/Layouts/Popup.js +37 -9
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
- package/dist/Components/Layouts/ThemePicker.js +16 -4
- package/dist/Components/Viewers/DataTable.js +205 -61
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
- package/dist/Components/Viewers/DataTableParts.js +62 -21
- package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
- package/dist/Components/Viewers/OwnerFiles.js +589 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- package/dist/Helpers/ApiModules.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +114 -6
- package/dist/Helpers/CrudBackend.js +214 -11
- package/dist/Helpers/CrudColumns.d.ts +98 -0
- package/dist/Helpers/CrudColumns.js +160 -0
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +130 -6
- package/dist/Helpers/CrudHelpers.js +304 -12
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudRowForms.d.ts +15 -0
- package/dist/Helpers/CrudRowForms.js +43 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +78 -0
- package/dist/Helpers/FileAdapter.js +102 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +90 -5
- package/dist/Helpers/FormStore.js +318 -22
- package/dist/Helpers/InputConditions.d.ts +50 -0
- package/dist/Helpers/InputConditions.js +168 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +385 -0
- package/dist/Helpers/InputNarrowing.d.ts +18 -0
- package/dist/Helpers/InputNarrowing.js +67 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +162 -71
- package/dist/Helpers/MessageBuilder.js +2 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
- package/dist/Helpers/NexusDriveAdapter.js +207 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/NumberHelpers.d.ts +95 -0
- package/dist/Helpers/NumberHelpers.js +126 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +47 -0
- package/dist/Helpers/RequestCache.js +132 -0
- package/dist/Helpers/RowsStore.d.ts +37 -2
- package/dist/Helpers/RowsStore.js +128 -10
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +21 -15
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +24 -3
- package/dist/Helpers/TableHelpers.js +58 -12
- package/dist/Helpers/TableJoins.d.ts +83 -0
- package/dist/Helpers/TableJoins.js +284 -0
- package/dist/Helpers/TableStore.d.ts +26 -1
- package/dist/Helpers/TableStore.js +73 -7
- package/dist/Helpers/TreeStore.d.ts +306 -11
- package/dist/Helpers/TreeStore.js +1048 -90
- package/dist/Helpers/UserPreferences.d.ts +8 -0
- package/dist/Helpers/UserPreferences.js +17 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
- package/dist/Interfaces/FileInterfaces.d.ts +162 -0
- package/dist/Interfaces/FileInterfaces.js +4 -0
- package/dist/Interfaces/FormInterfaces.d.ts +393 -29
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +39 -3
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
- package/dist/Interfaces/PreferenceInterfaces.js +2 -5
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +160 -5
- package/dist/Services/ApiProxy.d.ts +36 -1
- package/dist/Services/ApiProxy.js +182 -52
- package/dist/Services/ApiProxyRules.d.ts +203 -0
- package/dist/Services/ApiProxyRules.js +426 -0
- package/dist/Services/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +80 -0
- package/dist/Services/JoinSources.js +150 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Shared.Index.d.ts +20 -0
- package/dist/Shared.Index.js +20 -0
- package/package.json +92 -85
- package/src/Styles/Nexus.Avatar.css +80 -79
- package/src/Styles/Nexus.Base.css +117 -117
- package/src/Styles/Nexus.Button.css +186 -180
- package/src/Styles/Nexus.Calendar.css +273 -273
- package/src/Styles/Nexus.Chat.css +1443 -1443
- package/src/Styles/Nexus.Choice.css +359 -343
- package/src/Styles/Nexus.Crud.css +542 -168
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +733 -725
- package/src/Styles/Nexus.Editor.css +295 -295
- package/src/Styles/Nexus.Excel.css +389 -389
- package/src/Styles/Nexus.Files.css +410 -0
- package/src/Styles/Nexus.Form.css +282 -279
- package/src/Styles/Nexus.Index.css +41 -37
- package/src/Styles/Nexus.Input.css +238 -4
- package/src/Styles/Nexus.Markdown.css +237 -237
- package/src/Styles/Nexus.Menu.css +246 -237
- package/src/Styles/Nexus.Popup.css +800 -724
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Rows.css +637 -637
- package/src/Styles/Nexus.Slider.css +276 -276
- package/src/Styles/Nexus.Tab.Buttons.css +497 -432
- package/src/Styles/Nexus.Table.css +98 -35
- package/src/Styles/Nexus.Theme.Picker.css +59 -3
- package/src/Styles/Nexus.Themes.css +200 -194
- package/src/Styles/Nexus.Time.css +144 -144
- package/src/Styles/Nexus.Toast.css +263 -263
- package/src/Styles/Nexus.Tokens.css +94 -89
- package/src/Styles/Nexus.Tree.css +530 -431
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CRUD_ROW_ACTIONS } from "./CrudHelpers.js";
|
|
2
|
+
import { isApiEndpoint } from "./ApiRoutes.js";
|
|
3
|
+
/*
|
|
4
|
+
* The pure half of a row action that opens a form of its own for the record (`Crud` `rowForms`,
|
|
5
|
+
* Components/Forms/CrudRowForms.tsx): where it is offered, and what of a save's answer is written back into the record.
|
|
6
|
+
* Pure, so Node tests run it.
|
|
7
|
+
*/
|
|
8
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
/**
|
|
10
|
+
* Whether a row form is offered on a record: on its lists (default the lists that offer Edit), to a person who may call
|
|
11
|
+
* its `submit` and, when it is an endpoint, its `load`, and by the page's own `when`.
|
|
12
|
+
*/
|
|
13
|
+
export function crudRowFormOffered(form, row, mode, can) {
|
|
14
|
+
const modes = form.modes ?? Object.keys(CRUD_ROW_ACTIONS).filter(list => CRUD_ROW_ACTIONS[list].includes("edit"));
|
|
15
|
+
if (!modes.includes(mode))
|
|
16
|
+
return false;
|
|
17
|
+
if (!can(form.submit))
|
|
18
|
+
return false;
|
|
19
|
+
if (isApiEndpoint(form.load) && !can(form.load))
|
|
20
|
+
return false;
|
|
21
|
+
return form.when ? form.when(row, mode) : true;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* What a save changes on the record, as `writes` says: the answer's fields it names (each one the answer carries; an
|
|
25
|
+
* answer that is not a record changes nothing), or what its function answers. Never the record as the form was opened
|
|
26
|
+
* on: the CRUD merges this over the record as it holds it when the answer lands, so a change made meanwhile (an Edit
|
|
27
|
+
* while the popup was minimized) stays.
|
|
28
|
+
*/
|
|
29
|
+
export function crudRowFormWrites(writes, result, row, values) {
|
|
30
|
+
if (!writes)
|
|
31
|
+
return null;
|
|
32
|
+
if (typeof writes === "function") {
|
|
33
|
+
const patch = writes(result, row, values);
|
|
34
|
+
return patch && Object.keys(patch).length ? patch : null;
|
|
35
|
+
}
|
|
36
|
+
if (!isRecord(result))
|
|
37
|
+
return null;
|
|
38
|
+
const patch = {};
|
|
39
|
+
for (const field of writes)
|
|
40
|
+
if (result[field] !== undefined)
|
|
41
|
+
patch[field] = result[field];
|
|
42
|
+
return Object.keys(patch).length ? patch : null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CrudProps, CrudScope, CrudScopes } from "../Interfaces/CrudInterfaces.ts";
|
|
2
|
+
import type { FormValues } from "../Interfaces/FormInterfaces.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The scopes offered now: those whose `when` (the page's own rule) allows them and whose lists the person may call
|
|
5
|
+
* (`callable`, the CRUD's permission test on the scope's endpoints). None while the permissions are not known.
|
|
6
|
+
*/
|
|
7
|
+
export declare function offeredCrudScopes<TRow extends FormValues>(scopes: CrudScopes<TRow> | undefined, callable: (scope: CrudScope<TRow>) => boolean): CrudScope<TRow>[];
|
|
8
|
+
/** The scope shown: the one chosen while it is offered, else the CRUD's own list (`null`). */
|
|
9
|
+
export declare function shownCrudScope<TRow extends FormValues>(offered: readonly CrudScope<TRow>[], chosen: string | null): CrudScope<TRow> | null;
|
|
10
|
+
/**
|
|
11
|
+
* The CRUD's props on a scope: its endpoints, and its columns, inputs, forms, table settings and name where it gives its
|
|
12
|
+
* own (`form` and `table` over the CRUD's, field by field). The CRUD's own list is the props as they are given.
|
|
13
|
+
*/
|
|
14
|
+
export declare function crudScopeProps<TRow extends FormValues>(given: CrudProps<TRow>, scope: CrudScope<TRow> | null): CrudProps<TRow>;
|
|
15
|
+
/**
|
|
16
|
+
* Where a scope keeps its table's settings: the scope's own `table.stateKey`, else the CRUD's key and the scope's id, so
|
|
17
|
+
* each list keeps the columns shown, their order and widths, its sort and its page size apart (a Branch column hidden
|
|
18
|
+
* on one branch's list and shown on every branch's). The CRUD's own list keeps the CRUD's key.
|
|
19
|
+
*/
|
|
20
|
+
export declare function crudScopeStateKey<TRow extends FormValues>(ownKey: string, scope: CrudScope<TRow> | null): string;
|
|
21
|
+
/** Where this browser keeps the choice: `nx-crud-scope:{key}:{viewer}`; nothing without a viewer. */
|
|
22
|
+
export declare function crudScopeStorageKey(remember: {
|
|
23
|
+
key: string;
|
|
24
|
+
viewer?: string | null;
|
|
25
|
+
} | undefined): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The scope kept: its id, `null` for the CRUD's own list (kept as nothing), `undefined` when nothing could be read (no
|
|
28
|
+
* storage, no key). A kept scope the CRUD no longer offers stays kept and opens the CRUD's own list until it is offered.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readCrudScope(storage: Pick<Storage, "getItem"> | null, key: string | null): string | null | undefined;
|
|
31
|
+
/** Keeps the choice; the CRUD's own list is kept as nothing. A browser that keeps nothing opens the CRUD's own list. */
|
|
32
|
+
export declare function writeCrudScope(storage: Pick<Storage, "setItem" | "removeItem"> | null, key: string | null, scope: string | null): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The pure half of a CRUD whose list can be widened (`Crud` `scopes`, Components/Forms/CrudScopes.tsx): which lists are
|
|
3
|
+
* offered, which one shows, the props it gives the CRUD, and where this browser keeps the choice. Pure, so Node tests
|
|
4
|
+
* run it.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The scopes offered now: those whose `when` (the page's own rule) allows them and whose lists the person may call
|
|
8
|
+
* (`callable`, the CRUD's permission test on the scope's endpoints). None while the permissions are not known.
|
|
9
|
+
*/
|
|
10
|
+
export function offeredCrudScopes(scopes, callable) {
|
|
11
|
+
if (!scopes)
|
|
12
|
+
return [];
|
|
13
|
+
return scopes.options.filter(scope => (scope.when ? scope.when() : true) && callable(scope));
|
|
14
|
+
}
|
|
15
|
+
/** The scope shown: the one chosen while it is offered, else the CRUD's own list (`null`). */
|
|
16
|
+
export function shownCrudScope(offered, chosen) {
|
|
17
|
+
return chosen === null ? null : (offered.find(scope => scope.id === chosen) ?? null);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The CRUD's props on a scope: its endpoints, and its columns, inputs, forms, table settings and name where it gives its
|
|
21
|
+
* own (`form` and `table` over the CRUD's, field by field). The CRUD's own list is the props as they are given.
|
|
22
|
+
*/
|
|
23
|
+
export function crudScopeProps(given, scope) {
|
|
24
|
+
if (!scope)
|
|
25
|
+
return given;
|
|
26
|
+
return {
|
|
27
|
+
...given,
|
|
28
|
+
api: scope.api,
|
|
29
|
+
columns: scope.columns ?? given.columns,
|
|
30
|
+
inputs: scope.inputs ?? given.inputs,
|
|
31
|
+
form: scope.form ? { ...given.form, ...scope.form } : given.form,
|
|
32
|
+
table: scope.table ? { ...given.table, ...scope.table } : given.table,
|
|
33
|
+
"aria-label": scope["aria-label"] ?? given["aria-label"],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Where a scope keeps its table's settings: the scope's own `table.stateKey`, else the CRUD's key and the scope's id, so
|
|
38
|
+
* each list keeps the columns shown, their order and widths, its sort and its page size apart (a Branch column hidden
|
|
39
|
+
* on one branch's list and shown on every branch's). The CRUD's own list keeps the CRUD's key.
|
|
40
|
+
*/
|
|
41
|
+
export function crudScopeStateKey(ownKey, scope) {
|
|
42
|
+
if (!scope)
|
|
43
|
+
return ownKey;
|
|
44
|
+
return scope.table?.stateKey ?? `${ownKey}:${scope.id}`;
|
|
45
|
+
}
|
|
46
|
+
/** Where this browser keeps the choice: `nx-crud-scope:{key}:{viewer}`; nothing without a viewer. */
|
|
47
|
+
export function crudScopeStorageKey(remember) {
|
|
48
|
+
const viewer = remember?.viewer?.trim();
|
|
49
|
+
return remember?.key && viewer ? `nx-crud-scope:${remember.key}:${viewer}` : null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The scope kept: its id, `null` for the CRUD's own list (kept as nothing), `undefined` when nothing could be read (no
|
|
53
|
+
* storage, no key). A kept scope the CRUD no longer offers stays kept and opens the CRUD's own list until it is offered.
|
|
54
|
+
*/
|
|
55
|
+
export function readCrudScope(storage, key) {
|
|
56
|
+
if (!storage || !key)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const kept = storage.getItem(key);
|
|
60
|
+
return kept ? kept : null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Keeps the choice; the CRUD's own list is kept as nothing. A browser that keeps nothing opens the CRUD's own list. */
|
|
67
|
+
export function writeCrudScope(storage, key, scope) {
|
|
68
|
+
if (!storage || !key)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
if (scope)
|
|
72
|
+
storage.setItem(key, scope);
|
|
73
|
+
else
|
|
74
|
+
storage.removeItem(key);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Not kept: the CRUD opens its own list next time.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -45,8 +45,10 @@ export declare function filterItems<TItem extends DropdownItem>(items: readonly
|
|
|
45
45
|
export declare function defaultSearchKeys(labelKey: string, descriptionKey: string, valueKey: string, columns?: readonly DropdownColumn<never>[]): string[];
|
|
46
46
|
/** Whether a column draws a picture: an avatar, an image, or an icon. The first one also leads the value in the box. */
|
|
47
47
|
export declare function isMediaColumn(column: Pick<DropdownColumn<never>, "type">): boolean;
|
|
48
|
-
/** A column's grid track: its `width`, or `auto` for pictures and
|
|
48
|
+
/** A column's grid track: its `width`, or `auto` for pictures, codes and money and `minmax(0, 1fr)` for text. */
|
|
49
49
|
export declare function columnTrack(column: Pick<DropdownColumn<never>, "type" | "width">): string;
|
|
50
|
+
/** Where a column's cells and heading sit: its own `align`, else at the end for money (figures line up), else at the start. */
|
|
51
|
+
export declare function dropdownColumnAlign(column: Pick<DropdownColumn<never>, "type" | "align">): "start" | "center" | "end";
|
|
50
52
|
/**
|
|
51
53
|
* Up to two initials of a name, from its first and last words: "Ram Bahadur Sharma" → "RS", "admin" → "A",
|
|
52
54
|
* "sita.rai" → "SR". Letters keep their script, so "राम शर्मा" → "रश".
|
|
@@ -150,11 +150,15 @@ export function defaultSearchKeys(labelKey, descriptionKey, valueKey, columns) {
|
|
|
150
150
|
export function isMediaColumn(column) {
|
|
151
151
|
return column.type === "avatar" || column.type === "image" || column.type === "icon";
|
|
152
152
|
}
|
|
153
|
-
/** A column's grid track: its `width`, or `auto` for pictures and
|
|
153
|
+
/** A column's grid track: its `width`, or `auto` for pictures, codes and money and `minmax(0, 1fr)` for text. */
|
|
154
154
|
export function columnTrack(column) {
|
|
155
155
|
if (column.width)
|
|
156
156
|
return column.width;
|
|
157
|
-
return isMediaColumn(column) || column.type === "code" ? "auto" : "minmax(0, 1fr)";
|
|
157
|
+
return isMediaColumn(column) || column.type === "code" || column.type === "money" ? "auto" : "minmax(0, 1fr)";
|
|
158
|
+
}
|
|
159
|
+
/** Where a column's cells and heading sit: its own `align`, else at the end for money (figures line up), else at the start. */
|
|
160
|
+
export function dropdownColumnAlign(column) {
|
|
161
|
+
return column.align ?? (column.type === "money" ? "end" : "start");
|
|
158
162
|
}
|
|
159
163
|
/**
|
|
160
164
|
* Up to two initials of a name, from its first and last words: "Ram Bahadur Sharma" → "RS", "admin" → "A",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type InputParams } from "../Interfaces/FormInterfaces.ts";
|
|
2
2
|
import type { ExcelCellProblem, ExcelColumnInfo, ExcelDownload, ExcelReviewRow, ExcelSheetRow } from "../Interfaces/ExcelInterfaces.ts";
|
|
3
3
|
/** What a spreadsheet file is called in a file picker. */
|
|
4
4
|
export declare const EXCEL_FILE_ACCEPT = ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EInputTypes } from "../Interfaces/FormInterfaces.js";
|
|
1
2
|
import { ExcelDataType, ExcelRowKind } from "../Interfaces/ExcelInterfaces.js";
|
|
2
3
|
import { toIsoDate } from "./DateHelpers.js";
|
|
3
4
|
import { toIsoTime } from "./TimeHelpers.js";
|
|
@@ -111,28 +112,28 @@ export function excelColumnInput(column) {
|
|
|
111
112
|
...(column.maximum === null || column.maximum === undefined ? {} : { max: column.maximum }),
|
|
112
113
|
};
|
|
113
114
|
if (column.isReference)
|
|
114
|
-
return { ...shared, type:
|
|
115
|
+
return { ...shared, type: EInputTypes.Text, readOnly: true, readOnlyMessage: excelText("referenceLocked") };
|
|
115
116
|
if (column.isReadOnly)
|
|
116
|
-
return { ...shared, type:
|
|
117
|
+
return { ...shared, type: EInputTypes.Text, readOnly: true, readOnlyMessage: excelText("generated") };
|
|
117
118
|
switch (column.dataType) {
|
|
118
119
|
case ExcelDataType.Note:
|
|
119
|
-
return { ...shared, type:
|
|
120
|
+
return { ...shared, type: EInputTypes.TextArea, rows: 2, required, ...lengths };
|
|
120
121
|
case ExcelDataType.WholeNumber:
|
|
121
|
-
return { ...shared, type:
|
|
122
|
+
return { ...shared, type: EInputTypes.Number, required, decimals: 0, ...limits };
|
|
122
123
|
case ExcelDataType.Decimal:
|
|
123
|
-
return { ...shared, type:
|
|
124
|
+
return { ...shared, type: EInputTypes.Number, required, ...limits };
|
|
124
125
|
case ExcelDataType.Amount:
|
|
125
|
-
return { ...shared, type:
|
|
126
|
+
return { ...shared, type: EInputTypes.Number, required, decimals: 2, fixedDecimals: true, grouping: true, ...limits };
|
|
126
127
|
case ExcelDataType.Date:
|
|
127
|
-
return { ...shared, type:
|
|
128
|
+
return { ...shared, type: EInputTypes.Date, required };
|
|
128
129
|
case ExcelDataType.DateTime:
|
|
129
|
-
return { ...shared, type:
|
|
130
|
+
return { ...shared, type: EInputTypes.Text, required, pattern: MOMENT_PATTERN, placeholder: "2026-09-29 14:30" };
|
|
130
131
|
case ExcelDataType.Time:
|
|
131
|
-
return { ...shared, type:
|
|
132
|
+
return { ...shared, type: EInputTypes.Time, required };
|
|
132
133
|
case ExcelDataType.YesNo:
|
|
133
|
-
return { ...shared, type:
|
|
134
|
+
return { ...shared, type: EInputTypes.Dropdown, required, options: YES_NO, searchable: false };
|
|
134
135
|
default:
|
|
135
|
-
return { ...shared, type:
|
|
136
|
+
return { ...shared, type: EInputTypes.Text, required, ...lengths };
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
/** Every column of a sheet as the inputs of one editor row, in the order they read. */
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ApiModuleValue, ApiResult } from "../Interfaces/ApiInterfaces.ts";
|
|
2
|
+
import type { FileCallOptions, FileCategoryInfo, FileCopy, FileOwner, FileUpload, OwnerFiles, PictureType, StoredFile } from "../Interfaces/FileInterfaces.ts";
|
|
3
|
+
export declare abstract class FileAdapter {
|
|
4
|
+
/** The owner's files and what their category takes, `canWrite` included. */
|
|
5
|
+
abstract list(owner: FileOwner<ApiModuleValue>, options?: FileCallOptions): Promise<ApiResult<OwnerFiles>>;
|
|
6
|
+
/**
|
|
7
|
+
* Each owner's picture, in the order asked (null where an owner has none): a page's rows in one call. At most
|
|
8
|
+
* `maxOwners` at a time; `primaryPictures` splits a longer list.
|
|
9
|
+
*/
|
|
10
|
+
abstract primaries(module: ApiModuleValue, category: string, ownerIds: readonly string[], options?: FileCallOptions): Promise<ApiResult<(StoredFile | null)[]>>;
|
|
11
|
+
/** Stores one file for the owner and answers it as stored. */
|
|
12
|
+
abstract upload(owner: FileOwner<ApiModuleValue>, upload: FileUpload, options?: FileCallOptions): Promise<ApiResult<StoredFile>>;
|
|
13
|
+
/** Makes one of the owner's files its picture, or none (`null`). */
|
|
14
|
+
abstract setPrimary(owner: FileOwner<ApiModuleValue>, fileId: string | null, options?: FileCallOptions): Promise<ApiResult<unknown>>;
|
|
15
|
+
/** A new name for one of the owner's files; the backend keeps the extension of what the file really is. */
|
|
16
|
+
abstract rename(owner: FileOwner<ApiModuleValue>, fileId: string, name: string, options?: FileCallOptions): Promise<ApiResult<unknown>>;
|
|
17
|
+
/** Deletes one of the owner's files. */
|
|
18
|
+
abstract remove(owner: FileOwner<ApiModuleValue>, fileId: string, options?: FileCallOptions): Promise<ApiResult<unknown>>;
|
|
19
|
+
/** The address an `<img>`, a link or the viewer opens the file by. */
|
|
20
|
+
abstract fileUrl(file: StoredFile, copy: FileCopy): string;
|
|
21
|
+
/**
|
|
22
|
+
* The address of the image editor module (`nexus-image-editor`, an ES module) as this backend serves it, versioned,
|
|
23
|
+
* so `loadImageEditor` can import it. Rejects with an `Error` in plain words when it is not there.
|
|
24
|
+
*/
|
|
25
|
+
abstract editorModuleUrl(signal?: AbortSignal): Promise<string>;
|
|
26
|
+
/** The most owners one `primaries` call takes. */
|
|
27
|
+
get maxOwners(): number;
|
|
28
|
+
}
|
|
29
|
+
/** One owner's key, for caches and listeners: the same module, category and id are the same owner. */
|
|
30
|
+
export declare const fileOwnerKey: (owner: FileOwner<ApiModuleValue>) => string;
|
|
31
|
+
/** Whether a category takes anything that is not a picture: its files are a document list, not a gallery. */
|
|
32
|
+
export declare const takesDocuments: (category: Pick<FileCategoryInfo, "types">) => boolean;
|
|
33
|
+
/** What a file input offers for a category: its MIME types. */
|
|
34
|
+
export declare const acceptOf: (category: Pick<FileCategoryInfo, "types">) => string;
|
|
35
|
+
/** A file's kind in a word, for a list: "JPEG", "PDF". */
|
|
36
|
+
export declare function fileKindName(contentType: string): string;
|
|
37
|
+
/** "JPEG, PNG or WebP" for a category's types, as a sentence names them. */
|
|
38
|
+
export declare function fileKindsSentence(types: readonly string[]): string;
|
|
39
|
+
/** A size in words: "5 MB". */
|
|
40
|
+
export declare const fileSizeText: (bytes: number) => string;
|
|
41
|
+
/**
|
|
42
|
+
* What a stored picture may be when the backend does not say (Drive's own defaults): its longest side in pixels, the
|
|
43
|
+
* highest quality in percent, and the most bytes. A company that hosts the site on its own machine raises Drive's
|
|
44
|
+
* settings, and the browser follows what Drive's List answers.
|
|
45
|
+
*/
|
|
46
|
+
export declare const PICTURE_LIMITS: {
|
|
47
|
+
readonly maxSide: 1920;
|
|
48
|
+
readonly quality: 80;
|
|
49
|
+
readonly maxBytes: number;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* A category's limits for a stored picture: what the backend said, else `PICTURE_LIMITS`; the bytes never above the
|
|
53
|
+
* category's own `maxBytes`, the quality at most 100.
|
|
54
|
+
*/
|
|
55
|
+
export declare function pictureLimits(category: Pick<FileCategoryInfo, "maxBytes"> & Partial<Pick<FileCategoryInfo, "pictureMaxSide" | "pictureQuality" | "pictureMaxBytes">>): {
|
|
56
|
+
maxSide: number;
|
|
57
|
+
quality: number;
|
|
58
|
+
maxBytes: number;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Why a chosen file would be refused, before it is sent: a kind the category does not take, or larger than it takes -
|
|
62
|
+
* for a picture, the category's picture limit (`pictureLimits`). The backend checks the bytes again; this only spares
|
|
63
|
+
* the person an upload that cannot succeed. Null when it fits.
|
|
64
|
+
*/
|
|
65
|
+
export declare function fileRefusal(category: Pick<FileCategoryInfo, "types" | "maxBytes"> & Partial<Pick<FileCategoryInfo, "pictureMaxSide" | "pictureQuality" | "pictureMaxBytes">>, file: Pick<Blob, "type" | "size">, name: string): string | null;
|
|
66
|
+
/**
|
|
67
|
+
* A picture's kind read from its first bytes - JPEG, PNG or WebP - for a file the browser could not type (none, or
|
|
68
|
+
* `application/octet-stream`: a phone's share, a file dragged from another app, a name with no extension), so it still
|
|
69
|
+
* goes through the editor. Null for anything
|
|
70
|
+
* else, which is left to the backend (it reads the bytes too).
|
|
71
|
+
*/
|
|
72
|
+
export declare function sniffPictureType(file: Blob): Promise<PictureType | null>;
|
|
73
|
+
/** A chosen file with its kind: the browser's, else a picture's read from its bytes (`sniffPictureType`), else as it was. */
|
|
74
|
+
export declare function typedFile(file: File): Promise<File>;
|
|
75
|
+
/** How many more files the owner keeps: never below 0. */
|
|
76
|
+
export declare const roomLeft: (files: OwnerFiles) => number;
|
|
77
|
+
/** The name a picture made by the editor is kept by: the chosen file's name with the saved kind's extension. */
|
|
78
|
+
export declare function pictureName(name: string | null | undefined, type: string, fallback?: string): string;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { isPictureType } from "../Interfaces/FileInterfaces.js";
|
|
2
|
+
import { chatFileSize } from "./ChatHelpers.js";
|
|
3
|
+
/*
|
|
4
|
+
* Where an owner's files are kept. `FileAdapter` is the class an app implements for its own file backend;
|
|
5
|
+
* `NexusDriveAdapter` (nexus-shared/client) is the Nexus Drive and runs when nothing is registered - the same
|
|
6
|
+
* arrangement as `ApiModules` / `NexusApiModules` and `ExcelBackend` / `NexusExcelBackend`. The picture input, the
|
|
7
|
+
* gallery, the document list and the picture column call nothing else, so a page that declares pictures never names
|
|
8
|
+
* a backend.
|
|
9
|
+
*
|
|
10
|
+
* Every call answers an `ApiResult` and never throws for a refused or failed request, like `api`. None shows a toast:
|
|
11
|
+
* the panel that asked says what happened, once.
|
|
12
|
+
*/
|
|
13
|
+
export class FileAdapter {
|
|
14
|
+
/** The most owners one `primaries` call takes. */
|
|
15
|
+
get maxOwners() {
|
|
16
|
+
return 200;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** One owner's key, for caches and listeners: the same module, category and id are the same owner. */
|
|
20
|
+
export const fileOwnerKey = (owner) => `${String(owner.module)}|${owner.category}|${owner.ownerId}`;
|
|
21
|
+
/** Whether a category takes anything that is not a picture: its files are a document list, not a gallery. */
|
|
22
|
+
export const takesDocuments = (category) => category.types.some(type => !isPictureType(type));
|
|
23
|
+
/** What a file input offers for a category: its MIME types. */
|
|
24
|
+
export const acceptOf = (category) => category.types.join(",");
|
|
25
|
+
/** A file's kind in a word, for a list: "JPEG", "PDF". */
|
|
26
|
+
export function fileKindName(contentType) {
|
|
27
|
+
const kind = contentType.split("/")[1] ?? contentType;
|
|
28
|
+
return kind === "jpeg" ? "JPEG" : kind.toUpperCase();
|
|
29
|
+
}
|
|
30
|
+
/** "JPEG, PNG or WebP" for a category's types, as a sentence names them. */
|
|
31
|
+
export function fileKindsSentence(types) {
|
|
32
|
+
const names = types.map(type => (type === "image/webp" ? "WebP" : fileKindName(type)));
|
|
33
|
+
if (names.length <= 1)
|
|
34
|
+
return names[0] ?? "";
|
|
35
|
+
return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
|
|
36
|
+
}
|
|
37
|
+
/** A size in words: "5 MB". */
|
|
38
|
+
export const fileSizeText = (bytes) => chatFileSize(bytes);
|
|
39
|
+
/**
|
|
40
|
+
* What a stored picture may be when the backend does not say (Drive's own defaults): its longest side in pixels, the
|
|
41
|
+
* highest quality in percent, and the most bytes. A company that hosts the site on its own machine raises Drive's
|
|
42
|
+
* settings, and the browser follows what Drive's List answers.
|
|
43
|
+
*/
|
|
44
|
+
export const PICTURE_LIMITS = { maxSide: 1920, quality: 80, maxBytes: 300 * 1024 };
|
|
45
|
+
/**
|
|
46
|
+
* A category's limits for a stored picture: what the backend said, else `PICTURE_LIMITS`; the bytes never above the
|
|
47
|
+
* category's own `maxBytes`, the quality at most 100.
|
|
48
|
+
*/
|
|
49
|
+
export function pictureLimits(category) {
|
|
50
|
+
const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : null);
|
|
51
|
+
const bytes = positive(category.pictureMaxBytes) ?? PICTURE_LIMITS.maxBytes;
|
|
52
|
+
return {
|
|
53
|
+
maxSide: positive(category.pictureMaxSide) ?? PICTURE_LIMITS.maxSide,
|
|
54
|
+
quality: Math.min(100, positive(category.pictureQuality) ?? PICTURE_LIMITS.quality),
|
|
55
|
+
maxBytes: category.maxBytes > 0 ? Math.min(bytes, category.maxBytes) : bytes,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Why a chosen file would be refused, before it is sent: a kind the category does not take, or larger than it takes -
|
|
60
|
+
* for a picture, the category's picture limit (`pictureLimits`). The backend checks the bytes again; this only spares
|
|
61
|
+
* the person an upload that cannot succeed. Null when it fits.
|
|
62
|
+
*/
|
|
63
|
+
export function fileRefusal(category, file, name) {
|
|
64
|
+
if (file.type && !category.types.includes(file.type))
|
|
65
|
+
return `"${name}" is not a ${fileKindsSentence(category.types)} file.`;
|
|
66
|
+
const limit = isPictureType(file.type) ? pictureLimits(category).maxBytes : category.maxBytes;
|
|
67
|
+
if (limit > 0 && file.size > limit)
|
|
68
|
+
return `"${name}" is larger than the ${fileSizeText(limit)} this takes.`;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A picture's kind read from its first bytes - JPEG, PNG or WebP - for a file the browser could not type (none, or
|
|
73
|
+
* `application/octet-stream`: a phone's share, a file dragged from another app, a name with no extension), so it still
|
|
74
|
+
* goes through the editor. Null for anything
|
|
75
|
+
* else, which is left to the backend (it reads the bytes too).
|
|
76
|
+
*/
|
|
77
|
+
export async function sniffPictureType(file) {
|
|
78
|
+
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
|
|
79
|
+
const text = (from, to) => String.fromCharCode(...head.subarray(from, to));
|
|
80
|
+
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff)
|
|
81
|
+
return "image/jpeg";
|
|
82
|
+
if (head.length >= 8 && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((byte, at) => head[at] === byte))
|
|
83
|
+
return "image/png";
|
|
84
|
+
if (head.length >= 12 && text(0, 4) === "RIFF" && text(8, 12) === "WEBP")
|
|
85
|
+
return "image/webp";
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/** A chosen file with its kind: the browser's, else a picture's read from its bytes (`sniffPictureType`), else as it was. */
|
|
89
|
+
export async function typedFile(file) {
|
|
90
|
+
if (file.type && file.type !== "application/octet-stream")
|
|
91
|
+
return file;
|
|
92
|
+
const type = await sniffPictureType(file);
|
|
93
|
+
return type ? new File([file], file.name, { type, lastModified: file.lastModified }) : file;
|
|
94
|
+
}
|
|
95
|
+
/** How many more files the owner keeps: never below 0. */
|
|
96
|
+
export const roomLeft = (files) => Math.max(0, files.category.maxCount - files.files.length);
|
|
97
|
+
/** The name a picture made by the editor is kept by: the chosen file's name with the saved kind's extension. */
|
|
98
|
+
export function pictureName(name, type, fallback = "Picture") {
|
|
99
|
+
const base = (name ?? "").replace(/\.[A-Za-z][A-Za-z0-9]{0,3}$/, "").trim() || fallback;
|
|
100
|
+
const extension = type === "image/png" ? "png" : type === "image/webp" ? "webp" : "jpg";
|
|
101
|
+
return `${base}.${extension}`;
|
|
102
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ApiModuleValue, ApiResult } from "../Interfaces/ApiInterfaces.ts";
|
|
2
|
+
import type { FileOwner, StoredFile } from "../Interfaces/FileInterfaces.ts";
|
|
3
|
+
/** Reads several owners' pictures, in the order asked: `FileAdapter.primaries`. */
|
|
4
|
+
export type PrimariesReader = (module: ApiModuleValue, category: string, ownerIds: readonly string[]) => Promise<ApiResult<(StoredFile | null)[]>>;
|
|
5
|
+
export interface PrimaryPicturesOptions {
|
|
6
|
+
/** The most owners one call takes, or a function asked when a batch is sent. Default 200, the Nexus Drive's limit. */
|
|
7
|
+
batchSize?: number | (() => number);
|
|
8
|
+
/** How long asks are gathered before they are sent, in milliseconds. Default 0: the asks of one render. */
|
|
9
|
+
delay?: number;
|
|
10
|
+
/** How long a refused or failed read is kept as "no picture" before an ask reads again, in milliseconds. Default 30000. */
|
|
11
|
+
retryAfter?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class PrimaryPictures {
|
|
14
|
+
private readonly read;
|
|
15
|
+
private readonly batchSize;
|
|
16
|
+
private readonly delay;
|
|
17
|
+
private readonly retryAfter;
|
|
18
|
+
/** Answers kept: a file, or null for "no picture". A key that is missing has not been read. */
|
|
19
|
+
private readonly known;
|
|
20
|
+
/** Reads under way, so two thumbnails of one owner ask once. */
|
|
21
|
+
private readonly pending;
|
|
22
|
+
/** Asks gathered per module and category, not sent yet. */
|
|
23
|
+
private readonly queue;
|
|
24
|
+
private readonly listeners;
|
|
25
|
+
/** Owners whose last read failed, with the time an ask may read again. Their `known` answer is null until then. */
|
|
26
|
+
private readonly failedUntil;
|
|
27
|
+
/** Bumped by `clear`, so an answer that comes back after it is not kept. */
|
|
28
|
+
private generation;
|
|
29
|
+
private timer;
|
|
30
|
+
constructor(read: PrimariesReader, options?: PrimaryPicturesOptions);
|
|
31
|
+
/** The owner's picture as kept: a file, `null` for none, `undefined` when it has not been read. */
|
|
32
|
+
peek(owner: FileOwner<ApiModuleValue>): StoredFile | null | undefined;
|
|
33
|
+
/** The owner's picture, read with the other asks of this moment unless it is kept already. */
|
|
34
|
+
get(owner: FileOwner<ApiModuleValue>): Promise<StoredFile | null>;
|
|
35
|
+
/** Writes in what a change on this page made the owner's picture, and tells its thumbnails. */
|
|
36
|
+
set(owner: FileOwner<ApiModuleValue>, file: StoredFile | null): void;
|
|
37
|
+
/** Forgets the owner's picture, so the next ask reads it again. */
|
|
38
|
+
forget(owner: FileOwner<ApiModuleValue>): void;
|
|
39
|
+
/** Forgets every kept picture: after a sign-out, or a reload of the whole page's data. */
|
|
40
|
+
clear(): void;
|
|
41
|
+
/** Calls `listener` whenever the owner's kept picture changes. Answers the function that stops it. */
|
|
42
|
+
subscribe(owner: FileOwner<ApiModuleValue>, listener: () => void): () => void;
|
|
43
|
+
/** Whether the owner's last read failed and its wait is over: the next ask reads again. */
|
|
44
|
+
private mayRetry;
|
|
45
|
+
private notify;
|
|
46
|
+
private flush;
|
|
47
|
+
private send;
|
|
48
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { fileOwnerKey } from "./FileAdapter.js";
|
|
2
|
+
export class PrimaryPictures {
|
|
3
|
+
read;
|
|
4
|
+
batchSize;
|
|
5
|
+
delay;
|
|
6
|
+
retryAfter;
|
|
7
|
+
/** Answers kept: a file, or null for "no picture". A key that is missing has not been read. */
|
|
8
|
+
known = new Map();
|
|
9
|
+
/** Reads under way, so two thumbnails of one owner ask once. */
|
|
10
|
+
pending = new Map();
|
|
11
|
+
/** Asks gathered per module and category, not sent yet. */
|
|
12
|
+
queue = new Map();
|
|
13
|
+
listeners = new Map();
|
|
14
|
+
/** Owners whose last read failed, with the time an ask may read again. Their `known` answer is null until then. */
|
|
15
|
+
failedUntil = new Map();
|
|
16
|
+
/** Bumped by `clear`, so an answer that comes back after it is not kept. */
|
|
17
|
+
generation = 0;
|
|
18
|
+
timer = null;
|
|
19
|
+
constructor(read, options = {}) {
|
|
20
|
+
this.read = read;
|
|
21
|
+
const size = options.batchSize ?? 200;
|
|
22
|
+
this.batchSize = () => Math.max(1, typeof size === "function" ? size() : size);
|
|
23
|
+
this.delay = Math.max(0, options.delay ?? 0);
|
|
24
|
+
this.retryAfter = Math.max(0, options.retryAfter ?? 30_000);
|
|
25
|
+
}
|
|
26
|
+
/** The owner's picture as kept: a file, `null` for none, `undefined` when it has not been read. */
|
|
27
|
+
peek(owner) {
|
|
28
|
+
const key = fileOwnerKey(owner);
|
|
29
|
+
if (this.mayRetry(key))
|
|
30
|
+
return undefined;
|
|
31
|
+
return this.known.get(key);
|
|
32
|
+
}
|
|
33
|
+
/** The owner's picture, read with the other asks of this moment unless it is kept already. */
|
|
34
|
+
get(owner) {
|
|
35
|
+
const key = fileOwnerKey(owner);
|
|
36
|
+
if (this.mayRetry(key)) {
|
|
37
|
+
this.failedUntil.delete(key);
|
|
38
|
+
this.known.delete(key);
|
|
39
|
+
}
|
|
40
|
+
if (this.known.has(key))
|
|
41
|
+
return Promise.resolve(this.known.get(key) ?? null);
|
|
42
|
+
const pending = this.pending.get(key);
|
|
43
|
+
if (pending)
|
|
44
|
+
return pending;
|
|
45
|
+
const waiting = { owner, resolve: () => undefined, generation: this.generation };
|
|
46
|
+
const asked = new Promise(resolve => {
|
|
47
|
+
waiting.resolve = resolve;
|
|
48
|
+
});
|
|
49
|
+
waiting.asked = asked;
|
|
50
|
+
const group = `${String(owner.module)}|${owner.category}`;
|
|
51
|
+
const list = this.queue.get(group) ?? [];
|
|
52
|
+
list.push(waiting);
|
|
53
|
+
this.queue.set(group, list);
|
|
54
|
+
this.timer ??= setTimeout(() => this.flush(), this.delay);
|
|
55
|
+
this.pending.set(key, asked);
|
|
56
|
+
return asked;
|
|
57
|
+
}
|
|
58
|
+
/** Writes in what a change on this page made the owner's picture, and tells its thumbnails. */
|
|
59
|
+
set(owner, file) {
|
|
60
|
+
const key = fileOwnerKey(owner);
|
|
61
|
+
this.failedUntil.delete(key);
|
|
62
|
+
this.known.set(key, file);
|
|
63
|
+
this.notify(key);
|
|
64
|
+
}
|
|
65
|
+
/** Forgets the owner's picture, so the next ask reads it again. */
|
|
66
|
+
forget(owner) {
|
|
67
|
+
const key = fileOwnerKey(owner);
|
|
68
|
+
this.failedUntil.delete(key);
|
|
69
|
+
this.known.delete(key);
|
|
70
|
+
this.notify(key);
|
|
71
|
+
}
|
|
72
|
+
/** Forgets every kept picture: after a sign-out, or a reload of the whole page's data. */
|
|
73
|
+
clear() {
|
|
74
|
+
this.generation++;
|
|
75
|
+
this.known.clear();
|
|
76
|
+
this.failedUntil.clear();
|
|
77
|
+
// Reads still under way were asked for the person before; a `get` after this asks again.
|
|
78
|
+
this.pending.clear();
|
|
79
|
+
for (const key of this.listeners.keys())
|
|
80
|
+
this.notify(key);
|
|
81
|
+
}
|
|
82
|
+
/** Calls `listener` whenever the owner's kept picture changes. Answers the function that stops it. */
|
|
83
|
+
subscribe(owner, listener) {
|
|
84
|
+
const key = fileOwnerKey(owner);
|
|
85
|
+
const set = this.listeners.get(key) ?? new Set();
|
|
86
|
+
set.add(listener);
|
|
87
|
+
this.listeners.set(key, set);
|
|
88
|
+
return () => {
|
|
89
|
+
set.delete(listener);
|
|
90
|
+
if (set.size === 0)
|
|
91
|
+
this.listeners.delete(key);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Whether the owner's last read failed and its wait is over: the next ask reads again. */
|
|
95
|
+
mayRetry(key) {
|
|
96
|
+
const until = this.failedUntil.get(key);
|
|
97
|
+
return until !== undefined && Date.now() >= until;
|
|
98
|
+
}
|
|
99
|
+
notify(key) {
|
|
100
|
+
for (const listener of [...(this.listeners.get(key) ?? [])])
|
|
101
|
+
listener();
|
|
102
|
+
}
|
|
103
|
+
flush() {
|
|
104
|
+
this.timer = null;
|
|
105
|
+
const groups = [...this.queue.values()];
|
|
106
|
+
this.queue.clear();
|
|
107
|
+
for (const waiting of groups) {
|
|
108
|
+
const size = this.batchSize();
|
|
109
|
+
for (let start = 0; start < waiting.length; start += size)
|
|
110
|
+
void this.send(waiting.slice(start, start + size));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async send(batch) {
|
|
114
|
+
const first = batch[0].owner;
|
|
115
|
+
const answer = await this.read(first.module, first.category, batch.map(item => item.owner.ownerId)).catch(() => ({ ok: false }));
|
|
116
|
+
batch.forEach((item, index) => {
|
|
117
|
+
const key = fileOwnerKey(item.owner);
|
|
118
|
+
if (this.pending.get(key) === item.asked)
|
|
119
|
+
this.pending.delete(key);
|
|
120
|
+
const current = item.generation === this.generation;
|
|
121
|
+
if (!answer.ok) {
|
|
122
|
+
// Kept as no picture for a while, so the rows that draw again do not each ask again; then an ask reads again.
|
|
123
|
+
if (current && !this.known.has(key)) {
|
|
124
|
+
this.known.set(key, null);
|
|
125
|
+
this.failedUntil.set(key, Date.now() + this.retryAfter);
|
|
126
|
+
this.notify(key);
|
|
127
|
+
}
|
|
128
|
+
return item.resolve(null);
|
|
129
|
+
}
|
|
130
|
+
const file = answer.result?.[index] ?? null;
|
|
131
|
+
if (current && !this.known.has(key)) {
|
|
132
|
+
this.known.set(key, file);
|
|
133
|
+
this.notify(key);
|
|
134
|
+
}
|
|
135
|
+
item.resolve(current ? (this.known.get(key) ?? file) : file);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { PopupSize } from "../Components/Layouts/Popup.tsx";
|
|
3
|
+
import type { FormConfirmDetails, FormConfirmParams, FormValues } from "../Interfaces/FormInterfaces.ts";
|
|
4
|
+
import type { TableColumn, TableRow } from "../Interfaces/TableInterfaces.ts";
|
|
5
|
+
/** A run of neighbouring columns with one `group` (`group` null for columns with none): one section of a record's details. */
|
|
6
|
+
export interface CrudDetailsRun<TRow extends TableRow = TableRow> {
|
|
7
|
+
group: string | null;
|
|
8
|
+
columns: TableColumn<TRow>[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A record's fields in sections: each run of neighbouring columns that name the same `group` is one section under that
|
|
12
|
+
* heading, and a run of columns with no group is a section with none. A group named again further on starts a new run,
|
|
13
|
+
* as a table's band does. An empty group name counts as none.
|
|
14
|
+
*/
|
|
15
|
+
export declare function crudDetailsRuns<TRow extends TableRow>(columns: readonly TableColumn<TRow>[]): CrudDetailsRun<TRow>[];
|
|
16
|
+
/** Whether the form asks before this save: a `confirm` is given, and its `when` holds for the values (or it has none). */
|
|
17
|
+
export declare function formConfirmAsks<TValues extends FormValues>(confirm: FormConfirmParams<TValues> | null | undefined, values: TValues): boolean;
|
|
18
|
+
/** The question a form asks before a save, with every text settled for the values and the body to be sent. */
|
|
19
|
+
export interface FormConfirmQuestion {
|
|
20
|
+
title: ReactNode;
|
|
21
|
+
message: ReactNode;
|
|
22
|
+
details: FormConfirmDetails | null;
|
|
23
|
+
confirmLabel: ReactNode;
|
|
24
|
+
cancelLabel: ReactNode;
|
|
25
|
+
size: PopupSize;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The question for these values and this body: each text worked out from them, the details drawn when given (a null
|
|
29
|
+
* answer draws none), "Confirm and save" and "Back to the form" unless given, and the width `lg` with details (two
|
|
30
|
+
* fields to a line) or `sm` without, unless given. A rule that throws throws here, before anything is shown.
|
|
31
|
+
*/
|
|
32
|
+
export declare function formConfirmQuestion<TValues extends FormValues>(confirm: FormConfirmParams<TValues>, values: TValues, body: unknown): FormConfirmQuestion;
|