feat: instant preview of edited email template

This commit is contained in:
Björn Fromme
2026-08-17 12:17:05 +02:00
parent 489aafed3e
commit 0e1df6da7c
12 changed files with 250 additions and 58 deletions
+14
View File
@@ -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<string, string>
*/
public function getSamplePlaceholders(): array
{
$names = $this->getPlaceholderNames();
return array_combine($names, array_map(static fn (string $name): string => '['.$name.']', $names));
}
}
@@ -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()
),
]);
}
}
@@ -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()),
]);
}
}
@@ -0,0 +1,57 @@
<?php
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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PreviewDraftController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRenderer $emailTextRenderer,
) {
}
/**
* Renders the fragment the edit page swaps in while an admin types, so the wording is
* checked before it is saved rather than after. Unlike PreviewController this reads
* the submitted values instead of the stored ones.
*/
#[Route('/admin/system/email-text/preview-draft/{key}', name: 'app_admin_system_email_text_preview_draft', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$definition = $this->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()
),
]);
}
}
+27 -6
View File
@@ -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<string, string|int|null> $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),
);
}