nexus-shared 3.0.0 → 3.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 (121) hide show
  1. package/CHANGELOG.md +446 -262
  2. package/LICENSE +21 -21
  3. package/README.md +1084 -1000
  4. package/dist/Client.Index.d.ts +3 -0
  5. package/dist/Client.Index.js +3 -0
  6. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  7. package/dist/Components/Chats/ChatParts.js +3 -7
  8. package/dist/Components/Chats/ChatThread.js +3 -3
  9. package/dist/Components/Documents/Button.d.ts +6 -1
  10. package/dist/Components/Documents/Button.js +2 -2
  11. package/dist/Components/Documents/Menu.js +21 -9
  12. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  13. package/dist/Components/Documents/SplitButton.js +7 -5
  14. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  15. package/dist/Components/Documents/TabButtons.js +2 -2
  16. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  17. package/dist/Components/Forms/ApiForm.js +1 -1
  18. package/dist/Components/Forms/Crud.js +177 -58
  19. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  20. package/dist/Components/Forms/CrudParts.js +37 -5
  21. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  22. package/dist/Components/Forms/ExcelImport.js +189 -60
  23. package/dist/Components/Forms/Form.js +5 -13
  24. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  25. package/dist/Components/Forms/SubmitForm.js +23 -12
  26. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  27. package/dist/Components/Inputs/DropdownParts.js +4 -6
  28. package/dist/Components/Inputs/NumberBox.js +24 -2
  29. package/dist/Components/Inputs/RowsInput.js +2 -2
  30. package/dist/Components/Inputs/Slider.js +4 -1
  31. package/dist/Components/Inputs/TabularForm.js +42 -10
  32. package/dist/Components/Inputs/TimePanel.js +4 -3
  33. package/dist/Components/Layouts/Popup.js +1 -4
  34. package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
  35. package/dist/Components/Layouts/ThemePicker.js +77 -6
  36. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  37. package/dist/Components/Layouts/ThemeScript.js +4 -2
  38. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  39. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  40. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  41. package/dist/Components/Viewers/Avatar.js +21 -0
  42. package/dist/Components/Viewers/DataTable.js +192 -44
  43. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  44. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  45. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  46. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  47. package/dist/Components/Viewers/DataTableParts.d.ts +2 -2
  48. package/dist/Components/Viewers/DataTableParts.js +3 -3
  49. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  50. package/dist/Helpers/AnimationHelpers.js +20 -2
  51. package/dist/Helpers/CrudBackend.d.ts +7 -4
  52. package/dist/Helpers/CrudBackend.js +4 -2
  53. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  54. package/dist/Helpers/ExcelBackend.js +8 -2
  55. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  56. package/dist/Helpers/ExcelHelpers.js +55 -13
  57. package/dist/Helpers/FormStore.d.ts +9 -5
  58. package/dist/Helpers/FormStore.js +25 -5
  59. package/dist/Helpers/MessageBuilder.js +1 -0
  60. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  61. package/dist/Helpers/NumberHelpers.js +41 -3
  62. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  63. package/dist/Helpers/PopoverHelpers.js +52 -17
  64. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  65. package/dist/Helpers/PopoverHooks.js +62 -0
  66. package/dist/Helpers/RowsStore.d.ts +7 -2
  67. package/dist/Helpers/RowsStore.js +15 -3
  68. package/dist/Helpers/TableExport.js +12 -12
  69. package/dist/Helpers/TableFilters.d.ts +25 -0
  70. package/dist/Helpers/TableFilters.js +162 -0
  71. package/dist/Helpers/TableHelpers.d.ts +6 -3
  72. package/dist/Helpers/TableHelpers.js +8 -4
  73. package/dist/Helpers/TableStore.d.ts +11 -3
  74. package/dist/Helpers/TableStore.js +16 -5
  75. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  76. package/dist/Helpers/ThemeHelpers.js +46 -9
  77. package/dist/Helpers/UserPreferences.d.ts +14 -0
  78. package/dist/Helpers/UserPreferences.js +99 -0
  79. package/dist/Interfaces/CrudInterfaces.d.ts +20 -1
  80. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  81. package/dist/Interfaces/FormInterfaces.d.ts +33 -5
  82. package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
  83. package/dist/Interfaces/PreferenceInterfaces.js +28 -0
  84. package/dist/Interfaces/TableInterfaces.d.ts +59 -12
  85. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  86. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  87. package/dist/Services/ExcelApi.d.ts +3 -1
  88. package/dist/Services/ExcelApi.js +1 -1
  89. package/dist/Services/ThemeService.d.ts +18 -2
  90. package/dist/Services/ThemeService.js +80 -16
  91. package/dist/Shared.Index.d.ts +2 -0
  92. package/dist/Shared.Index.js +2 -0
  93. package/package.json +85 -85
  94. package/src/Styles/Nexus.Avatar.css +79 -0
  95. package/src/Styles/Nexus.Base.css +117 -70
  96. package/src/Styles/Nexus.Button.css +180 -153
  97. package/src/Styles/Nexus.Calendar.css +273 -275
  98. package/src/Styles/Nexus.Chat.css +1443 -1489
  99. package/src/Styles/Nexus.Choice.css +343 -345
  100. package/src/Styles/Nexus.Crud.css +168 -161
  101. package/src/Styles/Nexus.DateTime.css +81 -81
  102. package/src/Styles/Nexus.Dropdown.css +725 -774
  103. package/src/Styles/Nexus.Editor.css +295 -297
  104. package/src/Styles/Nexus.Excel.css +389 -370
  105. package/src/Styles/Nexus.Form.css +279 -321
  106. package/src/Styles/Nexus.Index.css +37 -36
  107. package/src/Styles/Nexus.Input.css +602 -637
  108. package/src/Styles/Nexus.Markdown.css +237 -265
  109. package/src/Styles/Nexus.Menu.css +237 -259
  110. package/src/Styles/Nexus.Popup.css +724 -731
  111. package/src/Styles/Nexus.RichText.css +2 -32
  112. package/src/Styles/Nexus.Rows.css +637 -596
  113. package/src/Styles/Nexus.Slider.css +276 -278
  114. package/src/Styles/Nexus.Tab.Buttons.css +432 -419
  115. package/src/Styles/Nexus.Table.css +1386 -1296
  116. package/src/Styles/Nexus.Theme.Picker.css +286 -232
  117. package/src/Styles/Nexus.Themes.css +194 -194
  118. package/src/Styles/Nexus.Time.css +144 -147
  119. package/src/Styles/Nexus.Toast.css +263 -265
  120. package/src/Styles/Nexus.Tokens.css +89 -72
  121. package/src/Styles/Nexus.Tree.css +431 -435
@@ -1,24 +1,31 @@
1
1
  "use client";
2
2
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { iconAlertTriangle, iconChevronDown, iconCircleCheck, iconCopy, iconDownload, iconFileImport, iconTable, iconUpload, InlineIcon } from "nexus-icons";
4
- import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3
+ import { iconAlertTriangle, iconChevronDown, iconCircleCheck, iconCopy, iconDownload, iconFile, iconFileImport, iconRefresh, iconTable, iconUpload, InlineIcon } from "nexus-icons";
4
+ import { createRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
- import { EXCEL_FILE_ACCEPT, excelColumnInputs, excelCount, excelEditorRows, excelPlural, excelProblemPlace, excelProblems, excelRowsOfKind, excelSheetRows, excelText, excelWritableRows, isExcelRowEdited, } from "../../Helpers/ExcelHelpers.js";
6
+ import { EXCEL_FILE_ACCEPT, EXCEL_ROW_NUMBER, excelColumnInputs, excelCount, excelEditorRows, excelPlural, excelProblemPlace, excelProblems, excelRowNumber, excelRowsOfKind, excelSheetRows, excelText, excelWritableRows, hasExcelValues, isExcelRowTyped, } from "../../Helpers/ExcelHelpers.js";
7
7
  import { ExcelRowKind } from "../../Interfaces/ExcelInterfaces.js";
8
8
  import { downloadExcelTemplate, excelSettings, importExcelRows, verifyExcelFile } from "../../Services/ExcelApi.js";
9
9
  import { toast } from "../Layouts/Toaster.js";
10
+ import { messageBox } from "../Layouts/MessageBox.js";
10
11
  import { openPopup } from "../Layouts/PopupHost.js";
11
12
  import { Button } from "../Documents/Button.js";
13
+ import { Dropdown } from "../Inputs/Dropdown.js";
12
14
  import { TabularForm } from "../Inputs/TabularForm.js";
13
15
  const DEFAULT_MAX_HEIGHT = 320;
16
+ /** A bulk insert starts from one empty row: the editor gives each column its own empty value. */
17
+ const BULK_ROWS = [{}];
14
18
  /**
15
- * The three steps of an import in one panel: the template, the file, and the review.
19
+ * The three steps of an import in one panel: the template, the file, and the review - or, for a bulk insert, the rows
20
+ * typed in and the import.
16
21
  *
17
22
  * @example
18
23
  * <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
24
+ * <ExcelImport endpoints={REGION_SHEET} subject="region" mode="bulk" onImported={() => table.reload()} />
19
25
  */
20
- export function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight = DEFAULT_MAX_HEIGHT, className }) {
26
+ export function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight = DEFAULT_MAX_HEIGHT, mode = "file", ref, className }) {
21
27
  const id = useId();
28
+ const bulk = mode === "bulk";
22
29
  const fileRef = useRef(null);
23
30
  const newRef = useRef(null);
24
31
  const sameRef = useRef(null);
@@ -26,10 +33,19 @@ export function ExcelImport({ endpoints, subject: given, params, onImported, onC
26
33
  const [file, setFile] = useState(null);
27
34
  const [verify, setVerify] = useState(null);
28
35
  const [imported, setImported] = useState(null);
36
+ /** The rows already there when the import was sent: skipped, and named in its report. */
37
+ const [skipped, setSkipped] = useState([]);
29
38
  const [failure, setFailure] = useState(null);
39
+ /** What an import that wrote nothing refused, row by row: listed under the rows, which stay to be fixed. */
40
+ const [refused, setRefused] = useState(null);
41
+ /** The status the person picked for the new records; until they pick, the backend's default. */
42
+ const [pickedStatus, setPickedStatus] = useState(null);
30
43
  const [busy, setBusy] = useState("settings");
31
44
  /** Bumped on every answer: the editors take their rows again instead of being told to change them. */
32
45
  const [generation, setGeneration] = useState(0);
46
+ /** The new rows' editor as the person changes it: its rows, and those an import would send. Null: as it started. */
47
+ const [editorRows, setEditorRows] = useState(null);
48
+ const [sendableRows, setSendableRows] = useState(null);
33
49
  // A page may well write its endpoints and its params as literals, which are a new object every render. What they
34
50
  // say is what matters, so the settings are asked for again only when that changes.
35
51
  const sheetKey = `${String(endpoints.settings.module)}|${endpoints.settings.path}|${JSON.stringify(params ?? null)}`;
@@ -43,6 +59,9 @@ export function ExcelImport({ endpoints, subject: given, params, onImported, onC
43
59
  return;
44
60
  if (answer.ok && answer.result)
45
61
  setSettings(answer.result);
62
+ // A file brings its own columns, but rows typed here have nothing else to be drawn from.
63
+ else if (bulk)
64
+ setFailure(answer.message ?? excelText("bulkUnavailable"));
46
65
  setBusy(null);
47
66
  })();
48
67
  return () => {
@@ -51,18 +70,52 @@ export function ExcelImport({ endpoints, subject: given, params, onImported, onC
51
70
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
71
  }, [sheetKey]);
53
72
  const subject = given ?? verify?.subject ?? settings?.subject ?? "row";
73
+ const statuses = settings?.recordStatuses ?? [];
74
+ const recordStatus = statuses.length ? (pickedStatus ?? settings?.defaultRecordStatus ?? statuses[0].value) : undefined;
75
+ const statusLabel = statuses.find(status => status.value === recordStatus)?.label;
54
76
  const columns = verify?.columns ?? settings?.columns ?? [];
55
77
  const inputs = useMemo(() => excelColumnInputs(columns), [columns]);
56
78
  const fresh = useMemo(() => (verify ? excelWritableRows(verify.rows) : []), [verify]);
57
79
  const same = useMemo(() => (verify ? excelRowsOfKind(verify.rows, ExcelRowKind.Duplicate) : []), [verify]);
58
80
  const freshRows = useMemo(() => excelEditorRows(fresh, columns), [fresh, columns]);
59
81
  const sameRows = useMemo(() => excelEditorRows(same, columns), [same, columns]);
82
+ /**
83
+ * The columns a row typed here can never fill: a required list is picked in the spreadsheet, under a key made for the
84
+ * person who downloaded it, which no browser can make. A sheet with one is imported from a file only, and its review
85
+ * takes no rows but the file's.
86
+ */
87
+ const requiredLists = useMemo(() => columns.filter(column => column.isReference && column.isRequired), [columns]);
88
+ const startRows = bulk ? BULK_ROWS : freshRows;
89
+ const rowCount = editorRows ?? startRows.length;
90
+ const sendable = sendableRows ?? (bulk ? 0 : freshRows.length);
91
+ const typing = bulk && requiredLists.length === 0;
92
+ /** The rows only this screen has - typed here, or a sheet's row edited here - which closing or starting over loses. */
93
+ const unsavedRows = () => (newRef.current?.getValue() ?? []).filter(row => isExcelRowTyped(row, columns)).length;
94
+ useImperativeHandle(ref, () => ({ unsavedRows }), [columns]);
95
+ /** As the person changes the rows: a sheet's row is sent as it is, and a row added here only with something in it. */
96
+ function onRowsChange(rows) {
97
+ setEditorRows(rows.length);
98
+ setSendableRows(rows.filter(row => excelRowNumber(row) > 0 || hasExcelValues(row, columns)).length);
99
+ }
100
+ function startEditor() {
101
+ setEditorRows(null);
102
+ setSendableRows(null);
103
+ setRefused(null);
104
+ setGeneration(value => value + 1);
105
+ }
106
+ /** Starting over clears what was typed here too, so it asks first, as closing the window does. */
107
+ async function onStartOver() {
108
+ if (await confirmDiscardRows(unsavedRows()))
109
+ reset();
110
+ }
60
111
  function reset() {
61
112
  setFile(null);
62
113
  setVerify(null);
63
114
  setImported(null);
115
+ setSkipped([]);
116
+ setPickedStatus(null);
64
117
  setFailure(null);
65
- setGeneration(value => value + 1);
118
+ startEditor();
66
119
  if (fileRef.current)
67
120
  fileRef.current.value = "";
68
121
  }
@@ -89,102 +142,178 @@ export function ExcelImport({ endpoints, subject: given, params, onImported, onC
89
142
  return;
90
143
  }
91
144
  setVerify(answer.result);
92
- setGeneration(value => value + 1);
145
+ startEditor();
93
146
  }
94
147
  async function onImport() {
95
- if (!verify)
96
- return;
97
- const newValues = newRef.current?.getValue() ?? freshRows;
98
- const sameValues = sameRef.current?.getValue() ?? sameRows;
99
- // A duplicate is never updated, so one the person left alone is not sent at all; one they edited is a new record.
100
- const edited = sameValues.filter(row => isExcelRowEdited(row, columns));
101
- if (newValues.length && newRef.current && !newRef.current.validate())
148
+ const editor = newRef.current;
149
+ if (!editor || (!verify && !bulk))
102
150
  return;
103
- if (edited.length && sameRef.current && !sameRef.current.validate())
151
+ // A row added here and left empty is not a row - Enter on the last one makes one - so it is dropped, not refused.
152
+ const before = editor.getValue();
153
+ for (let index = before.length - 1; index >= 0; index--) {
154
+ const row = before[index];
155
+ if (excelRowNumber(row) === 0 && !hasExcelValues(row, columns))
156
+ editor.remove(index, { undo: false });
157
+ }
158
+ const newValues = editor.getValue();
159
+ if (newValues.length && !editor.validate())
104
160
  return;
105
- const rows = excelSheetRows([...newValues, ...edited], columns);
161
+ // A row already there is never updated and cannot be edited here, so only the new rows are sent. A row added here
162
+ // goes after every row the file had - those already there and those taken out of the review too - so none takes a
163
+ // number the file uses; a bulk insert has no file, and numbers its rows from 1.
164
+ const lastRow = verify ? verify.rows.reduce((last, row) => Math.max(last, row.number), 0) : 0;
165
+ const rows = excelSheetRows(newValues, columns, lastRow);
106
166
  if (!rows.length) {
107
167
  setFailure(excelText("importNothing"));
108
168
  return;
109
169
  }
110
170
  setBusy("importing");
111
- const answer = await importExcelRows(endpoints, rows, callOptions);
171
+ const answer = await importExcelRows(endpoints, rows, { ...callOptions, recordStatus });
112
172
  setBusy(null);
173
+ // An import that wrote nothing - and a call that never answered - leaves the rows where they are, to be fixed and
174
+ // sent again: the panel says why, and lists under them each row the backend refused, when it sent its report.
113
175
  if (!answer.ok) {
114
176
  setFailure(answer.message ?? excelText("importedNone"));
115
- if (answer.result)
116
- setImported(answer.result);
177
+ if (answer.result?.refused?.length)
178
+ setRefused(byRowNumber(answer.result.refused));
117
179
  return;
118
180
  }
119
181
  const written = answer.result;
120
182
  if (!written)
121
183
  return;
184
+ // Anything else ends the import: the panel shows what was written, what was not and why, and what was skipped.
185
+ // Another file, or another batch, is a Start over.
122
186
  setFailure(null);
187
+ setSkipped(same);
123
188
  setImported(written);
124
- toast.success(`${excelCount(written.created, subject)} imported`, { message: written.note });
125
- // What was written is gone from the file's point of view: only what was refused stays to be fixed and sent again,
126
- // and the counts are what those rows are now - not what the verify said about a file that has been half written.
127
- const left = written.refused ?? [];
128
- setVerify(current => current
129
- ? {
130
- ...current,
131
- rows: left,
132
- read: left.length,
133
- new: left.filter(row => row.kind === ExcelRowKind.New).length,
134
- duplicates: left.filter(row => row.kind === ExcelRowKind.Duplicate).length,
135
- invalid: left.filter(row => row.kind === ExcelRowKind.Invalid).length,
136
- isReady: false,
137
- }
138
- : current);
139
- setGeneration(value => value + 1);
140
- onImported?.(written);
189
+ if (written.created > 0) {
190
+ toast.success(`${excelCount(written.created, subject)} imported`, { message: written.note });
191
+ onImported?.(written);
192
+ }
141
193
  }
142
- /** A problem names a cell: clicking it brings that row into view and puts the caret in it. */
143
- const goTo = (editor, list) => (problem) => {
144
- const index = list.findIndex(row => row.number === problem.row);
145
- if (index >= 0)
194
+ /**
195
+ * A problem names a row: clicking it brings that row into view, wherever it has moved to, and puts the caret in it.
196
+ * A typed row has no sheet row; the import numbered it by its place in the editor.
197
+ */
198
+ const goTo = (editor) => (problem) => {
199
+ const rows = editor.current?.getValue() ?? [];
200
+ let index = rows.findIndex(row => excelRowNumber(row) === problem.row);
201
+ if (index < 0 && bulk)
202
+ index = problem.row - 1;
203
+ if (index >= 0 && index < rows.length)
146
204
  editor.current?.focusRow(index, problem.field);
147
205
  };
148
206
  const checking = busy === "checking";
149
207
  const importing = busy === "importing";
150
- const canImport = Boolean(verify) && !busy && (fresh.length > 0 || same.length > 0);
151
- return (_jsxs("section", { className: cx("nx-excel", className), "aria-label": `Import ${excelPlural(subject)} from a spreadsheet`, "data-nx-excel": "", children: [_jsxs("ol", { className: "nx-excel__steps", children: [_jsxs("li", { children: ["Download the template", templateDownload === false ? "" : " below", ": every list in it is a dropdown, and the ids beside a choice fill themselves."] }), _jsxs("li", { children: ["Fill in one row per ", subject, ". Leave the locked columns alone."] }), _jsx("li", { children: "Send it here to be checked. Nothing is written until you import." })] }), _jsxs("div", { className: "nx-excel__pick", children: [templateDownload !== false && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconDownload }), loading: busy === "template", onClick: onTemplate, children: excelText("template") })), _jsx("input", { ref: fileRef, id: `${id}-file`, className: "nx-visually-hidden", type: "file", accept: EXCEL_FILE_ACCEPT, onChange: event => void onPicked(event.currentTarget.files?.[0]) }), _jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconFileImport }), loading: checking, onClick: () => fileRef.current?.click(), children: file ? excelText("change") : excelText("choose") }), _jsx("p", { className: "nx-excel__file", "data-nx-excel-file": "", children: checking ? (_jsxs(_Fragment, { children: [excelText("checking"), " ", _jsx("strong", { children: file?.name }), String.fromCharCode(8230)] })) : file ? (_jsx("strong", { children: file.name })) : (_jsx("span", { className: "nx-excel__muted", children: excelText("nothingPicked") })) }), settings && (_jsxs("p", { className: "nx-excel__limits", children: [excelCount(settings.maxImportRows, "row"), " at most per file"] }))] }), failure && (_jsxs("p", { className: "nx-excel__failure", role: "alert", "data-nx-excel-failure": "", children: [_jsx(InlineIcon, { icon: iconAlertTriangle, "aria-hidden": "true" }), _jsx("span", { children: failure })] })), imported && _jsx(ImportedReport, { answer: imported, subject: subject }), verify && (_jsxs("div", { className: "nx-excel__review", "data-nx-excel-review": "", children: [!imported && _jsx("p", { className: "nx-excel__note", children: verify.note }), _jsx(ReviewSection, { id: `${id}-new`, title: excelText("newRows"), tone: verify.invalid > 0 ? "warning" : "neutral", count: fresh.length, hint: verify.invalid > 0 ? `${excelCount(verify.invalid, "row")} to fix before anything is written.` : undefined, rows: fresh, onGo: goTo(newRef, fresh), open: true, children: _jsx(TabularForm, { ref: newRef, "aria-label": excelText("newRows"), inputs: inputs, itemLabel: subject, defaultValue: freshRows, addable: false, sortable: false, maxHeight: maxHeight, size: "sm" }, `new-${generation}`) }), _jsx(ReviewSection, { id: `${id}-same`, title: excelText("duplicates"), tone: "muted", count: same.length, hint: excelText("duplicateHint"), rows: same, onGo: goTo(sameRef, same), children: _jsx(TabularForm, { ref: sameRef, "aria-label": excelText("duplicates"), inputs: inputs, itemLabel: subject, defaultValue: sameRows, addable: false, removable: false, sortable: false, maxHeight: maxHeight, size: "sm" }, `same-${generation}`) })] })), _jsxs("footer", { className: "nx-excel__actions", children: [verify && (fresh.length > 0 || same.length > 0) && (_jsxs("p", { className: "nx-excel__sending", "data-nx-excel-sending": "", children: [fresh.length > 0 ? `${excelCount(fresh.length, subject)} to send.` : "Nothing to send yet.", same.length > 0 ? ` Edit ${same.length === 1 ? `the one ${subject}` : `one of the ${excelCount(same.length, subject)}`} already there to send it too.` : ""] })), _jsxs("div", { className: "nx-excel__buttons", children: [file && !busy && (_jsx(Button, { variant: "ghost", onClick: reset, children: excelText("startOver") })), onClose && (_jsx(Button, { variant: "secondary", onClick: onClose, children: excelText("close") })), _jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconUpload }), loading: importing, disabled: !canImport, onClick: () => void onImport(), children: excelText("importLabel") })] })] })] }));
208
+ const reviewing = !imported && (bulk ? settings !== null : verify !== null);
209
+ const canImport = !busy && sendable > 0 && (bulk ? typing : Boolean(verify));
210
+ const note = bulk
211
+ ? typing
212
+ ? `Type one ${subject} per row, up to ${(settings?.maxImportRows ?? 0).toLocaleString("en-US")}. Nothing is written until you import.`
213
+ : `${requiredLists.map(column => column.header).join(", ")} can only be picked in the spreadsheet, so ${excelPlural(subject)} are imported from one: use Import from a spreadsheet.`
214
+ : verify?.note;
215
+ /** What the new rows' line says needs doing: what the import refused, or what the file's check found. */
216
+ const toFix = refused
217
+ ? `${excelCount(refused.length, "row")} not written: fix or remove ${refused.length === 1 ? "it" : "them"}, and import again.`
218
+ : verify && verify.invalid > 0
219
+ ? `${excelCount(verify.invalid, "row")} to fix before anything is written.`
220
+ : undefined;
221
+ return (_jsxs("section", { className: cx("nx-excel", className), "aria-label": bulk ? `Bulk insert ${excelPlural(subject)}` : `Import ${excelPlural(subject)} from a spreadsheet`, "data-nx-excel": "", children: [!bulk && !file && (_jsxs("ol", { className: "nx-excel__steps", children: [_jsxs("li", { children: ["Download the template", templateDownload === false ? "" : " below", ": every list in it is a dropdown, and the ids beside a choice fill themselves."] }), _jsxs("li", { children: ["Fill in one row per ", subject, ". Leave the locked columns alone."] }), _jsx("li", { children: "Send it here to be checked. Nothing is written until you import." })] })), !bulk && (_jsxs("div", { className: "nx-excel__pick", children: [!file && templateDownload !== false && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconDownload }), loading: busy === "template", onClick: onTemplate, children: excelText("template") })), _jsx("input", { ref: fileRef, id: `${id}-file`, className: "nx-visually-hidden", type: "file", accept: EXCEL_FILE_ACCEPT, onChange: event => void onPicked(event.currentTarget.files?.[0]) }), !file && (_jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconFileImport }), onClick: () => fileRef.current?.click(), children: excelText("choose") })), _jsx("p", { className: "nx-excel__file", "data-nx-excel-file": "", children: checking ? (_jsxs(_Fragment, { children: [excelText("checking"), " ", _jsx("strong", { children: file?.name }), String.fromCharCode(8230)] })) : file ? (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconFile, "aria-hidden": "true" }), _jsx("strong", { children: file.name })] })) : (_jsx("span", { className: "nx-excel__muted", children: excelText("nothingPicked") })) }), settings && (_jsxs("p", { className: "nx-excel__limits", children: [excelCount(settings.maxImportRows, "row"), " at most per file"] }))] })), failure && (_jsxs("p", { className: "nx-excel__failure", role: "alert", "data-nx-excel-failure": "", children: [_jsx(InlineIcon, { icon: iconAlertTriangle, "aria-hidden": "true" }), _jsx("span", { children: failure })] })), imported && _jsx(ImportedReport, { answer: imported, subject: subject, skipped: skipped, status: statusLabel, byRow: bulk }), reviewing && (_jsxs("div", { className: "nx-excel__review", "data-nx-excel-review": "", inert: importing, children: [_jsx("p", { className: "nx-excel__note", children: note }), statuses.length > 0 && (bulk ? typing : fresh.length > 0) && (_jsx("div", { className: "nx-excel__status", children: _jsx(Dropdown, { name: `${id}-status`, label: "New records start as", size: "sm", options: statuses,
222
+ // Always one: there is no record without a status, so nothing to clear.
223
+ clearable: false, value: recordStatus, onValueChange: (value) => setPickedStatus(value), hint: "Change it later on each record." }) })), (bulk ? typing : fresh.length > 0) && (_jsx(ReviewSection, { id: `${id}-new`, title: excelText("newRows"), tone: toFix ? "warning" : "neutral", count: rowCount, hint: toFix, rows: refused ?? fresh, byRow: bulk, onGo: goTo(newRef),
224
+ // What the person types is in this editor only, and folding the section away would take it with it.
225
+ fixed: true, children: _jsx(TabularForm, { ref: newRef, "aria-label": excelText("newRows"), inputs: inputs, itemLabel: subject, defaultValue: startRows,
226
+ // A sheet's row is the row's id: a copy leaves it behind, so it is never sent as that row of the file.
227
+ rowKey: EXCEL_ROW_NUMBER,
228
+ // A row added here could never fill a required list, so such a sheet's review takes the file's rows only.
229
+ addable: requiredLists.length === 0, max: settings?.maxImportRows, sortable: false, actionsAt: "end", lockable: true, maxHeight: maxHeight, size: "sm", onValueChange: onRowsChange }, `new-${generation}`) })), same.length > 0 && (_jsx(ReviewSection, { id: `${id}-same`, title: excelText("duplicates"), tone: "muted", count: same.length, hint: excelText("duplicateHint"), rows: same, onGo: goTo(sameRef), children: _jsx(TabularForm, { ref: sameRef, "aria-label": excelText("duplicates"), inputs: inputs, itemLabel: subject, defaultValue: sameRows, readOnly: true, readOnlyMessage: excelText("duplicateHint"), maxHeight: maxHeight, size: "sm" }, `same-${generation}`) }))] })), _jsxs("footer", { className: "nx-excel__actions", children: [imported ? (_jsx("p", { className: "nx-excel__sending", children: excelText(bulk ? "addAgain" : "importAgain") })) : (reviewing &&
230
+ (bulk ? typing : rowCount > 0 || same.length > 0) && (_jsxs("p", { className: "nx-excel__sending", "data-nx-excel-sending": "", children: [sendable > 0 ? `${excelCount(sendable, subject)} to send.` : "Nothing to send.", same.length > 0 ? ` ${excelCount(same.length, subject)} already there ${same.length === 1 ? "is" : "are"} skipped.` : ""] }))), _jsxs("div", { className: "nx-excel__buttons", children: [!busy && (file || (bulk && imported)) && (_jsx(Button, { variant: "secondary", icon: _jsx(InlineIcon, { icon: iconRefresh }), onClick: () => void onStartOver(), children: excelText("startOver") })), onClose && (_jsx(Button, { variant: "secondary", onClick: onClose, children: excelText("close") })), !imported && (_jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconUpload }), loading: importing, disabled: !canImport, onClick: () => void onImport(), children: excelText("importLabel") }))] })] })] }));
152
231
  }
153
232
  /**
154
- * One half of the review: a line that says what these rows are and how many, and the editor under it. Collapsible,
155
- * because a person with twenty duplicates and three new rows is looking at the three.
233
+ * One half of the review: a line that says what these rows are and how many, and the editor under it. The duplicates
234
+ * fold away, because a person with twenty of them and three new rows is looking at the three; the new rows do not, as
235
+ * folding them would take what the person typed with them.
156
236
  */
157
- function ReviewSection({ id, title, count, tone, hint, rows, onGo, open: openByDefault = false, children }) {
158
- const [open, setOpen] = useState(openByDefault);
237
+ function ReviewSection({ id, title, count, tone, hint, rows, onGo, byRow = false, open: openByDefault = false, fixed = false, children }) {
238
+ const [opened, setOpened] = useState(openByDefault);
239
+ const open = fixed || opened;
159
240
  const problems = useMemo(() => excelProblems(rows), [rows]);
160
241
  const duplicates = useMemo(() => rows.filter(row => row.duplicate), [rows]);
161
- if (count === 0)
162
- return null;
163
- return (_jsxs("section", { className: "nx-excel__section", "data-open": open || undefined, "data-tone": tone, "data-nx-excel-section": title, children: [_jsx("h3", { className: "nx-excel__section-head", children: _jsxs("button", { type: "button", className: "nx-excel__toggle", "aria-expanded": open, "aria-controls": open ? id : undefined, onClick: () => setOpen(!open), children: [_jsx(InlineIcon, { icon: iconChevronDown, className: "nx-excel__chevron", "aria-hidden": "true" }), _jsx("span", { className: "nx-excel__title", children: title }), _jsx("span", { className: "nx-excel__tag", children: count.toLocaleString("en-US") }), hint && _jsx("span", { className: "nx-excel__hint", children: hint })] }) }), open && (_jsxs("div", { className: "nx-excel__section-body", id: id, children: [children, problems.length > 0 && _jsx(ProblemList, { problems: problems, onGo: onGo }), duplicates.length > 0 && (_jsxs("div", { className: "nx-excel__problems nx-excel__problems--same", "data-nx-excel-duplicates": "", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconCopy, "aria-hidden": "true" }), _jsx("span", { children: excelText("whyDuplicate") })] }), _jsx("ul", { children: duplicates.map(row => (_jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate })] }, row.number))) })] }))] }))] }));
242
+ const head = (_jsxs(_Fragment, { children: [!fixed && _jsx(InlineIcon, { icon: iconChevronDown, className: "nx-excel__chevron", "aria-hidden": "true" }), _jsx("span", { className: "nx-excel__title", children: title }), _jsx("span", { className: "nx-excel__tag", children: count.toLocaleString("en-US") }), hint && _jsx("span", { className: "nx-excel__hint", children: hint })] }));
243
+ return (_jsxs("section", { className: "nx-excel__section", "data-open": open || undefined, "data-tone": tone, "data-nx-excel-section": title, children: [_jsx("h3", { className: "nx-excel__section-head", children: fixed ? (_jsx("span", { className: "nx-excel__toggle", "data-fixed": "", children: head })) : (_jsx("button", { type: "button", className: "nx-excel__toggle", "aria-expanded": open, "aria-controls": open ? id : undefined, onClick: () => setOpened(!open), children: head })) }), open && (_jsxs("div", { className: "nx-excel__section-body", id: id, children: [children, problems.length > 0 && _jsx(ProblemList, { problems: problems, onGo: onGo, byRow: byRow }), duplicates.length > 0 && (_jsxs("div", { className: "nx-excel__problems nx-excel__problems--same", "data-nx-excel-duplicates": "", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconCopy, "aria-hidden": "true" }), _jsx("span", { children: excelText("whyDuplicate") })] }), _jsx("ul", { children: duplicates.map(row => (_jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate })] }, row.number))) })] }))] }))] }));
164
244
  }
165
- /** What the server found wrong, each naming the cell the person reads, and taking them to it. */
166
- function ProblemList({ problems, onGo }) {
167
- return (_jsxs("div", { className: "nx-excel__problems", "data-nx-excel-problems": "", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconAlertTriangle, "aria-hidden": "true" }), _jsx("span", { children: excelText("toFix") })] }), _jsx("ul", { children: problems.map(problem => (_jsxs("li", { children: [onGo ? (_jsx("button", { type: "button", className: "nx-excel__place nx-excel__go", onClick: () => onGo(problem), children: excelProblemPlace(problem) })) : (_jsx("span", { className: "nx-excel__place", children: excelProblemPlace(problem) })), _jsx("span", { children: problem.message })] }, `${problem.row}:${problem.column}:${problem.message}`))) })] }));
245
+ /** What the server found wrong, each naming the cell the person reads - or, for rows typed here, the row - and taking them to it. */
246
+ function ProblemList({ problems, onGo, byRow = false }) {
247
+ return (_jsxs("div", { className: "nx-excel__problems", "data-nx-excel-problems": "", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconAlertTriangle, "aria-hidden": "true" }), _jsx("span", { children: excelText("toFix") })] }), _jsx("ul", { children: problems.map(problem => (_jsxs("li", { children: [onGo ? (_jsx("button", { type: "button", className: "nx-excel__place nx-excel__go", onClick: () => onGo(problem), children: excelProblemPlace(problem, byRow) })) : (_jsx("span", { className: "nx-excel__place", children: excelProblemPlace(problem, byRow) })), _jsx("span", { children: problem.message })] }, `${problem.row}:${problem.column}:${problem.message}`))) })] }));
168
248
  }
169
- /** What the import did, in the backend's own words, and what it did not write. */
170
- function ImportedReport({ answer, subject }) {
249
+ /**
250
+ * What the import did, in three parts side by side rather than inside one another: what was written, in the backend's
251
+ * own words; what was not, and why, problem by problem; and what was skipped because it is already a record. Rows
252
+ * typed here have no sheet, so `byRow` names a problem by its row rather than by a spreadsheet's cell.
253
+ */
254
+ function ImportedReport({ answer, subject, skipped, status, byRow }) {
171
255
  const wrote = answer.created > 0;
172
- return (_jsxs("div", { className: cx("nx-excel__result", wrote ? "nx-excel__result--ok" : "nx-excel__result--none"), role: "status", "data-nx-excel-result": "", children: [_jsxs("p", { className: "nx-excel__result-head", children: [_jsx(InlineIcon, { icon: wrote ? iconCircleCheck : iconAlertTriangle, "aria-hidden": "true" }), _jsx("strong", { children: wrote ? `${excelCount(answer.created, subject)} added` : excelText("importedNone") })] }), _jsx("p", { className: "nx-excel__result-note", children: answer.note }), (answer.refused?.length ?? 0) > 0 && (_jsx("ul", { className: "nx-excel__refused", children: answer.refused?.map(row => (_jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate ?? row.problems?.[0]?.message ?? excelText("refused") })] }, row.number))) }))] }));
256
+ const refused = byRowNumber(answer.refused ?? []);
257
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: cx("nx-excel__result", wrote ? "nx-excel__result--ok" : "nx-excel__result--none"), role: "status", "data-nx-excel-result": "", children: [_jsxs("p", { className: "nx-excel__result-head", children: [_jsx(InlineIcon, { icon: wrote ? iconCircleCheck : iconAlertTriangle, "aria-hidden": "true" }), _jsx("strong", { children: wrote ? `${excelCount(answer.created, subject)} added${status ? ` as ${status}` : ""}` : excelText("importedNone") })] }), _jsx("p", { className: "nx-excel__result-note", children: answer.note })] }), refused.length > 0 && (_jsxs("div", { className: "nx-excel__problems", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconAlertTriangle, "aria-hidden": "true" }), _jsxs("span", { children: [excelText("failedHead"), " (", refused.length.toLocaleString("en-US"), ")"] })] }), _jsx("ul", { children: refused.flatMap(row => row.problems?.length
258
+ ? row.problems.map(problem => (_jsxs("li", { children: [_jsx("span", { className: "nx-excel__place", children: excelProblemPlace(problem, byRow) }), _jsx("span", { children: problem.message })] }, `${row.number}:${problem.column}:${problem.message}`)))
259
+ : [
260
+ _jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate ?? excelText("refused") })] }, row.number),
261
+ ]) })] })), skipped.length > 0 && (_jsxs("div", { className: "nx-excel__problems nx-excel__problems--same", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconCopy, "aria-hidden": "true" }), _jsxs("span", { children: [excelText("skippedHead"), " (", skipped.length.toLocaleString("en-US"), ")"] })] }), _jsx("ul", { children: skipped.map(row => (_jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate ?? excelText("duplicateHint") })] }, row.number))) })] }))] }));
262
+ }
263
+ /** Rows in the order the person reads them: by their row, whatever order the backend listed them in. */
264
+ function byRowNumber(rows) {
265
+ return [...rows].sort((a, b) => a.number - b.number);
173
266
  }
267
+ /** The import windows open now, by popup key, so a second opening can see what the first one holds. */
268
+ const openPanels = new Map();
174
269
  /**
175
- * Opens the import panel as a window. This is the one line a data table's Import entry needs.
270
+ * Opens the import panel as a window. This is the one line a data table's Import entry needs - and, with
271
+ * `mode: "bulk"`, its Bulk insert entry.
176
272
  *
177
273
  * @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
178
274
  */
179
275
  export function openExcelImport({ title, popupKey, ...props }) {
180
276
  const subject = props.subject ?? "row";
181
- return openPopup({
182
- key: popupKey ?? `nx-excel:${String(props.endpoints.import.module)}:${props.endpoints.import.path}`,
183
- title: title ?? `Import ${excelPlural(subject)} from a spreadsheet`,
277
+ const bulk = props.mode === "bulk";
278
+ // A key of its own for a bulk insert, so opening one never closes an import whose review is half gone over.
279
+ const key = popupKey ?? `${bulk ? "nx-excel-bulk" : "nx-excel"}:${String(props.endpoints.import.module)}:${props.endpoints.import.path}`;
280
+ // Picked again while its window is still open - minimized, say - a window holding rows typed or changed in it comes
281
+ // back as it was, as a CRUD's Add form with changes does; an untouched one, or one showing an import's report, is
282
+ // replaced by a fresh panel.
283
+ const open = openPanels.get(key);
284
+ if (open && (open.panel.current?.unsavedRows() ?? 0) > 0) {
285
+ open.handle.restore();
286
+ return open.handle;
287
+ }
288
+ const panel = createRef();
289
+ const handle = openPopup({
290
+ key,
291
+ title: title ?? (bulk ? `Bulk insert ${excelPlural(subject)}` : `Import ${excelPlural(subject)} from a spreadsheet`),
184
292
  icon: _jsx(InlineIcon, { icon: iconTable }),
185
293
  size: "xl",
186
294
  closeOnBackdrop: false,
295
+ // What was typed or changed here is in no file: closing over it asks first, as a form with changes does.
296
+ beforeClose: () => confirmDiscardRows(panel.current?.unsavedRows() ?? 0),
187
297
  // No Close of its own: the window's title bar already has one, and two ways out of one panel is one too many.
188
- content: _jsx(ExcelImport, { ...props }),
298
+ content: _jsx(ExcelImport, { ...props, ref: panel }),
299
+ });
300
+ const entry = { handle, panel };
301
+ openPanels.set(key, entry);
302
+ void handle.closed.then(() => {
303
+ if (openPanels.get(key) === entry)
304
+ openPanels.delete(key);
305
+ });
306
+ return handle;
307
+ }
308
+ /** Asks before rows only this screen has are thrown away. With nothing to lose, nothing is asked. */
309
+ function confirmDiscardRows(rows) {
310
+ if (rows === 0)
311
+ return true;
312
+ return messageBox.confirm({
313
+ title: excelText("discardTitle"),
314
+ message: `${excelCount(rows, "row")} you typed or changed here will be lost.`,
315
+ tone: "warning",
316
+ confirmLabel: excelText("discardLabel"),
317
+ cancelLabel: excelText("keepEditing"),
189
318
  });
190
319
  }
@@ -10,7 +10,6 @@ import { FormStore } from "../../Helpers/FormStore.js";
10
10
  import { inputFieldName } from "../../Helpers/InputParamsHelpers.js";
11
11
  import { formatTime } from "../../Helpers/TimeHelpers.js";
12
12
  import { Button } from "../Documents/Button.js";
13
- import { hasContent } from "../Inputs/Field.js";
14
13
  import { isFirstInvalid } from "../Inputs/InputField.js";
15
14
  import { renderInput } from "../Inputs/InputRenderer.js";
16
15
  import { firstFocusable } from "../Inputs/RowsInput.js";
@@ -22,8 +21,6 @@ import { firstFocusable } from "../Inputs/RowsInput.js";
22
21
  */
23
22
  /** Inputs without a box: they line up with the boxes beside them. */
24
23
  const CHOICE_TYPES = new Set(["checkbox", "switch", "radio"]);
25
- /** Inputs taller than one line, for the placeholders shown while the values load. */
26
- const TALL_TYPES = new Set(["textarea", "markdown", "rich-text", "tree", "tabular", "group", "checkbox-group", "radio-group"]);
27
24
  /** What takes focus in an input's cell: a control to type in or pick, before any button of its toolbar. */
28
25
  const TYPING_TARGETS = 'input:not([type="hidden"]):not(.nx-visually-hidden):not(:disabled), textarea:not(.nx-visually-hidden):not(:disabled), select:not(:disabled), [contenteditable="true"]';
29
26
  const INVALID_CONTROLS = ":is(input, textarea, select):invalid";
@@ -136,13 +133,6 @@ const FormCell = memo(function FormCell({ store, params, context }) {
136
133
  }
137
134
  return (_jsx("div", { className: cx("nx-form__cell", CHOICE_TYPES.has(params.type) && "nx-form__cell--choice"), style: { "--nx-span": span }, "data-span": span, "data-beside": besideBoxes(params, span, context.design) || undefined, "data-nx-field": name, onBlur: onBlur, children: _jsx(FieldInput, { store: store, params: params, stamp: stamp, error: store.shownError(name), context: context }, stamp) }));
138
135
  });
139
- /** A placeholder in an input's shape while the values load. */
140
- function SkeletonCell({ params, design }) {
141
- const span = spanOf(params);
142
- const choice = CHOICE_TYPES.has(params.type);
143
- const ownDesign = params.design ?? design ?? "standard";
144
- return (_jsxs("div", { className: cx("nx-form__cell", "nx-form__skeleton", choice && "nx-form__cell--choice"), style: { "--nx-span": span }, "data-span": span, "data-beside": besideBoxes(params, span, design) || undefined, children: [!choice && ownDesign === "standard" && hasContent(params.label) && _jsx("span", { className: "nx-form__skeleton-label" }), _jsx("span", { className: cx("nx-form__skeleton-box", TALL_TYPES.has(params.type) && "nx-form__skeleton-box--tall", choice && "nx-form__skeleton-box--choice") })] }));
145
- }
146
136
  /** The note over a form whose unsaved changes came back from a draft. */
147
137
  function DraftNotice({ store, restored, inputs, preferences }) {
148
138
  const label = (name) => formInputLabel(inputs.find(params => params.name === name), name);
@@ -186,7 +176,7 @@ export function Form(props) {
186
176
  // that already has values (from `useForm`, a form shown again) keeps them.
187
177
  if (!store.started) {
188
178
  store.configure(options, false);
189
- store.load(props.defaultValue ?? {}, { draft: !loading, quiet: true });
179
+ store.load(props.defaultValue ?? {}, { draft: !loading, quiet: true, loading });
190
180
  }
191
181
  useLayoutEffect(() => store.announce(), [store]);
192
182
  // Inputs or settings that change what is valid check every value again; callbacks only swap.
@@ -316,8 +306,10 @@ export function Form(props) {
316
306
  if (first && isFirstInvalid(event.currentTarget))
317
307
  focusInput(first);
318
308
  }
319
- const context = useMemo(() => ({ idPrefix, size, design, readOnly, disabled, scopeNames: !inForm }), [idPrefix, size, design, readOnly, disabled, inForm]);
309
+ // While the values load, the real inputs show, disabled: the form has its final shape from the first frame, so a
310
+ // popup around it never jumps when the record lands. The store refuses anything typed until then.
311
+ const context = useMemo(() => ({ idPrefix, size, design, readOnly, disabled: disabled || loading, scopeNames: !inForm }), [idPrefix, size, design, readOnly, disabled, loading, inForm]);
320
312
  // With FormButtons on the page, the form's message is in their status line.
321
313
  const ownMessage = status.buttons === 0 ? status.message : null;
322
- return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-form", `nx-form--${size}`, className), style: style, "data-nx-form": "", "aria-busy": loading || undefined, children: [status.restored && !loading && _jsx(DraftNotice, { store: anyStore, restored: status.restored, inputs: inputs, preferences: preferences }), _jsx("div", { className: "nx-form__grid", children: inputs.filter(params => params.dependsOn?.length || !params.hidden).map((params, index, visible) => (_jsxs(Fragment, { children: [params.section && (index === 0 || visible[index - 1]?.section !== params.section) && _jsx("h3", { className: "nx-form__section", children: params.section }), loading ? _jsx(SkeletonCell, { params: params, design: design }) : _jsx(FormCell, { store: anyStore, params: params, context: context })] }, params.name))) }), loading && (_jsx("p", { className: "nx-visually-hidden", role: "status", children: "Loading\u2026" })), _jsx("input", { ref: proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", disabled: locked, onInvalid: onProxyInvalid }), ownMessage && (_jsxs("p", { className: "nx-form__message nx-field__message nx-field__message--error", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: ownMessage })] }))] }));
314
+ return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-form", `nx-form--${size}`, className), style: style, "data-nx-form": "", "aria-busy": loading || undefined, children: [status.restored && !loading && _jsx(DraftNotice, { store: anyStore, restored: status.restored, inputs: inputs, preferences: preferences }), _jsx("div", { className: "nx-form__grid", children: inputs.filter(params => params.dependsOn?.length || !params.hidden).map((params, index, visible) => (_jsxs(Fragment, { children: [params.section && (index === 0 || visible[index - 1]?.section !== params.section) && _jsx("h3", { className: "nx-form__section", children: params.section }), _jsx(FormCell, { store: anyStore, params: params, context: context })] }, params.name))) }), loading && (_jsx("p", { className: "nx-visually-hidden", role: "status", children: "Loading\u2026" })), _jsx("input", { ref: proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", disabled: locked, onInvalid: onProxyInvalid }), ownMessage && (_jsxs("p", { className: "nx-form__message nx-field__message nx-field__message--error", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: ownMessage })] }))] }));
323
315
  }
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { AnyFormStore, FormStore } from "../../Helpers/FormStore.ts";
3
- import type { FormValues, SubmitFormProps } from "../../Interfaces/FormInterfaces.ts";
3
+ import type { FormStatusLineMode, FormValues, SubmitFormProps } from "../../Interfaces/FormInterfaces.ts";
4
4
  import type { Size } from "../../Interfaces/TypeInterfaces.ts";
5
5
  export interface FormButtonsProps<TValues extends FormValues = FormValues> {
6
6
  /** The form's store: the one given to the form as `form`. */
@@ -25,6 +25,15 @@ export interface FormButtonsProps<TValues extends FormValues = FormValues> {
25
25
  size?: Size;
26
26
  /** Nothing to save: only Cancel shows. */
27
27
  readOnly?: boolean;
28
+ /** What the status line reports. Default `full`; `errors` for a short form that keeps nothing, such as a sign-in or a search. */
29
+ statusLine?: FormStatusLineMode;
30
+ /** The buttons share the bar's whole width, such as a sign-in's one button across its card. */
31
+ block?: boolean;
32
+ /**
33
+ * With `statusLine="errors"` the line keeps quiet, so while the form loads the submit button says it instead: a
34
+ * spinner and this. Default "Loading…".
35
+ */
36
+ loadingLabel?: ReactNode;
28
37
  className?: string;
29
38
  }
30
39
  /**
@@ -43,7 +52,7 @@ export declare function confirmDiscard(form: AnyFormStore): Promise<boolean>;
43
52
  * <ApiForm form={form} buttons={false} inputs={inputs} api={api} />
44
53
  * </Popup>
45
54
  */
46
- export declare function FormButtons<TValues extends FormValues = FormValues>({ form, submitLabel, submitIcon, onCancel, confirmCancel, cancelLabel, resettable, resetLabel, align, size, readOnly, className, }: FormButtonsProps<TValues>): import("react").JSX.Element;
55
+ export declare function FormButtons<TValues extends FormValues = FormValues>({ form, submitLabel, submitIcon, onCancel, confirmCancel, cancelLabel, resettable, resetLabel, align, size, readOnly, statusLine, block, loadingLabel, className, }: FormButtonsProps<TValues>): import("react").JSX.Element;
47
56
  /**
48
57
  * A `Form` that submits: every value is checked on Save (the inputs' own rules, the form's `validate`, rows not on the
49
58
  * page), each message shows, and the first problem takes focus; then `onSubmit` gets the values, and while it runs Save
@@ -13,20 +13,29 @@ function ProblemList({ form, names }) {
13
13
  const { shown, more } = namesToShow(names);
14
14
  return (_jsxs(_Fragment, { children: [names.length === 1 ? "1 field needs attention: " : `${names.length} fields need attention: `, shown.map((name, index) => (_jsxs("span", { children: [index > 0 && (index === shown.length - 1 && !more ? (shown.length > 2 ? ", and " : " and ") : ", "), _jsx("button", { type: "button", className: "nx-form-status__jump", onClick: () => form.focus(name), children: formInputLabel(form.inputs.find(params => params.name === name), name) })] }, name))), more > 0 && `, and ${more} more`, "."] }));
15
15
  }
16
- /** One line of what the form is doing: loading, saving, what needs attention, unsaved changes, or when it saved. */
17
- function FormStatusLine({ form, status }) {
16
+ /**
17
+ * One line of what the form is doing: loading, saving, what needs attention, unsaved changes, or when it saved. In
18
+ * `errors` mode only an error that belongs to no field: each field already shows its own, all of them in view.
19
+ */
20
+ function FormStatusLine({ form, status, mode }) {
18
21
  const preferences = useDatePreferences();
19
22
  let tone = "muted";
20
23
  let content = null;
21
- if (status.loading) {
24
+ const errorsOnly = mode === "errors";
25
+ const names = errorsOnly ? [] : status.problems;
26
+ const problems = Boolean(status.message || names.length);
27
+ if (errorsOnly && !problems) {
28
+ // Nothing went wrong outside the fields, and nothing else is this form's to say.
29
+ }
30
+ else if (status.loading) {
22
31
  content = (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconLoader2, className: "nx-spin" }), _jsx("span", { children: "Loading\u2026" })] }));
23
32
  }
24
33
  else if (status.submitting) {
25
34
  content = _jsx("span", { children: "Saving\u2026" });
26
35
  }
27
- else if (status.message || status.problems.length) {
36
+ else if (problems) {
28
37
  tone = "error";
29
- content = (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsxs("span", { children: [status.message, status.message && status.problems.length > 0 && " ", status.problems.length > 0 && _jsx(ProblemList, { form: form, names: status.problems })] })] }));
38
+ content = (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsxs("span", { children: [status.message, status.message && names.length > 0 && " ", names.length > 0 && _jsx(ProblemList, { form: form, names: names })] })] }));
30
39
  }
31
40
  else if (status.changed) {
32
41
  content = _jsx("span", { children: status.draftSavedAt ? "Unsaved changes, kept on this device." : "Unsaved changes." });
@@ -35,7 +44,7 @@ function FormStatusLine({ form, status }) {
35
44
  tone = "success";
36
45
  content = (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconCircleCheck }), _jsxs("span", { children: ["Saved at ", formatFormTime(status.savedAt, preferences), "."] })] }));
37
46
  }
38
- return (_jsx("p", { className: cx("nx-form-status", `nx-form-status--${tone}`), "aria-live": "polite", children: content }));
47
+ return (_jsx("p", { className: cx("nx-form-status", `nx-form-status--${tone}`, errorsOnly && "nx-form-status--errors"), "aria-live": "polite", children: content }));
39
48
  }
40
49
  /**
41
50
  * Asks "Discard your changes?" when the form has changes. Resolves `true` to go on (nothing changed, or the user chose
@@ -63,10 +72,12 @@ export async function confirmDiscard(form) {
63
72
  * <ApiForm form={form} buttons={false} inputs={inputs} api={api} />
64
73
  * </Popup>
65
74
  */
66
- export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel, confirmCancel = true, cancelLabel = "Cancel", resettable = false, resetLabel = "Reset", align = "end", size = "md", readOnly = false, className, }) {
75
+ export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel, confirmCancel = true, cancelLabel = "Cancel", resettable = false, resetLabel = "Reset", align = "end", size = "md", readOnly = false, statusLine = "full", block = false, loadingLabel = "Loading…", className, }) {
67
76
  const store = form;
68
77
  const status = useFormStatus(store);
69
78
  useEffect(() => store.addButtons(), [store]);
79
+ // The quiet line says nothing while the values load, so the button does: same place, same size, no jump after.
80
+ const loadingInButton = statusLine === "errors" && status.loading;
70
81
  async function cancel() {
71
82
  if (confirmCancel && !(await confirmDiscard(store)))
72
83
  return;
@@ -77,13 +88,13 @@ export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel,
77
88
  const actions = [
78
89
  resettable && !readOnly && (_jsx(Button, { size: buttonSize, variant: "ghost", icon: _jsx(InlineIcon, { icon: iconRefresh }), disabled: !status.changed || status.submitting || status.loading, onClick: () => store.reset(), children: resetLabel }, "reset")),
79
90
  onCancel && (_jsx(Button, { size: buttonSize, onClick: () => void cancel(), children: cancelLabel }, "cancel")),
80
- !readOnly && (_jsx(Button, { type: "submit", form: status.formId ?? undefined, size: buttonSize, variant: "primary", loading: status.submitting, disabled: status.loading, icon: submitIcon === undefined ? _jsx(InlineIcon, { icon: iconDeviceFloppy }) : (submitIcon ?? undefined), children: submitLabel }, "submit")),
91
+ !readOnly && (_jsx(Button, { type: "submit", form: status.formId ?? undefined, size: buttonSize, variant: "primary", loading: status.submitting || loadingInButton, disabled: status.loading, icon: submitIcon === undefined ? _jsx(InlineIcon, { icon: iconDeviceFloppy }) : (submitIcon ?? undefined), children: loadingInButton ? loadingLabel : submitLabel }, "submit")),
81
92
  ].filter(Boolean);
82
93
  if (align === "start")
83
94
  actions.reverse();
84
- const line = _jsx(FormStatusLine, { form: store, status: status }, "status");
95
+ const line = _jsx(FormStatusLine, { form: store, status: status, mode: statusLine }, "status");
85
96
  const bar = actions.length ? (_jsx("div", { className: "nx-form-buttons__actions", children: actions }, "actions")) : null;
86
- return _jsx("div", { className: cx("nx-form-buttons", `nx-form-buttons--${align}`, className), children: align === "start" ? [bar, line] : [line, bar] });
97
+ return _jsx("div", { className: cx("nx-form-buttons", `nx-form-buttons--${align}`, block && "nx-form-buttons--block", className), children: align === "start" ? [bar, line] : [line, bar] });
87
98
  }
88
99
  /**
89
100
  * A `Form` that submits: every value is checked on Save (the inputs' own rules, the form's `validate`, rows not on the
@@ -104,7 +115,7 @@ export function FormButtons({ form, submitLabel = "Save", submitIcon, onCancel,
104
115
  * />
105
116
  */
106
117
  export function SubmitForm(props) {
107
- const { onSubmit: _onSubmit, onSaved: _onSaved, onCancel, confirmCancel, submitLabel, submitIcon, cancelLabel, resettable, resetLabel, resetOnSuccess: _resetOnSuccess, buttons = "end", confirmLeave, id, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...formProps } = props;
118
+ const { onSubmit: _onSubmit, onSaved: _onSaved, onCancel, confirmCancel, submitLabel, submitIcon, cancelLabel, resettable, resetLabel, resetOnSuccess: _resetOnSuccess, buttons = "end", statusLine, confirmLeave, id, className, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...formProps } = props;
108
119
  const store = useFormStore(props.form);
109
120
  const formRef = useRef(null);
110
121
  const generatedId = useId();
@@ -194,5 +205,5 @@ export function SubmitForm(props) {
194
205
  return (_jsxs("form", { ref: formRef, id: formId, className: cx("nx-submit-form", className), style: style, noValidate: true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, onSubmit: event => {
195
206
  event.preventDefault();
196
207
  void submit();
197
- }, onKeyDown: onKeyDown, children: [_jsx(Form, { ...formProps, form: store }), buttons !== false && (_jsx(FormButtons, { form: store, align: buttons, submitLabel: submitLabel, submitIcon: submitIcon, onCancel: onCancel, confirmCancel: confirmCancel, cancelLabel: cancelLabel, resettable: resettable, resetLabel: resetLabel, size: formProps.size, readOnly: locked }))] }));
208
+ }, onKeyDown: onKeyDown, children: [_jsx(Form, { ...formProps, form: store }), buttons !== false && (_jsx(FormButtons, { form: store, align: buttons, submitLabel: submitLabel, submitIcon: submitIcon, onCancel: onCancel, confirmCancel: confirmCancel, cancelLabel: cancelLabel, resettable: resettable, resetLabel: resetLabel, size: formProps.size, readOnly: locked, statusLine: statusLine }))] }));
198
209
  }
@@ -11,7 +11,7 @@ export declare const DROPDOWN_STATE_LABELS: Record<DropdownValueState, string>;
11
11
  /** The tooltip of a current value that cannot be picked again. */
12
12
  export declare const DROPDOWN_STATE_NOTES: Record<DropdownValueState, string>;
13
13
  type AnyColumn = DropdownColumn<never>;
14
- /** A round photo, or the initials of the name on a soft tone when there is no photo or it fails to load. */
14
+ /** The shared `Avatar`, sized to the dropdown's pictures by its class. */
15
15
  export declare function DropdownAvatar({ src, name }: {
16
16
  src: unknown;
17
17
  name: string;
@@ -3,7 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { Icon, iconCheck, iconX, InlineIcon } from "nexus-icons";
4
4
  import { isValidElement, memo, useState } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
- import { avatarTone, initials, isMediaColumn, readField } from "../../Helpers/DropdownHelpers.js";
6
+ import { isMediaColumn, readField } from "../../Helpers/DropdownHelpers.js";
7
+ import { Avatar } from "../Viewers/Avatar.js";
7
8
  export const DROPDOWN_STATE_LABELS = {
8
9
  inactive: "Inactive",
9
10
  unavailable: "Unavailable",
@@ -15,12 +16,9 @@ export const DROPDOWN_STATE_NOTES = {
15
16
  unavailable: "No longer in the list: kept as the current value. Once removed, it cannot be chosen again.",
16
17
  unknown: "Not found in the list: kept as the current value. Once removed, it cannot be chosen again.",
17
18
  };
18
- /** A round photo, or the initials of the name on a soft tone when there is no photo or it fails to load. */
19
+ /** The shared `Avatar`, sized to the dropdown's pictures by its class. */
19
20
  export function DropdownAvatar({ src, name }) {
20
- const url = typeof src === "string" ? src.trim() : "";
21
- const [failed, setFailed] = useState(null);
22
- const photo = url !== "" && failed !== url;
23
- return (_jsx("span", { className: "nx-dropdown-avatar", "data-tone": photo ? undefined : avatarTone(name), "aria-hidden": "true", children: photo ? _jsx("img", { src: url, alt: "", loading: "lazy", decoding: "async", onError: () => setFailed(url) }) : initials(name) }));
21
+ return _jsx(Avatar, { name: name, src: src, className: "nx-dropdown-avatar" });
24
22
  }
25
23
  /** A small 4:3 picture, such as a flag or a logo; an empty frame when there is none or it fails to load. */
26
24
  export function DropdownImage({ src }) {