Add Upload SOP option to workflow editor + button - frontend (#4566)

This commit is contained in:
Celal Zamanoglu
2026-01-28 17:04:43 +03:00
committed by GitHub
parent 87ad865d53
commit 76cbe02690
5 changed files with 364 additions and 36 deletions

View File

@@ -1,7 +1,7 @@
import { SquareIcon, PlusIcon } from "@radix-ui/react-icons"; import { SquareIcon, PlusIcon, UploadIcon } from "@radix-ui/react-icons";
import { ReactNode } from "react"; import { ReactNode, useMemo } from "react";
import { RadialMenu } from "@/components/RadialMenu"; import { RadialMenu, RadialMenuItem } from "@/components/RadialMenu";
import { useDebugStore } from "@/store/useDebugStore"; import { useDebugStore } from "@/store/useDebugStore";
import { useRecordingStore } from "@/store/useRecordingStore"; import { useRecordingStore } from "@/store/useRecordingStore";
import { useSettingsStore } from "@/store/SettingsStore"; import { useSettingsStore } from "@/store/SettingsStore";
@@ -13,9 +13,11 @@ type WorkflowAddMenuProps = {
radius?: string; radius?: string;
rotateText?: boolean; rotateText?: boolean;
startAt?: number; startAt?: number;
isUploadingSOP?: boolean;
// -- // --
onAdd: () => void; onAdd: () => void;
onRecord: () => void; onRecord: () => void;
onUploadSOP: () => void;
}; };
function WorkflowAddMenu({ function WorkflowAddMenu({
@@ -25,43 +27,71 @@ function WorkflowAddMenu({
radius = "80px", radius = "80px",
rotateText = true, rotateText = true,
startAt = 90, startAt = 90,
isUploadingSOP = false,
// -- // --
onAdd, onAdd,
onRecord, onRecord,
onUploadSOP,
}: WorkflowAddMenuProps) { }: WorkflowAddMenuProps) {
const debugStore = useDebugStore(); const debugStore = useDebugStore();
const recordingStore = useRecordingStore(); const recordingStore = useRecordingStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
if (!debugStore.isDebugMode || !settingsStore.isUsingABrowser) { const items = useMemo(() => {
const menuItems: Array<RadialMenuItem> = [
{
id: "1",
icon: <PlusIcon className={buttonSize ? "h-3 w-3" : undefined} />,
text: "Add Block",
onClick: () => {
onAdd();
},
},
];
// Only show Record Browser when browser is ON
if (settingsStore.isUsingABrowser) {
menuItems.push({
id: "2",
icon: <SquareIcon className={buttonSize ? "h-3 w-3" : undefined} />,
enabled: !recordingStore.isRecording,
text: "Record Browser",
onClick: () => {
onRecord();
},
});
}
// Always show Upload SOP option
menuItems.push({
id: "3",
icon: <UploadIcon className={buttonSize ? "h-3 w-3" : undefined} />,
text: "Upload SOP",
enabled: !isUploadingSOP,
onClick: () => {
onUploadSOP();
},
});
return menuItems;
}, [
buttonSize,
onAdd,
onRecord,
onUploadSOP,
recordingStore.isRecording,
settingsStore.isUsingABrowser,
isUploadingSOP,
]);
// Show menu in debug mode regardless of browser state
if (!debugStore.isDebugMode) {
return <>{children}</>; return <>{children}</>;
} }
return ( return (
<RadialMenu <RadialMenu
items={[ items={items}
{
id: "1",
icon: <PlusIcon className={buttonSize ? "h-3 w-3" : undefined} />,
text: "Add Block",
onClick: () => {
onAdd();
},
},
{
id: "2",
icon: <SquareIcon className={buttonSize ? "h-3 w-3" : undefined} />,
enabled: !recordingStore.isRecording && settingsStore.isUsingABrowser,
text: "Record Browser",
onClick: () => {
if (!settingsStore.isUsingABrowser) {
return;
}
onRecord();
},
},
]}
buttonSize={buttonSize} buttonSize={buttonSize}
radius={radius} radius={radius}
startAt={startAt} startAt={startAt}

View File

@@ -1,5 +1,14 @@
import { ReactNode, useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -11,7 +20,7 @@ import { cn } from "@/util/utils";
import "./WorkflowAdderBusy.css"; import "./WorkflowAdderBusy.css";
type Operation = "recording" | "processing"; type Operation = "recording" | "processing" | "uploading";
type Size = "small" | "large"; type Size = "small" | "large";
@@ -29,8 +38,14 @@ type Props = {
* Color for the cover and ellipses. Defaults to "red". * Color for the cover and ellipses. Defaults to "red".
*/ */
color?: string; color?: string;
// -- /**
* Callback for when the operation completes (recording/processing).
*/
onComplete: () => void; onComplete: () => void;
/**
* Callback for when the user cancels an upload operation.
*/
onCancel?: () => void;
}; };
function WorkflowAdderBusy({ function WorkflowAdderBusy({
@@ -39,10 +54,12 @@ function WorkflowAdderBusy({
size, size,
color = "red", color = "red",
onComplete, onComplete,
onCancel,
}: Props) { }: Props) {
const recordingStore = useRecordingStore(); const recordingStore = useRecordingStore();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [shouldBump, setShouldBump] = useState(false); const [shouldBump, setShouldBump] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const bumpTimeoutRef = useRef<NodeJS.Timeout | null>(null); const bumpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const prevCountRef = useRef(0); const prevCountRef = useRef(0);
const eventCount = recordingStore.exposedEventCount; const eventCount = recordingStore.exposedEventCount;
@@ -73,11 +90,21 @@ function WorkflowAdderBusy({
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
onComplete();
if (operation === "uploading" && onCancel) {
setShowCancelDialog(true);
} else {
onComplete();
}
return false; return false;
}; };
const handleConfirmCancel = () => {
setShowCancelDialog(false);
onCancel?.();
};
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="relative inline-block"> <div className="relative inline-block">
@@ -157,7 +184,11 @@ function WorkflowAdderBusy({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>
{operation === "recording" ? "Finish Recording" : "Processing..."} {operation === "recording"
? "Finish Recording"
: operation === "uploading"
? "Converting SOP... (click to cancel)"
: "Processing..."}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -186,6 +217,28 @@ function WorkflowAdderBusy({
</Tooltip> </Tooltip>
)} )}
</div> </div>
<Dialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel SOP Conversion?</DialogTitle>
<DialogDescription>
The SOP is currently being converted to workflow blocks. Are you
sure you want to cancel this operation?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCancelDialog(false)}
>
Continue
</Button>
<Button variant="destructive" onClick={handleConfirmCancel}>
Cancel Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider> </TooltipProvider>
); );
} }

View File

@@ -6,6 +6,7 @@ import {
getBezierPath, getBezierPath,
useNodes, useNodes,
} from "@xyflow/react"; } from "@xyflow/react";
import { useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -13,11 +14,13 @@ import {
useWorkflowPanelStore, useWorkflowPanelStore,
} from "@/store/WorkflowPanelStore"; } from "@/store/WorkflowPanelStore";
import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation"; import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation";
import { useSopToBlocksMutation } from "@/routes/workflows/hooks/useSopToBlocksMutation";
import { useDebugStore } from "@/store/useDebugStore"; import { useDebugStore } from "@/store/useDebugStore";
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore"; import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
import { useRecordingStore } from "@/store/useRecordingStore"; import { useRecordingStore } from "@/store/useRecordingStore";
import { useSettingsStore } from "@/store/SettingsStore"; import { useSettingsStore } from "@/store/SettingsStore";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { toast } from "@/components/ui/use-toast";
import { REACT_FLOW_EDGE_Z_INDEX } from "../constants"; import { REACT_FLOW_EDGE_Z_INDEX } from "../constants";
import type { NodeBaseData } from "../nodes/types"; import type { NodeBaseData } from "../nodes/types";
@@ -55,6 +58,11 @@ function EdgeWithAddButton({
const setWorkflowPanelState = useWorkflowPanelStore( const setWorkflowPanelState = useWorkflowPanelStore(
(state) => state.setWorkflowPanelState, (state) => state.setWorkflowPanelState,
); );
// SOP upload
const fileInputRef = useRef<HTMLInputElement>(null);
const sourceNode = nodes.find((node) => node.id === source);
const processRecordingMutation = useProcessRecordingMutation({ const processRecordingMutation = useProcessRecordingMutation({
browserSessionId: settingsStore.browserSessionId, browserSessionId: settingsStore.browserSessionId,
onSuccess: (result) => { onSuccess: (result) => {
@@ -67,9 +75,22 @@ function EdgeWithAddButton({
}, },
}); });
const isProcessing = processRecordingMutation.isPending; const sopToBlocksMutation = useSopToBlocksMutation({
onSuccess: (result) => {
// Reuse existing block insertion pattern
setRecordedBlocks(result, {
previous: source,
next: target,
parent: sourceNode?.parentId,
connectingEdgeType: "edgeWithAddButton",
});
},
});
const sourceNode = nodes.find((node) => node.id === source); // Derive upload state directly from mutation to avoid race conditions
const isUploadingSOP = sopToBlocksMutation.isPending;
const isProcessing = processRecordingMutation.isPending;
const isBusy = const isBusy =
(isProcessing || recordingStore.isRecording) && (isProcessing || recordingStore.isRecording) &&
@@ -170,6 +191,28 @@ function EdgeWithAddButton({
processRecordingMutation.mutate(); processRecordingMutation.mutate();
}; };
const onUploadSOP = () => {
fileInputRef.current?.click();
};
const handleSOPFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
if (!file.name.toLowerCase().endsWith(".pdf")) {
toast({
variant: "destructive",
title: "Invalid file type",
description: "Please select a PDF file",
});
e.target.value = "";
return;
}
sopToBlocksMutation.mutate(file);
e.target.value = "";
};
const adder = ( const adder = (
<Button <Button
size="icon" size="icon"
@@ -189,6 +232,8 @@ function EdgeWithAddButton({
radius="40px" radius="40px"
onAdd={onAdd} onAdd={onAdd}
onRecord={onRecord} onRecord={onRecord}
onUploadSOP={onUploadSOP}
isUploadingSOP={isUploadingSOP}
> >
{adder} {adder}
</WorkflowAddMenu> </WorkflowAddMenu>
@@ -207,6 +252,22 @@ function EdgeWithAddButton({
</WorkflowAdderBusy> </WorkflowAdderBusy>
); );
const handleCancelUpload = () => {
sopToBlocksMutation.cancel();
};
const sopUploadBusy = (
<WorkflowAdderBusy
color="#3b82f6"
operation="uploading"
size="small"
onComplete={() => {}}
onCancel={handleCancelUpload}
>
{adder}
</WorkflowAdderBusy>
);
return ( return (
<> <>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} /> <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
@@ -223,7 +284,20 @@ function EdgeWithAddButton({
}} }}
className="nodrag nopan" className="nodrag nopan"
> >
{isBusy ? busy : isDisabled ? adder : menu} <input
ref={fileInputRef}
type="file"
accept=".pdf"
className="hidden"
onChange={handleSOPFileChange}
/>
{isUploadingSOP
? sopUploadBusy
: isBusy
? busy
: isDisabled
? adder
: menu}
</div> </div>
</EdgeLabelRenderer> </EdgeLabelRenderer>
</> </>

View File

@@ -1,7 +1,9 @@
import { PlusIcon } from "@radix-ui/react-icons"; import { PlusIcon } from "@radix-ui/react-icons";
import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react"; import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
import { useRef } from "react";
import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation"; import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation";
import { useSopToBlocksMutation } from "@/routes/workflows/hooks/useSopToBlocksMutation";
import { useDebugStore } from "@/store/useDebugStore"; import { useDebugStore } from "@/store/useDebugStore";
import { import {
BranchContext, BranchContext,
@@ -13,6 +15,7 @@ import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
import { useRecordingStore } from "@/store/useRecordingStore"; import { useRecordingStore } from "@/store/useRecordingStore";
import { useSettingsStore } from "@/store/SettingsStore"; import { useSettingsStore } from "@/store/SettingsStore";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { toast } from "@/components/ui/use-toast";
import type { NodeAdderNode } from "./types"; import type { NodeAdderNode } from "./types";
import { WorkflowAddMenu } from "../../WorkflowAddMenu"; import { WorkflowAddMenu } from "../../WorkflowAddMenu";
@@ -33,6 +36,9 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
(state) => state.setRecordedBlocks, (state) => state.setRecordedBlocks,
); );
// SOP upload
const fileInputRef = useRef<HTMLInputElement>(null);
const deriveBranchContext = (previousNodeId: string | undefined) => { const deriveBranchContext = (previousNodeId: string | undefined) => {
const previousNode = nodes.find((node) => node.id === previousNodeId); const previousNode = nodes.find((node) => node.id === previousNodeId);
if ( if (
@@ -123,6 +129,21 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
}, },
}); });
const sopToBlocksMutation = useSopToBlocksMutation({
onSuccess: (result) => {
// Reuse existing block insertion pattern
setRecordedBlocks(result, {
previous: previous ?? null,
next: id,
parent: parentId,
connectingEdgeType: "default",
});
},
});
// Derive upload state directly from mutation to avoid race conditions
const isUploadingSOP = sopToBlocksMutation.isPending;
const isProcessing = processRecordingMutation.isPending; const isProcessing = processRecordingMutation.isPending;
const isBusy = const isBusy =
@@ -181,6 +202,28 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
processRecordingMutation.mutate(); processRecordingMutation.mutate();
}; };
const onUploadSOP = () => {
fileInputRef.current?.click();
};
const handleSOPFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
if (!file.name.toLowerCase().endsWith(".pdf")) {
toast({
variant: "destructive",
title: "Invalid file type",
description: "Please select a PDF file",
});
e.target.value = "";
return;
}
sopToBlocksMutation.mutate(file);
e.target.value = "";
};
const adder = ( const adder = (
<div <div
className={cn("rounded-full bg-slate-50 p-2", { className={cn("rounded-full bg-slate-50 p-2", {
@@ -206,14 +249,41 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
</WorkflowAdderBusy> </WorkflowAdderBusy>
); );
const handleCancelUpload = () => {
sopToBlocksMutation.cancel();
};
const sopUploadBusy = (
<WorkflowAdderBusy
color="#3b82f6"
operation="uploading"
onComplete={() => {}}
onCancel={handleCancelUpload}
>
{adder}
</WorkflowAdderBusy>
);
const menu = ( const menu = (
<WorkflowAddMenu onAdd={onAdd} onRecord={onRecord}> <WorkflowAddMenu
onAdd={onAdd}
onRecord={onRecord}
onUploadSOP={onUploadSOP}
isUploadingSOP={isUploadingSOP}
>
{adder} {adder}
</WorkflowAddMenu> </WorkflowAddMenu>
); );
return ( return (
<div> <div>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
className="hidden"
onChange={handleSOPFileChange}
/>
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
@@ -226,7 +296,13 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
id="b" id="b"
className="opacity-0" className="opacity-0"
/> />
{isBusy ? busy : isDisabled ? adder : menu} {isUploadingSOP
? sopUploadBusy
: isBusy
? busy
: isDisabled
? adder
: menu}
</div> </div>
); );
} }

View File

@@ -0,0 +1,95 @@
import { useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { toast } from "@/components/ui/use-toast";
import { WorkflowBlock, WorkflowParameter } from "../types/workflowTypes";
type SopToBlocksResponse = {
blocks: Array<WorkflowBlock>;
parameters: Array<WorkflowParameter>;
};
type UseSopToBlocksMutationOptions = {
onSuccess?: (result: SopToBlocksResponse) => void;
};
function useSopToBlocksMutation({ onSuccess }: UseSopToBlocksMutationOptions) {
const credentialGetter = useCredentialGetter();
const abortControllerRef = useRef<AbortController | null>(null);
const mutation = useMutation({
mutationFn: async (file: File) => {
// Create new AbortController for this request
abortControllerRef.current = new AbortController();
const formData = new FormData();
formData.append("file", file);
const client = await getClient(credentialGetter);
return (
await client.post<SopToBlocksResponse>(
"/workflows/sop-to-blocks",
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
signal: abortControllerRef.current.signal,
},
)
).data;
},
onSuccess: (result) => {
toast({
variant: "success",
title: "SOP converted",
description: `Generated ${result.blocks.length} block${result.blocks.length === 1 ? "" : "s"}`,
});
onSuccess?.(result);
},
onError: (error) => {
// Don't show error toast if request was cancelled
if (error instanceof AxiosError && error.code === "ERR_CANCELED") {
toast({
variant: "default",
title: "Upload cancelled",
description: "SOP conversion was cancelled.",
});
return;
}
// Graceful degradation for 404 (backend not deployed yet)
if (error instanceof AxiosError && error.response?.status === 404) {
toast({
variant: "destructive",
title: "Feature not yet available",
description:
"The Upload SOP feature is being deployed. Please try again later.",
});
} else {
const message =
error instanceof AxiosError
? error.response?.data?.detail || error.message
: error instanceof Error
? error.message
: "Failed to convert SOP";
toast({
variant: "destructive",
title: "Failed to convert SOP",
description: message,
});
}
},
});
const cancel = () => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
};
return { ...mutation, cancel };
}
export { useSopToBlocksMutation };
export type { SopToBlocksResponse };