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
|
@@ -1,19 +1,20 @@
|
|
|
1
|
+
import { readPath } from "./TableHelpers.js";
|
|
1
2
|
/*
|
|
2
3
|
* The rules of the CRUD component, with no React: which lists and actions a controller offers, which actions each list
|
|
3
|
-
* shows, which records leave a list after an action, the settings every CRUD follows (how many records may be pinned
|
|
4
|
-
* and the cache of lists kept while the page is open - including how a change
|
|
5
|
-
* a record saved or marked costs no request on any list.
|
|
4
|
+
* shows, which records leave a list after an action, the settings every CRUD follows (how many records may be pinned
|
|
5
|
+
* or made favourites), a record's marks, and the cache of lists kept while the page is open - including how a change
|
|
6
|
+
* is written into the lists kept, so that a record saved or marked costs no request on any list.
|
|
6
7
|
*/
|
|
7
8
|
/** Every list, in the order the CRUD shows them. */
|
|
8
9
|
export const CRUD_MODES = ["normal", "flag", "trash", "deleted"];
|
|
9
|
-
const ACTIVE_ROW_ACTIONS = ["view", "edit", "flag", "pin", "trash"];
|
|
10
|
-
const ACTIVE_MASS_ACTIONS = ["flag", "pin", "description", "trash"];
|
|
11
|
-
/** The built-in actions on one record, per list, in their menu order. */
|
|
10
|
+
const ACTIVE_ROW_ACTIONS = ["view", "edit", "flag", "pin", "favorite", "history", "trash"];
|
|
11
|
+
const ACTIVE_MASS_ACTIONS = ["flag", "pin", "favorite", "description", "trash"];
|
|
12
|
+
/** The built-in actions on one record, per list, in their menu order. A record's history is on every list. */
|
|
12
13
|
export const CRUD_ROW_ACTIONS = {
|
|
13
14
|
normal: ACTIVE_ROW_ACTIONS,
|
|
14
15
|
flag: ACTIVE_ROW_ACTIONS,
|
|
15
|
-
trash: ["view", "recover", "delete"],
|
|
16
|
-
deleted: ["view", "trashFromDelete"],
|
|
16
|
+
trash: ["view", "history", "recover", "delete"],
|
|
17
|
+
deleted: ["view", "history", "trashFromDelete"],
|
|
17
18
|
};
|
|
18
19
|
/** The built-in actions on selected records, per list. */
|
|
19
20
|
export const CRUD_MASS_ACTIONS = {
|
|
@@ -56,14 +57,31 @@ export function crudMassActions(mode, allowed, trashless = false) {
|
|
|
56
57
|
export function crudListEndpoint(api, mode, paging) {
|
|
57
58
|
return paging === "server" ? api.pages?.[mode] : api.lists?.[mode];
|
|
58
59
|
}
|
|
59
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The endpoint of a row action. `view` needs none of its own: it shows the row (and calls `details` when there is one).
|
|
62
|
+
* Pin and Favourite both set the record's marks through `bookmark`; a backend without it may still pin, through `pin`.
|
|
63
|
+
*/
|
|
60
64
|
export function crudRowEndpoint(api, action) {
|
|
61
65
|
if (action === "view")
|
|
62
66
|
return api.details;
|
|
63
67
|
if (action === "edit")
|
|
64
68
|
return api.update;
|
|
69
|
+
if (action === "favorite")
|
|
70
|
+
return api.bookmark;
|
|
71
|
+
if (action === "pin")
|
|
72
|
+
return api.bookmark ?? api.pin;
|
|
73
|
+
if (action === "history")
|
|
74
|
+
return api.audit;
|
|
65
75
|
return api[action];
|
|
66
76
|
}
|
|
77
|
+
/** The endpoint of an action on many records: Pin and Favourite through `mass.bookmark`, or Pin through `mass.pin`. */
|
|
78
|
+
export function crudMassEndpoint(api, action) {
|
|
79
|
+
if (action === "favorite")
|
|
80
|
+
return api.mass?.bookmark;
|
|
81
|
+
if (action === "pin")
|
|
82
|
+
return api.mass?.bookmark ?? api.mass?.pin;
|
|
83
|
+
return api.mass?.[action];
|
|
84
|
+
}
|
|
67
85
|
/** The lists a CRUD can offer: asked for, with an endpoint, and allowed. */
|
|
68
86
|
export function availableModes(api, paging, wanted, can) {
|
|
69
87
|
return CRUD_MODES.filter(mode => wanted.includes(mode)).filter(mode => {
|
|
@@ -91,13 +109,13 @@ export function actionAllowed(action, api, wanted, can) {
|
|
|
91
109
|
export function massActionAllowed(action, api, wanted, can) {
|
|
92
110
|
if (!wanted.includes(action))
|
|
93
111
|
return false;
|
|
94
|
-
const endpoint = api
|
|
112
|
+
const endpoint = crudMassEndpoint(api, action);
|
|
95
113
|
return Boolean(endpoint) && can(endpoint);
|
|
96
114
|
}
|
|
97
115
|
/**
|
|
98
116
|
* Whether a record leaves the list shown after an action. Trash, recover, delete, and moving back to the trash take it
|
|
99
|
-
* to another list, and unflagging leaves the flagged list.
|
|
100
|
-
*
|
|
117
|
+
* to another list, and unflagging leaves the flagged list. A pin or a favourite never does: a marked record stays in
|
|
118
|
+
* the list it is in. `markOn` is the record's new flag.
|
|
101
119
|
*/
|
|
102
120
|
export function leavesList(action, mode, markOn) {
|
|
103
121
|
switch (action) {
|
|
@@ -112,6 +130,200 @@ export function leavesList(action, mode, markOn) {
|
|
|
112
130
|
return false;
|
|
113
131
|
}
|
|
114
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* A CRUD change as changes to a tree that shows the same records (`CrudTreePlacement`): a record in the active list is
|
|
135
|
+
* put under its parent - added, changed or moved - and one out of it (in the trash, deleted) leaves the tree. Where it
|
|
136
|
+
* was comes from `before`: under its parent while it was active, out of the tree when it was not or is new, and not
|
|
137
|
+
* known otherwise (the tree then looks for it). A record without a status counts as active. Pins and favourites are a
|
|
138
|
+
* user's own and change no tree.
|
|
139
|
+
*/
|
|
140
|
+
export function crudTreeChanges(change, place, backend) {
|
|
141
|
+
if (change.action === "pin" || change.action === "favorite")
|
|
142
|
+
return [];
|
|
143
|
+
const valueKey = place.rules.valueKey ?? "id";
|
|
144
|
+
const inTree = (row) => backend.listMembership(row, "normal") !== false;
|
|
145
|
+
const list = [];
|
|
146
|
+
change.rows.forEach((row, index) => {
|
|
147
|
+
const before = change.before?.[index];
|
|
148
|
+
const was = change.action === "create" ? false : before === undefined ? undefined : inTree(before) ? place.parentOf(before) : false;
|
|
149
|
+
if (inTree(row))
|
|
150
|
+
list.push({ type: "put", item: place.itemOf(row), parent: place.parentOf(row), before: was });
|
|
151
|
+
else if (was !== false)
|
|
152
|
+
list.push({ type: "remove", value: place.itemOf(row)[valueKey], parent: was });
|
|
153
|
+
});
|
|
154
|
+
return list;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The records whose child count a change moved, among `rows` (a list's rows), through a tree placement: what the tree's
|
|
158
|
+
* parents follow (`crudTreeChanges`) written into the parents' own rows. A record added or back under a parent counts one
|
|
159
|
+
* more there; one moved counts one fewer where it was and one more where it went; one trashed or deleted one fewer. Each
|
|
160
|
+
* parent row comes back with what `recordOf` reads from its item with the count moved written over it (so `recordOf`
|
|
161
|
+
* must carry the item's count back into the record); a row whose count did not move, or a count that is not a number, is
|
|
162
|
+
* left out.
|
|
163
|
+
*/
|
|
164
|
+
export function crudParentCounts(treeChanges, rows, place) {
|
|
165
|
+
const countKey = place.rules.hasChildrenKey;
|
|
166
|
+
if (!countKey || treeChanges.length === 0)
|
|
167
|
+
return [];
|
|
168
|
+
const valueKey = place.rules.valueKey ?? "id";
|
|
169
|
+
const deltas = new Map();
|
|
170
|
+
const move = (parent, delta) => {
|
|
171
|
+
if (parent === null || parent === undefined)
|
|
172
|
+
return;
|
|
173
|
+
deltas.set(String(parent), (deltas.get(String(parent)) ?? 0) + delta);
|
|
174
|
+
};
|
|
175
|
+
for (const change of treeChanges) {
|
|
176
|
+
if (change.type === "remove")
|
|
177
|
+
move(change.parent, -1);
|
|
178
|
+
else if (change.before === false)
|
|
179
|
+
move(change.parent, 1);
|
|
180
|
+
else if (change.before !== undefined && String(change.before) !== String(change.parent)) {
|
|
181
|
+
move(change.before, -1);
|
|
182
|
+
move(change.parent, 1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (![...deltas.values()].some(Boolean))
|
|
186
|
+
return [];
|
|
187
|
+
const counted = [];
|
|
188
|
+
for (const row of rows) {
|
|
189
|
+
const item = place.itemOf(row);
|
|
190
|
+
const delta = deltas.get(String(item[valueKey]));
|
|
191
|
+
const count = item[countKey];
|
|
192
|
+
if (!delta || typeof count !== "number")
|
|
193
|
+
continue;
|
|
194
|
+
const record = place.recordOf({ ...item, [countKey]: Math.max(0, count + delta) });
|
|
195
|
+
// Over the row the list holds, so a placement whose item carries only some of its fields loses none of them.
|
|
196
|
+
if (record)
|
|
197
|
+
counted.push({ ...row, ...record });
|
|
198
|
+
}
|
|
199
|
+
return counted;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A saved record with the child count a tree placement keeps for it (`rules.hasChildrenKey`): a new record's own, as
|
|
203
|
+
* `itemOf` gives it (none yet), and an edited one's from the row the page holds (`held`) - its form's copy is as old as
|
|
204
|
+
* the form, and a child added since counts. Written over the record through `recordOf`, which carries an item's count
|
|
205
|
+
* back; a placement without a count leaves the record as it is.
|
|
206
|
+
*/
|
|
207
|
+
export function crudSavedCount(record, held, place) {
|
|
208
|
+
const countKey = place.rules.hasChildrenKey;
|
|
209
|
+
if (!countKey)
|
|
210
|
+
return record;
|
|
211
|
+
let item = place.itemOf(record);
|
|
212
|
+
if (held) {
|
|
213
|
+
const count = place.itemOf(held)[countKey];
|
|
214
|
+
if (count !== undefined)
|
|
215
|
+
item = { ...item, [countKey]: count };
|
|
216
|
+
}
|
|
217
|
+
const counted = place.recordOf(item);
|
|
218
|
+
return counted ? { ...record, ...counted } : record;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The names the database joins into a record (`crudColumns.dbJoin` columns with `from`), written again after a save that
|
|
222
|
+
* answered only what was sent - with no call. For each such column: a key the save kept as `before` had it keeps
|
|
223
|
+
* `before`'s name and status; an emptied key empties both; a new or changed key takes the name and the status of the
|
|
224
|
+
* record `find(from, key)` gives - the one picked in the form, or one the page holds. Where it gives none, a new record
|
|
225
|
+
* keeps the name its save answered, if any, and an edited one drops the old record's name and status rather than show
|
|
226
|
+
* them for the new key: either way the record is then read back once (`crudJoinsMissing`). Names and statuses are written
|
|
227
|
+
* as top-level fields of the record.
|
|
228
|
+
*/
|
|
229
|
+
export function crudJoinedNames(record, before, columns, find) {
|
|
230
|
+
const row = { ...record };
|
|
231
|
+
for (const column of columns) {
|
|
232
|
+
const join = column.dbJoin;
|
|
233
|
+
if (!join?.from)
|
|
234
|
+
continue;
|
|
235
|
+
const write = (name, status) => {
|
|
236
|
+
row[column.key] = name ?? null;
|
|
237
|
+
if (join.status)
|
|
238
|
+
row[join.status] = status ?? null;
|
|
239
|
+
};
|
|
240
|
+
const key = readPath(record, join.from);
|
|
241
|
+
if (key === undefined || key === null || key === "") {
|
|
242
|
+
write(null, null);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const held = before ? readPath(before, join.from) : undefined;
|
|
246
|
+
if (held !== undefined && held !== null && String(held) === String(key)) {
|
|
247
|
+
write(readPath(before, column.key), join.status ? readPath(before, join.status) : null);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const found = find(join.from, String(key));
|
|
251
|
+
if (found) {
|
|
252
|
+
write(found.name, found.status);
|
|
253
|
+
}
|
|
254
|
+
else if (before && readPath(record, column.key) === readPath(before, column.key)) {
|
|
255
|
+
// The name the record had is the old key's.
|
|
256
|
+
delete row[column.key];
|
|
257
|
+
if (join.status)
|
|
258
|
+
delete row[join.status];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return row;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Whether a saved record still lacks a name the database joins into it, which is the one case a CRUD reads it back
|
|
265
|
+
* (`details`, once): a `crudColumns.dbJoin` column whose name the record does not hold while its key is set - no record
|
|
266
|
+
* was known for a new key (`crudJoinedNames`) - or one that names no key field (`from`) on a new record, whose save
|
|
267
|
+
* answered no name. An edited record keeps the name it had on a column without `from`: whether its key changed cannot
|
|
268
|
+
* be told.
|
|
269
|
+
*/
|
|
270
|
+
export function crudJoinsMissing(record, before, columns) {
|
|
271
|
+
for (const column of columns) {
|
|
272
|
+
const join = column.dbJoin;
|
|
273
|
+
if (!join || readPath(record, column.key) !== undefined)
|
|
274
|
+
continue;
|
|
275
|
+
if (!join.from) {
|
|
276
|
+
if (!before)
|
|
277
|
+
return true;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const key = readPath(record, join.from);
|
|
281
|
+
if (key !== undefined && key !== null && key !== "")
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Where an Add or Edit form with fixed fields stands, as text for its popup's key and its draft's: the fixed fields'
|
|
288
|
+
* names and values, in name order (`"@countryId=7&parentId="`), or "" when nothing is fixed. So a form opened inside one
|
|
289
|
+
* parent never comes back inside another, a fixed form never stands in for a free one, and a draft written before the
|
|
290
|
+
* record moved is not brought back over its new place.
|
|
291
|
+
*/
|
|
292
|
+
export function crudFormPlace(fixed) {
|
|
293
|
+
if (!fixed)
|
|
294
|
+
return "";
|
|
295
|
+
const names = Object.keys(fixed).sort();
|
|
296
|
+
if (names.length === 0)
|
|
297
|
+
return "";
|
|
298
|
+
return `@${names.map(name => `${name}=${String(fixed[name] ?? "")}`).join("&")}`;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* A `Crud` `narrow` read once: the field, the picked record's value (its `valueKey` field, else its `key` field) and its
|
|
302
|
+
* name (its `labelKey` field, else the value). `null` without a narrowing; a value of `null` while nothing is picked, or
|
|
303
|
+
* the picked record holds no value.
|
|
304
|
+
*/
|
|
305
|
+
export function crudNarrowing(narrow) {
|
|
306
|
+
if (!narrow)
|
|
307
|
+
return null;
|
|
308
|
+
const picked = narrow.picked;
|
|
309
|
+
const raw = picked ? readPath(picked, narrow.valueKey ?? narrow.key) : undefined;
|
|
310
|
+
if (raw === undefined || raw === null || raw === "")
|
|
311
|
+
return { key: narrow.key, value: null, raw: null, label: "" };
|
|
312
|
+
const name = narrow.labelKey && picked ? readPath(picked, narrow.labelKey) : undefined;
|
|
313
|
+
return { key: narrow.key, value: String(raw), raw, label: name === undefined || name === null || name === "" ? String(raw) : String(name) };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Whether a record belongs in a narrowed list: its field holds the picked record's value (compared as text, so 7 and "7"
|
|
317
|
+
* are one). Every record does without a narrowing, and none while nothing is picked.
|
|
318
|
+
*/
|
|
319
|
+
export function crudInNarrowing(row, narrowing) {
|
|
320
|
+
if (!narrowing)
|
|
321
|
+
return true;
|
|
322
|
+
if (narrowing.value === null)
|
|
323
|
+
return false;
|
|
324
|
+
const held = readPath(row, narrowing.key);
|
|
325
|
+
return held !== undefined && held !== null && String(held) === narrowing.value;
|
|
326
|
+
}
|
|
115
327
|
/** The message builder's action for a built-in action: "Guest recovered.", "Unpin 3 guests?" */
|
|
116
328
|
export function crudMessageAction(action, markOn) {
|
|
117
329
|
switch (action) {
|
|
@@ -119,6 +331,8 @@ export function crudMessageAction(action, markOn) {
|
|
|
119
331
|
return markOn === false ? "unflag" : "flag";
|
|
120
332
|
case "pin":
|
|
121
333
|
return markOn === false ? "unpin" : "pin";
|
|
334
|
+
case "favorite":
|
|
335
|
+
return markOn === false ? "unfavorite" : "favorite";
|
|
122
336
|
case "trashFromDelete":
|
|
123
337
|
return "trash";
|
|
124
338
|
case "description":
|
|
@@ -126,6 +340,8 @@ export function crudMessageAction(action, markOn) {
|
|
|
126
340
|
case "edit":
|
|
127
341
|
case "view":
|
|
128
342
|
return "save";
|
|
343
|
+
case "history":
|
|
344
|
+
return "load";
|
|
129
345
|
default:
|
|
130
346
|
return action;
|
|
131
347
|
}
|
|
@@ -153,6 +369,82 @@ export function pinRoom(pinned, max = getCrudSettings().maxPins) {
|
|
|
153
369
|
return Number.POSITIVE_INFINITY;
|
|
154
370
|
return Math.max(0, max - pinned);
|
|
155
371
|
}
|
|
372
|
+
/* This user's marks on a record */
|
|
373
|
+
/** Every mark, in the order their sections show above the list: Pinned, then Favourites. */
|
|
374
|
+
export const CRUD_MARKS = ["pin", "favorite"];
|
|
375
|
+
/**
|
|
376
|
+
* Each mark's bit in a record's set of marks, as the Nexus backend's `[Flags] EBookmarkTypes` numbers them. A set may
|
|
377
|
+
* hold bits this package does not know - a backend's next mark is its next bit - and a change here keeps them.
|
|
378
|
+
*/
|
|
379
|
+
export const CRUD_MARK_BITS = { pin: 1, favorite: 2 };
|
|
380
|
+
/** Whether a set of marks holds this mark. */
|
|
381
|
+
export function hasMark(marks, mark) {
|
|
382
|
+
return (marks & CRUD_MARK_BITS[mark]) !== 0;
|
|
383
|
+
}
|
|
384
|
+
/** The set of marks with this one put on or taken off, and every other bit as it was. */
|
|
385
|
+
export function withMark(marks, mark, on) {
|
|
386
|
+
const bit = CRUD_MARK_BITS[mark];
|
|
387
|
+
return on ? marks | bit : marks & ~bit;
|
|
388
|
+
}
|
|
389
|
+
/** Most records a user may give a mark: `maxPins`, and for favourites `maxFavorites`, else the same as the pins. */
|
|
390
|
+
export function markLimit(mark, settings = getCrudSettings()) {
|
|
391
|
+
return mark === "pin" ? settings.maxPins : (settings.maxFavorites ?? settings.maxPins);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Which ways a selection can go for one mark, from each selected record's marks: on, when one of them lacks it, and
|
|
395
|
+
* off, when one of them has it. A mixed selection can go both ways, and each is offered on its own (the owner,
|
|
396
|
+
* 2026-10-07): no action that would change nothing.
|
|
397
|
+
*/
|
|
398
|
+
export function markDirections(marks, mark) {
|
|
399
|
+
return { on: marks.some(set => !hasMark(set, mark)), off: marks.some(set => hasMark(set, mark)) };
|
|
400
|
+
}
|
|
401
|
+
/** The marked records as one listing answered them, in its order: `marksOf` reads each record's marks. */
|
|
402
|
+
export function markedRecords(rows, marksOf, keyOf) {
|
|
403
|
+
const marks = new Map();
|
|
404
|
+
for (const row of rows) {
|
|
405
|
+
const set = marksOf(row);
|
|
406
|
+
if (set)
|
|
407
|
+
marks.set(keyOf(row), set);
|
|
408
|
+
}
|
|
409
|
+
const sections = {};
|
|
410
|
+
for (const mark of CRUD_MARKS)
|
|
411
|
+
sections[mark] = rows.filter(row => hasMark(marks.get(keyOf(row)) ?? 0, mark));
|
|
412
|
+
return { marks, rows: sections };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The marked records after a change, with no request. `stored` is each changed record's new set of marks (`0` for
|
|
416
|
+
* none). A record in `joining` - by default every record in `stored` - that now has a mark goes on top of that mark's
|
|
417
|
+
* section; one that lost a mark leaves it; every copy of a record in `rows` is replaced. With `joining` empty only the
|
|
418
|
+
* copies change: a flag or a save never moves a record into a section.
|
|
419
|
+
*/
|
|
420
|
+
export function patchMarkedRecords(current, rows, stored, keyOf, joining = new Set(stored.keys())) {
|
|
421
|
+
const marks = new Map(current.marks);
|
|
422
|
+
for (const [key, set] of stored) {
|
|
423
|
+
if (set)
|
|
424
|
+
marks.set(key, set);
|
|
425
|
+
else
|
|
426
|
+
marks.delete(key);
|
|
427
|
+
}
|
|
428
|
+
const changed = new Map(rows.map(row => [keyOf(row), row]));
|
|
429
|
+
const has = (row, mark) => hasMark(marks.get(keyOf(row)) ?? 0, mark);
|
|
430
|
+
const sections = {};
|
|
431
|
+
for (const mark of CRUD_MARKS) {
|
|
432
|
+
const kept = current.rows[mark].map(row => changed.get(keyOf(row)) ?? row).filter(row => has(row, mark));
|
|
433
|
+
const held = new Set(kept.map(keyOf));
|
|
434
|
+
sections[mark] = [...rows.filter(row => joining.has(keyOf(row)) && has(row, mark) && !held.has(keyOf(row))), ...kept];
|
|
435
|
+
}
|
|
436
|
+
return { marks, rows: sections };
|
|
437
|
+
}
|
|
438
|
+
/** The marked records with these records out of every section - they left the active lists - keeping their marks. */
|
|
439
|
+
export function dropMarkedRows(current, keys, keyOf) {
|
|
440
|
+
const gone = new Set(keys);
|
|
441
|
+
if (!CRUD_MARKS.some(mark => current.rows[mark].some(row => gone.has(keyOf(row)))))
|
|
442
|
+
return current;
|
|
443
|
+
const sections = {};
|
|
444
|
+
for (const mark of CRUD_MARKS)
|
|
445
|
+
sections[mark] = current.rows[mark].filter(row => !gone.has(keyOf(row)));
|
|
446
|
+
return { marks: current.marks, rows: sections };
|
|
447
|
+
}
|
|
156
448
|
/** Default time a CRUD keeps a list: 5 minutes. */
|
|
157
449
|
export const CRUD_CACHE_LIFETIME = 5 * 60 * 1000;
|
|
158
450
|
/** Lists kept while a page is open, by key, each for a lifetime. Plain values; nothing is written to storage. */
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CrudNarrowPickerSource } from "../Interfaces/CrudInterfaces.ts";
|
|
2
|
+
type Picked = Readonly<Record<string, unknown>>;
|
|
3
|
+
/** "Pick a group", "Pick an area": the box's text while nothing is picked. */
|
|
4
|
+
export declare function narrowPickerPlaceholder(label: string): string;
|
|
5
|
+
/** What a narrowed list says while no record is picked, on its Add, and on its Add form's fixed field. */
|
|
6
|
+
export interface NarrowPickWords {
|
|
7
|
+
/** The empty list: "Pick a group in the Group box to see its amenities." */
|
|
8
|
+
empty: string;
|
|
9
|
+
/** Add, switched off: "Pick a group in the Group box to add a new amenity for it." */
|
|
10
|
+
add: string;
|
|
11
|
+
/** The fixed field of the Add form, given the picked record's name. */
|
|
12
|
+
fixed: (pickedName: string) => string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The words of a narrowed list, by where its record is picked: in the CRUD's own box (`narrowPicker`, its label given),
|
|
16
|
+
* or in another list on the page (no label). A page's `emptyMessage`, `addMessage` and `fixedMessage` replace them.
|
|
17
|
+
*
|
|
18
|
+
* @example narrowPickWords("Group", "amenity", "amenities").add // "Pick a group in the Group box to add a new amenity for it."
|
|
19
|
+
*/
|
|
20
|
+
export declare function narrowPickWords(pickerLabel: string | undefined, subject: string, plural: string): NarrowPickWords;
|
|
21
|
+
/**
|
|
22
|
+
* Where a `narrowPicker`'s pick is kept: one place per page key and viewer. No place without a viewer: a pick is never
|
|
23
|
+
* shared by everyone on a browser, so a page that cannot say who is looking (not signed in yet) remembers nothing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function narrowPickerStorageKey(remember: {
|
|
26
|
+
key: string;
|
|
27
|
+
viewer?: string | null;
|
|
28
|
+
}): string | null;
|
|
29
|
+
/** The kept pick's key, or null. Only the record's key is kept: never its name or anything else of it. */
|
|
30
|
+
export declare function readNarrowPick(storage: Pick<Storage, "getItem">, key: string): string | null;
|
|
31
|
+
/** Keeps the pick's key, or forgets it (null). A browser that keeps nothing asks for the pick again next time. */
|
|
32
|
+
export declare function writeNarrowPick(storage: Pick<Storage, "setItem" | "removeItem">, key: string, value: string | null): void;
|
|
33
|
+
/**
|
|
34
|
+
* The record a kept pick names, as the box offers it now, or null when it no longer does (gone, inactive). Asked by its
|
|
35
|
+
* key (`resolveOptions`, one record) when the picker can; else the box's own pages are read, at a normal page size,
|
|
36
|
+
* until it shows up or they end.
|
|
37
|
+
*/
|
|
38
|
+
export declare function findNarrowPick(picker: CrudNarrowPickerSource, value: string, valueKey: string, signal: AbortSignal): Promise<Picked | null>;
|
|
39
|
+
/**
|
|
40
|
+
* The one record the box offers, or null when it offers none or more than one: a box with a single record to pick picks
|
|
41
|
+
* it by itself. Offered as the box offers it: an inactive record (a person still invited, say) is in the list's answer
|
|
42
|
+
* but never offered, and a key is counted once. Read at the box's own page size, the pages the box would ask for, and
|
|
43
|
+
* only until a second record shows.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findLoneNarrowPick(picker: CrudNarrowPickerSource, valueKey: string, signal: AbortSignal): Promise<Picked | null>;
|
|
46
|
+
/** How a narrowing box opens: on the pick this browser kept, on the one record it offers, or asking for a pick. */
|
|
47
|
+
export interface NarrowPickStart {
|
|
48
|
+
/** The record it opens on, or null: the box asks for a pick. */
|
|
49
|
+
picked: Picked | null;
|
|
50
|
+
/** `kept`: the pick this browser kept for the viewer. `lone`: the only record the box offers, picked by itself. */
|
|
51
|
+
from: "kept" | "lone" | null;
|
|
52
|
+
/**
|
|
53
|
+
* What to keep in this browser now: the record's key (a lone record is kept as a person's pick is), or null to forget
|
|
54
|
+
* a kept pick the box no longer offers. Undefined: leave what is kept as it is (nothing could be read just now).
|
|
55
|
+
*/
|
|
56
|
+
keep?: string | null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Settles a narrowing box as its page opens: the kept pick while the box still offers it (`findNarrowPick`), else the
|
|
60
|
+
* one record the box offers (`findLoneNarrowPick`), else none. Never rejects: a read that fails counts as nothing found,
|
|
61
|
+
* and a kept pick that could not be checked is left kept for next time.
|
|
62
|
+
*/
|
|
63
|
+
export declare function startNarrowPick(picker: CrudNarrowPickerSource, kept: string | null, valueKey: string, signal: AbortSignal): Promise<NarrowPickStart>;
|
|
64
|
+
/** The page around a narrowing box at the moment it could take the cursor, as its page opens. */
|
|
65
|
+
export interface NarrowCursorMoment {
|
|
66
|
+
/** The person has pressed a key or a pointer on the page since it opened: they are already at work. */
|
|
67
|
+
used: boolean;
|
|
68
|
+
/** Focus is on something other than the page itself. */
|
|
69
|
+
focusElsewhere: boolean;
|
|
70
|
+
/** A popup (a dialog) or a panel (a menu, a list) is open. */
|
|
71
|
+
popupOpen: boolean;
|
|
72
|
+
/** A touch screen with no mouse: a cursor in the box would raise the on-screen keyboard over the list. */
|
|
73
|
+
touchOnly: boolean;
|
|
74
|
+
/** The whole box is on screen: taking the cursor never scrolls the page. */
|
|
75
|
+
inView: boolean;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether a narrowing box that asks for a pick takes the cursor as its page opens: only on a page nobody has started
|
|
79
|
+
* using, with nothing open over it, where the box is on screen and a cursor brings no on-screen keyboard. Otherwise it is
|
|
80
|
+
* only drawn as wanted, and the list's button takes the person to it.
|
|
81
|
+
*/
|
|
82
|
+
export declare function narrowBoxTakesCursor(moment: NarrowCursorMoment): boolean;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** "a group", "an area". */
|
|
2
|
+
function withArticle(noun) {
|
|
3
|
+
return `${/^[aeiou]/i.test(noun) ? "an" : "a"} ${noun}`;
|
|
4
|
+
}
|
|
5
|
+
/** "Pick a group", "Pick an area": the box's text while nothing is picked. */
|
|
6
|
+
export function narrowPickerPlaceholder(label) {
|
|
7
|
+
return `Pick ${withArticle(label.trim().toLocaleLowerCase())}`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The words of a narrowed list, by where its record is picked: in the CRUD's own box (`narrowPicker`, its label given),
|
|
11
|
+
* or in another list on the page (no label). A page's `emptyMessage`, `addMessage` and `fixedMessage` replace them.
|
|
12
|
+
*
|
|
13
|
+
* @example narrowPickWords("Group", "amenity", "amenities").add // "Pick a group in the Group box to add a new amenity for it."
|
|
14
|
+
*/
|
|
15
|
+
export function narrowPickWords(pickerLabel, subject, plural) {
|
|
16
|
+
const label = pickerLabel?.trim();
|
|
17
|
+
if (!label) {
|
|
18
|
+
return {
|
|
19
|
+
empty: `Select a row in the other list to see its ${plural}.`,
|
|
20
|
+
add: `Select a row in the other list to add a new ${subject} for it.`,
|
|
21
|
+
fixed: name => `Added for ${name}: pick another row in the other list to add elsewhere.`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const noun = label.toLocaleLowerCase();
|
|
25
|
+
return {
|
|
26
|
+
empty: `Pick ${withArticle(noun)} in the ${label} box to see its ${plural}.`,
|
|
27
|
+
add: `Pick ${withArticle(noun)} in the ${label} box to add a new ${subject} for it.`,
|
|
28
|
+
fixed: name => `Added for ${name}: pick another ${noun} in the ${label} box to add elsewhere.`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Where a `narrowPicker`'s pick is kept: one place per page key and viewer. No place without a viewer: a pick is never
|
|
33
|
+
* shared by everyone on a browser, so a page that cannot say who is looking (not signed in yet) remembers nothing.
|
|
34
|
+
*/
|
|
35
|
+
export function narrowPickerStorageKey(remember) {
|
|
36
|
+
const viewer = remember.viewer?.trim();
|
|
37
|
+
return remember.key && viewer ? `nx-crud-narrow:${remember.key}:${viewer}` : null;
|
|
38
|
+
}
|
|
39
|
+
/** The kept pick's key, or null. Only the record's key is kept: never its name or anything else of it. */
|
|
40
|
+
export function readNarrowPick(storage, key) {
|
|
41
|
+
try {
|
|
42
|
+
const text = storage.getItem(key);
|
|
43
|
+
if (!text)
|
|
44
|
+
return null;
|
|
45
|
+
const saved = JSON.parse(text);
|
|
46
|
+
return typeof saved.value === "string" && saved.value ? saved.value : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Keeps the pick's key, or forgets it (null). A browser that keeps nothing asks for the pick again next time. */
|
|
53
|
+
export function writeNarrowPick(storage, key, value) {
|
|
54
|
+
try {
|
|
55
|
+
if (value)
|
|
56
|
+
storage.setItem(key, JSON.stringify({ value }));
|
|
57
|
+
else
|
|
58
|
+
storage.removeItem(key);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Kept nowhere: the pick is asked for again.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The page size a kept pick is looked for in, when the picker cannot name a record by its key. */
|
|
65
|
+
const FIND_PAGE_SIZE = 50;
|
|
66
|
+
/** Pages looked through before the kept pick counts as no longer offered. */
|
|
67
|
+
const FIND_PAGES = 20;
|
|
68
|
+
/**
|
|
69
|
+
* The record a kept pick names, as the box offers it now, or null when it no longer does (gone, inactive). Asked by its
|
|
70
|
+
* key (`resolveOptions`, one record) when the picker can; else the box's own pages are read, at a normal page size,
|
|
71
|
+
* until it shows up or they end.
|
|
72
|
+
*/
|
|
73
|
+
export async function findNarrowPick(picker, value, valueKey, signal) {
|
|
74
|
+
const offered = (row) => String(row[valueKey]) === value && !picker.isOptionInactive?.(row);
|
|
75
|
+
if (picker.resolveOptions) {
|
|
76
|
+
const rows = await picker.resolveOptions([value]);
|
|
77
|
+
return rows.find(offered) ?? null;
|
|
78
|
+
}
|
|
79
|
+
for (let page = 1; page <= FIND_PAGES; page++) {
|
|
80
|
+
if (signal.aborted)
|
|
81
|
+
return null;
|
|
82
|
+
const skip = (page - 1) * FIND_PAGE_SIZE;
|
|
83
|
+
const answer = await picker.loadOptions({ search: "", page, skip, pageSize: FIND_PAGE_SIZE, signal });
|
|
84
|
+
const row = answer.items.find(offered);
|
|
85
|
+
if (row)
|
|
86
|
+
return row;
|
|
87
|
+
if (!answer.hasMore || answer.items.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The one record the box offers, or null when it offers none or more than one: a box with a single record to pick picks
|
|
94
|
+
* it by itself. Offered as the box offers it: an inactive record (a person still invited, say) is in the list's answer
|
|
95
|
+
* but never offered, and a key is counted once. Read at the box's own page size, the pages the box would ask for, and
|
|
96
|
+
* only until a second record shows.
|
|
97
|
+
*/
|
|
98
|
+
export async function findLoneNarrowPick(picker, valueKey, signal) {
|
|
99
|
+
let lone = null;
|
|
100
|
+
for (let page = 1; page <= FIND_PAGES; page++) {
|
|
101
|
+
if (signal.aborted)
|
|
102
|
+
return null;
|
|
103
|
+
const skip = (page - 1) * FIND_PAGE_SIZE;
|
|
104
|
+
const answer = await picker.loadOptions({ search: "", page, skip, pageSize: FIND_PAGE_SIZE, signal });
|
|
105
|
+
for (const row of answer.items) {
|
|
106
|
+
if (picker.isOptionInactive?.(row))
|
|
107
|
+
continue;
|
|
108
|
+
if (!lone)
|
|
109
|
+
lone = row;
|
|
110
|
+
else if (String(row[valueKey]) !== String(lone[valueKey]))
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const more = answer.hasMore ?? answer.items.length >= FIND_PAGE_SIZE;
|
|
114
|
+
if (!more || answer.items.length === 0)
|
|
115
|
+
return lone;
|
|
116
|
+
}
|
|
117
|
+
// Pages that never end with one record offered in them: not a box worth deciding for the person.
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Settles a narrowing box as its page opens: the kept pick while the box still offers it (`findNarrowPick`), else the
|
|
122
|
+
* one record the box offers (`findLoneNarrowPick`), else none. Never rejects: a read that fails counts as nothing found,
|
|
123
|
+
* and a kept pick that could not be checked is left kept for next time.
|
|
124
|
+
*/
|
|
125
|
+
export async function startNarrowPick(picker, kept, valueKey, signal) {
|
|
126
|
+
let keep;
|
|
127
|
+
if (kept) {
|
|
128
|
+
try {
|
|
129
|
+
const row = await findNarrowPick(picker, kept, valueKey, signal);
|
|
130
|
+
if (row)
|
|
131
|
+
return { picked: row, from: "kept", keep: kept };
|
|
132
|
+
keep = null;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Not read just now: kept for the next visit, and the box is asked about as on a first one.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (signal.aborted)
|
|
139
|
+
return { picked: null, from: null };
|
|
140
|
+
try {
|
|
141
|
+
const lone = await findLoneNarrowPick(picker, valueKey, signal);
|
|
142
|
+
if (lone)
|
|
143
|
+
return { picked: lone, from: "lone", keep: String(lone[valueKey]) };
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// The box's records could not be read: it asks for a pick, and its own list says what failed when it opens.
|
|
147
|
+
}
|
|
148
|
+
return keep === undefined ? { picked: null, from: null } : { picked: null, from: null, keep };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether a narrowing box that asks for a pick takes the cursor as its page opens: only on a page nobody has started
|
|
152
|
+
* using, with nothing open over it, where the box is on screen and a cursor brings no on-screen keyboard. Otherwise it is
|
|
153
|
+
* only drawn as wanted, and the list's button takes the person to it.
|
|
154
|
+
*/
|
|
155
|
+
export function narrowBoxTakesCursor(moment) {
|
|
156
|
+
return !moment.used && !moment.focusElsewhere && !moment.popupOpen && !moment.touchOnly && moment.inView;
|
|
157
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ApiEndpoint } from "../Interfaces/ApiInterfaces.ts";
|
|
2
|
+
import type { CrudMode, CrudRowForm } from "../Interfaces/CrudInterfaces.ts";
|
|
3
|
+
import type { FormValues } from "../Interfaces/FormInterfaces.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Whether a row form is offered on a record: on its lists (default the lists that offer Edit), to a person who may call
|
|
6
|
+
* its `submit` and, when it is an endpoint, its `load`, and by the page's own `when`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function crudRowFormOffered<TRow extends FormValues>(form: CrudRowForm<TRow, FormValues>, row: TRow, mode: CrudMode, can: (endpoint: ApiEndpoint) => boolean): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* What a save changes on the record, as `writes` says: the answer's fields it names (each one the answer carries; an
|
|
11
|
+
* answer that is not a record changes nothing), or what its function answers. Never the record as the form was opened
|
|
12
|
+
* on: the CRUD merges this over the record as it holds it when the answer lands, so a change made meanwhile (an Edit
|
|
13
|
+
* while the popup was minimized) stays.
|
|
14
|
+
*/
|
|
15
|
+
export declare function crudRowFormWrites<TRow extends FormValues, TValues extends FormValues>(writes: CrudRowForm<TRow, TValues>["writes"], result: unknown, row: TRow, values: TValues): Partial<TRow> | null;
|