24 KiB
EmailTemplateEditorPage
Overview
File: admin/src/pages/EmailTemplateEditorPage.tsx
Route: /app/email-templates/:id/edit
Role Requirements: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN
EmailTemplateEditorPage is a full-screen Monaco code editor for editing email templates. It provides a split-pane interface with separate editors for HTML and plain text content, real-time preview with sample data, and a variables reference panel. The editor supports Ctrl+S keyboard shortcuts, test email sending, and mobile device detection.
The page displays:
- Top toolbar with template metadata (name, category, system status) and action buttons
- Subject line input with variable support
- Dual Monaco editors (HTML + text) side-by-side
- Right sidebar with tabs: Variables, HTML Preview, Text Preview
- Sample data inputs for preview rendering
- Mobile warning screen (desktop required)
Key Components:
- Monaco Editor (@monaco-editor/react) for syntax-highlighted code editing
- Ant Design theme tokens for consistent styling
- Three-tab right panel (Variables table, HTML iframe preview, Text pre block)
- TestEmailModal for sending test emails
- Full-screen layout (no AppLayout wrapper)
Screenshot
[Screenshot: EmailTemplateEditorPage showing full-screen layout with top toolbar (template name, category tag, Test Email and Save buttons), subject line input, two Monaco editors side-by-side (HTML content on left, plain text on right), and right sidebar with Variables/HTML Preview/Text Preview tabs. Desktop-only interface with dark theme editors.]
Features
Core Features
-
Dual Editor Layout
- Left pane (40% width): HTML content editor with syntax highlighting
- Center pane (40% width): Plain text content editor
- Right pane (20% width): Variables reference + previews
- VS Dark theme for all Monaco editors
- Line numbers, word wrap, no minimap for clean editing
-
Subject Line Editor
- Input field with envelope icon
- Supports variable interpolation (e.g.,
{{CAMPAIGN_NAME}}) - Large size input for visibility
- Saved together with HTML/text content
-
Variables Reference Panel
- Table showing all template variables with columns:
- Variable: Code format (e.g.,
{{FIRST_NAME}}) - Label: Human-readable name
- Description: Usage explanation
- Required: Red "Required" or gray "Optional" tag
- Variable: Code format (e.g.,
- Sample data input fields for preview
- Persists sample values during editing session
- Table showing all template variables with columns:
-
Real-Time Previews
- HTML Preview Tab: Sandboxed iframe rendering processed HTML
- Text Preview Tab: Pre-formatted text block with styling
- Live updates when sample data changes
- Variable interpolation uses simple string replacement
-
Save Operations
- Save button in toolbar (primary, blue)
- Ctrl+S (or Cmd+S on Mac) keyboard shortcut
- Creates new version in database
- Success message on save
- Updates template timestamp
-
Test Email Functionality
- Test Email button opens TestEmailModal
- Fill in variable values and recipient
- Sends email with current editor content (not saved)
- Success message on send
-
Template Metadata Display
- Template name in toolbar
- Category tag (color-coded: blue=Influence, green=Map, purple=System)
- System template indicator (blue SYSTEM tag)
- Back button to return to templates list
-
Mobile Detection
- Detects screens < 768px (md breakpoint)
- Shows warning Result component
- "Desktop Required" message
- Back button to return to templates list
-
Dark Theme Editor
- VS Dark Monaco theme
- Consistent with code editor expectations
- High contrast for readability
- Token colors from Ant Design theme
User Workflow
Opening Editor
- Navigate from templates list: Click Edit button on EmailTemplatesPage
- Route loads:
/app/email-templates/:id/edit - Template fetches: Loading spinner while fetching template data
- Editor displays: Full-screen layout with template content
Editing Template
- Modify subject line: Type in top input field, use
{{VARIABLES}}as needed - Edit HTML content: Click in left Monaco editor, write HTML markup
- Edit text content: Click in center Monaco editor, write plain text
- Check syntax: Monaco provides HTML syntax highlighting and error detection
- Save changes: Click Save button or press Ctrl+S
Using Variables
- View variables table: Click Variables tab in right sidebar
- Check variable syntax: Copy
{{VARIABLE_NAME}}from table - Insert in content: Paste into subject line, HTML, or text editor
- Mark required variables: Red "Required" tag indicates mandatory variables
- Reference descriptions: Read description column for usage guidance
Previewing Changes
- Enter sample data: In Variables tab, fill in input fields below table
- Switch to preview: Click "HTML Preview" or "Text Preview" tab
- View rendered output: Iframe shows HTML with variables replaced
- Update sample data: Change input values to see different renderings
- Verify output: Check that variables interpolate correctly
Testing Email
- Click Test Email button: Opens TestEmailModal
- Fill in variables: Enter values for each template variable
- Enter recipient email: Provide test email address
- Send test: Click Send button
- Check inbox: Verify email received (or MailHog in dev mode)
- Review formatting: Check HTML rendering in email client
Saving Template
- Make changes: Edit subject, HTML, or text content
- Save with Ctrl+S: Keyboard shortcut (or click Save button)
- Loading state: Save button shows spinner
- Success message: "Template saved successfully" notification
- New version created: Template version history incremented
- Continue editing: Can continue making changes and saving again
Returning to List
- Click back button: Arrow icon in top-left of toolbar
- Navigate back: Browser back button also works
- Unsaved changes: No confirmation prompt (consider implementing)
- Route change: Returns to
/app/email-templates
Component Breakdown
Top Toolbar
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/email-templates')}
/>
<Text strong>{template.name}</Text>
<Tag color={getCategoryColor(template.category)}>{template.category}</Tag>
{template.isSystem && <Tag color="blue">SYSTEM</Tag>}
</Space>
<Space>
<Button onClick={() => setTestModalOpen(true)} icon={<SendOutlined />}>
Test Email
</Button>
<Button type="primary" loading={saving} onClick={handleSave} icon={<SaveOutlined />}>
Save
</Button>
</Space>
</div>
Layout:
- Left: Back button + template metadata (name, category, system status)
- Right: Test Email + Save buttons
- Height: ~40px (shrinks to fit content)
- Border bottom for visual separation
Subject Line Input
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Input
value={subjectLine}
onChange={(e) => setSubjectLine(e.target.value)}
placeholder="Email Subject Line (use {{VARIABLES}})"
prefix={<MailOutlined />}
size="large"
/>
</div>
Props:
value: Controlled input withsubjectLinestateplaceholder: Explains variable syntaxprefix: Envelope icon for visual contextsize="large": 40px height for prominence
HTML Editor (Monaco)
<Editor
height="100%"
language="html"
theme="vs-dark"
value={htmlContent}
onChange={(value) => setHtmlContent(value || '')}
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
scrollBeyondLastLine: false,
}}
/>
Options:
minimap: false- No code minimap (saves space)fontSize: 14- Readable code sizewordWrap: 'on'- Wrap long lines instead of horizontal scrolllineNumbers: 'on'- Show line numbers for referencescrollBeyondLastLine: false- Don't scroll past last line
Text Editor (Monaco)
<Editor
height="100%"
language="plaintext"
theme="vs-dark"
value={textContent}
onChange={(value) => setTextContent(value || '')}
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
scrollBeyondLastLine: false,
}}
/>
Same options as HTML editor but language is plaintext (no syntax highlighting).
Variables Table
<Table
dataSource={template.variables}
columns={variableColumns}
rowKey="id"
size="small"
pagination={false}
/>
Columns:
const variableColumns = [
{
title: 'Variable',
dataIndex: 'key',
render: (key: string) => <Text code>{'{{' + key + '}}'}</Text>,
},
{
title: 'Label',
dataIndex: 'label',
},
{
title: 'Description',
dataIndex: 'description',
render: (desc: string | null) => <Text type="secondary">{desc || '—'}</Text>,
},
{
title: 'Required',
dataIndex: 'isRequired',
render: (isRequired: boolean) => (
isRequired ? <Tag color="red">Required</Tag> : <Tag>Optional</Tag>
),
},
];
Sample Data Inputs
<div style={{ marginTop: 16 }}>
<Text strong>Sample Data (for preview):</Text>
{template.variables.map((v) => (
<div key={v.key} style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{v.label}
</Text>
<Input
size="small"
value={sampleData[v.key] || ''}
onChange={(e) => setSampleData({ ...sampleData, [v.key]: e.target.value })}
placeholder={v.sampleValue || ''}
/>
</div>
))}
</div>
Pattern: One input per variable, labeled with variable label, placeholder shows default sample value.
HTML Preview Iframe
<iframe
srcDoc={processedHtml}
style={{
width: '100%',
height: '100%',
border: `1px solid ${token.colorBorder}`,
borderRadius: 4,
}}
sandbox="allow-same-origin"
title="HTML Preview"
/>
Security: sandbox="allow-same-origin" restricts iframe capabilities (no scripts, no forms).
srcDoc prop: Renders inline HTML without external URL.
Text Preview Block
<pre
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 12,
lineHeight: 1.5,
padding: 12,
backgroundColor: token.colorBgLayout,
borderRadius: 4,
border: `1px solid ${token.colorBorder}`,
}}
>
{processedText}
</pre>
Styling:
whiteSpace: 'pre-wrap'- Preserve whitespace but wrap long linesfontFamily: 'monospace'- Fixed-width font like email clients use- Background color for contrast
Template Processing Function
const processTemplate = (content: string, data: Record<string, string>): string => {
let processed = content;
Object.entries(data).forEach(([key, value]) => {
processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);
});
return processed;
};
Usage:
const processedHtml = processTemplate(htmlContent, sampleData);
const processedText = processTemplate(textContent, sampleData);
State Management
Local State
Template Data:
const [template, setTemplate] = useState<EmailTemplate | null>(null);
const [loading, setLoading] = useState(true);
Editor Content:
const [subjectLine, setSubjectLine] = useState('');
const [htmlContent, setHtmlContent] = useState('');
const [textContent, setTextContent] = useState('');
Sample Data for Preview:
const [sampleData, setSampleData] = useState<Record<string, string>>({});
UI State:
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState('variables');
const [testModalOpen, setTestModalOpen] = useState(false);
Responsive State:
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
Data Fetching
Fetch Template on Mount:
useEffect(() => {
const fetchTemplate = async () => {
try {
const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);
setTemplate(data);
setSubjectLine(data.subjectLine);
setHtmlContent(data.htmlContent);
setTextContent(data.textContent);
// Initialize sample data from variables
const initialSampleData: Record<string, string> = {};
data.variables.forEach((v) => {
initialSampleData[v.key] = v.sampleValue || '';
});
setSampleData(initialSampleData);
} catch {
message.error('Failed to load template');
navigate('/app/email-templates');
} finally {
setLoading(false);
}
};
fetchTemplate();
}, [id, navigate]);
Error Handling: Redirect to templates list if template not found.
Save Handler
const handleSave = useCallback(async () => {
if (!template) return;
setSaving(true);
try {
const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {
subjectLine,
htmlContent,
textContent,
});
setTemplate(updated);
message.success('Template saved successfully');
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
'Failed to save template';
message.error(msg);
} finally {
setSaving(false);
}
}, [template, id, subjectLine, htmlContent, textContent]);
Keyboard Shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handleSave]);
Why e.preventDefault()? Prevents browser's default "Save Page" dialog.
API Integration
Endpoints Used
GET /email-templates/:id - Fetch template
const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);
Response:
{
"id": "tmpl_123",
"key": "campaign_email",
"name": "Campaign Email",
"category": "INFLUENCE",
"subjectLine": "Take action on {{CAMPAIGN_NAME}}",
"htmlContent": "<html><body><h1>{{CAMPAIGN_NAME}}</h1><p>{{MESSAGE}}</p></body></html>",
"textContent": "{{CAMPAIGN_NAME}}\n\n{{MESSAGE}}",
"isActive": true,
"isSystem": false,
"variables": [
{
"id": "var_1",
"key": "CAMPAIGN_NAME",
"label": "Campaign Name",
"description": "Name of the campaign",
"isRequired": true,
"sampleValue": "Stop Deforestation"
},
{
"id": "var_2",
"key": "MESSAGE",
"label": "Message",
"description": "Main message content",
"isRequired": true,
"sampleValue": "Join us in protecting our forests."
}
],
"createdAt": "2026-01-15T10:00:00Z",
"updatedAt": "2026-02-10T14:30:00Z"
}
PUT /email-templates/:id - Update template
const { data: updated } = await api.put<EmailTemplate>(`/email-templates/${id}`, {
subjectLine: "Updated subject with {{VARIABLE}}",
htmlContent: "<html>...</html>",
textContent: "Plain text...",
});
Response: Returns updated EmailTemplate object with new updatedAt timestamp.
Code Examples
Keyboard Shortcut Pattern
const handleSave = useCallback(async () => {
// Save logic
}, [/* dependencies */]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handleSave]);
Pattern:
- Use
useCallbackfor save handler with dependencies - Add keyboard event listener in
useEffect - Check Ctrl/Cmd + S key combination
- Call preventDefault to stop browser save dialog
- Clean up listener on unmount
Variable Interpolation
const processTemplate = (content: string, data: Record<string, string>): string => {
let processed = content;
Object.entries(data).forEach(([key, value]) => {
processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value);
});
return processed;
};
// Usage
const processedHtml = processTemplate(htmlContent, sampleData);
// Input: "<h1>{{CAMPAIGN_NAME}}</h1>"
// Sample data: { CAMPAIGN_NAME: "Save the Planet" }
// Output: "<h1>Save the Planet</h1>"
Note: This is a simple string replacement for preview. Production email sending uses server-side template engine with proper escaping.
Mobile Detection
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
if (isMobile) {
return (
<Result
status="warning"
title="Desktop Required"
subTitle="The email template editor requires a desktop browser."
extra={
<Button type="primary" onClick={() => navigate('/app/email-templates')}>
Back to Templates
</Button>
}
/>
);
}
Breakpoint: md = 768px (Ant Design standard)
Sample Data Initialization
// Initialize sample data from template variables
const initialSampleData: Record<string, string> = {};
data.variables.forEach((v) => {
initialSampleData[v.key] = v.sampleValue || '';
});
setSampleData(initialSampleData);
Pattern: Pre-fill sample data inputs with default sample values from variable definitions.
Category Color Helper
const getCategoryColor = (category: EmailTemplateCategory): string => {
const colors: Record<EmailTemplateCategory, string> = {
INFLUENCE: 'blue',
MAP: 'green',
SYSTEM: 'purple',
};
return colors[category];
};
Consistent with EmailTemplatesPage color scheme.
Performance Considerations
Monaco Editor Lazy Loading
Monaco Editor is loaded via CDN when component mounts:
import Editor from '@monaco-editor/react';
Bundle size: Monaco not included in main bundle (reduces initial load).
useCallback for Save Handler
const handleSave = useCallback(async () => { /* ... */ }, [template, id, subjectLine, htmlContent, textContent]);
Why: Prevents recreation on every render, essential for keyboard shortcut listener dependency.
Controlled Inputs
All three editors (subject, HTML, text) use controlled state:
<Input value={subjectLine} onChange={(e) => setSubjectLine(e.target.value)} />
<Editor value={htmlContent} onChange={(value) => setHtmlContent(value || '')} />
Tradeoff: Controlled inputs = React re-renders on every keystroke, but ensures state consistency.
Iframe Preview Updates
Preview iframe updates only when:
- Sample data changes
- Editor content changes (via
processedHtmldependency)
No automatic refresh timer needed.
Responsive Design
Mobile Warning
const isMobile = !screens.md;
if (isMobile) {
return <Result status="warning" title="Desktop Required" />;
}
Screens < 768px: Show warning, don't render editor (unusable on small screens).
Full-Screen Layout
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Toolbar */}
<div style={{ flexShrink: 0 }}>...</div>
{/* Editors */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>...</div>
</div>
height: 100vh ensures full viewport height, no scrolling.
Flex Layout
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<div style={{ flex: '0 0 40%' }}>HTML Editor</div>
<div style={{ flex: '0 0 40%' }}>Text Editor</div>
<div style={{ flex: '0 0 20%' }}>Sidebar</div>
</div>
Flex basis percentages: Fixed width columns, no shrinking/growing.
Accessibility
Keyboard Navigation
- Tab: Navigate between subject input, editors, buttons
- Ctrl+S / Cmd+S: Save template
- Monaco shortcuts: Ctrl+F (find), Ctrl+H (replace), Ctrl+/ (comment)
Button Labels
<Button icon={<SaveOutlined />}>Save</Button>
<Button icon={<SendOutlined />}>Test Email</Button>
Not icon-only buttons – text labels for clarity.
Input Placeholders
<Input placeholder="Email Subject Line (use {{VARIABLES}})" />
Descriptive placeholder explains variable syntax.
Preview Iframe Sandbox
<iframe sandbox="allow-same-origin" />
Security: Restricts iframe capabilities (no JavaScript execution from injected HTML).
Troubleshooting
Template Not Loading
Symptoms:
- Loading spinner forever
- Error message "Failed to load template"
- Redirect to templates list
Causes:
- Invalid template ID in URL
- API server down
- Template deleted
- Permission denied
Solutions:
# Check API logs
docker compose logs -f api
# Test API endpoint
curl -H "Authorization: Bearer <token>" \
http://localhost:4000/email-templates/tmpl_123
# Verify template exists in database
docker compose exec api npx prisma studio
# Navigate to EmailTemplate model, search by ID
Save Not Working
Symptoms:
- Clicking Save does nothing
- Ctrl+S has no effect
- No success/error message
Causes:
handleSavecallback not defined- Keyboard listener not registered
- Network error
Debug:
const handleSave = useCallback(async () => {
console.log('Save triggered');
console.log('Template ID:', id);
console.log('Content:', { subjectLine, htmlContent, textContent });
// ... rest of save logic
}, [template, id, subjectLine, htmlContent, textContent]);
Preview Not Updating
Symptoms:
- Changing sample data doesn't update preview
- Preview shows old content
Causes:
processTemplatefunction not called- Sample data state not updating
- Iframe not re-rendering
Debug:
const processedHtml = processTemplate(htmlContent, sampleData);
console.log('Sample data:', sampleData);
console.log('Processed HTML:', processedHtml);
Variables Not Showing
Symptoms:
- Variables table empty
- Sample data inputs not rendering
Cause:
- Template has no variables defined
Expected Behavior:
- If
template.variablesis empty array, table shows no rows - This is valid (template may not use variables)
Mobile Warning Not Showing
Symptoms:
- Editor renders on mobile (broken layout)
Cause:
- Breakpoint detection not working
Debug:
const screens = Grid.useBreakpoint();
console.log('Breakpoints:', screens);
console.log('Is mobile:', !screens.md);
Monaco Editor Blank
Symptoms:
- Editor pane shows nothing (white/black)
- No code visible
Causes:
- Monaco CDN failed to load
- Content is empty string
- Height not set correctly
Solutions:
// Check if content loaded
console.log('HTML content length:', htmlContent.length);
// Verify Monaco loaded
import Editor from '@monaco-editor/react';
console.log('Monaco Editor component:', Editor);
// Check editor height
<Editor height="100%" /> // Ensure parent has defined height
Related Documentation
Backend Integration
- Email Templates Module - Service, schemas, routes
- Email Templates API Reference - Full endpoint documentation
Frontend Pages
- Email Templates Page - Template list and management
Frontend Components
- TestEmailModal Component - Test email modal
Features
- Email Template System - Feature overview
- Template Variables - Variable system documentation
- Template Versioning - Version control system
User Guides
- Admin Guide - Email Templates - Template editing workflows
External Resources
- Monaco Editor Documentation - Monaco API reference
- Monaco React Documentation - React wrapper docs
Related Technologies
- GrapesJS Editor Page - Similar full-screen editor for landing pages
- DocsPage - Similar Monaco editor for documentation files