Add Upload SOP option to workflow editor + button - frontend (#4566)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user