feat(templates): inline rename and delete actions in template header and list

Adds an actions dropdown to the template detail header with inline rename
(double-click or "Rename") and delete with a confirmation dialog. The
listing learns the same: a "Rename" item in the row dropdown and context
menu opens an in-row editor, and clicking the row no longer navigates while
a rename is in progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-13 20:52:42 +07:00
co-authored by Cursor
parent 465ea406f4
commit bce4d79069
2 changed files with 384 additions and 24 deletions
+245 -14
View File
@@ -1,8 +1,21 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { ChevronDown, FileSymlink, PanelRightClose, PanelRightOpen, Save } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Check,
ChevronDown,
Ellipsis,
FileSymlink,
Loader2,
PanelRightClose,
PanelRightOpen,
Pencil,
Save,
Trash,
X,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { z } from 'zod';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
@@ -10,13 +23,27 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/co
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Form, FormControl, FormField, FormItem } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextareaAutosize } from '@/components/ui/input-group';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupTextareaAutosize,
} from '@/components/ui/input-group';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Spinner } from '@/components/ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useFlowTemplateQuery } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { cn } from '@/lib/utils';
@@ -209,7 +236,7 @@ Action plan:
const Template = () => {
const navigate = useNavigate();
const { templateId } = useParams<{ templateId?: string }>();
const { createTemplate, updateTemplate } = useTemplates();
const { createTemplate, deleteTemplate, updateTemplate } = useTemplates();
const { isMobile } = useBreakpoint();
const isNew = templateId === 'new';
@@ -218,6 +245,11 @@ const Template = () => {
const [isReplaceConfirmOpen, setIsReplaceConfirmOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [pendingPreset, setPendingPreset] = useState<null | { text: string; title: string }>(null);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const editingInputRef = useRef<HTMLInputElement>(null);
// Fetch template data when editing
const { data: templateData, loading: isLoadingTemplate } = useFlowTemplateQuery({
@@ -243,10 +275,96 @@ const Template = () => {
reset({ text, title }, { keepDefaultValues: false });
}, [templateData, isNew, reset]);
// Reset inline-edit state when navigating between templates so the input
// doesn't carry over a stale draft from a previous template.
useEffect(() => {
setIsEditingTitle(false);
}, [templateId]);
// Focus and select the rename input when the inline editor opens. We can't
// rely on `autoFocus` here: the input mounts inside the same render cycle
// that closes the Radix DropdownMenu, and the dropdown's own focus restore
// (which it schedules via `requestAnimationFrame`) wins the race against
// React's autoFocus effect. Defer our focus to the next frame so it lands
// *after* Radix has finished its restore. Selecting the text lets the user
// overwrite the title in one keystroke.
useEffect(() => {
if (!isEditingTitle) {
return;
}
const id = requestAnimationFrame(() => {
const input = editingInputRef.current;
if (!input) {
return;
}
input.focus();
input.select();
});
return () => cancelAnimationFrame(id);
}, [isEditingTitle]);
// Check if form has unsaved changes
const hasUnsavedChanges = formState.isDirty;
const templateName = templateData?.flowTemplate?.title ?? null;
const handleTemplateRenameStart = useCallback(() => {
setIsEditingTitle(true);
}, []);
const handleTemplateRenameCancel = useCallback(() => {
setIsEditingTitle(false);
}, []);
const handleTemplateRenameSave = useCallback(async () => {
const newTitle = editingInputRef.current?.value.trim();
const template = templateData?.flowTemplate;
if (!templateId || !newTitle || !template) {
return;
}
if (newTitle === template.title) {
setIsEditingTitle(false);
return;
}
setIsRenaming(true);
try {
// Preserve the original `text` from the server so that an inline
// rename never overwrites unsaved edits in the form below.
await updateTemplate(templateId, { text: template.text, title: newTitle });
toast.success('Template renamed successfully');
setIsEditingTitle(false);
} catch {
// Error already handled in provider with toast
} finally {
setIsRenaming(false);
}
}, [templateId, templateData?.flowTemplate, updateTemplate]);
const handleTemplateDelete = useCallback(async () => {
if (!templateId) {
return;
}
setIsDeleting(true);
try {
await deleteTemplate(templateId);
navigate('/templates', { replace: true });
} catch {
// Error already handled in provider with toast
} finally {
setIsDeleting(false);
}
}, [templateId, deleteTemplate, navigate]);
const handleSubmit = async (values: FormValues) => {
if (isSaving) {
return;
@@ -304,6 +422,8 @@ const Template = () => {
}
}, [pendingPreset, setValue]);
const canShowActions = !isNew && !!templateData?.flowTemplate;
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
@@ -313,19 +433,121 @@ const Template = () => {
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{isNew ? 'New template' : (templateName ?? 'Template')}</BreadcrumbPage>
<BreadcrumbItem className="gap-2">
{isEditingTitle && canShowActions ? (
<InputGroup className="h-8 w-64 max-w-full">
<InputGroupInput
className="text-foreground"
defaultValue={templateName ?? ''}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleTemplateRenameSave();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
handleTemplateRenameCancel();
}
}}
placeholder="Template title"
ref={editingInputRef}
/>
<InputGroupAddon
align="inline-end"
className="gap-0 pr-2"
>
<InputGroupButton
aria-label="Save"
disabled={isRenaming}
onClick={() => handleTemplateRenameSave()}
>
{isRenaming ? <Loader2 className="animate-spin" /> : <Check />}
</InputGroupButton>
<InputGroupButton
aria-label="Cancel"
onClick={() => handleTemplateRenameCancel()}
>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
) : canShowActions ? (
<Tooltip>
<TooltipTrigger asChild>
<BreadcrumbPage
className="cursor-text select-none"
onDoubleClick={handleTemplateRenameStart}
>
{templateName ?? 'Template'}
</BreadcrumbPage>
</TooltipTrigger>
<TooltipContent>Double-click to rename</TooltipContent>
</Tooltip>
) : (
<BreadcrumbPage>{isNew ? 'New template' : (templateName ?? 'Template')}</BreadcrumbPage>
)}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Button
className="ml-auto"
onClick={() => setIsAsideOpen((open) => !open)}
size="icon"
variant="ghost"
>
{isAsideOpen ? <PanelRightClose /> : <PanelRightOpen />}
</Button>
<div className="ml-auto flex items-center gap-2">
<Button
onClick={() => setIsAsideOpen((open) => !open)}
size="icon"
variant="ghost"
>
{isAsideOpen ? <PanelRightClose /> : <PanelRightOpen />}
</Button>
{canShowActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Template actions"
className="size-8 p-0"
variant="ghost"
>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onCloseAutoFocus={(event) => {
// Radix returns focus to the trigger on close. When the
// selected action mounts the rename input, prevent that
// restore so our deferred focus actually wins.
if (isEditingTitle) {
event.preventDefault();
}
}}
>
<DropdownMenuItem onClick={handleTemplateRenameStart}>
<Pencil className="size-3" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isDeleting}
onClick={() => setIsDeleteDialogOpen(true)}
>
{isDeleting ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash className="size-4" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
);
@@ -544,6 +766,15 @@ const Template = () => {
isOpen={isReplaceConfirmOpen}
title="Replace content?"
/>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={handleTemplateDelete}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={templateName ?? undefined}
itemType="template"
/>
</>
);
};
+139 -10
View File
@@ -1,8 +1,21 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, Ellipsis, FileText, Loader2, Pencil, Plus, Trash } from 'lucide-react';
import { useState } from 'react';
import {
ArrowDown,
ArrowUp,
Check,
Ellipsis,
FileText,
Loader2,
Pencil,
PencilLine,
Plus,
Trash,
X,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
@@ -14,8 +27,10 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { StatusCard } from '@/components/ui/status-card';
@@ -24,19 +39,65 @@ import { type Template, useTemplates } from '@/providers/templates-provider';
const Templates = () => {
const navigate = useNavigate();
const { deleteTemplate, templates } = useTemplates();
const { deleteTemplate, templates, updateTemplate } = useTemplates();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingTemplate, setDeletingTemplate] = useState<null | Template>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const [editingTemplateId, setEditingTemplateId] = useState<null | string>(null);
const [isRenameLoading, setIsRenameLoading] = useState(false);
const editingInputRef = useRef<HTMLInputElement>(null);
const handleTemplateOpen = (templateId: string) => {
navigate(`/templates/${templateId}`);
};
const handleTemplateOpen = useCallback(
(templateId: string) => {
navigate(`/templates/${templateId}`);
},
[navigate],
);
const handleDeleteDialogOpen = (template: Template) => {
const handleDeleteDialogOpen = useCallback((template: Template) => {
setDeletingTemplate(template);
setIsDeleteDialogOpen(true);
};
}, []);
const handleTemplateRenameStart = useCallback((template: Template) => {
setEditingTemplateId(template.id);
}, []);
const handleTemplateRenameCancel = useCallback(() => {
setEditingTemplateId(null);
}, []);
const handleTemplateRenameSave = useCallback(async () => {
const newTitle = editingInputRef.current?.value.trim();
if (!editingTemplateId || !newTitle) {
return;
}
const template = templates.find((t) => t.id === editingTemplateId);
if (!template) {
return;
}
if (newTitle === template.title) {
setEditingTemplateId(null);
return;
}
setIsRenameLoading(true);
try {
await updateTemplate(editingTemplateId, { text: template.text, title: newTitle });
toast.success('Template renamed successfully');
setEditingTemplateId(null);
} catch {
// Error already handled in provider with toast
} finally {
setIsRenameLoading(false);
}
}, [editingTemplateId, templates, updateTemplate]);
const handleDelete = async () => {
if (!deletingTemplate) {
@@ -63,7 +124,60 @@ const Templates = () => {
const columns: ColumnDef<Template>[] = [
{
accessorKey: 'title',
cell: ({ row }) => <div className="font-medium">{row.getValue('title')}</div>,
cell: ({ row }) => {
const template = row.original;
const isEditing = editingTemplateId === template.id;
const title = row.getValue('title') as string;
if (isEditing) {
return (
<InputGroup
className="h-8"
onClick={(e) => e.stopPropagation()}
>
<InputGroupInput
autoFocus
defaultValue={title}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleTemplateRenameSave();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
handleTemplateRenameCancel();
}
}}
placeholder="Template title"
ref={editingInputRef}
/>
<InputGroupAddon
align="inline-end"
className="gap-0 pr-2"
>
<InputGroupButton
aria-label="Save"
disabled={isRenameLoading}
onClick={() => handleTemplateRenameSave()}
>
{isRenameLoading ? <Loader2 className="animate-spin" /> : <Check />}
</InputGroupButton>
<InputGroupButton
aria-label="Cancel"
onClick={() => handleTemplateRenameCancel()}
>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
}
return <div className="font-medium">{title}</div>;
},
header: ({ column }) => {
const sorted = column.getIsSorted();
@@ -119,6 +233,7 @@ const Templates = () => {
<DropdownMenuTrigger asChild>
<Button
className="size-8 p-0"
onClick={(e) => e.stopPropagation()}
variant="ghost"
>
<Ellipsis />
@@ -127,11 +242,17 @@ const Templates = () => {
<DropdownMenuContent
align="end"
className="min-w-24"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={() => handleTemplateOpen(template.id)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleTemplateRenameStart(template)}>
<Pencil className="size-3" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={deletingIds.has(template.id)}
onClick={() => handleDeleteDialogOpen(template)}
@@ -167,6 +288,10 @@ const Templates = () => {
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => handleTemplateRenameStart(template)}>
<PencilLine />
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={deletingIds.has(template.id)}
@@ -239,7 +364,11 @@ const Templates = () => {
data={templates}
filterColumn="title"
filterPlaceholder="Filter templates..."
onRowClick={(template) => handleTemplateOpen(template.id)}
onRowClick={(template) => {
if (editingTemplateId !== template.id) {
handleTemplateOpen(template.id);
}
}}
renderRowContextMenu={renderRowContextMenu}
/>