mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: inline rename of workflow on the editor page (#273)
* feat: inline rename of workflow on the editor page Add a pencil icon next to the workflow name in WorkflowEditorHeader.tsx. Clicking it swaps the <h1> for an inline <Input> that saves on Enter or Blur, cancels on Esc, validates empty/whitespace input, skips no-op renames, rolls back on API error, and guards against double-submit. Reuses saveWorkflowConfigurations (the existing rename path used by the Settings page) — no parallel API call. Closes https://github.com/dograh-hq/dograh/issues/252. * refactor: collapse rename state and remove silent config fallback - WorkflowEditorHeader: replace (isEditingName, nameDraft, nameError, isRenaming) with a single discriminated-union state. Error and saving were mutually exclusive and both meaningless in display mode; the union makes the bad combinations unrepresentable and structurally prevents the Enter -> disable-input -> blur -> re-fire race that the original code only guarded against by convention. - RenderWorkflow: drop the `?? DEFAULT_WORKFLOW_CONFIGURATIONS` fallback in renameWorkflow. In normal flow the store is initialized before the header renders, but if it ever weren't, the fallback would silently overwrite the server-side config with defaults. Throw instead so the header's existing catch surfaces a toast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Abhishek Kumar <abhishek@a6k.me> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
137b5e9f89
commit
f2cb6499e1
2 changed files with 137 additions and 8 deletions
|
|
@ -92,6 +92,8 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
setIsAddNodePanelOpen,
|
||||
handleNodeSelect,
|
||||
saveWorkflow,
|
||||
workflowConfigurations,
|
||||
saveWorkflowConfigurations,
|
||||
onConnect,
|
||||
onEdgesChange,
|
||||
onNodesChange,
|
||||
|
|
@ -312,6 +314,17 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
}
|
||||
}, [saveWorkflow, isViewingHistoricalVersion, fetchVersions]);
|
||||
|
||||
const renameWorkflow = useCallback(async (newName: string) => {
|
||||
// The header doesn't render the pencil until the page has mounted with
|
||||
// initial data, so workflowConfigurations is non-null by the time this
|
||||
// runs. Throw rather than silently sending DEFAULT_WORKFLOW_CONFIGURATIONS,
|
||||
// which would overwrite the saved server-side config.
|
||||
if (!workflowConfigurations) {
|
||||
throw new Error("Workflow configurations not loaded");
|
||||
}
|
||||
await saveWorkflowConfigurations(workflowConfigurations, newName);
|
||||
}, [saveWorkflowConfigurations, workflowConfigurations]);
|
||||
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
const workflowContextValue = useMemo(() => ({
|
||||
saveWorkflow: guardedSaveWorkflow,
|
||||
|
|
@ -342,6 +355,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
onBackToDraft={handleBackToDraft}
|
||||
hasDraft={hasDraft}
|
||||
onPublished={handlePublished}
|
||||
renameWorkflow={renameWorkflow}
|
||||
/>
|
||||
|
||||
{/* Workflow Canvas */}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { ReactFlowInstance } from "@xyflow/react";
|
||||
import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Phone, Rocket } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Pencil, Phone, Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -47,6 +48,7 @@ interface WorkflowEditorHeaderProps {
|
|||
onBackToDraft: () => void;
|
||||
hasDraft: boolean;
|
||||
onPublished: () => void;
|
||||
renameWorkflow: (newName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const WorkflowEditorHeader = ({
|
||||
|
|
@ -65,12 +67,25 @@ export const WorkflowEditorHeader = ({
|
|||
onPublished,
|
||||
workflowId,
|
||||
workflowUuid,
|
||||
renameWorkflow,
|
||||
}: WorkflowEditorHeaderProps) => {
|
||||
const router = useRouter();
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const [savingWorkflow, setSavingWorkflow] = useState(false);
|
||||
const [duplicating, setDuplicating] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
// One discriminated-union state instead of (isEditingName, nameDraft,
|
||||
// nameError, isRenaming): they're not independent — error and saving are
|
||||
// mutually exclusive, and both are meaningless in the display state. The
|
||||
// union makes the bad combinations unrepresentable and structurally
|
||||
// prevents the Enter→disable-input→blur→re-fire race.
|
||||
type RenameState =
|
||||
| { kind: "display" }
|
||||
| { kind: "editing"; draft: string; error: string | null }
|
||||
| { kind: "saving"; draft: string };
|
||||
const [rename, setRename] = useState<RenameState>({ kind: "display" });
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||
const isCallDisabled = isDirty || hasValidationErrors;
|
||||
|
|
@ -158,6 +173,66 @@ export const WorkflowEditorHeader = ({
|
|||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const enterEditMode = () => {
|
||||
setRename({ kind: "editing", draft: workflowName, error: null });
|
||||
};
|
||||
|
||||
const exitEditMode = () => {
|
||||
setRename({ kind: "display" });
|
||||
// Return focus to the pencil button so keyboard users aren't stranded.
|
||||
// Defer to next tick so React commits the input unmount first.
|
||||
setTimeout(() => renameButtonRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const attemptSave = async () => {
|
||||
// Only "editing" can initiate a save. This also guards against the
|
||||
// blur fired when disabling the input transitions us to "saving".
|
||||
if (rename.kind !== "editing") return;
|
||||
const trimmed = rename.draft.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setRename({ ...rename, error: "Name cannot be empty" });
|
||||
return;
|
||||
}
|
||||
if (trimmed === workflowName) {
|
||||
// No-op: exit cleanly with no API call.
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
setRename({ kind: "saving", draft: rename.draft });
|
||||
try {
|
||||
await renameWorkflow(trimmed);
|
||||
// Success: store update already propagated workflowName. Exit edit mode.
|
||||
exitEditMode();
|
||||
} catch {
|
||||
// Roll back: keep user's typed value, reopen the input, focus it,
|
||||
// surface a sonner toast (matches existing duplicate/publish failure pattern).
|
||||
toast.error("Failed to rename workflow");
|
||||
setRename({ kind: "editing", draft: trimmed, error: null });
|
||||
setTimeout(() => nameInputRef.current?.focus(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void attemptSave();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
exitEditMode();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameBlur = () => {
|
||||
// Ignore the blur fired when the input is disabled during save.
|
||||
if (rename.kind !== "editing") return;
|
||||
// On blur with empty/whitespace, revert silently to display mode so the user is never trapped.
|
||||
if (rename.draft.trim().length === 0) {
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
void attemptSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full h-14 px-4 bg-[#1a1a1a] border-b border-[#2a2a2a]">
|
||||
{/* Left section: Mobile menu + Back button + Workflow name */}
|
||||
|
|
@ -177,12 +252,52 @@ export const WorkflowEditorHeader = ({
|
|||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-base font-medium text-white whitespace-nowrap">
|
||||
<span className="md:hidden">
|
||||
{workflowName.length > 8 ? `${workflowName.slice(0, 8)}…` : workflowName}
|
||||
</span>
|
||||
<span className="hidden md:inline">{workflowName}</span>
|
||||
</h1>
|
||||
{rename.kind !== "display" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
value={rename.draft}
|
||||
onChange={(e) => {
|
||||
// onChange can't fire while disabled (kind === "saving"),
|
||||
// but the type guard is needed for the discriminated union.
|
||||
if (rename.kind === "editing") {
|
||||
setRename({ ...rename, draft: e.target.value, error: null });
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onBlur={handleRenameBlur}
|
||||
disabled={rename.kind === "saving"}
|
||||
autoFocus
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
aria-label="Workflow name"
|
||||
aria-invalid={rename.kind === "editing" && rename.error !== null}
|
||||
className="h-8 max-w-xs bg-[#2a2a2a] border-[#3a3a3a] text-white text-base font-medium"
|
||||
/>
|
||||
{rename.kind === "editing" && rename.error && (
|
||||
<span className="text-xs text-red-500" role="alert">{rename.error}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-base font-medium text-white whitespace-nowrap truncate max-w-[14rem] md:max-w-md">
|
||||
<span className="md:hidden">
|
||||
{workflowName.length > 8 ? `${workflowName.slice(0, 8)}…` : workflowName}
|
||||
</span>
|
||||
<span className="hidden md:inline">{workflowName}</span>
|
||||
</h1>
|
||||
{!isViewingHistoricalVersion && (
|
||||
<button
|
||||
ref={renameButtonRef}
|
||||
type="button"
|
||||
onClick={enterEditMode}
|
||||
aria-label="Rename workflow"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#2a2a2a] transition-colors"
|
||||
>
|
||||
<Pencil className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue