@anchrd/intel-ui 0.4.0 → 0.6.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 +27 -0
- package/src/app/app-sidebar/app-sidebar.tsx +39 -24
- package/src/app/app-tree/app-tree.tsx +332 -60
- package/src/app/app.tsx +31 -5
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
- package/src/app/tree-move/tree-move.tsx +331 -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 +135 -57
- package/src/data/intel-data-provider/intel-data-provider.types.ts +51 -13
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +665 -271
- package/src/flows/node-icon/node-icon.ts +28 -0
- package/src/flows/node-palette/node-palette.tsx +200 -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 +144 -29
- package/src/knowledge/knowledge.tsx +91 -367
- 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 +141 -0
- package/src/main.tsx +2 -2
- package/src/resource-menu/resource-menu.tsx +615 -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/title-row/title-row.tsx +49 -0
- package/src/tools/tools.tsx +57 -38
- package/src/app/header-actions/header-actions.tsx +0 -15
|
@@ -2,15 +2,25 @@ 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
|
+
moveErrorKey,
|
|
19
|
+
moveVerdict,
|
|
20
|
+
parentOf,
|
|
21
|
+
treeLevelKey,
|
|
22
|
+
useTreeMove,
|
|
23
|
+
} from "@/app/tree-move/tree-move.tsx";
|
|
14
24
|
import {
|
|
15
25
|
DropdownMenu,
|
|
16
26
|
DropdownMenuContent,
|
|
@@ -24,18 +34,20 @@ import {
|
|
|
24
34
|
SidebarMenuItem,
|
|
25
35
|
SidebarMenuSkeleton,
|
|
26
36
|
} from "@/components/ui/sidebar";
|
|
37
|
+
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
27
38
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
28
39
|
import { Modal } from "@/modal/modal.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,38 @@ 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] : treeLevelKey(level.id);
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
export function AppTree() {
|
|
69
86
|
const { data, i18n } = useIntelRouterContext();
|
|
70
87
|
const queryClient = useQueryClient();
|
|
71
88
|
const navigate = useNavigate();
|
|
72
|
-
const [expanded, setExpanded] = useState<
|
|
89
|
+
const [expanded, setExpanded] = useState<readonly Level[]>([]);
|
|
73
90
|
const [creating, setCreating] = useState<Creating | null>(null);
|
|
74
91
|
const [uploadTo, setUploadTo] = useState<string | null>(null);
|
|
75
92
|
const uploadInput = useRef<HTMLInputElement>(null);
|
|
93
|
+
const [dragged, setDragged] = useState<TreeEntry | null>(null);
|
|
94
|
+
const draggedRef = useRef<TreeEntry | null>(null);
|
|
95
|
+
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
96
|
+
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
97
|
+
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
98
|
+
// The drop's half of the move. The other half is the folder picker in the title line's menu, and
|
|
99
|
+
// both go through the same mutation (#58, `useTreeMove`) — the tree only adds what a tree can add,
|
|
100
|
+
// which is opening the folder the row just landed in.
|
|
101
|
+
const move = useTreeMove({
|
|
102
|
+
onMoved: (destination) => {
|
|
103
|
+
if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
76
106
|
|
|
77
107
|
const location = useRouterState({
|
|
78
108
|
select: (state) => ({
|
|
@@ -84,22 +114,27 @@ export function AppTree() {
|
|
|
84
114
|
// ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
|
|
85
115
|
// open. A recursive load would put an N+1 on every page of the app, because the tree is now on
|
|
86
116
|
// every page.
|
|
87
|
-
const parents:
|
|
117
|
+
const parents: Level[] = [{ id: null, type: "folder" }, ...expanded];
|
|
88
118
|
const levels = useQueries({
|
|
89
|
-
queries: parents.map((
|
|
90
|
-
queryKey: levelKey(
|
|
91
|
-
queryFn: () =>
|
|
119
|
+
queries: parents.map((parent) => ({
|
|
120
|
+
queryKey: levelKey(parent),
|
|
121
|
+
queryFn: async () =>
|
|
122
|
+
parent.type === "flow" && parent.id !== null
|
|
123
|
+
? // The same row a folder's level builds for a flow, from the same function: what hangs
|
|
124
|
+
// under an expanded flow differs in where the flows come from, never in what a flow row
|
|
125
|
+
// is (#30).
|
|
126
|
+
(await data.listFlowCalls(parent.id)).items.map(flowEntry)
|
|
127
|
+
: await data.listTreeChildren(parent.id),
|
|
92
128
|
})),
|
|
93
129
|
});
|
|
94
|
-
const levelFor = (
|
|
130
|
+
const levelFor = (level: Level) =>
|
|
131
|
+
levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
|
|
95
132
|
const root = levels[0];
|
|
96
133
|
|
|
97
|
-
function toggle(id: string, open: boolean) {
|
|
134
|
+
function toggle(level: Level & { id: string }, open: boolean) {
|
|
98
135
|
setExpanded((current) => {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
else next.delete(id);
|
|
102
|
-
return next;
|
|
136
|
+
const rest = current.filter((entry) => entry.id !== level.id || entry.type !== level.type);
|
|
137
|
+
return open ? [...rest, level] : rest;
|
|
103
138
|
});
|
|
104
139
|
}
|
|
105
140
|
|
|
@@ -110,15 +145,23 @@ export function AppTree() {
|
|
|
110
145
|
parentId: string | null,
|
|
111
146
|
collection: "flows" | "knowledge-graph",
|
|
112
147
|
): Promise<void> {
|
|
113
|
-
if (parentId !== null) toggle(parentId, true);
|
|
148
|
+
if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
|
|
114
149
|
await Promise.all([
|
|
115
|
-
queryClient.invalidateQueries({ queryKey: levelKey(parentId) }),
|
|
150
|
+
queryClient.invalidateQueries({ queryKey: levelKey({ id: parentId, type: "folder" }) }),
|
|
116
151
|
queryClient.invalidateQueries({ queryKey: [collection] }),
|
|
152
|
+
// The graph view of that same level draws exactly this list, so it goes stale for the same
|
|
153
|
+
// reason the level does (#19).
|
|
154
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
117
155
|
]);
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
const create = useMutation({
|
|
121
|
-
mutationFn: async ({
|
|
159
|
+
mutationFn: async ({
|
|
160
|
+
parentId,
|
|
161
|
+
kind,
|
|
162
|
+
title,
|
|
163
|
+
columns,
|
|
164
|
+
}: Creating & { title: string; columns: string[] }) => {
|
|
122
165
|
if (kind === "flow") {
|
|
123
166
|
const flow = await data.createFlow({
|
|
124
167
|
parentId,
|
|
@@ -136,6 +179,17 @@ export function AppTree() {
|
|
|
136
179
|
contextPolicy: "relevant",
|
|
137
180
|
idempotencyKey: crypto.randomUUID(),
|
|
138
181
|
});
|
|
182
|
+
// ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
|
|
183
|
+
// contract every later append is measured against (#40). A table without a header would sit
|
|
184
|
+
// there refusing every append, so the second call happens here rather than being left to the
|
|
185
|
+
// person who created it.
|
|
186
|
+
if (kind === "table") {
|
|
187
|
+
await data.defineKnowledgeTable({
|
|
188
|
+
nodeId: node.id,
|
|
189
|
+
columns,
|
|
190
|
+
idempotencyKey: crypto.randomUUID(),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
139
193
|
return {
|
|
140
194
|
area: "/knowledge" as const,
|
|
141
195
|
id: node.id,
|
|
@@ -193,13 +247,63 @@ export function AppTree() {
|
|
|
193
247
|
},
|
|
194
248
|
});
|
|
195
249
|
|
|
250
|
+
// What a drop on this target would do. `undefined` means nothing is being dragged, so the target
|
|
251
|
+
// is not a target at all.
|
|
252
|
+
function verdictFor(
|
|
253
|
+
carried: TreeEntry | null,
|
|
254
|
+
targetId: string | null,
|
|
255
|
+
targetAncestors: ReadonlySet<string>,
|
|
256
|
+
) {
|
|
257
|
+
if (!carried) return undefined;
|
|
258
|
+
return moveVerdict({
|
|
259
|
+
draggedId: carried.id,
|
|
260
|
+
draggedParentId: parentOf(carried),
|
|
261
|
+
targetId,
|
|
262
|
+
targetAncestors,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function dropHandlers(target: MoveDestination, ancestors: ReadonlySet<string>) {
|
|
267
|
+
// Two readings of the same question. The render-time one drives what is on screen; the handlers
|
|
268
|
+
// ask `draggedRef` instead, because a drag's events can all arrive before React has re-rendered
|
|
269
|
+
// and a closure over the previous render would then move the previous row.
|
|
270
|
+
const verdict = verdictFor(dragged, target.id, ancestors);
|
|
271
|
+
return {
|
|
272
|
+
verdict,
|
|
273
|
+
props: {
|
|
274
|
+
onDragOver: (event: React.DragEvent) => {
|
|
275
|
+
if (verdictFor(draggedRef.current, target.id, ancestors) !== "ok") {
|
|
276
|
+
event.dataTransfer.dropEffect = "none";
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// Without `preventDefault` the browser refuses the drop, so this is also what makes the
|
|
280
|
+
// "not allowed" cursor appear over every target the tree has ruled out.
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
event.dataTransfer.dropEffect = "move";
|
|
283
|
+
setOver(target.id);
|
|
284
|
+
},
|
|
285
|
+
onDragLeave: () => setOver((current) => (current === target.id ? undefined : current)),
|
|
286
|
+
onDrop: (event: React.DragEvent) => {
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
const carried = draggedRef.current;
|
|
289
|
+
setOver(undefined);
|
|
290
|
+
setDragged(null);
|
|
291
|
+
draggedRef.current = null;
|
|
292
|
+
if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
|
|
293
|
+
move.start(carried, target);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
196
300
|
function startUpload(parentId: string | null) {
|
|
197
301
|
setUploadTo(parentId);
|
|
198
302
|
uploadInput.current?.click();
|
|
199
303
|
}
|
|
200
304
|
|
|
201
|
-
function renderLevel(
|
|
202
|
-
const level = levelFor(
|
|
305
|
+
function renderLevel(parent: Level, ancestors: ReadonlySet<string>, label?: string) {
|
|
306
|
+
const level = levelFor(parent);
|
|
203
307
|
if (!level || level.isPending) {
|
|
204
308
|
return (
|
|
205
309
|
<SidebarMenu aria-label={label}>
|
|
@@ -231,42 +335,100 @@ export function AppTree() {
|
|
|
231
335
|
// now is that a row already on the path from the root is not rendered again.
|
|
232
336
|
const entries = level.data?.filter((entry) => !ancestors.has(entry.id)) ?? [];
|
|
233
337
|
if (entries.length === 0) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
338
|
+
// ⚠️ An open folder with nothing in it says nothing (#24): the missing row already is the
|
|
339
|
+
// answer, and the sentence used to repeat itself once per opened folder. The other two stay,
|
|
340
|
+
// and deliberately: the empty root is the whole of the navigation, so a blank sidebar on a
|
|
341
|
+
// fresh installation would leave a new user without any way in; and "this flow calls no other
|
|
342
|
+
// flow" is a different statement from "empty" — it is an answer, not filler.
|
|
343
|
+
const message =
|
|
344
|
+
parent.type === "flow"
|
|
345
|
+
? i18n.t("tree.noCalls")
|
|
346
|
+
: parent.id === null
|
|
347
|
+
? i18n.t("tree.empty")
|
|
348
|
+
: null;
|
|
349
|
+
// The folder keeps its list, empty. The structure is what carries "there is nothing here" to
|
|
350
|
+
// a screen reader — "list, 0 items" — which is the same answer the missing rows give a reader
|
|
351
|
+
// who can see them, and neither of them is a sentence.
|
|
352
|
+
return message === null ? (
|
|
353
|
+
<SidebarMenu aria-label={label} />
|
|
354
|
+
) : (
|
|
355
|
+
<p className="px-2 py-1.5 text-sm text-muted-foreground">{message}</p>
|
|
238
356
|
);
|
|
239
357
|
}
|
|
240
358
|
return (
|
|
241
359
|
<SidebarMenu aria-label={label}>
|
|
242
|
-
{entries.map((entry) => renderRow(entry, ancestors))}
|
|
360
|
+
{entries.map((entry) => renderRow(entry, ancestors, parent.type === "flow"))}
|
|
243
361
|
</SidebarMenu>
|
|
244
362
|
);
|
|
245
363
|
}
|
|
246
364
|
|
|
247
|
-
|
|
365
|
+
// ⚠️ `derived` is the level under a flow: its rows are that flow's calls, read out of its graph
|
|
366
|
+
// (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
|
|
367
|
+
// dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
|
|
368
|
+
function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string>, derived: boolean) {
|
|
248
369
|
const isFolder = entry.kind === "folder";
|
|
249
|
-
|
|
250
|
-
|
|
370
|
+
// ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
|
|
371
|
+
// from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
|
|
372
|
+
// the answer to "what do I run, and how" (ADR-0004 §3).
|
|
373
|
+
const level: Level & { id: string } = { id: entry.id, type: isFolder ? "folder" : "flow" };
|
|
374
|
+
const expandable = isFolder || entry.type === "flow";
|
|
375
|
+
const isOpen =
|
|
376
|
+
expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
|
|
377
|
+
const Icon = isOpen && isFolder ? FolderOpen : icons[entry.kind];
|
|
251
378
|
const area = entry.type === "flow" ? "/flows" : "/knowledge";
|
|
252
379
|
const isActive = location.select === entry.id && location.pathname === area;
|
|
253
380
|
// A row's plus files into that row's place: inside a folder, beside anything else.
|
|
254
|
-
const target = isFolder
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
381
|
+
const target = isFolder ? entry.id : parentOf(entry);
|
|
382
|
+
// Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
|
|
383
|
+
// default cursor while a drag is in progress, which is the answer "not here" without a word.
|
|
384
|
+
const drop =
|
|
385
|
+
isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
|
|
386
|
+
const isDragged = dragged?.id === entry.id;
|
|
387
|
+
// While a drag is in progress every row says which of the three it is: the row being carried,
|
|
388
|
+
// a folder that would take it, or something that would not. Silence on the last two is what
|
|
389
|
+
// turns a drag into a guess that only the drop answers.
|
|
390
|
+
const highlight =
|
|
391
|
+
dragged === null || isDragged
|
|
392
|
+
? ""
|
|
393
|
+
: drop?.verdict === "ok"
|
|
394
|
+
? over === entry.id
|
|
395
|
+
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
396
|
+
: "ring-1 ring-sidebar-border"
|
|
397
|
+
: "opacity-40";
|
|
259
398
|
|
|
260
399
|
return (
|
|
261
400
|
<SidebarMenuItem key={entry.id} className="group/row">
|
|
262
|
-
|
|
263
|
-
|
|
401
|
+
{/* ⚠️ The colouring sits on the ROW, not on the button inside it — the difference the
|
|
402
|
+
ticket is about. It used to hang on `SidebarMenuButton`, whose siblings the plus and this
|
|
403
|
+
menu are, so the grey stopped short of them: two icons standing outside the very row they
|
|
404
|
+
operate, with a pale strip left over on the right. Taken from gate, where the same
|
|
405
|
+
mistake was made and unmade three times (`service-tree.tsx`, GATE-76/-78/-81) — there the
|
|
406
|
+
row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
|
|
407
|
+
The button keeps its own hover and active fill switched off rather than doubled, so the
|
|
408
|
+
row shows one surface instead of two overlapping ones. */}
|
|
409
|
+
<div
|
|
410
|
+
className={`flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
|
|
411
|
+
isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
|
|
412
|
+
}`}
|
|
413
|
+
>
|
|
414
|
+
{expandable ? (
|
|
264
415
|
<button
|
|
265
416
|
type="button"
|
|
266
417
|
aria-expanded={isOpen}
|
|
267
|
-
aria-label={i18n.t(
|
|
268
|
-
|
|
269
|
-
|
|
418
|
+
aria-label={i18n.t(
|
|
419
|
+
isFolder
|
|
420
|
+
? isOpen
|
|
421
|
+
? "tree.collapse"
|
|
422
|
+
: "tree.expand"
|
|
423
|
+
: isOpen
|
|
424
|
+
? "tree.collapseCalls"
|
|
425
|
+
: "tree.expandCalls",
|
|
426
|
+
{ title: entry.title },
|
|
427
|
+
)}
|
|
428
|
+
onClick={() => toggle(level, !isOpen)}
|
|
429
|
+
// The icons inside a coloured row need a step of their own, or their hover would be
|
|
430
|
+
// the row's colour on the row's colour and read as nothing at all.
|
|
431
|
+
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
432
|
>
|
|
271
433
|
<ChevronRight
|
|
272
434
|
aria-hidden="true"
|
|
@@ -276,9 +438,28 @@ export function AppTree() {
|
|
|
276
438
|
) : (
|
|
277
439
|
<span aria-hidden="true" className="size-5 shrink-0" />
|
|
278
440
|
)}
|
|
441
|
+
{/* The row's own control is what is grabbed and what is dropped on: a real button, so the
|
|
442
|
+
gesture rides on something that is already focusable and named rather than on a bare
|
|
443
|
+
div that assistive technology would have to be told about twice. */}
|
|
279
444
|
<SidebarMenuButton
|
|
280
445
|
isActive={isActive}
|
|
281
|
-
|
|
446
|
+
draggable={!derived}
|
|
447
|
+
onDragStart={(event) => {
|
|
448
|
+
event.dataTransfer.effectAllowed = "move";
|
|
449
|
+
event.dataTransfer.setData("text/plain", entry.id);
|
|
450
|
+
draggedRef.current = entry;
|
|
451
|
+
setDragged(entry);
|
|
452
|
+
}}
|
|
453
|
+
onDragEnd={() => {
|
|
454
|
+
draggedRef.current = null;
|
|
455
|
+
setDragged(null);
|
|
456
|
+
setOver(undefined);
|
|
457
|
+
}}
|
|
458
|
+
{...(drop?.props ?? {})}
|
|
459
|
+
data-drop={
|
|
460
|
+
dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
|
|
461
|
+
}
|
|
462
|
+
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
463
|
onClick={() => void navigate({ to: area, search: { select: entry.id } })}
|
|
283
464
|
>
|
|
284
465
|
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
|
@@ -294,35 +475,81 @@ export function AppTree() {
|
|
|
294
475
|
else setCreating({ parentId: target, kind });
|
|
295
476
|
}}
|
|
296
477
|
/>
|
|
478
|
+
{/* ⚠️ Nothing else. The three-dot menu stood here until #58 and does not any more: two
|
|
479
|
+
buttons per row put ten of them into a 240px column, and one then reads buttons instead
|
|
480
|
+
of titles — the tree is for reading. Every action it held is in the title line of the
|
|
481
|
+
thing itself, which every row opens by being clicked, including a folder, whose screen
|
|
482
|
+
is nothing but that line. Do not put a second one back here: the point of the menu is
|
|
483
|
+
that it is always in the same place, and two places are not one. */}
|
|
297
484
|
</div>
|
|
298
485
|
{/* 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.
|
|
486
|
+
fourth level is the same plus as on the first. The list is named after the row it hangs
|
|
487
|
+
under — indentation says whose contents these are to anyone who can see it, and this is
|
|
488
|
+
the same sentence for anyone who cannot. */}
|
|
300
489
|
{isOpen ? (
|
|
301
490
|
<div className="ml-3.5 border-l border-sidebar-border pl-1.5">
|
|
302
|
-
{renderLevel(
|
|
491
|
+
{renderLevel(
|
|
492
|
+
level,
|
|
493
|
+
new Set([...ancestors, entry.id]),
|
|
494
|
+
i18n.t(isFolder ? "tree.contents" : "tree.calls", { title: entry.title }),
|
|
495
|
+
)}
|
|
303
496
|
</div>
|
|
304
497
|
) : null}
|
|
305
498
|
</SidebarMenuItem>
|
|
306
499
|
);
|
|
307
500
|
}
|
|
308
501
|
|
|
502
|
+
const rootDrop = dropHandlers({ id: null, title: i18n.t("tree.move.root") }, new Set());
|
|
503
|
+
|
|
309
504
|
return (
|
|
310
505
|
<SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
|
|
506
|
+
{/* The root has no row to drop on, so while something is being carried it gets one. It is the
|
|
507
|
+
only way back out of a folder, and it appears exactly when it can be used. */}
|
|
508
|
+
{rootDrop.verdict === undefined || dragged === null ? null : (
|
|
509
|
+
<button
|
|
510
|
+
type="button"
|
|
511
|
+
{...rootDrop.props}
|
|
512
|
+
onClick={() => move.start(dragged, { id: null, title: i18n.t("tree.move.root") })}
|
|
513
|
+
data-drop={rootDrop.verdict}
|
|
514
|
+
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 ${
|
|
515
|
+
rootDrop.verdict === "ok"
|
|
516
|
+
? over === null
|
|
517
|
+
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
518
|
+
: "text-sidebar-foreground"
|
|
519
|
+
: "text-muted-foreground opacity-40"
|
|
520
|
+
}`}
|
|
521
|
+
>
|
|
522
|
+
<CornerLeftUp aria-hidden="true" className="size-4 shrink-0" />
|
|
523
|
+
{i18n.t("tree.move.dropRoot")}
|
|
524
|
+
</button>
|
|
525
|
+
)}
|
|
311
526
|
{/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
|
|
312
527
|
named for assistive technology instead. */}
|
|
313
|
-
{renderLevel(null, new Set(), i18n.t("tree.label"))}
|
|
528
|
+
{renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
|
|
314
529
|
{create.isError || upload.isError ? (
|
|
315
530
|
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
316
531
|
{upload.isError ? i18n.t("tree.uploadFailed") : i18n.t("tree.createFailed")}
|
|
317
532
|
</p>
|
|
318
533
|
) : null}
|
|
319
|
-
{/*
|
|
320
|
-
|
|
534
|
+
{/* Four refusals, four sentences — and by the time one is read the row is already back where
|
|
535
|
+
it started, because the rollback happens in `onError` rather than here. */}
|
|
536
|
+
{move.error ? (
|
|
537
|
+
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
538
|
+
{i18n.t(moveErrorKey(move.error))}
|
|
539
|
+
</p>
|
|
540
|
+
) : null}
|
|
541
|
+
{/* The root has no row of its own, so it gets one — otherwise a tree could only ever grow
|
|
542
|
+
inside the folders it already has, and the plus would stand loose under the list, attached
|
|
543
|
+
to nothing (#24). As a whole row it is also the one thing on screen when the tree is empty,
|
|
544
|
+
which is exactly when somebody needs to be told where to start. */}
|
|
321
545
|
{root?.isPending ? null : (
|
|
322
|
-
<div className="mt-1 flex items-center
|
|
546
|
+
<div className="mt-1 flex items-center gap-0.5">
|
|
547
|
+
{/* The gutter the disclosure arrows occupy, so the plus lines up with the row icons
|
|
548
|
+
above it rather than half a step to their left. */}
|
|
549
|
+
<span aria-hidden="true" className="size-5 shrink-0" />
|
|
323
550
|
<AddMenu
|
|
324
551
|
label={i18n.t("tree.addRoot")}
|
|
325
|
-
|
|
552
|
+
variant="row"
|
|
326
553
|
onSelect={(kind) => {
|
|
327
554
|
if (kind === "upload") startUpload(null);
|
|
328
555
|
else setCreating({ parentId: null, kind });
|
|
@@ -344,44 +571,51 @@ export function AppTree() {
|
|
|
344
571
|
{creating ? (
|
|
345
572
|
<Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
|
|
346
573
|
<CreateForm
|
|
574
|
+
kind={creating.kind}
|
|
347
575
|
pending={create.isPending}
|
|
348
|
-
submit={(title) => create.mutate({ ...creating, title })}
|
|
576
|
+
submit={(title, columns) => create.mutate({ ...creating, title, columns })}
|
|
349
577
|
/>
|
|
350
578
|
</Modal>
|
|
351
579
|
) : null}
|
|
580
|
+
{move.dialog}
|
|
352
581
|
</SidebarGroup>
|
|
353
582
|
);
|
|
354
583
|
}
|
|
355
584
|
|
|
356
585
|
// ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
|
|
357
586
|
// having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
|
|
587
|
+
//
|
|
588
|
+
// `row` is the root's form: a full-width row that says what it does, because the root has no title
|
|
589
|
+
// beside which a bare icon would mean anything.
|
|
358
590
|
function AddMenu({
|
|
359
591
|
label,
|
|
360
|
-
|
|
592
|
+
variant = "icon",
|
|
361
593
|
onSelect,
|
|
362
594
|
}: {
|
|
363
595
|
label: string;
|
|
364
|
-
|
|
596
|
+
variant?: "icon" | "row";
|
|
365
597
|
onSelect(kind: NewKind | "upload"): void;
|
|
366
598
|
}) {
|
|
367
599
|
const { i18n } = useIntelRouterContext();
|
|
368
600
|
const items: Array<{ kind: NewKind | "upload"; labelKey: string; Icon: typeof Plus }> = [
|
|
369
601
|
{ kind: "folder", labelKey: "tree.new.folder", Icon: Folder },
|
|
370
602
|
{ kind: "document", labelKey: "tree.new.document", Icon: FileText },
|
|
603
|
+
{ kind: "table", labelKey: "tree.new.table", Icon: Table },
|
|
371
604
|
{ kind: "upload", labelKey: "tree.new.upload", Icon: Upload },
|
|
372
605
|
{ kind: "flow", labelKey: "tree.new.flow", Icon: Workflow },
|
|
373
606
|
];
|
|
374
607
|
return (
|
|
375
608
|
<DropdownMenu>
|
|
376
609
|
<DropdownMenuTrigger
|
|
377
|
-
aria-label
|
|
378
|
-
className={
|
|
379
|
-
|
|
380
|
-
? ""
|
|
381
|
-
: "group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
382
|
-
}
|
|
610
|
+
{...(variant === "row" ? {} : { "aria-label": label })}
|
|
611
|
+
className={
|
|
612
|
+
variant === "row"
|
|
613
|
+
? "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"
|
|
614
|
+
: "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"
|
|
615
|
+
}
|
|
383
616
|
>
|
|
384
|
-
<Plus aria-hidden="true" className="size-4" />
|
|
617
|
+
<Plus aria-hidden="true" className="size-4 shrink-0" />
|
|
618
|
+
{variant === "row" ? <span className="truncate">{label}</span> : null}
|
|
385
619
|
</DropdownMenuTrigger>
|
|
386
620
|
<DropdownMenuContent align="start" className="w-44">
|
|
387
621
|
{items.map((item) => (
|
|
@@ -395,15 +629,35 @@ function AddMenu({
|
|
|
395
629
|
);
|
|
396
630
|
}
|
|
397
631
|
|
|
398
|
-
|
|
632
|
+
// The column names of a new table, as one line. A comma-separated list is what a CSV header is,
|
|
633
|
+
// so the field looks like the thing it produces rather than like a form that hides it.
|
|
634
|
+
function columnsOf(value: string): string[] {
|
|
635
|
+
return value
|
|
636
|
+
.split(",")
|
|
637
|
+
.map((column) => column.trim())
|
|
638
|
+
.filter(Boolean);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function CreateForm({
|
|
642
|
+
kind,
|
|
643
|
+
pending,
|
|
644
|
+
submit,
|
|
645
|
+
}: {
|
|
646
|
+
kind: NewKind;
|
|
647
|
+
pending: boolean;
|
|
648
|
+
submit(title: string, columns: string[]): void;
|
|
649
|
+
}) {
|
|
399
650
|
const { i18n } = useIntelRouterContext();
|
|
400
651
|
const [title, setTitle] = useState("");
|
|
652
|
+
const [columns, setColumns] = useState("");
|
|
653
|
+
const parsedColumns = columnsOf(columns);
|
|
654
|
+
const incomplete = title.trim().length === 0 || (kind === "table" && parsedColumns.length === 0);
|
|
401
655
|
return (
|
|
402
656
|
<form
|
|
403
657
|
className="space-y-4"
|
|
404
658
|
onSubmit={(event) => {
|
|
405
659
|
event.preventDefault();
|
|
406
|
-
if (!pending) submit(title.trim());
|
|
660
|
+
if (!pending && !incomplete) submit(title.trim(), parsedColumns);
|
|
407
661
|
}}
|
|
408
662
|
>
|
|
409
663
|
<label className="block text-sm font-medium">
|
|
@@ -415,9 +669,27 @@ function CreateForm({ pending, submit }: { pending: boolean; submit(title: strin
|
|
|
415
669
|
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
670
|
/>
|
|
417
671
|
</label>
|
|
672
|
+
{kind === "table" ? (
|
|
673
|
+
<label className="block text-sm font-medium">
|
|
674
|
+
{i18n.t("tree.table.columns")}
|
|
675
|
+
<input
|
|
676
|
+
required
|
|
677
|
+
value={columns}
|
|
678
|
+
onChange={(event) => setColumns(event.target.value)}
|
|
679
|
+
aria-describedby="create-table-columns-hint"
|
|
680
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
681
|
+
/>
|
|
682
|
+
<span
|
|
683
|
+
id="create-table-columns-hint"
|
|
684
|
+
className="mt-1 block text-xs font-normal text-muted-foreground"
|
|
685
|
+
>
|
|
686
|
+
{i18n.t("tree.table.columnsHint")}
|
|
687
|
+
</span>
|
|
688
|
+
</label>
|
|
689
|
+
) : null}
|
|
418
690
|
<button
|
|
419
691
|
type="submit"
|
|
420
|
-
disabled={pending ||
|
|
692
|
+
disabled={pending || incomplete}
|
|
421
693
|
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
694
|
>
|
|
423
695
|
{pending ? i18n.t("common.saving") : i18n.t("common.create")}
|