@anchrd/intel-ui 0.4.0 → 0.5.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/app/action-slot/action-slot.tsx +23 -0
- package/src/app/app-sidebar/app-sidebar.tsx +39 -24
- package/src/app/app-tree/app-tree.tsx +431 -60
- package/src/app/app.tsx +17 -2
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
- package/src/app/tree-move/tree-move.tsx +197 -0
- package/src/app/user-footer/user-footer.tsx +73 -46
- package/src/app/view-toggle/view-toggle.tsx +77 -0
- package/src/blocknote-view/blocknote-view.tsx +19 -2
- package/src/branding/favicon.default.svg +2 -2
- package/src/branding/favicon.svg +2 -2
- package/src/components/ui/dropdown-menu.tsx +78 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +120 -54
- package/src/data/intel-data-provider/intel-data-provider.types.ts +47 -12
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +670 -271
- package/src/flows/node-icon/node-icon.ts +28 -0
- package/src/flows/node-palette/node-palette.tsx +174 -0
- package/src/flows/node-palette/node-palette.types.ts +15 -0
- package/src/graph-pane/graph-pane.tsx +44 -0
- package/src/i18n/en.json +141 -24
- package/src/knowledge/knowledge.tsx +69 -355
- package/src/knowledge-editor/knowledge-editor.tsx +169 -21
- package/src/knowledge-graph/knowledge-graph.ts +26 -24
- package/src/knowledge-graph/knowledge-graph.tsx +33 -24
- package/src/knowledge-table/knowledge-table.tsx +129 -0
- package/src/main.tsx +2 -2
- package/src/resource-menu/resource-menu.tsx +580 -0
- package/src/router/selection-search.ts +27 -3
- package/src/save-button/save-button.tsx +103 -0
- package/src/styles.css +37 -0
- package/src/theme/theme.ts +24 -0
- package/src/tools/tools.tsx +3 -3
- package/src/app/header-actions/header-actions.tsx +0 -15
|
@@ -2,15 +2,24 @@ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
|
|
2
2
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
3
3
|
import {
|
|
4
4
|
ChevronRight,
|
|
5
|
+
CornerLeftUp,
|
|
5
6
|
FileText,
|
|
6
7
|
Folder,
|
|
7
8
|
FolderOpen,
|
|
8
9
|
Paperclip,
|
|
9
10
|
Plus,
|
|
11
|
+
Table,
|
|
10
12
|
Upload,
|
|
11
13
|
Workflow,
|
|
12
14
|
} from "lucide-react";
|
|
13
15
|
import { useRef, useState } from "react";
|
|
16
|
+
import {
|
|
17
|
+
type MoveDestination,
|
|
18
|
+
MoveDialog,
|
|
19
|
+
moveErrorKey,
|
|
20
|
+
moveVerdict,
|
|
21
|
+
parentOf,
|
|
22
|
+
} from "@/app/tree-move/tree-move.tsx";
|
|
14
23
|
import {
|
|
15
24
|
DropdownMenu,
|
|
16
25
|
DropdownMenuContent,
|
|
@@ -24,18 +33,21 @@ import {
|
|
|
24
33
|
SidebarMenuItem,
|
|
25
34
|
SidebarMenuSkeleton,
|
|
26
35
|
} from "@/components/ui/sidebar";
|
|
36
|
+
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
27
37
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
28
38
|
import { Modal } from "@/modal/modal.tsx";
|
|
39
|
+
import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
|
|
29
40
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
30
41
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
31
42
|
|
|
32
|
-
type NewKind = "folder" | "document" | "flow";
|
|
43
|
+
type NewKind = "folder" | "document" | "table" | "flow";
|
|
33
44
|
type Creating = { parentId: string | null; kind: NewKind };
|
|
34
45
|
|
|
35
46
|
const icons = {
|
|
36
47
|
folder: Folder,
|
|
37
48
|
document: FileText,
|
|
38
49
|
attachment: Paperclip,
|
|
50
|
+
table: Table,
|
|
39
51
|
flow: Workflow,
|
|
40
52
|
} as const;
|
|
41
53
|
|
|
@@ -59,20 +71,42 @@ async function fileBase64(file: File): Promise<string> {
|
|
|
59
71
|
});
|
|
60
72
|
}
|
|
61
73
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
// A level is either a folder of the shared tree or the flows one flow calls. The second kind is
|
|
75
|
+
// derived from that flow's graph, not from `parent_id` (ADR-0004 §3), which is why it is its own
|
|
76
|
+
// level rather than another folder.
|
|
77
|
+
type Level = { id: string | null; type: "folder" | "flow" };
|
|
78
|
+
|
|
79
|
+
// One key per level. `null` is the root; every expanded row adds one of its own, and nothing else is
|
|
80
|
+
// ever asked for.
|
|
81
|
+
function levelKey(level: Level): readonly unknown[] {
|
|
82
|
+
return level.type === "flow" ? ["flow-calls", level.id] : ["tree", level.id];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
|
|
86
|
+
// plus on the moved row would still file into the folder it just left.
|
|
87
|
+
function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
88
|
+
return entry.type === "knowledge"
|
|
89
|
+
? { ...entry, node: { ...entry.node, parentId } }
|
|
90
|
+
: { ...entry, flow: { ...entry.flow, parentId } };
|
|
66
91
|
}
|
|
67
92
|
|
|
68
93
|
export function AppTree() {
|
|
69
94
|
const { data, i18n } = useIntelRouterContext();
|
|
70
95
|
const queryClient = useQueryClient();
|
|
71
96
|
const navigate = useNavigate();
|
|
72
|
-
const [expanded, setExpanded] = useState<
|
|
97
|
+
const [expanded, setExpanded] = useState<readonly Level[]>([]);
|
|
73
98
|
const [creating, setCreating] = useState<Creating | null>(null);
|
|
74
99
|
const [uploadTo, setUploadTo] = useState<string | null>(null);
|
|
75
100
|
const uploadInput = useRef<HTMLInputElement>(null);
|
|
101
|
+
const [dragged, setDragged] = useState<TreeEntry | null>(null);
|
|
102
|
+
const draggedRef = useRef<TreeEntry | null>(null);
|
|
103
|
+
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
104
|
+
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
105
|
+
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
106
|
+
const [moving, setMoving] = useState<{
|
|
107
|
+
entry: TreeEntry;
|
|
108
|
+
initial: MoveDestination | null;
|
|
109
|
+
} | null>(null);
|
|
76
110
|
|
|
77
111
|
const location = useRouterState({
|
|
78
112
|
select: (state) => ({
|
|
@@ -84,22 +118,27 @@ export function AppTree() {
|
|
|
84
118
|
// ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
|
|
85
119
|
// open. A recursive load would put an N+1 on every page of the app, because the tree is now on
|
|
86
120
|
// every page.
|
|
87
|
-
const parents:
|
|
121
|
+
const parents: Level[] = [{ id: null, type: "folder" }, ...expanded];
|
|
88
122
|
const levels = useQueries({
|
|
89
|
-
queries: parents.map((
|
|
90
|
-
queryKey: levelKey(
|
|
91
|
-
queryFn: () =>
|
|
123
|
+
queries: parents.map((parent) => ({
|
|
124
|
+
queryKey: levelKey(parent),
|
|
125
|
+
queryFn: async () =>
|
|
126
|
+
parent.type === "flow" && parent.id !== null
|
|
127
|
+
? // The same row a folder's level builds for a flow, from the same function: what hangs
|
|
128
|
+
// under an expanded flow differs in where the flows come from, never in what a flow row
|
|
129
|
+
// is (#30).
|
|
130
|
+
(await data.listFlowCalls(parent.id)).items.map(flowEntry)
|
|
131
|
+
: await data.listTreeChildren(parent.id),
|
|
92
132
|
})),
|
|
93
133
|
});
|
|
94
|
-
const levelFor = (
|
|
134
|
+
const levelFor = (level: Level) =>
|
|
135
|
+
levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
|
|
95
136
|
const root = levels[0];
|
|
96
137
|
|
|
97
|
-
function toggle(id: string, open: boolean) {
|
|
138
|
+
function toggle(level: Level & { id: string }, open: boolean) {
|
|
98
139
|
setExpanded((current) => {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
else next.delete(id);
|
|
102
|
-
return next;
|
|
140
|
+
const rest = current.filter((entry) => entry.id !== level.id || entry.type !== level.type);
|
|
141
|
+
return open ? [...rest, level] : rest;
|
|
103
142
|
});
|
|
104
143
|
}
|
|
105
144
|
|
|
@@ -110,15 +149,23 @@ export function AppTree() {
|
|
|
110
149
|
parentId: string | null,
|
|
111
150
|
collection: "flows" | "knowledge-graph",
|
|
112
151
|
): Promise<void> {
|
|
113
|
-
if (parentId !== null) toggle(parentId, true);
|
|
152
|
+
if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
|
|
114
153
|
await Promise.all([
|
|
115
|
-
queryClient.invalidateQueries({ queryKey: levelKey(parentId) }),
|
|
154
|
+
queryClient.invalidateQueries({ queryKey: levelKey({ id: parentId, type: "folder" }) }),
|
|
116
155
|
queryClient.invalidateQueries({ queryKey: [collection] }),
|
|
156
|
+
// The graph view of that same level draws exactly this list, so it goes stale for the same
|
|
157
|
+
// reason the level does (#19).
|
|
158
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
117
159
|
]);
|
|
118
160
|
}
|
|
119
161
|
|
|
120
162
|
const create = useMutation({
|
|
121
|
-
mutationFn: async ({
|
|
163
|
+
mutationFn: async ({
|
|
164
|
+
parentId,
|
|
165
|
+
kind,
|
|
166
|
+
title,
|
|
167
|
+
columns,
|
|
168
|
+
}: Creating & { title: string; columns: string[] }) => {
|
|
122
169
|
if (kind === "flow") {
|
|
123
170
|
const flow = await data.createFlow({
|
|
124
171
|
parentId,
|
|
@@ -136,6 +183,17 @@ export function AppTree() {
|
|
|
136
183
|
contextPolicy: "relevant",
|
|
137
184
|
idempotencyKey: crypto.randomUUID(),
|
|
138
185
|
});
|
|
186
|
+
// ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
|
|
187
|
+
// contract every later append is measured against (#40). A table without a header would sit
|
|
188
|
+
// there refusing every append, so the second call happens here rather than being left to the
|
|
189
|
+
// person who created it.
|
|
190
|
+
if (kind === "table") {
|
|
191
|
+
await data.defineKnowledgeTable({
|
|
192
|
+
nodeId: node.id,
|
|
193
|
+
columns,
|
|
194
|
+
idempotencyKey: crypto.randomUUID(),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
139
197
|
return {
|
|
140
198
|
area: "/knowledge" as const,
|
|
141
199
|
id: node.id,
|
|
@@ -193,13 +251,143 @@ export function AppTree() {
|
|
|
193
251
|
},
|
|
194
252
|
});
|
|
195
253
|
|
|
254
|
+
// ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the
|
|
255
|
+
// other before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
|
|
256
|
+
// `update_conflict` — puts both levels back exactly as they were and then re-reads them, so a
|
|
257
|
+
// rejected move cannot leave a row standing twice or nowhere at all.
|
|
258
|
+
const move = useMutation({
|
|
259
|
+
mutationFn: async ({
|
|
260
|
+
entry,
|
|
261
|
+
destination,
|
|
262
|
+
}: {
|
|
263
|
+
entry: TreeEntry;
|
|
264
|
+
destination: MoveDestination;
|
|
265
|
+
}) => {
|
|
266
|
+
// `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
|
|
267
|
+
// view can act on instead of an overwrite nobody notices.
|
|
268
|
+
if (entry.type === "flow") {
|
|
269
|
+
await data.updateFlow({
|
|
270
|
+
flowId: entry.id,
|
|
271
|
+
baseUpdatedAt: entry.flow.updatedAt,
|
|
272
|
+
parentId: destination.id,
|
|
273
|
+
idempotencyKey: crypto.randomUUID(),
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
await data.updateKnowledge({
|
|
277
|
+
nodeId: entry.id,
|
|
278
|
+
baseUpdatedAt: entry.node.updatedAt,
|
|
279
|
+
parentId: destination.id,
|
|
280
|
+
idempotencyKey: crypto.randomUUID(),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
onMutate: async ({ entry, destination }) => {
|
|
285
|
+
const fromKey = levelKey({ id: parentOf(entry), type: "folder" });
|
|
286
|
+
const toKey = levelKey({ id: destination.id, type: "folder" });
|
|
287
|
+
await Promise.all([
|
|
288
|
+
queryClient.cancelQueries({ queryKey: fromKey }),
|
|
289
|
+
queryClient.cancelQueries({ queryKey: toKey }),
|
|
290
|
+
]);
|
|
291
|
+
const snapshot = [
|
|
292
|
+
[fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
|
|
293
|
+
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
|
|
294
|
+
] as const;
|
|
295
|
+
queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
|
|
296
|
+
current?.filter((row) => row.id !== entry.id),
|
|
297
|
+
);
|
|
298
|
+
// A level nobody has opened stays unloaded: writing one here would show a folder's contents
|
|
299
|
+
// that were never read.
|
|
300
|
+
queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
|
|
301
|
+
current === undefined
|
|
302
|
+
? current
|
|
303
|
+
: [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
|
|
304
|
+
);
|
|
305
|
+
return { snapshot };
|
|
306
|
+
},
|
|
307
|
+
onError: (_error, _variables, context) => {
|
|
308
|
+
for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
|
|
309
|
+
},
|
|
310
|
+
onSuccess: (_result, { destination }) => {
|
|
311
|
+
if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
|
|
312
|
+
},
|
|
313
|
+
onSettled: async (_result, _error, { entry, destination }) => {
|
|
314
|
+
await Promise.all([
|
|
315
|
+
queryClient.invalidateQueries({
|
|
316
|
+
queryKey: levelKey({ id: parentOf(entry), type: "folder" }),
|
|
317
|
+
}),
|
|
318
|
+
queryClient.invalidateQueries({
|
|
319
|
+
queryKey: levelKey({ id: destination.id, type: "folder" }),
|
|
320
|
+
}),
|
|
321
|
+
queryClient.invalidateQueries({
|
|
322
|
+
queryKey: [entry.type === "flow" ? "flows" : "knowledge-graph"],
|
|
323
|
+
}),
|
|
324
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
325
|
+
]);
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
function startMove(entry: TreeEntry, destination: MoveDestination | null) {
|
|
330
|
+
move.reset();
|
|
331
|
+
setMoving({ entry, initial: destination });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// What a drop on this target would do. `undefined` means nothing is being dragged, so the target
|
|
335
|
+
// is not a target at all.
|
|
336
|
+
function verdictFor(
|
|
337
|
+
carried: TreeEntry | null,
|
|
338
|
+
targetId: string | null,
|
|
339
|
+
targetAncestors: ReadonlySet<string>,
|
|
340
|
+
) {
|
|
341
|
+
if (!carried) return undefined;
|
|
342
|
+
return moveVerdict({
|
|
343
|
+
draggedId: carried.id,
|
|
344
|
+
draggedParentId: parentOf(carried),
|
|
345
|
+
targetId,
|
|
346
|
+
targetAncestors,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function dropHandlers(target: MoveDestination, ancestors: ReadonlySet<string>) {
|
|
351
|
+
// Two readings of the same question. The render-time one drives what is on screen; the handlers
|
|
352
|
+
// ask `draggedRef` instead, because a drag's events can all arrive before React has re-rendered
|
|
353
|
+
// and a closure over the previous render would then move the previous row.
|
|
354
|
+
const verdict = verdictFor(dragged, target.id, ancestors);
|
|
355
|
+
return {
|
|
356
|
+
verdict,
|
|
357
|
+
props: {
|
|
358
|
+
onDragOver: (event: React.DragEvent) => {
|
|
359
|
+
if (verdictFor(draggedRef.current, target.id, ancestors) !== "ok") {
|
|
360
|
+
event.dataTransfer.dropEffect = "none";
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// Without `preventDefault` the browser refuses the drop, so this is also what makes the
|
|
364
|
+
// "not allowed" cursor appear over every target the tree has ruled out.
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
event.dataTransfer.dropEffect = "move";
|
|
367
|
+
setOver(target.id);
|
|
368
|
+
},
|
|
369
|
+
onDragLeave: () => setOver((current) => (current === target.id ? undefined : current)),
|
|
370
|
+
onDrop: (event: React.DragEvent) => {
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
const carried = draggedRef.current;
|
|
373
|
+
setOver(undefined);
|
|
374
|
+
setDragged(null);
|
|
375
|
+
draggedRef.current = null;
|
|
376
|
+
if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
|
|
377
|
+
startMove(carried, target);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
196
384
|
function startUpload(parentId: string | null) {
|
|
197
385
|
setUploadTo(parentId);
|
|
198
386
|
uploadInput.current?.click();
|
|
199
387
|
}
|
|
200
388
|
|
|
201
|
-
function renderLevel(
|
|
202
|
-
const level = levelFor(
|
|
389
|
+
function renderLevel(parent: Level, ancestors: ReadonlySet<string>, label?: string) {
|
|
390
|
+
const level = levelFor(parent);
|
|
203
391
|
if (!level || level.isPending) {
|
|
204
392
|
return (
|
|
205
393
|
<SidebarMenu aria-label={label}>
|
|
@@ -231,42 +419,100 @@ export function AppTree() {
|
|
|
231
419
|
// now is that a row already on the path from the root is not rendered again.
|
|
232
420
|
const entries = level.data?.filter((entry) => !ancestors.has(entry.id)) ?? [];
|
|
233
421
|
if (entries.length === 0) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
422
|
+
// ⚠️ An open folder with nothing in it says nothing (#24): the missing row already is the
|
|
423
|
+
// answer, and the sentence used to repeat itself once per opened folder. The other two stay,
|
|
424
|
+
// and deliberately: the empty root is the whole of the navigation, so a blank sidebar on a
|
|
425
|
+
// fresh installation would leave a new user without any way in; and "this flow calls no other
|
|
426
|
+
// flow" is a different statement from "empty" — it is an answer, not filler.
|
|
427
|
+
const message =
|
|
428
|
+
parent.type === "flow"
|
|
429
|
+
? i18n.t("tree.noCalls")
|
|
430
|
+
: parent.id === null
|
|
431
|
+
? i18n.t("tree.empty")
|
|
432
|
+
: null;
|
|
433
|
+
// The folder keeps its list, empty. The structure is what carries "there is nothing here" to
|
|
434
|
+
// a screen reader — "list, 0 items" — which is the same answer the missing rows give a reader
|
|
435
|
+
// who can see them, and neither of them is a sentence.
|
|
436
|
+
return message === null ? (
|
|
437
|
+
<SidebarMenu aria-label={label} />
|
|
438
|
+
) : (
|
|
439
|
+
<p className="px-2 py-1.5 text-sm text-muted-foreground">{message}</p>
|
|
238
440
|
);
|
|
239
441
|
}
|
|
240
442
|
return (
|
|
241
443
|
<SidebarMenu aria-label={label}>
|
|
242
|
-
{entries.map((entry) => renderRow(entry, ancestors))}
|
|
444
|
+
{entries.map((entry) => renderRow(entry, ancestors, parent.type === "flow"))}
|
|
243
445
|
</SidebarMenu>
|
|
244
446
|
);
|
|
245
447
|
}
|
|
246
448
|
|
|
247
|
-
|
|
449
|
+
// ⚠️ `derived` is the level under a flow: its rows are that flow's calls, read out of its graph
|
|
450
|
+
// (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
|
|
451
|
+
// dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
|
|
452
|
+
function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string>, derived: boolean) {
|
|
248
453
|
const isFolder = entry.kind === "folder";
|
|
249
|
-
|
|
250
|
-
|
|
454
|
+
// ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
|
|
455
|
+
// from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
|
|
456
|
+
// the answer to "what do I run, and how" (ADR-0004 §3).
|
|
457
|
+
const level: Level & { id: string } = { id: entry.id, type: isFolder ? "folder" : "flow" };
|
|
458
|
+
const expandable = isFolder || entry.type === "flow";
|
|
459
|
+
const isOpen =
|
|
460
|
+
expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
|
|
461
|
+
const Icon = isOpen && isFolder ? FolderOpen : icons[entry.kind];
|
|
251
462
|
const area = entry.type === "flow" ? "/flows" : "/knowledge";
|
|
252
463
|
const isActive = location.select === entry.id && location.pathname === area;
|
|
253
464
|
// A row's plus files into that row's place: inside a folder, beside anything else.
|
|
254
|
-
const target = isFolder
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
465
|
+
const target = isFolder ? entry.id : parentOf(entry);
|
|
466
|
+
// Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
|
|
467
|
+
// default cursor while a drag is in progress, which is the answer "not here" without a word.
|
|
468
|
+
const drop =
|
|
469
|
+
isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
|
|
470
|
+
const isDragged = dragged?.id === entry.id;
|
|
471
|
+
// While a drag is in progress every row says which of the three it is: the row being carried,
|
|
472
|
+
// a folder that would take it, or something that would not. Silence on the last two is what
|
|
473
|
+
// turns a drag into a guess that only the drop answers.
|
|
474
|
+
const highlight =
|
|
475
|
+
dragged === null || isDragged
|
|
476
|
+
? ""
|
|
477
|
+
: drop?.verdict === "ok"
|
|
478
|
+
? over === entry.id
|
|
479
|
+
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
480
|
+
: "ring-1 ring-sidebar-border"
|
|
481
|
+
: "opacity-40";
|
|
259
482
|
|
|
260
483
|
return (
|
|
261
484
|
<SidebarMenuItem key={entry.id} className="group/row">
|
|
262
|
-
|
|
263
|
-
|
|
485
|
+
{/* ⚠️ The colouring sits on the ROW, not on the button inside it — the difference the
|
|
486
|
+
ticket is about. It used to hang on `SidebarMenuButton`, whose siblings the plus and this
|
|
487
|
+
menu are, so the grey stopped short of them: two icons standing outside the very row they
|
|
488
|
+
operate, with a pale strip left over on the right. Taken from gate, where the same
|
|
489
|
+
mistake was made and unmade three times (`service-tree.tsx`, GATE-76/-78/-81) — there the
|
|
490
|
+
row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
|
|
491
|
+
The button keeps its own hover and active fill switched off rather than doubled, so the
|
|
492
|
+
row shows one surface instead of two overlapping ones. */}
|
|
493
|
+
<div
|
|
494
|
+
className={`flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
|
|
495
|
+
isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
|
|
496
|
+
}`}
|
|
497
|
+
>
|
|
498
|
+
{expandable ? (
|
|
264
499
|
<button
|
|
265
500
|
type="button"
|
|
266
501
|
aria-expanded={isOpen}
|
|
267
|
-
aria-label={i18n.t(
|
|
268
|
-
|
|
269
|
-
|
|
502
|
+
aria-label={i18n.t(
|
|
503
|
+
isFolder
|
|
504
|
+
? isOpen
|
|
505
|
+
? "tree.collapse"
|
|
506
|
+
: "tree.expand"
|
|
507
|
+
: isOpen
|
|
508
|
+
? "tree.collapseCalls"
|
|
509
|
+
: "tree.expandCalls",
|
|
510
|
+
{ title: entry.title },
|
|
511
|
+
)}
|
|
512
|
+
onClick={() => toggle(level, !isOpen)}
|
|
513
|
+
// The icons inside a coloured row need a step of their own, or their hover would be
|
|
514
|
+
// the row's colour on the row's colour and read as nothing at all.
|
|
515
|
+
className="shrink-0 rounded-md p-1 text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent-foreground/10 focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
270
516
|
>
|
|
271
517
|
<ChevronRight
|
|
272
518
|
aria-hidden="true"
|
|
@@ -276,9 +522,28 @@ export function AppTree() {
|
|
|
276
522
|
) : (
|
|
277
523
|
<span aria-hidden="true" className="size-5 shrink-0" />
|
|
278
524
|
)}
|
|
525
|
+
{/* The row's own control is what is grabbed and what is dropped on: a real button, so the
|
|
526
|
+
gesture rides on something that is already focusable and named rather than on a bare
|
|
527
|
+
div that assistive technology would have to be told about twice. */}
|
|
279
528
|
<SidebarMenuButton
|
|
280
529
|
isActive={isActive}
|
|
281
|
-
|
|
530
|
+
draggable={!derived}
|
|
531
|
+
onDragStart={(event) => {
|
|
532
|
+
event.dataTransfer.effectAllowed = "move";
|
|
533
|
+
event.dataTransfer.setData("text/plain", entry.id);
|
|
534
|
+
draggedRef.current = entry;
|
|
535
|
+
setDragged(entry);
|
|
536
|
+
}}
|
|
537
|
+
onDragEnd={() => {
|
|
538
|
+
draggedRef.current = null;
|
|
539
|
+
setDragged(null);
|
|
540
|
+
setOver(undefined);
|
|
541
|
+
}}
|
|
542
|
+
{...(drop?.props ?? {})}
|
|
543
|
+
data-drop={
|
|
544
|
+
dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
|
|
545
|
+
}
|
|
546
|
+
className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${derived ? "" : "cursor-grab active:cursor-grabbing"} ${isDragged ? "opacity-50" : ""} ${highlight}`}
|
|
282
547
|
onClick={() => void navigate({ to: area, search: { select: entry.id } })}
|
|
283
548
|
>
|
|
284
549
|
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
|
@@ -294,35 +559,86 @@ export function AppTree() {
|
|
|
294
559
|
else setCreating({ parentId: target, kind });
|
|
295
560
|
}}
|
|
296
561
|
/>
|
|
562
|
+
{/* ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no
|
|
563
|
+
touch worth the name. A capability that exists only for a mouse is missing for
|
|
564
|
+
everyone else, so moving has a second, equal route — this menu's folder picker. #27
|
|
565
|
+
opened the menu with that one item; the rest of the row's actions moved in here from
|
|
566
|
+
the header, which is what #24 is. A row under a flow is what that flow calls, read out
|
|
567
|
+
of its graph rather than from `parent_id` (ADR-0004 §3): it has no place of its own to
|
|
568
|
+
be moved from, so it gets no move — and no menu, since renaming the shared flow from
|
|
569
|
+
under one of its callers would rename it for all of them without saying so. */}
|
|
570
|
+
{derived ? null : (
|
|
571
|
+
<ResourceMenu target={entry} variant="row" onMove={() => startMove(entry, null)} />
|
|
572
|
+
)}
|
|
297
573
|
</div>
|
|
298
574
|
{/* The same rows one indent deeper: one row component for every depth, so the plus on the
|
|
299
|
-
fourth level is the same plus as on the first.
|
|
575
|
+
fourth level is the same plus as on the first. The list is named after the row it hangs
|
|
576
|
+
under — indentation says whose contents these are to anyone who can see it, and this is
|
|
577
|
+
the same sentence for anyone who cannot. */}
|
|
300
578
|
{isOpen ? (
|
|
301
579
|
<div className="ml-3.5 border-l border-sidebar-border pl-1.5">
|
|
302
|
-
{renderLevel(
|
|
580
|
+
{renderLevel(
|
|
581
|
+
level,
|
|
582
|
+
new Set([...ancestors, entry.id]),
|
|
583
|
+
i18n.t(isFolder ? "tree.contents" : "tree.calls", { title: entry.title }),
|
|
584
|
+
)}
|
|
303
585
|
</div>
|
|
304
586
|
) : null}
|
|
305
587
|
</SidebarMenuItem>
|
|
306
588
|
);
|
|
307
589
|
}
|
|
308
590
|
|
|
591
|
+
const rootDrop = dropHandlers({ id: null, title: i18n.t("tree.move.root") }, new Set());
|
|
592
|
+
|
|
309
593
|
return (
|
|
310
594
|
<SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
|
|
595
|
+
{/* The root has no row to drop on, so while something is being carried it gets one. It is the
|
|
596
|
+
only way back out of a folder, and it appears exactly when it can be used. */}
|
|
597
|
+
{rootDrop.verdict === undefined || dragged === null ? null : (
|
|
598
|
+
<button
|
|
599
|
+
type="button"
|
|
600
|
+
{...rootDrop.props}
|
|
601
|
+
onClick={() => startMove(dragged, { id: null, title: i18n.t("tree.move.root") })}
|
|
602
|
+
data-drop={rootDrop.verdict}
|
|
603
|
+
className={`mb-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
|
|
604
|
+
rootDrop.verdict === "ok"
|
|
605
|
+
? over === null
|
|
606
|
+
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
607
|
+
: "text-sidebar-foreground"
|
|
608
|
+
: "text-muted-foreground opacity-40"
|
|
609
|
+
}`}
|
|
610
|
+
>
|
|
611
|
+
<CornerLeftUp aria-hidden="true" className="size-4 shrink-0" />
|
|
612
|
+
{i18n.t("tree.move.dropRoot")}
|
|
613
|
+
</button>
|
|
614
|
+
)}
|
|
311
615
|
{/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
|
|
312
616
|
named for assistive technology instead. */}
|
|
313
|
-
{renderLevel(null, new Set(), i18n.t("tree.label"))}
|
|
617
|
+
{renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
|
|
314
618
|
{create.isError || upload.isError ? (
|
|
315
619
|
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
316
620
|
{upload.isError ? i18n.t("tree.uploadFailed") : i18n.t("tree.createFailed")}
|
|
317
621
|
</p>
|
|
318
622
|
) : null}
|
|
319
|
-
{/*
|
|
320
|
-
|
|
623
|
+
{/* Four refusals, four sentences — and by the time one is read the row is already back where
|
|
624
|
+
it started, because the rollback happens in `onError` rather than here. */}
|
|
625
|
+
{move.isError ? (
|
|
626
|
+
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
627
|
+
{i18n.t(moveErrorKey(move.error))}
|
|
628
|
+
</p>
|
|
629
|
+
) : null}
|
|
630
|
+
{/* The root has no row of its own, so it gets one — otherwise a tree could only ever grow
|
|
631
|
+
inside the folders it already has, and the plus would stand loose under the list, attached
|
|
632
|
+
to nothing (#24). As a whole row it is also the one thing on screen when the tree is empty,
|
|
633
|
+
which is exactly when somebody needs to be told where to start. */}
|
|
321
634
|
{root?.isPending ? null : (
|
|
322
|
-
<div className="mt-1 flex items-center
|
|
635
|
+
<div className="mt-1 flex items-center gap-0.5">
|
|
636
|
+
{/* The gutter the disclosure arrows occupy, so the plus lines up with the row icons
|
|
637
|
+
above it rather than half a step to their left. */}
|
|
638
|
+
<span aria-hidden="true" className="size-5 shrink-0" />
|
|
323
639
|
<AddMenu
|
|
324
640
|
label={i18n.t("tree.addRoot")}
|
|
325
|
-
|
|
641
|
+
variant="row"
|
|
326
642
|
onSelect={(kind) => {
|
|
327
643
|
if (kind === "upload") startUpload(null);
|
|
328
644
|
else setCreating({ parentId: null, kind });
|
|
@@ -344,44 +660,61 @@ export function AppTree() {
|
|
|
344
660
|
{creating ? (
|
|
345
661
|
<Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
|
|
346
662
|
<CreateForm
|
|
663
|
+
kind={creating.kind}
|
|
347
664
|
pending={create.isPending}
|
|
348
|
-
submit={(title) => create.mutate({ ...creating, title })}
|
|
665
|
+
submit={(title, columns) => create.mutate({ ...creating, title, columns })}
|
|
349
666
|
/>
|
|
350
667
|
</Modal>
|
|
351
668
|
) : null}
|
|
669
|
+
{moving ? (
|
|
670
|
+
<MoveDialog
|
|
671
|
+
entry={moving.entry}
|
|
672
|
+
initial={moving.initial}
|
|
673
|
+
close={() => setMoving(null)}
|
|
674
|
+
submit={(destination) => {
|
|
675
|
+
setMoving(null);
|
|
676
|
+
move.mutate({ entry: moving.entry, destination });
|
|
677
|
+
}}
|
|
678
|
+
/>
|
|
679
|
+
) : null}
|
|
352
680
|
</SidebarGroup>
|
|
353
681
|
);
|
|
354
682
|
}
|
|
355
683
|
|
|
356
684
|
// ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
|
|
357
685
|
// having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
|
|
686
|
+
//
|
|
687
|
+
// `row` is the root's form: a full-width row that says what it does, because the root has no title
|
|
688
|
+
// beside which a bare icon would mean anything.
|
|
358
689
|
function AddMenu({
|
|
359
690
|
label,
|
|
360
|
-
|
|
691
|
+
variant = "icon",
|
|
361
692
|
onSelect,
|
|
362
693
|
}: {
|
|
363
694
|
label: string;
|
|
364
|
-
|
|
695
|
+
variant?: "icon" | "row";
|
|
365
696
|
onSelect(kind: NewKind | "upload"): void;
|
|
366
697
|
}) {
|
|
367
698
|
const { i18n } = useIntelRouterContext();
|
|
368
699
|
const items: Array<{ kind: NewKind | "upload"; labelKey: string; Icon: typeof Plus }> = [
|
|
369
700
|
{ kind: "folder", labelKey: "tree.new.folder", Icon: Folder },
|
|
370
701
|
{ kind: "document", labelKey: "tree.new.document", Icon: FileText },
|
|
702
|
+
{ kind: "table", labelKey: "tree.new.table", Icon: Table },
|
|
371
703
|
{ kind: "upload", labelKey: "tree.new.upload", Icon: Upload },
|
|
372
704
|
{ kind: "flow", labelKey: "tree.new.flow", Icon: Workflow },
|
|
373
705
|
];
|
|
374
706
|
return (
|
|
375
707
|
<DropdownMenu>
|
|
376
708
|
<DropdownMenuTrigger
|
|
377
|
-
aria-label
|
|
378
|
-
className={
|
|
379
|
-
|
|
380
|
-
? ""
|
|
381
|
-
: "group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
382
|
-
}
|
|
709
|
+
{...(variant === "row" ? {} : { "aria-label": label })}
|
|
710
|
+
className={
|
|
711
|
+
variant === "row"
|
|
712
|
+
? "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-sidebar-foreground/70 outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-sidebar-accent"
|
|
713
|
+
: "shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent-foreground/10 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
714
|
+
}
|
|
383
715
|
>
|
|
384
|
-
<Plus aria-hidden="true" className="size-4" />
|
|
716
|
+
<Plus aria-hidden="true" className="size-4 shrink-0" />
|
|
717
|
+
{variant === "row" ? <span className="truncate">{label}</span> : null}
|
|
385
718
|
</DropdownMenuTrigger>
|
|
386
719
|
<DropdownMenuContent align="start" className="w-44">
|
|
387
720
|
{items.map((item) => (
|
|
@@ -395,15 +728,35 @@ function AddMenu({
|
|
|
395
728
|
);
|
|
396
729
|
}
|
|
397
730
|
|
|
398
|
-
|
|
731
|
+
// The column names of a new table, as one line. A comma-separated list is what a CSV header is,
|
|
732
|
+
// so the field looks like the thing it produces rather than like a form that hides it.
|
|
733
|
+
function columnsOf(value: string): string[] {
|
|
734
|
+
return value
|
|
735
|
+
.split(",")
|
|
736
|
+
.map((column) => column.trim())
|
|
737
|
+
.filter(Boolean);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function CreateForm({
|
|
741
|
+
kind,
|
|
742
|
+
pending,
|
|
743
|
+
submit,
|
|
744
|
+
}: {
|
|
745
|
+
kind: NewKind;
|
|
746
|
+
pending: boolean;
|
|
747
|
+
submit(title: string, columns: string[]): void;
|
|
748
|
+
}) {
|
|
399
749
|
const { i18n } = useIntelRouterContext();
|
|
400
750
|
const [title, setTitle] = useState("");
|
|
751
|
+
const [columns, setColumns] = useState("");
|
|
752
|
+
const parsedColumns = columnsOf(columns);
|
|
753
|
+
const incomplete = title.trim().length === 0 || (kind === "table" && parsedColumns.length === 0);
|
|
401
754
|
return (
|
|
402
755
|
<form
|
|
403
756
|
className="space-y-4"
|
|
404
757
|
onSubmit={(event) => {
|
|
405
758
|
event.preventDefault();
|
|
406
|
-
if (!pending) submit(title.trim());
|
|
759
|
+
if (!pending && !incomplete) submit(title.trim(), parsedColumns);
|
|
407
760
|
}}
|
|
408
761
|
>
|
|
409
762
|
<label className="block text-sm font-medium">
|
|
@@ -415,9 +768,27 @@ function CreateForm({ pending, submit }: { pending: boolean; submit(title: strin
|
|
|
415
768
|
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
416
769
|
/>
|
|
417
770
|
</label>
|
|
771
|
+
{kind === "table" ? (
|
|
772
|
+
<label className="block text-sm font-medium">
|
|
773
|
+
{i18n.t("tree.table.columns")}
|
|
774
|
+
<input
|
|
775
|
+
required
|
|
776
|
+
value={columns}
|
|
777
|
+
onChange={(event) => setColumns(event.target.value)}
|
|
778
|
+
aria-describedby="create-table-columns-hint"
|
|
779
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
780
|
+
/>
|
|
781
|
+
<span
|
|
782
|
+
id="create-table-columns-hint"
|
|
783
|
+
className="mt-1 block text-xs font-normal text-muted-foreground"
|
|
784
|
+
>
|
|
785
|
+
{i18n.t("tree.table.columnsHint")}
|
|
786
|
+
</span>
|
|
787
|
+
</label>
|
|
788
|
+
) : null}
|
|
418
789
|
<button
|
|
419
790
|
type="submit"
|
|
420
|
-
disabled={pending ||
|
|
791
|
+
disabled={pending || incomplete}
|
|
421
792
|
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"
|
|
422
793
|
>
|
|
423
794
|
{pending ? i18n.t("common.saving") : i18n.t("common.create")}
|