mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
fix: save a rich text draft as an HTML template and edit it in the rich editor #1506
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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, '&').replace(/</g, '<').replace(/>/g, '>').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();
|
||||
});
|
||||
});
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user