nexus-shared 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +442 -0
- package/README.md +1123 -1000
- package/dist/Client.Index.d.ts +6 -0
- package/dist/Client.Index.js +6 -0
- package/dist/Components/Chats/ChatParts.d.ts +2 -2
- package/dist/Components/Chats/ChatParts.js +3 -7
- package/dist/Components/Chats/ChatThread.js +3 -3
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +21 -9
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +7 -5
- package/dist/Components/Documents/TabButtons.d.ts +5 -2
- package/dist/Components/Documents/TabButtons.js +2 -2
- package/dist/Components/Forms/ApiForm.d.ts +1 -1
- package/dist/Components/Forms/ApiForm.js +1 -1
- package/dist/Components/Forms/Crud.js +486 -206
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +38 -5
- package/dist/Components/Forms/ExcelImport.d.ts +23 -7
- package/dist/Components/Forms/ExcelImport.js +189 -60
- package/dist/Components/Forms/Form.js +9 -14
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.js +4 -1
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/InputRenderer.js +3 -0
- package/dist/Components/Inputs/NumberBox.js +26 -2
- package/dist/Components/Inputs/RowsInput.js +2 -2
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.js +42 -10
- package/dist/Components/Inputs/TextArea.js +4 -1
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TimePanel.js +4 -3
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +55 -5
- package/dist/Components/Inputs/TreeView.js +372 -23
- package/dist/Components/Layouts/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
- package/dist/Components/Layouts/ThemePicker.js +89 -6
- package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
- package/dist/Components/Layouts/ThemeScript.js +4 -2
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
- package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +252 -62
- package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
- package/dist/Components/Viewers/DataTableColumns.js +25 -68
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
- package/dist/Components/Viewers/DataTableParts.js +42 -13
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/ApiModules.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +101 -8
- package/dist/Helpers/CrudBackend.js +172 -11
- package/dist/Helpers/CrudColumns.d.ts +51 -0
- package/dist/Helpers/CrudColumns.js +69 -0
- package/dist/Helpers/CrudHelpers.d.ts +50 -5
- package/dist/Helpers/CrudHelpers.js +109 -12
- package/dist/Helpers/ExcelBackend.d.ts +3 -3
- package/dist/Helpers/ExcelBackend.js +8 -2
- package/dist/Helpers/ExcelHelpers.d.ts +36 -8
- package/dist/Helpers/ExcelHelpers.js +55 -13
- package/dist/Helpers/FormStore.d.ts +9 -5
- package/dist/Helpers/FormStore.js +25 -5
- package/dist/Helpers/InputParamsHelpers.js +8 -0
- package/dist/Helpers/MessageBuilder.js +3 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +17 -5
- package/dist/Helpers/PopoverHelpers.js +52 -17
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RequestCache.d.ts +35 -0
- package/dist/Helpers/RequestCache.js +113 -0
- package/dist/Helpers/RowsStore.d.ts +7 -2
- package/dist/Helpers/RowsStore.js +15 -3
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +7 -4
- package/dist/Helpers/TableHelpers.js +15 -6
- package/dist/Helpers/TableJoins.d.ts +70 -0
- package/dist/Helpers/TableJoins.js +250 -0
- package/dist/Helpers/TableStore.d.ts +16 -3
- package/dist/Helpers/TableStore.js +25 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/TreeStore.d.ts +225 -11
- package/dist/Helpers/TreeStore.js +683 -90
- package/dist/Helpers/UserPreferences.d.ts +22 -0
- package/dist/Helpers/UserPreferences.js +116 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
- package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
- package/dist/Interfaces/FormInterfaces.d.ts +37 -6
- package/dist/Interfaces/InputInterfaces.d.ts +24 -0
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
- package/dist/Interfaces/PreferenceInterfaces.js +25 -0
- package/dist/Interfaces/TableInterfaces.d.ts +145 -16
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- package/dist/Services/ApiProxy.d.ts +36 -1
- package/dist/Services/ApiProxy.js +182 -52
- package/dist/Services/ApiProxyRules.d.ts +203 -0
- package/dist/Services/ApiProxyRules.js +426 -0
- package/dist/Services/ExcelApi.d.ts +3 -1
- package/dist/Services/ExcelApi.js +1 -1
- package/dist/Services/JoinSources.d.ts +48 -0
- package/dist/Services/JoinSources.js +74 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +6 -0
- package/dist/Shared.Index.js +6 -0
- package/package.json +4 -2
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +51 -4
- package/src/Styles/Nexus.Button.css +27 -0
- package/src/Styles/Nexus.Calendar.css +4 -6
- package/src/Styles/Nexus.Chat.css +5 -51
- package/src/Styles/Nexus.Choice.css +4 -6
- package/src/Styles/Nexus.Crud.css +16 -9
- package/src/Styles/Nexus.Dropdown.css +12 -61
- package/src/Styles/Nexus.Editor.css +4 -6
- package/src/Styles/Nexus.Excel.css +34 -15
- package/src/Styles/Nexus.Form.css +25 -67
- package/src/Styles/Nexus.Index.css +1 -0
- package/src/Styles/Nexus.Input.css +794 -637
- package/src/Styles/Nexus.Markdown.css +0 -28
- package/src/Styles/Nexus.Menu.css +8 -30
- package/src/Styles/Nexus.Popup.css +22 -29
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +51 -10
- package/src/Styles/Nexus.Slider.css +3 -5
- package/src/Styles/Nexus.Tab.Buttons.css +20 -7
- package/src/Styles/Nexus.Table.css +1387 -1296
- package/src/Styles/Nexus.Theme.Picker.css +342 -232
- package/src/Styles/Nexus.Time.css +9 -12
- package/src/Styles/Nexus.Toast.css +6 -8
- package/src/Styles/Nexus.Tokens.css +17 -0
- package/src/Styles/Nexus.Tree.css +76 -11
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
145
|
+
startEditor();
|
|
93
146
|
}
|
|
94
147
|
async function onImport() {
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
151
|
-
|
|
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.
|
|
155
|
-
* because a person with twenty
|
|
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 [
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
/**
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
}
|
|
@@ -4,13 +4,13 @@ import { Fragment } from "react";
|
|
|
4
4
|
import { iconAlertCircle, iconHistory, iconX, InlineIcon } from "nexus-icons";
|
|
5
5
|
import { memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
6
6
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
|
+
import { useInputDesign } from "../../Helpers/UserPreferences.js";
|
|
7
8
|
import { formatDate, toIsoDate, usesDevanagari } from "../../Helpers/DateHelpers.js";
|
|
8
9
|
import { useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
9
10
|
import { FormStore } from "../../Helpers/FormStore.js";
|
|
10
11
|
import { inputFieldName } from "../../Helpers/InputParamsHelpers.js";
|
|
11
12
|
import { formatTime } from "../../Helpers/TimeHelpers.js";
|
|
12
13
|
import { Button } from "../Documents/Button.js";
|
|
13
|
-
import { hasContent } from "../Inputs/Field.js";
|
|
14
14
|
import { isFirstInvalid } from "../Inputs/InputField.js";
|
|
15
15
|
import { renderInput } from "../Inputs/InputRenderer.js";
|
|
16
16
|
import { firstFocusable } from "../Inputs/RowsInput.js";
|
|
@@ -22,8 +22,6 @@ import { firstFocusable } from "../Inputs/RowsInput.js";
|
|
|
22
22
|
*/
|
|
23
23
|
/** Inputs without a box: they line up with the boxes beside them. */
|
|
24
24
|
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
25
|
/** What takes focus in an input's cell: a control to type in or pick, before any button of its toolbar. */
|
|
28
26
|
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
27
|
const INVALID_CONTROLS = ":is(input, textarea, select):invalid";
|
|
@@ -125,6 +123,8 @@ const FormCell = memo(function FormCell({ store, params, context }) {
|
|
|
125
123
|
useSyncExternalStore(subscribe, version, version);
|
|
126
124
|
const stamp = store.stamp(name);
|
|
127
125
|
const span = spanOf(params);
|
|
126
|
+
// The design the inputs draw in, the person's choice included: choice boxes sit beside others only in `standard`.
|
|
127
|
+
const design = useInputDesign(context.design);
|
|
128
128
|
// An input that follows others is asked whether it shows each time it draws: it comes and goes with their values.
|
|
129
129
|
if (params.dependsOn?.length && params.hidden)
|
|
130
130
|
return null;
|
|
@@ -134,15 +134,8 @@ const FormCell = memo(function FormCell({ store, params, context }) {
|
|
|
134
134
|
return;
|
|
135
135
|
store.touch(name);
|
|
136
136
|
}
|
|
137
|
-
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,
|
|
137
|
+
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, 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
138
|
});
|
|
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
139
|
/** The note over a form whose unsaved changes came back from a draft. */
|
|
147
140
|
function DraftNotice({ store, restored, inputs, preferences }) {
|
|
148
141
|
const label = (name) => formInputLabel(inputs.find(params => params.name === name), name);
|
|
@@ -186,7 +179,7 @@ export function Form(props) {
|
|
|
186
179
|
// that already has values (from `useForm`, a form shown again) keeps them.
|
|
187
180
|
if (!store.started) {
|
|
188
181
|
store.configure(options, false);
|
|
189
|
-
store.load(props.defaultValue ?? {}, { draft: !loading, quiet: true });
|
|
182
|
+
store.load(props.defaultValue ?? {}, { draft: !loading, quiet: true, loading });
|
|
190
183
|
}
|
|
191
184
|
useLayoutEffect(() => store.announce(), [store]);
|
|
192
185
|
// Inputs or settings that change what is valid check every value again; callbacks only swap.
|
|
@@ -316,8 +309,10 @@ export function Form(props) {
|
|
|
316
309
|
if (first && isFirstInvalid(event.currentTarget))
|
|
317
310
|
focusInput(first);
|
|
318
311
|
}
|
|
319
|
-
|
|
312
|
+
// While the values load, the real inputs show, disabled: the form has its final shape from the first frame, so a
|
|
313
|
+
// popup around it never jumps when the record lands. The store refuses anything typed until then.
|
|
314
|
+
const context = useMemo(() => ({ idPrefix, size, design, readOnly, disabled: disabled || loading, scopeNames: !inForm }), [idPrefix, size, design, readOnly, disabled, loading, inForm]);
|
|
320
315
|
// With FormButtons on the page, the form's message is in their status line.
|
|
321
316
|
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 }),
|
|
317
|
+
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
318
|
}
|
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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
|
-
|
|
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 (
|
|
36
|
+
else if (problems) {
|
|
28
37
|
tone = "error";
|
|
29
|
-
content = (_jsxs(_Fragment, { children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsxs("span", { children: [status.message, status.message &&
|
|
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}
|
|
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
|
}
|