nexus-shared 3.2.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 +1564 -0
- package/README.md +1705 -1123
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -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 +576 -243
- package/dist/Components/Forms/CrudActions.d.ts +136 -0
- package/dist/Components/Forms/CrudActions.js +184 -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 +55 -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 +123 -17
- 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/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +5 -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 +37 -32
- 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 +11 -2
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TreeView.d.ts +53 -2
- package/dist/Components/Inputs/TreeView.js +147 -13
- 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/Viewers/DataTable.js +150 -48
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +21 -5
- package/dist/Components/Viewers/DataTableParts.js +26 -14
- 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/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/CrudBackend.d.ts +20 -2
- package/dist/Helpers/CrudBackend.js +46 -2
- package/dist/Helpers/CrudColumns.d.ts +53 -6
- package/dist/Helpers/CrudColumns.js +98 -7
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +81 -2
- package/dist/Helpers/CrudHelpers.js +195 -0
- 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 +157 -74
- package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
- package/dist/Helpers/NexusDriveAdapter.js +207 -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 +14 -2
- package/dist/Helpers/RequestCache.js +22 -3
- 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 +9 -3
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +23 -2
- package/dist/Helpers/TableHelpers.js +51 -10
- package/dist/Helpers/TableJoins.d.ts +13 -0
- package/dist/Helpers/TableJoins.js +34 -0
- package/dist/Helpers/TableStore.d.ts +21 -1
- package/dist/Helpers/TableStore.js +64 -7
- package/dist/Helpers/TreeStore.d.ts +81 -0
- package/dist/Helpers/TreeStore.js +365 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +465 -5
- package/dist/Interfaces/FileInterfaces.d.ts +162 -0
- package/dist/Interfaces/FileInterfaces.js +4 -0
- package/dist/Interfaces/FormInterfaces.d.ts +391 -30
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +15 -3
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +74 -1
- 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 +37 -5
- package/dist/Services/JoinSources.js +90 -14
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Shared.Index.d.ts +16 -0
- package/dist/Shared.Index.js +16 -0
- package/package.json +7 -2
- package/src/Styles/Nexus.Avatar.css +6 -5
- package/src/Styles/Nexus.Button.css +10 -4
- package/src/Styles/Nexus.Choice.css +16 -0
- package/src/Styles/Nexus.Crud.css +383 -9
- package/src/Styles/Nexus.Dropdown.css +8 -0
- package/src/Styles/Nexus.Files.css +410 -0
- package/src/Styles/Nexus.Form.css +5 -2
- package/src/Styles/Nexus.Index.css +4 -0
- package/src/Styles/Nexus.Input.css +42 -0
- package/src/Styles/Nexus.Menu.css +10 -1
- package/src/Styles/Nexus.Popup.css +77 -1
- 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.Tab.Buttons.css +87 -22
- package/src/Styles/Nexus.Table.css +67 -5
- package/src/Styles/Nexus.Themes.css +12 -6
- package/src/Styles/Nexus.Tokens.css +8 -3
- package/src/Styles/Nexus.Tree.css +31 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { EInputTypes } from "../Interfaces/FormInterfaces.js";
|
|
2
|
+
import { isApiEndpoint } from "./ApiRoutes.js";
|
|
3
|
+
import { formatInputValue, holdsValue, isBooleanInput, isEmptyInputValue, isRowsInput } from "./InputParamsHelpers.js";
|
|
4
|
+
/*
|
|
5
|
+
* The pure half of a record's page of cards (`RecordCards`, Components/Viewers/RecordCards.tsx): how a value reads in a
|
|
6
|
+
* card, what an answer writes into the data, where a card's items are, its limit, an action's state, a popup's steps,
|
|
7
|
+
* and the sources the page reads. Pure, so Node tests run it.
|
|
8
|
+
*/
|
|
9
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
10
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
/** A fields card with its data's type: `fieldsCard<Profile>({ ... })`. */
|
|
12
|
+
export const fieldsCard = (card) => card;
|
|
13
|
+
/** An items card with its data's and its items' types: `itemsCard<ContactList, Contact>({ ... })`. */
|
|
14
|
+
export const itemsCard = (card) => card;
|
|
15
|
+
/** A card of the page's own, with its data's type. */
|
|
16
|
+
export const contentCard = (card) => card;
|
|
17
|
+
/** Which of the three a card is. */
|
|
18
|
+
export function recordCardKind(card) {
|
|
19
|
+
if ("fields" in card && Array.isArray(card.fields))
|
|
20
|
+
return "fields";
|
|
21
|
+
if ("render" in card && typeof card.render === "function")
|
|
22
|
+
return "content";
|
|
23
|
+
return "items";
|
|
24
|
+
}
|
|
25
|
+
/* Values */
|
|
26
|
+
/** A field given as its input alone, or with its own label and look: always the second. */
|
|
27
|
+
export function recordField(entry) {
|
|
28
|
+
return "input" in entry && isRecord(entry.input) && "type" in entry.input ? entry : { input: entry };
|
|
29
|
+
}
|
|
30
|
+
const isPlain = (value) => value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
31
|
+
/**
|
|
32
|
+
* What a popup's form opens on, of the record or the item it works on: the values its inputs name that an input can hold
|
|
33
|
+
* (text, a number, a yes or no, a list of those; rows for a rows input - Tabular, Group). A field of another shape - the
|
|
34
|
+
* code record of a contact, when the step's box is also called `code` - is left out, so the box opens empty rather than
|
|
35
|
+
* on "[object Object]". Rows are kept whole: an Edit that opened with no rows would send none and wipe them.
|
|
36
|
+
*/
|
|
37
|
+
export function recordOpenedValues(given, inputs) {
|
|
38
|
+
const opened = {};
|
|
39
|
+
if (!isRecord(given))
|
|
40
|
+
return opened;
|
|
41
|
+
for (const input of inputs) {
|
|
42
|
+
const value = given[input.name];
|
|
43
|
+
if (value === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
if (isPlain(value) || (Array.isArray(value) && value.every(isPlain)))
|
|
46
|
+
opened[input.name] = value;
|
|
47
|
+
else if (isRowsInput(input) && Array.isArray(value) && value.every(row => isRecord(row)))
|
|
48
|
+
opened[input.name] = value;
|
|
49
|
+
}
|
|
50
|
+
return opened;
|
|
51
|
+
}
|
|
52
|
+
/** A fields card's Edit inputs: the fields' own, without those only shown (`edit: false`) or that hold no value. */
|
|
53
|
+
export function recordFieldInputs(card) {
|
|
54
|
+
return card.fields
|
|
55
|
+
.map(recordField)
|
|
56
|
+
.filter(field => field.edit !== false && holdsValue(field.input))
|
|
57
|
+
.map(field => field.input);
|
|
58
|
+
}
|
|
59
|
+
const LONG_TEXT = new Set([EInputTypes.TextArea]);
|
|
60
|
+
const TEXT_TYPES = new Set([EInputTypes.Text, EInputTypes.Email, EInputTypes.Search, EInputTypes.Tel, EInputTypes.Url, EInputTypes.TextArea]);
|
|
61
|
+
/** Whether a field takes the card's whole width when it does not say: long text, chips and editors do. */
|
|
62
|
+
export function recordFieldWide(field) {
|
|
63
|
+
if (field.wide !== undefined)
|
|
64
|
+
return field.wide;
|
|
65
|
+
const type = field.input.type;
|
|
66
|
+
return field.look === "lines" || field.look === "chips" || LONG_TEXT.has(type) || type === EInputTypes.Markdown || type === EInputTypes.RichText || type === EInputTypes.CheckBoxGroup;
|
|
67
|
+
}
|
|
68
|
+
/** What a field holds while its source loads, so the card is as tall as it will be: a line, two lines, or a chip's row. */
|
|
69
|
+
export function recordFieldHeld(field) {
|
|
70
|
+
const type = field.input.type;
|
|
71
|
+
const multiple = field.input.multiple === true;
|
|
72
|
+
if (field.look === "chips" || type === EInputTypes.CheckBoxGroup || (type === EInputTypes.Dropdown && multiple))
|
|
73
|
+
return "chips";
|
|
74
|
+
if (field.look === "lines" || (field.look !== "text" && (LONG_TEXT.has(type) || type === EInputTypes.Markdown || type === EInputTypes.RichText)))
|
|
75
|
+
return "lines";
|
|
76
|
+
return "line";
|
|
77
|
+
}
|
|
78
|
+
/** How many lines a value held as lines holds while its source loads: the field's `heldLines`, else two. */
|
|
79
|
+
export function recordHeldLines(field) {
|
|
80
|
+
const lines = field.heldLines;
|
|
81
|
+
return typeof lines === "number" && Number.isFinite(lines) && lines >= 1 ? Math.min(Math.floor(lines), 12) : 2;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A value as a card draws it, from the input that edits it: a choice by its label, a date in the person's format, a
|
|
85
|
+
* number with its format, a yes or a no, a list as chips (`look: "chips"`, a multiple choice, a group of checkboxes), long
|
|
86
|
+
* text on its own lines, Markdown and rich text as such, a password never. Empty when there is nothing to say.
|
|
87
|
+
*/
|
|
88
|
+
export function recordValueView(params, value, options = {}) {
|
|
89
|
+
const { look, preferences } = options;
|
|
90
|
+
if (!holdsValue(params))
|
|
91
|
+
return { kind: "empty" };
|
|
92
|
+
if (isBooleanInput(params))
|
|
93
|
+
return value === true ? { kind: "text", text: "Yes" } : value === false ? { kind: "text", text: "No" } : { kind: "empty" };
|
|
94
|
+
if (isEmptyInputValue(value))
|
|
95
|
+
return { kind: "empty" };
|
|
96
|
+
if (params.type === EInputTypes.Password)
|
|
97
|
+
return { kind: "text", text: "••••••••" };
|
|
98
|
+
const multiple = params.type === EInputTypes.CheckBoxGroup || (params.type === EInputTypes.Dropdown && Array.isArray(value));
|
|
99
|
+
if (multiple) {
|
|
100
|
+
const items = (Array.isArray(value) ? value : [value]).map(item => formatInputValue(params, [item], { preferences })).filter(Boolean);
|
|
101
|
+
return items.length ? { kind: "chips", items } : { kind: "empty" };
|
|
102
|
+
}
|
|
103
|
+
if (look === "chips") {
|
|
104
|
+
const items = (Array.isArray(value) ? value.map(String) : String(value).split(","))
|
|
105
|
+
.map(item => item.trim())
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
return items.length ? { kind: "chips", items } : { kind: "empty" };
|
|
108
|
+
}
|
|
109
|
+
if (params.type === EInputTypes.Markdown)
|
|
110
|
+
return { kind: "markdown", text: String(value) };
|
|
111
|
+
if (params.type === EInputTypes.RichText)
|
|
112
|
+
return { kind: "rich", html: String(value) };
|
|
113
|
+
if (TEXT_TYPES.has(params.type)) {
|
|
114
|
+
const text = String(value).trim();
|
|
115
|
+
return look === "lines" || (look !== "text" && LONG_TEXT.has(params.type)) ? { kind: "lines", text } : { kind: "text", text };
|
|
116
|
+
}
|
|
117
|
+
const text = formatInputValue(params, value, { preferences });
|
|
118
|
+
return text ? { kind: look === "lines" ? "lines" : "text", text } : { kind: "empty" };
|
|
119
|
+
}
|
|
120
|
+
/* Items */
|
|
121
|
+
/** A card's items: the field it names, what its function answers, or the data itself when that is the list. */
|
|
122
|
+
export function recordItems(data, items) {
|
|
123
|
+
if (typeof items === "function")
|
|
124
|
+
return items(data) ?? [];
|
|
125
|
+
const list = typeof items === "string" ? (isRecord(data) ? data[items] : undefined) : data;
|
|
126
|
+
return Array.isArray(list) ? list : [];
|
|
127
|
+
}
|
|
128
|
+
/** An item's key, as text. */
|
|
129
|
+
export function recordItemKey(item, itemKey) {
|
|
130
|
+
const key = typeof itemKey === "function" ? itemKey(item) : isRecord(item) ? item[itemKey] : undefined;
|
|
131
|
+
return key === undefined || key === null ? "" : String(key);
|
|
132
|
+
}
|
|
133
|
+
/** The data with these items in place of its own, or `null` when the items come from a function (which cannot be set). */
|
|
134
|
+
export function recordWithItems(data, items, next) {
|
|
135
|
+
if (typeof items === "function")
|
|
136
|
+
return null;
|
|
137
|
+
if (typeof items === "string")
|
|
138
|
+
return { ...(isRecord(data) ? data : {}), [items]: next };
|
|
139
|
+
return [...next];
|
|
140
|
+
}
|
|
141
|
+
/** The most a card's list may hold, by its `limit`: a number, a field of the data, or a function. `null` for no limit. */
|
|
142
|
+
export function recordLimit(data, limit) {
|
|
143
|
+
const value = typeof limit === "function" ? limit(data) : typeof limit === "string" ? (isRecord(data) ? data[limit] : undefined) : limit;
|
|
144
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
145
|
+
}
|
|
146
|
+
/** "3 of 5", or "3" with no limit. */
|
|
147
|
+
export const recordCount = (count, limit) => (limit === null ? String(count) : `${count} of ${limit}`);
|
|
148
|
+
/** Why Add is held at the limit. */
|
|
149
|
+
export function recordLimitReason(limit, reason) {
|
|
150
|
+
if (typeof reason === "function")
|
|
151
|
+
return reason(limit);
|
|
152
|
+
return reason ?? `The most there may be is ${limit}. Remove one first.`;
|
|
153
|
+
}
|
|
154
|
+
const merged = (data, patch) => (isRecord(data) && isRecord(patch) ? { ...data, ...patch } : patch);
|
|
155
|
+
/**
|
|
156
|
+
* What an answer writes into the card's data, as `writes` says (`RecordWrites`). Everything merges over the data as held
|
|
157
|
+
* now; a list (`"item"`, `"remove"`) is written back only where the card names its field or the data is the list, else
|
|
158
|
+
* the source is read again.
|
|
159
|
+
*/
|
|
160
|
+
export function recordWrite(request) {
|
|
161
|
+
const { writes = "answer", result, data, values, fallback, items, itemKey, item } = request;
|
|
162
|
+
if (typeof writes === "function") {
|
|
163
|
+
const patch = writes(result, data, values);
|
|
164
|
+
return patch === null || patch === undefined ? { kind: "none" } : { kind: "write", data: merged(data, patch) };
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(writes)) {
|
|
167
|
+
if (!isRecord(result))
|
|
168
|
+
return { kind: "none" };
|
|
169
|
+
const patch = {};
|
|
170
|
+
for (const field of writes)
|
|
171
|
+
if (result[field] !== undefined)
|
|
172
|
+
patch[field] = result[field];
|
|
173
|
+
return Object.keys(patch).length ? { kind: "write", data: merged(data, patch) } : { kind: "none" };
|
|
174
|
+
}
|
|
175
|
+
switch (writes) {
|
|
176
|
+
case "none":
|
|
177
|
+
return { kind: "none" };
|
|
178
|
+
case "reload":
|
|
179
|
+
return { kind: "reload" };
|
|
180
|
+
case "values":
|
|
181
|
+
return isRecord(values) ? { kind: "write", data: merged(data, values) } : { kind: "none" };
|
|
182
|
+
case "item":
|
|
183
|
+
case "remove": {
|
|
184
|
+
if (!itemKey)
|
|
185
|
+
return { kind: "reload" };
|
|
186
|
+
const list = recordItems(data, items);
|
|
187
|
+
const key = recordItemKey(writes === "item" ? result : item, itemKey);
|
|
188
|
+
if (!key)
|
|
189
|
+
return { kind: "reload" };
|
|
190
|
+
const next = writes === "remove"
|
|
191
|
+
? list.filter(one => recordItemKey(one, itemKey) !== key)
|
|
192
|
+
: list.some(one => recordItemKey(one, itemKey) === key)
|
|
193
|
+
? list.map(one => (recordItemKey(one, itemKey) === key ? result : one))
|
|
194
|
+
: [...list, result];
|
|
195
|
+
const written = recordWithItems(data, items, next);
|
|
196
|
+
return written === null ? { kind: "reload" } : { kind: "write", data: written };
|
|
197
|
+
}
|
|
198
|
+
default: {
|
|
199
|
+
if (isRecord(result))
|
|
200
|
+
return { kind: "write", data: merged(data, result) };
|
|
201
|
+
if (Array.isArray(result) && Array.isArray(data))
|
|
202
|
+
return { kind: "write", data: result };
|
|
203
|
+
if (fallback === "values" && isRecord(values))
|
|
204
|
+
return { kind: "write", data: merged(data, values) };
|
|
205
|
+
return { kind: "reload" };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** The endpoint an action calls first, when it is one: the action shows only to a person who may call it. */
|
|
210
|
+
export function recordActionEndpoint(action) {
|
|
211
|
+
const submit = action.flow ? (recordSteps(action.flow)[0]?.submit ?? action.flow.submit) : action.confirm?.submit;
|
|
212
|
+
return isApiEndpoint(submit) ? submit : undefined;
|
|
213
|
+
}
|
|
214
|
+
/** What a heading's button says while its card's data is read. */
|
|
215
|
+
export const RECORD_STILL_READ = "Still being read.";
|
|
216
|
+
/**
|
|
217
|
+
* An action's state: hidden on a read-only card, by its own rule, or to a person who may not call its endpoint; held with
|
|
218
|
+
* its reason; else ready. `args` is `null` while the data is still read: the rules typed on the data are not asked then,
|
|
219
|
+
* and the action is held ("Still being read.") or, with `loading: "hidden"`, not drawn yet.
|
|
220
|
+
*/
|
|
221
|
+
export function recordActionState(action, args, { readOnly, can }) {
|
|
222
|
+
if (readOnly)
|
|
223
|
+
return { kind: "hidden" };
|
|
224
|
+
const endpoint = recordActionEndpoint(action);
|
|
225
|
+
if (args === null) {
|
|
226
|
+
if (endpoint && !can(endpoint))
|
|
227
|
+
return { kind: "hidden" };
|
|
228
|
+
return action.loading === "hidden" ? { kind: "hidden" } : { kind: "held", reason: RECORD_STILL_READ };
|
|
229
|
+
}
|
|
230
|
+
if (action.hidden?.(...args))
|
|
231
|
+
return { kind: "hidden" };
|
|
232
|
+
if (endpoint && !can(endpoint))
|
|
233
|
+
return { kind: "hidden" };
|
|
234
|
+
const reason = action.held?.(...args);
|
|
235
|
+
return reason ? { kind: "held", reason } : { kind: "ready" };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* A card's calls on their way, by what each works on (an item's key, or "card"): one added as it starts, that one alone
|
|
239
|
+
* taken away as it ends, so a call that ends first never frees a row whose own call still runs.
|
|
240
|
+
*/
|
|
241
|
+
export function recordBusyKeys(keys, key, on) {
|
|
242
|
+
const now = keys ?? [];
|
|
243
|
+
if (on)
|
|
244
|
+
return [...now, key];
|
|
245
|
+
const at = now.indexOf(key);
|
|
246
|
+
return at < 0 ? now : [...now.slice(0, at), ...now.slice(at + 1)];
|
|
247
|
+
}
|
|
248
|
+
/** An action's accessible name with what it acts on: "Edit: Personal details", "Verify: ram@example.com". */
|
|
249
|
+
export const recordActionName = (label, of) => (of ? `${label}: ${of}` : label);
|
|
250
|
+
/** A popup's steps, each with its id (its place, "1", when it names none): the flow itself when it has no `steps`. */
|
|
251
|
+
export function recordSteps(flow) {
|
|
252
|
+
const steps = flow.steps?.length ? flow.steps : [flow];
|
|
253
|
+
return steps.map((step, index) => ({ ...step, id: step.id ?? String(index + 1) }));
|
|
254
|
+
}
|
|
255
|
+
/** The step a popup opens on: `start` when it names a step there is, else the first. */
|
|
256
|
+
export function recordStartStep(flow, context) {
|
|
257
|
+
const steps = recordSteps(flow);
|
|
258
|
+
const asked = typeof flow.start === "function" ? flow.start(context) : flow.start;
|
|
259
|
+
return steps.find(step => step.id === asked)?.id ?? steps[0]?.id ?? "1";
|
|
260
|
+
}
|
|
261
|
+
/** The status line a step's bar keeps: the full one for a form of several inputs, the quiet one for a step of one or two. */
|
|
262
|
+
export const recordStatusLine = (inputs) => (inputs > 2 ? "full" : "errors");
|
|
263
|
+
/** The default rule for a session that ended: a 401 that names no input (a wrong password names its box). */
|
|
264
|
+
export const recordSessionEnded = (answer) => answer.status === 401 && Object.keys(answer.errors ?? {}).length === 0;
|
|
265
|
+
/** Whole seconds until a moment (an ISO text or ms), never below zero. */
|
|
266
|
+
export function secondsUntil(moment, now = Date.now()) {
|
|
267
|
+
if (moment === null || moment === undefined || moment === "")
|
|
268
|
+
return 0;
|
|
269
|
+
const at = typeof moment === "number" ? moment : Date.parse(moment);
|
|
270
|
+
return Number.isFinite(at) ? Math.max(0, Math.ceil((at - now) / 1000)) : 0;
|
|
271
|
+
}
|
|
272
|
+
const isSourceParams = (source) => isRecord(source) && "read" in source;
|
|
273
|
+
/**
|
|
274
|
+
* The page's sources: each read once (and again on Try again or a step's `reload`), written by the answers. A read that
|
|
275
|
+
* fails before anything was read leaves the source failed; one that fails while its data shows keeps the data and only
|
|
276
|
+
* answers the failure. A newer read wins over an older one still on its way. `end` marks the session ended: the page
|
|
277
|
+
* shows its words in place of the cards and nothing more is read.
|
|
278
|
+
*/
|
|
279
|
+
export class RecordSources {
|
|
280
|
+
states;
|
|
281
|
+
controllers = new Map();
|
|
282
|
+
listeners = new Set();
|
|
283
|
+
endedAnswer = null;
|
|
284
|
+
sources;
|
|
285
|
+
reader;
|
|
286
|
+
constructor(sources, reader) {
|
|
287
|
+
this.sources = sources;
|
|
288
|
+
this.reader = reader;
|
|
289
|
+
const states = {};
|
|
290
|
+
for (const [name, source] of Object.entries(sources))
|
|
291
|
+
states[name] = { status: "loading", data: isSourceParams(source) ? (source.held ?? null) : null };
|
|
292
|
+
this.states = states;
|
|
293
|
+
}
|
|
294
|
+
/** The names, in the page's order: the first is every card's default. */
|
|
295
|
+
get names() {
|
|
296
|
+
return Object.keys(this.sources);
|
|
297
|
+
}
|
|
298
|
+
subscribe = (listener) => {
|
|
299
|
+
this.listeners.add(listener);
|
|
300
|
+
return () => {
|
|
301
|
+
this.listeners.delete(listener);
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
/** Every source's state; a new object after each change, for `useSyncExternalStore`. */
|
|
305
|
+
snapshot = () => this.states;
|
|
306
|
+
get(name) {
|
|
307
|
+
return this.states[name] ?? { status: "ready", data: null };
|
|
308
|
+
}
|
|
309
|
+
/** The answer that found the session ended, once one has. */
|
|
310
|
+
get ended() {
|
|
311
|
+
return this.endedAnswer;
|
|
312
|
+
}
|
|
313
|
+
set(name, state) {
|
|
314
|
+
this.states = { ...this.states, [name]: state };
|
|
315
|
+
for (const listener of this.listeners)
|
|
316
|
+
listener();
|
|
317
|
+
}
|
|
318
|
+
/** Reads every source not read yet (none read, or stopped on its way). */
|
|
319
|
+
start() {
|
|
320
|
+
for (const name of this.names) {
|
|
321
|
+
const state = this.get(name);
|
|
322
|
+
if (state.status === "loading" && !this.controllers.has(name))
|
|
323
|
+
void this.read(name);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/** Stops every read on its way; `start` reads them again. */
|
|
327
|
+
stop() {
|
|
328
|
+
for (const controller of this.controllers.values())
|
|
329
|
+
controller.abort();
|
|
330
|
+
this.controllers.clear();
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Reads a source (again): resolves with the answer. A failed source goes back to loading while it runs; one that shows
|
|
334
|
+
* its data keeps it. An answer that a newer read or `stop` overtook changes nothing and resolves aborted.
|
|
335
|
+
*/
|
|
336
|
+
async read(name) {
|
|
337
|
+
const source = this.sources[name];
|
|
338
|
+
if (!source || this.endedAnswer)
|
|
339
|
+
return { ok: false, aborted: true };
|
|
340
|
+
this.controllers.get(name)?.abort();
|
|
341
|
+
const controller = new AbortController();
|
|
342
|
+
this.controllers.set(name, controller);
|
|
343
|
+
const before = this.get(name);
|
|
344
|
+
if (before.status === "failed")
|
|
345
|
+
this.set(name, { status: "loading", data: before.data });
|
|
346
|
+
// A read overtaken or stopped settles at once, even when the page's own function never looks at its signal.
|
|
347
|
+
const stopped = new Promise(resolve => controller.signal.addEventListener("abort", () => resolve({ ok: false, aborted: true }), { once: true }));
|
|
348
|
+
const reading = this.reader(isSourceParams(source) ? source.read : source, controller.signal).catch((error) => (controller.signal.aborted ? { ok: false, aborted: true } : { ok: false, message: error instanceof Error ? error.message : "The server did not answer." }));
|
|
349
|
+
const answer = await Promise.race([reading, stopped]);
|
|
350
|
+
if (controller.signal.aborted || this.controllers.get(name) !== controller)
|
|
351
|
+
return { ...answer, ok: false, aborted: true };
|
|
352
|
+
this.controllers.delete(name);
|
|
353
|
+
if (this.endedAnswer)
|
|
354
|
+
return answer;
|
|
355
|
+
const now = this.get(name);
|
|
356
|
+
if (answer.ok)
|
|
357
|
+
this.set(name, { status: "ready", data: answer.result ?? null });
|
|
358
|
+
else if (now.status !== "ready")
|
|
359
|
+
this.set(name, { status: "failed", data: now.data, answer });
|
|
360
|
+
return answer;
|
|
361
|
+
}
|
|
362
|
+
/** Writes a source's data: every card on it follows. */
|
|
363
|
+
write(name, data) {
|
|
364
|
+
if (!(name in this.sources) || this.endedAnswer)
|
|
365
|
+
return;
|
|
366
|
+
this.set(name, { status: "ready", data });
|
|
367
|
+
}
|
|
368
|
+
/** The session ended: nothing more is read, and the page shows its words. */
|
|
369
|
+
end(answer) {
|
|
370
|
+
if (this.endedAnswer)
|
|
371
|
+
return;
|
|
372
|
+
this.endedAnswer = answer;
|
|
373
|
+
this.stop();
|
|
374
|
+
this.states = { ...this.states };
|
|
375
|
+
for (const listener of this.listeners)
|
|
376
|
+
listener();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -20,8 +20,20 @@ export declare class RequestCache<T> {
|
|
|
20
20
|
* const page = cache.get(`regions|${parentId}|0`, signal => api.post(children, { parentId, skipCount: 0, maxCount: 50 }, { signal }));
|
|
21
21
|
*/
|
|
22
22
|
get(key: string, load: (signal: AbortSignal) => T | PromiseLike<T>, lifetime?: number): T | Promise<T>;
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Keeps an answer under `key`, as if it had just been loaded - or as if loaded at `at`, for an answer written over
|
|
25
|
+
* one that was kept: a change written in does not make what was kept any newer.
|
|
26
|
+
*/
|
|
27
|
+
set(key: string, value: T, at?: number): void;
|
|
28
|
+
/**
|
|
29
|
+
* The fresh answers kept under keys that start with `prefix`, with their keys and when they were loaded. A request
|
|
30
|
+
* on its way is not an answer yet, and an aged answer is not handed out.
|
|
31
|
+
*/
|
|
32
|
+
entries(prefix?: string, lifetime?: number): {
|
|
33
|
+
key: string;
|
|
34
|
+
value: T;
|
|
35
|
+
at: number;
|
|
36
|
+
}[];
|
|
25
37
|
private keep;
|
|
26
38
|
/**
|
|
27
39
|
* Drops the answers - kept or on their way - under every key that starts with `prefix`, or that `test` passes, or
|
|
@@ -77,9 +77,28 @@ export class RequestCache {
|
|
|
77
77
|
this.kept.set(key, loading);
|
|
78
78
|
return loading.promise;
|
|
79
79
|
}
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Keeps an answer under `key`, as if it had just been loaded - or as if loaded at `at`, for an answer written over
|
|
82
|
+
* one that was kept: a change written in does not make what was kept any newer.
|
|
83
|
+
*/
|
|
84
|
+
set(key, value, at) {
|
|
85
|
+
if (at === undefined)
|
|
86
|
+
this.keep(key, value, this.lifetime);
|
|
87
|
+
else if (this.lifetime > 0)
|
|
88
|
+
this.kept.set(key, { state: "ready", value, at });
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The fresh answers kept under keys that start with `prefix`, with their keys and when they were loaded. A request
|
|
92
|
+
* on its way is not an answer yet, and an aged answer is not handed out.
|
|
93
|
+
*/
|
|
94
|
+
entries(prefix = "", lifetime = this.lifetime) {
|
|
95
|
+
const list = [];
|
|
96
|
+
const now = this.now();
|
|
97
|
+
for (const [key, entry] of this.kept) {
|
|
98
|
+
if (entry.state === "ready" && key.startsWith(prefix) && now - entry.at < lifetime)
|
|
99
|
+
list.push({ key, value: entry.value, at: entry.at });
|
|
100
|
+
}
|
|
101
|
+
return list;
|
|
83
102
|
}
|
|
84
103
|
keep(key, value, lifetime) {
|
|
85
104
|
if (lifetime > 0)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type InputParams, type InputValidationContext, type RowsInputProps, type RowValue } from "../Interfaces/FormInterfaces.ts";
|
|
2
|
+
import { type InputMemoryStorage } from "./InputMemory.ts";
|
|
2
3
|
export type RowsStoreOptions<TRow extends RowValue = RowValue> = Pick<RowsInputProps<TRow>, "inputs" | "required" | "min" | "max" | "unique" | "validate" | "itemLabel" | "itemLabelPlural" | "rowKey" | "newRow" | "onRowChange" | "deriveRows"> & {
|
|
3
4
|
/** The row's name when there is no `itemLabel`: "Row" in a table, "Item" in sections. */
|
|
4
5
|
fallbackItem?: string;
|
|
@@ -47,6 +48,10 @@ export declare class RowsStore<TRow extends RowValue = RowValue> {
|
|
|
47
48
|
private showAll;
|
|
48
49
|
private touched;
|
|
49
50
|
private removed;
|
|
51
|
+
private memory;
|
|
52
|
+
private added;
|
|
53
|
+
private asking;
|
|
54
|
+
private memoryRound;
|
|
50
55
|
private revisions;
|
|
51
56
|
private versions;
|
|
52
57
|
private rowListeners;
|
|
@@ -105,8 +110,23 @@ export declare class RowsStore<TRow extends RowValue = RowValue> {
|
|
|
105
110
|
set(key: string, name: string, value: unknown, source?: "input" | "code"): void;
|
|
106
111
|
/** Marks a value as left by the user: a repeated value shows its message from now on. */
|
|
107
112
|
touch(key: string, name: string): void;
|
|
108
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Adds a row with the inputs' defaults, `newRow`, and `values`, at `index` or at the end. Returns its key. A remembered
|
|
115
|
+
* input (`remember`) that neither `newRow` nor `values` names with a value opens on the value kept for it, while its
|
|
116
|
+
* input offers it; a picker's opens once the picker answers, unless the person picked one first.
|
|
117
|
+
*/
|
|
109
118
|
add(values?: Partial<TRow>, index?: number): string;
|
|
119
|
+
/**
|
|
120
|
+
* The storage remembered row inputs read and keep (`InputLayoutParams.remember`), given once the rows are drawn in the
|
|
121
|
+
* browser: rows added from then on open on the kept values. The rows already there keep theirs.
|
|
122
|
+
*/
|
|
123
|
+
attachMemory(storage: InputMemoryStorage | null): void;
|
|
124
|
+
/**
|
|
125
|
+
* A save of the form holding the rows: each remembered input keeps its value in the row added last that is still there
|
|
126
|
+
* (an empty value forgets), under that row's `per` value and where its `when` holds on that row. No row added since the
|
|
127
|
+
* rows were loaded: nothing is kept, since no new choice was made.
|
|
128
|
+
*/
|
|
129
|
+
rememberAdded(): void;
|
|
110
130
|
/** Adds a copy of a row after it, without its id and with its radios off. Returns the copy's key. */
|
|
111
131
|
duplicate(key: string): string | null;
|
|
112
132
|
/**
|
|
@@ -138,6 +158,21 @@ export declare class RowsStore<TRow extends RowValue = RowValue> {
|
|
|
138
158
|
private normalizeRow;
|
|
139
159
|
private load;
|
|
140
160
|
private afterStructure;
|
|
161
|
+
/**
|
|
162
|
+
* Opens a new row's remembered inputs on their kept values, where the row is not added with one: the company's
|
|
163
|
+
* `preferred`, else the person's last choice, while the input offers it (`rememberedOpeningNow`, `when` and `per` read
|
|
164
|
+
* in the row). A kept choice no longer offered is forgotten. Answers the pickers that must be asked first.
|
|
165
|
+
*/
|
|
166
|
+
private openRemembered;
|
|
167
|
+
/** Stops asking pickers about remembered values: their answers no longer open anything. */
|
|
168
|
+
private stopAskingPickers;
|
|
169
|
+
/**
|
|
170
|
+
* Asks each picker whether it still offers the remembered value of a new row's input (`findRememberedOpening`), judged
|
|
171
|
+
* on the row as it was added: an answer forgets a choice no longer offered (under that row's `per` value), and opens an
|
|
172
|
+
* offered one while the row is there, the person has not picked in that cell since, and the row's values it was judged
|
|
173
|
+
* on (`rememberDependencies`) are unchanged.
|
|
174
|
+
*/
|
|
175
|
+
private askPickers;
|
|
141
176
|
/** Drops what the store knew about a removed row. */
|
|
142
177
|
private forget;
|
|
143
178
|
private write;
|