Skip to content

Commit 722fef4

Browse files
authored
feat: add autofocus into the composer after a template is selected (#1978)
<!-- This is an auto-generated description by cubic. --> ## Summary by cubic Added autofocus to the email composer after selecting a template, so users can start editing right away. This improves workflow by placing the cursor at the end of the content automatically. <!-- End of auto-generated description by cubic. --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Added a delete confirmation toast with an action before removing a template. - Added an empty-state message when no templates are available. - Applying a template now fills subject, body, and recipients, then returns focus to the editor. - The template menu closes immediately after selection. - Smoother searching/filtering when browsing templates. - Bug Fixes - Templates list refreshes after creating a new template to reliably show the latest items. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 7545c7d commit 722fef4

File tree

1 file changed

+73
-46
lines changed

1 file changed

+73
-46
lines changed

apps/mail/components/create/template-button.tsx

Lines changed: 73 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { toast } from 'sonner';
1515
import { useMutation, useQueryClient } from '@tanstack/react-query';
1616
import { FileText, Save, Trash2 } from 'lucide-react';
17-
import React, { useState, useMemo, useDeferredValue, useCallback, startTransition } from 'react';
17+
import React, { useState, useMemo, useDeferredValue, useCallback } from 'react';
1818
import {
1919
Dialog,
2020
DialogContent,
@@ -25,21 +25,20 @@ import {
2525
import { Input } from '@/components/ui/input';
2626
import { TRPCClientError } from '@trpc/client';
2727

28-
type RecipientField = 'to' | 'cc' | 'bcc';
29-
30-
type Template = {
28+
type EmailTemplate = {
3129
id: string;
30+
userId: string;
3231
name: string;
33-
subject?: string | null;
34-
body?: string | null;
35-
to?: string[] | null;
36-
cc?: string[] | null;
37-
bcc?: string[] | null;
32+
subject: string | null;
33+
body: string | null;
34+
to: string[] | null;
35+
cc: string[] | null;
36+
bcc: string[] | null;
37+
createdAt: Date;
38+
updatedAt: Date;
3839
};
3940

40-
type TemplatesQueryData = {
41-
templates: Template[];
42-
} | undefined;
41+
type RecipientField = 'to' | 'cc' | 'bcc';
4342

4443
interface TemplateButtonProps {
4544
editor: Editor | null;
@@ -64,7 +63,7 @@ const TemplateButtonComponent: React.FC<TemplateButtonProps> = ({
6463
const queryClient = useQueryClient();
6564
const { data } = useTemplates();
6665

67-
const templates: Template[] = data?.templates ?? [];
66+
const templates = (data?.templates ?? []) as EmailTemplate[];
6867

6968
const [menuOpen, setMenuOpen] = useState(false);
7069
const [isSaving, setIsSaving] = useState(false);
@@ -81,6 +80,10 @@ const TemplateButtonComponent: React.FC<TemplateButtonProps> = ({
8180
);
8281
}, [deferredSearch, templates]);
8382

83+
const templatesById = useMemo(() => {
84+
return new Map(templates.map((t) => [t.id, t] as const));
85+
}, [templates]);
86+
8487
const { mutateAsync: createTemplate } = useMutation(trpc.templates.create.mutationOptions());
8588
const { mutateAsync: deleteTemplateMutation } = useMutation(
8689
trpc.templates.delete.mutationOptions(),
@@ -95,19 +98,17 @@ const TemplateButtonComponent: React.FC<TemplateButtonProps> = ({
9598

9699
setIsSaving(true);
97100
try {
98-
const newTemplate = await createTemplate({
101+
const normalizedSubject = subject.trim() ? subject : null;
102+
await createTemplate({
99103
name: templateName.trim(),
100-
subject: subject || '',
101104
body: editor.getHTML(),
102105
to: to.length ? to : undefined,
103106
cc: cc.length ? cc : undefined,
104107
bcc: bcc.length ? bcc : undefined,
108+
...(normalizedSubject !== null ? { subject: normalizedSubject } : {}),
105109
});
106-
queryClient.setQueryData(trpc.templates.list.queryKey(), (old: TemplatesQueryData) => {
107-
if (!old?.templates) return old;
108-
return {
109-
templates: [newTemplate.template, ...old.templates],
110-
};
110+
await queryClient.invalidateQueries({
111+
queryKey: trpc.templates.list.queryKey(),
111112
});
112113
toast.success('Template saved');
113114
setTemplateName('');
@@ -123,15 +124,18 @@ const TemplateButtonComponent: React.FC<TemplateButtonProps> = ({
123124
}
124125
};
125126

126-
const handleApplyTemplate = useCallback((template: Template) => {
127+
const handleApplyTemplate = useCallback((template: EmailTemplate) => {
127128
if (!editor) return;
128-
startTransition(() => {
129-
if (template.subject) setSubject(template.subject);
130-
if (template.body) editor.commands.setContent(template.body, false);
131-
if (template.to) setRecipients('to', template.to);
132-
if (template.cc) setRecipients('cc', template.cc);
133-
if (template.bcc) setRecipients('bcc', template.bcc);
134-
});
129+
130+
if (template.subject) setSubject(template.subject);
131+
if (template.body) editor.commands.setContent(template.body, false);
132+
if (template.to) setRecipients('to', template.to);
133+
if (template.cc) setRecipients('cc', template.cc);
134+
if (template.bcc) setRecipients('bcc', template.bcc);
135+
136+
setTimeout(() => {
137+
editor.chain().focus('end').run();
138+
}, 200);
135139
}, [editor, setSubject, setRecipients]);
136140

137141
const handleDeleteTemplate = useCallback(
@@ -153,6 +157,41 @@ const TemplateButtonComponent: React.FC<TemplateButtonProps> = ({
153157
[deleteTemplateMutation, queryClient, trpc.templates.list],
154158
);
155159

160+
const handleTemplateItemClick = useCallback(
161+
(e: React.MouseEvent<HTMLElement>) => {
162+
const templateId = (e.currentTarget as HTMLElement).dataset.templateId;
163+
if (!templateId) return;
164+
const template = templatesById.get(templateId);
165+
if (!template) return;
166+
handleApplyTemplate(template);
167+
setMenuOpen(false);
168+
},
169+
[handleApplyTemplate, templatesById],
170+
);
171+
172+
const handleDeleteButtonClick = useCallback(
173+
(e: React.MouseEvent<HTMLButtonElement>) => {
174+
e.stopPropagation();
175+
setMenuOpen(false);
176+
const templateId = (e.currentTarget as HTMLButtonElement).dataset.templateId;
177+
if (!templateId) return;
178+
const template = templatesById.get(templateId);
179+
const templateName = template?.name ?? 'this template';
180+
toast(`Delete template "${templateName}"?`, {
181+
duration: 10000,
182+
action: {
183+
label: 'Delete',
184+
onClick: () => handleDeleteTemplate(templateId),
185+
},
186+
className: 'pointer-events-auto',
187+
style: {
188+
pointerEvents: 'auto',
189+
},
190+
});
191+
},
192+
[templatesById, handleDeleteTemplate],
193+
);
194+
156195
return (
157196
<>
158197
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
@@ -171,7 +210,7 @@ const TemplateButtonComponent: React.FC<TemplateButtonProps> = ({
171210
>
172211
<Save className="mr-2 h-3.5 w-3.5" /> Save current as template
173212
</DropdownMenuItem>
174-
{templates.length > 0 ? (
213+
{templates.length > 0 ? (
175214
<DropdownMenuSub>
176215
<DropdownMenuSubTrigger>
177216
<FileText className="mr-2 h-3.5 w-3.5" /> Use template
@@ -187,30 +226,18 @@ const TemplateButtonComponent: React.FC<TemplateButtonProps> = ({
187226
/>
188227
</div>
189228
<div className="max-h-30 overflow-y-auto">
190-
{filteredTemplates.map((t: Template) => (
229+
{filteredTemplates.map((t) => (
191230
<DropdownMenuItem
192231
key={t.id}
232+
data-template-id={t.id}
193233
className="flex items-center justify-between gap-2"
194-
onClick={() => handleApplyTemplate(t)}
234+
onClick={handleTemplateItemClick}
195235
>
196236
<span className="flex-1 truncate text-left">{t.name}</span>
197237
<button
198238
className="p-0.5 text-muted-foreground hover:text-destructive"
199-
onClick={(e) => {
200-
e.stopPropagation();
201-
setMenuOpen(false);
202-
toast(`Delete template "${t.name}"?`, {
203-
duration: 10000,
204-
action: {
205-
label: 'Delete',
206-
onClick: () => handleDeleteTemplate(t.id),
207-
},
208-
className: 'pointer-events-auto',
209-
style: {
210-
pointerEvents: 'auto',
211-
},
212-
});
213-
}}
239+
data-template-id={t.id}
240+
onClick={handleDeleteButtonClick}
214241
>
215242
<Trash2 className="h-3.5 w-3.5" />
216243
</button>

0 commit comments

Comments
 (0)