nexus-shared 4.0.0 → 4.1.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 (163) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +220 -14
  3. package/dist/Client.Index.d.ts +13 -0
  4. package/dist/Client.Index.js +13 -0
  5. package/dist/Components/Chats/ChatComposer.d.ts +7 -1
  6. package/dist/Components/Chats/ChatComposer.js +9 -6
  7. package/dist/Components/Chats/ChatParts.d.ts +15 -2
  8. package/dist/Components/Chats/ChatParts.js +15 -4
  9. package/dist/Components/Chats/ChatPopup.d.ts +46 -0
  10. package/dist/Components/Chats/ChatPopup.js +34 -0
  11. package/dist/Components/Chats/ChatThread.d.ts +9 -1
  12. package/dist/Components/Chats/ChatThread.js +54 -6
  13. package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
  14. package/dist/Components/Dashboards/DashboardBoard.js +594 -0
  15. package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
  16. package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
  17. package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
  18. package/dist/Components/Dashboards/WidgetFrame.js +45 -0
  19. package/dist/Components/Documents/Button.d.ts +6 -1
  20. package/dist/Components/Documents/Button.js +2 -2
  21. package/dist/Components/Documents/TabButtons.d.ts +6 -0
  22. package/dist/Components/Documents/TabButtons.js +5 -4
  23. package/dist/Components/Domains/DomainCard.d.ts +98 -0
  24. package/dist/Components/Domains/DomainCard.js +182 -0
  25. package/dist/Components/Forms/ApiForm.js +5 -1
  26. package/dist/Components/Forms/Crud.js +72 -24
  27. package/dist/Components/Forms/CrudActions.d.ts +5 -4
  28. package/dist/Components/Forms/CrudActions.js +42 -12
  29. package/dist/Components/Forms/CrudHistory.d.ts +6 -1
  30. package/dist/Components/Forms/CrudHistory.js +14 -6
  31. package/dist/Components/Forms/CrudParts.d.ts +11 -4
  32. package/dist/Components/Forms/CrudParts.js +76 -10
  33. package/dist/Components/Forms/CrudRowForms.js +65 -5
  34. package/dist/Components/Forms/CrudTree.js +7 -7
  35. package/dist/Components/Forms/CrudWorkspace.d.ts +15 -5
  36. package/dist/Components/Forms/CrudWorkspace.js +19 -11
  37. package/dist/Components/Forms/Form.d.ts +3 -2
  38. package/dist/Components/Forms/Form.js +33 -26
  39. package/dist/Components/Forms/FormQuestion.d.ts +6 -0
  40. package/dist/Components/Forms/FormQuestion.js +28 -0
  41. package/dist/Components/Forms/SubmitForm.js +2 -15
  42. package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
  43. package/dist/Components/Inputs/ComputedInput.js +179 -0
  44. package/dist/Components/Inputs/Dropdown.js +4 -2
  45. package/dist/Components/Inputs/GroupForm.js +3 -2
  46. package/dist/Components/Inputs/InputRenderer.d.ts +5 -1
  47. package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
  48. package/dist/Components/Inputs/MarkdownEditor.js +32 -6
  49. package/dist/Components/Inputs/PictureInput.js +8 -3
  50. package/dist/Components/Inputs/RowsInput.d.ts +14 -1
  51. package/dist/Components/Inputs/RowsInput.js +78 -5
  52. package/dist/Components/Inputs/TabularForm.js +16 -3
  53. package/dist/Components/Inputs/TreeView.d.ts +24 -1
  54. package/dist/Components/Inputs/TreeView.js +84 -7
  55. package/dist/Components/Layouts/Popup.d.ts +22 -2
  56. package/dist/Components/Layouts/Popup.js +108 -6
  57. package/dist/Components/Messages/MailClient.d.ts +50 -0
  58. package/dist/Components/Messages/MailClient.js +410 -0
  59. package/dist/Components/Messages/MailColumns.d.ts +21 -0
  60. package/dist/Components/Messages/MailColumns.js +92 -0
  61. package/dist/Components/Messages/MailCompose.d.ts +46 -0
  62. package/dist/Components/Messages/MailCompose.js +154 -0
  63. package/dist/Components/Messages/MailReader.d.ts +43 -0
  64. package/dist/Components/Messages/MailReader.js +207 -0
  65. package/dist/Components/Messages/MailThread.d.ts +71 -0
  66. package/dist/Components/Messages/MailThread.js +185 -0
  67. package/dist/Components/Messages/MessageBell.d.ts +41 -0
  68. package/dist/Components/Messages/MessageBell.js +76 -0
  69. package/dist/Components/Viewers/DataTable.js +7 -5
  70. package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
  71. package/dist/Components/Viewers/DataTableColumns.js +2 -1
  72. package/dist/Components/Viewers/DataTableParts.d.ts +3 -1
  73. package/dist/Components/Viewers/DataTableParts.js +2 -2
  74. package/dist/Components/Viewers/OwnerFiles.d.ts +57 -5
  75. package/dist/Components/Viewers/OwnerFiles.js +142 -31
  76. package/dist/Helpers/ApiModules.js +4 -0
  77. package/dist/Helpers/AuditEntries.d.ts +50 -0
  78. package/dist/Helpers/AuditEntries.js +96 -0
  79. package/dist/Helpers/ChatBackend.d.ts +8 -1
  80. package/dist/Helpers/ChatBackend.js +18 -0
  81. package/dist/Helpers/ChatHelpers.d.ts +2 -0
  82. package/dist/Helpers/ChatHelpers.js +4 -1
  83. package/dist/Helpers/CrudColumns.d.ts +44 -6
  84. package/dist/Helpers/CrudColumns.js +63 -13
  85. package/dist/Helpers/CrudHelpers.d.ts +36 -1
  86. package/dist/Helpers/CrudHelpers.js +61 -0
  87. package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
  88. package/dist/Helpers/CrudPickerPlan.js +46 -0
  89. package/dist/Helpers/CrudRowForms.d.ts +18 -1
  90. package/dist/Helpers/CrudRowForms.js +35 -2
  91. package/dist/Helpers/DashboardHelpers.d.ts +115 -0
  92. package/dist/Helpers/DashboardHelpers.js +305 -0
  93. package/dist/Helpers/DomainCards.d.ts +140 -0
  94. package/dist/Helpers/DomainCards.js +370 -0
  95. package/dist/Helpers/FileAdapter.d.ts +82 -2
  96. package/dist/Helpers/FileAdapter.js +72 -1
  97. package/dist/Helpers/FormStore.d.ts +11 -1
  98. package/dist/Helpers/FormStore.js +66 -19
  99. package/dist/Helpers/InputConditions.d.ts +6 -0
  100. package/dist/Helpers/InputConditions.js +24 -0
  101. package/dist/Helpers/InputMemory.js +40 -30
  102. package/dist/Helpers/InputNarrowing.d.ts +38 -6
  103. package/dist/Helpers/InputNarrowing.js +102 -21
  104. package/dist/Helpers/MailClientHelpers.d.ts +96 -0
  105. package/dist/Helpers/MailClientHelpers.js +211 -0
  106. package/dist/Helpers/MessageBackend.d.ts +152 -0
  107. package/dist/Helpers/MessageBackend.js +329 -0
  108. package/dist/Helpers/MessageHelpers.d.ts +160 -0
  109. package/dist/Helpers/MessageHelpers.js +573 -0
  110. package/dist/Helpers/MessageUnread.d.ts +95 -0
  111. package/dist/Helpers/MessageUnread.js +245 -0
  112. package/dist/Helpers/NexusDriveAdapter.d.ts +17 -1
  113. package/dist/Helpers/NexusDriveAdapter.js +32 -3
  114. package/dist/Helpers/NumberHelpers.d.ts +61 -0
  115. package/dist/Helpers/NumberHelpers.js +124 -8
  116. package/dist/Helpers/PagedPicker.d.ts +42 -0
  117. package/dist/Helpers/PagedPicker.js +85 -0
  118. package/dist/Helpers/RowsStore.d.ts +24 -0
  119. package/dist/Helpers/RowsStore.js +79 -10
  120. package/dist/Helpers/TableHelpers.d.ts +15 -2
  121. package/dist/Helpers/TableHelpers.js +28 -1
  122. package/dist/Helpers/TableStore.d.ts +7 -3
  123. package/dist/Helpers/TableStore.js +9 -4
  124. package/dist/Helpers/ToolbarFit.d.ts +23 -0
  125. package/dist/Helpers/ToolbarFit.js +30 -0
  126. package/dist/Helpers/TreeStore.d.ts +75 -6
  127. package/dist/Helpers/TreeStore.js +182 -18
  128. package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
  129. package/dist/Interfaces/ApiInterfaces.js +10 -1
  130. package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
  131. package/dist/Interfaces/CrudInterfaces.d.ts +78 -5
  132. package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
  133. package/dist/Interfaces/DashboardInterfaces.js +1 -0
  134. package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
  135. package/dist/Interfaces/DomainInterfaces.js +1 -0
  136. package/dist/Interfaces/FileInterfaces.d.ts +21 -0
  137. package/dist/Interfaces/FileInterfaces.js +2 -0
  138. package/dist/Interfaces/FormInterfaces.d.ts +78 -11
  139. package/dist/Interfaces/InputInterfaces.d.ts +12 -5
  140. package/dist/Interfaces/MailInterfaces.d.ts +192 -0
  141. package/dist/Interfaces/MailInterfaces.js +1 -0
  142. package/dist/Interfaces/TableInterfaces.d.ts +28 -2
  143. package/dist/Services/JoinSources.d.ts +47 -6
  144. package/dist/Services/JoinSources.js +68 -33
  145. package/dist/Services/MessageApi.d.ts +18 -0
  146. package/dist/Services/MessageApi.js +64 -0
  147. package/dist/Shared.Index.d.ts +12 -0
  148. package/dist/Shared.Index.js +12 -0
  149. package/package.json +7 -1
  150. package/src/Styles/Nexus.Button.css +29 -0
  151. package/src/Styles/Nexus.Chat.css +114 -22
  152. package/src/Styles/Nexus.Crud.css +119 -28
  153. package/src/Styles/Nexus.Dashboard.css +763 -0
  154. package/src/Styles/Nexus.Domain.css +384 -0
  155. package/src/Styles/Nexus.Editor.css +20 -0
  156. package/src/Styles/Nexus.Files.css +27 -0
  157. package/src/Styles/Nexus.Index.css +3 -0
  158. package/src/Styles/Nexus.Input.css +31 -0
  159. package/src/Styles/Nexus.Mail.css +1062 -0
  160. package/src/Styles/Nexus.Popup.css +58 -0
  161. package/src/Styles/Nexus.Table.css +1458 -1449
  162. package/src/Styles/Nexus.Theme.Picker.css +1 -0
  163. package/src/Styles/Nexus.Tree.css +10 -0
@@ -0,0 +1,182 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { iconArrowRight, iconChevronDown, iconChevronUp, iconLoader2, iconLock, InlineIcon } from "nexus-icons";
4
+ import { useEffect, useId, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
+ import { chatDomainLabel } from "../../Helpers/ChatBackend.js";
6
+ import { chatTimeLabel } from "../../Helpers/ChatHelpers.js";
7
+ import { cx } from "../../Helpers/ClassHelpers.js";
8
+ import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
9
+ import { domainCardContext, domainCardFailedText, domainCardFields, domainCardNoun, domainCardOf, domainCardRefusedText, domainCardShape, domainCardValueText, DomainCardRun, heldDomainCardShape, rememberDomainCardShape, } from "../../Helpers/DomainCards.js";
10
+ import { useNumberLocale } from "../../Helpers/UserPreferences.js";
11
+ import { ChatFailure } from "../Chats/ChatParts.js";
12
+ import { Button } from "../Documents/Button.js";
13
+ /** A record's card, read live from its module: see the notes at the top of this file. */
14
+ export function DomainCard({ card: given, kind: givenKind, ...props }) {
15
+ const registered = domainCardOf(props.domain);
16
+ const card = given === undefined ? registered?.card : (given ?? undefined);
17
+ const kind = givenKind ?? registered?.info.label ?? chatDomainLabel(props.domain);
18
+ const { id } = props;
19
+ if (!card || id === null || id === undefined || id === "")
20
+ return _jsx(DomainLine, { kind: kind, label: props.label, className: props.className });
21
+ // A record of its own is a read of its own: another id starts from its placeholder, never from the last record.
22
+ return _jsx(LiveDomainCard, { ...props, id: id, card: card, kind: kind }, `${String(props.domain)}|${String(id)}|${String(props.viewer ?? "")}`);
23
+ }
24
+ /** One record's card read, for a page that draws it its own way: the snapshot, and `run.reload()` / `run.retry()`. */
25
+ export function useDomainCard({ domain, id, card, viewer, label, kind, revision }) {
26
+ const context = useMemo(() => domainCardContext(domain, id, { ...(viewer !== null && viewer !== undefined && { viewer }), label, kind }), [domain, id, viewer, label, kind]);
27
+ const latest = useRef({ card, context });
28
+ latest.current = { card, context };
29
+ const run = useMemo(() => new DomainCardRun({ domainType: domain, id, viewer, lifetime: card.lifetime, load: signal => latest.current.card.load(id, signal, latest.current.context) }),
30
+ // A new card definition for the same record keeps the run: `load` is read when it is called.
31
+ // eslint-disable-next-line react-hooks/exhaustive-deps
32
+ [domain, id, viewer]);
33
+ useEffect(() => {
34
+ run.start();
35
+ return () => run.stop();
36
+ }, [run]);
37
+ const snapshot = useSyncExternalStore(run.subscribe, run.getSnapshot, run.getSnapshot);
38
+ // A change seen after the card was drawn reads the record again; the revision it opened with does not.
39
+ const seen = useRef(revision);
40
+ useEffect(() => {
41
+ if (revision === seen.current)
42
+ return;
43
+ seen.current = revision;
44
+ run.reload();
45
+ }, [revision, run]);
46
+ const shown = useMemo(() => {
47
+ if (snapshot.data === undefined)
48
+ return {};
49
+ try {
50
+ return { params: card.view(snapshot.data, context) };
51
+ }
52
+ catch (error) {
53
+ console.error("A record's card could not be drawn from its answer:", error);
54
+ return { viewError: error };
55
+ }
56
+ }, [snapshot.data, card, context]);
57
+ useEffect(() => {
58
+ if (shown.params)
59
+ rememberDomainCardShape(domain, domainCardShape(shown.params));
60
+ }, [domain, shown.params]);
61
+ return { run, snapshot, context, ...shown };
62
+ }
63
+ function LiveDomainCard({ domain, id, label, revision, viewer, card, kind, headingLevel = 3, collapsible = true, defaultCollapsed = false, collapsed: heldCollapsed, onCollapsedChange, className, ref }) {
64
+ const state = useDomainCard({ domain, id, card, viewer, label, kind, revision });
65
+ const { run, snapshot, params, viewError, context } = state;
66
+ const [ownCollapsed, setOwnCollapsed] = useState(defaultCollapsed);
67
+ const collapsed = heldCollapsed ?? ownCollapsed;
68
+ const setCollapsed = (next) => {
69
+ setOwnCollapsed(next);
70
+ onCollapsedChange?.(next);
71
+ };
72
+ useImperativeHandle(ref, () => ({ reload: () => run.reload() }), [run]);
73
+ // Said aloud once, when a read again changes what is on screen or fails.
74
+ const [spoken, setSpoken] = useState("");
75
+ const noun = domainCardNoun(kind);
76
+ useEffect(() => {
77
+ if (snapshot.changedAt)
78
+ setSpoken(`The ${noun} was updated.`);
79
+ }, [snapshot.changedAt, noun]);
80
+ useEffect(() => {
81
+ if (snapshot.refreshFailed)
82
+ setSpoken(`The ${noun} could not be read again.`);
83
+ }, [snapshot.refreshFailed, noun]);
84
+ const live = (_jsx("span", { className: "nx-visually-hidden", "aria-live": "polite", children: spoken }));
85
+ if (snapshot.status === "refused") {
86
+ return (_jsxs("section", { className: cx("nx-domain-card", className), "data-state": "refused", "aria-label": kind ?? label ?? undefined, "aria-busy": snapshot.refreshing || undefined, children: [_jsx(DomainLine, { kind: kind, label: label }), _jsxs("p", { className: "nx-domain-card__notice", children: [_jsx(InlineIcon, { icon: iconLock }), domainCardRefusedText(kind)] }), live] }));
87
+ }
88
+ if (snapshot.status === "failed" || (viewError !== undefined && !params)) {
89
+ return (_jsxs("section", { className: cx("nx-domain-card", className), "data-state": "failed", "aria-label": kind ?? label ?? undefined, children: [_jsx(DomainLine, { kind: kind, label: label }), _jsx(ChatFailure, { message: domainCardFailedText(kind), onRetry: () => run.retry() }), live] }));
90
+ }
91
+ if (snapshot.status === "loading" || !params) {
92
+ return (_jsxs(_Fragment, { children: [_jsx(DomainCardHeld, { shape: heldDomainCardShape(domain, card), kind: kind, label: label, headingLevel: headingLevel, collapsible: collapsible, collapsed: collapsed, onCollapsedChange: setCollapsed, className: className }), live] }));
93
+ }
94
+ const status = snapshot.refreshing ? (_jsx("span", { className: "nx-domain-card__meta", title: "Reading again", children: _jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }) })) : snapshot.refreshFailed ? (_jsxs("span", { className: "nx-domain-card__meta nx-domain-card__meta--failed", children: ["Not up to date", _jsx("button", { type: "button", className: "nx-domain-card__retry", onClick: () => run.retry(), children: "Try again" })] })) : snapshot.changedAt ? (_jsx(DomainCardUpdated, { at: snapshot.changedAt })) : undefined;
95
+ return (_jsxs(_Fragment, { children: [_jsx(DomainCardFrame, { params: params, kind: kind, headingLevel: headingLevel, status: status, busy: snapshot.refreshing, collapsible: collapsible, collapsed: collapsed, onCollapsedChange: setCollapsed, className: className, "data-state": "ready", children: card.extra && snapshot.data !== undefined ? card.extra(snapshot.data, context) : null }), live] }));
96
+ }
97
+ /** "Updated 2:45 PM": the record changed while its card was open. */
98
+ function DomainCardUpdated({ at }) {
99
+ const preferences = useDatePreferences();
100
+ const zone = useBrowserTimeZone();
101
+ const moment = new Date(at).toISOString();
102
+ return (_jsxs("span", { className: "nx-domain-card__meta", children: ["Updated ", _jsx("time", { dateTime: moment, children: chatTimeLabel(moment, { ...preferences, ...(zone && { timeZone: zone }) }) })] }));
103
+ }
104
+ /**
105
+ * A card drawn from params alone: the look every record's card shares. `DomainCard` draws it once a record is read; a
106
+ * page that already holds a record (a dashboard, a print preview) can draw it straight.
107
+ */
108
+ export function DomainCardFrame({ params, kind, headingLevel = 3, status, busy = false, collapsible = false, collapsed: heldCollapsed, defaultCollapsed = false, onCollapsedChange, children, className, "data-state": dataState, }) {
109
+ const [ownCollapsed, setOwnCollapsed] = useState(defaultCollapsed);
110
+ const collapsed = collapsible && (heldCollapsed ?? ownCollapsed);
111
+ const titleId = useId();
112
+ const detailsId = useId();
113
+ const preferences = useDatePreferences();
114
+ const zone = useBrowserTimeZone();
115
+ const locale = useNumberLocale();
116
+ const values = { preferences, timeZone: zone, locale, currency: params.currency };
117
+ const Heading = `h${headingLevel}`;
118
+ const fields = domainCardFields(params);
119
+ const figures = params.figures ?? [];
120
+ const lines = params.lines && params.lines.rows.length > 0 ? params.lines : null;
121
+ const extra = children === undefined || children === null || children === false ? null : children;
122
+ // Only what can fold has a fold button: the fields, the lines and what a kind adds; the figures always show.
123
+ const foldable = collapsible && (fields.length > 0 || lines !== null || extra !== null);
124
+ const toggle = () => {
125
+ const next = !collapsed;
126
+ setOwnCollapsed(next);
127
+ onCollapsedChange?.(next);
128
+ };
129
+ return (_jsxs("section", { className: cx("nx-domain-card", className), "aria-labelledby": titleId, "aria-busy": busy || undefined, "data-state": dataState, children: [_jsxs("div", { className: "nx-domain-card__head", children: [_jsxs(Heading, { className: "nx-domain-card__title", id: titleId, children: [kind && _jsx("span", { className: "nx-table__tag nx-table__tag--plain nx-domain-card__kind", children: kind }), kind && " ", _jsx("span", { children: params.title })] }), _jsxs("div", { className: "nx-domain-card__status", children: [status, params.state && (_jsx("span", { className: "nx-table__tag nx-domain-card__state", "data-tone": params.state.tone && params.state.tone !== "neutral" ? params.state.tone : undefined, children: params.state.label })), foldable && (_jsx(Button, { variant: "ghost", size: "sm", className: "nx-domain-card__toggle", icon: _jsx(InlineIcon, { icon: collapsed ? iconChevronDown : iconChevronUp }), "aria-expanded": !collapsed, "aria-controls": detailsId, "aria-label": `${collapsed ? "Show" : "Hide"} the details: ${params.title}`, title: collapsed ? "Show the details" : "Hide the details", onClick: toggle }))] }), (params.code || params.subtitle || params.link) && (_jsxs("div", { className: "nx-domain-card__subtitle", children: [_jsxs("span", { title: [params.code, params.subtitle].filter(Boolean).join(" · ") || undefined, children: [params.code && _jsx("span", { className: "nx-domain-card__code", children: params.code }), params.code && params.subtitle && " · ", params.subtitle] }), params.link && (_jsxs("a", { className: "nx-domain-card__link", href: params.link.href, target: params.link.target, rel: params.link.target === "_blank" ? "noopener" : undefined, children: [params.link.label, _jsx(InlineIcon, { icon: iconArrowRight })] }))] }))] }), _jsxs("div", { className: "nx-domain-card__body", hidden: collapsed && figures.length === 0, children: [figures.length > 0 && (_jsx("dl", { className: "nx-domain-card__figures", children: figures.map((figure, index) => (_jsxs("div", { className: "nx-domain-card__figure", "data-tone": figure.tone && figure.tone !== "neutral" ? figure.tone : undefined, children: [_jsx("dt", { children: figure.label }), _jsx("dd", { children: _jsx(DomainFigureValue, { figure: figure, values: values }) }), figure.hint && _jsx("dd", { className: "nx-domain-card__hint", children: figure.hint })] }, `${figure.label}-${index}`))) })), _jsxs("div", { className: "nx-domain-card__details", id: detailsId, hidden: collapsed || (fields.length === 0 && lines === null && extra === null), children: [fields.length > 0 && (_jsx("dl", { className: "nx-domain-card__fields", children: fields.map((field, index) => {
130
+ const text = domainCardValueText(field.value, field.kind, { ...values, currency: field.currency ?? values.currency });
131
+ return (_jsxs("div", { className: "nx-domain-card__field", "data-kind": field.kind ?? "text", "data-empty": text ? undefined : "", children: [_jsx("dt", { children: field.label }), _jsxs("dd", { children: [text || field.empty, field.note && _jsxs("span", { className: "nx-domain-card__note", children: [" \u00B7 ", field.note] })] })] }, `${field.label}-${index}`));
132
+ }) })), lines && (_jsxs(_Fragment, { children: [_jsxs("table", { className: "nx-domain-card__lines", children: [_jsx("caption", { className: "nx-visually-hidden", children: lines.label ?? "Lines" }), _jsx("thead", { children: _jsx("tr", { children: lines.columns.map((column, index) => (_jsx("th", { scope: "col", "data-align": alignOf(column.kind, column.align), children: column.label }, `${column.label}-${index}`))) }) }), _jsx("tbody", { children: lines.rows.map((row, rowIndex) => (_jsx("tr", { children: lines.columns.map((column, index) => {
133
+ const text = domainCardValueText(row[index], column.kind, values);
134
+ // The first column keeps to one line, cut with "..." on a narrow card: its whole words on hover.
135
+ return (_jsx("td", { "data-align": alignOf(column.kind, column.align), title: index === 0 && text ? text : undefined, children: text }, index));
136
+ }) }, rowIndex))) })] }), (lines.more ?? 0) > 0 && _jsxs("p", { className: "nx-domain-card__more", children: ["and ", lines.more, " more"] })] })), extra] })] })] }));
137
+ }
138
+ const alignOf = (kind, align) => align ?? (kind === "number" || kind === "money" || kind === "days" ? "end" : "start");
139
+ /** A figure's parts: the words before it (money's currency), the figure, the words after it (days, a unit). */
140
+ function figureParts(figure, values) {
141
+ const { value } = figure;
142
+ if (value === null || value === undefined || value === "" || (typeof value === "number" && !Number.isFinite(value)))
143
+ return { shown: "-" };
144
+ if (figure.kind === "money") {
145
+ const currency = (figure.currency ?? values.currency ?? "").trim();
146
+ return { before: currency || undefined, shown: domainCardValueText(value, "money", { ...values, currency: null }) };
147
+ }
148
+ if (figure.kind === "days") {
149
+ // "14 days": the same helper as every other days value, its number large and its word small.
150
+ const text = domainCardValueText(value, "days", values);
151
+ const space = text.lastIndexOf(" ");
152
+ return space > 0 ? { shown: text.slice(0, space), after: text.slice(space + 1) } : { shown: text };
153
+ }
154
+ return { shown: typeof value === "string" ? value : domainCardValueText(value, "number", values), after: figure.unit };
155
+ }
156
+ /** A figure's value: large, its unit smaller beside it, a real space between them for a screen reader. */
157
+ function DomainFigureValue({ figure, values }) {
158
+ const { before, shown, after } = figureParts(figure, values);
159
+ return (_jsxs(_Fragment, { children: [before && _jsx("span", { className: "nx-domain-card__unit", children: before }), before && " ", _jsx("span", { className: "nx-domain-card__value", children: shown }), after && " ", after && _jsx("span", { className: "nx-domain-card__unit", children: after })] }));
160
+ }
161
+ /** A bone on the line of the text it stands for. */
162
+ const Bone = ({ width }) => _jsx("span", { className: "nx-table__bone nx-domain-card__bone", style: { width } });
163
+ /**
164
+ * The card while it loads: the frame's own parts, each holding its line with a bone, in the shape of the kind's last
165
+ * card - so when the record lands the thread under it does not move. The record's words name it for a screen reader.
166
+ */
167
+ function DomainCardHeld({ shape, kind, label, headingLevel, collapsible, collapsed, onCollapsedChange, className }) {
168
+ const titleId = useId();
169
+ const detailsId = useId();
170
+ const Heading = `h${headingLevel}`;
171
+ const details = shape.fields > 0 || shape.lines > 0;
172
+ const foldable = collapsible && details;
173
+ const folded = foldable && collapsed;
174
+ const columns = Math.max(shape.columns, 1);
175
+ return (_jsxs("section", { className: cx("nx-domain-card", className), "aria-labelledby": titleId, "aria-busy": "true", "data-state": "loading", children: [_jsxs("div", { className: "nx-domain-card__head", children: [_jsxs(Heading, { className: "nx-domain-card__title", id: titleId, children: [kind && _jsx("span", { className: "nx-table__tag nx-table__tag--plain nx-domain-card__kind", children: kind }), kind && " ", _jsxs("span", { children: [_jsx(Bone, { width: "8rem" }), _jsx("span", { className: "nx-visually-hidden", children: label ? `${label}, loading` : "Loading" })] })] }), _jsxs("div", { className: "nx-domain-card__status", children: [shape.state && _jsx("span", { className: "nx-table__tag nx-table__tag--plain nx-domain-card__bone-tag", "aria-hidden": "true" }), foldable && (_jsx(Button, { variant: "ghost", size: "sm", className: "nx-domain-card__toggle", icon: _jsx(InlineIcon, { icon: folded ? iconChevronDown : iconChevronUp }), "aria-expanded": !folded, "aria-controls": detailsId, "aria-label": `${folded ? "Show" : "Hide"} the details`, title: folded ? "Show the details" : "Hide the details", onClick: () => onCollapsedChange(!folded) }))] }), shape.subtitle && (_jsxs("div", { className: "nx-domain-card__subtitle", "aria-hidden": "true", children: [_jsx("span", { children: _jsx(Bone, { width: "10rem" }) }), shape.link && (_jsx("span", { className: "nx-domain-card__link", children: _jsx(Bone, { width: "7rem" }) }))] }))] }), _jsxs("div", { className: "nx-domain-card__body", hidden: folded && shape.figures === 0, "aria-hidden": "true", children: [shape.figures > 0 && (_jsx("dl", { className: "nx-domain-card__figures", children: Array.from({ length: shape.figures }, (_, index) => (_jsxs("div", { className: "nx-domain-card__figure", children: [_jsx("dt", { children: _jsx(Bone, { width: "3.5rem" }) }), _jsx("dd", { children: _jsx("span", { className: "nx-domain-card__value", children: _jsx(Bone, { width: "2.5rem" }) }) }), shape.hints.includes(index) && (_jsx("dd", { className: "nx-domain-card__hint", children: _jsx(Bone, { width: "5rem" }) }))] }, index))) })), _jsxs("div", { className: "nx-domain-card__details", id: detailsId, hidden: folded || !details, children: [shape.fields > 0 && (_jsx("dl", { className: "nx-domain-card__fields", children: Array.from({ length: shape.fields }, (_, index) => (_jsxs("div", { className: "nx-domain-card__field", children: [_jsx("dt", { children: _jsx(Bone, { width: "3.5rem" }) }), _jsx("dd", { children: _jsx(Bone, { width: "6rem" }) })] }, index))) })), shape.lines > 0 && (_jsxs(_Fragment, { children: [_jsxs("table", { className: "nx-domain-card__lines", children: [_jsx("thead", { children: _jsx("tr", { children: Array.from({ length: columns }, (_, index) => (_jsx("th", { children: _jsx(Bone, { width: "3rem" }) }, index))) }) }), _jsx("tbody", { children: Array.from({ length: shape.lines }, (_, row) => (_jsx("tr", { children: Array.from({ length: columns }, (_, index) => (_jsx("td", { children: _jsx(Bone, { width: index === 0 ? "7rem" : "2.5rem" }) }, index))) }, row))) })] }), shape.more && (_jsx("p", { className: "nx-domain-card__more", children: _jsx(Bone, { width: "5rem" }) }))] }))] })] })] }));
176
+ }
177
+ /** A record named by its kind and its words, with nothing read: a message about a kind with no card. */
178
+ export function DomainLine({ kind, label, className }) {
179
+ if (!kind && !label)
180
+ return null;
181
+ return (_jsxs("p", { className: cx("nx-domain-line", className), children: [kind && _jsx("span", { className: "nx-table__tag nx-table__tag--plain", children: kind }), kind && label && " ", label && _jsx("span", { className: "nx-domain-line__label", children: label })] }));
182
+ }
@@ -39,6 +39,8 @@ export function ApiForm(props) {
39
39
  const latest = useRef(props);
40
40
  latest.current = props;
41
41
  const answerRef = useRef(null);
42
+ // What the save that answered sent: the values and the body made of them, for `onSuccess`.
43
+ const sentRef = useRef(null);
42
44
  // The body the question before a save showed (`confirm`), and the submit it is for.
43
45
  const keptRef = useRef(null);
44
46
  const [load, setLoad] = useState(() => (loadValue ? { state: "loading" } : { state: "done" }));
@@ -76,6 +78,7 @@ export function ApiForm(props) {
76
78
  // No `readResponse` means the client's own reader: the app's (`configureApi({ read })`), else the built-in one.
77
79
  const { api, transform, readResponse } = latest.current;
78
80
  answerRef.current = null;
81
+ sentRef.current = null;
79
82
  const kept = keptRef.current;
80
83
  keptRef.current = null;
81
84
  const body = kept && kept.context === context ? kept.body : transform ? await transform(values, context) : values;
@@ -91,6 +94,7 @@ export function ApiForm(props) {
91
94
  }
92
95
  if (answer.ok) {
93
96
  answerRef.current = answer;
97
+ sentRef.current = { values, body };
94
98
  return { ok: true };
95
99
  }
96
100
  latest.current.onError?.(answer, values);
@@ -106,7 +110,7 @@ export function ApiForm(props) {
106
110
  const endpoint = isApiEndpoint(api) ? api : undefined;
107
111
  if (successMessage !== false)
108
112
  toast.success(successMessage ?? answer?.message ?? messages.success(endpoint?.action ?? "save", endpoint?.subject));
109
- onSuccess?.(answer?.result, values, store);
113
+ onSuccess?.(answer?.result, values, store, sentRef.current ?? { values, body: values });
110
114
  }
111
115
  if (load.state === "failed") {
112
116
  return (_jsxs("div", { className: cx("nx-form-load-failed", rest.className), style: rest.style, role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsxs("p", { children: [_jsx("strong", { children: "Could not load the values." }), " ", load.message] }), _jsxs("div", { className: "nx-form-load-failed__actions", children: [rest.onCancel && (_jsx(Button, { size: "sm", onClick: rest.onCancel, children: rest.cancelLabel ?? "Cancel" })), _jsx(Button, { size: "sm", icon: _jsx(InlineIcon, { icon: iconRefresh }), onClick: () => setAttempt(count => count + 1), children: "Retry" })] })] }));
@@ -6,19 +6,19 @@ import { abortError } from "../../Helpers/ApiFormHelpers.js";
6
6
  import { useSlowBusy } from "../../Helpers/BusyHooks.js";
7
7
  import { isAbortError, unwrapApiResult } from "../../Helpers/ApiResponses.js";
8
8
  import { getCrudBackend } from "../../Helpers/CrudBackend.js";
9
- import { actionAllowed, availableModes, CRUD_CACHE_LIFETIME, CRUD_MARK_BITS, CRUD_MARKS, CRUD_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudInNarrowing, crudJoinedNames, crudJoinsMissing, crudNarrowing, crudParentCounts, crudSavedCount, crudTreeChanges, crudListEndpoint, crudMassActions, crudMassEndpoint, crudMessageAction, crudRowActions, crudRowEndpoint, dropMarkedRows, hasMark, leavesList, markDirections, markedRecords, markLimit, massActionAllowed, patchCachedList, patchMarkedRecords, pinRoom, withMark, } from "../../Helpers/CrudHelpers.js";
9
+ import { actionAllowed, availableModes, CRUD_CACHE_LIFETIME, CRUD_MARK_BITS, CRUD_MARKS, CRUD_MODES, CRUD_ROW_ACTIONS, CrudCache, crudCacheMode, crudInNarrowing, crudJoinedNames, crudJoinsMissing, crudNarrowing, crudParentCounts, crudSavedCount, crudSavedRecord, crudTreeChanges, crudListEndpoint, crudMassActions, crudMassEndpoint, crudMessageAction, crudRowActions, crudRowEndpoint, dropMarkedRows, hasMark, leavesList, markDirections, markedRecords, markLimit, massActionAllowed, patchCachedList, patchMarkedRecords, pinRoom, withMark, } from "../../Helpers/CrudHelpers.js";
10
10
  import { cx } from "../../Helpers/ClassHelpers.js";
11
11
  import { FormStore } from "../../Helpers/FormStore.js";
12
12
  import { buildMessage, pluralize } from "../../Helpers/MessageBuilder.js";
13
13
  import { getPermissions, permissionsVersion, subscribePermissions } from "../../Helpers/Permissions.js";
14
14
  import { filterRequest } from "../../Helpers/TableFilters.js";
15
- import { columnValue, formatCellText, readPath } from "../../Helpers/TableHelpers.js";
15
+ import { columnValue, formatCellText, namingColumn, readPath } from "../../Helpers/TableHelpers.js";
16
16
  import { narrowPickWords } from "../../Helpers/CrudNarrowPick.js";
17
17
  import { useCrudNarrowPicker } from "./CrudNarrowPicker.js";
18
18
  import { CrudScopeControl, useCrudScopes } from "./CrudScopes.js";
19
19
  import { openCrudRowForm } from "./CrudRowForms.js";
20
20
  import { crudRowFormOffered, crudRowFormWrites } from "../../Helpers/CrudRowForms.js";
21
- import { crudDetailsWithoutProfile } from "../../Helpers/CrudDetailsTabs.js";
21
+ import { CRUD_DETAILS_TAB, crudDetailsWithoutProfile } from "../../Helpers/CrudDetailsTabs.js";
22
22
  import { crudScopeProps, crudScopeStateKey } from "../../Helpers/CrudScopes.js";
23
23
  import { EInputTypes } from "../../Interfaces/FormInterfaces.js";
24
24
  import { api as browserApi } from "../../Services/BrowserApi.js";
@@ -243,7 +243,7 @@ export function Crud(given) {
243
243
  const custom = latest.current.rowLabel;
244
244
  if (custom)
245
245
  return custom(row);
246
- const first = latest.current.columns[0];
246
+ const first = namingColumn(latest.current.columns);
247
247
  const text = first ? formatCellText(first, columnValue(first, row), { currency: tableOptions?.currency }, row) : "";
248
248
  return text || `${capitalize(subject)} ${table.keyOf(row)}`;
249
249
  }, [subject, table]);
@@ -785,8 +785,9 @@ export function Crud(given) {
785
785
  /**
786
786
  * Opens Add or Edit. `opened`: the scope the form belongs to and its props - a details popup's, captured when it opened,
787
787
  * so an Edit pressed in it after a switch is that scope's form on that scope's endpoints; default the scope shown.
788
+ * `after`: called once the save is written (a workspace section's Edit reads the record again).
788
789
  */
789
- async function openForm(action, row, values, opened) {
790
+ async function openForm(action, row, values, opened, after) {
790
791
  // A locked record is shown, not edited: its details say what it is, and the page's columns can say why.
791
792
  if (action === "edit" && row && actionLockOf(row, "edit")) {
792
793
  openDetails(row);
@@ -824,7 +825,10 @@ export function Crud(given) {
824
825
  name: action === "create" && asked ? `${name}@${asked}` : name,
825
826
  rowKey,
826
827
  key: row ? table.keyOf(row) : "",
827
- onSaved: (result, saved, joined) => run.current.onSaved(action, row, result, saved, joined, asked),
828
+ onSaved: (result, sent, joined) => {
829
+ run.current.onSaved(action, row, result, sent, joined, asked);
830
+ after?.();
831
+ },
828
832
  });
829
833
  }
830
834
  /**
@@ -866,10 +870,10 @@ export function Crud(given) {
866
870
  next = crudSavedCount(next, held, tree);
867
871
  return next;
868
872
  }
869
- function onSaved(action, row, result, values, joined, asked = scopeIdRef.current) {
873
+ function onSaved(action, row, result, sent, joined, asked = scopeIdRef.current) {
870
874
  // The record as the server now has it: what was sent, over the row it came from, with whatever the answer added.
871
875
  const answered = backend.answeredRow(result);
872
- let record = { ...row, ...values, ...answered };
876
+ let record = crudSavedRecord(row, sent, answered);
873
877
  // A new record whose answer is only its key still gets it, so its row goes in.
874
878
  if (action === "create" && !answered && !readPath(record, rowKey)) {
875
879
  const given = backend.answeredKey(result);
@@ -959,8 +963,34 @@ export function Crud(given) {
959
963
  });
960
964
  return loading;
961
965
  };
966
+ // A section's own Edit in a workspace's Details tab (`details.sections`): the Crud's Edit form or one of its row forms,
967
+ // offered by the rules the row's menu uses, the record read again once saved. Nothing where the form is not offered.
968
+ const sectionEdit = (reread) => (group) => {
969
+ const section = details?.sections?.find(one => one.group === group);
970
+ if (!section?.edit)
971
+ return null;
972
+ const record = table.getRow(key) ?? shown.getValue();
973
+ const mode = modeRef.current;
974
+ if (mode === undefined || section.hidden?.(record))
975
+ return null;
976
+ const again = () => void reread();
977
+ let open = null;
978
+ if (section.edit === "edit") {
979
+ if (allowed.edit && CRUD_ROW_ACTIONS[mode].includes("edit") && !actionLockOf(record, "edit"))
980
+ open = () => void openForm("edit", table.getRow(key) ?? shown.getValue(), undefined, opened, again);
981
+ }
982
+ else {
983
+ const form = latest.current.rowForms?.find(one => one.id === section.edit);
984
+ if (form && crudRowFormOffered(form, record, mode, can) && !form.lock?.(record))
985
+ open = () => openRowForm(form, table.getRow(key) ?? shown.getValue(), again);
986
+ }
987
+ if (!open)
988
+ return null;
989
+ const word = section.label ?? "Edit";
990
+ return (_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `${word} ${group}`, "data-action": "section-edit", onClick: open, children: word }));
991
+ };
962
992
  // A workspace's Details tab leaves out the fields its profile shows (`profile.fields`).
963
- const fields = (record, load, without) => (_jsx(CrudFormBody, { aside: aside, details: true, below: (details?.asidePlace ?? (workspace ? "below" : "beside")) === "below", children: _jsx(CrudDetails, { row: record, columns: crudDetailsWithoutProfile(details?.columns ?? latest.current.columns, without), render: details?.render, currency: tableOptions?.currency, load: load }) }));
993
+ const fields = (record, load, without, reread) => (_jsx(CrudFormBody, { aside: aside, details: true, below: (details?.asidePlace ?? (workspace ? "below" : "beside")) === "below", children: _jsx(CrudDetails, { row: record, columns: crudDetailsWithoutProfile(details?.columns ?? latest.current.columns, without), render: details?.render, currency: tableOptions?.currency, load: load, headingEnd: reread && details?.sections?.length ? sectionEdit(reread) : undefined }) }));
964
994
  // The workspace reads the record once for the whole popup - the profile, every tab and the fields share it - and
965
995
  // again on each `refresh()`. Closing the popup stops the first read, never a refresh's: a tab saved something on
966
996
  // the server, and the lists must hear of it even when the popup closed before the record came back.
@@ -985,6 +1015,27 @@ export function Crud(given) {
985
1015
  writeTreeChanges(tree.cacheKey, crudTreeChanges({ action: "edit", rows: [next], mode: modeRef.current ?? "normal", before: [held] }, tree, backend), tree.rules);
986
1016
  return next;
987
1017
  };
1018
+ // The popup's footer: the load's line, the page's actions, View history, Edit and Close. In a workspace it is the
1019
+ // Details tab's alone (`onTab`): another tab has its own footer (`CrudTabFooter`) and the title bar's X still closes.
1020
+ const footer = (popup) => (_jsxs(DetailsFooter, { load: status, children: [details?.actions
1021
+ ?.filter(action => (!action.endpoint || (ready && can(action.endpoint))) && !action.hidden?.(row))
1022
+ .map(action => (_jsx(Button, { icon: action.icon, "data-action": action.id, onClick: () => action.onSelect(table.getRow(key) ?? row, popup), children: action.label }, action.id))), allowed.history && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconHistory }), "data-action": "history", onClick: () => openHistory(table.getRow(key) ?? row), children: "View history" })), canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: async () => {
1023
+ if (!(await leave()))
1024
+ return;
1025
+ popup.close();
1026
+ void openForm("edit", table.getRow(key) ?? row, undefined, opened);
1027
+ }, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: async () => {
1028
+ if (await leave())
1029
+ popup.close();
1030
+ }, children: "Close" })] }));
1031
+ let footerShown = true;
1032
+ const onTab = (tab, popup) => {
1033
+ const shows = tab === CRUD_DETAILS_TAB;
1034
+ if (shows === footerShown)
1035
+ return;
1036
+ footerShown = shows;
1037
+ popup.update({ footer: shows ? footer : undefined });
1038
+ };
988
1039
  const handle = openPopup({
989
1040
  key: `${name}-view-${key}`,
990
1041
  reopen: "restore",
@@ -1001,19 +1052,9 @@ export function Crud(given) {
1001
1052
  initialFocus: workspace ? '.nx-crud-workspace [role="tab"][aria-selected="true"], .nx-popup__foot button' : aside ? ".nx-popup__foot button" : undefined,
1002
1053
  beforeClose: workspace ? leave : undefined,
1003
1054
  content: workspace
1004
- ? popup => (_jsx(CrudWorkspace, { options: details, form: shown, current: () => table.getRow(key) ?? shown.getValue(), popup: popup, subject: capitalize(subject), load: endpoint ? startReading : undefined, read: status, refresh: refresh, guard: guard, columns: details?.columns ?? latest.current.columns, currency: tableOptions?.currency, details: without => fields(shown.getValue(), endpoint && status.get() !== "done" ? () => startReading() : undefined, without) }))
1055
+ ? popup => (_jsx(CrudWorkspace, { options: details, form: shown, current: () => table.getRow(key) ?? shown.getValue(), popup: popup, subject: capitalize(subject), load: endpoint ? startReading : undefined, read: status, refresh: refresh, guard: guard, columns: details?.columns ?? latest.current.columns, currency: tableOptions?.currency, details: (without, reread) => fields(shown.getValue(), endpoint && status.get() !== "done" ? () => startReading() : undefined, without, reread), onTab: tab => onTab(tab, popup) }))
1005
1056
  : fields(row, endpoint ? readDetails : undefined),
1006
- footer: popup => (_jsxs(DetailsFooter, { load: status, children: [details?.actions
1007
- ?.filter(action => (!action.endpoint || (ready && can(action.endpoint))) && !action.hidden?.(row))
1008
- .map(action => (_jsx(Button, { icon: action.icon, "data-action": action.id, onClick: () => action.onSelect(table.getRow(key) ?? row, popup), children: action.label }, action.id))), allowed.history && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconHistory }), "data-action": "history", onClick: () => openHistory(table.getRow(key) ?? row), children: "View history" })), canEdit && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconPencil }), onClick: async () => {
1009
- if (!(await leave()))
1010
- return;
1011
- popup.close();
1012
- void openForm("edit", table.getRow(key) ?? row, undefined, opened);
1013
- }, children: "Edit" })), _jsx(Button, { variant: "primary", onClick: async () => {
1014
- if (await leave())
1015
- popup.close();
1016
- }, children: "Close" })] })),
1057
+ footer,
1017
1058
  });
1018
1059
  void handle.closed.then(() => reading.abort());
1019
1060
  }
@@ -1022,8 +1063,11 @@ export function Crud(given) {
1022
1063
  const sections = marked ? [...marked.rows.pin, ...marked.rows.favorite] : [];
1023
1064
  return table.getRow(key) ?? heldRows().find(row => table.keyOf(row) === key) ?? sections.find(row => table.keyOf(row) === key);
1024
1065
  }
1025
- /** Opens one of the page's own row forms (`rowForms`) for a record; its save writes the fields its answer names. */
1026
- function openRowForm(form, row) {
1066
+ /**
1067
+ * Opens one of the page's own row forms (`rowForms`) for a record; its save writes the fields its answer names.
1068
+ * `after`: called once the save is written (a workspace section's Edit reads the record again).
1069
+ */
1070
+ function openRowForm(form, row, after) {
1027
1071
  const key = table.keyOf(row);
1028
1072
  // The scope the form was opened on: a save that lands after a switch is that scope's.
1029
1073
  const asked = scopeIdRef.current;
@@ -1036,9 +1080,13 @@ export function Crud(given) {
1036
1080
  name,
1037
1081
  label: labelOf(row),
1038
1082
  onSaved: (result, values) => {
1083
+ // A save there changed the record: what pickers and joins elsewhere looked up from this controller (a run's state
1084
+ // in another page's box) is asked again where it is next shown, as after any change the CRUD makes.
1085
+ expireLookups(api);
1039
1086
  const patch = crudRowFormWrites(form.writes, result, row, values);
1040
1087
  const now = patch ? run.current.writeRow(key, patch, asked) : run.current.heldRecord(key);
1041
1088
  form.onSaved?.(result, now ?? row, values);
1089
+ after?.();
1042
1090
  },
1043
1091
  });
1044
1092
  }
@@ -1294,7 +1342,7 @@ export function Crud(given) {
1294
1342
  max: markLimit(mark),
1295
1343
  }))
1296
1344
  : undefined;
1297
- return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, caption: narrowing && (_jsxs("p", { className: "nx-crud__scope", children: [capitalize(plural), " of:", " ", nonePicked ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "\u2014" }), _jsx("span", { className: "nx-visually-hidden", children: "no row picked" })] })) : (_jsx("strong", { className: "nx-crud__scope-name", children: narrowing.label }))] })), columns: columns, load: load, loadKey: mode && !scopeWaiting ? `${scopeId}#${mode}${scope}` : "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "auto", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: scopeControl || narrowPicker.box ? (_jsxs(_Fragment, { children: [scopeControl, narrowPicker.box, props.toolbar] })) : (props.toolbar), add: add, selectable: selectable, massActions: massActionsFor, selectBy: selectBy, rowActions: rowActions, sections: sections, rowMarks: marksShown ? rowMarks : undefined, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, onPick: props.onPick, onReload: () => {
1345
+ return (_jsx("div", { id: id, className: cx("nx-crud", className), style: style, "data-mode": mode, children: _jsx(DataTable, { table: table, caption: narrowing && (_jsxs("p", { className: "nx-crud__scope", children: [capitalize(plural), " of:", " ", nonePicked ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "\u2014" }), _jsx("span", { className: "nx-visually-hidden", children: "no row picked" })] })) : (_jsx("strong", { className: "nx-crud__scope-name", children: narrowing.label }))] })), columns: columns, load: load, loadKey: mode && !scopeWaiting ? `${scopeId}#${mode}${scope}` : "waiting", paging: paging, rowKey: rowKey, rowLabel: labelOf, "aria-label": tableLabel, "aria-labelledby": props["aria-labelledby"], toolbar: modes.length > 1 && mode ? (_jsx(TabButtons, { size: "sm", labels: "auto", options: modeOptions, value: mode, onChange: setChosenMode, "aria-label": `${capitalize(plural)}: lists` })) : undefined, actions: scopeControl || narrowPicker.box ? (_jsxs(_Fragment, { children: [scopeControl, narrowPicker.box, props.toolbar] })) : (props.toolbar), add: add, selectable: selectable, massActions: massActionsFor, selectBy: selectBy, rowActions: rowActions, sections: sections, rowMarks: marksShown ? rowMarks : undefined, onRowOpen: onRowOpen, openOnClick: tableOptions?.openOnClick, rowStrong: tableOptions?.rowStrong, onPick: props.onPick, onReload: () => {
1298
1346
  clearCaches();
1299
1347
  reloadMarks();
1300
1348
  }, emptyMessage: tableOptions?.emptyMessage ?? (mode ? emptyMessage(mode) : undefined),
@@ -1,7 +1,7 @@
1
1
  import { type IconNode } from "nexus-icons";
2
- import type { ReactNode } from "react";
2
+ import { type ReactNode } from "react";
3
3
  import type { CrudBackend } from "../../Helpers/CrudBackend.ts";
4
- import { type CrudJoinedRecord } from "../../Helpers/CrudHelpers.ts";
4
+ import { type CrudJoinedRecord, type CrudSent } from "../../Helpers/CrudHelpers.ts";
5
5
  import type { ApiResult } from "../../Interfaces/ApiInterfaces.ts";
6
6
  import type { CrudEndpoints, CrudFormOptions, CrudInputs, CrudMassActionName, CrudRowActionName } from "../../Interfaces/CrudInterfaces.ts";
7
7
  import { type FormValues, type InputParams } from "../../Interfaces/FormInterfaces.ts";
@@ -98,10 +98,11 @@ export interface CrudFormRequest<TRow extends FormValues = FormValues> {
98
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
99
  label?: string;
100
100
  /**
101
- * After the save: the answer, the values sent, and what the form's dropdowns know of the records their keys name
101
+ * After the save: the answer, what was sent (the values, the body `transform` made, the inputs `showWhen` hid - the
102
+ * record is built from them, `crudSavedRecord`), and what the form's dropdowns know of the records their keys name
102
103
  * (`joined`), so the record's joined names are written with no call. The popup closes itself.
103
104
  */
104
- onSaved: (result: unknown, saved: TRow, joined: CrudJoinedLookup) => void;
105
+ onSaved: (result: unknown, sent: CrudSent, joined: CrudJoinedLookup) => void;
105
106
  }
106
107
  /**
107
108
  * The record behind a key of a form's dropdown (`name`), as a save names its joins (`crudJoinedNames`): the item the
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
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";
4
+ import { useLayoutEffect, useRef, useState } from "react";
5
+ import { isAbortError, unwrapApiResult } from "../../Helpers/ApiResponses.js";
6
+ import { crudComputeWith, crudFormPlace, crudHiddenOnSave, crudMessageAction, crudRowEndpoint } from "../../Helpers/CrudHelpers.js";
6
7
  import { cx } from "../../Helpers/ClassHelpers.js";
7
8
  import { FormStore } from "../../Helpers/FormStore.js";
8
9
  import { lockedFieldChange, lockInputs } from "../../Helpers/InputParamsHelpers.js";
@@ -53,6 +54,24 @@ export function CrudFormBody({ aside, details, below, children }) {
53
54
  return children;
54
55
  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
  }
57
+ /**
58
+ * The Add or Edit form's `computeWith` (`CrudFormOptions.computeWith`), worked out from the form's values after every
59
+ * change - before the browser paints, so a worked-out cell never shows the old figure - and kept the same object while
60
+ * the rule answers the same fields and values, so the form's worked-out cells redraw only when it says something new.
61
+ */
62
+ function CrudComputeWith({ form, rule, action, row, children, }) {
63
+ const latest = useRef({ rule, action, row });
64
+ latest.current = { rule, action, row };
65
+ const [computeWith, setComputeWith] = useState(() => crudComputeWith(rule, form.getValue(), { action, row }, undefined));
66
+ useLayoutEffect(() => {
67
+ if (!latest.current.rule)
68
+ return;
69
+ const read = () => setComputeWith((was) => crudComputeWith(latest.current.rule, form.getValue(), { action: latest.current.action, row: latest.current.row }, was));
70
+ read();
71
+ return form.subscribeValue(read);
72
+ }, [form]);
73
+ return children(computeWith);
74
+ }
56
75
  /** 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
76
  export async function crudInputsFor(inputs, action, row) {
58
77
  if (typeof inputs === "function")
@@ -126,13 +145,24 @@ export async function openCrudForm(request) {
126
145
  const form = new FormStore();
127
146
  const loader = options.load;
128
147
  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 }))
148
+ const defaultsLoader = options.createDefaults;
149
+ // A new record's defaults read as the form opens: a help, so a failed read opens the form on the values it has.
150
+ const loadDefaults = action === "create" && defaultsLoader
151
+ ? (signal) => defaultsLoader(signal).then(loaded => ({ ...newValues, ...loaded, ...values }), (error) => {
152
+ if (signal.aborted || isAbortError(error))
153
+ throw error;
154
+ toast.warning(`The usual values of a new ${subject} could not be filled in`, error instanceof Error && error.message ? error.message : "The server did not answer.");
155
+ return opening;
156
+ })
135
157
  : undefined;
158
+ const loadValue = loadDefaults ??
159
+ (action === "edit" && row && loader && loader !== "row"
160
+ ? loader === "details"
161
+ ? api.details
162
+ ? (signal) => browserApi.get(api.details, { params: { id, [rowKey]: id }, signal, toast: false }).then(answer => ({ ...row, ...unwrapApiResult(answer) }))
163
+ : undefined
164
+ : (signal) => loader(row, signal).then(full => ({ ...row, ...full }))
165
+ : undefined);
136
166
  const handle = openPopup({
137
167
  key: popupKey,
138
168
  // One still open under this key was untouched, or opened for another place: a fresh form takes its place.
@@ -150,10 +180,10 @@ export async function openCrudForm(request) {
150
180
  form.dropDraft();
151
181
  return discard;
152
182
  },
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
- } }) })),
183
+ content: popup => (_jsx(CrudFormBody, { aside: typeof options.aside === "function" ? options.aside({ form, action, row }) : options.aside, children: _jsx(CrudComputeWith, { form: form, rule: options.computeWith, action: action, row: row, children: computeWith => (_jsx(ApiForm, { form: form, computeWith: computeWith, 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, _values, _form, sent) => {
184
+ onSaved(result, { values: sent.values, body: sent.body, hidden: crudHiddenOnSave(inputs, sent.values) }, joinedLookup(inputs, form));
185
+ popup.close();
186
+ } })) }) })),
157
187
  footer: popup => _jsx(FormButtons, { form: form, submitLabel: action === "create" ? `Add ${subject}` : "Save", onCancel: () => popup.close() }),
158
188
  });
159
189
  const entry = { name, action, handle, form: form, place };
@@ -21,8 +21,13 @@ export interface AuditChange {
21
21
  export interface AuditEntry {
22
22
  /** When, in UTC. Every entry of one mass call shares it. */
23
23
  creationTime: string;
24
- /** Who made the change. */
24
+ /** Who made the change, as a token made for the reader. */
25
25
  creatorUserId?: string;
26
+ /**
27
+ * Who made the change, by name, when the backend's answer carries it (`AuditResponseContract.CreatorName`): the entry
28
+ * says "by Sita Rai" in place of the token.
29
+ */
30
+ creatorName?: string | null;
26
31
  /** The company they made it in. */
27
32
  companyId?: string;
28
33
  /** The branch they made it in. */
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { iconAlertCircle, iconHistory, iconInfoCircle, iconLoader2, iconRefresh, InlineIcon } from "nexus-icons";
4
4
  import { Fragment, useEffect, useMemo, useState } from "react";
5
+ import { auditActorText, resolveAuditActors, shortToken } from "../../Helpers/AuditEntries.js";
5
6
  import { getCrudBackend } from "../../Helpers/CrudBackend.js";
6
7
  import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
7
8
  import { pluralize } from "../../Helpers/MessageBuilder.js";
@@ -27,11 +28,6 @@ const ID = 6;
27
28
  /** When an entry happened, written as the table writes a date-time column: the user's settings, in their zone. */
28
29
  const WHEN = { key: "creationTime", label: "When", format: "date-time" };
29
30
  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
31
  /** One side of a change, in words; "" when that side has no value. */
36
32
  function sideText(change, value, label, options) {
37
33
  if (label)
@@ -53,7 +49,8 @@ function SideCell({ text, missing }) {
53
49
  function HistoryEntry({ entry, words, subject, plural, options }) {
54
50
  const changes = entry.changes ?? [];
55
51
  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."] }))] }));
52
+ const actor = auditActorText(entry);
53
+ return (_jsxs(_Fragment, { children: [_jsx("h3", { children: whatOf(entry, words) }), _jsxs("p", { children: [_jsx("time", { dateTime: entry.creationTime, children: formatCellText(WHEN, entry.creationTime, options) }), actor && ` · ${actor}`, 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
54
  }
58
55
  /** One page of a record's history, newest first: the entries after the first `skipCount`. */
59
56
  function readHistory(endpoint, id, skipCount, options = {}) {
@@ -101,6 +98,17 @@ export function AuditHistory({ endpoint, id, subject = "record", subjectPlural,
101
98
  });
102
99
  return () => controller.abort();
103
100
  }, [endpoint, id, attempt]);
101
+ // The people the answer names nobody for, named by the app (`setAuditActorNames`): one call for each page shown, for
102
+ // the tokens it has had no answer for. A page shown meanwhile stops the call and asks for both pages' people.
103
+ const [, setNamed] = useState(0);
104
+ useEffect(() => {
105
+ const controller = new AbortController();
106
+ void resolveAuditActors(entries, controller.signal).then(learnt => {
107
+ if (learnt)
108
+ setNamed(count => count + 1);
109
+ });
110
+ return () => controller.abort();
111
+ }, [entries]);
104
112
  /** The next page, after the entries already shown. A failure is the api's own toast, and the button stays to try again. */
105
113
  async function showMore() {
106
114
  setBusy(true);