fix: save a rich text draft as an HTML template and edit it in the rich editor #1506

This commit is contained in:
Linus Rath
2026-11-02 22:06:02 +01:00
parent 6990abff45
commit 0e801edcf2
4 changed files with 180 additions and 18 deletions
@@ -29,7 +29,14 @@ vi.mock('@/components/templates/template-picker', () => ({
TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record<string, string>) => void }) =>
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
}));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
// Save as template: what the composer hands the form.
const savedForm = vi.hoisted(() => ({ initialData: null as null | { body?: string; isHTML?: boolean } }));
vi.mock('@/components/templates/template-form', () => ({
TemplateForm: (props: { initialData?: { body?: string; isHTML?: boolean } }) => {
savedForm.initialData = props.initialData ?? null;
return null;
},
}));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
@@ -274,4 +281,53 @@ describe('applying a template to a reply (#911)', () => {
expect(textarea.value).toBe('Hello Mr. XDear reader\n\nCheers, Me\n\nreply_line\n> Original message');
});
// An HTML template goes into a plain text draft as its text (#1506).
it('puts an HTML template into a plain text reply as text, not markup', async () => {
useSettingsStore.setState({ plainTextMode: true });
Object.assign(TEMPLATE, { body: '<p>Dear <strong>reader</strong></p>', isHTML: true });
try {
render(<EmailComposer mode="reply" replyTo={REPLY_TO} />);
const textarea = await waitFor(() => {
const el = document.querySelector('textarea');
expect(el).toBeTruthy();
return el as HTMLTextAreaElement;
});
await applyTemplate();
expect(textarea.value).toContain('Dear reader');
expect(textarea.value).not.toContain('<');
} finally {
Object.assign(TEMPLATE, { body: 'Dear reader', isHTML: false });
}
});
describe('saving the draft as a template (#1506)', () => {
afterEach(() => {
savedForm.initialData = null;
});
async function openSaveAsTemplate() {
fireEvent.click(await screen.findByTestId('composer-templates-menu'));
fireEvent.click(await screen.findByRole('menuitem', { name: 'save_as_template' }));
await waitFor(() => expect(savedForm.initialData).not.toBeNull());
return savedForm.initialData!;
}
it('marks a rich text draft as HTML', async () => {
await openReply();
const data = await openSaveAsTemplate();
expect(data.body).toContain('<');
expect(data.isHTML).toBe(true);
});
it('leaves a plain text draft as plain text', async () => {
useSettingsStore.setState({ plainTextMode: true });
render(<EmailComposer mode="reply" replyTo={REPLY_TO} />);
await waitFor(() => expect(document.querySelector('textarea')).toBeTruthy());
const data = await openSaveAsTemplate();
expect(data.isHTML).toBe(false);
});
});
});
+8 -4
View File
@@ -2168,10 +2168,13 @@ export function EmailComposer({
? substitutePlaceholders(template.body, filledValues)
: template.body;
// In plain text mode, use template body as-is; otherwise convert to HTML
let bodyContent = plainTextMode || template.isHTML
? filledBody
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
// Match the template to the draft's format: an HTML template goes into a
// plain-text draft as its text, not its markup (#1506).
let bodyContent = plainTextMode
? (template.isHTML ? htmlToPlainText(filledBody) : filledBody)
: template.isHTML
? filledBody
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
// Plugins may rewrite the template before it goes in (onTemplateRender).
const rendered = await renderTemplateWithPlugins(template.id, {
subject: filledSubject,
@@ -5299,6 +5302,7 @@ export function EmailComposer({
initialData={{
subject,
body,
isHTML: !plainTextMode,
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
@@ -0,0 +1,64 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TemplateForm } from '../template-form';
// A template saved from a rich text draft is HTML: the form shows it in the
// composer's editor with HTML ticked, never as source in a text box (#1506).
const draftHtml = '<p>Hello <strong>team</strong></p><p>See you</p>';
function editingArea() {
return document.querySelector('[data-testid="template-body-editor"] .ProseMirror') as HTMLElement | null;
}
describe('TemplateForm body format (#1506)', () => {
it('opens a rich text draft in the rich editor, with HTML ticked', async () => {
const onSave = vi.fn();
render(<TemplateForm initialData={{ body: draftHtml, isHTML: true }} onSave={onSave} onCancel={vi.fn()} />);
await waitFor(() => expect(editingArea()).toBeTruthy());
expect(editingArea()!.textContent).toContain('Hello team');
expect(document.querySelector('textarea')).toBeNull();
expect(screen.getByRole('button', { name: 'HTML' }).getAttribute('aria-pressed')).toBe('true');
fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'Weekly' } });
fireEvent.click(screen.getByRole('button', { name: 'create' }));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ isHTML: true, body: expect.stringContaining('<strong>team</strong>') }));
});
it('keeps a plain text draft in the text box', () => {
render(<TemplateForm initialData={{ body: 'Hello team', isHTML: false }} onSave={vi.fn()} onCancel={vi.fn()} />);
expect((document.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello team');
expect(editingArea()).toBeNull();
});
it('converts the body when HTML is switched on and off', async () => {
const onSave = vi.fn();
render(<TemplateForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.change(document.querySelector('textarea')!, { target: { value: 'Hi <you>\n\nBye' } });
fireEvent.click(screen.getByRole('button', { name: 'HTML' }));
await waitFor(() => expect(editingArea()).toBeTruthy());
// The text is shown as text: the angle brackets were escaped, not parsed.
expect(editingArea()!.textContent).toContain('Hi <you>');
expect(editingArea()!.querySelectorAll('p').length).toBe(2);
fireEvent.click(screen.getByRole('button', { name: 'HTML' }));
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
expect(textarea.value).not.toContain('<p>');
expect(textarea.value).toContain('Hi <you>');
expect(textarea.value).toContain('Bye');
});
it('keeps an existing HTML template in the rich editor when edited', async () => {
render(
<TemplateForm
template={{ id: 't', name: 'Old', subject: '', body: draftHtml, isHTML: true, category: '', isFavorite: false, createdAt: '', updatedAt: '' }}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);
await waitFor(() => expect(editingArea()).toBeTruthy());
expect(document.querySelector('textarea')).toBeNull();
});
});
+51 -13
View File
@@ -13,6 +13,10 @@ import { BUILT_IN_PLACEHOLDERS } from '@/lib/template-types';
import type { EmailTemplate } from '@/lib/template-types';
import { useTemplateStore } from '@/stores/template-store';
import { useAuthStore } from '@/stores/auth-store';
import { RichTextEditor } from '@/components/email/rich-text-editor';
import { plainTextToComposerBody } from '@/lib/email-composer-utils';
import { htmlToPlainText } from '@/lib/html-to-text';
import type { Editor } from '@tiptap/react';
interface TemplateFormProps {
@@ -20,6 +24,8 @@ interface TemplateFormProps {
initialData?: {
subject?: string;
body?: string;
/** The body is the composer's HTML, not plain text (#1506). */
isHTML?: boolean;
to?: string[];
cc?: string[];
bcc?: string[];
@@ -55,7 +61,9 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
const [category, setCategory] = useState(template?.category || '');
const [subject, setSubject] = useState(template?.subject || initialData?.subject || '');
const [body, setBody] = useState(template?.body || initialData?.body || '');
const [isHTML, setIsHTML] = useState(template?.isHTML || false);
const [isHTML, setIsHTML] = useState(template ? !!template.isHTML : !!initialData?.isHTML);
// The rich editor, while the body is HTML: placeholders go in at its caret.
const editorRef = useRef<Editor | null>(null);
const [toRecipients, setToRecipients] = useState(
template?.defaultRecipients?.to?.join(', ') || initialData?.to?.join(', ') || ''
);
@@ -120,9 +128,26 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
const tag = `{{${placeholder}}}`;
if (field === 'subject') {
setSubject((prev) => prev + tag);
} else {
setBody((prev) => prev + tag);
return;
}
const editor = editorRef.current;
if (isHTML && editor && !editor.isDestroyed) {
editor.chain().focus().insertContent(tag).run();
return;
}
setBody((prev) => prev + tag);
};
// Switching the body between plain text and HTML converts what is there,
// so neither editor ever shows the other format's source (#1506).
const toggleHTML = () => {
if (isHTML) {
editorRef.current = null;
setBody((prev) => htmlToPlainText(prev, { paragraphSpacing: true }));
} else {
setBody((prev) => plainTextToComposerBody(prev));
}
setIsHTML(!isHTML);
};
const placeholderButton = (field: 'subject' | 'body') => (
@@ -195,20 +220,33 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
<div>
<div className="flex items-center justify-between">
<label htmlFor={labelFor("body")} className="text-sm font-medium text-foreground">{tSettings('body')}</label>
<label htmlFor={isHTML ? undefined : labelFor("body")} className="text-sm font-medium text-foreground">{tSettings('body')}</label>
{placeholderButton('body')}
</div>
<textarea
id={labelFor("body")}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={tSettings('body_placeholder')}
rows={6}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden resize-y"
/>
{isHTML ? (
<div className="mt-1 rounded-md border border-border overflow-hidden" data-testid="template-body-editor">
<RichTextEditor
content={body}
onChange={setBody}
placeholder={tSettings('body_placeholder')}
ariaLabel={tSettings('body')}
onEditorReady={(editor) => { editorRef.current = editor; }}
/>
</div>
) : (
<textarea
id={labelFor("body")}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={tSettings('body_placeholder')}
rows={6}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden resize-y"
/>
)}
<button
type="button"
onClick={() => setIsHTML(!isHTML)}
onClick={toggleHTML}
aria-pressed={isHTML}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{isHTML ? <CheckSquare className={cn('w-4 h-4')}></CheckSquare> : <Square className={cn('w-4 h-4')}></Square>}