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 { 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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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