From bce4d79069622aacbb4180d7720cb2318063cf0b Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Wed, 13 May 2026 20:52:42 +0700 Subject: [PATCH] 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 --- frontend/src/pages/templates/template.tsx | 259 +++++++++++++++++++-- frontend/src/pages/templates/templates.tsx | 149 +++++++++++- 2 files changed, 384 insertions(+), 24 deletions(-) diff --git a/frontend/src/pages/templates/template.tsx b/frontend/src/pages/templates/template.tsx index d4156cc6..4e02da4d 100644 --- a/frontend/src/pages/templates/template.tsx +++ b/frontend/src/pages/templates/template.tsx @@ -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); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const editingInputRef = useRef(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 = (
@@ -313,19 +433,121 @@ const Template = () => { /> - - {isNew ? 'New template' : (templateName ?? 'Template')} + + {isEditingTitle && canShowActions ? ( + + { + if (event.key === 'Enter') { + event.preventDefault(); + handleTemplateRenameSave(); + + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + handleTemplateRenameCancel(); + } + }} + placeholder="Template title" + ref={editingInputRef} + /> + + handleTemplateRenameSave()} + > + {isRenaming ? : } + + handleTemplateRenameCancel()} + > + + + + + ) : canShowActions ? ( + + + + {templateName ?? 'Template'} + + + Double-click to rename + + ) : ( + {isNew ? 'New template' : (templateName ?? 'Template')} + )} - +
+ + {canShowActions && ( + + + + + { + // 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(); + } + }} + > + + + Rename + + + setIsDeleteDialogOpen(true)} + > + {isDeleting ? ( + <> + + Deleting... + + ) : ( + <> + + Delete + + )} + + + + )} +
); @@ -544,6 +766,15 @@ const Template = () => { isOpen={isReplaceConfirmOpen} title="Replace content?" /> + ); }; diff --git a/frontend/src/pages/templates/templates.tsx b/frontend/src/pages/templates/templates.tsx index 867bbd02..00d109ed 100644 --- a/frontend/src/pages/templates/templates.tsx +++ b/frontend/src/pages/templates/templates.tsx @@ -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); const [deletingIds, setDeletingIds] = useState>(new Set()); + const [editingTemplateId, setEditingTemplateId] = useState(null); + const [isRenameLoading, setIsRenameLoading] = useState(false); + const editingInputRef = useRef(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