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:
spuice 2026-05-12 08:24:12 -05:00 committed by GitHub
parent 137b5e9f89
commit f2cb6499e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 137 additions and 8 deletions

View file

@ -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 */}

View file

@ -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>