From 0e1df6da7c00b99cc74b765b1ecbceb1dfa06fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 17 Aug 2026 12:17:05 +0200 Subject: [PATCH] feat: instant preview of edited email template --- src/Config/EmailTextDefinition.php | 14 +++++ .../Admin/System/EmailText/EditController.php | 18 ++++-- .../System/EmailText/PreviewController.php | 11 +--- .../EmailText/PreviewDraftController.php | 57 ++++++++++++++++++ src/Email/EmailTextRenderer.php | 33 +++++++++-- .../admin/system/email_text/_form.html.twig | 22 +++++-- .../system/email_text/_preview.html.twig | 21 +++++++ .../admin/system/email_text/edit.html.twig | 36 ++++++++++++ .../admin/system/email_text/index.html.twig | 9 +-- .../system/email_text/modal_edit.html.twig | 7 --- .../system/email_text/modal_preview.html.twig | 22 +------ tests/Email/EmailTextRendererTest.php | 58 +++++++++++++++++++ 12 files changed, 250 insertions(+), 58 deletions(-) create mode 100644 src/Controller/Admin/System/EmailText/PreviewDraftController.php create mode 100644 templates/admin/system/email_text/_preview.html.twig create mode 100644 templates/admin/system/email_text/edit.html.twig delete mode 100644 templates/admin/system/email_text/modal_edit.html.twig diff --git a/src/Config/EmailTextDefinition.php b/src/Config/EmailTextDefinition.php index dcf3836..00fcf4b 100644 --- a/src/Config/EmailTextDefinition.php +++ b/src/Config/EmailTextDefinition.php @@ -28,4 +28,18 @@ readonly class EmailTextDefinition { return array_keys($this->placeholders); } + + /** + * Placeholder values for the admin preview, where the placeholders stand in for + * themselves: no disposition is at hand there, and a visible [destination] tells an + * admin where a real value lands better than an invented one would. + * + * @return array + */ + public function getSamplePlaceholders(): array + { + $names = $this->getPlaceholderNames(); + + return array_combine($names, array_map(static fn (string $name): string => '['.$name.']', $names)); + } } diff --git a/src/Controller/Admin/System/EmailText/EditController.php b/src/Controller/Admin/System/EmailText/EditController.php index d655807..1c61bbc 100644 --- a/src/Controller/Admin/System/EmailText/EditController.php +++ b/src/Controller/Admin/System/EmailText/EditController.php @@ -4,9 +4,9 @@ namespace App\Controller\Admin\System\EmailText; use App\Config\EmailTextCatalog; use App\Config\EmailTextKey; +use App\Email\EmailTextRenderer; use App\Entity\EmailText; use App\Form\EmailTextType; -use App\Htmx\HxRedirectResponse; use App\Repository\EmailTextRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -21,6 +21,7 @@ class EditController extends AbstractController public function __construct( private readonly EmailTextCatalog $catalog, private readonly EmailTextRepository $emailTextRepository, + private readonly EmailTextRenderer $emailTextRenderer, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, ) { @@ -46,7 +47,6 @@ class EditController extends AbstractController $form = $this->createForm(EmailTextType::class, $emailText, [ 'definition' => $definition, - 'hx_post' => $request->getUri(), ]); $form->handleRequest($request); @@ -63,12 +63,22 @@ class EditController extends AbstractController 'email_text_key' => $key->value, ]); - return new HxRedirectResponse($this->generateUrl('app_admin_system_email_text_index')); + return $this->redirectToRoute('app_admin_system_email_text_index'); } - return $this->render('admin/system/email_text/modal_edit.html.twig', [ + return $this->render('admin/system/email_text/edit.html.twig', [ 'form' => $form->createView(), 'definition' => $definition, + 'emailText' => $emailText, + // Filling the preview pane here saves the page a round trip on first paint; + // from then on the pane refreshes itself while the admin types. + 'text' => $this->emailTextRenderer->renderValues( + $key, + $emailText->getSubject(), + $emailText->getHeadline(), + $emailText->getBody(), + $definition->getSamplePlaceholders() + ), ]); } } diff --git a/src/Controller/Admin/System/EmailText/PreviewController.php b/src/Controller/Admin/System/EmailText/PreviewController.php index 644795f..be89c26 100644 --- a/src/Controller/Admin/System/EmailText/PreviewController.php +++ b/src/Controller/Admin/System/EmailText/PreviewController.php @@ -30,18 +30,9 @@ class PreviewController extends AbstractController { $definition = $this->catalog->get($key); - // The placeholders stand in for themselves: no disposition is at hand here, and a - // visible [destination] tells an admin where a real value lands better than an - // invented one would. - $placeholders = []; - - foreach ($definition->getPlaceholderNames() as $name) { - $placeholders[$name] = '['.$name.']'; - } - return $this->render('admin/system/email_text/modal_preview.html.twig', [ 'definition' => $definition, - 'text' => $this->emailTextRenderer->render($key, $placeholders), + 'text' => $this->emailTextRenderer->render($key, $definition->getSamplePlaceholders()), ]); } } diff --git a/src/Controller/Admin/System/EmailText/PreviewDraftController.php b/src/Controller/Admin/System/EmailText/PreviewDraftController.php new file mode 100644 index 0000000..adf8b69 --- /dev/null +++ b/src/Controller/Admin/System/EmailText/PreviewDraftController.php @@ -0,0 +1,57 @@ +catalog->get($key); + + $form = $this->createForm(EmailTextType::class, new EmailText($key), [ + 'definition' => $definition, + ]); + $form->handleRequest($request); + + /** @var EmailText $draft */ + $draft = $form->getData(); + + // Deliberately rendered whether or not the form is valid: a text carrying a typo + // in a placeholder is exactly what an admin needs to see in the preview, and the + // field error is shown by the form itself when the text is saved. + return $this->render('admin/system/email_text/_preview.html.twig', [ + 'definition' => $definition, + 'text' => $this->emailTextRenderer->renderValues( + $key, + $draft->getSubject(), + $draft->getHeadline(), + $draft->getBody(), + $definition->getSamplePlaceholders() + ), + ]); + } +} diff --git a/src/Email/EmailTextRenderer.php b/src/Email/EmailTextRenderer.php index f7f4989..60afc5b 100644 --- a/src/Email/EmailTextRenderer.php +++ b/src/Email/EmailTextRenderer.php @@ -43,9 +43,30 @@ class EmailTextRenderer $definition = $this->catalog->get($key); $emailText = $this->emailTextRepository->findByKey($key); - $subject = $emailText?->getSubject() ?: $definition->defaultSubject; - $headline = $emailText?->getHeadline() ?? $definition->defaultHeadline; - $body = $emailText?->getBody() ?: $definition->defaultBody; + return $this->renderValues( + $key, + $emailText?->getSubject() ?: $definition->defaultSubject, + $emailText?->getHeadline() ?? $definition->defaultHeadline, + $emailText?->getBody() ?: $definition->defaultBody, + $placeholders + ); + } + + /** + * Renders wording that is not (or not yet) the saved one - the admin preview shows + * what is currently in the form, which is the whole point of previewing before saving. + * The values come straight from a half-filled form, so all three may be null. + * + * @param array $placeholders + */ + public function renderValues( + EmailTextKey $key, + ?string $subject, + ?string $headline, + ?string $body, + array $placeholders, + ): RenderedEmailTextDto { + $definition = $this->catalog->get($key); // Two token maps from the same values: the subject of a mail is plain text, the // body is HTML. Escaping happens here, before substitution, so that neither the @@ -63,9 +84,9 @@ class EmailTextRenderer } return new RenderedEmailTextDto( - strtr($subject, $plainTokens), - $this->prepare($headline, $htmlTokens), - $this->renderBody($body, $htmlTokens), + strtr($subject ?? '', $plainTokens), + $this->prepare($headline ?? '', $htmlTokens), + $this->renderBody($body ?? '', $htmlTokens), ); } diff --git a/templates/admin/system/email_text/_form.html.twig b/templates/admin/system/email_text/_form.html.twig index a23762a..8627b1a 100644 --- a/templates/admin/system/email_text/_form.html.twig +++ b/templates/admin/system/email_text/_form.html.twig @@ -1,4 +1,4 @@ -{{ form_start(form) }} +{{ form_start(form, { 'attr': { 'id': 'email-text-form' } }) }}
{{ form_row(form.subject) }} {{ form_row(form.headline) }} @@ -20,8 +20,22 @@
- +
+ + + Abbrechen + + {% if emailText.id is not null %} + + {% endif %} +
{{ form_rest(form) }} {{ form_end(form) }} diff --git a/templates/admin/system/email_text/_preview.html.twig b/templates/admin/system/email_text/_preview.html.twig new file mode 100644 index 0000000..bdbf567 --- /dev/null +++ b/templates/admin/system/email_text/_preview.html.twig @@ -0,0 +1,21 @@ +
+ Betreff: {{ text.subject }} +
+{# The renderer escaped everything before it added a tag, see RenderedEmailTextDto. #} +
+ {% if text.headline is not empty %} +

{{ text.headline | raw }}

+ {% endif %} + {# The paragraphs come from the mail body, where email/layout.html.twig styles every +

with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #} +

+ {{ text.bodyHtml | raw }} +
+

+ Zum Portal +

+
+
+ Platzhalter sind als [name] dargestellt und werden beim Versand durch + die echten Werte ersetzt. Logo und Fußzeile der E-Mail sind hier nicht abgebildet. +
diff --git a/templates/admin/system/email_text/edit.html.twig b/templates/admin/system/email_text/edit.html.twig new file mode 100644 index 0000000..4bd60a7 --- /dev/null +++ b/templates/admin/system/email_text/edit.html.twig @@ -0,0 +1,36 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Einstellungen - E-Mail-Text bearbeiten{% endblock %} + +{% block content %} +

+ {{ definition.label }} +

+
+ {% include 'admin/system/email_text/_form.html.twig' %} +
+
+ Vorschau +
+ {# The pane refreshes itself from the form while the admin types, so the + wording is checked before it is saved rather than after. #} +
+
+ {% include 'admin/system/email_text/_preview.html.twig' %} +
+ {# Deliberately a sibling of the swap target, not a child: hx-swap replaces + the target's contents, which would carry the indicator away with them. #} +
+ {% include '_partials/_spinner.html.twig' with { 'class': 'w-8 h-8' } %} +
+
+
+
+{% endblock %} diff --git a/templates/admin/system/email_text/index.html.twig b/templates/admin/system/email_text/index.html.twig index 0931101..39504a2 100644 --- a/templates/admin/system/email_text/index.html.twig +++ b/templates/admin/system/email_text/index.html.twig @@ -59,13 +59,10 @@ hx-swap="beforeend"> {{ icon('eye') }} - + diff --git a/templates/admin/system/email_text/modal_edit.html.twig b/templates/admin/system/email_text/modal_edit.html.twig deleted file mode 100644 index 1cf3acd..0000000 --- a/templates/admin/system/email_text/modal_edit.html.twig +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'htmx_modal.html.twig' %} - -{% block title %}{{ definition.label }}{% endblock %} - -{% block content %} - {% include 'admin/system/email_text/_form.html.twig' %} -{% endblock %} diff --git a/templates/admin/system/email_text/modal_preview.html.twig b/templates/admin/system/email_text/modal_preview.html.twig index 591c69f..86d8b50 100644 --- a/templates/admin/system/email_text/modal_preview.html.twig +++ b/templates/admin/system/email_text/modal_preview.html.twig @@ -3,25 +3,5 @@ {% block title %}Vorschau: {{ definition.label }}{% endblock %} {% block content %} -
- Betreff: {{ text.subject }} -
- {# The renderer escaped everything before it added a tag, see RenderedEmailTextDto. #} -
- {% if text.headline is not empty %} -

{{ text.headline | raw }}

- {% endif %} - {# The paragraphs come from the mail body, where email/layout.html.twig styles every -

with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #} -

- {{ text.bodyHtml | raw }} -
-

- Zum Portal -

-
-
- Platzhalter sind als [name] dargestellt und werden beim Versand durch - die echten Werte ersetzt. Logo und Fußzeile der E-Mail sind hier nicht abgebildet. -
+ {% include 'admin/system/email_text/_preview.html.twig' %} {% endblock %} diff --git a/tests/Email/EmailTextRendererTest.php b/tests/Email/EmailTextRendererTest.php index 40b612b..40577dc 100644 --- a/tests/Email/EmailTextRendererTest.php +++ b/tests/Email/EmailTextRendererTest.php @@ -179,6 +179,64 @@ class EmailTextRendererTest extends KernelTestCase $this->assertSame('Einsatz Ski & Snowboard', $rendered->subject); } + /** + * The admin preview renders what is in the form, not what is in the database - a + * preview showing the stored wording while the admin types a new one would be + * pointless. + */ + public function testGivenValuesAreRenderedInsteadOfTheStoredOnes(): void + { + $repository = $this->createMock(EmailTextRepository::class); + $repository + ->method('findByKey') + ->willReturn($this->emailText(subject: 'Gespeichert', body: 'Gespeicherter Text')) + ; + + self::bootKernel(); + + $renderer = new EmailTextRenderer( + self::getContainer()->get(EmailTextCatalog::class), + $repository + ); + + $rendered = $renderer->renderValues( + EmailTextKey::APPLICATION_REJECTED, + 'Entwurf {destination}', + 'Überschrift', + 'Entwurfstext *fett*', + ['destination' => 'Skireise'] + ); + + $this->assertSame('Entwurf Skireise', $rendered->subject); + $this->assertSame('Überschrift', $rendered->headline); + $this->assertSame('

Entwurfstext fett

', $rendered->bodyHtml); + } + + /** + * A preview is asked for while the form is still half filled. + */ + public function testEmptyDraftValuesRenderWithoutFallingBackToTheDefault(): void + { + self::bootKernel(); + + $renderer = new EmailTextRenderer( + self::getContainer()->get(EmailTextCatalog::class), + $this->createMock(EmailTextRepository::class) + ); + + $rendered = $renderer->renderValues( + EmailTextKey::APPLICATION_REJECTED, + null, + null, + null, + ['destination' => 'Skireise'] + ); + + $this->assertSame('', $rendered->subject); + $this->assertSame('', $rendered->headline); + $this->assertSame('', $rendered->bodyHtml); + } + /** * @param array $placeholders */