nexus-shared 2.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.
- package/CHANGELOG.md +446 -0
- package/LICENSE +21 -21
- package/README.md +1084 -903
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -0
- package/dist/Components/Chats/Chat.d.ts +28 -0
- package/dist/Components/Chats/Chat.js +19 -0
- package/dist/Components/Chats/ChatButton.d.ts +26 -0
- package/dist/Components/Chats/ChatButton.js +41 -0
- package/dist/Components/Chats/ChatComposer.d.ts +41 -0
- package/dist/Components/Chats/ChatComposer.js +177 -0
- package/dist/Components/Chats/ChatConversations.d.ts +35 -0
- package/dist/Components/Chats/ChatConversations.js +38 -0
- package/dist/Components/Chats/ChatPanel.d.ts +66 -0
- package/dist/Components/Chats/ChatPanel.js +94 -0
- package/dist/Components/Chats/ChatParts.d.ts +87 -0
- package/dist/Components/Chats/ChatParts.js +96 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Button.d.ts +6 -1
- package/dist/Components/Documents/Button.js +2 -2
- package/dist/Components/Documents/Menu.js +44 -28
- package/dist/Components/Documents/SplitButton.d.ts +3 -1
- package/dist/Components/Documents/SplitButton.js +12 -8
- package/dist/Components/Documents/TabButtons.d.ts +29 -8
- package/dist/Components/Documents/TabButtons.js +24 -5
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +342 -71
- package/dist/Components/Forms/CrudParts.d.ts +5 -1
- package/dist/Components/Forms/CrudParts.js +37 -5
- package/dist/Components/Forms/ExcelImport.d.ts +58 -0
- package/dist/Components/Forms/ExcelImport.js +319 -0
- package/dist/Components/Forms/Form.js +9 -13
- package/dist/Components/Forms/SubmitForm.d.ts +11 -2
- package/dist/Components/Forms/SubmitForm.js +23 -12
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
- package/dist/Components/Inputs/DropdownParts.js +4 -6
- package/dist/Components/Inputs/GroupForm.js +4 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
- package/dist/Components/Inputs/InputRenderer.js +1 -1
- package/dist/Components/Inputs/NumberBox.js +24 -2
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +55 -6
- package/dist/Components/Inputs/Slider.js +4 -1
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +117 -20
- package/dist/Components/Inputs/TimePanel.js +4 -3
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/Popup.js +1 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +20 -3
- package/dist/Components/Layouts/ThemePicker.js +77 -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 +39 -18
- package/dist/Components/Viewers/Avatar.d.ts +21 -0
- package/dist/Components/Viewers/Avatar.js +21 -0
- package/dist/Components/Viewers/DataTable.js +262 -60
- package/dist/Components/Viewers/DataTableColumns.d.ts +32 -0
- package/dist/Components/Viewers/DataTableColumns.js +200 -0
- package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
- package/dist/Components/Viewers/DataTableFilters.js +68 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +8 -4
- package/dist/Components/Viewers/DataTableParts.js +24 -12
- package/dist/Helpers/AnimationHelpers.d.ts +11 -0
- package/dist/Helpers/AnimationHelpers.js +20 -2
- package/dist/Helpers/ApiClient.d.ts +22 -0
- package/dist/Helpers/ApiClient.js +4 -1
- package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
- package/dist/Helpers/ApiFormHelpers.js +73 -9
- package/dist/Helpers/ApiModules.d.ts +0 -4
- package/dist/Helpers/ApiModules.js +1 -0
- package/dist/Helpers/ApiResponses.d.ts +14 -4
- package/dist/Helpers/ApiResponses.js +116 -25
- package/dist/Helpers/ApiRoutes.d.ts +0 -10
- package/dist/Helpers/ApiRoutes.js +2 -10
- package/dist/Helpers/ChatBackend.d.ts +134 -0
- package/dist/Helpers/ChatBackend.js +332 -0
- package/dist/Helpers/ChatHelpers.d.ts +189 -0
- package/dist/Helpers/ChatHelpers.js +486 -0
- package/dist/Helpers/ChatHooks.d.ts +81 -0
- package/dist/Helpers/ChatHooks.js +175 -0
- package/dist/Helpers/ChatStore.d.ts +132 -0
- package/dist/Helpers/ChatStore.js +394 -0
- package/dist/Helpers/ChatSummaries.d.ts +39 -0
- package/dist/Helpers/ChatSummaries.js +118 -0
- package/dist/Helpers/CrudBackend.d.ts +54 -9
- package/dist/Helpers/CrudBackend.js +103 -6
- package/dist/Helpers/CrudHelpers.d.ts +39 -4
- package/dist/Helpers/CrudHelpers.js +76 -11
- package/dist/Helpers/ExcelBackend.d.ts +106 -0
- package/dist/Helpers/ExcelBackend.js +296 -0
- package/dist/Helpers/ExcelHelpers.d.ts +126 -0
- package/dist/Helpers/ExcelHelpers.js +344 -0
- package/dist/Helpers/FormStore.d.ts +11 -5
- package/dist/Helpers/FormStore.js +44 -6
- package/dist/Helpers/MessageBuilder.js +1 -2
- package/dist/Helpers/NumberHelpers.d.ts +7 -1
- package/dist/Helpers/NumberHelpers.js +41 -3
- package/dist/Helpers/PopoverHelpers.d.ts +46 -9
- package/dist/Helpers/PopoverHelpers.js +163 -24
- package/dist/Helpers/PopoverHooks.d.ts +24 -0
- package/dist/Helpers/PopoverHooks.js +62 -0
- package/dist/Helpers/RowsStore.d.ts +14 -3
- package/dist/Helpers/RowsStore.js +37 -3
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableFilters.d.ts +25 -0
- package/dist/Helpers/TableFilters.js +162 -0
- package/dist/Helpers/TableHelpers.d.ts +15 -4
- package/dist/Helpers/TableHelpers.js +28 -4
- package/dist/Helpers/TableStore.d.ts +11 -3
- package/dist/Helpers/TableStore.js +16 -5
- package/dist/Helpers/ThemeHelpers.d.ts +21 -4
- package/dist/Helpers/ThemeHelpers.js +46 -9
- package/dist/Helpers/UserPreferences.d.ts +14 -0
- package/dist/Helpers/UserPreferences.js +99 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
- package/dist/Interfaces/ApiInterfaces.js +16 -10
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/dist/Interfaces/ChatInterfaces.js +1 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +92 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +227 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +85 -7
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +43 -0
- package/dist/Interfaces/PreferenceInterfaces.js +28 -0
- package/dist/Interfaces/TableInterfaces.d.ts +116 -9
- package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
- package/dist/Interfaces/ThemeInterfaces.js +15 -0
- package/dist/Services/BrowserApi.d.ts +18 -1
- package/dist/Services/BrowserApi.js +18 -0
- package/dist/Services/ChatApi.d.ts +37 -0
- package/dist/Services/ChatApi.js +98 -0
- package/dist/Services/ExcelApi.d.ts +48 -0
- package/dist/Services/ExcelApi.js +196 -0
- package/dist/Services/ServerApi.d.ts +6 -1
- package/dist/Services/ServerApi.js +3 -0
- package/dist/Services/ThemeService.d.ts +18 -2
- package/dist/Services/ThemeService.js +80 -16
- package/dist/Shared.Index.d.ts +10 -0
- package/dist/Shared.Index.js +10 -0
- package/package.json +85 -82
- package/src/Styles/Nexus.Avatar.css +79 -0
- package/src/Styles/Nexus.Base.css +117 -70
- package/src/Styles/Nexus.Button.css +180 -151
- package/src/Styles/Nexus.Calendar.css +273 -275
- package/src/Styles/Nexus.Chat.css +1443 -0
- package/src/Styles/Nexus.Choice.css +343 -345
- package/src/Styles/Nexus.Crud.css +168 -84
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -774
- package/src/Styles/Nexus.Editor.css +295 -297
- package/src/Styles/Nexus.Excel.css +389 -0
- package/src/Styles/Nexus.Form.css +279 -298
- package/src/Styles/Nexus.Index.css +37 -34
- package/src/Styles/Nexus.Input.css +602 -637
- package/src/Styles/Nexus.Markdown.css +237 -265
- package/src/Styles/Nexus.Menu.css +237 -249
- package/src/Styles/Nexus.Popup.css +724 -731
- package/src/Styles/Nexus.RichText.css +2 -32
- package/src/Styles/Nexus.Rows.css +637 -566
- package/src/Styles/Nexus.Slider.css +276 -278
- package/src/Styles/Nexus.Tab.Buttons.css +432 -364
- package/src/Styles/Nexus.Table.css +1386 -1014
- package/src/Styles/Nexus.Theme.Picker.css +286 -159
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -147
- package/src/Styles/Nexus.Toast.css +263 -265
- package/src/Styles/Nexus.Tokens.css +89 -72
- package/src/Styles/Nexus.Tree.css +431 -435
|
@@ -6,7 +6,11 @@ export interface CrudDetailsProps<TRow extends FormValues = FormValues> {
|
|
|
6
6
|
row: TRow;
|
|
7
7
|
/** The fields, as the table's columns describe them; hidden columns included. */
|
|
8
8
|
columns: readonly TableColumn<TRow>[];
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Loads the full record (fields the list leaves out); its values replace the row's when they come. Meanwhile a field
|
|
11
|
+
* the row lacks keeps its line blank, and reads "Not loaded" if the load fails; the details say nothing else about
|
|
12
|
+
* the load (`aria-busy` while it runs), so the popup around them never changes size for it.
|
|
13
|
+
*/
|
|
10
14
|
load?: (signal: AbortSignal) => Promise<Partial<TRow> | null | undefined>;
|
|
11
15
|
/** Draws the details instead of the list of fields. */
|
|
12
16
|
render?: (row: TRow) => ReactNode;
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { motionAllowed } from "../../Helpers/AnimationHelpers.js";
|
|
5
5
|
import { isAbortError } from "../../Helpers/ApiResponses.js";
|
|
6
6
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
7
|
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
8
|
+
import { readPath } from "../../Helpers/TableHelpers.js";
|
|
9
|
+
import { useNumberLocale } from "../../Helpers/UserPreferences.js";
|
|
8
10
|
import { TableCellValue } from "../Viewers/DataTableParts.js";
|
|
11
|
+
/**
|
|
12
|
+
* An element's height as its `height` would set it: unrounded, unlike `offsetHeight`, so an unroll between two of
|
|
13
|
+
* them starts and ends exactly where the details are and the popup never steps by a fraction of a pixel. NaN while
|
|
14
|
+
* not drawn.
|
|
15
|
+
*/
|
|
16
|
+
const usedHeight = (element) => parseFloat(getComputedStyle(element).height);
|
|
9
17
|
/** A record's fields as label and value, two to a line where there is room, in the formats of the table's columns. */
|
|
10
18
|
export function CrudDetails({ row, columns, load, render }) {
|
|
11
19
|
const [record, setRecord] = useState(row);
|
|
12
20
|
const [state, setState] = useState(load ? "loading" : "done");
|
|
21
|
+
const rootRef = useRef(null);
|
|
22
|
+
// The details' height just before the full record went in, kept only when the change may be animated.
|
|
23
|
+
const grownFrom = useRef(null);
|
|
13
24
|
const preferences = useDatePreferences();
|
|
14
25
|
const timeZone = useBrowserTimeZone();
|
|
15
|
-
const
|
|
26
|
+
const locale = useNumberLocale();
|
|
27
|
+
const options = useMemo(() => ({ preferences, timeZone: timeZone ?? undefined, locale }), [preferences, timeZone, locale]);
|
|
16
28
|
useEffect(() => {
|
|
17
29
|
if (!load)
|
|
18
30
|
return;
|
|
@@ -20,8 +32,11 @@ export function CrudDetails({ row, columns, load, render }) {
|
|
|
20
32
|
load(controller.signal).then(full => {
|
|
21
33
|
if (controller.signal.aborted)
|
|
22
34
|
return;
|
|
23
|
-
if (full)
|
|
35
|
+
if (full) {
|
|
36
|
+
if (motionAllowed() && rootRef.current)
|
|
37
|
+
grownFrom.current = usedHeight(rootRef.current);
|
|
24
38
|
setRecord(current => ({ ...current, ...full }));
|
|
39
|
+
}
|
|
25
40
|
setState("done");
|
|
26
41
|
}, error => {
|
|
27
42
|
if (controller.signal.aborted || isAbortError(error))
|
|
@@ -32,5 +47,22 @@ export function CrudDetails({ row, columns, load, render }) {
|
|
|
32
47
|
// The record loads once per popup.
|
|
33
48
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
49
|
}, []);
|
|
35
|
-
|
|
50
|
+
// A record that makes the details taller (a long note the list did not have) unrolls to its height when motion is
|
|
51
|
+
// allowed, as maximize does; without motion it changes at once. Nothing waits on it.
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
const from = grownFrom.current;
|
|
54
|
+
grownFrom.current = null;
|
|
55
|
+
const root = rootRef.current;
|
|
56
|
+
if (!from || !root || typeof root.animate !== "function")
|
|
57
|
+
return;
|
|
58
|
+
const to = usedHeight(root);
|
|
59
|
+
if (!to || Math.abs(to - from) < 1)
|
|
60
|
+
return;
|
|
61
|
+
const animation = root.animate([{ height: `${from}px`, overflow: "clip" }, { height: `${to}px`, overflow: "clip" }], { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)" });
|
|
62
|
+
return () => animation.cancel();
|
|
63
|
+
}, [record]);
|
|
64
|
+
// A field the list's row does not carry can only be filled by the full record. Columns that draw or work out their
|
|
65
|
+
// own value (`render`, `value`) draw as always, because only they know what they read.
|
|
66
|
+
const awaited = (column) => state !== "done" && !column.render && !column.value && readPath(record, column.key) === undefined;
|
|
67
|
+
return (_jsx("div", { ref: rootRef, className: "nx-crud-details", "aria-busy": state === "loading" || undefined, children: render ? (render(record)) : (_jsx("dl", { className: "nx-crud-details__list", children: columns.map(column => (_jsxs("div", { className: cx("nx-crud-details__item", column.wrap && "nx-crud-details__item--wide"), children: [_jsx("dt", { className: "nx-crud-details__label", children: column.label }), _jsx("dd", { className: "nx-crud-details__value", children: !awaited(column) ? _jsx(TableCellValue, { column: column, row: record, options: options }) : state === "failed" ? _jsx("span", { className: "nx-crud-details__missing", children: "Not loaded" }) : null })] }, column.key))) })) }));
|
|
36
68
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ReactNode, type Ref } from "react";
|
|
2
|
+
import type { ExcelEndpoints, ExcelImportAnswer } from "../../Interfaces/ExcelInterfaces.ts";
|
|
3
|
+
import { type ExcelCallOptions } from "../../Services/ExcelApi.ts";
|
|
4
|
+
import { type PopupHandle } from "../Layouts/PopupHost.tsx";
|
|
5
|
+
/** What an import panel needs. */
|
|
6
|
+
export interface ExcelImportProps {
|
|
7
|
+
/** The model's spreadsheet endpoints: `excelEndpoints(CITIES)`, or the app's own four. */
|
|
8
|
+
endpoints: ExcelEndpoints;
|
|
9
|
+
/** What one row is: "city". Default: what the settings say. */
|
|
10
|
+
subject?: string;
|
|
11
|
+
/** Path values the calls need, such as a parent id. */
|
|
12
|
+
params?: ExcelCallOptions["params"];
|
|
13
|
+
/** After an import wrote something, to reload the list behind the panel. */
|
|
14
|
+
onImported?: (answer: ExcelImportAnswer) => void;
|
|
15
|
+
/** The Close button, and what it does. Without it, none shows (a popup has its own). */
|
|
16
|
+
onClose?: () => void;
|
|
17
|
+
/** No template download is offered, for a model whose file comes from somewhere else. */
|
|
18
|
+
templateDownload?: false;
|
|
19
|
+
/** The tallest each editor grows before its rows scroll. Default 320. */
|
|
20
|
+
maxHeight?: number;
|
|
21
|
+
/**
|
|
22
|
+
* `"file"` (the default): a filled template is sent to be checked, and its rows come back to be gone over.
|
|
23
|
+
* `"bulk"`: no file - the rows are typed here, from one empty row, and imported with the same checks. A sheet with a
|
|
24
|
+
* required list cannot be typed in (its key is picked in the spreadsheet), and the panel says so.
|
|
25
|
+
*/
|
|
26
|
+
mode?: "file" | "bulk";
|
|
27
|
+
/** What closing the panel would lose, so a window can ask first: `openExcelImport` passes one. */
|
|
28
|
+
ref?: Ref<ExcelImportHandle>;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
/** What `ref` gives on an import panel. */
|
|
32
|
+
export interface ExcelImportHandle {
|
|
33
|
+
/** The rows only this screen has - typed here, or a sheet's row edited here - which closing the panel loses. */
|
|
34
|
+
unsavedRows: () => number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The three steps of an import in one panel: the template, the file, and the review - or, for a bulk insert, the rows
|
|
38
|
+
* typed in and the import.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
|
|
42
|
+
* <ExcelImport endpoints={REGION_SHEET} subject="region" mode="bulk" onImported={() => table.reload()} />
|
|
43
|
+
*/
|
|
44
|
+
export declare function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight, mode, ref, className }: ExcelImportProps): import("react").JSX.Element;
|
|
45
|
+
/** Options of the import popup: the panel's, plus what the window looks like. */
|
|
46
|
+
export interface OpenExcelImportOptions extends Omit<ExcelImportProps, "onClose" | "ref"> {
|
|
47
|
+
/** The window's title. Default: "Import <subjects> from a spreadsheet", or "Bulk insert <subjects>". */
|
|
48
|
+
title?: ReactNode;
|
|
49
|
+
/** One popup per key. Default: the import endpoint's path and the mode, so one model opens one of each. */
|
|
50
|
+
popupKey?: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Opens the import panel as a window. This is the one line a data table's Import entry needs - and, with
|
|
54
|
+
* `mode: "bulk"`, its Bulk insert entry.
|
|
55
|
+
*
|
|
56
|
+
* @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
|
|
57
|
+
*/
|
|
58
|
+
export declare function openExcelImport({ title, popupKey, ...props }: OpenExcelImportOptions): PopupHandle;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
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
|
+
import { cx } from "../../Helpers/ClassHelpers.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
|
+
import { ExcelRowKind } from "../../Interfaces/ExcelInterfaces.js";
|
|
8
|
+
import { downloadExcelTemplate, excelSettings, importExcelRows, verifyExcelFile } from "../../Services/ExcelApi.js";
|
|
9
|
+
import { toast } from "../Layouts/Toaster.js";
|
|
10
|
+
import { messageBox } from "../Layouts/MessageBox.js";
|
|
11
|
+
import { openPopup } from "../Layouts/PopupHost.js";
|
|
12
|
+
import { Button } from "../Documents/Button.js";
|
|
13
|
+
import { Dropdown } from "../Inputs/Dropdown.js";
|
|
14
|
+
import { TabularForm } from "../Inputs/TabularForm.js";
|
|
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 = [{}];
|
|
18
|
+
/**
|
|
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.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
|
|
24
|
+
* <ExcelImport endpoints={REGION_SHEET} subject="region" mode="bulk" onImported={() => table.reload()} />
|
|
25
|
+
*/
|
|
26
|
+
export function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight = DEFAULT_MAX_HEIGHT, mode = "file", ref, className }) {
|
|
27
|
+
const id = useId();
|
|
28
|
+
const bulk = mode === "bulk";
|
|
29
|
+
const fileRef = useRef(null);
|
|
30
|
+
const newRef = useRef(null);
|
|
31
|
+
const sameRef = useRef(null);
|
|
32
|
+
const [settings, setSettings] = useState(null);
|
|
33
|
+
const [file, setFile] = useState(null);
|
|
34
|
+
const [verify, setVerify] = useState(null);
|
|
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([]);
|
|
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);
|
|
43
|
+
const [busy, setBusy] = useState("settings");
|
|
44
|
+
/** Bumped on every answer: the editors take their rows again instead of being told to change them. */
|
|
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);
|
|
49
|
+
// A page may well write its endpoints and its params as literals, which are a new object every render. What they
|
|
50
|
+
// say is what matters, so the settings are asked for again only when that changes.
|
|
51
|
+
const sheetKey = `${String(endpoints.settings.module)}|${endpoints.settings.path}|${JSON.stringify(params ?? null)}`;
|
|
52
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
|
+
const callOptions = useMemo(() => ({ params }), [sheetKey]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let left = false;
|
|
56
|
+
void (async () => {
|
|
57
|
+
const answer = await excelSettings(endpoints, callOptions);
|
|
58
|
+
if (left)
|
|
59
|
+
return;
|
|
60
|
+
if (answer.ok && answer.result)
|
|
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"));
|
|
65
|
+
setBusy(null);
|
|
66
|
+
})();
|
|
67
|
+
return () => {
|
|
68
|
+
left = true;
|
|
69
|
+
};
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
}, [sheetKey]);
|
|
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;
|
|
76
|
+
const columns = verify?.columns ?? settings?.columns ?? [];
|
|
77
|
+
const inputs = useMemo(() => excelColumnInputs(columns), [columns]);
|
|
78
|
+
const fresh = useMemo(() => (verify ? excelWritableRows(verify.rows) : []), [verify]);
|
|
79
|
+
const same = useMemo(() => (verify ? excelRowsOfKind(verify.rows, ExcelRowKind.Duplicate) : []), [verify]);
|
|
80
|
+
const freshRows = useMemo(() => excelEditorRows(fresh, columns), [fresh, columns]);
|
|
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
|
+
}
|
|
111
|
+
function reset() {
|
|
112
|
+
setFile(null);
|
|
113
|
+
setVerify(null);
|
|
114
|
+
setImported(null);
|
|
115
|
+
setSkipped([]);
|
|
116
|
+
setPickedStatus(null);
|
|
117
|
+
setFailure(null);
|
|
118
|
+
startEditor();
|
|
119
|
+
if (fileRef.current)
|
|
120
|
+
fileRef.current.value = "";
|
|
121
|
+
}
|
|
122
|
+
async function onTemplate() {
|
|
123
|
+
setBusy("template");
|
|
124
|
+
await downloadExcelTemplate(endpoints, callOptions);
|
|
125
|
+
setBusy(null);
|
|
126
|
+
}
|
|
127
|
+
async function onPicked(picked) {
|
|
128
|
+
if (!picked)
|
|
129
|
+
return;
|
|
130
|
+
setFile(picked);
|
|
131
|
+
setVerify(null);
|
|
132
|
+
setImported(null);
|
|
133
|
+
setFailure(null);
|
|
134
|
+
setBusy("checking");
|
|
135
|
+
const answer = await verifyExcelFile(endpoints, picked, callOptions);
|
|
136
|
+
setBusy(null);
|
|
137
|
+
// Clearing the input is what lets the same file be picked again after a fix in the spreadsheet.
|
|
138
|
+
if (fileRef.current)
|
|
139
|
+
fileRef.current.value = "";
|
|
140
|
+
if (!answer.ok || !answer.result) {
|
|
141
|
+
setFailure(answer.message ?? excelText("verifyFailed"));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setVerify(answer.result);
|
|
145
|
+
startEditor();
|
|
146
|
+
}
|
|
147
|
+
async function onImport() {
|
|
148
|
+
const editor = newRef.current;
|
|
149
|
+
if (!editor || (!verify && !bulk))
|
|
150
|
+
return;
|
|
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())
|
|
160
|
+
return;
|
|
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);
|
|
166
|
+
if (!rows.length) {
|
|
167
|
+
setFailure(excelText("importNothing"));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
setBusy("importing");
|
|
171
|
+
const answer = await importExcelRows(endpoints, rows, { ...callOptions, recordStatus });
|
|
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.
|
|
175
|
+
if (!answer.ok) {
|
|
176
|
+
setFailure(answer.message ?? excelText("importedNone"));
|
|
177
|
+
if (answer.result?.refused?.length)
|
|
178
|
+
setRefused(byRowNumber(answer.result.refused));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const written = answer.result;
|
|
182
|
+
if (!written)
|
|
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.
|
|
186
|
+
setFailure(null);
|
|
187
|
+
setSkipped(same);
|
|
188
|
+
setImported(written);
|
|
189
|
+
if (written.created > 0) {
|
|
190
|
+
toast.success(`${excelCount(written.created, subject)} imported`, { message: written.note });
|
|
191
|
+
onImported?.(written);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
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)
|
|
204
|
+
editor.current?.focusRow(index, problem.field);
|
|
205
|
+
};
|
|
206
|
+
const checking = busy === "checking";
|
|
207
|
+
const importing = busy === "importing";
|
|
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") }))] })] })] }));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
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.
|
|
236
|
+
*/
|
|
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;
|
|
240
|
+
const problems = useMemo(() => excelProblems(rows), [rows]);
|
|
241
|
+
const duplicates = useMemo(() => rows.filter(row => row.duplicate), [rows]);
|
|
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))) })] }))] }))] }));
|
|
244
|
+
}
|
|
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}`))) })] }));
|
|
248
|
+
}
|
|
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 }) {
|
|
255
|
+
const wrote = answer.created > 0;
|
|
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);
|
|
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();
|
|
269
|
+
/**
|
|
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.
|
|
272
|
+
*
|
|
273
|
+
* @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
|
|
274
|
+
*/
|
|
275
|
+
export function openExcelImport({ title, popupKey, ...props }) {
|
|
276
|
+
const subject = props.subject ?? "row";
|
|
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`),
|
|
292
|
+
icon: _jsx(InlineIcon, { icon: iconTable }),
|
|
293
|
+
size: "xl",
|
|
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),
|
|
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.
|
|
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"),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Fragment } from "react";
|
|
3
4
|
import { iconAlertCircle, iconHistory, iconX, InlineIcon } from "nexus-icons";
|
|
4
5
|
import { memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
5
6
|
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
@@ -9,7 +10,6 @@ import { FormStore } from "../../Helpers/FormStore.js";
|
|
|
9
10
|
import { inputFieldName } from "../../Helpers/InputParamsHelpers.js";
|
|
10
11
|
import { formatTime } from "../../Helpers/TimeHelpers.js";
|
|
11
12
|
import { Button } from "../Documents/Button.js";
|
|
12
|
-
import { hasContent } from "../Inputs/Field.js";
|
|
13
13
|
import { isFirstInvalid } from "../Inputs/InputField.js";
|
|
14
14
|
import { renderInput } from "../Inputs/InputRenderer.js";
|
|
15
15
|
import { firstFocusable } from "../Inputs/RowsInput.js";
|
|
@@ -21,8 +21,6 @@ import { firstFocusable } from "../Inputs/RowsInput.js";
|
|
|
21
21
|
*/
|
|
22
22
|
/** Inputs without a box: they line up with the boxes beside them. */
|
|
23
23
|
const CHOICE_TYPES = new Set(["checkbox", "switch", "radio"]);
|
|
24
|
-
/** Inputs taller than one line, for the placeholders shown while the values load. */
|
|
25
|
-
const TALL_TYPES = new Set(["textarea", "markdown", "rich-text", "tree", "tabular", "group", "checkbox-group", "radio-group"]);
|
|
26
24
|
/** What takes focus in an input's cell: a control to type in or pick, before any button of its toolbar. */
|
|
27
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"]';
|
|
28
26
|
const INVALID_CONTROLS = ":is(input, textarea, select):invalid";
|
|
@@ -124,6 +122,9 @@ const FormCell = memo(function FormCell({ store, params, context }) {
|
|
|
124
122
|
useSyncExternalStore(subscribe, version, version);
|
|
125
123
|
const stamp = store.stamp(name);
|
|
126
124
|
const span = spanOf(params);
|
|
125
|
+
// An input that follows others is asked whether it shows each time it draws: it comes and goes with their values.
|
|
126
|
+
if (params.dependsOn?.length && params.hidden)
|
|
127
|
+
return null;
|
|
127
128
|
// Leaving the input shows the form's own rule message on it, such as "Passwords do not match".
|
|
128
129
|
function onBlur(event) {
|
|
129
130
|
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))
|
|
@@ -132,13 +133,6 @@ const FormCell = memo(function FormCell({ store, params, context }) {
|
|
|
132
133
|
}
|
|
133
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) }));
|
|
134
135
|
});
|
|
135
|
-
/** A placeholder in an input's shape while the values load. */
|
|
136
|
-
function SkeletonCell({ params, design }) {
|
|
137
|
-
const span = spanOf(params);
|
|
138
|
-
const choice = CHOICE_TYPES.has(params.type);
|
|
139
|
-
const ownDesign = params.design ?? design ?? "standard";
|
|
140
|
-
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") })] }));
|
|
141
|
-
}
|
|
142
136
|
/** The note over a form whose unsaved changes came back from a draft. */
|
|
143
137
|
function DraftNotice({ store, restored, inputs, preferences }) {
|
|
144
138
|
const label = (name) => formInputLabel(inputs.find(params => params.name === name), name);
|
|
@@ -182,7 +176,7 @@ export function Form(props) {
|
|
|
182
176
|
// that already has values (from `useForm`, a form shown again) keeps them.
|
|
183
177
|
if (!store.started) {
|
|
184
178
|
store.configure(options, false);
|
|
185
|
-
store.load(props.defaultValue ?? {}, { draft: !loading, quiet: true });
|
|
179
|
+
store.load(props.defaultValue ?? {}, { draft: !loading, quiet: true, loading });
|
|
186
180
|
}
|
|
187
181
|
useLayoutEffect(() => store.announce(), [store]);
|
|
188
182
|
// Inputs or settings that change what is valid check every value again; callbacks only swap.
|
|
@@ -312,8 +306,10 @@ export function Form(props) {
|
|
|
312
306
|
if (first && isFirstInvalid(event.currentTarget))
|
|
313
307
|
focusInput(first);
|
|
314
308
|
}
|
|
315
|
-
|
|
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]);
|
|
316
312
|
// With FormButtons on the page, the form's message is in their status line.
|
|
317
313
|
const ownMessage = status.buttons === 0 ? status.message : null;
|
|
318
|
-
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.
|
|
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 })] }))] }));
|
|
319
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
|