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,136 @@
|
|
|
1
|
+
import { type IconNode } from "nexus-icons";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { CrudBackend } from "../../Helpers/CrudBackend.ts";
|
|
4
|
+
import { type CrudJoinedRecord } from "../../Helpers/CrudHelpers.ts";
|
|
5
|
+
import type { ApiResult } from "../../Interfaces/ApiInterfaces.ts";
|
|
6
|
+
import type { CrudEndpoints, CrudFormOptions, CrudInputs, CrudMassActionName, CrudRowActionName } from "../../Interfaces/CrudInterfaces.ts";
|
|
7
|
+
import { type FormValues, type InputParams } from "../../Interfaces/FormInterfaces.ts";
|
|
8
|
+
/** The subject the endpoints were given: `apiController(module, "Guest", "guest")` → "guest". */
|
|
9
|
+
export declare function crudSubject(api: CrudEndpoints): string | undefined;
|
|
10
|
+
/** How the built-in actions on one record read wherever they are offered: a table's row, a tree's row, a panel's buttons. */
|
|
11
|
+
export declare const CRUD_ACTION_WORDS: {
|
|
12
|
+
readonly create: {
|
|
13
|
+
readonly label: "Add New";
|
|
14
|
+
readonly icon: IconNode;
|
|
15
|
+
};
|
|
16
|
+
readonly edit: {
|
|
17
|
+
readonly label: "Edit";
|
|
18
|
+
readonly icon: IconNode;
|
|
19
|
+
};
|
|
20
|
+
readonly history: {
|
|
21
|
+
readonly label: "History";
|
|
22
|
+
readonly icon: IconNode;
|
|
23
|
+
};
|
|
24
|
+
readonly trash: {
|
|
25
|
+
readonly label: "Move to trash";
|
|
26
|
+
readonly icon: IconNode;
|
|
27
|
+
readonly tone: "danger";
|
|
28
|
+
};
|
|
29
|
+
readonly recover: {
|
|
30
|
+
readonly label: "Recover";
|
|
31
|
+
readonly description: "Back to the active list";
|
|
32
|
+
readonly icon: IconNode;
|
|
33
|
+
};
|
|
34
|
+
readonly delete: {
|
|
35
|
+
readonly label: "Delete permanently";
|
|
36
|
+
readonly icon: IconNode;
|
|
37
|
+
readonly tone: "danger";
|
|
38
|
+
};
|
|
39
|
+
readonly trashFromDelete: {
|
|
40
|
+
readonly label: "Move back to trash";
|
|
41
|
+
readonly icon: IconNode;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/** An action's words as a menu entry or a button takes them: the label, the icon drawn, and its tone and second line. */
|
|
45
|
+
export declare function crudActionEntry(action: keyof typeof CRUD_ACTION_WORDS): {
|
|
46
|
+
label: string;
|
|
47
|
+
icon: ReactNode;
|
|
48
|
+
tone?: "danger";
|
|
49
|
+
description?: string;
|
|
50
|
+
};
|
|
51
|
+
/** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
|
|
52
|
+
export declare const CRUD_ASKED: ReadonlySet<CrudRowActionName | CrudMassActionName>;
|
|
53
|
+
/**
|
|
54
|
+
* The body of an Add, Edit or details popup. Without an `aside` it is the form (or the details) and nothing else, so
|
|
55
|
+
* a popup that wants no panel carries no wrapper at all. With one, the two become columns that scroll on their own,
|
|
56
|
+
* and the form is first in the DOM — which is also the order they stack in when there is no room for two. A details
|
|
57
|
+
* popup is marked, because there the panel is what the popup is for and takes the larger share.
|
|
58
|
+
*/
|
|
59
|
+
export declare function CrudFormBody({ aside, details, below, children }: {
|
|
60
|
+
aside: ReactNode;
|
|
61
|
+
details?: boolean;
|
|
62
|
+
below?: boolean;
|
|
63
|
+
children: ReactNode;
|
|
64
|
+
}): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
|
|
65
|
+
/** The inputs of the Add or the Edit form, from one list, a list each, or a function of the action and the record. */
|
|
66
|
+
export declare function crudInputsFor<TRow extends FormValues>(inputs: CrudInputs<TRow>, action: "create" | "edit", row: TRow | null): Promise<readonly InputParams[]>;
|
|
67
|
+
/** What the Add or Edit popup is opened with. */
|
|
68
|
+
export interface CrudFormRequest<TRow extends FormValues = FormValues> {
|
|
69
|
+
api: CrudEndpoints;
|
|
70
|
+
action: "create" | "edit";
|
|
71
|
+
/** The record edited; `null` for a new one. */
|
|
72
|
+
row: TRow | null;
|
|
73
|
+
/** Values over the new record's (`form.newValues`): the parent it is added under. */
|
|
74
|
+
values?: Partial<TRow>;
|
|
75
|
+
/**
|
|
76
|
+
* Inputs fixed for this opening, by name, each with why: read-only with the lock and the reason as its note, their
|
|
77
|
+
* values (from `values`, or the edited record) kept and sent (`lockInputs`), and never rewritten by the form's own
|
|
78
|
+
* `onFieldChange`. An Add with fixed inputs is a popup and a draft of its own per set of fixed values (`crudFormPlace`),
|
|
79
|
+
* so an Add started inside one parent never comes back inside another; an Edit stays one popup per record, its draft
|
|
80
|
+
* per set of fixed values.
|
|
81
|
+
*/
|
|
82
|
+
locked?: Readonly<Record<string, string>>;
|
|
83
|
+
/**
|
|
84
|
+
* Records the caller holds for the values it fixes - the row picked in another list that a new record is added for: a
|
|
85
|
+
* fixed dropdown names its value from them, with no lookup (`lockInputs`).
|
|
86
|
+
*/
|
|
87
|
+
known?: readonly FormValues[];
|
|
88
|
+
inputs: CrudInputs<TRow>;
|
|
89
|
+
form?: CrudFormOptions<TRow>;
|
|
90
|
+
/** What one record is: "region". */
|
|
91
|
+
subject: string;
|
|
92
|
+
/** Tells the popups of one controller apart from another's. */
|
|
93
|
+
name: string;
|
|
94
|
+
/** The field that tells records apart. Default `id`. */
|
|
95
|
+
rowKey?: string;
|
|
96
|
+
/** The record's key as text, for the Edit popup's key and its draft. */
|
|
97
|
+
key?: string;
|
|
98
|
+
/** Beside the title, and under it on the dock while minimized: the record an Edit is of, where an Add adds ("Inside Bagmati"). */
|
|
99
|
+
label?: string;
|
|
100
|
+
/**
|
|
101
|
+
* After the save: the answer, the values sent, and what the form's dropdowns know of the records their keys name
|
|
102
|
+
* (`joined`), so the record's joined names are written with no call. The popup closes itself.
|
|
103
|
+
*/
|
|
104
|
+
onSaved: (result: unknown, saved: TRow, joined: CrudJoinedLookup) => void;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The record behind a key of a form's dropdown (`name`), as a save names its joins (`crudJoinedNames`): the item the
|
|
108
|
+
* user picked for that key, else one of the dropdown's `selectedOptions` (given for a saved or a fixed value), else one
|
|
109
|
+
* the page holds (the dropdown's `peekOption`), else one of `held` (records the caller holds: a tree's parent, a narrowed
|
|
110
|
+
* list's picked record), else one the page held once and has marked old (`peekOption(key, true)`: an old name beats
|
|
111
|
+
* none). Its name is the dropdown's `labelKey` field, its status the record's `recordStatus`. `null` when none is known:
|
|
112
|
+
* nothing is asked for it.
|
|
113
|
+
*/
|
|
114
|
+
export type CrudJoinedLookup = (name: string, key: string, held?: readonly FormValues[]) => CrudJoinedRecord | null;
|
|
115
|
+
/**
|
|
116
|
+
* Opens the Add or the Edit popup of a controller's record: its inputs in an `ApiForm` that calls `create` or `update`,
|
|
117
|
+
* a discard question before changes are lost, and `onSaved` with the answer.
|
|
118
|
+
*
|
|
119
|
+
* - An Add form with changes comes back as it was - the one of the same place, when inputs are fixed (`locked`). Asking
|
|
120
|
+
* for one replaces every untouched Add of the controller, so the dock keeps only forms with work in them.
|
|
121
|
+
* - A record has one Edit popup wherever it was opened from. Asked for again with the same inputs fixed, it comes back;
|
|
122
|
+
* with others (a tree's fixed form, a data table's free one), an untouched one is replaced, and one with changes comes
|
|
123
|
+
* back as it is, saying why: an older form's save would undo what the newer one saved.
|
|
124
|
+
*/
|
|
125
|
+
export declare function openCrudForm<TRow extends FormValues>(request: CrudFormRequest<TRow>): Promise<void>;
|
|
126
|
+
/**
|
|
127
|
+
* Calls the endpoint of a built-in action on one record - asking first for those that move it between the lists - with
|
|
128
|
+
* the record's key in the path and its name in the messages. A flag carries the way it goes (`markOn`). `null` when the
|
|
129
|
+
* controller has no such endpoint or the question was answered No.
|
|
130
|
+
*/
|
|
131
|
+
export declare function sendCrudRowAction<TRow extends FormValues>(api: CrudEndpoints, action: CrudRowActionName, row: TRow, options: {
|
|
132
|
+
rowKey?: string;
|
|
133
|
+
label?: string;
|
|
134
|
+
backend: CrudBackend;
|
|
135
|
+
markOn?: boolean;
|
|
136
|
+
}): Promise<ApiResult | null>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { iconArrowBackUp, iconHistory, iconPencil, iconPlus, iconTrash, iconTrashX, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { unwrapApiResult } from "../../Helpers/ApiResponses.js";
|
|
5
|
+
import { crudFormPlace, crudMessageAction, crudRowEndpoint } from "../../Helpers/CrudHelpers.js";
|
|
6
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
|
+
import { FormStore } from "../../Helpers/FormStore.js";
|
|
8
|
+
import { lockedFieldChange, lockInputs } from "../../Helpers/InputParamsHelpers.js";
|
|
9
|
+
import { readPath } from "../../Helpers/TableHelpers.js";
|
|
10
|
+
import { EInputTypes } from "../../Interfaces/FormInterfaces.js";
|
|
11
|
+
import { api as browserApi } from "../../Services/BrowserApi.js";
|
|
12
|
+
import { openPopup } from "../Layouts/PopupHost.js";
|
|
13
|
+
import { popupRuleFor } from "../Layouts/PopupRules.js";
|
|
14
|
+
import { toast } from "../Layouts/Toaster.js";
|
|
15
|
+
import { ApiForm } from "./ApiForm.js";
|
|
16
|
+
import { confirmDiscard, FormButtons } from "./SubmitForm.js";
|
|
17
|
+
/*
|
|
18
|
+
* What every view of a controller's records does the same way - the data table (`Crud`) and the tree (`CrudTree`): the
|
|
19
|
+
* Add and Edit popups, the call behind an action on one record, and the words and icons each action shows. One copy, so
|
|
20
|
+
* a record added from a tree is added through exactly the form the table opens.
|
|
21
|
+
*/
|
|
22
|
+
/** The subject the endpoints were given: `apiController(module, "Guest", "guest")` → "guest". */
|
|
23
|
+
export function crudSubject(api) {
|
|
24
|
+
const endpoints = [api.create, api.update, api.details, ...Object.values(api.lists ?? {}), ...Object.values(api.pages ?? {})];
|
|
25
|
+
return endpoints.find(endpoint => endpoint?.subject)?.subject;
|
|
26
|
+
}
|
|
27
|
+
/** How the built-in actions on one record read wherever they are offered: a table's row, a tree's row, a panel's buttons. */
|
|
28
|
+
export const CRUD_ACTION_WORDS = {
|
|
29
|
+
// One fixed label on every CRUD, whatever its subject (the owner, 2026-10-10); the form it opens says "Add <subject>".
|
|
30
|
+
create: { label: "Add New", icon: iconPlus },
|
|
31
|
+
edit: { label: "Edit", icon: iconPencil },
|
|
32
|
+
history: { label: "History", icon: iconHistory },
|
|
33
|
+
trash: { label: "Move to trash", icon: iconTrash, tone: "danger" },
|
|
34
|
+
recover: { label: "Recover", description: "Back to the active list", icon: iconArrowBackUp },
|
|
35
|
+
delete: { label: "Delete permanently", icon: iconTrashX, tone: "danger" },
|
|
36
|
+
trashFromDelete: { label: "Move back to trash", icon: iconArrowBackUp },
|
|
37
|
+
};
|
|
38
|
+
/** An action's words as a menu entry or a button takes them: the label, the icon drawn, and its tone and second line. */
|
|
39
|
+
export function crudActionEntry(action) {
|
|
40
|
+
const words = CRUD_ACTION_WORDS[action];
|
|
41
|
+
return { label: words.label, icon: _jsx(InlineIcon, { icon: words.icon }), tone: words.tone, description: words.description };
|
|
42
|
+
}
|
|
43
|
+
/** Actions that move a record between the lists are asked about first; flags and pins are undone by a click. */
|
|
44
|
+
export const CRUD_ASKED = new Set(["trash", "recover", "delete", "trashFromDelete"]);
|
|
45
|
+
/**
|
|
46
|
+
* The body of an Add, Edit or details popup. Without an `aside` it is the form (or the details) and nothing else, so
|
|
47
|
+
* a popup that wants no panel carries no wrapper at all. With one, the two become columns that scroll on their own,
|
|
48
|
+
* and the form is first in the DOM — which is also the order they stack in when there is no room for two. A details
|
|
49
|
+
* popup is marked, because there the panel is what the popup is for and takes the larger share.
|
|
50
|
+
*/
|
|
51
|
+
export function CrudFormBody({ aside, details, below, children }) {
|
|
52
|
+
if (!aside)
|
|
53
|
+
return children;
|
|
54
|
+
return (_jsxs("div", { className: cx("nx-crud-aside", details && "nx-crud-aside--details", below && "nx-crud-aside--below"), children: [_jsx("div", { className: "nx-crud-aside__main", children: children }), _jsx("aside", { className: "nx-crud-aside__side", children: aside })] }));
|
|
55
|
+
}
|
|
56
|
+
/** The inputs of the Add or the Edit form, from one list, a list each, or a function of the action and the record. */
|
|
57
|
+
export async function crudInputsFor(inputs, action, row) {
|
|
58
|
+
if (typeof inputs === "function")
|
|
59
|
+
return inputs(action, row);
|
|
60
|
+
if (Array.isArray(inputs))
|
|
61
|
+
return inputs;
|
|
62
|
+
const split = inputs;
|
|
63
|
+
return (action === "create" ? (split.create ?? split.edit) : (split.edit ?? split.create)) ?? [];
|
|
64
|
+
}
|
|
65
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
const capitalize = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
67
|
+
/** `CrudJoinedLookup` over a form's inputs and its store. */
|
|
68
|
+
function joinedLookup(inputs, form) {
|
|
69
|
+
return (name, key, held = []) => {
|
|
70
|
+
const params = inputs.find(input => input.name === name);
|
|
71
|
+
if (!params || params.type !== EInputTypes.Dropdown || params.multiple)
|
|
72
|
+
return null;
|
|
73
|
+
const valueKey = params.valueKey ?? "value";
|
|
74
|
+
const labelKey = params.labelKey ?? "label";
|
|
75
|
+
const matches = (item) => isRecord(item) && String(item[valueKey]) === key;
|
|
76
|
+
// The form's own first: what the user picked, then the records the input was given for its saved value.
|
|
77
|
+
const record = [form.pickedItem(name), ...(params.selectedOptions ?? []), params.peekOption?.(key), ...held].find(matches) ?? [params.peekOption?.(key, true)].find(matches);
|
|
78
|
+
return record ? { name: record[labelKey], status: record.recordStatus } : null;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** The Add and Edit popups open now, by popup key: the controller's name, the action, the form, and the place it was opened for. */
|
|
82
|
+
const openForms = new Map();
|
|
83
|
+
/** Brings a popup back from the dock; one on screen is left as it is. */
|
|
84
|
+
function bringBack(handle) {
|
|
85
|
+
if (handle.windowState === "minimized")
|
|
86
|
+
handle.restore();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Opens the Add or the Edit popup of a controller's record: its inputs in an `ApiForm` that calls `create` or `update`,
|
|
90
|
+
* a discard question before changes are lost, and `onSaved` with the answer.
|
|
91
|
+
*
|
|
92
|
+
* - An Add form with changes comes back as it was - the one of the same place, when inputs are fixed (`locked`). Asking
|
|
93
|
+
* for one replaces every untouched Add of the controller, so the dock keeps only forms with work in them.
|
|
94
|
+
* - A record has one Edit popup wherever it was opened from. Asked for again with the same inputs fixed, it comes back;
|
|
95
|
+
* with others (a tree's fixed form, a data table's free one), an untouched one is replaced, and one with changes comes
|
|
96
|
+
* back as it is, saying why: an older form's save would undo what the newer one saved.
|
|
97
|
+
*/
|
|
98
|
+
export async function openCrudForm(request) {
|
|
99
|
+
const { api, action, row, values, locked, subject, name, rowKey = "id", key = "", label, onSaved } = request;
|
|
100
|
+
const endpoint = action === "create" ? api.create : api.update;
|
|
101
|
+
if (!endpoint)
|
|
102
|
+
return;
|
|
103
|
+
const options = request.form ?? {};
|
|
104
|
+
const newValues = typeof options.newValues === "function" ? options.newValues() : options.newValues;
|
|
105
|
+
// What the form opens with: a new record's values, or the record edited.
|
|
106
|
+
const opening = (action === "create" ? { ...newValues, ...values } : (row ?? {}));
|
|
107
|
+
// Fixed inputs make it the form of one place: an Add's popup and every draft carry the values fixed.
|
|
108
|
+
const place = locked ? crudFormPlace(Object.fromEntries(Object.keys(locked).map(field => [field, opening[field]]))) : "";
|
|
109
|
+
const popupKey = action === "create" ? `${name}-new${place}` : `${name}-edit-${key}`;
|
|
110
|
+
const open = openForms.get(popupKey);
|
|
111
|
+
if (open && (open.form.isChanged() || (action === "edit" && open.place === place))) {
|
|
112
|
+
bringBack(open.handle);
|
|
113
|
+
if (open.place !== place)
|
|
114
|
+
toast.info(`${label ?? capitalize(subject)} is already being edited`, "Its form is open with changes: save or discard them first.");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (action === "create") {
|
|
118
|
+
for (const [other, entry] of openForms)
|
|
119
|
+
if (other !== popupKey && entry.name === name && entry.action === "create" && !entry.form.isChanged())
|
|
120
|
+
entry.handle.close("replaced");
|
|
121
|
+
}
|
|
122
|
+
const given = await crudInputsFor(request.inputs, action, row);
|
|
123
|
+
const inputs = locked ? lockInputs(given, locked, opening, request.known) : given;
|
|
124
|
+
// The inputs lockInputs fixed - a copy each - are out of reach of the form's own rules too.
|
|
125
|
+
const onFieldChange = lockedFieldChange(options.onFieldChange, inputs.filter((params, index) => params !== given[index]).map(params => params.name));
|
|
126
|
+
const form = new FormStore();
|
|
127
|
+
const loader = options.load;
|
|
128
|
+
const id = row ? readPath(row, rowKey) : undefined;
|
|
129
|
+
const loadValue = action === "edit" && row && loader && loader !== "row"
|
|
130
|
+
? loader === "details"
|
|
131
|
+
? api.details
|
|
132
|
+
? (signal) => browserApi.get(api.details, { params: { id, [rowKey]: id }, signal, toast: false }).then(answer => ({ ...row, ...unwrapApiResult(answer) }))
|
|
133
|
+
: undefined
|
|
134
|
+
: (signal) => loader(row, signal).then(full => ({ ...row, ...full }))
|
|
135
|
+
: undefined;
|
|
136
|
+
const handle = openPopup({
|
|
137
|
+
key: popupKey,
|
|
138
|
+
// One still open under this key was untouched, or opened for another place: a fresh form takes its place.
|
|
139
|
+
reopen: "replace",
|
|
140
|
+
title: action === "create" ? `Add ${subject}` : `Edit ${subject}`,
|
|
141
|
+
description: label,
|
|
142
|
+
icon: _jsx(InlineIcon, { icon: action === "create" ? iconPlus : iconPencil }),
|
|
143
|
+
size: options.size ?? (inputs.length > 8 ? "lg" : "md"),
|
|
144
|
+
closeOnBackdrop: false,
|
|
145
|
+
// A CRUD page's Add and Edit are the popups that can be put aside on the dock; a page's rule for the key may say no.
|
|
146
|
+
minimizable: popupRuleFor(popupKey)?.minimizable ?? true,
|
|
147
|
+
beforeClose: async () => {
|
|
148
|
+
const discard = await confirmDiscard(form);
|
|
149
|
+
if (discard)
|
|
150
|
+
form.dropDraft();
|
|
151
|
+
return discard;
|
|
152
|
+
},
|
|
153
|
+
content: popup => (_jsx(CrudFormBody, { aside: typeof options.aside === "function" ? options.aside({ form, action, row }) : options.aside, children: _jsx(ApiForm, { form: form, inputs: inputs, buttons: false, defaultValue: (action === "create" ? opening : (row ?? undefined)), loadValue: loadValue, api: endpoint, transform: options.transform ? formValues => options.transform(formValues, { action, row }) : undefined, confirm: options.confirm, validate: options.validate, onFieldChange: onFieldChange, design: options.design, draftKey: options.draftKey ? `${options.draftKey}:${name}:${action === "create" ? "new" : key}${place}` : undefined, "aria-label": action === "create" ? `Add ${subject}` : `Edit ${label}`, onSuccess: (result, saved) => {
|
|
154
|
+
onSaved(result, saved, joinedLookup(inputs, form));
|
|
155
|
+
popup.close();
|
|
156
|
+
} }) })),
|
|
157
|
+
footer: popup => _jsx(FormButtons, { form: form, submitLabel: action === "create" ? `Add ${subject}` : "Save", onCancel: () => popup.close() }),
|
|
158
|
+
});
|
|
159
|
+
const entry = { name, action, handle, form: form, place };
|
|
160
|
+
openForms.set(popupKey, entry);
|
|
161
|
+
void handle.closed.then(() => {
|
|
162
|
+
if (openForms.get(popupKey) === entry)
|
|
163
|
+
openForms.delete(popupKey);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Calls the endpoint of a built-in action on one record - asking first for those that move it between the lists - with
|
|
168
|
+
* the record's key in the path and its name in the messages. A flag carries the way it goes (`markOn`). `null` when the
|
|
169
|
+
* controller has no such endpoint or the question was answered No.
|
|
170
|
+
*/
|
|
171
|
+
export async function sendCrudRowAction(api, action, row, options) {
|
|
172
|
+
const endpoint = crudRowEndpoint(api, action);
|
|
173
|
+
if (!endpoint)
|
|
174
|
+
return null;
|
|
175
|
+
const rowKey = options.rowKey ?? "id";
|
|
176
|
+
const key = readPath(row, rowKey);
|
|
177
|
+
const call = {
|
|
178
|
+
params: { id: key, [rowKey]: key },
|
|
179
|
+
subject: options.label,
|
|
180
|
+
action: crudMessageAction(action, options.markOn),
|
|
181
|
+
...(options.markOn !== undefined ? options.backend.flagRequest(options.markOn) : {}),
|
|
182
|
+
};
|
|
183
|
+
return CRUD_ASKED.has(action) ? browserApi.confirm(endpoint, call) : browserApi.send(endpoint, call);
|
|
184
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type CrudBackend } from "../../Helpers/CrudBackend.ts";
|
|
2
|
+
import type { ApiEndpoint } from "../../Interfaces/ApiInterfaces.ts";
|
|
3
|
+
/** One column of a change, as the backend's history answers it (`AuditChangeResponseContract`). */
|
|
4
|
+
export interface AuditChange {
|
|
5
|
+
/** The property: `RegionName`. */
|
|
6
|
+
column: string;
|
|
7
|
+
/** What a person calls it: "Region name". */
|
|
8
|
+
label: string;
|
|
9
|
+
/** How the value reads, as the backend's `EAuditValueTypes`: 0 text, 1 number, 2 yes or no, 3 date, 4 date and time, 5 enum, 6 id. */
|
|
10
|
+
type: number;
|
|
11
|
+
/** Before, as text. Absent when this change set it. */
|
|
12
|
+
oldValue?: string | null;
|
|
13
|
+
/** After, as text. Absent when this change cleared it. */
|
|
14
|
+
newValue?: string | null;
|
|
15
|
+
/** The words for an enum's or an id's value before, when the history kept them: RecordStatus 40 is "Trash". */
|
|
16
|
+
oldLabel?: string | null;
|
|
17
|
+
/** The words for the value after. */
|
|
18
|
+
newLabel?: string | null;
|
|
19
|
+
}
|
|
20
|
+
/** One entry of a record's history (`AuditResponseContract`). Every id in it is a token made for the reader. */
|
|
21
|
+
export interface AuditEntry {
|
|
22
|
+
/** When, in UTC. Every entry of one mass call shares it. */
|
|
23
|
+
creationTime: string;
|
|
24
|
+
/** Who made the change. */
|
|
25
|
+
creatorUserId?: string;
|
|
26
|
+
/** The company they made it in. */
|
|
27
|
+
companyId?: string;
|
|
28
|
+
/** The branch they made it in. */
|
|
29
|
+
branchId?: string;
|
|
30
|
+
/** What the request did, as the backend numbers its commands. */
|
|
31
|
+
commandType: number;
|
|
32
|
+
/** The part of the record it was about, as the backend numbers its audit keys. */
|
|
33
|
+
auditKey: number;
|
|
34
|
+
/** 0 for one record; else how many records one mass call changed, which every entry of that call shares. */
|
|
35
|
+
massCount: number;
|
|
36
|
+
/** What moved, column by column. Empty for a request that found the record already as it asked. */
|
|
37
|
+
changes?: AuditChange[];
|
|
38
|
+
}
|
|
39
|
+
export interface AuditHistoryProps {
|
|
40
|
+
/** The model's history endpoint: GET `{Model}/AuditListing/{ownerId}/{skipCount}`, as `apiController(...).audit` builds it. */
|
|
41
|
+
endpoint: ApiEndpoint;
|
|
42
|
+
/** The record's key, as the backend handed it out. */
|
|
43
|
+
id: string | number;
|
|
44
|
+
/** What one record is, for the words: "region". Default "record". */
|
|
45
|
+
subject?: string;
|
|
46
|
+
/** Its plural, when adding "s" is wrong. */
|
|
47
|
+
subjectPlural?: string;
|
|
48
|
+
/** The backend whose numbers the entries carry. Default the app's (`getCrudBackend`). */
|
|
49
|
+
backend?: CrudBackend;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A record's history, newest first, 50 entries at a time: "Show more" while the last page came back full. Each entry
|
|
53
|
+
* says what was done, when, by whom and how many records it changed at once, and lists the columns it changed with
|
|
54
|
+
* their values before and after - the words the backend kept for an enum or an id where it has them.
|
|
55
|
+
*/
|
|
56
|
+
export declare function AuditHistory({ endpoint, id, subject, subjectPlural, backend }: AuditHistoryProps): import("react").JSX.Element;
|
|
57
|
+
/** What `openAuditHistory` takes: the history's own props, and the record's name for the title bar. */
|
|
58
|
+
export interface AuditHistoryPopupOptions extends AuditHistoryProps {
|
|
59
|
+
/** Names the record beside the title: "Asia". */
|
|
60
|
+
label?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Opens a record's history in a popup, "Region history" with the record's name beside it. One popup per record:
|
|
64
|
+
* opening it again brings back the one that is open, with what it has loaded.
|
|
65
|
+
*/
|
|
66
|
+
export declare function openAuditHistory({ label, ...props }: AuditHistoryPopupOptions): import("../Layouts/PopupHost.tsx").PopupHandle;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { iconAlertCircle, iconHistory, iconInfoCircle, iconLoader2, iconRefresh, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { getCrudBackend } from "../../Helpers/CrudBackend.js";
|
|
6
|
+
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
7
|
+
import { pluralize } from "../../Helpers/MessageBuilder.js";
|
|
8
|
+
import { formatCellText } from "../../Helpers/TableHelpers.js";
|
|
9
|
+
import { api } from "../../Services/BrowserApi.js";
|
|
10
|
+
import { Button } from "../Documents/Button.js";
|
|
11
|
+
import { openPopup } from "../Layouts/PopupHost.js";
|
|
12
|
+
/** How many entries one call answers (the backend's `AUDIT_PAGE_SIZE`): a page that came back full may have more behind it. */
|
|
13
|
+
const PAGE_SIZE = 50;
|
|
14
|
+
/** What an entry did, in words: "Added", "Updated: basic information", "Flagged", "Moved to the trash". */
|
|
15
|
+
function whatOf(entry, words) {
|
|
16
|
+
const direction = words.directions[entry.auditKey];
|
|
17
|
+
if (direction)
|
|
18
|
+
return direction;
|
|
19
|
+
const command = words.commands[entry.commandType] ?? `Action ${entry.commandType}`;
|
|
20
|
+
const part = entry.commandType === words.update ? words.parts[entry.auditKey] : undefined;
|
|
21
|
+
return part ? `${command}: ${part}` : command;
|
|
22
|
+
}
|
|
23
|
+
/** How each `EAuditValueTypes` is written: as a table column of that format writes it. Text, enums and ids are left as they are. */
|
|
24
|
+
const FORMATS = { 1: "number", 2: "boolean", 3: "date", 4: "date-time" };
|
|
25
|
+
/** `EAuditValueTypes.Id`: a record's key, handed out as a token made for the reader. */
|
|
26
|
+
const ID = 6;
|
|
27
|
+
/** When an entry happened, written as the table writes a date-time column: the user's settings, in their zone. */
|
|
28
|
+
const WHEN = { key: "creationTime", label: "When", format: "date-time" };
|
|
29
|
+
const NEWLINE = String.fromCharCode(10);
|
|
30
|
+
/**
|
|
31
|
+
* A token, short. It names nobody and only tells two apart, so its first characters are enough. A token is the same
|
|
32
|
+
* text for one id on every entry this reader is given (the id is encrypted for the reader, not at random).
|
|
33
|
+
*/
|
|
34
|
+
const shortToken = (token) => (token.length > 8 ? `${token.slice(0, 6)}…` : token);
|
|
35
|
+
/** One side of a change, in words; "" when that side has no value. */
|
|
36
|
+
function sideText(change, value, label, options) {
|
|
37
|
+
if (label)
|
|
38
|
+
return label;
|
|
39
|
+
if (value === null || value === undefined || value === "")
|
|
40
|
+
return "";
|
|
41
|
+
// An id the history kept no name for: its token, which is all there is to show.
|
|
42
|
+
if (change.type === ID)
|
|
43
|
+
return `#${shortToken(value)}`;
|
|
44
|
+
return formatCellText({ key: change.column, label: change.label, format: FORMATS[change.type] ?? "text" }, value, options);
|
|
45
|
+
}
|
|
46
|
+
/** One side of a change as a cell: its words, a line of text to a line, or what having no value there means. */
|
|
47
|
+
function SideCell({ text, missing }) {
|
|
48
|
+
if (!text)
|
|
49
|
+
return (_jsx("td", { children: _jsx("span", { className: "nx-markdown__empty", children: missing }) }));
|
|
50
|
+
return (_jsx("td", { children: text.split(NEWLINE).map((line, index) => (_jsxs(Fragment, { children: [index > 0 && _jsx("br", {}), line] }, index))) }));
|
|
51
|
+
}
|
|
52
|
+
/** One entry: what was done as a heading, when and by whom under it, and the columns it changed as a small table. */
|
|
53
|
+
function HistoryEntry({ entry, words, subject, plural, options }) {
|
|
54
|
+
const changes = entry.changes ?? [];
|
|
55
|
+
const count = entry.massCount;
|
|
56
|
+
return (_jsxs(_Fragment, { children: [_jsx("h3", { children: whatOf(entry, words) }), _jsxs("p", { children: [_jsx("time", { dateTime: entry.creationTime, children: formatCellText(WHEN, entry.creationTime, options) }), entry.creatorUserId && ` · User ${shortToken(entry.creatorUserId)}`, count > 1 && ` · ${count} ${plural} at once`, count === 1 && " · from a selection"] }), changes.length > 0 ? (_jsx("div", { className: "nx-markdown__table", children: _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Field" }), _jsx("th", { scope: "col", children: "Before" }), _jsx("th", { scope: "col", children: "After" })] }) }), _jsx("tbody", { children: changes.map((change, index) => (_jsxs("tr", { children: [_jsx("td", { children: change.label || change.column }), _jsx(SideCell, { text: sideText(change, change.oldValue, change.oldLabel, options), missing: "Not set" }), _jsx(SideCell, { text: sideText(change, change.newValue, change.newLabel, options), missing: "Cleared" })] }, `${change.column}:${index}`))) })] }) })) : (_jsxs("p", { className: "nx-markdown__empty", children: ["Nothing changed: the ", subject, " was already as asked."] }))] }));
|
|
57
|
+
}
|
|
58
|
+
/** One page of a record's history, newest first: the entries after the first `skipCount`. */
|
|
59
|
+
function readHistory(endpoint, id, skipCount, options = {}) {
|
|
60
|
+
// `apiController`'s own `audit` endpoint names the key `{ownerId}`, and one a page writes itself may say `{id}`: both
|
|
61
|
+
// are given, and the path takes the one it has.
|
|
62
|
+
return api.get(endpoint, { params: { id, ownerId: id, skipCount }, subject: "the history", ...options });
|
|
63
|
+
}
|
|
64
|
+
/** A status line: an icon and its words, in the form's muted line. */
|
|
65
|
+
function Note({ icon, children, alert }) {
|
|
66
|
+
return (_jsxs("p", { className: "nx-form-status", role: alert ? "alert" : "status", children: [_jsx(InlineIcon, { icon: icon, className: icon === iconLoader2 ? "nx-spin" : undefined }), _jsx("span", { children: children })] }));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A record's history, newest first, 50 entries at a time: "Show more" while the last page came back full. Each entry
|
|
70
|
+
* says what was done, when, by whom and how many records it changed at once, and lists the columns it changed with
|
|
71
|
+
* their values before and after - the words the backend kept for an enum or an id where it has them.
|
|
72
|
+
*/
|
|
73
|
+
export function AuditHistory({ endpoint, id, subject = "record", subjectPlural, backend }) {
|
|
74
|
+
const plural = subjectPlural ?? pluralize(subject);
|
|
75
|
+
const words = (backend ?? getCrudBackend()).auditWords;
|
|
76
|
+
const preferences = useDatePreferences();
|
|
77
|
+
const timeZone = useBrowserTimeZone();
|
|
78
|
+
const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined }), [preferences, timeZone]);
|
|
79
|
+
const [entries, setEntries] = useState([]);
|
|
80
|
+
const [status, setStatus] = useState("loading");
|
|
81
|
+
const [problem, setProblem] = useState();
|
|
82
|
+
const [more, setMore] = useState(false);
|
|
83
|
+
const [busy, setBusy] = useState(false);
|
|
84
|
+
const [attempt, setAttempt] = useState(0);
|
|
85
|
+
// The first page. Its failure is said here, with a way to try again, rather than as a toast over the popup.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
setStatus("loading");
|
|
89
|
+
void readHistory(endpoint, id, 0, { signal: controller.signal, toast: false }).then(answer => {
|
|
90
|
+
if (answer.aborted)
|
|
91
|
+
return;
|
|
92
|
+
if (!answer.ok) {
|
|
93
|
+
setProblem(answer.message);
|
|
94
|
+
setStatus("failed");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const page = Array.isArray(answer.result) ? answer.result : [];
|
|
98
|
+
setEntries(page);
|
|
99
|
+
setMore(page.length >= PAGE_SIZE);
|
|
100
|
+
setStatus("ready");
|
|
101
|
+
});
|
|
102
|
+
return () => controller.abort();
|
|
103
|
+
}, [endpoint, id, attempt]);
|
|
104
|
+
/** The next page, after the entries already shown. A failure is the api's own toast, and the button stays to try again. */
|
|
105
|
+
async function showMore() {
|
|
106
|
+
setBusy(true);
|
|
107
|
+
const answer = await readHistory(endpoint, id, entries.length);
|
|
108
|
+
setBusy(false);
|
|
109
|
+
if (!answer.ok)
|
|
110
|
+
return;
|
|
111
|
+
const page = Array.isArray(answer.result) ? answer.result : [];
|
|
112
|
+
setEntries(current => [...current, ...page]);
|
|
113
|
+
setMore(page.length >= PAGE_SIZE);
|
|
114
|
+
}
|
|
115
|
+
if (status === "loading")
|
|
116
|
+
return _jsx(Note, { icon: iconLoader2, children: "Loading the history\u2026" });
|
|
117
|
+
if (status === "failed") {
|
|
118
|
+
return (_jsxs("div", { className: "nx-crud-details", children: [_jsx(Note, { icon: iconAlertCircle, alert: true, children: problem ?? "The history could not be loaded." }), _jsx("div", { children: _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh }), onClick: () => setAttempt(count => count + 1), children: "Try again" }) })] }));
|
|
119
|
+
}
|
|
120
|
+
if (entries.length === 0)
|
|
121
|
+
return _jsx(Note, { icon: iconInfoCircle, children: `Nothing has been recorded for this ${subject} yet.` });
|
|
122
|
+
return (_jsxs("div", { className: "nx-markdown", children: [entries.map((entry, index) => (_jsx(HistoryEntry, { entry: entry, words: words, subject: subject, plural: plural, options: options }, index))), more && (_jsx("p", { children: _jsx(Button, { size: "sm", loading: busy, onClick: () => void showMore(), children: "Show more" }) }))] }));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Opens a record's history in a popup, "Region history" with the record's name beside it. One popup per record:
|
|
126
|
+
* opening it again brings back the one that is open, with what it has loaded.
|
|
127
|
+
*/
|
|
128
|
+
export function openAuditHistory({ label, ...props }) {
|
|
129
|
+
const subject = props.subject ?? "record";
|
|
130
|
+
return openPopup({
|
|
131
|
+
key: `${subject}-history-${props.id}`,
|
|
132
|
+
reopen: "restore",
|
|
133
|
+
title: `${subject.charAt(0).toUpperCase()}${subject.slice(1)} history`,
|
|
134
|
+
description: label,
|
|
135
|
+
icon: _jsx(InlineIcon, { icon: iconHistory }),
|
|
136
|
+
size: "lg",
|
|
137
|
+
content: _jsx(AuditHistory, { ...props }),
|
|
138
|
+
footer: popup => (_jsx(Button, { variant: "primary", onClick: () => popup.close(), children: "Close" })),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { CrudNarrow, CrudNarrowPicker } from "../../Interfaces/CrudInterfaces.ts";
|
|
3
|
+
/** What a narrowed list says while nothing is picked, and on its Add: the box's reason, read with it. */
|
|
4
|
+
export interface CrudNarrowPickerWords {
|
|
5
|
+
empty: string;
|
|
6
|
+
add: string;
|
|
7
|
+
}
|
|
8
|
+
/** The narrowing a `narrowPicker` gives its CRUD, the parts it draws, and what the CRUD's Add and list do with it. */
|
|
9
|
+
export interface CrudNarrowPickerParts {
|
|
10
|
+
/** The CRUD's `narrow`, the picked record in it; nothing without a `narrowPicker`. */
|
|
11
|
+
narrow: CrudNarrow | undefined;
|
|
12
|
+
/** The box, for the toolbar. */
|
|
13
|
+
box: ReactNode;
|
|
14
|
+
/** The list's button to the box, for its empty message while the box asks for a pick; null otherwise. */
|
|
15
|
+
pickButton: ReactNode;
|
|
16
|
+
/** The box is settling as the page opens (a kept pick looked up, its records counted for a lone one): the list waits. */
|
|
17
|
+
waiting: boolean;
|
|
18
|
+
/** Nothing is picked and the box has settled: it asks for a pick. */
|
|
19
|
+
wanted: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Add pressed with nothing picked: the cursor goes to the box, the box is nudged (still, with reduced motion) and a
|
|
22
|
+
* screen reader hears Add's reason with it. False while the box cannot take it (settling, or not drawn).
|
|
23
|
+
*/
|
|
24
|
+
askForAdd: () => boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The narrowing a `narrowPicker` gives its CRUD, and the box it draws in the toolbar. Without one: nothing, and the
|
|
28
|
+
* CRUD's own `narrow` stands. `words` are the list's: the box's reason while it asks for a pick, and Add's after a press
|
|
29
|
+
* (default: the box's own words for "records").
|
|
30
|
+
*/
|
|
31
|
+
export declare function useCrudNarrowPicker(options: CrudNarrowPicker | undefined, words?: CrudNarrowPickerWords): CrudNarrowPickerParts;
|