nexus-shared 3.0.0 → 3.2.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/README.md +1123 -1000
  3. package/dist/Client.Index.d.ts +6 -0
  4. package/dist/Client.Index.js +6 -0
  5. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  6. package/dist/Components/Chats/ChatParts.js +3 -7
  7. package/dist/Components/Chats/ChatThread.js +3 -3
  8. package/dist/Components/Documents/Button.d.ts +6 -1
  9. package/dist/Components/Documents/Button.js +2 -2
  10. package/dist/Components/Documents/Menu.js +21 -9
  11. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  12. package/dist/Components/Documents/SplitButton.js +7 -5
  13. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  14. package/dist/Components/Documents/TabButtons.js +2 -2
  15. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  16. package/dist/Components/Forms/ApiForm.js +1 -1
  17. package/dist/Components/Forms/Crud.js +486 -206
  18. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  19. package/dist/Components/Forms/CrudHistory.js +140 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  21. package/dist/Components/Forms/CrudParts.js +38 -5
  22. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  23. package/dist/Components/Forms/ExcelImport.js +189 -60
  24. package/dist/Components/Forms/Form.js +9 -14
  25. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  26. package/dist/Components/Forms/SubmitForm.js +23 -12
  27. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  28. package/dist/Components/Inputs/CodeBox.js +316 -0
  29. package/dist/Components/Inputs/DatePicker.js +4 -1
  30. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  31. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  32. package/dist/Components/Inputs/Dropdown.js +4 -1
  33. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  34. package/dist/Components/Inputs/DropdownParts.js +4 -6
  35. package/dist/Components/Inputs/InputRenderer.js +3 -0
  36. package/dist/Components/Inputs/NumberBox.js +26 -2
  37. package/dist/Components/Inputs/RowsInput.js +2 -2
  38. package/dist/Components/Inputs/Slider.js +4 -1
  39. package/dist/Components/Inputs/TabularForm.js +42 -10
  40. package/dist/Components/Inputs/TextArea.js +4 -1
  41. package/dist/Components/Inputs/TextBox.js +4 -1
  42. package/dist/Components/Inputs/TimePanel.js +4 -3
  43. package/dist/Components/Inputs/TimePicker.js +4 -1
  44. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  45. package/dist/Components/Inputs/TreeView.js +372 -23
  46. package/dist/Components/Layouts/Popup.js +1 -4
  47. package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
  48. package/dist/Components/Layouts/ThemePicker.js +89 -6
  49. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  50. package/dist/Components/Layouts/ThemeScript.js +4 -2
  51. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  52. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  53. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  54. package/dist/Components/Viewers/Avatar.js +21 -0
  55. package/dist/Components/Viewers/DataTable.js +252 -62
  56. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  57. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  58. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  59. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  60. package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
  61. package/dist/Components/Viewers/DataTableParts.js +42 -13
  62. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  63. package/dist/Helpers/AnimationHelpers.js +20 -2
  64. package/dist/Helpers/ApiModules.d.ts +3 -34
  65. package/dist/Helpers/ApiModules.js +3 -3
  66. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  67. package/dist/Helpers/ApiRoutes.js +31 -27
  68. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  69. package/dist/Helpers/ArrayHelpers.js +2 -0
  70. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  71. package/dist/Helpers/CodeHelpers.js +332 -0
  72. package/dist/Helpers/CrudBackend.d.ts +101 -8
  73. package/dist/Helpers/CrudBackend.js +172 -11
  74. package/dist/Helpers/CrudColumns.d.ts +51 -0
  75. package/dist/Helpers/CrudColumns.js +69 -0
  76. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  77. package/dist/Helpers/CrudHelpers.js +109 -12
  78. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  79. package/dist/Helpers/ExcelBackend.js +8 -2
  80. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  81. package/dist/Helpers/ExcelHelpers.js +55 -13
  82. package/dist/Helpers/FormStore.d.ts +9 -5
  83. package/dist/Helpers/FormStore.js +25 -5
  84. package/dist/Helpers/InputParamsHelpers.js +8 -0
  85. package/dist/Helpers/MessageBuilder.js +3 -0
  86. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  87. package/dist/Helpers/NexusHelpers.js +1 -0
  88. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  89. package/dist/Helpers/NumberHelpers.js +41 -3
  90. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  91. package/dist/Helpers/PopoverHelpers.js +52 -17
  92. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  93. package/dist/Helpers/PopoverHooks.js +62 -0
  94. package/dist/Helpers/RequestCache.d.ts +35 -0
  95. package/dist/Helpers/RequestCache.js +113 -0
  96. package/dist/Helpers/RowsStore.d.ts +7 -2
  97. package/dist/Helpers/RowsStore.js +15 -3
  98. package/dist/Helpers/TableFilters.d.ts +25 -0
  99. package/dist/Helpers/TableFilters.js +162 -0
  100. package/dist/Helpers/TableHelpers.d.ts +7 -4
  101. package/dist/Helpers/TableHelpers.js +15 -6
  102. package/dist/Helpers/TableJoins.d.ts +70 -0
  103. package/dist/Helpers/TableJoins.js +250 -0
  104. package/dist/Helpers/TableStore.d.ts +16 -3
  105. package/dist/Helpers/TableStore.js +25 -5
  106. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  107. package/dist/Helpers/ThemeHelpers.js +46 -9
  108. package/dist/Helpers/TreeStore.d.ts +225 -11
  109. package/dist/Helpers/TreeStore.js +683 -90
  110. package/dist/Helpers/UserPreferences.d.ts +22 -0
  111. package/dist/Helpers/UserPreferences.js +116 -0
  112. package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
  113. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  114. package/dist/Interfaces/FormInterfaces.d.ts +37 -6
  115. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  116. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  117. package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
  118. package/dist/Interfaces/PreferenceInterfaces.js +25 -0
  119. package/dist/Interfaces/TableInterfaces.d.ts +145 -16
  120. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  121. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  122. package/dist/Services/ApiProxy.d.ts +36 -1
  123. package/dist/Services/ApiProxy.js +182 -52
  124. package/dist/Services/ApiProxyRules.d.ts +203 -0
  125. package/dist/Services/ApiProxyRules.js +426 -0
  126. package/dist/Services/ExcelApi.d.ts +3 -1
  127. package/dist/Services/ExcelApi.js +1 -1
  128. package/dist/Services/JoinSources.d.ts +48 -0
  129. package/dist/Services/JoinSources.js +74 -0
  130. package/dist/Services/ServerApi.d.ts +13 -2
  131. package/dist/Services/ServerApi.js +8 -3
  132. package/dist/Services/ThemeService.d.ts +18 -2
  133. package/dist/Services/ThemeService.js +80 -16
  134. package/dist/Shared.Index.d.ts +6 -0
  135. package/dist/Shared.Index.js +6 -0
  136. package/package.json +4 -2
  137. package/src/Styles/Nexus.Avatar.css +79 -0
  138. package/src/Styles/Nexus.Base.css +51 -4
  139. package/src/Styles/Nexus.Button.css +27 -0
  140. package/src/Styles/Nexus.Calendar.css +4 -6
  141. package/src/Styles/Nexus.Chat.css +5 -51
  142. package/src/Styles/Nexus.Choice.css +4 -6
  143. package/src/Styles/Nexus.Crud.css +16 -9
  144. package/src/Styles/Nexus.Dropdown.css +12 -61
  145. package/src/Styles/Nexus.Editor.css +4 -6
  146. package/src/Styles/Nexus.Excel.css +34 -15
  147. package/src/Styles/Nexus.Form.css +25 -67
  148. package/src/Styles/Nexus.Index.css +1 -0
  149. package/src/Styles/Nexus.Input.css +794 -637
  150. package/src/Styles/Nexus.Markdown.css +0 -28
  151. package/src/Styles/Nexus.Menu.css +8 -30
  152. package/src/Styles/Nexus.Popup.css +22 -29
  153. package/src/Styles/Nexus.RichText.css +2 -32
  154. package/src/Styles/Nexus.Rows.css +51 -10
  155. package/src/Styles/Nexus.Slider.css +3 -5
  156. package/src/Styles/Nexus.Tab.Buttons.css +20 -7
  157. package/src/Styles/Nexus.Table.css +1387 -1296
  158. package/src/Styles/Nexus.Theme.Picker.css +342 -232
  159. package/src/Styles/Nexus.Time.css +9 -12
  160. package/src/Styles/Nexus.Toast.css +6 -8
  161. package/src/Styles/Nexus.Tokens.css +17 -0
  162. package/src/Styles/Nexus.Tree.css +76 -11
@@ -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
+ }
@@ -6,7 +6,11 @@ export interface CrudDetailsProps<TRow extends FormValues = FormValues> {
6
6
  row: TRow;
7
7
  /** The fields, as the table's columns describe them; hidden columns included. */
8
8
  columns: readonly TableColumn<TRow>[];
9
- /** Loads the full record (fields the list leaves out); its values replace the row's when they come. */
9
+ /**
10
+ * Loads the full record (fields the list leaves out); its values replace the row's when they come. Meanwhile a field
11
+ * the row lacks keeps its line blank, and reads "Not loaded" if the load fails; the details say nothing else about
12
+ * the load (`aria-busy` while it runs), so the popup around them never changes size for it.
13
+ */
10
14
  load?: (signal: AbortSignal) => Promise<Partial<TRow> | null | undefined>;
11
15
  /** Draws the details instead of the list of fields. */
12
16
  render?: (row: TRow) => ReactNode;
@@ -1,18 +1,31 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconInfoCircle, iconLoader2, InlineIcon } from "nexus-icons";
4
- import { useEffect, useMemo, useState } from "react";
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { motionAllowed } from "../../Helpers/AnimationHelpers.js";
5
5
  import { isAbortError } from "../../Helpers/ApiResponses.js";
6
6
  import { cx } from "../../Helpers/ClassHelpers.js";
7
7
  import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
8
+ import { readPath } from "../../Helpers/TableHelpers.js";
9
+ import { useNumberLocale } from "../../Helpers/UserPreferences.js";
8
10
  import { TableCellValue } from "../Viewers/DataTableParts.js";
11
+ import { MarkdownView } from "../Viewers/MarkdownView.js";
12
+ /**
13
+ * An element's height as its `height` would set it: unrounded, unlike `offsetHeight`, so an unroll between two of
14
+ * them starts and ends exactly where the details are and the popup never steps by a fraction of a pixel. NaN while
15
+ * not drawn.
16
+ */
17
+ const usedHeight = (element) => parseFloat(getComputedStyle(element).height);
9
18
  /** A record's fields as label and value, two to a line where there is room, in the formats of the table's columns. */
10
19
  export function CrudDetails({ row, columns, load, render }) {
11
20
  const [record, setRecord] = useState(row);
12
21
  const [state, setState] = useState(load ? "loading" : "done");
22
+ const rootRef = useRef(null);
23
+ // The details' height just before the full record went in, kept only when the change may be animated.
24
+ const grownFrom = useRef(null);
13
25
  const preferences = useDatePreferences();
14
26
  const timeZone = useBrowserTimeZone();
15
- const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined }), [preferences, timeZone]);
27
+ const locale = useNumberLocale();
28
+ const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined, locale }), [preferences, timeZone, locale]);
16
29
  useEffect(() => {
17
30
  if (!load)
18
31
  return;
@@ -20,8 +33,11 @@ export function CrudDetails({ row, columns, load, render }) {
20
33
  load(controller.signal).then(full => {
21
34
  if (controller.signal.aborted)
22
35
  return;
23
- if (full)
36
+ if (full) {
37
+ if (motionAllowed() && rootRef.current)
38
+ grownFrom.current = usedHeight(rootRef.current);
24
39
  setRecord(current => ({ ...current, ...full }));
40
+ }
25
41
  setState("done");
26
42
  }, error => {
27
43
  if (controller.signal.aborted || isAbortError(error))
@@ -32,5 +48,22 @@ export function CrudDetails({ row, columns, load, render }) {
32
48
  // The record loads once per popup.
33
49
  // eslint-disable-next-line react-hooks/exhaustive-deps
34
50
  }, []);
35
- return (_jsxs("div", { className: "nx-crud-details", "aria-busy": state === "loading" || undefined, children: [state === "loading" && (_jsxs("p", { className: "nx-crud-details__note", role: "status", children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), "Loading the full record\u2026"] })), state === "failed" && (_jsxs("p", { className: "nx-crud-details__note", role: "status", children: [_jsx(InlineIcon, { icon: iconInfoCircle }), "The full record could not be loaded; these are the values from the list."] })), render ? (render(record)) : (_jsx("dl", { className: "nx-crud-details__list", children: columns.map(column => (_jsxs("div", { className: cx("nx-crud-details__item", column.wrap && "nx-crud-details__item--wide"), children: [_jsx("dt", { className: "nx-crud-details__label", children: column.label }), _jsx("dd", { className: "nx-crud-details__value", children: _jsx(TableCellValue, { column: column, row: record, options: options }) })] }, column.key))) }))] }));
51
+ // A record that makes the details taller (a long note the list did not have) unrolls to its height when motion is
52
+ // allowed, as maximize does; without motion it changes at once. Nothing waits on it.
53
+ useLayoutEffect(() => {
54
+ const from = grownFrom.current;
55
+ grownFrom.current = null;
56
+ const root = rootRef.current;
57
+ if (!from || !root || typeof root.animate !== "function")
58
+ return;
59
+ const to = usedHeight(root);
60
+ if (!to || Math.abs(to - from) < 1)
61
+ return;
62
+ const animation = root.animate([{ height: `${from}px`, overflow: "clip" }, { height: `${to}px`, overflow: "clip" }], { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)" });
63
+ return () => animation.cancel();
64
+ }, [record]);
65
+ // A field the list's row does not carry can only be filled by the full record. Columns that draw or work out their
66
+ // own value (`render`, `value`) draw as always, because only they know what they read.
67
+ const awaited = (column) => state !== "done" && !column.render && !column.value && readPath(record, column.key) === undefined;
68
+ return (_jsx("div", { ref: rootRef, className: "nx-crud-details", "aria-busy": state === "loading" || undefined, children: render ? (render(record)) : (_jsx("dl", { className: "nx-crud-details__list", children: columns.map(column => (_jsxs("div", { className: cx("nx-crud-details__item", column.wrap && "nx-crud-details__item--wide"), children: [_jsx("dt", { className: "nx-crud-details__label", children: column.label }), _jsx("dd", { className: "nx-crud-details__value", children: awaited(column) ? (state === "failed" ? _jsx("span", { className: "nx-crud-details__missing", children: "Not loaded" }) : null) : column.markdown && !column.render ? (_jsx(MarkdownView, { value: String(readPath(record, column.key) ?? ""), placeholder: `No ${column.label.toLowerCase()}` })) : (_jsx(TableCellValue, { column: column, row: record, options: options })) })] }, column.key))) })) }));
36
69
  }
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import { type ReactNode, type Ref } from "react";
2
2
  import type { ExcelEndpoints, ExcelImportAnswer } from "../../Interfaces/ExcelInterfaces.ts";
3
3
  import { type ExcelCallOptions } from "../../Services/ExcelApi.ts";
4
4
  import { type PopupHandle } from "../Layouts/PopupHost.tsx";
@@ -18,24 +18,40 @@ export interface ExcelImportProps {
18
18
  templateDownload?: false;
19
19
  /** The tallest each editor grows before its rows scroll. Default 320. */
20
20
  maxHeight?: number;
21
+ /**
22
+ * `"file"` (the default): a filled template is sent to be checked, and its rows come back to be gone over.
23
+ * `"bulk"`: no file - the rows are typed here, from one empty row, and imported with the same checks. A sheet with a
24
+ * required list cannot be typed in (its key is picked in the spreadsheet), and the panel says so.
25
+ */
26
+ mode?: "file" | "bulk";
27
+ /** What closing the panel would lose, so a window can ask first: `openExcelImport` passes one. */
28
+ ref?: Ref<ExcelImportHandle>;
21
29
  className?: string;
22
30
  }
31
+ /** What `ref` gives on an import panel. */
32
+ export interface ExcelImportHandle {
33
+ /** The rows only this screen has - typed here, or a sheet's row edited here - which closing the panel loses. */
34
+ unsavedRows: () => number;
35
+ }
23
36
  /**
24
- * The three steps of an import in one panel: the template, the file, and the review.
37
+ * The three steps of an import in one panel: the template, the file, and the review - or, for a bulk insert, the rows
38
+ * typed in and the import.
25
39
  *
26
40
  * @example
27
41
  * <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
42
+ * <ExcelImport endpoints={REGION_SHEET} subject="region" mode="bulk" onImported={() => table.reload()} />
28
43
  */
29
- export declare function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight, className }: ExcelImportProps): import("react").JSX.Element;
44
+ export declare function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight, mode, ref, className }: ExcelImportProps): import("react").JSX.Element;
30
45
  /** Options of the import popup: the panel's, plus what the window looks like. */
31
- export interface OpenExcelImportOptions extends Omit<ExcelImportProps, "onClose"> {
32
- /** The window's title. Default: "Import <subjects> from a spreadsheet". */
46
+ export interface OpenExcelImportOptions extends Omit<ExcelImportProps, "onClose" | "ref"> {
47
+ /** The window's title. Default: "Import <subjects> from a spreadsheet", or "Bulk insert <subjects>". */
33
48
  title?: ReactNode;
34
- /** One popup per key. Default: the import endpoint's path, so one model opens one. */
49
+ /** One popup per key. Default: the import endpoint's path and the mode, so one model opens one of each. */
35
50
  popupKey?: string;
36
51
  }
37
52
  /**
38
- * Opens the import panel as a window. This is the one line a data table's Import entry needs.
53
+ * Opens the import panel as a window. This is the one line a data table's Import entry needs - and, with
54
+ * `mode: "bulk"`, its Bulk insert entry.
39
55
  *
40
56
  * @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
41
57
  */