@anchrd/intel-ui 0.31.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 +2 -2
- package/src/access-summary/access-summary.tsx +13 -2
- package/src/data/intel-data-provider/intel-data-provider.ts +37 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +21 -0
- package/src/flows/flows.tsx +0 -3
- package/src/i18n/de.json +32 -4
- package/src/i18n/en.json +32 -4
- package/src/i18n/es.json +32 -4
- package/src/node-table/node-table.tsx +4 -2
- package/src/resource-error.ts +33 -0
- package/src/resource-menu/resource-menu.tsx +156 -131
- package/src/table-columns/table-columns.tsx +379 -0
- package/src/title-row/title-row.tsx +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@anchrd/intel-contract": "^0.
|
|
36
|
+
"@anchrd/intel-contract": "^0.20.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -255,14 +255,25 @@ export function AccessSummary({
|
|
|
255
255
|
export function ResourceAccessSummary({
|
|
256
256
|
resourceId,
|
|
257
257
|
ownerId,
|
|
258
|
+
kind = "node",
|
|
258
259
|
}: {
|
|
259
260
|
resourceId: string;
|
|
260
261
|
ownerId: string;
|
|
262
|
+
// Which side of the tree the id belongs to (#530). A flow is not a node, so it is a different
|
|
263
|
+
// route — but the same answer, which is why one component draws both.
|
|
264
|
+
kind?: "node" | "flow";
|
|
261
265
|
}) {
|
|
262
266
|
const { data } = useIntelRouterContext();
|
|
263
267
|
const grants = useQuery({
|
|
264
|
-
|
|
265
|
-
|
|
268
|
+
// ⚠️ Both kinds share the `effective-node-grants` prefix on purpose: a share mutation
|
|
269
|
+
// invalidates that prefix, and a flow grant changes what a folder summary above it shows just
|
|
270
|
+
// as a folder grant changes what the flow below it shows. Two prefixes would mean one of the
|
|
271
|
+
// two keeps drawing an access picture that is no longer in force.
|
|
272
|
+
queryKey: ["effective-node-grants", kind, resourceId],
|
|
273
|
+
queryFn: () =>
|
|
274
|
+
kind === "flow"
|
|
275
|
+
? data.listEffectiveFlowAccess(resourceId)
|
|
276
|
+
: data.listEffectiveAccess(resourceId),
|
|
266
277
|
retry: false,
|
|
267
278
|
// Time cannot change this answer locally. Share mutations invalidate the whole effective prefix
|
|
268
279
|
// because changing one folder also changes every descendant summary already on screen.
|
|
@@ -55,8 +55,10 @@ import {
|
|
|
55
55
|
import {
|
|
56
56
|
ResourceAccessList,
|
|
57
57
|
ResourceGrantList,
|
|
58
|
+
RevokeFlowGrantInput,
|
|
58
59
|
RevokeGrantInput,
|
|
59
60
|
RevokeGrantResult,
|
|
61
|
+
ShareFlowInput,
|
|
60
62
|
ShareInput,
|
|
61
63
|
ShareResult,
|
|
62
64
|
} from "@anchrd/intel-contract/share";
|
|
@@ -64,6 +66,7 @@ import {
|
|
|
64
66
|
AppendTableRowsInput,
|
|
65
67
|
AppendTableRowsResult,
|
|
66
68
|
DefineTableInput,
|
|
69
|
+
RedefineTableInput,
|
|
67
70
|
} from "@anchrd/intel-contract/table";
|
|
68
71
|
import { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
|
|
69
72
|
import type { z } from "zod";
|
|
@@ -317,6 +320,16 @@ export function createIntelDataProvider(
|
|
|
317
320
|
{ method: "POST", body: JSON.stringify(parsed) },
|
|
318
321
|
);
|
|
319
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
|
+
},
|
|
320
333
|
async listNodeVersions(nodeId) {
|
|
321
334
|
return await request(`/nodes/${encodeURIComponent(nodeId)}/versions`, NodeVersionList);
|
|
322
335
|
},
|
|
@@ -384,6 +397,30 @@ export function createIntelDataProvider(
|
|
|
384
397
|
{ method: "POST", body: JSON.stringify(parsed) },
|
|
385
398
|
);
|
|
386
399
|
},
|
|
400
|
+
async listFlowGrants(flowId) {
|
|
401
|
+
return await request(`/flows/${encodeURIComponent(flowId)}/grants`, ResourceGrantList);
|
|
402
|
+
},
|
|
403
|
+
async listEffectiveFlowAccess(flowId) {
|
|
404
|
+
return await request(
|
|
405
|
+
`/flows/${encodeURIComponent(flowId)}/effective-access`,
|
|
406
|
+
ResourceAccessList,
|
|
407
|
+
);
|
|
408
|
+
},
|
|
409
|
+
async shareFlow(input) {
|
|
410
|
+
const parsed = ShareFlowInput.parse(input);
|
|
411
|
+
return await request(`/flows/${encodeURIComponent(parsed.flowId)}/grants`, ShareResult, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
body: JSON.stringify(parsed),
|
|
414
|
+
});
|
|
415
|
+
},
|
|
416
|
+
async revokeFlowGrant(input) {
|
|
417
|
+
const parsed = RevokeFlowGrantInput.parse(input);
|
|
418
|
+
return await request(
|
|
419
|
+
`/flows/${encodeURIComponent(parsed.flowId)}/grants/${encodeURIComponent(parsed.grantId)}/revoke`,
|
|
420
|
+
RevokeGrantResult,
|
|
421
|
+
{ method: "POST", body: JSON.stringify(parsed) },
|
|
422
|
+
);
|
|
423
|
+
},
|
|
387
424
|
listFlows,
|
|
388
425
|
async getFlow(flowId) {
|
|
389
426
|
return await request(`/flows/${encodeURIComponent(flowId)}`, FlowDocument);
|
|
@@ -54,8 +54,10 @@ import type {
|
|
|
54
54
|
} from "@anchrd/intel-contract/node";
|
|
55
55
|
import type {
|
|
56
56
|
ResourceGrantList,
|
|
57
|
+
RevokeFlowGrantInput,
|
|
57
58
|
RevokeGrantInput,
|
|
58
59
|
RevokeGrantResult,
|
|
60
|
+
ShareFlowInput,
|
|
59
61
|
ShareInput,
|
|
60
62
|
ShareResult,
|
|
61
63
|
} from "@anchrd/intel-contract/share";
|
|
@@ -63,6 +65,7 @@ import type {
|
|
|
63
65
|
AppendTableRowsInput,
|
|
64
66
|
AppendTableRowsResult,
|
|
65
67
|
DefineTableInput,
|
|
68
|
+
RedefineTableInput,
|
|
66
69
|
} from "@anchrd/intel-contract/table";
|
|
67
70
|
import type { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
|
|
68
71
|
|
|
@@ -123,6 +126,14 @@ export interface IntelDataProvider {
|
|
|
123
126
|
getNodeTable(nodeId: string): Promise<NodeTable>;
|
|
124
127
|
defineTable(input: DefineTableInput): Promise<NodeTable>;
|
|
125
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>;
|
|
126
137
|
|
|
127
138
|
// Walks the browser through the portal's OAuth flow without asking anybody anything: Gate is the
|
|
128
139
|
// identity provider Cloudflare Access consumes, so a signed-in person is already known there
|
|
@@ -147,6 +158,16 @@ export interface IntelDataProvider {
|
|
|
147
158
|
// the new principal still cannot. A warning, never a refusal (ADR-0004 §4).
|
|
148
159
|
shareNode(input: ShareInput): Promise<ShareResult>;
|
|
149
160
|
revokeGrant(input: RevokeGrantInput): Promise<RevokeGrantResult>;
|
|
161
|
+
// The same four for one flow (#530). Separate calls rather than a widened `resourceId`, because
|
|
162
|
+
// the id names a different table on the other side and the routes are separate there too.
|
|
163
|
+
listFlowGrants(flowId: string): Promise<ResourceGrantList>;
|
|
164
|
+
listEffectiveFlowAccess(
|
|
165
|
+
flowId: string,
|
|
166
|
+
): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
|
|
167
|
+
// ⚠️ `ShareResult.unrunnable` is only ever filled in here: a grant on one flow does not reach the
|
|
168
|
+
// flows it calls, and this is where that gets said.
|
|
169
|
+
shareFlow(input: ShareFlowInput): Promise<ShareResult>;
|
|
170
|
+
revokeFlowGrant(input: RevokeFlowGrantInput): Promise<RevokeGrantResult>;
|
|
150
171
|
listFlows(input?: ListFlowsInput): Promise<FlowList>;
|
|
151
172
|
getFlow(flowId: string): Promise<FlowDocument>;
|
|
152
173
|
// What a flow calls, read out of its graph. It answers a different question from `listTreeChildren`
|
package/src/flows/flows.tsx
CHANGED
|
@@ -421,9 +421,6 @@ function FlowsEditor() {
|
|
|
421
421
|
// Lifted out of the bar because the canvas dismisses it too — a click on the pane is the third way
|
|
422
422
|
// out, next to the trigger and Escape.
|
|
423
423
|
const [paletteOpen, setPaletteOpen] = usePaletteOpen();
|
|
424
|
-
// A flow is shared through the folder it is filed in, on the Intelligence screen (ADR-0004 §2). It
|
|
425
|
-
// has no share action of its own — a narrower grant beside the folder's would break the guarantee
|
|
426
|
-
// that a flow only reaches flows in its own subtree.
|
|
427
424
|
const document = useQuery({
|
|
428
425
|
queryKey: ["flow", selectedFlowId],
|
|
429
426
|
queryFn: () => data.getFlow(selectedFlowId ?? ""),
|
package/src/i18n/de.json
CHANGED
|
@@ -74,9 +74,9 @@
|
|
|
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
|
-
"resource.downloadCsv": "CSV herunterladen",
|
|
80
80
|
"resource.validate": "Prüfen",
|
|
81
81
|
"resource.links": "Verweise",
|
|
82
82
|
"resource.share": "Freigeben",
|
|
@@ -116,10 +116,25 @@
|
|
|
116
116
|
"node.download": "Datei herunterladen",
|
|
117
117
|
"node.attachmentHelp": "Die kanonische Datei liegt privat in Intel. Ihre KI-lesbare Projektion wird getrennt indexiert.",
|
|
118
118
|
"node.table.summary": "{rows} Zeilen, {columns} Spalten",
|
|
119
|
-
"node.table.download": "CSV herunterladen",
|
|
120
|
-
"node.table.downloadFailed": "Die CSV konnte nicht erzeugt werden. Möglicherweise ist die Tabelle noch leer.",
|
|
121
119
|
"node.table.empty": "Noch keine Zeilen. Zeilen werden von Flows angehängt.",
|
|
122
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",
|
|
123
138
|
"node.select": "Wähle ein Dokument oder einen Ordner, um damit zu arbeiten.",
|
|
124
139
|
"node.folderEmpty": "In diesem Ordner ist noch nichts. Lege etwas über das Plus in der Seitenleiste an.",
|
|
125
140
|
"node.folderFailed": "Dieser Ordner konnte nicht gelesen werden.",
|
|
@@ -347,6 +362,7 @@
|
|
|
347
362
|
"common.loading": "Wird geladen …",
|
|
348
363
|
"common.retry": "Erneut versuchen",
|
|
349
364
|
"common.close": "Schließen",
|
|
365
|
+
"common.cancel": "Abbrechen",
|
|
350
366
|
"common.unavailable": "Intel ist derzeit nicht verfügbar.",
|
|
351
367
|
"common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
|
|
352
368
|
"common.noPermission": "Dir fehlt die Berechtigung, das zu sehen.",
|
|
@@ -364,5 +380,17 @@
|
|
|
364
380
|
"flows.toolStepNeedsServer": "Dem Schritt „{label}“ fehlt noch ein Server.",
|
|
365
381
|
"flows.toolFunctionsFailed": "Die Funktionsliste ließ sich nicht laden, daher ist die Auswahl möglicherweise unvollständig.",
|
|
366
382
|
"flows.publishPreviewToolUnavailable": "Diesen Server erreichst du nicht, daher wird das Veröffentlichen abgelehnt.",
|
|
367
|
-
"flows.publishPreviewToolAllow": "Eingefroren auf: {functions}"
|
|
383
|
+
"flows.publishPreviewToolAllow": "Eingefroren auf: {functions}",
|
|
384
|
+
"flow.shareUnreadable": "Dieser Flow liest Dokumente, die diese Freigabe nicht abdeckt: {titles}.",
|
|
385
|
+
"flow.shareUnreadableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
|
|
386
|
+
"flow.shareUnreadableHidden": "{count} Dokumente, die dieser Flow liest, liegen mit dieser Freigabe außer Reichweite. Du kannst sie nicht sehen.",
|
|
387
|
+
"flow.shareUnreadableHint": "Nichts ist blockiert. Ein Lauf hält für sie schlicht an dieser Stelle an.",
|
|
388
|
+
"flow.shareUnrunnable": "Dieser Flow ruft Flows auf, die diese Freigabe nicht abdeckt: {titles}.",
|
|
389
|
+
"flow.shareUnrunnableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
|
|
390
|
+
"flow.shareUnrunnableHidden": "{count} Flows, die dieser aufruft, liegen mit dieser Freigabe außer Reichweite. Du kannst sie nicht sehen.",
|
|
391
|
+
"flow.shareUnrunnableHint": "Eine Freigabe auf einem Flow reicht nur bis zu diesem Flow. Gib die anderen ebenfalls frei oder den Ordner, in dem sie liegen. Mit „Prüfen“ sehen die Beschenkten es vor dem Start selbst.",
|
|
392
|
+
"flow.verbHint.read": "Diesen Flow öffnen und lesen",
|
|
393
|
+
"flow.verbHint.write": "Diesen Flow ändern",
|
|
394
|
+
"flow.verbHint.execute": "Diesen Flow starten",
|
|
395
|
+
"flow.verbHint.share": "Anderen Zugriff darauf geben"
|
|
368
396
|
}
|
package/src/i18n/en.json
CHANGED
|
@@ -74,9 +74,9 @@
|
|
|
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
|
-
"resource.downloadCsv": "Download CSV",
|
|
80
80
|
"resource.validate": "Check",
|
|
81
81
|
"resource.links": "Links",
|
|
82
82
|
"resource.share": "Share",
|
|
@@ -116,10 +116,25 @@
|
|
|
116
116
|
"node.download": "Download file",
|
|
117
117
|
"node.attachmentHelp": "The canonical file is stored privately in Intel. Its AI-readable projection is indexed separately.",
|
|
118
118
|
"node.table.summary": "{rows} rows, {columns} columns",
|
|
119
|
-
"node.table.download": "Download CSV",
|
|
120
|
-
"node.table.downloadFailed": "The CSV could not be created. The table may still be empty.",
|
|
121
119
|
"node.table.empty": "No rows yet. Rows are appended by flows.",
|
|
122
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",
|
|
123
138
|
"node.select": "Select a document or folder to work with it.",
|
|
124
139
|
"node.folderEmpty": "Nothing in this folder yet. Create something with the plus in the sidebar.",
|
|
125
140
|
"node.folderFailed": "This folder could not be read.",
|
|
@@ -347,6 +362,7 @@
|
|
|
347
362
|
"common.loading": "Loading…",
|
|
348
363
|
"common.retry": "Try again",
|
|
349
364
|
"common.close": "Close",
|
|
365
|
+
"common.cancel": "Cancel",
|
|
350
366
|
"common.unavailable": "Intel is currently unavailable.",
|
|
351
367
|
"common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
|
|
352
368
|
"common.noPermission": "You do not have permission to see this.",
|
|
@@ -364,5 +380,17 @@
|
|
|
364
380
|
"flows.toolStepNeedsServer": "The step “{label}” still needs a server.",
|
|
365
381
|
"flows.toolFunctionsFailed": "The list of functions could not be loaded, so the selection may be incomplete.",
|
|
366
382
|
"flows.publishPreviewToolUnavailable": "You do not reach this server, so publishing will be refused.",
|
|
367
|
-
"flows.publishPreviewToolAllow": "Frozen to: {functions}"
|
|
383
|
+
"flows.publishPreviewToolAllow": "Frozen to: {functions}",
|
|
384
|
+
"flow.shareUnreadable": "This flow reads documents this grant does not cover: {titles}.",
|
|
385
|
+
"flow.shareUnreadableMore": "{count} more are out of reach too, and you cannot see them.",
|
|
386
|
+
"flow.shareUnreadableHidden": "{count} documents this flow reads are out of reach with this grant. You cannot see them.",
|
|
387
|
+
"flow.shareUnreadableHint": "Nothing is blocked. A run will simply stop at that step for them.",
|
|
388
|
+
"flow.shareUnrunnable": "This flow calls flows this grant does not cover: {titles}.",
|
|
389
|
+
"flow.shareUnrunnableMore": "{count} more are out of reach too, and you cannot see them.",
|
|
390
|
+
"flow.shareUnrunnableHidden": "{count} flows this one calls are out of reach with this grant. You cannot see them.",
|
|
391
|
+
"flow.shareUnrunnableHint": "A grant on one flow reaches that flow alone. Share those flows too, or share the folder they are filed in. Let them check with Validate before they run it.",
|
|
392
|
+
"flow.verbHint.read": "Open this flow and read it",
|
|
393
|
+
"flow.verbHint.write": "Change this flow",
|
|
394
|
+
"flow.verbHint.execute": "Start this flow",
|
|
395
|
+
"flow.verbHint.share": "Give others access to it"
|
|
368
396
|
}
|
package/src/i18n/es.json
CHANGED
|
@@ -74,9 +74,9 @@
|
|
|
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
|
-
"resource.downloadCsv": "Descargar CSV",
|
|
80
80
|
"resource.validate": "Comprobar",
|
|
81
81
|
"resource.links": "Enlaces",
|
|
82
82
|
"resource.share": "Compartir",
|
|
@@ -116,10 +116,25 @@
|
|
|
116
116
|
"node.download": "Descargar el archivo",
|
|
117
117
|
"node.attachmentHelp": "El archivo canónico se guarda en privado en Intel. Su proyección legible por la IA se indexa por separado.",
|
|
118
118
|
"node.table.summary": "{rows} filas, {columns} columnas",
|
|
119
|
-
"node.table.download": "Descargar CSV",
|
|
120
|
-
"node.table.downloadFailed": "No se pudo crear el CSV. Puede que la tabla todavía esté vacía.",
|
|
121
119
|
"node.table.empty": "Todavía no hay filas. Las filas las añaden los flujos.",
|
|
122
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",
|
|
123
138
|
"node.select": "Elige un documento o una carpeta para trabajar con ello.",
|
|
124
139
|
"node.folderEmpty": "En esta carpeta todavía no hay nada. Crea algo con el más de la barra lateral.",
|
|
125
140
|
"node.folderFailed": "Esta carpeta no se ha podido leer.",
|
|
@@ -347,6 +362,7 @@
|
|
|
347
362
|
"common.loading": "Cargando…",
|
|
348
363
|
"common.retry": "Intentarlo de nuevo",
|
|
349
364
|
"common.close": "Cerrar",
|
|
365
|
+
"common.cancel": "Cancelar",
|
|
350
366
|
"common.unavailable": "Intel no está disponible en este momento.",
|
|
351
367
|
"common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
|
|
352
368
|
"common.noPermission": "No tienes permiso para ver esto.",
|
|
@@ -364,5 +380,17 @@
|
|
|
364
380
|
"flows.toolStepNeedsServer": "Al paso «{label}» todavía le falta un servidor.",
|
|
365
381
|
"flows.toolFunctionsFailed": "No se pudo cargar la lista de funciones, así que la selección puede estar incompleta.",
|
|
366
382
|
"flows.publishPreviewToolUnavailable": "No alcanzas este servidor, así que la publicación será rechazada.",
|
|
367
|
-
"flows.publishPreviewToolAllow": "Congelado en: {functions}"
|
|
383
|
+
"flows.publishPreviewToolAllow": "Congelado en: {functions}",
|
|
384
|
+
"flow.shareUnreadable": "Este flujo lee documentos que este acceso no cubre: {titles}.",
|
|
385
|
+
"flow.shareUnreadableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
|
|
386
|
+
"flow.shareUnreadableHidden": "{count} documentos que lee este flujo quedan fuera de alcance con este acceso. No puedes verlos.",
|
|
387
|
+
"flow.shareUnreadableHint": "No se bloquea nada. Una ejecución simplemente se detendrá en ese paso para esa persona.",
|
|
388
|
+
"flow.shareUnrunnable": "Este flujo llama a flujos que este acceso no cubre: {titles}.",
|
|
389
|
+
"flow.shareUnrunnableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
|
|
390
|
+
"flow.shareUnrunnableHidden": "{count} flujos a los que llama este quedan fuera de alcance con este acceso. No puedes verlos.",
|
|
391
|
+
"flow.shareUnrunnableHint": "Un acceso sobre un flujo llega solo a ese flujo. Comparte también esos flujos, o la carpeta donde están. Con «Validar» lo verán ellos mismos antes de ejecutarlo.",
|
|
392
|
+
"flow.verbHint.read": "Abrir este flujo y leerlo",
|
|
393
|
+
"flow.verbHint.write": "Cambiar este flujo",
|
|
394
|
+
"flow.verbHint.execute": "Iniciar este flujo",
|
|
395
|
+
"flow.verbHint.share": "Dar acceso a otras personas"
|
|
368
396
|
}
|
|
@@ -32,8 +32,10 @@ export function NodeTablePanel({ node }: { node: Node }) {
|
|
|
32
32
|
query lives; the screen would otherwise have to load a table it does not show.
|
|
33
33
|
|
|
34
34
|
⚠️ The CSV export used to stand here too, as a button WITH TEXT beside two icon buttons.
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
#454 moved it into the title line's three-dot menu, and #532 dropped it there: the bundle
|
|
36
|
+
export beside it already writes the table into the zip as `<title>.csv` (`bundle.ts`), so
|
|
37
|
+
the two entries handed out the same bytes. Taking a table home is `Export`, here and
|
|
38
|
+
nowhere else. */}
|
|
37
39
|
<ActionSlot name="title-meta">
|
|
38
40
|
{table.data
|
|
39
41
|
? i18n.t("node.table.summary", {
|
|
@@ -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,8 +5,8 @@ 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
|
-
Download,
|
|
10
10
|
Ellipsis,
|
|
11
11
|
FileArchive,
|
|
12
12
|
FolderDown,
|
|
@@ -35,8 +35,10 @@ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.t
|
|
|
35
35
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
36
36
|
import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
|
|
37
37
|
import { Modal } from "@/modal/modal.tsx";
|
|
38
|
+
import { resourceErrorKey } from "@/resource-error.ts";
|
|
38
39
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
39
40
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
41
|
+
import { TableColumnsDialog } from "@/table-columns/table-columns.tsx";
|
|
40
42
|
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
41
43
|
import { useDateTime } from "@/time/time-context.tsx";
|
|
42
44
|
|
|
@@ -49,9 +51,9 @@ import { useDateTime } from "@/time/time-context.tsx";
|
|
|
49
51
|
* row menu could do is reached here — including moving, which is why this component owns the folder
|
|
50
52
|
* picker rather than being handed one.
|
|
51
53
|
*
|
|
52
|
-
* ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode
|
|
53
|
-
*
|
|
54
|
-
*
|
|
54
|
+
* ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode and no
|
|
55
|
+
* links, a flow has no version history of the node kind. A greyed-out row still promises something
|
|
56
|
+
* is there. Sharing applies to all of them since #530.
|
|
55
57
|
*/
|
|
56
58
|
export type ResourceTarget = { type: "node"; node: Node } | { type: "flow"; flow: Flow };
|
|
57
59
|
|
|
@@ -83,33 +85,14 @@ function titleOf(target: ResourceTarget): string {
|
|
|
83
85
|
/**
|
|
84
86
|
* Which sentence explains a verb.
|
|
85
87
|
*
|
|
86
|
-
* ⚠️
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
88
|
+
* ⚠️ The hint is about the SUBJECT as much as the verb, and the branch came back with #530. "See
|
|
89
|
+
* everything in here" is right for a folder and wrong for one flow, and a hint describing the wrong
|
|
90
|
+
* reach is worse than none — it is the sentence somebody reads INSTEAD of thinking about what they
|
|
91
|
+
* are granting. The wording that used to fork here was the agent's (#143), parked with it (#388);
|
|
92
|
+
* this is a different fork, on the subject rather than on a second meaning of one word.
|
|
90
93
|
*/
|
|
91
|
-
export function verbHintKey(verb: ResourceVerb): string {
|
|
92
|
-
return
|
|
93
|
-
}
|
|
94
|
-
|
|
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
|
-
}
|
|
94
|
+
export function verbHintKey(verb: ResourceVerb, subject: ResourceTarget["type"] = "node"): string {
|
|
95
|
+
return `${subject}.verbHint.${verb}`;
|
|
113
96
|
}
|
|
114
97
|
|
|
115
98
|
/**
|
|
@@ -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
|
|
@@ -271,37 +259,6 @@ export function ResourceMenu({
|
|
|
271
259
|
},
|
|
272
260
|
});
|
|
273
261
|
|
|
274
|
-
// #454: a table's CSV export — an ENTRY, not a button of its own. It was the only control WITH
|
|
275
|
-
// TEXT beside two icon buttons in the title line, and it was needed no more often than what sits
|
|
276
|
-
// in here anyway; it was merely wider. Intel has made the same decision twice already (#363 for
|
|
277
|
-
// status and archive, #346 for the import).
|
|
278
|
-
//
|
|
279
|
-
// ⚠️ It lives HERE and no longer in `NodeTablePanel`: there it hung off the table query in order
|
|
280
|
-
// to prevent an empty export. The condition survives the move without a second query — the
|
|
281
|
-
// canonical content IS the CSV, and if it is empty nothing is downloaded, it is refused instead.
|
|
282
|
-
// A file of zero bytes is the worse answer.
|
|
283
|
-
const downloadCsv = useMutation({
|
|
284
|
-
mutationFn: async () => {
|
|
285
|
-
if (target.type !== "node") throw new Error("csv export is a node's");
|
|
286
|
-
const document_ = await data.getNode(target.node.id);
|
|
287
|
-
const content = document_.content ?? "";
|
|
288
|
-
if (content === "") throw new Error("nothing to export yet");
|
|
289
|
-
return new Blob([content], { type: "text/csv;charset=utf-8" });
|
|
290
|
-
},
|
|
291
|
-
onSuccess: (blob) => {
|
|
292
|
-
const url = URL.createObjectURL(blob);
|
|
293
|
-
const anchor = document.createElement("a");
|
|
294
|
-
anchor.href = url;
|
|
295
|
-
// No doubled `.csv` when the title already carries the extension — unchanged from #40.
|
|
296
|
-
anchor.download = title.toLowerCase().endsWith(".csv") ? title : `${title}.csv`;
|
|
297
|
-
document.body.append(anchor);
|
|
298
|
-
anchor.click();
|
|
299
|
-
anchor.remove();
|
|
300
|
-
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
301
|
-
},
|
|
302
|
-
});
|
|
303
|
-
const isTable = target.type === "node" && target.node.kind === "table";
|
|
304
|
-
|
|
305
262
|
const failure = rename.isError
|
|
306
263
|
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
307
264
|
: archive.isError
|
|
@@ -327,6 +284,15 @@ export function ResourceMenu({
|
|
|
327
284
|
<Pencil aria-hidden="true" />
|
|
328
285
|
{i18n.t("resource.rename")}
|
|
329
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}
|
|
330
296
|
{/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
|
|
331
297
|
shortcut; it does not need a second label beside this action. */}
|
|
332
298
|
{/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
|
|
@@ -340,19 +306,21 @@ export function ResourceMenu({
|
|
|
340
306
|
</DropdownMenuItem>
|
|
341
307
|
{/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
|
|
342
308
|
one (#136). The entry sits with rename and move because it acts on the whole thing,
|
|
343
|
-
not on its content.
|
|
309
|
+
not on its content.
|
|
310
|
+
|
|
311
|
+
⚠️ A table has NO second entry beside this one (#532). It used to carry "Download CSV"
|
|
312
|
+
here, and for a table WITH a header both handed out the same bytes: `bundle.ts` writes
|
|
313
|
+
such a node into the zip as `<title>.csv`. Two ways to one file are two strings, two
|
|
314
|
+
catalogs and two tests — the zip costs one step more and is the whole difference.
|
|
315
|
+
|
|
316
|
+
⚠️ The one case where they differed is the table that has no header yet: `downloadCsv`
|
|
317
|
+
refused it out loud, `tableCsv` joins no segments and writes a file of zero bytes
|
|
318
|
+
(#534). That is the export's gap on every kind it can hit, not something this entry
|
|
319
|
+
was covering — which is why it is fixed there and not by keeping a second entry. */}
|
|
344
320
|
<DropdownMenuItem onSelect={() => exportBundle.mutate()}>
|
|
345
321
|
<FolderDown aria-hidden="true" />
|
|
346
322
|
{i18n.t("resource.export")}
|
|
347
323
|
</DropdownMenuItem>
|
|
348
|
-
{/* Only a table has a CSV — and it stands beside the bundle export because both do the
|
|
349
|
-
same thing: hand out the whole thing, not a part of its content. */}
|
|
350
|
-
{isTable ? (
|
|
351
|
-
<DropdownMenuItem onSelect={() => downloadCsv.mutate()}>
|
|
352
|
-
<Download aria-hidden="true" />
|
|
353
|
-
{i18n.t("resource.downloadCsv")}
|
|
354
|
-
</DropdownMenuItem>
|
|
355
|
-
) : null}
|
|
356
324
|
{/* The other half of the same round trip, next to it rather than in the tree's plus
|
|
357
325
|
(#346): one word in the menu, both sources under it. */}
|
|
358
326
|
{importable ? (
|
|
@@ -387,15 +355,12 @@ export function ResourceMenu({
|
|
|
387
355
|
{i18n.t("resource.links")}
|
|
388
356
|
</DropdownMenuItem>
|
|
389
357
|
) : null}
|
|
390
|
-
{/*
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
{i18n.t("resource.share")}
|
|
397
|
-
</DropdownMenuItem>
|
|
398
|
-
) : null}
|
|
358
|
+
{/* Every kind of resource is shared here, a flow included since #530. What a grant on one
|
|
359
|
+
flow does NOT reach is said in the dialog rather than refused here. */}
|
|
360
|
+
<DropdownMenuItem onSelect={() => setSharing(true)}>
|
|
361
|
+
<Share2 aria-hidden="true" />
|
|
362
|
+
{i18n.t("resource.share")}
|
|
363
|
+
</DropdownMenuItem>
|
|
399
364
|
{(node === null || node.kind !== "folder") && (
|
|
400
365
|
<DropdownMenuItem onSelect={() => setVersionsOpen(true)}>
|
|
401
366
|
<History aria-hidden="true" />
|
|
@@ -419,9 +384,6 @@ export function ResourceMenu({
|
|
|
419
384
|
{/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
|
|
420
385
|
arrive, and "the change was not saved" would send the reader looking for a change. */}
|
|
421
386
|
{exportBundle.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
|
|
422
|
-
{downloadCsv.isError ? (
|
|
423
|
-
<MenuFailure>{i18n.t("node.table.downloadFailed")}</MenuFailure>
|
|
424
|
-
) : null}
|
|
425
387
|
{/* The import's own sentence, and its own pickers — only where the entry exists, because two
|
|
426
388
|
file inputs on every document's menu would be two elements nothing can ever open. */}
|
|
427
389
|
{bundleImport.isError ? <MenuFailure>{i18n.t("tree.importFailed")}</MenuFailure> : null}
|
|
@@ -490,8 +452,11 @@ export function ResourceMenu({
|
|
|
490
452
|
submit={(next) => rename.mutate(next)}
|
|
491
453
|
/>
|
|
492
454
|
) : null}
|
|
493
|
-
{sharing
|
|
455
|
+
{sharing ? <SharePanel target={target} close={() => setSharing(false)} /> : null}
|
|
494
456
|
{linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
|
|
457
|
+
{columnsOpen && node ? (
|
|
458
|
+
<TableColumnsDialog node={node} close={() => setColumnsOpen(false)} />
|
|
459
|
+
) : null}
|
|
495
460
|
{versionsOpen ? (
|
|
496
461
|
<VersionHistory target={target} close={() => setVersionsOpen(false)} />
|
|
497
462
|
) : null}
|
|
@@ -688,10 +653,10 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
|
688
653
|
);
|
|
689
654
|
}
|
|
690
655
|
|
|
691
|
-
// The one share dialog in the product
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
656
|
+
// The one share dialog in the product, for every kind of resource that can be shared — a folder, a
|
|
657
|
+
// document, a table, and since #530 a flow on its own. Which verbs it offers is the server's
|
|
658
|
+
// answer, not this component's: `execute` never appears on a document, because a document has
|
|
659
|
+
// nothing to run, and all four appear on a flow.
|
|
695
660
|
/**
|
|
696
661
|
* Which of the three principals the dialog can SET (#431).
|
|
697
662
|
*
|
|
@@ -709,7 +674,18 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
|
|
|
709
674
|
*/
|
|
710
675
|
type SharePrincipalKind = "email" | "organization";
|
|
711
676
|
|
|
712
|
-
|
|
677
|
+
// What the grant just made does not cover, in the two directions it can fail to (#530): documents
|
|
678
|
+
// the grantee cannot read, and flows the grantee cannot start. Both are `null` until an answer
|
|
679
|
+
// arrives, so "no warning yet" and "nothing to warn about" stay distinguishable.
|
|
680
|
+
type ShareWarnings = { unreadable: UnreadableNodes; unrunnable: UnreadableNodes } | null;
|
|
681
|
+
|
|
682
|
+
// Whether there is anything to say. A warning with neither a title nor a count is silence, and
|
|
683
|
+
// drawing an empty box for it would make every ordinary grant look like it had a caveat.
|
|
684
|
+
function withheld(value: UnreadableNodes): boolean {
|
|
685
|
+
return value.titles.length > 0 || value.hidden > 0;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function SharePanel({ target, close }: { target: ResourceTarget; close(): void }) {
|
|
713
689
|
const { data } = useIntelRouterContext();
|
|
714
690
|
const i18n = useI18n();
|
|
715
691
|
const queryClient = useQueryClient();
|
|
@@ -718,31 +694,50 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
718
694
|
const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
|
|
719
695
|
// What the grant just made does not cover. It survives the form being cleared, because it is the
|
|
720
696
|
// answer to the question the user just asked and they need a moment to read it.
|
|
721
|
-
const [
|
|
697
|
+
const [warnings, setWarnings] = useState<ShareWarnings>(null);
|
|
698
|
+
const isFlow = target.type === "flow";
|
|
699
|
+
const resourceId = idOf(target);
|
|
722
700
|
const grants = useQuery({
|
|
723
|
-
queryKey: ["
|
|
724
|
-
queryFn: () => data.listGrants(
|
|
701
|
+
queryKey: ["resource-grants", target.type, resourceId],
|
|
702
|
+
queryFn: () => (isFlow ? data.listFlowGrants(resourceId) : data.listGrants(resourceId)),
|
|
725
703
|
});
|
|
704
|
+
// ⚠️ Both queries are invalidated by every mutation below, and the flow key is under the same
|
|
705
|
+
// effective prefix as the node key: a grant on a flow changes what the summary of the folder
|
|
706
|
+
// above it shows, and the other way round.
|
|
707
|
+
const invalidate = async () => {
|
|
708
|
+
await Promise.all([
|
|
709
|
+
queryClient.invalidateQueries({ queryKey: ["resource-grants", target.type, resourceId] }),
|
|
710
|
+
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
711
|
+
]);
|
|
712
|
+
};
|
|
726
713
|
const share = useMutation({
|
|
727
714
|
// One request per verb, because one grant is one verb. The keys differ so a retry of the whole
|
|
728
715
|
// form replays each verb on its own rather than collapsing them into one.
|
|
729
716
|
mutationFn: async () => {
|
|
730
|
-
let last:
|
|
717
|
+
let last: ShareWarnings = null;
|
|
731
718
|
for (const verb of verbs) {
|
|
719
|
+
const principal =
|
|
720
|
+
principalKind === "organization"
|
|
721
|
+
? ({ type: "organization" } as const)
|
|
722
|
+
: ({ type: "email", email } as const);
|
|
732
723
|
// The last verb's answer is the one kept: every request describes the access in force after
|
|
733
724
|
// it, so the newest is the only one still true.
|
|
734
|
-
|
|
735
|
-
await data.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
725
|
+
const result = isFlow
|
|
726
|
+
? await data.shareFlow({
|
|
727
|
+
flowId: resourceId,
|
|
728
|
+
principal,
|
|
729
|
+
verb,
|
|
730
|
+
expiresAt: null,
|
|
731
|
+
idempotencyKey: crypto.randomUUID(),
|
|
732
|
+
})
|
|
733
|
+
: await data.shareNode({
|
|
734
|
+
resourceId,
|
|
735
|
+
principal,
|
|
736
|
+
verb,
|
|
737
|
+
expiresAt: null,
|
|
738
|
+
idempotencyKey: crypto.randomUUID(),
|
|
739
|
+
});
|
|
740
|
+
last = { unreadable: result.unreadable, unrunnable: result.unrunnable };
|
|
746
741
|
}
|
|
747
742
|
return last;
|
|
748
743
|
},
|
|
@@ -752,32 +747,28 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
752
747
|
// until the review of #431 caught it, and "everyone in the organization" is the one setting
|
|
753
748
|
// that must never be the quiet default for the NEXT grant somebody makes in the same dialog.
|
|
754
749
|
setPrincipalKind("email");
|
|
755
|
-
|
|
750
|
+
setWarnings(result);
|
|
756
751
|
},
|
|
757
752
|
// ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
|
|
758
753
|
// granted, the fourth refused. On `onSuccess` the list would then still show the state from
|
|
759
754
|
// before, and the user would read a permission picture that is not the one in force. Of all the
|
|
760
755
|
// things to be silently wrong about, access is the worst.
|
|
761
|
-
onSettled:
|
|
762
|
-
await Promise.all([
|
|
763
|
-
queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] }),
|
|
764
|
-
queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
|
|
765
|
-
]);
|
|
766
|
-
},
|
|
756
|
+
onSettled: invalidate,
|
|
767
757
|
});
|
|
768
758
|
const revoke = useMutation({
|
|
769
759
|
mutationFn: (grantId: string) =>
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
760
|
+
isFlow
|
|
761
|
+
? data.revokeFlowGrant({
|
|
762
|
+
flowId: resourceId,
|
|
763
|
+
grantId,
|
|
764
|
+
idempotencyKey: crypto.randomUUID(),
|
|
765
|
+
})
|
|
766
|
+
: data.revokeGrant({
|
|
767
|
+
resourceId,
|
|
768
|
+
grantId,
|
|
769
|
+
idempotencyKey: crypto.randomUUID(),
|
|
770
|
+
}),
|
|
771
|
+
onSettled: invalidate,
|
|
781
772
|
});
|
|
782
773
|
|
|
783
774
|
/**
|
|
@@ -817,19 +808,45 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
817
808
|
{/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
|
|
818
809
|
given. A node reference across the folder edge is a possible failure, not a way around
|
|
819
810
|
permissions, so nothing here blocks anything (ADR-0004 §4). */}
|
|
820
|
-
{
|
|
811
|
+
{warnings && withheld(warnings.unreadable) && (
|
|
812
|
+
<div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
|
|
813
|
+
{warnings.unreadable.titles.length > 0 ? (
|
|
814
|
+
<p>
|
|
815
|
+
{i18n.t(`${target.type}.shareUnreadable`, {
|
|
816
|
+
titles: warnings.unreadable.titles.join(", "),
|
|
817
|
+
})}
|
|
818
|
+
{warnings.unreadable.hidden > 0
|
|
819
|
+
? ` ${i18n.t(`${target.type}.shareUnreadableMore`, { count: warnings.unreadable.hidden })}`
|
|
820
|
+
: ""}
|
|
821
|
+
</p>
|
|
822
|
+
) : (
|
|
823
|
+
<p>
|
|
824
|
+
{i18n.t(`${target.type}.shareUnreadableHidden`, {
|
|
825
|
+
count: warnings.unreadable.hidden,
|
|
826
|
+
})}
|
|
827
|
+
</p>
|
|
828
|
+
)}
|
|
829
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
830
|
+
{i18n.t(`${target.type}.shareUnreadableHint`)}
|
|
831
|
+
</p>
|
|
832
|
+
</div>
|
|
833
|
+
)}
|
|
834
|
+
{/* The half a folder grant never needed: a grant on ONE flow stops at that flow, so the flows
|
|
835
|
+
it calls are somebody else's to grant. `status` and not `alert` for the same reason as
|
|
836
|
+
above — the access was given, and this says what it does not reach (#530). */}
|
|
837
|
+
{warnings && withheld(warnings.unrunnable) && (
|
|
821
838
|
<div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
|
|
822
|
-
{
|
|
839
|
+
{warnings.unrunnable.titles.length > 0 ? (
|
|
823
840
|
<p>
|
|
824
|
-
{i18n.t("
|
|
825
|
-
{
|
|
826
|
-
? ` ${i18n.t("
|
|
841
|
+
{i18n.t("flow.shareUnrunnable", { titles: warnings.unrunnable.titles.join(", ") })}
|
|
842
|
+
{warnings.unrunnable.hidden > 0
|
|
843
|
+
? ` ${i18n.t("flow.shareUnrunnableMore", { count: warnings.unrunnable.hidden })}`
|
|
827
844
|
: ""}
|
|
828
845
|
</p>
|
|
829
846
|
) : (
|
|
830
|
-
<p>{i18n.t("
|
|
847
|
+
<p>{i18n.t("flow.shareUnrunnableHidden", { count: warnings.unrunnable.hidden })}</p>
|
|
831
848
|
)}
|
|
832
|
-
<p className="mt-1 text-xs text-muted-foreground">{i18n.t("
|
|
849
|
+
<p className="mt-1 text-xs text-muted-foreground">{i18n.t("flow.shareUnrunnableHint")}</p>
|
|
833
850
|
</div>
|
|
834
851
|
)}
|
|
835
852
|
{grants.data && grants.data.items.length > 0 ? (
|
|
@@ -922,7 +939,13 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
922
939
|
It is a warning and not a block, deliberately: the library is a documented, wanted
|
|
923
940
|
shape, MCP has always been able to make one, and refusing it here would put a person
|
|
924
941
|
back below a model on the same tree — the very asymmetry this ticket removed. */}
|
|
925
|
-
{
|
|
942
|
+
{/* ⚠️ Only on a node, and that is not an oversight. What makes a folder a library is
|
|
943
|
+
`execute` for the organization ON THE FOLDER: `callReach` reads `node_grants` along
|
|
944
|
+
the callee's ancestors and nothing else, so the same grant on a flow itself makes no
|
|
945
|
+
library and reaches no caller outside its subtree. Showing this line there would
|
|
946
|
+
warn about a consequence that cannot happen — and about a refusal on the way back
|
|
947
|
+
that would never come (#530). */}
|
|
948
|
+
{!isFlow && verbs.includes("execute") ? (
|
|
926
949
|
<p role="note" className="font-medium text-destructive">
|
|
927
950
|
{i18n.t("node.shareLibraryWarning")}
|
|
928
951
|
</p>
|
|
@@ -946,7 +969,9 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
|
|
|
946
969
|
className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
947
970
|
/>
|
|
948
971
|
<span>{i18n.t(`node.verb.${verb}`)}</span>
|
|
949
|
-
<span className="text-xs text-muted-foreground">
|
|
972
|
+
<span className="text-xs text-muted-foreground">
|
|
973
|
+
{i18n.t(verbHintKey(verb, target.type))}
|
|
974
|
+
</span>
|
|
950
975
|
</label>
|
|
951
976
|
))}
|
|
952
977
|
</fieldset>
|
|
@@ -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
|
+
}
|
|
@@ -98,6 +98,15 @@ export function TitleRow({
|
|
|
98
98
|
{target.type === "node" && target.node.kind === "folder" ? (
|
|
99
99
|
<ResourceAccessSummary resourceId={target.node.id} ownerId={target.node.ownerId} />
|
|
100
100
|
) : null}
|
|
101
|
+
{/* A flow carries its own grants since #530, so who reaches it is worth showing where
|
|
102
|
+
it stands — the same question the folder above already answers here. */}
|
|
103
|
+
{target.type === "flow" ? (
|
|
104
|
+
<ResourceAccessSummary
|
|
105
|
+
kind="flow"
|
|
106
|
+
resourceId={target.flow.id}
|
|
107
|
+
ownerId={target.flow.ownerId}
|
|
108
|
+
/>
|
|
109
|
+
) : null}
|
|
101
110
|
<span data-slot="title-meta" className="text-sm text-muted-foreground" />
|
|
102
111
|
</div>
|
|
103
112
|
{description ? <DescriptionDisclosure key={targetId} description={description} /> : null}
|