feat: add keyboard shortcut for save

This commit is contained in:
Abhishek Kumar 2026-01-02 16:02:46 +05:30
parent 6f34433e00
commit fec8da9d20
10 changed files with 357 additions and 127 deletions

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, PlusIcon, Trash2Icon, Wrench } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ToolBadges } from "@/components/flow/ToolBadges";
@ -56,6 +56,14 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
// Compute if form has unsaved changes (only check prompt, name)
const isDirty = useMemo(() => {
return (
prompt !== (data.prompt ?? "") ||
name !== (data.name ?? "")
);
}, [prompt, name, data]);
const handleSave = async () => {
handleSaveNodeData({
...data,
@ -150,6 +158,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
nodeData={data}
title="Edit Agent"
onSave={handleSave}
isDirty={isDirty}
>
{open && (
<AgentNodeEditForm

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, OctagonX, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
@ -51,6 +51,14 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
// Compute if form has unsaved changes (simplified: only check prompt, name)
const isDirty = useMemo(() => {
return (
prompt !== (data.prompt ?? "") ||
name !== (data.name ?? "")
);
}, [prompt, name, data]);
const handleSave = async () => {
handleSaveNodeData({
...data,
@ -125,6 +133,7 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
nodeData={data}
title="End Call"
onSave={handleSave}
isDirty={isDirty}
>
{open && (
<EndCallEditForm

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, Trash2Icon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
@ -33,6 +33,14 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
const [prompt, setPrompt] = useState(data.prompt);
const [name, setName] = useState(data.name);
// Compute if form has unsaved changes (simplified: only check prompt, name)
const isDirty = useMemo(() => {
return (
prompt !== (data.prompt ?? "") ||
name !== (data.name ?? "")
);
}, [prompt, name, data]);
const handleSave = async () => {
handleSaveNodeData({
...data,
@ -99,6 +107,7 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
nodeData={data}
title="Edit Global Node"
onSave={handleSave}
isDirty={isDirty}
>
{open && (
<GlobalNodeEditForm

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Play, Wrench } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ToolBadges } from "@/components/flow/ToolBadges";
@ -58,6 +58,14 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
const [delayedStartDuration, setDelayedStartDuration] = useState(data.delayed_start_duration ?? 2);
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
// Compute if form has unsaved changes (only check prompt, name)
const isDirty = useMemo(() => {
return (
prompt !== (data.prompt ?? "") ||
name !== (data.name ?? "")
);
}, [prompt, name, data]);
const handleSave = async () => {
handleSaveNodeData({
...data,
@ -146,6 +154,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
nodeData={data}
title="Start Call"
onSave={handleSave}
isDirty={isDirty}
>
{open && (
<StartCallEditForm

View file

@ -1,7 +1,7 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Check, Copy, Edit, Trash2Icon, Webhook } from "lucide-react";
import Link from "next/link";
import { memo, useEffect, useState } from "react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
@ -40,6 +40,11 @@ export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
// Copy state for button feedback
const [copied, setCopied] = useState(false);
// Compute if form has unsaved changes (simplified: only check name)
const isDirty = useMemo(() => {
return name !== (data.name || "API Trigger");
}, [name, data.name]);
const handleCopy = async () => {
await navigator.clipboard.writeText(endpoint);
setCopied(true);
@ -137,6 +142,7 @@ export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
nodeData={data}
title="Edit API Trigger"
onSave={handleSave}
isDirty={isDirty}
>
{open && (
<TriggerNodeEditForm

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Circle, Edit, Link2, Trash2Icon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
@ -47,6 +47,14 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
const [jsonError, setJsonError] = useState<string | null>(null);
const [endpointError, setEndpointError] = useState<string | null>(null);
// Compute if form has unsaved changes (simplified: only check name, endpoint)
const isDirty = useMemo(() => {
return (
name !== (data.name || "Webhook") ||
endpointUrl !== (data.endpoint_url || "")
);
}, [name, endpointUrl, data]);
const handleSave = async () => {
// Validate endpoint URL
if (!endpointUrl.trim()) {
@ -168,6 +176,7 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
title="Edit Webhook"
onSave={handleSave}
error={endpointError || jsonError}
isDirty={isDirty}
>
{open && (
<WebhookNodeEditForm

View file

@ -1,7 +1,17 @@
import { AlertCircle } from "lucide-react";
import { ReactNode } from "react";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { FlowNodeData } from "@/components/flow/types";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -13,6 +23,7 @@ interface NodeEditDialogProps {
children: ReactNode;
onSave?: () => void;
error?: string | null;
isDirty?: boolean;
}
export const NodeEditDialog = ({
@ -22,18 +33,52 @@ export const NodeEditDialog = ({
title,
children,
onSave,
error
error,
isDirty = false
}: NodeEditDialogProps) => {
const [showDiscardAlert, setShowDiscardAlert] = useState(false);
const handleClose = () => onOpenChange(false);
const handleSave = () => {
const handleSave = useCallback(() => {
if (onSave) {
onSave();
}
};
}, [onSave]);
// Intercept dialog close attempts when dirty
const handleOpenChange = useCallback((newOpen: boolean) => {
// If trying to close and form is dirty, show confirmation
if (!newOpen && isDirty) {
setShowDiscardAlert(true);
return;
}
onOpenChange(newOpen);
}, [isDirty, onOpenChange]);
// Handle confirmed discard
const handleConfirmDiscard = useCallback(() => {
setShowDiscardAlert(false);
onOpenChange(false);
}, [onOpenChange]);
// Handle Cmd+S / Ctrl+S keyboard shortcut to save
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, handleSave]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[85vh] overflow-y-auto"
style={{ maxWidth: "1200px", width: "95vw" }}
@ -61,11 +106,37 @@ export const NodeEditDialog = ({
)}
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button
variant="outline"
onClick={isDirty ? () => setShowDiscardAlert(true) : handleClose}
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</DialogFooter>
</DialogContent>
{/* Discard changes confirmation dialog */}
<AlertDialog open={showDiscardAlert} onOpenChange={setShowDiscardAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to discard them?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Editing</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDiscard}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Discard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
};