mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-23 11:36:29 +00:00
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:
co-authored by
Cursor
parent
465ea406f4
commit
bce4d79069
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user