mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
feat: add keyboard shortcut for save
This commit is contained in:
parent
6f34433e00
commit
fec8da9d20
10 changed files with 357 additions and 127 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
157
ui/src/components/ui/alert-dialog.tsx
Normal file
157
ui/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue