changemaker.lite/mkdocs/docs/v2/frontend/pages/admin/email-template-editor-page.md

24 KiB
Raw Blame History

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

  1. 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
  2. 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
  3. 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
    • Sample data input fields for preview
    • Persists sample values during editing session
  4. 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
  5. 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
  6. 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
  7. 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
  8. Mobile Detection

    • Detects screens < 768px (md breakpoint)
    • Shows warning Result component
    • "Desktop Required" message
    • Back button to return to templates list
  9. 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

  1. Navigate from templates list: Click Edit button on EmailTemplatesPage
  2. Route loads: /app/email-templates/:id/edit
  3. Template fetches: Loading spinner while fetching template data
  4. Editor displays: Full-screen layout with template content

Editing Template

  1. Modify subject line: Type in top input field, use {{VARIABLES}} as needed
  2. Edit HTML content: Click in left Monaco editor, write HTML markup
  3. Edit text content: Click in center Monaco editor, write plain text
  4. Check syntax: Monaco provides HTML syntax highlighting and error detection
  5. Save changes: Click Save button or press Ctrl+S

Using Variables

  1. View variables table: Click Variables tab in right sidebar
  2. Check variable syntax: Copy {{VARIABLE_NAME}} from table
  3. Insert in content: Paste into subject line, HTML, or text editor
  4. Mark required variables: Red "Required" tag indicates mandatory variables
  5. Reference descriptions: Read description column for usage guidance

Previewing Changes

  1. Enter sample data: In Variables tab, fill in input fields below table
  2. Switch to preview: Click "HTML Preview" or "Text Preview" tab
  3. View rendered output: Iframe shows HTML with variables replaced
  4. Update sample data: Change input values to see different renderings
  5. Verify output: Check that variables interpolate correctly

Testing Email

  1. Click Test Email button: Opens TestEmailModal
  2. Fill in variables: Enter values for each template variable
  3. Enter recipient email: Provide test email address
  4. Send test: Click Send button
  5. Check inbox: Verify email received (or MailHog in dev mode)
  6. Review formatting: Check HTML rendering in email client

Saving Template

  1. Make changes: Edit subject, HTML, or text content
  2. Save with Ctrl+S: Keyboard shortcut (or click Save button)
  3. Loading state: Save button shows spinner
  4. Success message: "Template saved successfully" notification
  5. New version created: Template version history incremented
  6. Continue editing: Can continue making changes and saving again

Returning to List

  1. Click back button: Arrow icon in top-left of toolbar
  2. Navigate back: Browser back button also works
  3. Unsaved changes: No confirmation prompt (consider implementing)
  4. 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 with subjectLine state
  • placeholder: Explains variable syntax
  • prefix: Envelope icon for visual context
  • size="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 size
  • wordWrap: 'on' - Wrap long lines instead of horizontal scroll
  • lineNumbers: 'on' - Show line numbers for reference
  • scrollBeyondLastLine: 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 lines
  • fontFamily: '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:

  1. Use useCallback for save handler with dependencies
  2. Add keyboard event listener in useEffect
  3. Check Ctrl/Cmd + S key combination
  4. Call preventDefault to stop browser save dialog
  5. 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:

  1. Sample data changes
  2. Editor content changes (via processedHtml dependency)

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:

  1. Invalid template ID in URL
  2. API server down
  3. Template deleted
  4. 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:

  1. handleSave callback not defined
  2. Keyboard listener not registered
  3. 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:

  1. processTemplate function not called
  2. Sample data state not updating
  3. 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.variables is 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:

  1. Monaco CDN failed to load
  2. Content is empty string
  3. 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

Backend Integration

Frontend Pages

Frontend Components

Features

User Guides

External Resources