@anchrd/intel-ui 0.33.0 → 0.35.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/app-tree/app-tree.tsx +100 -11
- package/src/app/tree-move/tree-move.tsx +5 -5
- package/src/archive/archive.tsx +83 -7
- package/src/data/intel-data-provider/intel-data-provider.ts +12 -1
- package/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/src/entry-picker/entry-picker.tsx +27 -4
- package/src/flow-runs/flow-runs.tsx +9 -1
- package/src/flows/flows.tsx +264 -109
- package/src/graph-pane/graph-pane.tsx +9 -0
- package/src/i18n/de.json +34 -17
- package/src/i18n/en.json +34 -17
- package/src/i18n/es.json +34 -17
- package/src/node-editor/node-editor.tsx +24 -1
- package/src/nodes/nodes.tsx +31 -17
- package/src/resource-menu/resource-menu.tsx +84 -24
- package/src/tools/tools.tsx +5 -4
package/src/flows/flows.tsx
CHANGED
|
@@ -24,15 +24,19 @@ import {
|
|
|
24
24
|
useReactFlow,
|
|
25
25
|
} from "@xyflow/react";
|
|
26
26
|
import { FileSearch, Info, Send, Wrench } from "lucide-react";
|
|
27
|
-
import { useEffect, useId, useMemo, useState } from "react";
|
|
27
|
+
import { createContext, useContext, useEffect, useId, useMemo, useState } from "react";
|
|
28
28
|
import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
|
|
29
29
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
30
30
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
31
|
+
import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
32
|
+
import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
|
|
33
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
31
34
|
import { EntryPicker } from "@/entry-picker/entry-picker.tsx";
|
|
32
35
|
import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
|
|
33
36
|
import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
|
|
34
37
|
import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
|
|
35
38
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
39
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
36
40
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
37
41
|
import { Modal } from "@/modal/modal.tsx";
|
|
38
42
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -90,9 +94,24 @@ const flowKindOfEntry: Record<string, FlowNode["kind"] | undefined> = {
|
|
|
90
94
|
flow: "subflow",
|
|
91
95
|
};
|
|
92
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The tree links of this flow that name nothing any more, by resource id (#509).
|
|
99
|
+
*
|
|
100
|
+
* ⚠️ A context rather than a field on the node's `data`, because the canvas nodes are React state
|
|
101
|
+
* that the loaded version owns: writing the answer of a query into them would put a second writer
|
|
102
|
+
* on that state and let a background refetch land in the middle of an edit. The set is read where
|
|
103
|
+
* it is drawn and nowhere else.
|
|
104
|
+
*/
|
|
105
|
+
const InvalidLinks = createContext<ReadonlySet<string>>(new Set());
|
|
106
|
+
|
|
93
107
|
function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
94
108
|
const i18n = useI18n();
|
|
95
109
|
const contract = data.node;
|
|
110
|
+
const invalidLinks = useContext(InvalidLinks);
|
|
111
|
+
// ⚠️ Said in a WORD, not by colour alone. A card that only turned red would say nothing to a
|
|
112
|
+
// colour-blind reader and nothing at all to a screen reader — and this is the one state on the
|
|
113
|
+
// canvas that stops the flow from running (#509).
|
|
114
|
+
const broken = isLinkNode(contract) && invalidLinks.has(contract.configuration.resourceId);
|
|
96
115
|
const Icon = nodeIcon[contract.kind];
|
|
97
116
|
const branching = contract.kind === "condition";
|
|
98
117
|
// ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
|
|
@@ -117,9 +136,14 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
|
117
136
|
<span className="min-w-0">
|
|
118
137
|
<span className="block truncate text-sm font-medium">{contract.label}</span>
|
|
119
138
|
{/* The kind through the catalog, not the raw key: the start node is called Start on the
|
|
120
|
-
canvas as well, and `trigger` would name an event that no longer exists (#39).
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
canvas as well, and `trigger` would name an event that no longer exists (#39).
|
|
140
|
+
⚠️ A broken link says so INSTEAD of naming its kind, because "Document" beside a
|
|
141
|
+
reference that names nothing is the sentence #509 is about: the card looked like every
|
|
142
|
+
valid link on the canvas. */}
|
|
143
|
+
<span
|
|
144
|
+
className={`block text-xs ${broken ? "text-destructive" : "text-muted-foreground"}`}
|
|
145
|
+
>
|
|
146
|
+
{broken ? i18n.t("flows.nodeInvalid") : i18n.t(`flows.node.${contract.kind}`)}
|
|
123
147
|
</span>
|
|
124
148
|
</span>
|
|
125
149
|
{riding === "follows" || riding === "pinned" ? (
|
|
@@ -348,6 +372,75 @@ function newNode(
|
|
|
348
372
|
}
|
|
349
373
|
}
|
|
350
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Which sentence a refused SAVE is told with.
|
|
377
|
+
*
|
|
378
|
+
* ⚠️ `400 flow_graph_invalid` and `409` are two different answers, and until #508 they shared one
|
|
379
|
+
* sentence — the conflict one. For a rejected graph that advice is not merely useless but
|
|
380
|
+
* destructive: reloading discards the unsaved canvas, and the next attempt is refused for exactly
|
|
381
|
+
* the same reason, so following it costs the work and changes nothing.
|
|
382
|
+
*
|
|
383
|
+
* The graph rule therefore shows the API's OWN sentence: only the server knows which step broke
|
|
384
|
+
* which rule, and it names that step by its label since #508. A conflict keeps the reload sentence,
|
|
385
|
+
* because there reloading IS the way out. Everything else gets neither — a timeout or a `502` is
|
|
386
|
+
* worth repeating, and nothing about it says the loaded version is stale.
|
|
387
|
+
*
|
|
388
|
+
* ⚠️ Not `resourceErrorKey`: that maps a code onto a catalog key, and this case has no key to map
|
|
389
|
+
* onto — its whole content is the server's sentence.
|
|
390
|
+
*/
|
|
391
|
+
function saveRefusalText(error: unknown, i18n: I18n): string {
|
|
392
|
+
const refusal = error instanceof IntelRequestError ? error : null;
|
|
393
|
+
if (refusal?.status === 400 && refusal.code === "flow_graph_invalid") {
|
|
394
|
+
return i18n.t("flows.saveInvalid", { detail: refusal.message });
|
|
395
|
+
}
|
|
396
|
+
if (refusal?.status === 409) return i18n.t("flows.saveConflict");
|
|
397
|
+
return i18n.t("flows.saveFailed");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Which sentence a refused PUBLISH is told with — #508 one surface further (#551).
|
|
402
|
+
*
|
|
403
|
+
* Publishing answers with at least eight different refusals: the compile rules of the save path,
|
|
404
|
+
* a link whose document is out of reach, a tool server that is gone, a frozen schema that moved,
|
|
405
|
+
* three rules about calling another flow — and all of them read
|
|
406
|
+
* "Publishing failed. Reload the latest version and try again." Reloading is the way out of exactly
|
|
407
|
+
* one of them, and for the rest it is the advice that costs the canvas and changes nothing.
|
|
408
|
+
*
|
|
409
|
+
* The split is therefore by what the answer IS, not by which code it carries:
|
|
410
|
+
*
|
|
411
|
+
* - **A refusal** (`4xx`) always shows the server's own sentence. It is the only party that knows
|
|
412
|
+
* which step broke which rule, and since #508 it names that step by its label rather than by an
|
|
413
|
+
* id — which is why #551 rewrote `flow_node_unavailable`, the one that still named a resource id.
|
|
414
|
+
* - **Two of them add the way out**, because the server's sentence identifies the step and stops
|
|
415
|
+
* there: a document out of reach is answered by asking for access, and a tool server that is
|
|
416
|
+
* gone by looking in the portal. Neither is something the API should prescribe per surface.
|
|
417
|
+
* - **A conflict** keeps the reload sentence, because there reloading IS the way out.
|
|
418
|
+
* - **Everything else** — a timeout, a `502`, a network that came back — says nothing about the
|
|
419
|
+
* loaded version and is worth repeating, so it gets neither.
|
|
420
|
+
*/
|
|
421
|
+
function publishRefusalText(error: unknown, i18n: I18n): string {
|
|
422
|
+
const refusal = error instanceof IntelRequestError ? error : null;
|
|
423
|
+
// ⚠️ A `5xx` carries a sentence too, and showing it would dress a broken deployment up as a
|
|
424
|
+
// decision about this flow. Only the deliberate range speaks for itself.
|
|
425
|
+
if (refusal === null || refusal.status < 400 || refusal.status >= 500) {
|
|
426
|
+
return i18n.t("flows.publishFailed");
|
|
427
|
+
}
|
|
428
|
+
const detail = { detail: refusal.message };
|
|
429
|
+
switch (refusal.code) {
|
|
430
|
+
case "flow_node_unavailable":
|
|
431
|
+
return i18n.t("flows.publishNodeUnavailable", detail);
|
|
432
|
+
case "flow_tool_unavailable":
|
|
433
|
+
return i18n.t("flows.publishToolUnavailable", detail);
|
|
434
|
+
// The two ways the version being published stopped being the one to publish. Both are races
|
|
435
|
+
// against another editor, and the loaded version is genuinely stale afterwards.
|
|
436
|
+
case "flow_version_conflict":
|
|
437
|
+
case "flow_publish_conflict":
|
|
438
|
+
return i18n.t("flows.publishConflict");
|
|
439
|
+
default:
|
|
440
|
+
return i18n.t("flows.publishRefused", detail);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
351
444
|
// ⚠️ The provider wraps the editor rather than sitting inside it: `useReactFlow` — which is how the
|
|
352
445
|
// drop turns a screen point into a graph point — has to be called from a component UNDER it, and
|
|
353
446
|
// the hook lives in the same component that renders the canvas.
|
|
@@ -401,6 +494,21 @@ function FlowsEditor() {
|
|
|
401
494
|
queryKey: ["flows"],
|
|
402
495
|
queryFn: () => data.listFlows(),
|
|
403
496
|
});
|
|
497
|
+
/**
|
|
498
|
+
* The refusal of the top level, read off the query the picker already holds.
|
|
499
|
+
*
|
|
500
|
+
* ⚠️ No second query, and that is the whole difference to #445 next door: `["flows"]` is asked
|
|
501
|
+
* with or without a selection, so the answer for the empty screen is already in the cache. What
|
|
502
|
+
* the screen did was throw it away — `listTreeChildren` fails whole once `flows/read` is missing,
|
|
503
|
+
* the sidebar has said so since #430, and the main area beside it went on inviting the reader to
|
|
504
|
+
* pick a flow they cannot see (#557).
|
|
505
|
+
*
|
|
506
|
+
* ⚠️ Off the ERROR rather than off a missing answer. A sentence about a permission, shown while
|
|
507
|
+
* the request is still travelling, claims a refusal nobody has spoken yet — the mistake from #350,
|
|
508
|
+
* and `packages/ui/CLAUDE.md` has the rule: a missing answer is a reason to say less, never to
|
|
509
|
+
* refuse more. Until a refusal arrives, what stands is the invitation, which claims nothing.
|
|
510
|
+
*/
|
|
511
|
+
const rootRefusal = refusalOf(callable.error);
|
|
404
512
|
// The tree in the sidebar and the header search both name the flow to open through `?select=`;
|
|
405
513
|
// `?view=` says how it is shown — the editor, the relation graph (#19) or the runs (#35).
|
|
406
514
|
const selection = useRouterState({
|
|
@@ -426,6 +534,10 @@ function FlowsEditor() {
|
|
|
426
534
|
queryFn: () => data.getFlow(selectedFlowId ?? ""),
|
|
427
535
|
enabled: Boolean(selectedFlowId),
|
|
428
536
|
});
|
|
537
|
+
// ⚠️ A refusal is not a failure. "Reload the latest version and try again." is advice for a
|
|
538
|
+
// network that came back; against a `403` or a `404` it sends somebody to reload a screen that
|
|
539
|
+
// will answer the same thing forever, and it hides that the answer was final (#430).
|
|
540
|
+
const documentRefusal = refusalOf(document.error);
|
|
429
541
|
const initial = useMemo(() => canvas(defaultGraph()), []);
|
|
430
542
|
const [nodes, setNodes] = useState<CanvasNode[]>(initial.nodes);
|
|
431
543
|
const [edges, setEdges] = useState<CanvasEdge[]>(initial.edges);
|
|
@@ -450,6 +562,19 @@ function FlowsEditor() {
|
|
|
450
562
|
setDirty(false);
|
|
451
563
|
}, [document.data, selectedFlowId]);
|
|
452
564
|
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
|
|
565
|
+
// ⚠️ The same query `FlowNeeds` asks, under the same key: TanStack answers both from one cache
|
|
566
|
+
// entry, so the canvas and the panel cannot end up disagreeing about which link is dead. A second
|
|
567
|
+
// key would be a second answer to one question, and the one people would trust is the one they
|
|
568
|
+
// happen to be looking at (#509).
|
|
569
|
+
const needs = useQuery({
|
|
570
|
+
queryKey: ["flow-requirements", selectedFlowId],
|
|
571
|
+
queryFn: () => data.getFlowRequirements(selectedFlowId ?? ""),
|
|
572
|
+
enabled: Boolean(selectedFlowId),
|
|
573
|
+
});
|
|
574
|
+
const invalidLinks = useMemo(
|
|
575
|
+
() => new Set(needs.data?.invalidNodes ?? []),
|
|
576
|
+
[needs.data?.invalidNodes],
|
|
577
|
+
);
|
|
453
578
|
|
|
454
579
|
const save = useMutation({
|
|
455
580
|
mutationFn: () =>
|
|
@@ -496,9 +621,25 @@ function FlowsEditor() {
|
|
|
496
621
|
) : null}
|
|
497
622
|
<UnsavedChangesGuard dirty={dirty} />
|
|
498
623
|
<div className="flex min-h-0 flex-1">
|
|
499
|
-
{selectedFlowId
|
|
624
|
+
{!selectedFlowId ? (
|
|
625
|
+
rootRefusal ? (
|
|
626
|
+
<RefusalNotice refusal={rootRefusal} />
|
|
627
|
+
) : (
|
|
628
|
+
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
629
|
+
{i18n.t("flows.select")}
|
|
630
|
+
</div>
|
|
631
|
+
)
|
|
632
|
+
) : /* ⚠️ The refusal comes BEFORE the view switch, and that placement is the whole of #568.
|
|
633
|
+
`?view=graph` and `?view=runs` used to be read first, so a flow the reader may not
|
|
634
|
+
open still rendered its two side views — each with a failure sentence and a "Try
|
|
635
|
+
again" button on an answer that will not change (#430). Whether the reader may see
|
|
636
|
+
this flow at all is answered by `getFlow`, once, for every view of it; asking it
|
|
637
|
+
after the switch means asking it three times and getting two of them wrong. */
|
|
638
|
+
documentRefusal ? (
|
|
639
|
+
<RefusalNotice refusal={documentRefusal} />
|
|
640
|
+
) : selection.view === "runs" ? (
|
|
500
641
|
<FlowRuns flowId={selectedFlowId} />
|
|
501
|
-
) :
|
|
642
|
+
) : selection.view === "graph" ? (
|
|
502
643
|
<GraphPane
|
|
503
644
|
query={relations}
|
|
504
645
|
select={(node) =>
|
|
@@ -508,10 +649,6 @@ function FlowsEditor() {
|
|
|
508
649
|
})
|
|
509
650
|
}
|
|
510
651
|
/>
|
|
511
|
-
) : !selectedFlowId ? (
|
|
512
|
-
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
513
|
-
{i18n.t("flows.select")}
|
|
514
|
-
</div>
|
|
515
652
|
) : document.isError ? (
|
|
516
653
|
<div
|
|
517
654
|
role="alert"
|
|
@@ -566,102 +703,104 @@ function FlowsEditor() {
|
|
|
566
703
|
media query that sets the class, so both turn together and mid-session. What the
|
|
567
704
|
surfaces are actually coloured with is in `styles.css`, through the library's own
|
|
568
705
|
theming variables — for the same reason `.react-sigma` is there. */}
|
|
569
|
-
<
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
(connection
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
706
|
+
<InvalidLinks.Provider value={invalidLinks}>
|
|
707
|
+
<ReactFlow<CanvasNode, CanvasEdge>
|
|
708
|
+
colorMode={theme}
|
|
709
|
+
nodes={nodes}
|
|
710
|
+
edges={edges}
|
|
711
|
+
nodeTypes={nodeTypes}
|
|
712
|
+
fitView
|
|
713
|
+
onNodesChange={(changes: NodeChange<CanvasNode>[]) => {
|
|
714
|
+
if (editsNodes(changes)) setDirty(true);
|
|
715
|
+
setNodes((current) => applyNodeChanges(changes, current));
|
|
716
|
+
}}
|
|
717
|
+
onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) => {
|
|
718
|
+
if (editsEdges(changes)) setDirty(true);
|
|
719
|
+
setEdges((current) => applyEdgeChanges(changes, current));
|
|
720
|
+
}}
|
|
721
|
+
// ⚠️ The two meanings cannot be mixed on one edge: a line from a context point into
|
|
722
|
+
// a flow input would be stored as context and drawn as an attachment, while the
|
|
723
|
+
// author meant "and then". Refused at the point where it is still visible.
|
|
724
|
+
isValidConnection={(connection) =>
|
|
725
|
+
(connection.sourceHandle === ContextHandle) ===
|
|
726
|
+
(connection.targetHandle === ContextHandle)
|
|
727
|
+
}
|
|
728
|
+
onConnect={(connection: Connection) => {
|
|
729
|
+
setDirty(true);
|
|
730
|
+
setEdges((current) =>
|
|
731
|
+
addEdge(
|
|
732
|
+
{
|
|
733
|
+
...connection,
|
|
734
|
+
id: crypto.randomUUID(),
|
|
735
|
+
...contextEdgeStyle(connection.sourceHandle === ContextHandle),
|
|
736
|
+
},
|
|
737
|
+
current,
|
|
738
|
+
),
|
|
739
|
+
);
|
|
740
|
+
}}
|
|
741
|
+
onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
|
|
742
|
+
onPaneClick={() => {
|
|
743
|
+
setSelectedNodeId(null);
|
|
744
|
+
setPaletteOpen(false);
|
|
745
|
+
}}
|
|
746
|
+
// ⚠️ `copy`, not `move` (#75). The tree's own drop targets say `move`, and the two
|
|
747
|
+
// have to feel different while the pointer is still travelling: dropping here makes
|
|
748
|
+
// a node that POINTS at the row, it does not take the row out of its folder.
|
|
749
|
+
onDragOver={(event) => {
|
|
750
|
+
if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
|
|
751
|
+
event.preventDefault();
|
|
752
|
+
event.dataTransfer.dropEffect = "copy";
|
|
753
|
+
}}
|
|
754
|
+
onDrop={(event) => {
|
|
755
|
+
const payload = event.dataTransfer.getData(TreeEntryMediaType);
|
|
756
|
+
if (!payload) return;
|
|
757
|
+
event.preventDefault();
|
|
758
|
+
const dropped = droppedEntry(payload);
|
|
759
|
+
if (!dropped) return;
|
|
760
|
+
const kind = flowKindOfEntry[dropped.kind];
|
|
761
|
+
if (!kind) return;
|
|
762
|
+
// Where the pointer let go, in the graph's own coordinates — not the screen's, or
|
|
763
|
+
// the node would land somewhere else at every zoom level.
|
|
764
|
+
const position = flow.screenToFlowPosition({
|
|
765
|
+
x: event.clientX,
|
|
766
|
+
y: event.clientY,
|
|
767
|
+
});
|
|
768
|
+
setDirty(true);
|
|
769
|
+
setNodes((current) => [
|
|
770
|
+
...current,
|
|
594
771
|
{
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// a node that POINTS at the row, it does not take the row out of its folder.
|
|
611
|
-
onDragOver={(event) => {
|
|
612
|
-
if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
|
|
613
|
-
event.preventDefault();
|
|
614
|
-
event.dataTransfer.dropEffect = "copy";
|
|
615
|
-
}}
|
|
616
|
-
onDrop={(event) => {
|
|
617
|
-
const payload = event.dataTransfer.getData(TreeEntryMediaType);
|
|
618
|
-
if (!payload) return;
|
|
619
|
-
event.preventDefault();
|
|
620
|
-
const dropped = droppedEntry(payload);
|
|
621
|
-
if (!dropped) return;
|
|
622
|
-
const kind = flowKindOfEntry[dropped.kind];
|
|
623
|
-
if (!kind) return;
|
|
624
|
-
// Where the pointer let go, in the graph's own coordinates — not the screen's, or
|
|
625
|
-
// the node would land somewhere else at every zoom level.
|
|
626
|
-
const position = flow.screenToFlowPosition({
|
|
627
|
-
x: event.clientX,
|
|
628
|
-
y: event.clientY,
|
|
629
|
-
});
|
|
630
|
-
setDirty(true);
|
|
631
|
-
setNodes((current) => [
|
|
632
|
-
...current,
|
|
633
|
-
{
|
|
634
|
-
id: `${kind}-${crypto.randomUUID()}`,
|
|
635
|
-
type: "intel" as const,
|
|
636
|
-
position,
|
|
637
|
-
data: {
|
|
638
|
-
node: {
|
|
639
|
-
id: `${kind}-${crypto.randomUUID()}`,
|
|
640
|
-
kind,
|
|
641
|
-
label: dropped.title,
|
|
642
|
-
position,
|
|
643
|
-
configuration:
|
|
644
|
-
kind === "subflow"
|
|
645
|
-
? { flowId: dropped.id, version: { mode: "latest" as const } }
|
|
646
|
-
: { resourceId: dropped.id },
|
|
647
|
-
} as FlowNode,
|
|
772
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
773
|
+
type: "intel" as const,
|
|
774
|
+
position,
|
|
775
|
+
data: {
|
|
776
|
+
node: {
|
|
777
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
778
|
+
kind,
|
|
779
|
+
label: dropped.title,
|
|
780
|
+
position,
|
|
781
|
+
configuration:
|
|
782
|
+
kind === "subflow"
|
|
783
|
+
? { flowId: dropped.id, version: { mode: "latest" as const } }
|
|
784
|
+
: { resourceId: dropped.id },
|
|
785
|
+
} as FlowNode,
|
|
786
|
+
},
|
|
648
787
|
},
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
{/* The overview map has no room for a label, so the start is marked there the only
|
|
788
|
+
]);
|
|
789
|
+
}}
|
|
790
|
+
>
|
|
791
|
+
<Background />
|
|
792
|
+
{/* The overview map has no room for a label, so the start is marked there the only
|
|
655
793
|
way that is left: its own colour, from the tokens rather than a fixed value. */}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
794
|
+
<MiniMap
|
|
795
|
+
pannable
|
|
796
|
+
zoomable
|
|
797
|
+
nodeClassName={(node) =>
|
|
798
|
+
(node as CanvasNode).data.node.kind === "trigger" ? "intel-minimap-start" : ""
|
|
799
|
+
}
|
|
800
|
+
/>
|
|
801
|
+
<Controls />
|
|
802
|
+
</ReactFlow>
|
|
803
|
+
</InvalidLinks.Provider>
|
|
665
804
|
</section>
|
|
666
805
|
<aside className="flex w-80 shrink-0 flex-col border-l bg-card">
|
|
667
806
|
<TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto">
|
|
@@ -696,7 +835,7 @@ function FlowsEditor() {
|
|
|
696
835
|
role="alert"
|
|
697
836
|
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"
|
|
698
837
|
>
|
|
699
|
-
{
|
|
838
|
+
{saveRefusalText(save.error, i18n)}
|
|
700
839
|
</p>
|
|
701
840
|
)}
|
|
702
841
|
{publishing && selectedFlowId && document.data?.flow.currentVersionId ? (
|
|
@@ -712,7 +851,9 @@ function FlowsEditor() {
|
|
|
712
851
|
// graph that just changed.
|
|
713
852
|
await Promise.all([
|
|
714
853
|
queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] }),
|
|
715
|
-
queryClient.invalidateQueries({
|
|
854
|
+
queryClient.invalidateQueries({
|
|
855
|
+
queryKey: ["flow-publish-preview", selectedFlowId],
|
|
856
|
+
}),
|
|
716
857
|
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
717
858
|
]);
|
|
718
859
|
}}
|
|
@@ -910,7 +1051,7 @@ function PublishPreview({
|
|
|
910
1051
|
</ul>
|
|
911
1052
|
{publish.isError && (
|
|
912
1053
|
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
913
|
-
{
|
|
1054
|
+
{publishRefusalText(publish.error, i18n)}
|
|
914
1055
|
</p>
|
|
915
1056
|
)}
|
|
916
1057
|
<button
|
|
@@ -1249,11 +1390,13 @@ function FlowNeeds({ flowId }: { flowId: string }) {
|
|
|
1249
1390
|
queryKey: ["flow-requirements", flowId],
|
|
1250
1391
|
queryFn: () => data.getFlowRequirements(flowId),
|
|
1251
1392
|
});
|
|
1252
|
-
// ⚠️ A flow that touches nothing and a flow whose every reference is hidden are
|
|
1253
|
-
// answers, so the counted
|
|
1393
|
+
// ⚠️ A flow that touches nothing and a flow whose every reference is hidden or dead are three
|
|
1394
|
+
// different answers, so neither the counted nor the broken ones may fall out of this condition —
|
|
1395
|
+
// otherwise the panel claims the first when it means one of the others.
|
|
1254
1396
|
const empty =
|
|
1255
1397
|
needs.data !== undefined &&
|
|
1256
1398
|
needs.data.nodes.length === 0 &&
|
|
1399
|
+
needs.data.invalidNodes.length === 0 &&
|
|
1257
1400
|
needs.data.hiddenNodes === 0 &&
|
|
1258
1401
|
needs.data.servers.length === 0;
|
|
1259
1402
|
return (
|
|
@@ -1270,7 +1413,9 @@ function FlowNeeds({ flowId }: { flowId: string }) {
|
|
|
1270
1413
|
{empty && <p className="mt-2 text-sm text-muted-foreground">{i18n.t("flows.needsEmpty")}</p>}
|
|
1271
1414
|
{needs.data && !empty && (
|
|
1272
1415
|
<>
|
|
1273
|
-
{(needs.data.nodes.length > 0 ||
|
|
1416
|
+
{(needs.data.nodes.length > 0 ||
|
|
1417
|
+
needs.data.invalidNodes.length > 0 ||
|
|
1418
|
+
needs.data.hiddenNodes > 0) && (
|
|
1274
1419
|
<>
|
|
1275
1420
|
<h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
1276
1421
|
{i18n.t("flows.needsNodes")}
|
|
@@ -1282,6 +1427,16 @@ function FlowNeeds({ flowId }: { flowId: string }) {
|
|
|
1282
1427
|
<span className="min-w-0 truncate">{reference.title}</span>
|
|
1283
1428
|
</li>
|
|
1284
1429
|
))}
|
|
1430
|
+
{/* ⚠️ A dead reference and an unreachable one are two entries, not one (#509).
|
|
1431
|
+
They used to share the counted line, which said "you cannot see it" about a
|
|
1432
|
+
document that is gone — a sentence about a permission, in front of something no
|
|
1433
|
+
permission can fix. This one says what to do instead, because there is exactly
|
|
1434
|
+
one thing to do: replace the step or take it out. */}
|
|
1435
|
+
{needs.data.invalidNodes.length > 0 && (
|
|
1436
|
+
<li className="text-destructive">
|
|
1437
|
+
{i18n.t("flows.needsInvalid", { count: needs.data.invalidNodes.length })}
|
|
1438
|
+
</li>
|
|
1439
|
+
)}
|
|
1285
1440
|
{/* ⚠️ Counted, never named. A title is exactly what someone without access to the
|
|
1286
1441
|
document may not learn from a list about it. */}
|
|
1287
1442
|
{needs.data.hiddenNodes > 0 && (
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { RelationGraph } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { UseQueryResult } from "@tanstack/react-query";
|
|
3
3
|
import { lazy, Suspense } from "react";
|
|
4
|
+
import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
|
|
5
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
4
6
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
7
|
|
|
6
8
|
const RelationGraphView = lazy(async () => ({
|
|
@@ -20,6 +22,13 @@ export function GraphPane({
|
|
|
20
22
|
const i18n = useI18n();
|
|
21
23
|
const loading = <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
22
24
|
if (query.isPending) return loading;
|
|
25
|
+
// ⚠️ Read off this pane's OWN error, not handed down from the screen around it (#568). The screen
|
|
26
|
+
// above answers whether the flow or folder may be opened at all; this answers whether its
|
|
27
|
+
// relations may be read, and the graph endpoint can refuse on its own — an authorized folder
|
|
28
|
+
// whose graph is not shared. Two questions, so two places, and the one that is refused says so
|
|
29
|
+
// where it happened.
|
|
30
|
+
const refusal = refusalOf(query.error);
|
|
31
|
+
if (refusal) return <RefusalNotice refusal={refusal} />;
|
|
23
32
|
if (query.isError || !query.data) {
|
|
24
33
|
return (
|
|
25
34
|
<div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
|