@anchrd/intel-ui 0.5.0 → 0.7.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/app/action-slot/action-slot.tsx +4 -0
- package/src/app/app-tree/app-tree.tsx +35 -122
- package/src/app/app.tsx +15 -4
- package/src/app/tree-move/tree-move.tsx +135 -1
- package/src/components/ui/table.tsx +82 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +19 -3
- package/src/data/intel-data-provider/intel-data-provider.types.ts +7 -1
- package/src/entry-picker/entry-picker.tsx +166 -0
- package/src/flows/flows.tsx +183 -183
- package/src/flows/node-icon/node-icon.ts +13 -7
- package/src/flows/node-palette/node-palette.tsx +63 -23
- package/src/folder-contents/folder-contents.tsx +106 -0
- package/src/i18n/en.json +36 -19
- package/src/knowledge/knowledge.tsx +47 -38
- package/src/knowledge-table/knowledge-table.tsx +23 -11
- package/src/resource-menu/resource-menu.tsx +110 -65
- package/src/title-row/title-row.tsx +49 -0
- package/src/tools/tools.tsx +57 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"typecheck": "tsc --noEmit"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@anchrd/intel-contract": "^0.
|
|
31
|
+
"@anchrd/intel-contract": "^0.3.0",
|
|
32
32
|
"@blocknote/core": "^0.52.1",
|
|
33
33
|
"@blocknote/react": "^0.52.1",
|
|
34
34
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -10,6 +10,10 @@ import { createPortal } from "react-dom";
|
|
|
10
10
|
// ⚠️ The slot lives above the component that fills it, so it is not in the document while that
|
|
11
11
|
// component first renders. Looking it up in an effect costs one extra render and is the only order
|
|
12
12
|
// that works; reading it during render finds nothing on the first paint.
|
|
13
|
+
//
|
|
14
|
+
// ⚠️ A portal only ever appends, so the slot decides where its content sits and mount order decides
|
|
15
|
+
// nothing. That is why the shell gives the screens a box of their own next to the search rather
|
|
16
|
+
// than one shared list (see `app.tsx`, #56).
|
|
13
17
|
export function ActionSlot({
|
|
14
18
|
name = "header-actions",
|
|
15
19
|
children,
|
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
import { useRef, useState } from "react";
|
|
16
16
|
import {
|
|
17
17
|
type MoveDestination,
|
|
18
|
-
MoveDialog,
|
|
19
18
|
moveErrorKey,
|
|
20
19
|
moveVerdict,
|
|
21
20
|
parentOf,
|
|
21
|
+
treeLevelKey,
|
|
22
|
+
useTreeMove,
|
|
22
23
|
} from "@/app/tree-move/tree-move.tsx";
|
|
23
24
|
import {
|
|
24
25
|
DropdownMenu,
|
|
@@ -36,7 +37,6 @@ import {
|
|
|
36
37
|
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
37
38
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
38
39
|
import { Modal } from "@/modal/modal.tsx";
|
|
39
|
-
import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
|
|
40
40
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
41
41
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
42
42
|
|
|
@@ -79,16 +79,13 @@ type Level = { id: string | null; type: "folder" | "flow" };
|
|
|
79
79
|
// One key per level. `null` is the root; every expanded row adds one of its own, and nothing else is
|
|
80
80
|
// ever asked for.
|
|
81
81
|
function levelKey(level: Level): readonly unknown[] {
|
|
82
|
-
return level.type === "flow" ? ["flow-calls", level.id] :
|
|
82
|
+
return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
? { ...entry, node: { ...entry.node, parentId } }
|
|
90
|
-
: { ...entry, flow: { ...entry.flow, parentId } };
|
|
91
|
-
}
|
|
85
|
+
// What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
|
|
86
|
+
// `text/plain`, so a drop target that means "move this row" and one that means "make a node for
|
|
87
|
+
// this" cannot be confused by the same payload.
|
|
88
|
+
export const TreeEntryMediaType = "application/x-intel-tree-entry";
|
|
92
89
|
|
|
93
90
|
export function AppTree() {
|
|
94
91
|
const { data, i18n } = useIntelRouterContext();
|
|
@@ -103,10 +100,14 @@ export function AppTree() {
|
|
|
103
100
|
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
104
101
|
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
105
102
|
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
// The drop's half of the move. The other half is the folder picker in the title line's menu, and
|
|
104
|
+
// both go through the same mutation (#58, `useTreeMove`) — the tree only adds what a tree can add,
|
|
105
|
+
// which is opening the folder the row just landed in.
|
|
106
|
+
const move = useTreeMove({
|
|
107
|
+
onMoved: (destination) => {
|
|
108
|
+
if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
110
111
|
|
|
111
112
|
const location = useRouterState({
|
|
112
113
|
select: (state) => ({
|
|
@@ -180,7 +181,6 @@ export function AppTree() {
|
|
|
180
181
|
kind,
|
|
181
182
|
title,
|
|
182
183
|
description: null,
|
|
183
|
-
contextPolicy: "relevant",
|
|
184
184
|
idempotencyKey: crypto.randomUUID(),
|
|
185
185
|
});
|
|
186
186
|
// ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
|
|
@@ -216,7 +216,6 @@ export function AppTree() {
|
|
|
216
216
|
kind: "attachment",
|
|
217
217
|
title: file.name,
|
|
218
218
|
description: null,
|
|
219
|
-
contextPolicy: "relevant",
|
|
220
219
|
idempotencyKey: crypto.randomUUID(),
|
|
221
220
|
});
|
|
222
221
|
try {
|
|
@@ -251,86 +250,6 @@ export function AppTree() {
|
|
|
251
250
|
},
|
|
252
251
|
});
|
|
253
252
|
|
|
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
253
|
// What a drop on this target would do. `undefined` means nothing is being dragged, so the target
|
|
335
254
|
// is not a target at all.
|
|
336
255
|
function verdictFor(
|
|
@@ -374,7 +293,7 @@ export function AppTree() {
|
|
|
374
293
|
setDragged(null);
|
|
375
294
|
draggedRef.current = null;
|
|
376
295
|
if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
|
|
377
|
-
|
|
296
|
+
move.start(carried, target);
|
|
378
297
|
}
|
|
379
298
|
},
|
|
380
299
|
},
|
|
@@ -529,8 +448,17 @@ export function AppTree() {
|
|
|
529
448
|
isActive={isActive}
|
|
530
449
|
draggable={!derived}
|
|
531
450
|
onDragStart={(event) => {
|
|
532
|
-
|
|
451
|
+
// ⚠️ Two formats, one gesture. `text/plain` is what a drop inside the tree reads —
|
|
452
|
+
// that is a MOVE, and it only ever needed the id. The flow canvas needs the kind as
|
|
453
|
+
// well, to know which node to make, and resolving an id there would mean a second
|
|
454
|
+
// read of something the drag already knows. `effectAllowed` says both are allowed;
|
|
455
|
+
// each drop target picks the one it means (#75).
|
|
456
|
+
event.dataTransfer.effectAllowed = "copyMove";
|
|
533
457
|
event.dataTransfer.setData("text/plain", entry.id);
|
|
458
|
+
event.dataTransfer.setData(
|
|
459
|
+
TreeEntryMediaType,
|
|
460
|
+
JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
|
|
461
|
+
);
|
|
534
462
|
draggedRef.current = entry;
|
|
535
463
|
setDragged(entry);
|
|
536
464
|
}}
|
|
@@ -559,17 +487,12 @@ export function AppTree() {
|
|
|
559
487
|
else setCreating({ parentId: target, kind });
|
|
560
488
|
}}
|
|
561
489
|
/>
|
|
562
|
-
{/* ⚠️
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
)}
|
|
490
|
+
{/* ⚠️ Nothing else. The three-dot menu stood here until #58 and does not any more: two
|
|
491
|
+
buttons per row put ten of them into a 240px column, and one then reads buttons instead
|
|
492
|
+
of titles — the tree is for reading. Every action it held is in the title line of the
|
|
493
|
+
thing itself, which every row opens by being clicked, including a folder, whose screen
|
|
494
|
+
is nothing but that line. Do not put a second one back here: the point of the menu is
|
|
495
|
+
that it is always in the same place, and two places are not one. */}
|
|
573
496
|
</div>
|
|
574
497
|
{/* The same rows one indent deeper: one row component for every depth, so the plus on the
|
|
575
498
|
fourth level is the same plus as on the first. The list is named after the row it hangs
|
|
@@ -598,7 +521,7 @@ export function AppTree() {
|
|
|
598
521
|
<button
|
|
599
522
|
type="button"
|
|
600
523
|
{...rootDrop.props}
|
|
601
|
-
onClick={() =>
|
|
524
|
+
onClick={() => move.start(dragged, { id: null, title: i18n.t("tree.move.root") })}
|
|
602
525
|
data-drop={rootDrop.verdict}
|
|
603
526
|
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
527
|
rootDrop.verdict === "ok"
|
|
@@ -622,7 +545,7 @@ export function AppTree() {
|
|
|
622
545
|
) : null}
|
|
623
546
|
{/* Four refusals, four sentences — and by the time one is read the row is already back where
|
|
624
547
|
it started, because the rollback happens in `onError` rather than here. */}
|
|
625
|
-
{move.
|
|
548
|
+
{move.error ? (
|
|
626
549
|
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
627
550
|
{i18n.t(moveErrorKey(move.error))}
|
|
628
551
|
</p>
|
|
@@ -666,17 +589,7 @@ export function AppTree() {
|
|
|
666
589
|
/>
|
|
667
590
|
</Modal>
|
|
668
591
|
) : null}
|
|
669
|
-
{
|
|
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}
|
|
592
|
+
{move.dialog}
|
|
680
593
|
</SidebarGroup>
|
|
681
594
|
);
|
|
682
595
|
}
|
package/src/app/app.tsx
CHANGED
|
@@ -91,10 +91,21 @@ export function App() {
|
|
|
91
91
|
) : null}
|
|
92
92
|
</BreadcrumbList>
|
|
93
93
|
</Breadcrumb>
|
|
94
|
-
{/* The bar for area-owned actions. A screen renders into
|
|
95
|
-
search is the shell's own, because it has to open from every screen
|
|
96
|
-
to none of them. It stays
|
|
97
|
-
|
|
94
|
+
{/* The bar for area-owned actions. A screen renders into the inner box through
|
|
95
|
+
`ActionSlot`; search is the shell's own, because it has to open from every screen
|
|
96
|
+
alike and belongs to none of them. It stays last, against the right edge, and a
|
|
97
|
+
screen's actions line up to its left: the search is the one thing in this bar that is
|
|
98
|
+
on every screen, so it is the one whose place must not depend on what a screen happens
|
|
99
|
+
to bring. Pinned the other way round it was the constant that moved (#56).
|
|
100
|
+
|
|
101
|
+
⚠️ Two boxes rather than one list, and that is the whole of the mechanism: a portal
|
|
102
|
+
only ever appends to its container, so with the search a sibling of the portalled
|
|
103
|
+
actions a screen that mounts them later — Flows does, once a flow is selected — would
|
|
104
|
+
land to its right. Giving the screens a container of their own takes mount order out
|
|
105
|
+
of the question entirely. `data-slot="header-actions"` therefore names the inner box:
|
|
106
|
+
it is the name every `ActionSlot` looks up. */}
|
|
107
|
+
<div data-slot="header-bar" className="ml-auto flex items-center gap-2">
|
|
108
|
+
<div data-slot="header-actions" className="flex items-center gap-2" />
|
|
98
109
|
<HeaderSearch />
|
|
99
110
|
</div>
|
|
100
111
|
</header>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useQuery } from "@tanstack/react-query";
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import { ChevronLeft, Folder } from "lucide-react";
|
|
3
|
+
import type * as React from "react";
|
|
3
4
|
import { useState } from "react";
|
|
4
5
|
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
5
6
|
import { Modal } from "@/modal/modal.tsx";
|
|
@@ -17,6 +18,20 @@ export function parentOf(entry: TreeEntry): string | null {
|
|
|
17
18
|
return entry.type === "knowledge" ? entry.node.parentId : entry.flow.parentId;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// One level of the shared tree, as a query key. The move writes into two of them and the picker
|
|
22
|
+
// reads a third, so the shape is stated once rather than spelled out at each of those places.
|
|
23
|
+
export function treeLevelKey(parentId: string | null): readonly unknown[] {
|
|
24
|
+
return ["tree", parentId];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
|
|
28
|
+
// plus on the moved row would still file into the folder it just left.
|
|
29
|
+
function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
30
|
+
return entry.type === "knowledge"
|
|
31
|
+
? { ...entry, node: { ...entry.node, parentId } }
|
|
32
|
+
: { ...entry, flow: { ...entry.flow, parentId } };
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* ⚠️ The trap this ticket is built around: a node must not travel into its own descendants. The
|
|
22
37
|
* service refuses it (`move_cycle`, `knowledge.ts:421`), but a refusal that only arrives once the
|
|
@@ -195,3 +210,122 @@ export function MoveDialog({
|
|
|
195
210
|
</Modal>
|
|
196
211
|
);
|
|
197
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Moving a row, wherever it is asked for.
|
|
216
|
+
*
|
|
217
|
+
* ⚠️ It is asked for from two places now (#58): a drop on a folder in the tree, and the menu in the
|
|
218
|
+
* title line — the keyboard route, which since #58 is the only one the menu still has anywhere. Both
|
|
219
|
+
* are the same move, so both take it from here rather than each carrying its own mutation. Two
|
|
220
|
+
* copies of an optimistic update that rewrites two cached levels is how a rejected move ends up
|
|
221
|
+
* leaving a row standing twice.
|
|
222
|
+
*
|
|
223
|
+
* ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the other
|
|
224
|
+
* before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
|
|
225
|
+
* `update_conflict` — puts both levels back exactly as they were and then re-reads them.
|
|
226
|
+
*
|
|
227
|
+
* The caller renders `dialog` where it likes and words `error` itself with `moveErrorKey`: the tree
|
|
228
|
+
* says it in the sidebar, the menu over the screen, and neither position belongs to the move.
|
|
229
|
+
*/
|
|
230
|
+
export function useTreeMove({
|
|
231
|
+
onMoved,
|
|
232
|
+
}: {
|
|
233
|
+
// What the caller wants to do with the destination once the move is through — the tree opens that
|
|
234
|
+
// folder so the row is where the eye follows it. Nobody else has a tree to open.
|
|
235
|
+
onMoved?: ((destination: MoveDestination) => void) | undefined;
|
|
236
|
+
} = {}): {
|
|
237
|
+
start(entry: TreeEntry, destination: MoveDestination | null): void;
|
|
238
|
+
error: unknown;
|
|
239
|
+
dialog: React.ReactNode;
|
|
240
|
+
} {
|
|
241
|
+
const { data } = useIntelRouterContext();
|
|
242
|
+
const queryClient = useQueryClient();
|
|
243
|
+
const [moving, setMoving] = useState<{
|
|
244
|
+
entry: TreeEntry;
|
|
245
|
+
initial: MoveDestination | null;
|
|
246
|
+
} | null>(null);
|
|
247
|
+
|
|
248
|
+
const move = useMutation({
|
|
249
|
+
mutationFn: async ({
|
|
250
|
+
entry,
|
|
251
|
+
destination,
|
|
252
|
+
}: {
|
|
253
|
+
entry: TreeEntry;
|
|
254
|
+
destination: MoveDestination;
|
|
255
|
+
}) => {
|
|
256
|
+
// `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
|
|
257
|
+
// view can act on instead of an overwrite nobody notices.
|
|
258
|
+
if (entry.type === "flow") {
|
|
259
|
+
await data.updateFlow({
|
|
260
|
+
flowId: entry.id,
|
|
261
|
+
baseUpdatedAt: entry.flow.updatedAt,
|
|
262
|
+
parentId: destination.id,
|
|
263
|
+
idempotencyKey: crypto.randomUUID(),
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
await data.updateKnowledge({
|
|
267
|
+
nodeId: entry.id,
|
|
268
|
+
baseUpdatedAt: entry.node.updatedAt,
|
|
269
|
+
parentId: destination.id,
|
|
270
|
+
idempotencyKey: crypto.randomUUID(),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
onMutate: async ({ entry, destination }) => {
|
|
275
|
+
const fromKey = treeLevelKey(parentOf(entry));
|
|
276
|
+
const toKey = treeLevelKey(destination.id);
|
|
277
|
+
await Promise.all([
|
|
278
|
+
queryClient.cancelQueries({ queryKey: fromKey }),
|
|
279
|
+
queryClient.cancelQueries({ queryKey: toKey }),
|
|
280
|
+
]);
|
|
281
|
+
const snapshot = [
|
|
282
|
+
[fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
|
|
283
|
+
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
|
|
284
|
+
] as const;
|
|
285
|
+
queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
|
|
286
|
+
current?.filter((row) => row.id !== entry.id),
|
|
287
|
+
);
|
|
288
|
+
// A level nobody has opened stays unloaded: writing one here would show a folder's contents
|
|
289
|
+
// that were never read.
|
|
290
|
+
queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
|
|
291
|
+
current === undefined
|
|
292
|
+
? current
|
|
293
|
+
: [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
|
|
294
|
+
);
|
|
295
|
+
return { snapshot };
|
|
296
|
+
},
|
|
297
|
+
onError: (_error, _variables, context) => {
|
|
298
|
+
for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
|
|
299
|
+
},
|
|
300
|
+
onSuccess: (_result, { destination }) => onMoved?.(destination),
|
|
301
|
+
onSettled: async (_result, _error, { entry, destination }) => {
|
|
302
|
+
await Promise.all([
|
|
303
|
+
queryClient.invalidateQueries({ queryKey: treeLevelKey(parentOf(entry)) }),
|
|
304
|
+
queryClient.invalidateQueries({ queryKey: treeLevelKey(destination.id) }),
|
|
305
|
+
queryClient.invalidateQueries({
|
|
306
|
+
queryKey: [entry.type === "flow" ? "flows" : "knowledge-graph"],
|
|
307
|
+
}),
|
|
308
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
309
|
+
]);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
start(entry, destination) {
|
|
315
|
+
move.reset();
|
|
316
|
+
setMoving({ entry, initial: destination });
|
|
317
|
+
},
|
|
318
|
+
error: move.isError ? move.error : null,
|
|
319
|
+
dialog: moving ? (
|
|
320
|
+
<MoveDialog
|
|
321
|
+
entry={moving.entry}
|
|
322
|
+
initial={moving.initial}
|
|
323
|
+
close={() => setMoving(null)}
|
|
324
|
+
submit={(destination) => {
|
|
325
|
+
setMoving(null);
|
|
326
|
+
move.mutate({ entry: moving.entry, destination });
|
|
327
|
+
}}
|
|
328
|
+
/>
|
|
329
|
+
) : null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils.ts";
|
|
3
|
+
|
|
4
|
+
// The shadcn table primitive. A real `<table>` and not a grid of divs: the row and column
|
|
5
|
+
// relationships are what a screen reader reads out, and no `role` patch on a div reproduces them
|
|
6
|
+
// as reliably as the element that means it.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ The horizontal scroll lives on the wrapper, not on the page. A wide table has to be able to
|
|
9
|
+
// scroll inside its own box, or the whole layout scrolls sideways with it.
|
|
10
|
+
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|
11
|
+
return (
|
|
12
|
+
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
|
13
|
+
<table
|
|
14
|
+
data-slot="table"
|
|
15
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
23
|
+
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|
27
|
+
return (
|
|
28
|
+
<tbody
|
|
29
|
+
data-slot="table-body"
|
|
30
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|
37
|
+
return (
|
|
38
|
+
<tr
|
|
39
|
+
data-slot="table-row"
|
|
40
|
+
className={cn(
|
|
41
|
+
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|
50
|
+
return (
|
|
51
|
+
<th
|
|
52
|
+
data-slot="table-head"
|
|
53
|
+
className={cn(
|
|
54
|
+
"h-10 px-2 text-left align-middle font-medium text-muted-foreground whitespace-nowrap",
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|
63
|
+
return (
|
|
64
|
+
<td
|
|
65
|
+
data-slot="table-cell"
|
|
66
|
+
className={cn("p-2 align-middle whitespace-nowrap", className)}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
|
73
|
+
return (
|
|
74
|
+
<caption
|
|
75
|
+
data-slot="table-caption"
|
|
76
|
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow };
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
FlowRunHistory,
|
|
15
15
|
FlowRunList,
|
|
16
16
|
FlowRunStep,
|
|
17
|
+
FlowValidation,
|
|
17
18
|
KnowledgeDocument,
|
|
18
19
|
KnowledgeGraph,
|
|
19
20
|
KnowledgeLinkList,
|
|
@@ -84,10 +85,20 @@ export function flowEntry(flow: Flow): TreeEntry {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
export function createIntelDataProvider(
|
|
87
|
-
deps: {
|
|
88
|
+
deps: {
|
|
89
|
+
fetch?: typeof fetch;
|
|
90
|
+
baseUrl?: string;
|
|
91
|
+
onUnauthorized?(): void;
|
|
92
|
+
navigate?(url: string): void;
|
|
93
|
+
} = {},
|
|
88
94
|
): IntelDataProvider {
|
|
89
95
|
const doFetch = deps.fetch ?? fetch;
|
|
90
96
|
const baseUrl = deps.baseUrl?.replace(/\/$/, "") ?? "";
|
|
97
|
+
const navigate =
|
|
98
|
+
deps.navigate ??
|
|
99
|
+
((url: string) => {
|
|
100
|
+
if (typeof window !== "undefined") window.location.assign(url);
|
|
101
|
+
});
|
|
91
102
|
const unauthorized =
|
|
92
103
|
deps.onUnauthorized ??
|
|
93
104
|
(() => {
|
|
@@ -306,6 +317,9 @@ export function createIntelDataProvider(
|
|
|
306
317
|
async getFlow(flowId) {
|
|
307
318
|
return await request(`/flows/${encodeURIComponent(flowId)}`, FlowDocument);
|
|
308
319
|
},
|
|
320
|
+
async validateFlow(flowId) {
|
|
321
|
+
return await request(`/flows/${encodeURIComponent(flowId)}/validation`, FlowValidation);
|
|
322
|
+
},
|
|
309
323
|
async listFlowCalls(flowId) {
|
|
310
324
|
return await request(`/flows/${encodeURIComponent(flowId)}/calls`, FlowList);
|
|
311
325
|
},
|
|
@@ -400,8 +414,10 @@ export function createIntelDataProvider(
|
|
|
400
414
|
return await request("/tools", ToolCatalog);
|
|
401
415
|
},
|
|
402
416
|
|
|
403
|
-
|
|
404
|
-
|
|
417
|
+
// The Worker serves the connect route, so this navigation belongs to the data layer for the
|
|
418
|
+
// same reason `loginPath` does. `silent=1` is what makes it a redirect nobody has to watch.
|
|
419
|
+
startPortalSignIn(returnTo = "/tools") {
|
|
420
|
+
navigate(`${baseUrl}/auth/connect?returnTo=${encodeURIComponent(returnTo)}&silent=1`);
|
|
405
421
|
},
|
|
406
422
|
async logout() {
|
|
407
423
|
const response = await doFetch(`${baseUrl}/auth/logout`, {
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
FlowRunHistory,
|
|
15
15
|
FlowRunList,
|
|
16
16
|
FlowRunStep,
|
|
17
|
+
FlowValidation,
|
|
17
18
|
KnowledgeDocument,
|
|
18
19
|
KnowledgeGraph,
|
|
19
20
|
KnowledgeLinkList,
|
|
@@ -77,7 +78,10 @@ export interface IntelDataProvider {
|
|
|
77
78
|
appendKnowledgeTableRows(
|
|
78
79
|
input: AppendKnowledgeTableRowsInput,
|
|
79
80
|
): Promise<AppendKnowledgeTableRowsResult>;
|
|
80
|
-
|
|
81
|
+
// Walks the browser through the portal's OAuth flow without asking anybody anything: Gate is the
|
|
82
|
+
// identity provider Cloudflare Access consumes, so a signed-in person is already known there
|
|
83
|
+
// (#60). It navigates away — the caller renders no button for it and gets no answer back.
|
|
84
|
+
startPortalSignIn(returnTo?: string): void;
|
|
81
85
|
listKnowledgeVersions(nodeId: string): Promise<KnowledgeVersionList>;
|
|
82
86
|
updateKnowledge(input: UpdateKnowledgeNodeInput): Promise<KnowledgeNode>;
|
|
83
87
|
archiveKnowledge(input: ArchiveKnowledgeNodeInput): Promise<KnowledgeNode>;
|
|
@@ -92,6 +96,8 @@ export interface IntelDataProvider {
|
|
|
92
96
|
// What a flow calls, read out of its graph. It answers a different question from `listTreeChildren`
|
|
93
97
|
// and deliberately gives a different answer: a shared sub-flow is listed under every caller.
|
|
94
98
|
listFlowCalls(flowId: string): Promise<FlowList>;
|
|
99
|
+
// Would this flow start for me, right now. A question, not an action: nothing is created (#72).
|
|
100
|
+
validateFlow(flowId: string): Promise<FlowValidation>;
|
|
95
101
|
// What accesses what, for one level of the shared tree: a folder for its contents, a flow for
|
|
96
102
|
// itself. ⚠️ Only nodes the signed-in user may see come back, and one they may not is absent
|
|
97
103
|
// altogether — never a placeholder, because the edge into one would already say that it exists.
|