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 { ReactNode } from "react";
import { SquareIcon, PlusIcon, UploadIcon } from "@radix-ui/react-icons";
import { ReactNode, useMemo } from "react";
import { RadialMenu } from "@/components/RadialMenu";
import { RadialMenu, RadialMenuItem } from "@/components/RadialMenu";
import { useDebugStore } from "@/store/useDebugStore";
import { useRecordingStore } from "@/store/useRecordingStore";
import { useSettingsStore } from "@/store/SettingsStore";
@@ -13,9 +13,11 @@ type WorkflowAddMenuProps = {
radius?: string;
rotateText?: boolean;
startAt?: number;
isUploadingSOP?: boolean;
// --
onAdd: () => void;
onRecord: () => void;
onUploadSOP: () => void;
};
function WorkflowAddMenu({
@@ -25,43 +27,71 @@ function WorkflowAddMenu({
radius = "80px",
rotateText = true,
startAt = 90,
isUploadingSOP = false,
// --
onAdd,
onRecord,
onUploadSOP,
}: WorkflowAddMenuProps) {
const debugStore = useDebugStore();
const recordingStore = useRecordingStore();
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 (
<RadialMenu
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();
},
},
]}
items={items}
buttonSize={buttonSize}
radius={radius}
startAt={startAt}

View File

@@ -1,5 +1,14 @@
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 {
Tooltip,
TooltipContent,
@@ -11,7 +20,7 @@ import { cn } from "@/util/utils";
import "./WorkflowAdderBusy.css";
type Operation = "recording" | "processing";
type Operation = "recording" | "processing" | "uploading";
type Size = "small" | "large";
@@ -29,8 +38,14 @@ type Props = {
* Color for the cover and ellipses. Defaults to "red".
*/
color?: string;
// --
/**
* Callback for when the operation completes (recording/processing).
*/
onComplete: () => void;
/**
* Callback for when the user cancels an upload operation.
*/
onCancel?: () => void;
};
function WorkflowAdderBusy({
@@ -39,10 +54,12 @@ function WorkflowAdderBusy({
size,
color = "red",
onComplete,
onCancel,
}: Props) {
const recordingStore = useRecordingStore();
const [isHovered, setIsHovered] = useState(false);
const [shouldBump, setShouldBump] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const bumpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const prevCountRef = useRef(0);
const eventCount = recordingStore.exposedEventCount;
@@ -73,11 +90,21 @@ function WorkflowAdderBusy({
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onComplete();
if (operation === "uploading" && onCancel) {
setShowCancelDialog(true);
} else {
onComplete();
}
return false;
};
const handleConfirmCancel = () => {
setShowCancelDialog(false);
onCancel?.();
};
return (
<TooltipProvider>
<div className="relative inline-block">
@@ -157,7 +184,11 @@ function WorkflowAdderBusy({
</TooltipTrigger>
<TooltipContent>
<p>
{operation === "recording" ? "Finish Recording" : "Processing..."}
{operation === "recording"
? "Finish Recording"
: operation === "uploading"
? "Converting SOP... (click to cancel)"
: "Processing..."}
</p>
</TooltipContent>
</Tooltip>
@@ -186,6 +217,28 @@ function WorkflowAdderBusy({
</Tooltip>
)}
</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>
);
}

View File

@@ -6,6 +6,7 @@ import {
getBezierPath,
useNodes,
} from "@xyflow/react";
import { useRef } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -13,11 +14,13 @@ import {
useWorkflowPanelStore,
} from "@/store/WorkflowPanelStore";
import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation";
import { useSopToBlocksMutation } from "@/routes/workflows/hooks/useSopToBlocksMutation";
import { useDebugStore } from "@/store/useDebugStore";
import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
import { useRecordingStore } from "@/store/useRecordingStore";
import { useSettingsStore } from "@/store/SettingsStore";
import { cn } from "@/util/utils";
import { toast } from "@/components/ui/use-toast";
import { REACT_FLOW_EDGE_Z_INDEX } from "../constants";
import type { NodeBaseData } from "../nodes/types";
@@ -55,6 +58,11 @@ function EdgeWithAddButton({
const setWorkflowPanelState = useWorkflowPanelStore(
(state) => state.setWorkflowPanelState,
);
// SOP upload
const fileInputRef = useRef<HTMLInputElement>(null);
const sourceNode = nodes.find((node) => node.id === source);
const processRecordingMutation = useProcessRecordingMutation({
browserSessionId: settingsStore.browserSessionId,
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 =
(isProcessing || recordingStore.isRecording) &&
@@ -170,6 +191,28 @@ function EdgeWithAddButton({
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 = (
<Button
size="icon"
@@ -189,6 +232,8 @@ function EdgeWithAddButton({
radius="40px"
onAdd={onAdd}
onRecord={onRecord}
onUploadSOP={onUploadSOP}
isUploadingSOP={isUploadingSOP}
>
{adder}
</WorkflowAddMenu>
@@ -207,6 +252,22 @@ function EdgeWithAddButton({
</WorkflowAdderBusy>
);
const handleCancelUpload = () => {
sopToBlocksMutation.cancel();
};
const sopUploadBusy = (
<WorkflowAdderBusy
color="#3b82f6"
operation="uploading"
size="small"
onComplete={() => {}}
onCancel={handleCancelUpload}
>
{adder}
</WorkflowAdderBusy>
);
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
@@ -223,7 +284,20 @@ function EdgeWithAddButton({
}}
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>
</EdgeLabelRenderer>
</>

View File

@@ -1,7 +1,9 @@
import { PlusIcon } from "@radix-ui/react-icons";
import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react";
import { useRef } from "react";
import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation";
import { useSopToBlocksMutation } from "@/routes/workflows/hooks/useSopToBlocksMutation";
import { useDebugStore } from "@/store/useDebugStore";
import {
BranchContext,
@@ -13,6 +15,7 @@ import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore";
import { useRecordingStore } from "@/store/useRecordingStore";
import { useSettingsStore } from "@/store/SettingsStore";
import { cn } from "@/util/utils";
import { toast } from "@/components/ui/use-toast";
import type { NodeAdderNode } from "./types";
import { WorkflowAddMenu } from "../../WorkflowAddMenu";
@@ -33,6 +36,9 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
(state) => state.setRecordedBlocks,
);
// SOP upload
const fileInputRef = useRef<HTMLInputElement>(null);
const deriveBranchContext = (previousNodeId: string | undefined) => {
const previousNode = nodes.find((node) => node.id === previousNodeId);
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 isBusy =
@@ -181,6 +202,28 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
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 = (
<div
className={cn("rounded-full bg-slate-50 p-2", {
@@ -206,14 +249,41 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
</WorkflowAdderBusy>
);
const handleCancelUpload = () => {
sopToBlocksMutation.cancel();
};
const sopUploadBusy = (
<WorkflowAdderBusy
color="#3b82f6"
operation="uploading"
onComplete={() => {}}
onCancel={handleCancelUpload}
>
{adder}
</WorkflowAdderBusy>
);
const menu = (
<WorkflowAddMenu onAdd={onAdd} onRecord={onRecord}>
<WorkflowAddMenu
onAdd={onAdd}
onRecord={onRecord}
onUploadSOP={onUploadSOP}
isUploadingSOP={isUploadingSOP}
>
{adder}
</WorkflowAddMenu>
);
return (
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
className="hidden"
onChange={handleSOPFileChange}
/>
<Handle
type="source"
position={Position.Bottom}
@@ -226,7 +296,13 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
id="b"
className="opacity-0"
/>
{isBusy ? busy : isDisabled ? adder : menu}
{isUploadingSOP
? sopUploadBusy
: isBusy
? busy
: isDisabled
? adder
: menu}
</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 };