@anchrd/intel-ui 0.32.0 → 0.33.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -66,6 +66,7 @@ import {
66
66
  AppendTableRowsInput,
67
67
  AppendTableRowsResult,
68
68
  DefineTableInput,
69
+ RedefineTableInput,
69
70
  } from "@anchrd/intel-contract/table";
70
71
  import { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
71
72
  import type { z } from "zod";
@@ -319,6 +320,16 @@ export function createIntelDataProvider(
319
320
  { method: "POST", body: JSON.stringify(parsed) },
320
321
  );
321
322
  },
323
+ // ⚠️ `PATCH`, where the two writes above are `POST`. The route is the same address as the
324
+ // header itself, and this changes it — `POST /nodes/:id/table` is the one that WRITES a header
325
+ // and refuses on a table that already has one.
326
+ async redefineTable(input) {
327
+ const parsed = RedefineTableInput.parse(input);
328
+ return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}/table`, NodeTable, {
329
+ method: "PATCH",
330
+ body: JSON.stringify(parsed),
331
+ });
332
+ },
322
333
  async listNodeVersions(nodeId) {
323
334
  return await request(`/nodes/${encodeURIComponent(nodeId)}/versions`, NodeVersionList);
324
335
  },
@@ -65,6 +65,7 @@ import type {
65
65
  AppendTableRowsInput,
66
66
  AppendTableRowsResult,
67
67
  DefineTableInput,
68
+ RedefineTableInput,
68
69
  } from "@anchrd/intel-contract/table";
69
70
  import type { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
70
71
 
@@ -125,6 +126,14 @@ export interface IntelDataProvider {
125
126
  getNodeTable(nodeId: string): Promise<NodeTable>;
126
127
  defineTable(input: DefineTableInput): Promise<NodeTable>;
127
128
  appendTableRows(input: AppendTableRowsInput): Promise<AppendTableRowsResult>;
129
+ // Changing the header of a table that already has one (#135). The mapping is explicit — every new
130
+ // column names the current one whose cells fill it, or `null` for an empty one — because a new
131
+ // header without it would reinterpret every stored row under names nobody matched to the old ones.
132
+ //
133
+ // ⚠️ `baseVersionId` is the `versionId` the mapping was written against, and the server refuses
134
+ // with `version_conflict` when the table has moved on. Not optional: a mapping onto a header
135
+ // somebody else has since changed would move the wrong cells, silently.
136
+ redefineTable(input: RedefineTableInput): Promise<NodeTable>;
128
137
 
129
138
  // Walks the browser through the portal's OAuth flow without asking anybody anything: Gate is the
130
139
  // identity provider Cloudflare Access consumes, so a signed-in person is already known there
package/src/i18n/de.json CHANGED
@@ -74,6 +74,7 @@
74
74
  "resource.move": "Verschieben",
75
75
  "resource.renameTitle": "{title} umbenennen",
76
76
  "resource.archive": "Archivieren",
77
+ "resource.columns": "Spalten",
77
78
  "resource.export": "Export",
78
79
  "resource.import": "Import",
79
80
  "resource.validate": "Prüfen",
@@ -117,6 +118,23 @@
117
118
  "node.table.summary": "{rows} Zeilen, {columns} Spalten",
118
119
  "node.table.empty": "Noch keine Zeilen. Zeilen werden von Flows angehängt.",
119
120
  "node.table.undefined": "Diese Tabelle hat noch keine Spalten.",
121
+ "node.table.columnsTitle": "Spalten von {title}",
122
+ "node.table.columnName": "Name der Spalte „{column}“",
123
+ "node.table.columnNewName": "Name der neuen Spalte",
124
+ "node.table.columnAdd": "Spalte hinzufügen",
125
+ "node.table.columnDrop": "Die Spalte „{column}“ entfernen",
126
+ "node.table.columnDropNew": "Die neue Spalte entfernen",
127
+ "node.table.columnsDistinct": "Zwei Spalten können nicht denselben Namen tragen.",
128
+ "node.table.columnsNamed": "Jede Spalte braucht einen Namen.",
129
+ "node.table.columnsAtLeastOne": "Eine Tabelle braucht mindestens eine Spalte.",
130
+ "node.table.columnsTooLong": "Ein Spaltenname darf höchstens 120 Zeichen lang sein.",
131
+ "node.table.columnsTooMany": "Eine Tabelle kann höchstens 64 Spalten haben.",
132
+ "node.table.columnsDropWarning.one": "Diese Spalte wird mitsamt allen Zellen darin entfernt: {columns}",
133
+ "node.table.columnsDropWarning.many": "Diese Spalten werden mitsamt allen Zellen darin entfernt: {columns}",
134
+ "node.table.columnsDropRows.none": "Keine Zeile trägt dort Inhalt, es geht also nichts mit ihnen verloren.",
135
+ "node.table.columnsDropRows.one": "Eine Zeile trägt dort Inhalt. Er bleibt im Versionsverlauf lesbar und sonst nirgends.",
136
+ "node.table.columnsDropRows.many": "{count} Zeilen tragen dort Inhalt. Er bleibt im Versionsverlauf lesbar und sonst nirgends.",
137
+ "node.table.columnsDropConfirm": "Entfernen und speichern",
120
138
  "node.select": "Wähle ein Dokument oder einen Ordner, um damit zu arbeiten.",
121
139
  "node.folderEmpty": "In diesem Ordner ist noch nichts. Lege etwas über das Plus in der Seitenleiste an.",
122
140
  "node.folderFailed": "Dieser Ordner konnte nicht gelesen werden.",
@@ -344,6 +362,7 @@
344
362
  "common.loading": "Wird geladen …",
345
363
  "common.retry": "Erneut versuchen",
346
364
  "common.close": "Schließen",
365
+ "common.cancel": "Abbrechen",
347
366
  "common.unavailable": "Intel ist derzeit nicht verfügbar.",
348
367
  "common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
349
368
  "common.noPermission": "Dir fehlt die Berechtigung, das zu sehen.",
package/src/i18n/en.json CHANGED
@@ -74,6 +74,7 @@
74
74
  "resource.move": "Move",
75
75
  "resource.renameTitle": "Rename {title}",
76
76
  "resource.archive": "Archive",
77
+ "resource.columns": "Columns",
77
78
  "resource.export": "Export",
78
79
  "resource.import": "Import",
79
80
  "resource.validate": "Check",
@@ -117,6 +118,23 @@
117
118
  "node.table.summary": "{rows} rows, {columns} columns",
118
119
  "node.table.empty": "No rows yet. Rows are appended by flows.",
119
120
  "node.table.undefined": "This table has no columns yet.",
121
+ "node.table.columnsTitle": "Columns of {title}",
122
+ "node.table.columnName": "Name of the column “{column}”",
123
+ "node.table.columnNewName": "Name of the new column",
124
+ "node.table.columnAdd": "Add column",
125
+ "node.table.columnDrop": "Remove the column “{column}”",
126
+ "node.table.columnDropNew": "Remove the new column",
127
+ "node.table.columnsDistinct": "Two columns cannot carry the same name.",
128
+ "node.table.columnsNamed": "Every column needs a name.",
129
+ "node.table.columnsAtLeastOne": "A table needs at least one column.",
130
+ "node.table.columnsTooLong": "A column name can be at most 120 characters long.",
131
+ "node.table.columnsTooMany": "A table can have at most 64 columns.",
132
+ "node.table.columnsDropWarning.one": "This column is removed with every cell in it: {columns}",
133
+ "node.table.columnsDropWarning.many": "These columns are removed with every cell in them: {columns}",
134
+ "node.table.columnsDropRows.none": "No row carries content there, so nothing is lost with them.",
135
+ "node.table.columnsDropRows.one": "One row carries content there. It stays readable in the version history and nowhere else.",
136
+ "node.table.columnsDropRows.many": "{count} rows carry content there. It stays readable in the version history and nowhere else.",
137
+ "node.table.columnsDropConfirm": "Remove and save",
120
138
  "node.select": "Select a document or folder to work with it.",
121
139
  "node.folderEmpty": "Nothing in this folder yet. Create something with the plus in the sidebar.",
122
140
  "node.folderFailed": "This folder could not be read.",
@@ -344,6 +362,7 @@
344
362
  "common.loading": "Loading…",
345
363
  "common.retry": "Try again",
346
364
  "common.close": "Close",
365
+ "common.cancel": "Cancel",
347
366
  "common.unavailable": "Intel is currently unavailable.",
348
367
  "common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
349
368
  "common.noPermission": "You do not have permission to see this.",
package/src/i18n/es.json CHANGED
@@ -74,6 +74,7 @@
74
74
  "resource.move": "Mover",
75
75
  "resource.renameTitle": "Cambiar el nombre de {title}",
76
76
  "resource.archive": "Archivar",
77
+ "resource.columns": "Columnas",
77
78
  "resource.export": "Exportar",
78
79
  "resource.import": "Importar",
79
80
  "resource.validate": "Comprobar",
@@ -117,6 +118,23 @@
117
118
  "node.table.summary": "{rows} filas, {columns} columnas",
118
119
  "node.table.empty": "Todavía no hay filas. Las filas las añaden los flujos.",
119
120
  "node.table.undefined": "Esta tabla todavía no tiene columnas.",
121
+ "node.table.columnsTitle": "Columnas de {title}",
122
+ "node.table.columnName": "Nombre de la columna «{column}»",
123
+ "node.table.columnNewName": "Nombre de la nueva columna",
124
+ "node.table.columnAdd": "Añadir columna",
125
+ "node.table.columnDrop": "Eliminar la columna «{column}»",
126
+ "node.table.columnDropNew": "Eliminar la nueva columna",
127
+ "node.table.columnsDistinct": "Dos columnas no pueden llevar el mismo nombre.",
128
+ "node.table.columnsNamed": "Cada columna necesita un nombre.",
129
+ "node.table.columnsAtLeastOne": "Una tabla necesita al menos una columna.",
130
+ "node.table.columnsTooLong": "Un nombre de columna puede tener como máximo 120 caracteres.",
131
+ "node.table.columnsTooMany": "Una tabla puede tener como máximo 64 columnas.",
132
+ "node.table.columnsDropWarning.one": "Esta columna se elimina con todas sus celdas: {columns}",
133
+ "node.table.columnsDropWarning.many": "Estas columnas se eliminan con todas sus celdas: {columns}",
134
+ "node.table.columnsDropRows.none": "Ninguna fila tiene contenido ahí, así que no se pierde nada con ellas.",
135
+ "node.table.columnsDropRows.one": "Una fila tiene contenido ahí. Seguirá siendo legible en el historial de versiones y en ningún otro sitio.",
136
+ "node.table.columnsDropRows.many": "{count} filas tienen contenido ahí. Seguirá siendo legible en el historial de versiones y en ningún otro sitio.",
137
+ "node.table.columnsDropConfirm": "Eliminar y guardar",
120
138
  "node.select": "Elige un documento o una carpeta para trabajar con ello.",
121
139
  "node.folderEmpty": "En esta carpeta todavía no hay nada. Crea algo con el más de la barra lateral.",
122
140
  "node.folderFailed": "Esta carpeta no se ha podido leer.",
@@ -344,6 +362,7 @@
344
362
  "common.loading": "Cargando…",
345
363
  "common.retry": "Intentarlo de nuevo",
346
364
  "common.close": "Cerrar",
365
+ "common.cancel": "Cancelar",
347
366
  "common.unavailable": "Intel no está disponible en este momento.",
348
367
  "common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
349
368
  "common.noPermission": "No tienes permiso para ver esto.",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Which sentence a refused change to a resource is told with.
3
+ *
4
+ * ⚠️ A conflict is the one that must not be swallowed: every write in the product names the state it
5
+ * was made against — `baseUpdatedAt` for a record, `baseVersionId` for a table — and turns a parallel
6
+ * edit into a refusal. A refusal nobody words is indistinguishable from a change that went through.
7
+ *
8
+ * ⚠️ Flat and on its own since #531, where the table's column dialog became the second reader. It
9
+ * used to live in `resource-menu.tsx`; importing it from there would have made the menu and the
10
+ * dialog import each other, and a cycle is a thing that works until the bundler splits differently.
11
+ */
12
+ export function resourceErrorKey(error: unknown): string {
13
+ const code =
14
+ typeof error === "object" && error !== null && "code" in error
15
+ ? String((error as { code: unknown }).code)
16
+ : null;
17
+ switch (code) {
18
+ // ⚠️ `version_conflict` is the table's word for the same event (`nodes.ts`, `tableStateFor`):
19
+ // the positions or the mapping were read from a version that is no longer the newest. It is the
20
+ // conflict sentence and not the general one — "reload it and try again" is exactly the way out,
21
+ // and letting it fall through to `resource.failed` would tell somebody to reload in the same
22
+ // breath as claiming nobody knows what happened.
23
+ case "version_conflict":
24
+ case "update_conflict":
25
+ case "flow_update_conflict":
26
+ return "resource.conflict";
27
+ case "node_forbidden":
28
+ case "flow_edit_forbidden":
29
+ return "resource.forbidden";
30
+ default:
31
+ return "resource.failed";
32
+ }
33
+ }
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  import { useNavigate, useRouterState } from "@tanstack/react-router";
6
6
  import {
7
7
  Archive,
8
+ Columns3,
8
9
  CornerLeftUp,
9
10
  Ellipsis,
10
11
  FileArchive,
@@ -34,8 +35,10 @@ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.t
34
35
  import { useI18n } from "@/i18n/i18n-context.tsx";
35
36
  import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
36
37
  import { Modal } from "@/modal/modal.tsx";
38
+ import { resourceErrorKey } from "@/resource-error.ts";
37
39
  import { useIntelRouterContext } from "@/router/router-context.ts";
38
40
  import { selectedFrom } from "@/router/selection-search.ts";
41
+ import { TableColumnsDialog } from "@/table-columns/table-columns.tsx";
39
42
  import { RelativeTime } from "@/time/relative-time.tsx";
40
43
  import { useDateTime } from "@/time/time-context.tsx";
41
44
 
@@ -92,26 +95,6 @@ export function verbHintKey(verb: ResourceVerb, subject: ResourceTarget["type"]
92
95
  return `${subject}.verbHint.${verb}`;
93
96
  }
94
97
 
95
- // ⚠️ The same reading `moveErrorKey` does for a move, for the changes this menu makes. A conflict is
96
- // the one that must not be swallowed: `baseUpdatedAt` turns a parallel edit into a refusal, and a
97
- // refusal nobody words is indistinguishable from a change that went through.
98
- export function resourceErrorKey(error: unknown): string {
99
- const code =
100
- typeof error === "object" && error !== null && "code" in error
101
- ? String((error as { code: unknown }).code)
102
- : null;
103
- switch (code) {
104
- case "update_conflict":
105
- case "flow_update_conflict":
106
- return "resource.conflict";
107
- case "node_forbidden":
108
- case "flow_edit_forbidden":
109
- return "resource.forbidden";
110
- default:
111
- return "resource.failed";
112
- }
113
- }
114
-
115
98
  /**
116
99
  * The three-dot menu.
117
100
  *
@@ -140,6 +123,7 @@ export function ResourceMenu({
140
123
  const [sharing, setSharing] = useState(false);
141
124
  const [linksOpen, setLinksOpen] = useState(false);
142
125
  const [versionsOpen, setVersionsOpen] = useState(false);
126
+ const [columnsOpen, setColumnsOpen] = useState(false);
143
127
  const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
144
128
  const pathname = useRouterState({ select: (state) => state.location.pathname });
145
129
  // ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
@@ -165,6 +149,10 @@ export function ResourceMenu({
165
149
  // ⚠️ Absent, not disabled — the rule at the top of this file. An import needs a folder to file
166
150
  // into; a document has nowhere to put a subtree, and a greyed-out entry would promise it does.
167
151
  const importable = node !== null && node.kind === "folder";
152
+ // ⚠️ Only a table has a header to change, so only a table carries the entry — absent, not
153
+ // disabled, the rule at the top of this file. Until #531 the header could be changed over MCP and
154
+ // nowhere else: a person had strictly less reach on their own table than a model did.
155
+ const isTable = node !== null && node.kind === "table";
168
156
  const bundleImport = useBundleImport({ where: title });
169
157
 
170
158
  // Everything a change here can make stale. The row sits in one level of the tree, the open screen
@@ -296,6 +284,15 @@ export function ResourceMenu({
296
284
  <Pencil aria-hidden="true" />
297
285
  {i18n.t("resource.rename")}
298
286
  </DropdownMenuItem>
287
+ {/* Directly under the rename, because they are the same gesture at two levels: one names
288
+ the table, the other names its columns. A reader looking for "change what this is
289
+ called" finds both without reading the rest of the menu (#531). */}
290
+ {isTable ? (
291
+ <DropdownMenuItem onSelect={() => setColumnsOpen(true)}>
292
+ <Columns3 aria-hidden="true" />
293
+ {i18n.t("resource.columns")}
294
+ </DropdownMenuItem>
295
+ ) : null}
299
296
  {/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
300
297
  shortcut; it does not need a second label beside this action. */}
301
298
  {/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
@@ -457,6 +454,9 @@ export function ResourceMenu({
457
454
  ) : null}
458
455
  {sharing ? <SharePanel target={target} close={() => setSharing(false)} /> : null}
459
456
  {linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
457
+ {columnsOpen && node ? (
458
+ <TableColumnsDialog node={node} close={() => setColumnsOpen(false)} />
459
+ ) : null}
460
460
  {versionsOpen ? (
461
461
  <VersionHistory target={target} close={() => setVersionsOpen(false)} />
462
462
  ) : null}
@@ -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
+ }