@anchrd/intel-ui 0.3.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/components.json +7 -1
- package/package.json +3 -1
- package/src/app/action-slot/action-slot.tsx +23 -0
- package/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/src/app/app-tree/app-tree.tsx +798 -0
- package/src/app/app.tsx +99 -54
- package/src/app/header-search/header-search.tsx +291 -0
- package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/src/app/tree-move/tree-move.tsx +197 -0
- package/src/app/user-footer/user-footer.tsx +103 -0
- 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/breadcrumb.tsx +102 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/collapsible.tsx +20 -0
- package/src/components/ui/command.tsx +160 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/dropdown-menu.tsx +162 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +136 -0
- package/src/components/ui/sidebar.tsx +693 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +170 -85
- package/src/data/intel-data-provider/intel-data-provider.types.ts +64 -20
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +684 -368
- 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/hooks/use-mobile.ts +19 -0
- package/src/i18n/en.json +188 -52
- package/src/knowledge/knowledge.tsx +133 -709
- 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/router.tsx +4 -0
- package/src/router/selection-search.ts +43 -0
- package/src/save-button/save-button.tsx +103 -0
- package/src/styles.css +91 -51
- package/src/theme/theme.ts +24 -0
- package/src/tools/tools.tsx +175 -158
package/src/flows/flows.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { FlowGraph, FlowNode,
|
|
1
|
+
import type { Flow, FlowGraph, FlowNode, KnowledgeNode } from "@anchrd/intel-contract";
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
3
4
|
import {
|
|
4
5
|
addEdge,
|
|
5
6
|
applyEdgeChanges,
|
|
@@ -17,60 +18,82 @@ import {
|
|
|
17
18
|
Position,
|
|
18
19
|
ReactFlow,
|
|
19
20
|
} from "@xyflow/react";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
Share2,
|
|
30
|
-
ShieldCheck,
|
|
31
|
-
Sparkles,
|
|
32
|
-
Trash2,
|
|
33
|
-
Wrench,
|
|
34
|
-
} from "lucide-react";
|
|
35
|
-
import { useEffect, useMemo, useState } from "react";
|
|
36
|
-
import type { KnowledgeTreeNode } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
21
|
+
import { FileSearch, Info, Send, Wrench } from "lucide-react";
|
|
22
|
+
import { useEffect, useId, useMemo, useState } from "react";
|
|
23
|
+
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
24
|
+
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
25
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
26
|
+
import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
|
|
27
|
+
import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
|
|
28
|
+
import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
|
|
29
|
+
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
37
30
|
import { Modal } from "@/modal/modal.tsx";
|
|
31
|
+
import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
|
|
38
32
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
33
|
+
import { selectedFrom, viewFrom } from "@/router/selection-search.ts";
|
|
34
|
+
import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
|
|
35
|
+
import { useSystemTheme } from "@/theme/theme.ts";
|
|
39
36
|
|
|
40
37
|
type CanvasNode = Node<{ node: FlowNode }, "intel">;
|
|
41
38
|
type CanvasEdge = Edge;
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
// One handle id at both ends of a context edge. React Flow reports the handles a connection was
|
|
41
|
+
// drawn between, and that is the only place the two meanings can still be told apart — the contract
|
|
42
|
+
// stores the meaning itself, not which dot it was dragged from.
|
|
43
|
+
const ContextHandle = "context";
|
|
44
|
+
|
|
45
|
+
// The order the bar offers them in: roughly the order a flow is built, from the start to the result.
|
|
46
|
+
// ⚠️ The start is in the bar even though every graph opens with one, because it can be deleted —
|
|
47
|
+
// without an entry there would be no way back to a flow that has a beginning.
|
|
48
|
+
const paletteKinds = [
|
|
49
|
+
"trigger",
|
|
50
|
+
"instruction",
|
|
51
|
+
"knowledge",
|
|
52
|
+
"tool",
|
|
53
|
+
"condition",
|
|
54
|
+
"approval",
|
|
55
|
+
"subflow",
|
|
56
|
+
"output",
|
|
57
|
+
] as const;
|
|
52
58
|
|
|
53
59
|
function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
60
|
+
const { i18n } = useIntelRouterContext();
|
|
54
61
|
const contract = data.node;
|
|
55
62
|
const Icon = nodeIcon[contract.kind];
|
|
56
63
|
const branching = contract.kind === "condition" || contract.kind === "approval";
|
|
64
|
+
// ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
|
|
65
|
+
// token. Shape survives zooming out past the point where the label is legible, and it is the only
|
|
66
|
+
// one of the three that also carries into the overview map, where nothing is written at all.
|
|
67
|
+
const start = contract.kind === "trigger";
|
|
68
|
+
// ⚠️ A call that rides along says so on the call itself (ADR-0004 §5). A version choice that only
|
|
69
|
+
// showed up in the inspector would be invisible on exactly the screen people read a flow from,
|
|
70
|
+
// and "why did this flow change" would have no answer on the canvas.
|
|
71
|
+
const riding = contract.kind === "subflow" ? contract.configuration.version.mode : null;
|
|
57
72
|
return (
|
|
58
73
|
<div
|
|
59
|
-
className={`min-w-48
|
|
74
|
+
className={`min-w-48 border bg-card p-3 text-card-foreground shadow-sm ${start ? "rounded-full border-primary bg-primary/5 px-4" : "rounded-xl"} ${selected ? "ring-2 ring-ring" : ""}`}
|
|
60
75
|
>
|
|
61
76
|
{contract.kind !== "trigger" && <Handle type="target" position={Position.Left} />}
|
|
62
77
|
<div className="flex items-center gap-2">
|
|
63
|
-
<span
|
|
78
|
+
<span
|
|
79
|
+
className={`grid size-8 shrink-0 place-items-center bg-primary/10 text-primary ${start ? "rounded-full bg-primary text-primary-foreground" : "rounded-lg"}`}
|
|
80
|
+
>
|
|
64
81
|
<Icon aria-hidden="true" className="size-4" />
|
|
65
82
|
</span>
|
|
66
83
|
<span className="min-w-0">
|
|
67
84
|
<span className="block truncate text-sm font-medium">{contract.label}</span>
|
|
68
|
-
|
|
85
|
+
{/* The kind through the catalog, not the raw key: the start node is called Start on the
|
|
86
|
+
canvas as well, and `trigger` would name an event that no longer exists (#39). */}
|
|
87
|
+
<span className="block text-xs text-muted-foreground">
|
|
88
|
+
{i18n.t(`flows.node.${contract.kind}`)}
|
|
89
|
+
</span>
|
|
69
90
|
</span>
|
|
91
|
+
{riding === "follows" || riding === "pinned" ? (
|
|
92
|
+
<span className="ml-auto shrink-0 rounded-full border px-2 py-0.5 text-[0.625rem] uppercase tracking-wide text-muted-foreground">
|
|
93
|
+
{i18n.t(`flows.subflowBadge.${riding}`)}
|
|
94
|
+
</span>
|
|
95
|
+
) : null}
|
|
70
96
|
</div>
|
|
71
|
-
{contract.description && (
|
|
72
|
-
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{contract.description}</p>
|
|
73
|
-
)}
|
|
74
97
|
{contract.kind !== "output" && !branching && (
|
|
75
98
|
<Handle type="source" position={Position.Right} />
|
|
76
99
|
)}
|
|
@@ -90,6 +113,15 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
|
90
113
|
/>
|
|
91
114
|
</>
|
|
92
115
|
)}
|
|
116
|
+
{/* The context point (#37). Sideways is the order of work, downwards is what a step works
|
|
117
|
+
with — including at the output, where the same edge reads as where the result goes. Both
|
|
118
|
+
ends sit on the vertical axis so an attachment hangs under its step and the two meanings
|
|
119
|
+
are told apart by direction as well as by the dashed line. The start has no top point:
|
|
120
|
+
the beginning of a flow is not material another step works with. */}
|
|
121
|
+
<Handle id={ContextHandle} type="source" position={Position.Bottom} />
|
|
122
|
+
{contract.kind !== "trigger" && (
|
|
123
|
+
<Handle id={ContextHandle} type="target" position={Position.Top} />
|
|
124
|
+
)}
|
|
93
125
|
</div>
|
|
94
126
|
);
|
|
95
127
|
}
|
|
@@ -103,7 +135,6 @@ function defaultGraph(): FlowGraph {
|
|
|
103
135
|
id: "trigger",
|
|
104
136
|
kind: "trigger",
|
|
105
137
|
label: "Manual start",
|
|
106
|
-
description: null,
|
|
107
138
|
position: { x: 80, y: 180 },
|
|
108
139
|
configuration: { mode: "manual" },
|
|
109
140
|
},
|
|
@@ -111,7 +142,6 @@ function defaultGraph(): FlowGraph {
|
|
|
111
142
|
id: "output",
|
|
112
143
|
kind: "output",
|
|
113
144
|
label: "Result",
|
|
114
|
-
description: null,
|
|
115
145
|
position: { x: 520, y: 180 },
|
|
116
146
|
configuration: { template: "" },
|
|
117
147
|
},
|
|
@@ -121,6 +151,7 @@ function defaultGraph(): FlowGraph {
|
|
|
121
151
|
id: "trigger-output",
|
|
122
152
|
source: "trigger",
|
|
123
153
|
target: "output",
|
|
154
|
+
kind: "flow",
|
|
124
155
|
label: null,
|
|
125
156
|
sourceHandle: null,
|
|
126
157
|
},
|
|
@@ -136,13 +167,31 @@ function canvas(graph: FlowGraph) {
|
|
|
136
167
|
position: node.position,
|
|
137
168
|
data: { node },
|
|
138
169
|
})),
|
|
139
|
-
edges: graph.edges.map((edge) =>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
170
|
+
edges: graph.edges.map((edge) => {
|
|
171
|
+
const context = edge.kind === "context";
|
|
172
|
+
return {
|
|
173
|
+
id: edge.id,
|
|
174
|
+
source: edge.source,
|
|
175
|
+
target: edge.target,
|
|
176
|
+
// The handle ids are put back from the stored meaning, so a context edge lands on the
|
|
177
|
+
// vertical pair again and a flow edge keeps the branch handle it was saved with.
|
|
178
|
+
sourceHandle: context ? ContextHandle : edge.sourceHandle,
|
|
179
|
+
targetHandle: context ? ContextHandle : null,
|
|
180
|
+
label: edge.label ?? undefined,
|
|
181
|
+
...contextEdgeStyle(context),
|
|
182
|
+
};
|
|
183
|
+
}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Dashed and unarrowed, in the muted token rather than the colour of a flow edge: the reader has to
|
|
188
|
+
// be able to separate the procedure from the material without being told which is which.
|
|
189
|
+
function contextEdgeStyle(context: boolean): Partial<CanvasEdge> {
|
|
190
|
+
if (!context) return {};
|
|
191
|
+
return {
|
|
192
|
+
className: "intel-context-edge",
|
|
193
|
+
style: { strokeDasharray: "6 4" },
|
|
194
|
+
animated: false,
|
|
146
195
|
};
|
|
147
196
|
}
|
|
148
197
|
|
|
@@ -152,31 +201,58 @@ function graph(nodes: CanvasNode[], edges: CanvasEdge[]): FlowGraph {
|
|
|
152
201
|
...node.data.node,
|
|
153
202
|
position: node.position,
|
|
154
203
|
})),
|
|
155
|
-
edges: edges.map((edge) =>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
204
|
+
edges: edges.map((edge) => {
|
|
205
|
+
const context = edge.sourceHandle === ContextHandle;
|
|
206
|
+
return {
|
|
207
|
+
id: edge.id,
|
|
208
|
+
source: edge.source,
|
|
209
|
+
target: edge.target,
|
|
210
|
+
kind: context ? ("context" as const) : ("flow" as const),
|
|
211
|
+
label: typeof edge.label === "string" && edge.label ? edge.label : null,
|
|
212
|
+
// A context edge carries no branch: the handle it was drawn from is a fact about the canvas,
|
|
213
|
+
// and storing it would make `compileFlow` read a branch where there is none.
|
|
214
|
+
sourceHandle: context ? null : (edge.sourceHandle ?? null),
|
|
215
|
+
};
|
|
216
|
+
}),
|
|
162
217
|
};
|
|
163
218
|
}
|
|
164
219
|
|
|
220
|
+
// ⚠️ Not every change React Flow reports is an edit. It also announces selection and the sizes it
|
|
221
|
+
// has just measured, and both arrive unasked on the first paint — a flow would be "unsaved" the
|
|
222
|
+
// moment it was opened, and the one signal that means something would mean nothing.
|
|
223
|
+
function editsNodes(changes: NodeChange<CanvasNode>[]): boolean {
|
|
224
|
+
return changes.some(
|
|
225
|
+
(change) =>
|
|
226
|
+
change.type === "add" ||
|
|
227
|
+
change.type === "remove" ||
|
|
228
|
+
change.type === "replace" ||
|
|
229
|
+
(change.type === "position" && change.dragging === true),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function editsEdges(changes: EdgeChange<CanvasEdge>[]): boolean {
|
|
234
|
+
return changes.some(
|
|
235
|
+
(change) => change.type === "add" || change.type === "remove" || change.type === "replace",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
165
239
|
function newNode(
|
|
166
|
-
kind:
|
|
240
|
+
kind: FlowNode["kind"],
|
|
167
241
|
index: number,
|
|
168
242
|
firstKnowledgeId?: string,
|
|
243
|
+
firstFlowId?: string,
|
|
169
244
|
): FlowNode {
|
|
170
245
|
const common = {
|
|
171
246
|
id: `${kind}-${crypto.randomUUID()}`,
|
|
172
247
|
label: kind[0]?.toUpperCase() + kind.slice(1),
|
|
173
|
-
description: null,
|
|
174
248
|
position: {
|
|
175
249
|
x: 260 + (index % 3) * 240,
|
|
176
250
|
y: 80 + Math.floor(index / 3) * 180,
|
|
177
251
|
},
|
|
178
252
|
};
|
|
179
253
|
switch (kind) {
|
|
254
|
+
case "trigger":
|
|
255
|
+
return { ...common, kind, label: "Manual start", configuration: { mode: "manual" } };
|
|
180
256
|
case "instruction":
|
|
181
257
|
return {
|
|
182
258
|
...common,
|
|
@@ -221,6 +297,14 @@ function newNode(
|
|
|
221
297
|
timeout: "7 days",
|
|
222
298
|
},
|
|
223
299
|
};
|
|
300
|
+
case "subflow":
|
|
301
|
+
// `latest` while drafting: nobody should have to version things while building, and
|
|
302
|
+
// publishing turns it into the concrete version (ADR-0004 §5).
|
|
303
|
+
return {
|
|
304
|
+
...common,
|
|
305
|
+
kind,
|
|
306
|
+
configuration: { flowId: firstFlowId ?? "", version: { mode: "latest" } },
|
|
307
|
+
};
|
|
224
308
|
case "output":
|
|
225
309
|
return { ...common, kind, configuration: { template: "" } };
|
|
226
310
|
}
|
|
@@ -228,23 +312,49 @@ function newNode(
|
|
|
228
312
|
|
|
229
313
|
export function Flows() {
|
|
230
314
|
const { data, i18n } = useIntelRouterContext();
|
|
315
|
+
const theme = useSystemTheme();
|
|
231
316
|
const queryClient = useQueryClient();
|
|
232
|
-
const
|
|
233
|
-
queryKey: ["flows"],
|
|
234
|
-
queryFn: () => data.listFlows(),
|
|
235
|
-
});
|
|
317
|
+
const navigate = useNavigate();
|
|
236
318
|
const tools = useQuery({
|
|
237
319
|
queryKey: ["tools"],
|
|
238
320
|
queryFn: () => data.listTools(),
|
|
239
321
|
});
|
|
322
|
+
// A knowledge step points at nodes, so the editor needs candidates. The graph is the authorized
|
|
323
|
+
// flat list of them; walking the folder tree for a picker would be the N+1 all over again.
|
|
240
324
|
const knowledge = useQuery({
|
|
241
|
-
queryKey: ["knowledge-
|
|
242
|
-
queryFn: () => data.
|
|
325
|
+
queryKey: ["knowledge-graph"],
|
|
326
|
+
queryFn: () => data.getKnowledgeGraph(),
|
|
327
|
+
});
|
|
328
|
+
// The candidates for a subflow step. Which of them a flow may actually call is answered when it is
|
|
329
|
+
// published (ADR-0004 §3) — the picker offers what the user may see, and the refusal names the
|
|
330
|
+
// reason rather than hiding the option.
|
|
331
|
+
const callable = useQuery({
|
|
332
|
+
queryKey: ["flows"],
|
|
333
|
+
queryFn: () => data.listFlows(),
|
|
334
|
+
});
|
|
335
|
+
// The tree in the sidebar and the header search both name the flow to open through `?select=`;
|
|
336
|
+
// `?view=` says how it is shown — the editor, the relation graph (#19) or the runs (#35).
|
|
337
|
+
const selection = useRouterState({
|
|
338
|
+
select: (state) => ({
|
|
339
|
+
id: selectedFrom(state.location.search),
|
|
340
|
+
view: viewFrom(state.location.search),
|
|
341
|
+
}),
|
|
342
|
+
});
|
|
343
|
+
const selectedFlowId = selection.id;
|
|
344
|
+
// A single flow answers the relation question for itself: what it reads and what it calls.
|
|
345
|
+
const relations = useQuery({
|
|
346
|
+
queryKey: ["relation-graph", "flow", selectedFlowId],
|
|
347
|
+
queryFn: () => data.getRelationGraph({ of: "flow", flowId: selectedFlowId ?? "" }),
|
|
348
|
+
enabled: Boolean(selectedFlowId) && selection.view === "graph",
|
|
243
349
|
});
|
|
244
|
-
const [selectedFlowId, setSelectedFlowId] = useState<string | null>(null);
|
|
245
350
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
246
|
-
const [
|
|
247
|
-
|
|
351
|
+
const [publishing, setPublishing] = useState(false);
|
|
352
|
+
// Lifted out of the bar because the canvas dismisses it too — a click on the pane is the third way
|
|
353
|
+
// out, next to the trigger and Escape.
|
|
354
|
+
const [paletteOpen, setPaletteOpen] = usePaletteOpen();
|
|
355
|
+
// A flow is shared through the folder it is filed in, on the Knowledge screen (ADR-0004 §2). It
|
|
356
|
+
// has no share action of its own — a narrower grant beside the folder's would break the guarantee
|
|
357
|
+
// that a flow only reaches flows in its own subtree.
|
|
248
358
|
const document = useQuery({
|
|
249
359
|
queryKey: ["flow", selectedFlowId],
|
|
250
360
|
queryFn: () => data.getFlow(selectedFlowId ?? ""),
|
|
@@ -253,6 +363,7 @@ export function Flows() {
|
|
|
253
363
|
const initial = useMemo(() => canvas(defaultGraph()), []);
|
|
254
364
|
const [nodes, setNodes] = useState<CanvasNode[]>(initial.nodes);
|
|
255
365
|
const [edges, setEdges] = useState<CanvasEdge[]>(initial.edges);
|
|
366
|
+
const [dirty, setDirty] = useState(false);
|
|
256
367
|
// The canvas stays visible during a background refetch; only a mutation needs the stricter
|
|
257
368
|
// guarantee that the loaded document is both the selected flow and settled.
|
|
258
369
|
const documentReady = selectedFlowId !== null && document.data?.flow.id === selectedFlowId;
|
|
@@ -263,6 +374,7 @@ export function Flows() {
|
|
|
263
374
|
setNodes(next.nodes);
|
|
264
375
|
setEdges(next.edges);
|
|
265
376
|
setSelectedNodeId(null);
|
|
377
|
+
setDirty(false);
|
|
266
378
|
}, [document.data, selectedFlowId]);
|
|
267
379
|
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
|
|
268
380
|
|
|
@@ -275,21 +387,14 @@ export function Flows() {
|
|
|
275
387
|
idempotencyKey: crypto.randomUUID(),
|
|
276
388
|
}),
|
|
277
389
|
onSuccess: async (saved) => {
|
|
390
|
+
setDirty(false);
|
|
278
391
|
queryClient.setQueryData(["flow", saved.flow.id], saved);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const publish = useMutation({
|
|
283
|
-
mutationFn: () =>
|
|
284
|
-
data.publishFlow({
|
|
285
|
-
flowId: selectedFlowId ?? "",
|
|
286
|
-
versionId: document.data?.flow.currentVersionId ?? "",
|
|
287
|
-
idempotencyKey: crypto.randomUUID(),
|
|
288
|
-
}),
|
|
289
|
-
onSuccess: async () => {
|
|
392
|
+
// Saving is the whole of what changes here: the graph is both what this flow reads and calls
|
|
393
|
+
// — so the relation view of it and of its folder move with it — and the whole of what it
|
|
394
|
+
// needs.
|
|
290
395
|
await Promise.all([
|
|
291
|
-
queryClient.invalidateQueries({ queryKey: ["
|
|
292
|
-
queryClient.invalidateQueries({ queryKey: ["flow",
|
|
396
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
397
|
+
queryClient.invalidateQueries({ queryKey: ["flow-requirements", saved.flow.id] }),
|
|
293
398
|
]);
|
|
294
399
|
},
|
|
295
400
|
});
|
|
@@ -298,11 +403,13 @@ export function Flows() {
|
|
|
298
403
|
data.startFlow({
|
|
299
404
|
flowId: selectedFlowId ?? "",
|
|
300
405
|
input: {},
|
|
406
|
+
parent: null,
|
|
301
407
|
idempotencyKey: crypto.randomUUID(),
|
|
302
408
|
}),
|
|
303
409
|
});
|
|
304
410
|
|
|
305
411
|
function updateNode(update: (node: FlowNode) => FlowNode) {
|
|
412
|
+
setDirty(true);
|
|
306
413
|
setNodes((current) =>
|
|
307
414
|
current.map((node) =>
|
|
308
415
|
node.id === selectedNodeId ? { ...node, data: { node: update(node.data.node) } } : node,
|
|
@@ -311,80 +418,54 @@ export function Flows() {
|
|
|
311
418
|
}
|
|
312
419
|
|
|
313
420
|
return (
|
|
314
|
-
<div className="flex h-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
<button
|
|
339
|
-
type="button"
|
|
340
|
-
onClick={() => publish.mutate()}
|
|
341
|
-
disabled={!canMutate || !document.data?.flow.currentVersionId || publish.isPending}
|
|
342
|
-
className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
343
|
-
>
|
|
344
|
-
<Send aria-hidden="true" className="size-4" /> {i18n.t("flows.publish")}
|
|
345
|
-
</button>
|
|
346
|
-
<button
|
|
347
|
-
type="button"
|
|
348
|
-
onClick={() => run.mutate()}
|
|
349
|
-
disabled={!canMutate || !document.data?.flow.publishedVersionId || run.isPending}
|
|
350
|
-
className="inline-flex items-center gap-2 rounded-md bg-primary px-3 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"
|
|
351
|
-
>
|
|
352
|
-
<Bot aria-hidden="true" className="size-4" /> {i18n.t("flows.run")}
|
|
353
|
-
</button>
|
|
354
|
-
</>
|
|
355
|
-
)}
|
|
356
|
-
<button
|
|
357
|
-
type="button"
|
|
358
|
-
onClick={() => setCreating(true)}
|
|
359
|
-
className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring"
|
|
360
|
-
>
|
|
361
|
-
<Plus aria-hidden="true" className="size-4" /> {i18n.t("flows.new")}
|
|
362
|
-
</button>
|
|
363
|
-
</div>
|
|
364
|
-
</header>
|
|
421
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
422
|
+
{/* Only the view switch belongs to the area: it says how the thing the breadcrumb names is
|
|
423
|
+
being shown, and it is mounted only where there is a flow to draw — a switch with nothing
|
|
424
|
+
to point at is what #19 rules out for the header. A flow is the one level that has runs,
|
|
425
|
+
so it is the one that offers the third view (#35). Everything that acts on the flow itself
|
|
426
|
+
left this bar for the flow's own title line below (#24). */}
|
|
427
|
+
{selectedFlowId ? (
|
|
428
|
+
<ActionSlot>
|
|
429
|
+
<ViewToggle views={["editor", "graph", "runs"]} />
|
|
430
|
+
</ActionSlot>
|
|
431
|
+
) : null}
|
|
432
|
+
{selectedFlowId && document.data ? (
|
|
433
|
+
<FlowTitle
|
|
434
|
+
flow={document.data.flow}
|
|
435
|
+
dirty={dirty}
|
|
436
|
+
saving={save.isPending}
|
|
437
|
+
canMutate={canMutate}
|
|
438
|
+
running={run.isPending}
|
|
439
|
+
onSave={() => save.mutate()}
|
|
440
|
+
onPublish={() => setPublishing(true)}
|
|
441
|
+
onRun={() => run.mutate()}
|
|
442
|
+
/>
|
|
443
|
+
) : null}
|
|
444
|
+
<UnsavedChangesGuard dirty={dirty} />
|
|
365
445
|
<div className="flex min-h-0 flex-1">
|
|
366
|
-
|
|
367
|
-
{
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
))}
|
|
380
|
-
{flows.data?.items.length === 0 && (
|
|
381
|
-
<p className="p-3 text-sm text-muted-foreground">{i18n.t("flows.empty")}</p>
|
|
382
|
-
)}
|
|
383
|
-
</aside>
|
|
384
|
-
{!selectedFlowId ? (
|
|
446
|
+
{selectedFlowId && selection.view === "runs" ? (
|
|
447
|
+
<FlowRuns flowId={selectedFlowId} />
|
|
448
|
+
) : selectedFlowId && selection.view === "graph" ? (
|
|
449
|
+
<GraphPane
|
|
450
|
+
query={relations}
|
|
451
|
+
select={(node) =>
|
|
452
|
+
void navigate({
|
|
453
|
+
to: node.kind === "flow" ? "/flows" : "/knowledge",
|
|
454
|
+
search: { select: node.id },
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
/>
|
|
458
|
+
) : !selectedFlowId ? (
|
|
385
459
|
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
386
460
|
{i18n.t("flows.select")}
|
|
387
461
|
</div>
|
|
462
|
+
) : document.isError ? (
|
|
463
|
+
<div
|
|
464
|
+
role="alert"
|
|
465
|
+
className="grid flex-1 place-items-center p-8 text-center text-sm text-destructive"
|
|
466
|
+
>
|
|
467
|
+
{i18n.t("flows.operationFailed")}
|
|
468
|
+
</div>
|
|
388
469
|
) : !documentReady ? (
|
|
389
470
|
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
390
471
|
{i18n.t("common.loading")}
|
|
@@ -392,65 +473,108 @@ export function Flows() {
|
|
|
392
473
|
) : (
|
|
393
474
|
<>
|
|
394
475
|
<section className="relative min-w-0 flex-1">
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
417
|
-
>
|
|
418
|
-
<Icon aria-hidden="true" className="size-3.5" />
|
|
419
|
-
{i18n.t(`flows.node.${kind}`)}
|
|
420
|
-
</button>
|
|
476
|
+
{/* Adding is not what one does most of the time — reading the graph is. The bar stays
|
|
477
|
+
open once opened, so three steps in a row cost one click, not three. */}
|
|
478
|
+
<NodePalette
|
|
479
|
+
kinds={paletteKinds}
|
|
480
|
+
open={paletteOpen}
|
|
481
|
+
setOpen={setPaletteOpen}
|
|
482
|
+
// ⚠️ Only the bar is closed off here. `compileFlow` keeps insisting on exactly one
|
|
483
|
+
// start, because a graph can also arrive over MCP or an import, where no bar was
|
|
484
|
+
// involved — this is the earlier answer, not the only one.
|
|
485
|
+
disabledReason={(kind) =>
|
|
486
|
+
kind === "trigger" && nodes.some((node) => node.data.node.kind === "trigger")
|
|
487
|
+
? i18n.t("flows.startExists")
|
|
488
|
+
: null
|
|
489
|
+
}
|
|
490
|
+
add={(kind) => {
|
|
491
|
+
setDirty(true);
|
|
492
|
+
const item = newNode(
|
|
493
|
+
kind,
|
|
494
|
+
nodes.length,
|
|
495
|
+
knowledge.data?.nodes[0]?.id,
|
|
496
|
+
callable.data?.items.find((entry) => entry.id !== selectedFlowId)?.id,
|
|
421
497
|
);
|
|
422
|
-
|
|
423
|
-
|
|
498
|
+
setNodes((current) => [
|
|
499
|
+
...current,
|
|
500
|
+
{
|
|
501
|
+
id: item.id,
|
|
502
|
+
type: "intel",
|
|
503
|
+
position: item.position,
|
|
504
|
+
data: { node: item },
|
|
505
|
+
},
|
|
506
|
+
]);
|
|
507
|
+
}}
|
|
508
|
+
/>
|
|
509
|
+
{/* ⚠️ React Flow knows nothing about the `.dark` class our tokens hang off; without
|
|
510
|
+
`colorMode` it paints its controls and its minimap from its own light palette and
|
|
511
|
+
leaves two glaring white surfaces on a black canvas. The value comes from the same
|
|
512
|
+
media query that sets the class, so both turn together and mid-session. What the
|
|
513
|
+
surfaces are actually coloured with is in `styles.css`, through the library's own
|
|
514
|
+
theming variables — for the same reason `.react-sigma` is there. */}
|
|
424
515
|
<ReactFlow<CanvasNode, CanvasEdge>
|
|
516
|
+
colorMode={theme}
|
|
425
517
|
nodes={nodes}
|
|
426
518
|
edges={edges}
|
|
427
519
|
nodeTypes={nodeTypes}
|
|
428
520
|
fitView
|
|
429
|
-
onNodesChange={(changes: NodeChange<CanvasNode>[]) =>
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
521
|
+
onNodesChange={(changes: NodeChange<CanvasNode>[]) => {
|
|
522
|
+
if (editsNodes(changes)) setDirty(true);
|
|
523
|
+
setNodes((current) => applyNodeChanges(changes, current));
|
|
524
|
+
}}
|
|
525
|
+
onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) => {
|
|
526
|
+
if (editsEdges(changes)) setDirty(true);
|
|
527
|
+
setEdges((current) => applyEdgeChanges(changes, current));
|
|
528
|
+
}}
|
|
529
|
+
// ⚠️ The two meanings cannot be mixed on one edge: a line from a context point into
|
|
530
|
+
// a flow input would be stored as context and drawn as an attachment, while the
|
|
531
|
+
// author meant "and then". Refused at the point where it is still visible.
|
|
532
|
+
isValidConnection={(connection) =>
|
|
533
|
+
(connection.sourceHandle === ContextHandle) ===
|
|
534
|
+
(connection.targetHandle === ContextHandle)
|
|
434
535
|
}
|
|
435
|
-
onConnect={(connection: Connection) =>
|
|
536
|
+
onConnect={(connection: Connection) => {
|
|
537
|
+
setDirty(true);
|
|
436
538
|
setEdges((current) =>
|
|
437
|
-
addEdge(
|
|
438
|
-
|
|
439
|
-
|
|
539
|
+
addEdge(
|
|
540
|
+
{
|
|
541
|
+
...connection,
|
|
542
|
+
id: crypto.randomUUID(),
|
|
543
|
+
...contextEdgeStyle(connection.sourceHandle === ContextHandle),
|
|
544
|
+
},
|
|
545
|
+
current,
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
}}
|
|
440
549
|
onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
|
|
441
|
-
onPaneClick={() =>
|
|
550
|
+
onPaneClick={() => {
|
|
551
|
+
setSelectedNodeId(null);
|
|
552
|
+
setPaletteOpen(false);
|
|
553
|
+
}}
|
|
442
554
|
>
|
|
443
555
|
<Background />
|
|
444
|
-
|
|
556
|
+
{/* The overview map has no room for a label, so the start is marked there the only
|
|
557
|
+
way that is left: its own colour, from the tokens rather than a fixed value. */}
|
|
558
|
+
<MiniMap
|
|
559
|
+
pannable
|
|
560
|
+
zoomable
|
|
561
|
+
nodeClassName={(node) =>
|
|
562
|
+
(node as CanvasNode).data.node.kind === "trigger" ? "intel-minimap-start" : ""
|
|
563
|
+
}
|
|
564
|
+
/>
|
|
445
565
|
<Controls />
|
|
446
566
|
</ReactFlow>
|
|
447
567
|
</section>
|
|
448
|
-
<
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
568
|
+
<aside className="flex w-80 shrink-0 flex-col overflow-y-auto border-l bg-card">
|
|
569
|
+
<NodeInspector
|
|
570
|
+
node={selectedNode}
|
|
571
|
+
update={updateNode}
|
|
572
|
+
tools={tools.data?.items ?? []}
|
|
573
|
+
knowledge={knowledge.data?.nodes ?? []}
|
|
574
|
+
flows={(callable.data?.items ?? []).filter((entry) => entry.id !== selectedFlowId)}
|
|
575
|
+
/>
|
|
576
|
+
<FlowNeeds flowId={selectedFlowId} />
|
|
577
|
+
</aside>
|
|
454
578
|
</>
|
|
455
579
|
)}
|
|
456
580
|
</div>
|
|
@@ -462,7 +586,7 @@ export function Flows() {
|
|
|
462
586
|
</p>
|
|
463
587
|
</div>
|
|
464
588
|
)}
|
|
465
|
-
{(save.isError ||
|
|
589
|
+
{(save.isError || run.isError) && (
|
|
466
590
|
<p
|
|
467
591
|
role="alert"
|
|
468
592
|
className="fixed bottom-5 left-[18rem] z-20 rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
@@ -470,122 +594,204 @@ export function Flows() {
|
|
|
470
594
|
{i18n.t("flows.operationFailed")}
|
|
471
595
|
</p>
|
|
472
596
|
)}
|
|
473
|
-
{
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
597
|
+
{publishing && selectedFlowId && document.data?.flow.currentVersionId ? (
|
|
598
|
+
<PublishPreview
|
|
599
|
+
flowId={selectedFlowId}
|
|
600
|
+
versionId={document.data.flow.currentVersionId}
|
|
601
|
+
close={() => setPublishing(false)}
|
|
602
|
+
onPublished={async () => {
|
|
603
|
+
setPublishing(false);
|
|
604
|
+
// Publishing appends the frozen version, so more than the flow row moves: the canvas
|
|
605
|
+
// reloads from the new current version, the preview cached under the old version id
|
|
606
|
+
// would still claim there is something left to freeze, and the relation view reads the
|
|
607
|
+
// graph that just changed.
|
|
608
|
+
await Promise.all([
|
|
609
|
+
queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] }),
|
|
610
|
+
queryClient.invalidateQueries({ queryKey: ["flow-publish-preview", selectedFlowId] }),
|
|
611
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
612
|
+
]);
|
|
613
|
+
}}
|
|
614
|
+
/>
|
|
615
|
+
) : null}
|
|
616
|
+
</div>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The flow's title line — the same one a document has (#24).
|
|
622
|
+
*
|
|
623
|
+
* Before this, a flow wore four spelled-out buttons in the shell's header while a document wore
|
|
624
|
+
* icons in its own title line: two screens of one application, built two ways, and a reader who has
|
|
625
|
+
* to learn where actions live twice.
|
|
626
|
+
*
|
|
627
|
+
* ⚠️ `Publish` and `Run` stay visible buttons rather than menu entries, and they are deliberately
|
|
628
|
+
* *not* the same shape as each other. Both reach outside the editor — publishing freezes the version
|
|
629
|
+
* every later run hangs on, and a run keeps going after the tab is closed (Cloudflare Workflows,
|
|
630
|
+
* binding `FLOWS`) — so neither belongs behind three dots. But `Run` is the only one that starts
|
|
631
|
+
* work, so it is the only button in the line that carries a word instead of a picture: the
|
|
632
|
+
* difference in form *is* the warning against hitting it by accident.
|
|
633
|
+
*/
|
|
634
|
+
function FlowTitle({
|
|
635
|
+
flow,
|
|
636
|
+
dirty,
|
|
637
|
+
saving,
|
|
638
|
+
canMutate,
|
|
639
|
+
running,
|
|
640
|
+
onSave,
|
|
641
|
+
onPublish,
|
|
642
|
+
onRun,
|
|
643
|
+
}: {
|
|
644
|
+
flow: Flow;
|
|
645
|
+
dirty: boolean;
|
|
646
|
+
saving: boolean;
|
|
647
|
+
canMutate: boolean;
|
|
648
|
+
running: boolean;
|
|
649
|
+
onSave(): void;
|
|
650
|
+
onPublish(): void;
|
|
651
|
+
onRun(): void;
|
|
652
|
+
}) {
|
|
653
|
+
const { i18n } = useIntelRouterContext();
|
|
654
|
+
// A draft that is already the published one freezes nothing, so publishing it would be a no-op
|
|
655
|
+
// with a dialog in front of it.
|
|
656
|
+
const publishable =
|
|
657
|
+
canMutate &&
|
|
658
|
+
Boolean(flow.currentVersionId) &&
|
|
659
|
+
flow.currentVersionId !== flow.publishedVersionId;
|
|
660
|
+
// ⚠️ Nothing published means `Start run` answers 409 `flow_not_published` (`flows.ts:541`), and
|
|
661
|
+
// until now that was only readable by clicking it. The button that fixes it says so instead.
|
|
662
|
+
const unpublished = flow.publishedVersionId === null;
|
|
663
|
+
const publishLabel = i18n.t(
|
|
664
|
+
!publishable ? "flows.publishNothing" : unpublished ? "flows.publishNeeded" : "flows.publish",
|
|
665
|
+
);
|
|
666
|
+
return (
|
|
667
|
+
<div className="flex items-start justify-between gap-5 border-b px-6 py-4">
|
|
668
|
+
<div className="min-w-0">
|
|
669
|
+
<h2 className="truncate text-lg font-semibold">{flow.title}</h2>
|
|
670
|
+
{flow.description ? (
|
|
671
|
+
<p className="mt-1 text-sm text-muted-foreground">{flow.description}</p>
|
|
672
|
+
) : null}
|
|
673
|
+
</div>
|
|
674
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
675
|
+
<TooltipProvider delayDuration={300}>
|
|
676
|
+
<Tooltip>
|
|
677
|
+
<TooltipTrigger asChild>
|
|
678
|
+
<span className="inline-flex">
|
|
679
|
+
{/* Publishing goes through the preview, always. ADR-0004 §5 asks the author to see
|
|
680
|
+
the freeze before it happens, and a second, quieter path around the dialog would
|
|
681
|
+
be the one everybody ends up using. */}
|
|
682
|
+
<button
|
|
683
|
+
type="button"
|
|
684
|
+
onClick={onPublish}
|
|
685
|
+
disabled={!publishable}
|
|
686
|
+
aria-label={publishLabel}
|
|
687
|
+
className={`inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${
|
|
688
|
+
publishable && unpublished ? "border-primary text-primary" : ""
|
|
689
|
+
}`}
|
|
690
|
+
>
|
|
691
|
+
<Send aria-hidden="true" className="size-4" />
|
|
692
|
+
</button>
|
|
693
|
+
</span>
|
|
694
|
+
</TooltipTrigger>
|
|
695
|
+
<TooltipContent>{publishLabel}</TooltipContent>
|
|
696
|
+
</Tooltip>
|
|
697
|
+
</TooltipProvider>
|
|
698
|
+
<ResourceMenu target={{ type: "flow", flow }} variant="title" />
|
|
699
|
+
<SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
|
|
700
|
+
<button
|
|
701
|
+
type="button"
|
|
702
|
+
onClick={onRun}
|
|
703
|
+
disabled={!canMutate || unpublished || running}
|
|
704
|
+
className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
705
|
+
>
|
|
706
|
+
{i18n.t("flows.run")}
|
|
707
|
+
</button>
|
|
708
|
+
</div>
|
|
477
709
|
</div>
|
|
478
710
|
);
|
|
479
711
|
}
|
|
480
712
|
|
|
481
|
-
|
|
713
|
+
// ⚠️ The list is the server's, not this component's. Publishing computes the same freeze from the
|
|
714
|
+
// same code, so what the author agrees to here is what happens — a version resolved in the browser
|
|
715
|
+
// would be a second opinion, and the two would part ways the moment someone else publishes.
|
|
716
|
+
function PublishPreview({
|
|
717
|
+
flowId,
|
|
718
|
+
versionId,
|
|
719
|
+
close,
|
|
720
|
+
onPublished,
|
|
721
|
+
}: {
|
|
722
|
+
flowId: string;
|
|
723
|
+
versionId: string;
|
|
724
|
+
close(): void;
|
|
725
|
+
onPublished(): Promise<void>;
|
|
726
|
+
}) {
|
|
482
727
|
const { data, i18n } = useIntelRouterContext();
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const grants = useQuery({
|
|
487
|
-
queryKey: ["flow-grants", flowId],
|
|
488
|
-
queryFn: () => data.listFlowGrants(flowId),
|
|
489
|
-
});
|
|
490
|
-
const share = useMutation({
|
|
491
|
-
mutationFn: () =>
|
|
492
|
-
data.shareFlow({
|
|
493
|
-
resourceId: flowId,
|
|
494
|
-
principal: { type: "email", email },
|
|
495
|
-
role,
|
|
496
|
-
expiresAt: null,
|
|
497
|
-
idempotencyKey: crypto.randomUUID(),
|
|
498
|
-
}),
|
|
499
|
-
onSuccess: async () => {
|
|
500
|
-
setEmail("");
|
|
501
|
-
await queryClient.invalidateQueries({
|
|
502
|
-
queryKey: ["flow-grants", flowId],
|
|
503
|
-
});
|
|
504
|
-
},
|
|
728
|
+
const preview = useQuery({
|
|
729
|
+
queryKey: ["flow-publish-preview", flowId, versionId],
|
|
730
|
+
queryFn: () => data.previewFlowPublish({ flowId, versionId }),
|
|
505
731
|
});
|
|
506
|
-
const
|
|
507
|
-
mutationFn: (
|
|
508
|
-
|
|
509
|
-
resourceId: flowId,
|
|
510
|
-
grantId,
|
|
511
|
-
idempotencyKey: crypto.randomUUID(),
|
|
512
|
-
}),
|
|
513
|
-
onSuccess: async () => {
|
|
514
|
-
await queryClient.invalidateQueries({
|
|
515
|
-
queryKey: ["flow-grants", flowId],
|
|
516
|
-
});
|
|
517
|
-
},
|
|
732
|
+
const publish = useMutation({
|
|
733
|
+
mutationFn: () => data.publishFlow({ flowId, versionId, idempotencyKey: crypto.randomUUID() }),
|
|
734
|
+
onSuccess: onPublished,
|
|
518
735
|
});
|
|
519
|
-
|
|
520
736
|
return (
|
|
521
|
-
<Modal title={i18n.t("flows.
|
|
522
|
-
{
|
|
523
|
-
<p
|
|
524
|
-
|
|
737
|
+
<Modal title={i18n.t("flows.publishPreview")} close={close}>
|
|
738
|
+
{preview.isPending ? (
|
|
739
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
740
|
+
) : preview.isError ? (
|
|
741
|
+
<p role="alert" className="text-sm text-destructive">
|
|
742
|
+
{i18n.t("flows.operationFailed")}
|
|
525
743
|
</p>
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
>
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
value={role}
|
|
573
|
-
onChange={(event) => setRole(event.target.value as ResourceRole)}
|
|
574
|
-
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
744
|
+
) : (
|
|
745
|
+
<>
|
|
746
|
+
<p className="text-sm text-muted-foreground">
|
|
747
|
+
{preview.data.calls.length === 0
|
|
748
|
+
? i18n.t("flows.publishPreviewEmpty")
|
|
749
|
+
: i18n.t("flows.publishPreviewIntro")}
|
|
750
|
+
</p>
|
|
751
|
+
<ul className="mt-4 space-y-2">
|
|
752
|
+
{preview.data.calls.map((call) => {
|
|
753
|
+
// ⚠️ One condition for the sentence and its colour. They were two, so a call that
|
|
754
|
+
// rides along into a callee with nothing published read "runs along" in the colour of
|
|
755
|
+
// a failure — the reader would have gone looking for a problem that is not there.
|
|
756
|
+
const blocked = !call.available && call.mode !== "follows";
|
|
757
|
+
return (
|
|
758
|
+
<li key={call.nodeId} className="rounded-md border p-3 text-sm">
|
|
759
|
+
<span className="block font-medium">{call.nodeLabel}</span>
|
|
760
|
+
<span className="block text-xs text-muted-foreground">{call.calleeTitle}</span>
|
|
761
|
+
<span
|
|
762
|
+
className={`mt-1 block text-xs ${blocked ? "text-destructive" : "text-muted-foreground"}`}
|
|
763
|
+
>
|
|
764
|
+
{blocked
|
|
765
|
+
? i18n.t("flows.publishPreviewUnavailable")
|
|
766
|
+
: call.freezes
|
|
767
|
+
? i18n.t("flows.publishPreviewFreeze", {
|
|
768
|
+
sequence: call.versionSequence ?? 0,
|
|
769
|
+
})
|
|
770
|
+
: call.mode === "pinned"
|
|
771
|
+
? i18n.t("flows.publishPreviewPinned", {
|
|
772
|
+
sequence: call.versionSequence ?? 0,
|
|
773
|
+
})
|
|
774
|
+
: i18n.t("flows.publishPreviewFollows")}
|
|
775
|
+
</span>
|
|
776
|
+
</li>
|
|
777
|
+
);
|
|
778
|
+
})}
|
|
779
|
+
</ul>
|
|
780
|
+
{publish.isError && (
|
|
781
|
+
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
782
|
+
{i18n.t("flows.publishFailed")}
|
|
783
|
+
</p>
|
|
784
|
+
)}
|
|
785
|
+
<button
|
|
786
|
+
type="button"
|
|
787
|
+
onClick={() => publish.mutate()}
|
|
788
|
+
disabled={publish.isPending}
|
|
789
|
+
className="mt-5 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"
|
|
575
790
|
>
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
</label>
|
|
581
|
-
<button
|
|
582
|
-
type="submit"
|
|
583
|
-
disabled={share.isPending}
|
|
584
|
-
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
585
|
-
>
|
|
586
|
-
{i18n.t("knowledge.shareAction")}
|
|
587
|
-
</button>
|
|
588
|
-
</form>
|
|
791
|
+
{publish.isPending ? i18n.t("common.saving") : i18n.t("flows.publishConfirm")}
|
|
792
|
+
</button>
|
|
793
|
+
</>
|
|
794
|
+
)}
|
|
589
795
|
</Modal>
|
|
590
796
|
);
|
|
591
797
|
}
|
|
@@ -595,40 +801,31 @@ function NodeInspector({
|
|
|
595
801
|
update,
|
|
596
802
|
tools,
|
|
597
803
|
knowledge,
|
|
804
|
+
flows,
|
|
598
805
|
}: {
|
|
599
806
|
node: CanvasNode | null;
|
|
600
807
|
update(fn: (node: FlowNode) => FlowNode): void;
|
|
601
808
|
tools: Array<{ name: string; title: string | null; fingerprint: string }>;
|
|
602
|
-
knowledge:
|
|
809
|
+
knowledge: KnowledgeNode[];
|
|
810
|
+
flows: Flow[];
|
|
603
811
|
}) {
|
|
604
812
|
const { i18n } = useIntelRouterContext();
|
|
605
|
-
if (!node)
|
|
606
|
-
return (
|
|
607
|
-
<aside className="w-80 shrink-0 border-l bg-card p-5 text-sm text-muted-foreground">
|
|
608
|
-
{i18n.t("flows.inspect")}
|
|
609
|
-
</aside>
|
|
610
|
-
);
|
|
813
|
+
if (!node) return <p className="p-5 text-sm text-muted-foreground">{i18n.t("flows.inspect")}</p>;
|
|
611
814
|
const contract = node.data.node;
|
|
612
815
|
return (
|
|
613
|
-
<
|
|
816
|
+
<div className="p-5">
|
|
614
817
|
<h2 className="font-semibold">{i18n.t(`flows.node.${contract.kind}`)}</h2>
|
|
615
818
|
<Field
|
|
616
819
|
label={i18n.t("common.title")}
|
|
617
820
|
value={contract.label}
|
|
618
821
|
setValue={(label) => update((value) => ({ ...value, label }))}
|
|
619
822
|
/>
|
|
620
|
-
<Field
|
|
621
|
-
label={i18n.t("flows.nodeDescription")}
|
|
622
|
-
value={contract.description ?? ""}
|
|
623
|
-
setValue={(description) =>
|
|
624
|
-
update((value) => ({ ...value, description: description || null }))
|
|
625
|
-
}
|
|
626
|
-
/>
|
|
627
823
|
{(contract.kind === "instruction" ||
|
|
628
824
|
contract.kind === "condition" ||
|
|
629
825
|
contract.kind === "approval") && (
|
|
630
826
|
<TextArea
|
|
631
827
|
label={i18n.t("flows.instruction")}
|
|
828
|
+
hint={i18n.t("flows.instructionHint")}
|
|
632
829
|
value={
|
|
633
830
|
contract.kind === "instruction"
|
|
634
831
|
? contract.configuration.prompt
|
|
@@ -659,11 +856,10 @@ function NodeInspector({
|
|
|
659
856
|
<>
|
|
660
857
|
<fieldset className="mt-4 rounded-lg border p-3">
|
|
661
858
|
<legend className="px-1 text-sm font-medium">{i18n.t("flows.knowledgeIds")}</legend>
|
|
662
|
-
{
|
|
859
|
+
{knowledge.map((item) => (
|
|
663
860
|
<label
|
|
664
861
|
key={item.id}
|
|
665
862
|
className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted"
|
|
666
|
-
style={{ paddingLeft: `${item.depth * 12 + 8}px` }}
|
|
667
863
|
>
|
|
668
864
|
<input
|
|
669
865
|
type="checkbox"
|
|
@@ -717,6 +913,36 @@ function NodeInspector({
|
|
|
717
913
|
/>
|
|
718
914
|
</>
|
|
719
915
|
)}
|
|
916
|
+
{contract.kind === "subflow" && (
|
|
917
|
+
<div className="mt-4">
|
|
918
|
+
<label className="block text-sm font-medium">
|
|
919
|
+
{i18n.t("flows.subflowTarget")}
|
|
920
|
+
<select
|
|
921
|
+
value={contract.configuration.flowId}
|
|
922
|
+
onChange={(event) => {
|
|
923
|
+
const flowId = event.target.value;
|
|
924
|
+
update((value) =>
|
|
925
|
+
value.kind === "subflow"
|
|
926
|
+
? { ...value, configuration: { ...value.configuration, flowId } }
|
|
927
|
+
: value,
|
|
928
|
+
);
|
|
929
|
+
}}
|
|
930
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
931
|
+
>
|
|
932
|
+
<option value="">{i18n.t("flows.selectSubflow")}</option>
|
|
933
|
+
{flows.map((entry) => (
|
|
934
|
+
<option key={entry.id} value={entry.id}>
|
|
935
|
+
{entry.title}
|
|
936
|
+
</option>
|
|
937
|
+
))}
|
|
938
|
+
</select>
|
|
939
|
+
</label>
|
|
940
|
+
{/* The call rule is a folder question, so it cannot be answered while typing. Publishing
|
|
941
|
+
answers it and names the reason. */}
|
|
942
|
+
<p className="mt-2 text-xs text-muted-foreground">{i18n.t("flows.subflowRule")}</p>
|
|
943
|
+
<SubflowVersionField configuration={contract.configuration} update={update} />
|
|
944
|
+
</div>
|
|
945
|
+
)}
|
|
720
946
|
{contract.kind === "tool" && (
|
|
721
947
|
<div className="mt-4">
|
|
722
948
|
<label className="block text-sm font-medium">
|
|
@@ -767,7 +993,125 @@ function NodeInspector({
|
|
|
767
993
|
/>
|
|
768
994
|
</div>
|
|
769
995
|
)}
|
|
770
|
-
</
|
|
996
|
+
</div>
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// "What this flow needs": the documents and tools the graph names, and nothing about who may reach
|
|
1001
|
+
// them. A standing "this flow has conflicts" badge is not offered on purpose — for tools it could
|
|
1002
|
+
// never be true, because the catalog is a live query with each user's own portal token (ADR-0003).
|
|
1003
|
+
function FlowNeeds({ flowId }: { flowId: string }) {
|
|
1004
|
+
const { data, i18n } = useIntelRouterContext();
|
|
1005
|
+
const needs = useQuery({
|
|
1006
|
+
queryKey: ["flow-requirements", flowId],
|
|
1007
|
+
queryFn: () => data.getFlowRequirements(flowId),
|
|
1008
|
+
});
|
|
1009
|
+
// ⚠️ A flow that touches nothing and a flow whose every reference is hidden are two different
|
|
1010
|
+
// answers, so the counted ones keep the panel from claiming the first when it means the second.
|
|
1011
|
+
const empty =
|
|
1012
|
+
needs.data !== undefined &&
|
|
1013
|
+
needs.data.knowledge.length === 0 &&
|
|
1014
|
+
needs.data.hiddenKnowledge === 0 &&
|
|
1015
|
+
needs.data.tools.length === 0;
|
|
1016
|
+
return (
|
|
1017
|
+
<section aria-label={i18n.t("flows.needs")} className="mt-auto border-t p-5">
|
|
1018
|
+
<h2 className="font-semibold">{i18n.t("flows.needs")}</h2>
|
|
1019
|
+
{needs.isPending && (
|
|
1020
|
+
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
1021
|
+
)}
|
|
1022
|
+
{needs.isError && (
|
|
1023
|
+
<p role="alert" className="mt-2 text-sm text-destructive">
|
|
1024
|
+
{i18n.t("flows.needsFailed")}
|
|
1025
|
+
</p>
|
|
1026
|
+
)}
|
|
1027
|
+
{empty && <p className="mt-2 text-sm text-muted-foreground">{i18n.t("flows.needsEmpty")}</p>}
|
|
1028
|
+
{needs.data && !empty && (
|
|
1029
|
+
<>
|
|
1030
|
+
{(needs.data.knowledge.length > 0 || needs.data.hiddenKnowledge > 0) && (
|
|
1031
|
+
<>
|
|
1032
|
+
<h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
1033
|
+
{i18n.t("flows.needsKnowledge")}
|
|
1034
|
+
</h3>
|
|
1035
|
+
<ul className="mt-2 space-y-1 text-sm">
|
|
1036
|
+
{needs.data.knowledge.map((reference) => (
|
|
1037
|
+
<li key={reference.id} className="flex items-center gap-2">
|
|
1038
|
+
<FileSearch aria-hidden="true" className="size-3.5 shrink-0" />
|
|
1039
|
+
<span className="min-w-0 truncate">{reference.title}</span>
|
|
1040
|
+
</li>
|
|
1041
|
+
))}
|
|
1042
|
+
{/* ⚠️ Counted, never named. A title is exactly what someone without access to the
|
|
1043
|
+
document may not learn from a list about it. */}
|
|
1044
|
+
{needs.data.hiddenKnowledge > 0 && (
|
|
1045
|
+
<li className="text-muted-foreground">
|
|
1046
|
+
{i18n.t("flows.needsHidden", { count: needs.data.hiddenKnowledge })}
|
|
1047
|
+
</li>
|
|
1048
|
+
)}
|
|
1049
|
+
</ul>
|
|
1050
|
+
</>
|
|
1051
|
+
)}
|
|
1052
|
+
{needs.data.tools.length > 0 && (
|
|
1053
|
+
<>
|
|
1054
|
+
<h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
1055
|
+
{i18n.t("flows.needsTools")}
|
|
1056
|
+
</h3>
|
|
1057
|
+
<ul className="mt-2 space-y-1 text-sm">
|
|
1058
|
+
{needs.data.tools.map((name) => (
|
|
1059
|
+
<li key={name} className="flex items-center gap-2">
|
|
1060
|
+
<Wrench aria-hidden="true" className="size-3.5 shrink-0" />
|
|
1061
|
+
<span className="min-w-0 truncate">{name}</span>
|
|
1062
|
+
</li>
|
|
1063
|
+
))}
|
|
1064
|
+
</ul>
|
|
1065
|
+
</>
|
|
1066
|
+
)}
|
|
1067
|
+
<p className="mt-4 text-xs text-muted-foreground">{i18n.t("flows.needsHint")}</p>
|
|
1068
|
+
</>
|
|
1069
|
+
)}
|
|
1070
|
+
</section>
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// The version choice of one call (ADR-0004 §5). Two of the three modes are chosen here; the third,
|
|
1075
|
+
// `pinned`, is what publishing writes, so it is offered as the current value and can be given up
|
|
1076
|
+
// again — but no arbitrary version is picked by hand. Which version a pin points at is a fact of the
|
|
1077
|
+
// published graph, not something to browse for while drafting.
|
|
1078
|
+
function SubflowVersionField({
|
|
1079
|
+
configuration,
|
|
1080
|
+
update,
|
|
1081
|
+
}: {
|
|
1082
|
+
configuration: Extract<FlowNode, { kind: "subflow" }>["configuration"];
|
|
1083
|
+
update(fn: (node: FlowNode) => FlowNode): void;
|
|
1084
|
+
}) {
|
|
1085
|
+
const { i18n } = useIntelRouterContext();
|
|
1086
|
+
const mode = configuration.version.mode;
|
|
1087
|
+
return (
|
|
1088
|
+
<div className="mt-4">
|
|
1089
|
+
<label className="block text-sm font-medium">
|
|
1090
|
+
{i18n.t("flows.subflowVersion")}
|
|
1091
|
+
<select
|
|
1092
|
+
value={mode}
|
|
1093
|
+
onChange={(event) => {
|
|
1094
|
+
const next = event.target.value;
|
|
1095
|
+
if (next !== "latest" && next !== "follows") return;
|
|
1096
|
+
update((value) =>
|
|
1097
|
+
value.kind === "subflow"
|
|
1098
|
+
? { ...value, configuration: { ...value.configuration, version: { mode: next } } }
|
|
1099
|
+
: value,
|
|
1100
|
+
);
|
|
1101
|
+
}}
|
|
1102
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1103
|
+
>
|
|
1104
|
+
<option value="latest">{i18n.t("flows.subflowVersion.latest")}</option>
|
|
1105
|
+
<option value="follows">{i18n.t("flows.subflowVersion.follows")}</option>
|
|
1106
|
+
{mode === "pinned" && (
|
|
1107
|
+
<option value="pinned">{i18n.t("flows.subflowVersion.pinned")}</option>
|
|
1108
|
+
)}
|
|
1109
|
+
</select>
|
|
1110
|
+
</label>
|
|
1111
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
1112
|
+
{i18n.t(`flows.subflowVersionHint.${mode}`)}
|
|
1113
|
+
</p>
|
|
1114
|
+
</div>
|
|
771
1115
|
);
|
|
772
1116
|
}
|
|
773
1117
|
|
|
@@ -812,16 +1156,6 @@ function ToolArgumentsEditor({
|
|
|
812
1156
|
);
|
|
813
1157
|
}
|
|
814
1158
|
|
|
815
|
-
function flattenKnowledge(
|
|
816
|
-
nodes: KnowledgeTreeNode[],
|
|
817
|
-
depth = 0,
|
|
818
|
-
): Array<{ id: string; title: string; depth: number }> {
|
|
819
|
-
return nodes.flatMap((node) => [
|
|
820
|
-
{ id: node.id, title: node.title, depth },
|
|
821
|
-
...flattenKnowledge(node.children, depth + 1),
|
|
822
|
-
]);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
1159
|
function Field({
|
|
826
1160
|
label,
|
|
827
1161
|
value,
|
|
@@ -846,64 +1180,46 @@ function TextArea({
|
|
|
846
1180
|
label,
|
|
847
1181
|
value,
|
|
848
1182
|
setValue,
|
|
1183
|
+
hint,
|
|
849
1184
|
}: {
|
|
850
1185
|
label: string;
|
|
851
1186
|
value: string;
|
|
852
1187
|
setValue(value: string): void;
|
|
1188
|
+
hint?: string;
|
|
853
1189
|
}) {
|
|
1190
|
+
const hintId = useId();
|
|
854
1191
|
return (
|
|
855
1192
|
<label className="mt-4 block text-sm font-medium">
|
|
856
|
-
|
|
1193
|
+
<span className="flex items-center gap-1.5">
|
|
1194
|
+
{label}
|
|
1195
|
+
{/* The note that keeps the box of parts small (#39): a condition, a check or a branch can be
|
|
1196
|
+
said in words inside the instruction, so `condition` can stay a node without everyone
|
|
1197
|
+
having to drag one for every small decision. A button, not a bare icon — a hint reachable
|
|
1198
|
+
only by hovering is not reachable at all. */}
|
|
1199
|
+
{hint && (
|
|
1200
|
+
<button
|
|
1201
|
+
type="button"
|
|
1202
|
+
aria-label={hint}
|
|
1203
|
+
aria-describedby={hintId}
|
|
1204
|
+
title={hint}
|
|
1205
|
+
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
1206
|
+
>
|
|
1207
|
+
<Info aria-hidden="true" className="size-3.5" />
|
|
1208
|
+
</button>
|
|
1209
|
+
)}
|
|
1210
|
+
</span>
|
|
857
1211
|
<textarea
|
|
858
1212
|
rows={6}
|
|
859
1213
|
value={value}
|
|
1214
|
+
aria-describedby={hint ? hintId : undefined}
|
|
860
1215
|
onChange={(event) => setValue(event.target.value)}
|
|
861
1216
|
className="mt-2 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
862
1217
|
/>
|
|
1218
|
+
{hint && (
|
|
1219
|
+
<span id={hintId} className="mt-1 block text-xs font-normal text-muted-foreground">
|
|
1220
|
+
{hint}
|
|
1221
|
+
</span>
|
|
1222
|
+
)}
|
|
863
1223
|
</label>
|
|
864
1224
|
);
|
|
865
1225
|
}
|
|
866
|
-
|
|
867
|
-
function CreateFlow({ close, select }: { close(): void; select(id: string): void }) {
|
|
868
|
-
const { data, i18n } = useIntelRouterContext();
|
|
869
|
-
const queryClient = useQueryClient();
|
|
870
|
-
const [title, setTitle] = useState("");
|
|
871
|
-
const create = useMutation({
|
|
872
|
-
mutationFn: () =>
|
|
873
|
-
data.createFlow({
|
|
874
|
-
title,
|
|
875
|
-
description: null,
|
|
876
|
-
idempotencyKey: crypto.randomUUID(),
|
|
877
|
-
}),
|
|
878
|
-
onSuccess: async (flow) => {
|
|
879
|
-
await queryClient.invalidateQueries({ queryKey: ["flows"] });
|
|
880
|
-
select(flow.id);
|
|
881
|
-
close();
|
|
882
|
-
},
|
|
883
|
-
});
|
|
884
|
-
return (
|
|
885
|
-
<Modal title={i18n.t("flows.new")} close={close}>
|
|
886
|
-
<form
|
|
887
|
-
onSubmit={(event) => {
|
|
888
|
-
event.preventDefault();
|
|
889
|
-
if (create.isPending) return;
|
|
890
|
-
create.mutate();
|
|
891
|
-
}}
|
|
892
|
-
>
|
|
893
|
-
<Field label={i18n.t("common.title")} value={title} setValue={setTitle} />
|
|
894
|
-
{create.isError ? (
|
|
895
|
-
<p role="alert" className="mt-3 text-sm text-destructive">
|
|
896
|
-
{i18n.t("flows.operationFailed")}
|
|
897
|
-
</p>
|
|
898
|
-
) : null}
|
|
899
|
-
<button
|
|
900
|
-
type="submit"
|
|
901
|
-
disabled={create.isPending || title.trim().length === 0}
|
|
902
|
-
className="mt-5 w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
903
|
-
>
|
|
904
|
-
{create.isPending ? i18n.t("common.saving") : i18n.t("common.create")}
|
|
905
|
-
</button>
|
|
906
|
-
</form>
|
|
907
|
-
</Modal>
|
|
908
|
-
);
|
|
909
|
-
}
|