fix(email): derive preview sample data from each template's variables

The preview modal's hardcoded sampleData had drifted from the templates'
declared variables arrays: it carried `password` and `expiration_date` and no
`host_name` at all, so {{host_name}}, {{gallery_password}} and {{expiry_date}}
rendered as literal placeholders in the gallery_created preview while
event_name/event_date/gallery_link substituted fine.

Derive the key set from the template's own `variables` instead, so nothing can
be missing again. editedTemplate already carries the array at the call site,
so no plumbing was needed. A small module-level lookup keeps sensible shapes
for the ~11 variables where shape matters (dates look like dates, links like
URLs), with a readable [name] fallback for anything uncurated -- curating all
~60 distinct variable names across the ~32 template seeds would just recreate
the drift trap.

Preview-only; real sent mail was never affected.

Refs testplan REPORT.md #17 (Part 3, J.04).
This commit is contained in:
Paul Nothaft
2026-09-01 16:40:02 +02:00
parent 73b08a7b5c
commit 1be27404fa
2 changed files with 91 additions and 12 deletions
+35 -12
View File
@@ -68,6 +68,39 @@ const CORE_SUBCATEGORY_ORDER: readonly string[] = [
'system',
] as const;
/**
* Realistic stand-ins for the variables whose *shape* matters in a
* preview — a date has to read like a date, a link like a link. This is
* deliberately not a full list of every variable every template declares;
* `buildPreviewSampleData` below covers the rest.
*/
const PREVIEW_SAMPLE_VALUES: Record<string, string> = {
event_name: 'John & Jane Wedding',
event_date: 'December 25, 2024',
expiry_date: 'January 25, 2025',
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
gallery_password: '••••••••',
password: '••••••••',
host_name: 'Jane Doe',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
days_remaining: '30',
welcome_message: 'Thank you for celebrating our special day with us!',
};
/**
* Build the preview payload from the template's OWN declared `variables`,
* so the two can no longer drift apart. The previous hand-maintained key
* list had gone stale and left {{host_name}}, {{gallery_password}} and
* {{expiry_date}} rendering as raw tokens in the gallery_created preview.
* Variables without a curated value get a readable stand-in rather than an
* unsubstituted {{token}}.
*/
export const buildPreviewSampleData = (variables: string[] = []): Record<string, string> =>
Object.fromEntries(
variables.map((name) => [name, PREVIEW_SAMPLE_VALUES[name] ?? `[${name}]`])
);
const defaultTemplateKeys = [
{
key: 'gallery_created',
@@ -395,18 +428,8 @@ export const EmailConfigPage: React.FC = () => {
const handlePreviewTemplate = async () => {
if (!selectedTemplateKey || !editedTemplate) return;
// Generate sample data based on the template
const sampleData: Record<string, string> = {
event_name: 'John & Jane Wedding',
event_date: 'December 25, 2024',
password: '••••••••',
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
expiration_date: 'January 25, 2025',
welcome_message: 'Thank you for celebrating our special day with us!',
days_remaining: '30',
admin_email: 'admin@example.com',
host_email: 'host@example.com'
};
// Sample data is derived from the template's declared variables
const sampleData = buildPreviewSampleData(editedTemplate.variables);
try {
const preview = await emailService.previewTemplate(selectedTemplateKey, sampleData, editingLang);
@@ -0,0 +1,56 @@
/**
* The template Preview modal substituted a hand-maintained sample-data object
* whose keys had drifted from the templates' declared `variables` (QA J.04):
* {{host_name}}, {{gallery_password}} and {{expiry_date}} rendered as literal
* tokens because the object still carried `password` / `expiration_date` and
* no `host_name` at all. The payload is now derived from `variables`, so the
* property that matters is coverage, not the contents of any one value.
*/
import { describe, it, expect } from 'vitest';
import { buildPreviewSampleData } from '../EmailConfigPage';
describe('buildPreviewSampleData', () => {
const galleryCreatedVariables = [
'host_name',
'event_name',
'event_date',
'gallery_link',
'gallery_password',
'expiry_date',
];
it('supplies a value for every variable the template declares', () => {
const sample = buildPreviewSampleData(galleryCreatedVariables);
expect(Object.keys(sample).sort()).toEqual([...galleryCreatedVariables].sort());
for (const name of galleryCreatedVariables) {
expect(sample[name]).toBeTruthy();
}
});
it('never leaves a variable to render as a raw {{token}}', () => {
const sample = buildPreviewSampleData(['customer_name', 'invoice_number', 'total_amount']);
for (const value of Object.values(sample)) {
expect(value).not.toMatch(/\{\{|\}\}/);
}
});
it('keeps date- and link-shaped variables looking like dates and links', () => {
const sample = buildPreviewSampleData(galleryCreatedVariables);
expect(sample.event_date).toMatch(/\d{4}/);
expect(sample.expiry_date).toMatch(/\d{4}/);
expect(sample.gallery_link).toMatch(/^https?:\/\//);
});
it('falls back to a readable stand-in for variables it does not know', () => {
expect(buildPreviewSampleData(['storno_number'])).toEqual({ storno_number: '[storno_number]' });
});
it('handles a template with no declared variables', () => {
expect(buildPreviewSampleData()).toEqual({});
expect(buildPreviewSampleData([])).toEqual({});
});
});