nexus-shared 2.0.0 → 3.0.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 +262 -0
- package/README.md +122 -25
- package/dist/Client.Index.d.ts +11 -0
- package/dist/Client.Index.js +11 -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 +100 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Menu.js +24 -20
- package/dist/Components/Documents/SplitButton.js +5 -3
- package/dist/Components/Documents/TabButtons.d.ts +24 -6
- package/dist/Components/Documents/TabButtons.js +23 -4
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +202 -50
- package/dist/Components/Forms/ExcelImport.d.ts +42 -0
- package/dist/Components/Forms/ExcelImport.js +190 -0
- package/dist/Components/Forms/Form.js +5 -1
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- 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/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +53 -4
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +85 -20
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
- package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
- package/dist/Components/Viewers/DataTable.js +88 -34
- package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
- package/dist/Components/Viewers/DataTableColumns.js +243 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
- package/dist/Components/Viewers/DataTableParts.js +21 -9
- 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 +47 -5
- package/dist/Helpers/CrudBackend.js +99 -4
- 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 +290 -0
- package/dist/Helpers/ExcelHelpers.d.ts +98 -0
- package/dist/Helpers/ExcelHelpers.js +302 -0
- package/dist/Helpers/FormStore.d.ts +2 -0
- package/dist/Helpers/FormStore.js +19 -1
- package/dist/Helpers/MessageBuilder.js +0 -2
- package/dist/Helpers/PopoverHelpers.d.ts +31 -6
- package/dist/Helpers/PopoverHelpers.js +116 -12
- package/dist/Helpers/RowsStore.d.ts +7 -1
- package/dist/Helpers/RowsStore.js +22 -0
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableHelpers.d.ts +9 -1
- package/dist/Helpers/TableHelpers.js +20 -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 +73 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +52 -2
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/TableInterfaces.d.ts +62 -2
- 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 +46 -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/Shared.Index.d.ts +8 -0
- package/dist/Shared.Index.js +8 -0
- package/package.json +6 -3
- package/src/Styles/Nexus.Button.css +2 -0
- package/src/Styles/Nexus.Chat.css +1489 -0
- package/src/Styles/Nexus.Crud.css +77 -0
- package/src/Styles/Nexus.Excel.css +370 -0
- package/src/Styles/Nexus.Form.css +23 -0
- package/src/Styles/Nexus.Index.css +2 -0
- package/src/Styles/Nexus.Menu.css +12 -2
- package/src/Styles/Nexus.Rows.css +78 -48
- package/src/Styles/Nexus.Tab.Buttons.css +55 -0
- package/src/Styles/Nexus.Table.css +286 -4
- package/src/Styles/Nexus.Theme.Picker.css +75 -2
|
@@ -0,0 +1,190 @@
|
|
|
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, iconFileImport, iconTable, iconUpload, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
6
|
+
import { EXCEL_FILE_ACCEPT, excelColumnInputs, excelCount, excelEditorRows, excelPlural, excelProblemPlace, excelProblems, excelRowsOfKind, excelSheetRows, excelText, excelWritableRows, isExcelRowEdited, } from "../../Helpers/ExcelHelpers.js";
|
|
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 { openPopup } from "../Layouts/PopupHost.js";
|
|
11
|
+
import { Button } from "../Documents/Button.js";
|
|
12
|
+
import { TabularForm } from "../Inputs/TabularForm.js";
|
|
13
|
+
const DEFAULT_MAX_HEIGHT = 320;
|
|
14
|
+
/**
|
|
15
|
+
* The three steps of an import in one panel: the template, the file, and the review.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* <ExcelImport endpoints={CITY_SHEET} subject="city" onImported={() => table.reload()} />
|
|
19
|
+
*/
|
|
20
|
+
export function ExcelImport({ endpoints, subject: given, params, onImported, onClose, templateDownload, maxHeight = DEFAULT_MAX_HEIGHT, className }) {
|
|
21
|
+
const id = useId();
|
|
22
|
+
const fileRef = useRef(null);
|
|
23
|
+
const newRef = useRef(null);
|
|
24
|
+
const sameRef = useRef(null);
|
|
25
|
+
const [settings, setSettings] = useState(null);
|
|
26
|
+
const [file, setFile] = useState(null);
|
|
27
|
+
const [verify, setVerify] = useState(null);
|
|
28
|
+
const [imported, setImported] = useState(null);
|
|
29
|
+
const [failure, setFailure] = useState(null);
|
|
30
|
+
const [busy, setBusy] = useState("settings");
|
|
31
|
+
/** Bumped on every answer: the editors take their rows again instead of being told to change them. */
|
|
32
|
+
const [generation, setGeneration] = useState(0);
|
|
33
|
+
// A page may well write its endpoints and its params as literals, which are a new object every render. What they
|
|
34
|
+
// say is what matters, so the settings are asked for again only when that changes.
|
|
35
|
+
const sheetKey = `${String(endpoints.settings.module)}|${endpoints.settings.path}|${JSON.stringify(params ?? null)}`;
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
const callOptions = useMemo(() => ({ params }), [sheetKey]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let left = false;
|
|
40
|
+
void (async () => {
|
|
41
|
+
const answer = await excelSettings(endpoints, callOptions);
|
|
42
|
+
if (left)
|
|
43
|
+
return;
|
|
44
|
+
if (answer.ok && answer.result)
|
|
45
|
+
setSettings(answer.result);
|
|
46
|
+
setBusy(null);
|
|
47
|
+
})();
|
|
48
|
+
return () => {
|
|
49
|
+
left = true;
|
|
50
|
+
};
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
}, [sheetKey]);
|
|
53
|
+
const subject = given ?? verify?.subject ?? settings?.subject ?? "row";
|
|
54
|
+
const columns = verify?.columns ?? settings?.columns ?? [];
|
|
55
|
+
const inputs = useMemo(() => excelColumnInputs(columns), [columns]);
|
|
56
|
+
const fresh = useMemo(() => (verify ? excelWritableRows(verify.rows) : []), [verify]);
|
|
57
|
+
const same = useMemo(() => (verify ? excelRowsOfKind(verify.rows, ExcelRowKind.Duplicate) : []), [verify]);
|
|
58
|
+
const freshRows = useMemo(() => excelEditorRows(fresh, columns), [fresh, columns]);
|
|
59
|
+
const sameRows = useMemo(() => excelEditorRows(same, columns), [same, columns]);
|
|
60
|
+
function reset() {
|
|
61
|
+
setFile(null);
|
|
62
|
+
setVerify(null);
|
|
63
|
+
setImported(null);
|
|
64
|
+
setFailure(null);
|
|
65
|
+
setGeneration(value => value + 1);
|
|
66
|
+
if (fileRef.current)
|
|
67
|
+
fileRef.current.value = "";
|
|
68
|
+
}
|
|
69
|
+
async function onTemplate() {
|
|
70
|
+
setBusy("template");
|
|
71
|
+
await downloadExcelTemplate(endpoints, callOptions);
|
|
72
|
+
setBusy(null);
|
|
73
|
+
}
|
|
74
|
+
async function onPicked(picked) {
|
|
75
|
+
if (!picked)
|
|
76
|
+
return;
|
|
77
|
+
setFile(picked);
|
|
78
|
+
setVerify(null);
|
|
79
|
+
setImported(null);
|
|
80
|
+
setFailure(null);
|
|
81
|
+
setBusy("checking");
|
|
82
|
+
const answer = await verifyExcelFile(endpoints, picked, callOptions);
|
|
83
|
+
setBusy(null);
|
|
84
|
+
// Clearing the input is what lets the same file be picked again after a fix in the spreadsheet.
|
|
85
|
+
if (fileRef.current)
|
|
86
|
+
fileRef.current.value = "";
|
|
87
|
+
if (!answer.ok || !answer.result) {
|
|
88
|
+
setFailure(answer.message ?? excelText("verifyFailed"));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setVerify(answer.result);
|
|
92
|
+
setGeneration(value => value + 1);
|
|
93
|
+
}
|
|
94
|
+
async function onImport() {
|
|
95
|
+
if (!verify)
|
|
96
|
+
return;
|
|
97
|
+
const newValues = newRef.current?.getValue() ?? freshRows;
|
|
98
|
+
const sameValues = sameRef.current?.getValue() ?? sameRows;
|
|
99
|
+
// A duplicate is never updated, so one the person left alone is not sent at all; one they edited is a new record.
|
|
100
|
+
const edited = sameValues.filter(row => isExcelRowEdited(row, columns));
|
|
101
|
+
if (newValues.length && newRef.current && !newRef.current.validate())
|
|
102
|
+
return;
|
|
103
|
+
if (edited.length && sameRef.current && !sameRef.current.validate())
|
|
104
|
+
return;
|
|
105
|
+
const rows = excelSheetRows([...newValues, ...edited], columns);
|
|
106
|
+
if (!rows.length) {
|
|
107
|
+
setFailure(excelText("importNothing"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setBusy("importing");
|
|
111
|
+
const answer = await importExcelRows(endpoints, rows, callOptions);
|
|
112
|
+
setBusy(null);
|
|
113
|
+
if (!answer.ok) {
|
|
114
|
+
setFailure(answer.message ?? excelText("importedNone"));
|
|
115
|
+
if (answer.result)
|
|
116
|
+
setImported(answer.result);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const written = answer.result;
|
|
120
|
+
if (!written)
|
|
121
|
+
return;
|
|
122
|
+
setFailure(null);
|
|
123
|
+
setImported(written);
|
|
124
|
+
toast.success(`${excelCount(written.created, subject)} imported`, { message: written.note });
|
|
125
|
+
// What was written is gone from the file's point of view: only what was refused stays to be fixed and sent again,
|
|
126
|
+
// and the counts are what those rows are now - not what the verify said about a file that has been half written.
|
|
127
|
+
const left = written.refused ?? [];
|
|
128
|
+
setVerify(current => current
|
|
129
|
+
? {
|
|
130
|
+
...current,
|
|
131
|
+
rows: left,
|
|
132
|
+
read: left.length,
|
|
133
|
+
new: left.filter(row => row.kind === ExcelRowKind.New).length,
|
|
134
|
+
duplicates: left.filter(row => row.kind === ExcelRowKind.Duplicate).length,
|
|
135
|
+
invalid: left.filter(row => row.kind === ExcelRowKind.Invalid).length,
|
|
136
|
+
isReady: false,
|
|
137
|
+
}
|
|
138
|
+
: current);
|
|
139
|
+
setGeneration(value => value + 1);
|
|
140
|
+
onImported?.(written);
|
|
141
|
+
}
|
|
142
|
+
/** A problem names a cell: clicking it brings that row into view and puts the caret in it. */
|
|
143
|
+
const goTo = (editor, list) => (problem) => {
|
|
144
|
+
const index = list.findIndex(row => row.number === problem.row);
|
|
145
|
+
if (index >= 0)
|
|
146
|
+
editor.current?.focusRow(index, problem.field);
|
|
147
|
+
};
|
|
148
|
+
const checking = busy === "checking";
|
|
149
|
+
const importing = busy === "importing";
|
|
150
|
+
const canImport = Boolean(verify) && !busy && (fresh.length > 0 || same.length > 0);
|
|
151
|
+
return (_jsxs("section", { className: cx("nx-excel", className), "aria-label": `Import ${excelPlural(subject)} from a spreadsheet`, "data-nx-excel": "", children: [_jsxs("ol", { className: "nx-excel__steps", children: [_jsxs("li", { children: ["Download the template", templateDownload === false ? "" : " below", ": every list in it is a dropdown, and the ids beside a choice fill themselves."] }), _jsxs("li", { children: ["Fill in one row per ", subject, ". Leave the locked columns alone."] }), _jsx("li", { children: "Send it here to be checked. Nothing is written until you import." })] }), _jsxs("div", { className: "nx-excel__pick", children: [templateDownload !== false && (_jsx(Button, { icon: _jsx(InlineIcon, { icon: iconDownload }), loading: busy === "template", onClick: onTemplate, children: excelText("template") })), _jsx("input", { ref: fileRef, id: `${id}-file`, className: "nx-visually-hidden", type: "file", accept: EXCEL_FILE_ACCEPT, onChange: event => void onPicked(event.currentTarget.files?.[0]) }), _jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconFileImport }), loading: checking, onClick: () => fileRef.current?.click(), children: file ? excelText("change") : excelText("choose") }), _jsx("p", { className: "nx-excel__file", "data-nx-excel-file": "", children: checking ? (_jsxs(_Fragment, { children: [excelText("checking"), " ", _jsx("strong", { children: file?.name }), String.fromCharCode(8230)] })) : file ? (_jsx("strong", { children: file.name })) : (_jsx("span", { className: "nx-excel__muted", children: excelText("nothingPicked") })) }), settings && (_jsxs("p", { className: "nx-excel__limits", children: [excelCount(settings.maxImportRows, "row"), " at most per file"] }))] }), failure && (_jsxs("p", { className: "nx-excel__failure", role: "alert", "data-nx-excel-failure": "", children: [_jsx(InlineIcon, { icon: iconAlertTriangle, "aria-hidden": "true" }), _jsx("span", { children: failure })] })), imported && _jsx(ImportedReport, { answer: imported, subject: subject }), verify && (_jsxs("div", { className: "nx-excel__review", "data-nx-excel-review": "", children: [!imported && _jsx("p", { className: "nx-excel__note", children: verify.note }), _jsx(ReviewSection, { id: `${id}-new`, title: excelText("newRows"), tone: verify.invalid > 0 ? "warning" : "neutral", count: fresh.length, hint: verify.invalid > 0 ? `${excelCount(verify.invalid, "row")} to fix before anything is written.` : undefined, rows: fresh, onGo: goTo(newRef, fresh), open: true, children: _jsx(TabularForm, { ref: newRef, "aria-label": excelText("newRows"), inputs: inputs, itemLabel: subject, defaultValue: freshRows, addable: false, sortable: false, maxHeight: maxHeight, size: "sm" }, `new-${generation}`) }), _jsx(ReviewSection, { id: `${id}-same`, title: excelText("duplicates"), tone: "muted", count: same.length, hint: excelText("duplicateHint"), rows: same, onGo: goTo(sameRef, same), children: _jsx(TabularForm, { ref: sameRef, "aria-label": excelText("duplicates"), inputs: inputs, itemLabel: subject, defaultValue: sameRows, addable: false, removable: false, sortable: false, maxHeight: maxHeight, size: "sm" }, `same-${generation}`) })] })), _jsxs("footer", { className: "nx-excel__actions", children: [verify && (fresh.length > 0 || same.length > 0) && (_jsxs("p", { className: "nx-excel__sending", "data-nx-excel-sending": "", children: [fresh.length > 0 ? `${excelCount(fresh.length, subject)} to send.` : "Nothing to send yet.", same.length > 0 ? ` Edit ${same.length === 1 ? `the one ${subject}` : `one of the ${excelCount(same.length, subject)}`} already there to send it too.` : ""] })), _jsxs("div", { className: "nx-excel__buttons", children: [file && !busy && (_jsx(Button, { variant: "ghost", onClick: reset, children: excelText("startOver") })), onClose && (_jsx(Button, { variant: "secondary", onClick: onClose, children: excelText("close") })), _jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconUpload }), loading: importing, disabled: !canImport, onClick: () => void onImport(), children: excelText("importLabel") })] })] })] }));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* One half of the review: a line that says what these rows are and how many, and the editor under it. Collapsible,
|
|
155
|
+
* because a person with twenty duplicates and three new rows is looking at the three.
|
|
156
|
+
*/
|
|
157
|
+
function ReviewSection({ id, title, count, tone, hint, rows, onGo, open: openByDefault = false, children }) {
|
|
158
|
+
const [open, setOpen] = useState(openByDefault);
|
|
159
|
+
const problems = useMemo(() => excelProblems(rows), [rows]);
|
|
160
|
+
const duplicates = useMemo(() => rows.filter(row => row.duplicate), [rows]);
|
|
161
|
+
if (count === 0)
|
|
162
|
+
return null;
|
|
163
|
+
return (_jsxs("section", { className: "nx-excel__section", "data-open": open || undefined, "data-tone": tone, "data-nx-excel-section": title, children: [_jsx("h3", { className: "nx-excel__section-head", children: _jsxs("button", { type: "button", className: "nx-excel__toggle", "aria-expanded": open, "aria-controls": open ? id : undefined, onClick: () => setOpen(!open), children: [_jsx(InlineIcon, { icon: iconChevronDown, className: "nx-excel__chevron", "aria-hidden": "true" }), _jsx("span", { className: "nx-excel__title", children: title }), _jsx("span", { className: "nx-excel__tag", children: count.toLocaleString("en-US") }), hint && _jsx("span", { className: "nx-excel__hint", children: hint })] }) }), open && (_jsxs("div", { className: "nx-excel__section-body", id: id, children: [children, problems.length > 0 && _jsx(ProblemList, { problems: problems, onGo: onGo }), duplicates.length > 0 && (_jsxs("div", { className: "nx-excel__problems nx-excel__problems--same", "data-nx-excel-duplicates": "", children: [_jsxs("p", { className: "nx-excel__problems-head", children: [_jsx(InlineIcon, { icon: iconCopy, "aria-hidden": "true" }), _jsx("span", { children: excelText("whyDuplicate") })] }), _jsx("ul", { children: duplicates.map(row => (_jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate })] }, row.number))) })] }))] }))] }));
|
|
164
|
+
}
|
|
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}`))) })] }));
|
|
168
|
+
}
|
|
169
|
+
/** What the import did, in the backend's own words, and what it did not write. */
|
|
170
|
+
function ImportedReport({ answer, subject }) {
|
|
171
|
+
const wrote = answer.created > 0;
|
|
172
|
+
return (_jsxs("div", { className: cx("nx-excel__result", wrote ? "nx-excel__result--ok" : "nx-excel__result--none"), role: "status", "data-nx-excel-result": "", children: [_jsxs("p", { className: "nx-excel__result-head", children: [_jsx(InlineIcon, { icon: wrote ? iconCircleCheck : iconAlertTriangle, "aria-hidden": "true" }), _jsx("strong", { children: wrote ? `${excelCount(answer.created, subject)} added` : excelText("importedNone") })] }), _jsx("p", { className: "nx-excel__result-note", children: answer.note }), (answer.refused?.length ?? 0) > 0 && (_jsx("ul", { className: "nx-excel__refused", children: answer.refused?.map(row => (_jsxs("li", { children: [_jsxs("span", { className: "nx-excel__place", children: ["Row ", row.number] }), _jsx("span", { children: row.duplicate ?? row.problems?.[0]?.message ?? excelText("refused") })] }, row.number))) }))] }));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Opens the import panel as a window. This is the one line a data table's Import entry needs.
|
|
176
|
+
*
|
|
177
|
+
* @example onImport={() => openExcelImport({ endpoints: CITY_SHEET, subject: "city", onImported: reload })}
|
|
178
|
+
*/
|
|
179
|
+
export function openExcelImport({ title, popupKey, ...props }) {
|
|
180
|
+
const subject = props.subject ?? "row";
|
|
181
|
+
return openPopup({
|
|
182
|
+
key: popupKey ?? `nx-excel:${String(props.endpoints.import.module)}:${props.endpoints.import.path}`,
|
|
183
|
+
title: title ?? `Import ${excelPlural(subject)} from a spreadsheet`,
|
|
184
|
+
icon: _jsx(InlineIcon, { icon: iconTable }),
|
|
185
|
+
size: "xl",
|
|
186
|
+
closeOnBackdrop: false,
|
|
187
|
+
// 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 }),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -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";
|
|
@@ -124,6 +125,9 @@ const FormCell = memo(function FormCell({ store, params, context }) {
|
|
|
124
125
|
useSyncExternalStore(subscribe, version, version);
|
|
125
126
|
const stamp = store.stamp(name);
|
|
126
127
|
const span = spanOf(params);
|
|
128
|
+
// An input that follows others is asked whether it shows each time it draws: it comes and goes with their values.
|
|
129
|
+
if (params.dependsOn?.length && params.hidden)
|
|
130
|
+
return null;
|
|
127
131
|
// Leaving the input shows the form's own rule message on it, such as "Passwords do not match".
|
|
128
132
|
function onBlur(event) {
|
|
129
133
|
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))
|
|
@@ -315,5 +319,5 @@ export function Form(props) {
|
|
|
315
319
|
const context = useMemo(() => ({ idPrefix, size, design, readOnly, disabled, scopeNames: !inForm }), [idPrefix, size, design, readOnly, disabled, inForm]);
|
|
316
320
|
// With FormButtons on the page, the form's message is in their status line.
|
|
317
321
|
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.
|
|
322
|
+
return (_jsxs("div", { ref: rootRef, id: id, className: cx("nx-form", `nx-form--${size}`, className), style: style, "data-nx-form": "", "aria-busy": loading || undefined, children: [status.restored && !loading && _jsx(DraftNotice, { store: anyStore, restored: status.restored, inputs: inputs, preferences: preferences }), _jsx("div", { className: "nx-form__grid", children: inputs.filter(params => params.dependsOn?.length || !params.hidden).map((params, index, visible) => (_jsxs(Fragment, { children: [params.section && (index === 0 || visible[index - 1]?.section !== params.section) && _jsx("h3", { className: "nx-form__section", children: params.section }), loading ? _jsx(SkeletonCell, { params: params, design: design }) : _jsx(FormCell, { store: anyStore, params: params, context: context })] }, params.name))) }), loading && (_jsx("p", { className: "nx-visually-hidden", role: "status", children: "Loading\u2026" })), _jsx("input", { ref: proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", disabled: locked, onInvalid: onProxyInvalid }), ownMessage && (_jsxs("p", { className: "nx-form__message nx-field__message nx-field__message--error", role: "alert", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), _jsx("span", { children: ownMessage })] }))] }));
|
|
319
323
|
}
|
|
@@ -188,7 +188,8 @@ export function DateTimePicker(props) {
|
|
|
188
188
|
const cap = natural > room ? `${Math.max(0, room)}px` : "";
|
|
189
189
|
if (panel.style.maxHeight !== cap)
|
|
190
190
|
panel.style.maxHeight = cap;
|
|
191
|
-
|
|
191
|
+
// No arrow: the panel opens flush under the box and is read as the field's own calendar.
|
|
192
|
+
placeUnderAnchor(panel, anchor, { gap: PANEL_GAP, margin: PANEL_MARGIN, arrow: false });
|
|
192
193
|
};
|
|
193
194
|
place();
|
|
194
195
|
if (focusPanelRef.current) {
|
|
@@ -64,7 +64,7 @@ const GroupSection = memo(function GroupSection({ store, rowKey, index, count, i
|
|
|
64
64
|
store.touch(rowKey, cell.dataset.nxCell ?? "");
|
|
65
65
|
}
|
|
66
66
|
const heading = (_jsxs(_Fragment, { children: [_jsx("span", { id: titleId, className: "nx-group-form__title", children: title }), tags.map(tag => (_jsx("span", { className: "nx-group-form__tag", children: tag }, tag))), summary && _jsx("span", { className: "nx-group-form__summary", children: summary }), problems > 0 && (_jsxs("span", { className: "nx-group-form__problems", children: [_jsx(InlineIcon, { icon: iconAlertCircle }), problems, " to fix"] }))] }));
|
|
67
|
-
return (_jsxs("li", { className: "nx-group-form__section", "data-nx-row": rowKey, "data-open": open || undefined, "data-problem": problems > 0 || undefined, children: [_jsxs("div", { className: "nx-group-form__head", children: [context.collapsible ? (_jsxs("button", { type: "button", className: "nx-group-form__toggle", "aria-expanded": open, "aria-controls": open ? bodyId : undefined, "data-nx-row-actions": "", onClick: () => setCollapsed(open), children: [_jsx(InlineIcon, { icon: iconChevronDown, className: "nx-group-form__chevron" }), heading] })) : (_jsx("div", { className: "nx-group-form__heading", children: heading })), (context.sortable || context.canAdd || context.removable) && (_jsxs("div", { className: "nx-group-form__actions", children: [context.sortable && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowUp }), "aria-label": `Move ${name} up`, title: "Move up", disabled: index === 0, onClick: () => onAction("move-up", rowKey) }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowDown }), "aria-label": `Move ${name} down`, title: "Move down", disabled: index === count - 1, onClick: () => onAction("move-down", rowKey) })] })), context.canAdd && (_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconCopy }), "aria-label": `Duplicate ${name}`, title: "Duplicate", onClick: () => onAction("duplicate", rowKey) })), context.removable && (_jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Remove ${name}`, title: "Remove", onClick: () => onAction("remove", rowKey) }))] }))] }), open && (_jsx("div", { id: bodyId, role: "group", "aria-labelledby": titleId, className: "nx-group-form__body", onBlur: onBlur, children: inputs.map(params => params.hidden ? null : (_jsx("div", { className: cx("nx-group-form__cell", CHOICE_TYPES.has(params.type) && "nx-group-form__cell--choice"), style: { "--nx-span": Math.max(1, Math.min(12, params.span ?? 12)) }, "data-nx-cell": params.name, children: _jsx(RowCell, { store: store, rowKey: rowKey, params: params, index: index, error: store.shownError(rowKey, params.name), layout: "group", idPrefix: context.idPrefix, size: context.size, design: context.design, readOnly: context.readOnly, disabled: context.disabled }, store.revision(rowKey, params.name)) }, params.name))) }))] }));
|
|
67
|
+
return (_jsxs("li", { className: "nx-group-form__section", "data-nx-row": rowKey, "data-open": open || undefined, "data-problem": problems > 0 || undefined, children: [_jsxs("div", { className: "nx-group-form__head", children: [context.collapsible ? (_jsxs("button", { type: "button", className: "nx-group-form__toggle", "aria-expanded": open, "aria-controls": open ? bodyId : undefined, "data-nx-row-actions": "", onClick: () => setCollapsed(open), children: [_jsx(InlineIcon, { icon: iconChevronDown, className: "nx-group-form__chevron" }), heading] })) : (_jsx("div", { className: "nx-group-form__heading", children: heading })), (context.sortable || context.canAdd || context.removable) && (_jsxs("div", { className: "nx-group-form__actions", children: [context.sortable && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowUp }), "aria-label": `Move ${name} up`, title: "Move up", disabled: index === 0, onClick: () => onAction("move-up", rowKey) }), _jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconArrowDown }), "aria-label": `Move ${name} down`, title: "Move down", disabled: index === count - 1, onClick: () => onAction("move-down", rowKey) })] })), context.canAdd && (_jsx(Button, { variant: "ghost", size: "sm", icon: _jsx(InlineIcon, { icon: iconCopy }), "aria-label": `Duplicate ${name}`, title: "Duplicate", onClick: () => onAction("duplicate", rowKey) })), context.removable && (_jsx(Button, { variant: "ghost", size: "sm", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Remove ${name}`, title: "Remove", onClick: () => onAction("remove", rowKey) }))] }))] }), open && (_jsx("div", { id: bodyId, role: "group", "aria-labelledby": titleId, className: "nx-group-form__body", onBlur: onBlur, children: inputs.map(params => params.hidden ? null : (_jsx("div", { className: cx("nx-group-form__cell", CHOICE_TYPES.has(params.type) && "nx-group-form__cell--choice"), style: { "--nx-span": Math.max(1, Math.min(12, params.span ?? 12)) }, "data-nx-cell": params.name, children: _jsx(RowCell, { store: store, rowKey: rowKey, params: context.cellParams(rowKey, params, index), index: index, error: store.shownError(rowKey, params.name), layout: "group", idPrefix: context.idPrefix, size: context.size, design: context.design, readOnly: context.readOnly, disabled: context.disabled, lock: context.cellLock(rowKey, params.name, index) }, store.revision(rowKey, params.name)) }, params.name))) }))] }));
|
|
68
68
|
});
|
|
69
69
|
/**
|
|
70
70
|
* The rows of a `TabularForm` as sections: each row's inputs with their own labels, on a 12-column grid (`span` in
|
|
@@ -153,6 +153,8 @@ export function GroupForm(props) {
|
|
|
153
153
|
idPrefix: frame.id,
|
|
154
154
|
size,
|
|
155
155
|
design,
|
|
156
|
+
cellLock: rows.cellLock,
|
|
157
|
+
cellParams: rows.cellParams,
|
|
156
158
|
readOnly,
|
|
157
159
|
disabled,
|
|
158
160
|
removable,
|
|
@@ -164,7 +166,7 @@ export function GroupForm(props) {
|
|
|
164
166
|
preferences,
|
|
165
167
|
sectionTitle,
|
|
166
168
|
summary,
|
|
167
|
-
}), [frame.id, size, design, readOnly, disabled, removable, sortable, rows.canAdd, collapsible, defaultCollapsed, snapshot.showAll, preferences, sectionTitle, summary]);
|
|
169
|
+
}), [frame.id, size, design, rows.cellLock, rows.cellParams, readOnly, disabled, removable, sortable, rows.canAdd, collapsible, defaultCollapsed, snapshot.showAll, preferences, sectionTitle, summary]);
|
|
168
170
|
const invalid = Boolean(error || snapshot.message || snapshot.invalidRows);
|
|
169
171
|
return (_jsxs("fieldset", { id: frame.id, className: cx("nx-rows", "nx-group-form", `nx-group-form--${size}`, className), style: style, disabled: disabled, "data-problem": invalid || undefined, "data-readonly": readOnly || undefined, "aria-label": props["aria-label"], "aria-describedby": frame.messageId, children: [_jsx(RowsLegend, { id: frame.legendId, label: label, required: required || (props.min ?? 0) > 0, lock: frame.lock }), _jsx("input", { ref: rows.proxyRef, className: "nx-visually-hidden", tabIndex: -1, "aria-hidden": "true", form: props.form, onInvalid: rows.onInvalid }), count === 0 ? (_jsx("p", { className: "nx-group-form__empty", children: frame.emptyMessage })) : (_jsx("ol", { ref: listRef, className: "nx-group-form__list", children: snapshot.keys.map((key, index) => (_jsx(GroupSection, { store: store, rowKey: key, index: index, count: count, inputs: inputs, context: context, isNew: !loadedKeys.has(key), onAction: onAction }, `${snapshot.generation}:${key}`))) })), _jsx(RowsFooter, { messageId: frame.messageId, store: store, snapshot: snapshot, error: error, hint: hint, addLabel: frame.addLabel, addable: addable, canAdd: rows.canAdd, max: max, maxMessage: frame.maxMessage, size: size, onAdd: () => rows.actions.add(), onUndo: () => rows.actions.undo() })] }));
|
|
170
172
|
}
|
|
@@ -21,6 +21,8 @@ export interface InputOverrides {
|
|
|
21
21
|
design?: InputDesign;
|
|
22
22
|
/** On top of the params: `true` makes the input read-only even when its params do not. */
|
|
23
23
|
readOnly?: boolean;
|
|
24
|
+
/** Replaces the params' own read-only note, for a cell locked by `cellReadOnly` rather than by its params. */
|
|
25
|
+
readOnlyMessage?: string;
|
|
24
26
|
/** On top of the params, like `readOnly`. */
|
|
25
27
|
disabled?: boolean;
|
|
26
28
|
fieldName?: string;
|
|
@@ -25,7 +25,7 @@ import { TreeView } from "./TreeView.js";
|
|
|
25
25
|
export function renderInput(params, overrides = {}) {
|
|
26
26
|
if (params.hidden)
|
|
27
27
|
return null;
|
|
28
|
-
const { type, span: _span, width: _width, grow: _grow, hidden: _hidden, onValueChange: ownChange, ...rest } = params;
|
|
28
|
+
const { type, section: _section, span: _span, width: _width, grow: _grow, hidden: _hidden, dependsOn: _dependsOn, onValueChange: ownChange, ...rest } = params;
|
|
29
29
|
const { defaultValue, onValueChange, readOnly, disabled, ...set } = overrides;
|
|
30
30
|
const props = { ...rest, ...set };
|
|
31
31
|
if (readOnly)
|
|
@@ -88,7 +88,8 @@ function ReadOnlyNote({ ref, message }) {
|
|
|
88
88
|
return;
|
|
89
89
|
if (!isPopoverOpen(note))
|
|
90
90
|
note.showPopover?.();
|
|
91
|
-
|
|
91
|
+
// No arrow: the note is not a panel, and it answers the whole box rather than one button in it.
|
|
92
|
+
const place = () => placeUnderAnchor(note, shown.anchor.getBoundingClientRect(), { align: shown.align, arrow: false });
|
|
92
93
|
place();
|
|
93
94
|
return followAnchor(place);
|
|
94
95
|
}, [shown]);
|
|
@@ -34,7 +34,16 @@ export declare function useRowsInput<TRow extends RowValue>(props: RowsInputProp
|
|
|
34
34
|
move(key: string, delta: number): void;
|
|
35
35
|
undo(): void;
|
|
36
36
|
};
|
|
37
|
+
cellLock: (key: string, name: string, index: number) => string | boolean | null | undefined;
|
|
38
|
+
rowLock: (key: string | null, index: number) => string | boolean | null | undefined;
|
|
39
|
+
cellParams: (key: string, params: InputParams, index: number) => InputParams;
|
|
37
40
|
};
|
|
41
|
+
/** What a layout asks for each cell: the reason it is generated, `true` for no reason, or nothing. */
|
|
42
|
+
export type CellLock = (key: string, name: string, index: number) => string | boolean | null | undefined;
|
|
43
|
+
/** What a layout asks for each cell: its own params, which are the column's unless `cellParams` answers for its row. */
|
|
44
|
+
export type CellParams = (key: string, params: InputParams, index: number) => InputParams;
|
|
45
|
+
/** What a layout asks for each row: the reason nobody may change it, `true` for no reason, or nothing. From `rowReadOnly`. */
|
|
46
|
+
export type RowLock = (key: string | null, index: number) => string | boolean | null | undefined;
|
|
38
47
|
/** The first control of an element that takes focus. */
|
|
39
48
|
export declare function firstFocusable(element: Element | null): HTMLElement | null;
|
|
40
49
|
/** Subscribes a row's component to its row: it renders again only when the row's messages or code-set values change. */
|
|
@@ -55,13 +64,15 @@ export interface RowCellProps {
|
|
|
55
64
|
design?: InputDesign;
|
|
56
65
|
readOnly: boolean;
|
|
57
66
|
disabled: boolean;
|
|
67
|
+
/** This one cell holds a generated value: the reason, `true` for no reason, or nothing. From `cellReadOnly`. */
|
|
68
|
+
lock?: string | boolean | null;
|
|
58
69
|
}
|
|
59
70
|
/**
|
|
60
71
|
* One input of one row. It starts from the store's value when it mounts and writes every change back, so typing renders
|
|
61
72
|
* nothing; a value set from code mounts it again (its key holds the value's revision). In a table the column header is
|
|
62
73
|
* the label, so the input is named "City, row 2" for screen readers and its messages still say "City".
|
|
63
74
|
*/
|
|
64
|
-
export declare const RowCell: import("react").MemoExoticComponent<({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled }: RowCellProps) => ReactNode>;
|
|
75
|
+
export declare const RowCell: import("react").MemoExoticComponent<({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled, lock }: RowCellProps) => ReactNode>;
|
|
65
76
|
/** The legend of the rows, with the required mark and, when read-only, a lock. */
|
|
66
77
|
export declare function RowsLegend({ id, label, required, lock }: {
|
|
67
78
|
id: string;
|
|
@@ -26,9 +26,9 @@ function sameRows(a, b) {
|
|
|
26
26
|
* listener, a parent form's data and reset, and the handle given to `ref`.
|
|
27
27
|
*/
|
|
28
28
|
export function useRowsInput(props, fallbackItem, layoutRef) {
|
|
29
|
-
const { inputs, required, min, max, unique, validate, rowKey, newRow, onRowChange, itemLabel, itemLabelPlural, value, defaultValue, name, readOnly = false, disabled = false, ref } = props;
|
|
29
|
+
const { inputs, required, min, max, unique, validate, rowKey, newRow, onRowChange, deriveRows, itemLabel, itemLabelPlural, value, defaultValue, name, readOnly = false, disabled = false, ref } = props;
|
|
30
30
|
const preferences = useDatePreferences();
|
|
31
|
-
const options = { inputs, required, min, max, unique, validate, rowKey, newRow, onRowChange, itemLabel, itemLabelPlural, fallbackItem, context: { preferences } };
|
|
31
|
+
const options = { inputs, required, min, max, unique, validate, rowKey, newRow, onRowChange, deriveRows, itemLabel, itemLabelPlural, fallbackItem, context: { preferences } };
|
|
32
32
|
const [store] = useState(() => new RowsStore(options, value ?? defaultValue ?? []));
|
|
33
33
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
34
34
|
const proxyRef = useRef(null);
|
|
@@ -160,7 +160,49 @@ export function useRowsInput(props, fallbackItem, layoutRef) {
|
|
|
160
160
|
}),
|
|
161
161
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
162
|
[store]);
|
|
163
|
-
|
|
163
|
+
// Stable, so a sheet of thousands of rows does not render again because the parent handed a new function.
|
|
164
|
+
const rowLock = useCallback((key, index) => {
|
|
165
|
+
const ask = latest.current.props.rowReadOnly;
|
|
166
|
+
if (!ask || !key)
|
|
167
|
+
return undefined;
|
|
168
|
+
const row = store.row(key);
|
|
169
|
+
return row ? ask(row, index) : undefined;
|
|
170
|
+
}, [store]);
|
|
171
|
+
const cellLock = useCallback((key, name, index) => {
|
|
172
|
+
// A row nobody may change locks every one of its cells, with the row's reason.
|
|
173
|
+
const whole = rowLock(key, index);
|
|
174
|
+
if (whole)
|
|
175
|
+
return whole;
|
|
176
|
+
const ask = latest.current.props.cellReadOnly;
|
|
177
|
+
if (!ask)
|
|
178
|
+
return undefined;
|
|
179
|
+
const row = store.row(key);
|
|
180
|
+
return row ? ask(row, name, index) : undefined;
|
|
181
|
+
}, [store, rowLock]);
|
|
182
|
+
// A cell's own params, merged once per answer: the same answer gives the same object, so the cell does not draw again.
|
|
183
|
+
const merged = useRef(new WeakMap());
|
|
184
|
+
const cellParams = useCallback((key, params, index) => {
|
|
185
|
+
const ask = latest.current.props.cellParams;
|
|
186
|
+
if (!ask)
|
|
187
|
+
return params;
|
|
188
|
+
const row = store.row(key);
|
|
189
|
+
const own = row ? ask(row, params.name, index) : null;
|
|
190
|
+
if (!own)
|
|
191
|
+
return params;
|
|
192
|
+
let byColumn = merged.current.get(own);
|
|
193
|
+
if (!byColumn)
|
|
194
|
+
merged.current.set(own, (byColumn = new WeakMap()));
|
|
195
|
+
let result = byColumn.get(params);
|
|
196
|
+
if (!result) {
|
|
197
|
+
// Descriptors rather than a spread, so a getter on either side is still read as the cell draws, as it would be
|
|
198
|
+
// on the column's own params; the column keeps its type and its name whatever the answer holds.
|
|
199
|
+
const { type: _type, name: _name, ...rest } = Object.getOwnPropertyDescriptors(own);
|
|
200
|
+
result = Object.defineProperties({}, { ...Object.getOwnPropertyDescriptors(params), ...rest });
|
|
201
|
+
byColumn.set(params, result);
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}, [store]);
|
|
205
|
+
return { store, snapshot, proxyRef, onInvalid, canAdd, actions, cellLock, rowLock, cellParams };
|
|
164
206
|
}
|
|
165
207
|
function addButtonOf(proxy) {
|
|
166
208
|
return proxy?.closest(".nx-rows")?.querySelector(":scope > .nx-rows__footer .nx-rows__add:not(:disabled)") ?? null;
|
|
@@ -191,7 +233,7 @@ export function labelText(params) {
|
|
|
191
233
|
* nothing; a value set from code mounts it again (its key holds the value's revision). In a table the column header is
|
|
192
234
|
* the label, so the input is named "City, row 2" for screen readers and its messages still say "City".
|
|
193
235
|
*/
|
|
194
|
-
export const RowCell = memo(function RowCell({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled }) {
|
|
236
|
+
export const RowCell = memo(function RowCell({ store, rowKey, params, index, error, layout, idPrefix, size, design, readOnly, disabled, lock }) {
|
|
195
237
|
const [initial] = useState(() => store.get(rowKey, params.name));
|
|
196
238
|
const onValueChange = useCallback((next) => store.set(rowKey, params.name, next, "input"), [store, rowKey, params.name]);
|
|
197
239
|
const needsName = params.type === "radio-group" || params.type === "checkbox-group";
|
|
@@ -205,6 +247,13 @@ export const RowCell = memo(function RowCell({ store, rowKey, params, index, err
|
|
|
205
247
|
readOnly,
|
|
206
248
|
disabled,
|
|
207
249
|
};
|
|
250
|
+
// A generated cell: read-only however its params have it, and saying why, since a box that ignores typing without a
|
|
251
|
+
// word reads as broken. Code still writes it — that is what makes it generated rather than merely locked.
|
|
252
|
+
if (lock) {
|
|
253
|
+
overrides.readOnly = true;
|
|
254
|
+
if (typeof lock === "string")
|
|
255
|
+
overrides.readOnlyMessage = lock;
|
|
256
|
+
}
|
|
208
257
|
if (error !== undefined)
|
|
209
258
|
overrides.error = error;
|
|
210
259
|
// A required radio means one row has it on, which the rows check; each radio alone is not required.
|
|
@@ -5,9 +5,13 @@ import type { RowValue, TabularFormProps } from "../../Interfaces/FormInterfaces
|
|
|
5
5
|
* come as params, the same objects a form takes, so the same list can show as sections in a `GroupForm`.
|
|
6
6
|
*
|
|
7
7
|
* Only the rows in view are on the page, so a sheet of thousands of rows opens and types as fast as a short one; the
|
|
8
|
-
* values live in a store outside React, and typing renders nothing. The header
|
|
9
|
-
*
|
|
10
|
-
* a text or number cell moves down the column, and on the last row adds a row.
|
|
8
|
+
* values live in a store outside React, and typing renders nothing. The header and the numbered column stay in place
|
|
9
|
+
* while the table scrolls. Each row has one control, beside its number and never in its place: a menu of insert,
|
|
10
|
+
* duplicate, move and remove. Enter in a text or number cell moves down the column, and on the last row adds a row.
|
|
11
|
+
*
|
|
12
|
+
* Every row is one line high, whatever it holds, so nothing shifts as the table is used; a column generated for every
|
|
13
|
+
* row (`cellReadOnly`) says so once on its header rather than on each of its cells; and `maxHeight` is brought down
|
|
14
|
+
* to whole rows, because a scroller that cuts the last one in half reads as broken rather than as scrollable.
|
|
11
15
|
*
|
|
12
16
|
* Inside a `<form>`, the rows are sent as JSON under `name`; a row with a problem blocks submit, and the first one
|
|
13
17
|
* scrolls into view with focus.
|