@anchrd/intel-ui 0.32.0 → 0.34.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/package.json +2 -2
- package/src/app/app-tree/app-tree.tsx +56 -5
- package/src/app/tree-move/tree-move.tsx +5 -5
- package/src/data/intel-data-provider/intel-data-provider.ts +11 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +9 -0
- package/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/src/entry-picker/entry-picker.tsx +27 -4
- package/src/flow-runs/flow-runs.tsx +9 -1
- package/src/flows/flows.tsx +219 -108
- package/src/graph-pane/graph-pane.tsx +9 -0
- package/src/i18n/de.json +24 -0
- package/src/i18n/en.json +24 -0
- package/src/i18n/es.json +24 -0
- package/src/node-editor/node-editor.tsx +24 -1
- package/src/nodes/nodes.tsx +31 -17
- package/src/resource-error.ts +33 -0
- package/src/resource-menu/resource-menu.tsx +37 -26
- package/src/table-columns/table-columns.tsx +379 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import type { Node } from "@anchrd/intel-contract/node";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { Plus, X } from "lucide-react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
6
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
7
|
+
import { resourceErrorKey } from "@/resource-error.ts";
|
|
8
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Changing a table's header from the UI (#531).
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ Adding and renaming are free; removing is confirmed. That asymmetry is the whole point of the
|
|
14
|
+
* dialog and not a courtesy: a rename keeps every cell, an added column starts empty and takes
|
|
15
|
+
* nothing away, and a removal is the one gesture whose cost is invisible in this dialog — the cells
|
|
16
|
+
* stand in the grid behind it, and after the write they are only in the version history.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ The confirmation is a SECOND STEP of this dialog, not `window.confirm` and not a modal over a
|
|
19
|
+
* modal. It has to name which columns go and how much content goes with them, and a browser prompt
|
|
20
|
+
* carries neither markup nor the reader's language.
|
|
21
|
+
*/
|
|
22
|
+
type Draft = { key: string; name: string; source: string | null };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The mapping together with the state it was written against (#531, review finding).
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ `versionId` is pinned HERE and not read from the query at save time, and that is the whole
|
|
28
|
+
* optimistic lock. The query behind it shares its key with the grid and refetches on window focus,
|
|
29
|
+
* so a mapping written against `version-3` would otherwise be sent with whatever version the table
|
|
30
|
+
* had grown to in the meantime — the server would accept it, and a column somebody else added in
|
|
31
|
+
* between falls with its cells, because no entry in this mapping names it. Measured in the review:
|
|
32
|
+
* a refetch between renaming and saving turned `version-3` into `version-9` and the guard never
|
|
33
|
+
* fired. `columns` and `rows` are pinned with it, so the warning counts the state the reader saw.
|
|
34
|
+
*/
|
|
35
|
+
type Snapshot = {
|
|
36
|
+
versionId: string | null;
|
|
37
|
+
columns: string[];
|
|
38
|
+
rows: string[][];
|
|
39
|
+
entries: Draft[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// One entry per column the table will have afterwards, in order — the shape `RedefineTableInput`
|
|
43
|
+
// takes. `source` is the CURRENT name of the column whose cells fill it, so renaming is keeping the
|
|
44
|
+
// source and changing the name, and a current column no entry names is the one that falls.
|
|
45
|
+
//
|
|
46
|
+
// ⚠️ The key of an existing column is its source, not a fresh id. This runs on every render for as
|
|
47
|
+
// long as nothing has been edited, and `crypto.randomUUID()` would hand React a new key each time —
|
|
48
|
+
// every field remounts, and a refetch behind the open dialog takes the caret out of the one being
|
|
49
|
+
// typed in. Sources are distinct on every path that writes a table, so they are already keys.
|
|
50
|
+
function draftOf(columns: string[]): Draft[] {
|
|
51
|
+
return columns.map((column) => ({ key: `source:${column}`, name: column, source: column }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Which current columns no draft entry claims. Reading it off the draft rather than tracking a
|
|
55
|
+
// second "removed" list is what makes taking a removal back the same gesture as adding: there is one
|
|
56
|
+
// list, and it is the answer.
|
|
57
|
+
function droppedFrom(columns: string[], draft: Draft[]): string[] {
|
|
58
|
+
const kept = new Set(draft.map((entry) => entry.source).filter((source) => source !== null));
|
|
59
|
+
return columns.filter((column) => !kept.has(column));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How many rows actually lose something.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ Not `rows.length`. Dropping a column nobody ever filled costs nothing, and telling the reader
|
|
66
|
+
* "42 rows lose cells" when none of them carried content there is a wrong sentence about the one
|
|
67
|
+
* thing they are being asked to confirm. Counted per row, because a row is what a person sees.
|
|
68
|
+
*/
|
|
69
|
+
function rowsWithContentIn(columns: string[], rows: string[][], dropped: string[]): number {
|
|
70
|
+
const indexes = dropped.map((column) => columns.indexOf(column)).filter((index) => index >= 0);
|
|
71
|
+
return rows.filter((row) => indexes.some((index) => (row[index] ?? "") !== "")).length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function TableColumnsDialog({ node, close }: { node: Node; close(): void }) {
|
|
75
|
+
const { data } = useIntelRouterContext();
|
|
76
|
+
const i18n = useI18n();
|
|
77
|
+
const queryClient = useQueryClient();
|
|
78
|
+
// The same key the grid reads under, so the dialog and the table behind it are one loaded state
|
|
79
|
+
// and never two that disagree about how many columns there are.
|
|
80
|
+
const table = useQuery({
|
|
81
|
+
queryKey: ["node-table", node.id],
|
|
82
|
+
queryFn: () => data.getNodeTable(node.id),
|
|
83
|
+
});
|
|
84
|
+
const [draft, setDraft] = useState<Snapshot | null>(null);
|
|
85
|
+
const [confirming, setConfirming] = useState(false);
|
|
86
|
+
// The focus on the way back out of the confirmation. A ref alone cannot do it: the button does
|
|
87
|
+
// not exist yet at the moment the step is left, so the wish is recorded and spent on the render
|
|
88
|
+
// that brings the form back.
|
|
89
|
+
const [refocusSave, setRefocusSave] = useState(false);
|
|
90
|
+
const saveButton = useRef<HTMLButtonElement>(null);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!refocusSave) return;
|
|
93
|
+
saveButton.current?.focus();
|
|
94
|
+
setRefocusSave(false);
|
|
95
|
+
}, [refocusSave]);
|
|
96
|
+
|
|
97
|
+
// ⚠️ Derived rather than seeded through an effect: the query answers after the first render, and
|
|
98
|
+
// an effect would leave one frame in which the dialog is a form over no rows. The FIRST edit
|
|
99
|
+
// freezes this into state, and from then on the pinned snapshot is what is read — including its
|
|
100
|
+
// `versionId`, which is what makes the server's refusal reachable at all.
|
|
101
|
+
const current: Snapshot = draft ?? {
|
|
102
|
+
versionId: table.data?.versionId ?? null,
|
|
103
|
+
columns: table.data?.columns ?? [],
|
|
104
|
+
rows: table.data?.rows ?? [],
|
|
105
|
+
entries: draftOf(table.data?.columns ?? []),
|
|
106
|
+
};
|
|
107
|
+
const entries = current.entries;
|
|
108
|
+
const dropped = droppedFrom(current.columns, entries);
|
|
109
|
+
const losing = rowsWithContentIn(current.columns, current.rows, dropped);
|
|
110
|
+
|
|
111
|
+
const names = entries.map((entry) => entry.name.trim());
|
|
112
|
+
const blank = names.some((name) => name === "");
|
|
113
|
+
const duplicate = new Set(names.map((name) => name.toLowerCase())).size !== names.length;
|
|
114
|
+
// ⚠️ The bounds of `TableColumn` and `RedefineTableInput`, mirrored rather than left to the
|
|
115
|
+
// provider's `parse`. A `ZodError` never reaches `resourceErrorKey` with a code, so it arrives as
|
|
116
|
+
// "reload the latest version and try again" — advice that cannot help somebody whose column name
|
|
117
|
+
// is simply too long.
|
|
118
|
+
//
|
|
119
|
+
// ⚠️ And it is not `maxLength`'s job: that attribute bounds what is TYPED or pasted, and it does
|
|
120
|
+
// not touch a value that is already in the field. The header this dialog starts from comes from
|
|
121
|
+
// the server, and `bundle.ts` checks an imported CSV header against neither bound — so a table
|
|
122
|
+
// that is already over one of them is where this check earns its place.
|
|
123
|
+
const tooLong = names.some((name) => name.length > 120);
|
|
124
|
+
const tooMany = entries.length > 64;
|
|
125
|
+
const empty = entries.length === 0;
|
|
126
|
+
const incomplete = empty || blank || duplicate || tooLong || tooMany;
|
|
127
|
+
|
|
128
|
+
const save = useMutation({
|
|
129
|
+
mutationFn: async () => {
|
|
130
|
+
const idempotencyKey = crypto.randomUUID();
|
|
131
|
+
// ⚠️ Two calls, because the server has two: `defineTable` refuses on a table that already has
|
|
132
|
+
// a header, and `redefineTable` refuses on one that has none (`table_undefined`). A table
|
|
133
|
+
// without a header is reachable through MCP and through the bundle import, so this dialog is
|
|
134
|
+
// the only place in the UI where such a table can be given one at all.
|
|
135
|
+
//
|
|
136
|
+
// ⚠️ `current.versionId`, never `table.data.versionId`: the mapping below was written against
|
|
137
|
+
// the pinned snapshot, and sending it under a version it was not written against is exactly
|
|
138
|
+
// the silent wrong-cells move `Snapshot` exists to prevent.
|
|
139
|
+
const base = current.versionId;
|
|
140
|
+
if (base === null) {
|
|
141
|
+
await data.defineTable({ nodeId: node.id, columns: names, idempotencyKey });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await data.redefineTable({
|
|
145
|
+
nodeId: node.id,
|
|
146
|
+
baseVersionId: base,
|
|
147
|
+
columns: entries.map((entry, index) => ({
|
|
148
|
+
name: names[index] ?? "",
|
|
149
|
+
source: entry.source,
|
|
150
|
+
})),
|
|
151
|
+
idempotencyKey,
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
onSuccess: async () => {
|
|
155
|
+
// The grid, the count beside the title and the record itself: the header is drawn from the
|
|
156
|
+
// first, the summary from the first, and `updatedAt` — which every other change in the menu
|
|
157
|
+
// sends as `baseUpdatedAt` — from the last.
|
|
158
|
+
await Promise.all([
|
|
159
|
+
queryClient.invalidateQueries({ queryKey: ["node-table", node.id] }),
|
|
160
|
+
queryClient.invalidateQueries({ queryKey: ["node", node.id] }),
|
|
161
|
+
]);
|
|
162
|
+
close();
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Every edit writes the WHOLE snapshot back, which is what pins the version: the first CHANGE —
|
|
167
|
+
// a keystroke, a ✕, an added column — is the moment the reader started working against a
|
|
168
|
+
// particular state of the table.
|
|
169
|
+
//
|
|
170
|
+
// ⚠️ Between opening and that first change there is deliberately no guard. Nothing has been
|
|
171
|
+
// composed yet that points at a particular header, so a version arriving in between is simply the
|
|
172
|
+
// one the reader is now looking at. Saying otherwise would promise more than is here.
|
|
173
|
+
function edit(key: string, name: string): void {
|
|
174
|
+
setDraft({
|
|
175
|
+
...current,
|
|
176
|
+
entries: entries.map((entry) => (entry.key === key ? { ...entry, name } : entry)),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function drop(key: string): void {
|
|
181
|
+
setDraft({ ...current, entries: entries.filter((entry) => entry.key !== key) });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function add(): void {
|
|
185
|
+
setDraft({
|
|
186
|
+
...current,
|
|
187
|
+
// A fresh id, because an added column has no source to be named after — and no `source:`
|
|
188
|
+
// prefix, so it can never collide with the key of a column that has one.
|
|
189
|
+
entries: [...entries, { key: crypto.randomUUID(), name: "", source: null }],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function submit(): void {
|
|
194
|
+
// Removing is the only branch that asks. Everything else — a rename, an added column, both at
|
|
195
|
+
// once — goes straight through, because nothing stored is lost by either.
|
|
196
|
+
if (dropped.length > 0 && !confirming) {
|
|
197
|
+
setConfirming(true);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
save.mutate();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<Modal title={i18n.t("node.table.columnsTitle", { title: node.title })} close={close}>
|
|
205
|
+
{table.isPending ? (
|
|
206
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
207
|
+
) : table.isError ? (
|
|
208
|
+
<p role="alert" className="text-sm text-destructive">
|
|
209
|
+
{i18n.t("node.loadFailed")}
|
|
210
|
+
</p>
|
|
211
|
+
) : confirming ? (
|
|
212
|
+
<div className="space-y-4">
|
|
213
|
+
{/* ⚠️ `alert`, not `status`: this is the one screen in the dialog that must be read before
|
|
214
|
+
the button under it is pressed, and it is the only place the cost is named at all. */}
|
|
215
|
+
<div role="alert" className="space-y-2 rounded-md border border-destructive/30 p-3">
|
|
216
|
+
{/* ⚠️ One sentence per number, the `.none`/`.one`/`.many` shape `archive.purge.links`
|
|
217
|
+
already uses for the same job. A single plural template reads as broken German the
|
|
218
|
+
moment the count is one — "Diese Spalten … : note", "1 Zeilen tragen dort Inhalt" —
|
|
219
|
+
and this is the screen a reader is asked to trust before losing cells. Found in the
|
|
220
|
+
browser, not in jsdom: the tests were written around the two-row case. */}
|
|
221
|
+
<p className="text-sm font-medium text-destructive">
|
|
222
|
+
{i18n.t(
|
|
223
|
+
dropped.length === 1
|
|
224
|
+
? "node.table.columnsDropWarning.one"
|
|
225
|
+
: "node.table.columnsDropWarning.many",
|
|
226
|
+
{ columns: dropped.join(", ") },
|
|
227
|
+
)}
|
|
228
|
+
</p>
|
|
229
|
+
{/* The honest number, and the honest sentence when it is zero. A column nobody ever
|
|
230
|
+
filled costs nothing, and saying "42 rows" about it would be the wrong sentence. */}
|
|
231
|
+
<p className="text-xs text-muted-foreground">
|
|
232
|
+
{losing === 0
|
|
233
|
+
? i18n.t("node.table.columnsDropRows.none")
|
|
234
|
+
: losing === 1
|
|
235
|
+
? i18n.t("node.table.columnsDropRows.one")
|
|
236
|
+
: i18n.t("node.table.columnsDropRows.many", { count: losing })}
|
|
237
|
+
</p>
|
|
238
|
+
</div>
|
|
239
|
+
{save.isError ? (
|
|
240
|
+
<p role="alert" className="text-sm text-destructive">
|
|
241
|
+
{i18n.t(resourceErrorKey(save.error))}
|
|
242
|
+
</p>
|
|
243
|
+
) : null}
|
|
244
|
+
<div className="flex gap-2">
|
|
245
|
+
{/* The way back is first and it is a real button: somebody who reads the warning and
|
|
246
|
+
changes their mind must not have to find the × in the corner.
|
|
247
|
+
|
|
248
|
+
⚠️ And it takes the focus, because this step REPLACES the form the focus was in —
|
|
249
|
+
without it the focus falls to `<body>` and a keyboard reader stands outside the
|
|
250
|
+
dialog at the one step that destroys something (review finding to #531). The safe
|
|
251
|
+
button, not the destructive one: focus is where Enter lands. */}
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
// biome-ignore lint/a11y/noAutofocus: the step replaces the focused form; see above
|
|
255
|
+
autoFocus
|
|
256
|
+
// ⚠️ And the way BACK carries the focus too. `autoFocus` only fires on the way in;
|
|
257
|
+
// returning to the form dropped it on `<body>` again — half a keyboard path is the
|
|
258
|
+
// same defect as none, one step further on (second review round of #531).
|
|
259
|
+
onClick={() => {
|
|
260
|
+
setConfirming(false);
|
|
261
|
+
setRefocusSave(true);
|
|
262
|
+
}}
|
|
263
|
+
className="flex-1 rounded-md border px-4 py-2 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
264
|
+
>
|
|
265
|
+
{i18n.t("common.cancel")}
|
|
266
|
+
</button>
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
onClick={() => save.mutate()}
|
|
270
|
+
disabled={save.isPending}
|
|
271
|
+
className="flex-1 rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
272
|
+
>
|
|
273
|
+
{save.isPending ? i18n.t("common.saving") : i18n.t("node.table.columnsDropConfirm")}
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
) : (
|
|
278
|
+
<form
|
|
279
|
+
className="space-y-4"
|
|
280
|
+
onSubmit={(event) => {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
if (!save.isPending && !incomplete) submit();
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
{save.isError ? (
|
|
286
|
+
<p role="alert" className="text-sm text-destructive">
|
|
287
|
+
{i18n.t(resourceErrorKey(save.error))}
|
|
288
|
+
</p>
|
|
289
|
+
) : null}
|
|
290
|
+
<ul className="max-h-72 space-y-2 overflow-y-auto">
|
|
291
|
+
{entries.map((entry) => (
|
|
292
|
+
<li key={entry.key} className="flex items-center gap-2">
|
|
293
|
+
<label className="min-w-0 flex-1 text-sm">
|
|
294
|
+
{/* ⚠️ The name is the accessible name of the field, and it is the CURRENT column's
|
|
295
|
+
name rather than a bare "Column": with five fields on screen, five labels
|
|
296
|
+
reading the same word leave a screen reader with no way to say which one is
|
|
297
|
+
being edited. An added column has no current name and says so. */}
|
|
298
|
+
<span className="sr-only">
|
|
299
|
+
{entry.source === null
|
|
300
|
+
? i18n.t("node.table.columnNewName")
|
|
301
|
+
: i18n.t("node.table.columnName", { column: entry.source })}
|
|
302
|
+
</span>
|
|
303
|
+
<input
|
|
304
|
+
value={entry.name}
|
|
305
|
+
// The bound `TableColumn` carries, for what is typed or pasted here. What
|
|
306
|
+
// arrives from the server already over it is caught by `tooLong` above.
|
|
307
|
+
maxLength={120}
|
|
308
|
+
onChange={(event) => edit(entry.key, event.target.value)}
|
|
309
|
+
className="w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
310
|
+
/>
|
|
311
|
+
</label>
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
onClick={() => drop(entry.key)}
|
|
315
|
+
// ⚠️ A column with no name yet has its own label. The fallback used to fill the
|
|
316
|
+
// template with an empty string, and the button then announced itself as
|
|
317
|
+
// `Die Spalte „" entfernen` — seen in the browser right after adding one.
|
|
318
|
+
aria-label={
|
|
319
|
+
entry.name.trim() === "" && entry.source === null
|
|
320
|
+
? i18n.t("node.table.columnDropNew")
|
|
321
|
+
: i18n.t("node.table.columnDrop", {
|
|
322
|
+
column: entry.name.trim() === "" ? (entry.source ?? "") : entry.name,
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
className="shrink-0 rounded-md p-2 text-muted-foreground outline-none hover:bg-muted hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring"
|
|
326
|
+
>
|
|
327
|
+
<X aria-hidden="true" className="size-4" />
|
|
328
|
+
</button>
|
|
329
|
+
</li>
|
|
330
|
+
))}
|
|
331
|
+
</ul>
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
onClick={add}
|
|
335
|
+
disabled={entries.length >= 64}
|
|
336
|
+
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed px-4 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
337
|
+
>
|
|
338
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
339
|
+
{i18n.t("node.table.columnAdd")}
|
|
340
|
+
</button>
|
|
341
|
+
{/* ⚠️ EVERY reason the button is grey is named while it holds, and there are five. The
|
|
342
|
+
first version of this listed two and left the empty header — the state a reader
|
|
343
|
+
reaches by removing the last column — with a dead button and no sentence at all
|
|
344
|
+
(review finding to #531). A submit that is grey for a reason nobody states is a dialog
|
|
345
|
+
that has stopped answering. Ordered so the one the reader just caused comes first. */}
|
|
346
|
+
{duplicate ? (
|
|
347
|
+
<p role="alert" className="text-sm text-destructive">
|
|
348
|
+
{i18n.t("node.table.columnsDistinct")}
|
|
349
|
+
</p>
|
|
350
|
+
) : blank ? (
|
|
351
|
+
<p role="alert" className="text-sm text-destructive">
|
|
352
|
+
{i18n.t("node.table.columnsNamed")}
|
|
353
|
+
</p>
|
|
354
|
+
) : tooLong ? (
|
|
355
|
+
<p role="alert" className="text-sm text-destructive">
|
|
356
|
+
{i18n.t("node.table.columnsTooLong")}
|
|
357
|
+
</p>
|
|
358
|
+
) : tooMany ? (
|
|
359
|
+
<p role="alert" className="text-sm text-destructive">
|
|
360
|
+
{i18n.t("node.table.columnsTooMany")}
|
|
361
|
+
</p>
|
|
362
|
+
) : empty ? (
|
|
363
|
+
<p role="alert" className="text-sm text-destructive">
|
|
364
|
+
{i18n.t("node.table.columnsAtLeastOne")}
|
|
365
|
+
</p>
|
|
366
|
+
) : null}
|
|
367
|
+
<button
|
|
368
|
+
ref={saveButton}
|
|
369
|
+
type="submit"
|
|
370
|
+
disabled={save.isPending || incomplete}
|
|
371
|
+
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
372
|
+
>
|
|
373
|
+
{save.isPending ? i18n.t("common.saving") : i18n.t("common.save")}
|
|
374
|
+
</button>
|
|
375
|
+
</form>
|
|
376
|
+
)}
|
|
377
|
+
</Modal>
|
|
378
|
+
);
|
|
379
|
+
}
|