@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 +1 -1
- 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/i18n/de.json +19 -0
- package/src/i18n/en.json +19 -0
- package/src/i18n/es.json +19 -0
- package/src/resource-error.ts +33 -0
- package/src/resource-menu/resource-menu.tsx +20 -20
- package/src/table-columns/table-columns.tsx +379 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|