@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.
@@ -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
+ }