feat: instant preview of edited email template
This commit is contained in:
@@ -28,4 +28,18 @@ readonly class EmailTextDefinition
|
|||||||
{
|
{
|
||||||
return array_keys($this->placeholders);
|
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\EmailTextCatalog;
|
||||||
use App\Config\EmailTextKey;
|
use App\Config\EmailTextKey;
|
||||||
|
use App\Email\EmailTextRenderer;
|
||||||
use App\Entity\EmailText;
|
use App\Entity\EmailText;
|
||||||
use App\Form\EmailTextType;
|
use App\Form\EmailTextType;
|
||||||
use App\Htmx\HxRedirectResponse;
|
|
||||||
use App\Repository\EmailTextRepository;
|
use App\Repository\EmailTextRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -21,6 +21,7 @@ class EditController extends AbstractController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EmailTextCatalog $catalog,
|
private readonly EmailTextCatalog $catalog,
|
||||||
private readonly EmailTextRepository $emailTextRepository,
|
private readonly EmailTextRepository $emailTextRepository,
|
||||||
|
private readonly EmailTextRenderer $emailTextRenderer,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
@@ -46,7 +47,6 @@ class EditController extends AbstractController
|
|||||||
|
|
||||||
$form = $this->createForm(EmailTextType::class, $emailText, [
|
$form = $this->createForm(EmailTextType::class, $emailText, [
|
||||||
'definition' => $definition,
|
'definition' => $definition,
|
||||||
'hx_post' => $request->getUri(),
|
|
||||||
]);
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
@@ -63,12 +63,22 @@ class EditController extends AbstractController
|
|||||||
'email_text_key' => $key->value,
|
'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(),
|
'form' => $form->createView(),
|
||||||
'definition' => $definition,
|
'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);
|
$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', [
|
return $this->render('admin/system/email_text/modal_preview.html.twig', [
|
||||||
'definition' => $definition,
|
'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()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,9 +43,30 @@ class EmailTextRenderer
|
|||||||
$definition = $this->catalog->get($key);
|
$definition = $this->catalog->get($key);
|
||||||
$emailText = $this->emailTextRepository->findByKey($key);
|
$emailText = $this->emailTextRepository->findByKey($key);
|
||||||
|
|
||||||
$subject = $emailText?->getSubject() ?: $definition->defaultSubject;
|
return $this->renderValues(
|
||||||
$headline = $emailText?->getHeadline() ?? $definition->defaultHeadline;
|
$key,
|
||||||
$body = $emailText?->getBody() ?: $definition->defaultBody;
|
$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
|
// 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
|
// body is HTML. Escaping happens here, before substitution, so that neither the
|
||||||
@@ -63,9 +84,9 @@ class EmailTextRenderer
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new RenderedEmailTextDto(
|
return new RenderedEmailTextDto(
|
||||||
strtr($subject, $plainTokens),
|
strtr($subject ?? '', $plainTokens),
|
||||||
$this->prepare($headline, $htmlTokens),
|
$this->prepare($headline ?? '', $htmlTokens),
|
||||||
$this->renderBody($body, $htmlTokens),
|
$this->renderBody($body ?? '', $htmlTokens),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{{ form_start(form) }}
|
{{ form_start(form, { 'attr': { 'id': 'email-text-form' } }) }}
|
||||||
<div class="flex flex-col space-y-4 pb-8">
|
<div class="flex flex-col space-y-4 pb-8">
|
||||||
{{ form_row(form.subject) }}
|
{{ form_row(form.subject) }}
|
||||||
{{ form_row(form.headline) }}
|
{{ form_row(form.headline) }}
|
||||||
@@ -20,8 +20,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">
|
<div class="flex items-center space-x-2">
|
||||||
Speichern
|
<button type="submit" class="btn">
|
||||||
</button>
|
Speichern
|
||||||
|
</button>
|
||||||
|
<a href="{{ path('app_admin_system_email_text_index') }}" class="btn btn--secondary">
|
||||||
|
Abbrechen
|
||||||
|
</a>
|
||||||
|
{% if emailText.id is not null %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn--secondary text-red-500"
|
||||||
|
hx-get="{{ path('app_admin_system_email_text_reset', { 'key': definition.key.value }) }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Auf Standard zurücksetzen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{{ form_rest(form) }}
|
{{ form_rest(form) }}
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<div class="pb-4">
|
||||||
|
<span class="font-bold">Betreff:</span> {{ text.subject }}
|
||||||
|
</div>
|
||||||
|
{# The renderer escaped everything before it added a tag, see RenderedEmailTextDto. #}
|
||||||
|
<div class="border border-gray-200 rounded-md p-4">
|
||||||
|
{% if text.headline is not empty %}
|
||||||
|
<h1 class="text-xl font-bold pb-2">{{ text.headline | raw }}</h1>
|
||||||
|
{% endif %}
|
||||||
|
{# The paragraphs come from the mail body, where email/layout.html.twig styles every
|
||||||
|
<p> with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #}
|
||||||
|
<div class="[&>p]:pb-4 [&_a]:underline">
|
||||||
|
{{ text.bodyHtml | raw }}
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<span class="btn inline-block">Zum Portal</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pt-4 text-sm text-gray-500">
|
||||||
|
Platzhalter sind als <code>[name]</code> dargestellt und werden beim Versand durch
|
||||||
|
die echten Werte ersetzt. Logo und Fußzeile der E-Mail sind hier nicht abgebildet.
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends 'admin/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Einstellungen - E-Mail-Text bearbeiten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-bold pb-8">
|
||||||
|
{{ definition.label }}
|
||||||
|
</h1>
|
||||||
|
<div class="grid lg:grid-cols-2 gap-8 items-start">
|
||||||
|
{% include 'admin/system/email_text/_form.html.twig' %}
|
||||||
|
<div class="lg:sticky lg:top-4">
|
||||||
|
<div class="font-bold pb-2">
|
||||||
|
Vorschau
|
||||||
|
</div>
|
||||||
|
{# The pane refreshes itself from the form while the admin types, so the
|
||||||
|
wording is checked before it is saved rather than after. #}
|
||||||
|
<div class="relative">
|
||||||
|
<div id="email-text-preview"
|
||||||
|
hx-post="{{ path('app_admin_system_email_text_preview_draft', { 'key': definition.key.value }) }}"
|
||||||
|
hx-trigger="input from:#email-text-form delay:500ms"
|
||||||
|
hx-include="#email-text-form"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#email-text-preview-indicator">
|
||||||
|
{% include 'admin/system/email_text/_preview.html.twig' %}
|
||||||
|
</div>
|
||||||
|
{# 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. #}
|
||||||
|
<div class="htmx-indicator absolute inset-0 bg-white/75 flex items-center justify-center"
|
||||||
|
id="email-text-preview-indicator">
|
||||||
|
{% include '_partials/_spinner.html.twig' with { 'class': 'w-8 h-8' } %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -59,13 +59,10 @@
|
|||||||
hx-swap="beforeend">
|
hx-swap="beforeend">
|
||||||
{{ icon('eye') }}
|
{{ icon('eye') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<a href="{{ path('app_admin_system_email_text_edit', { 'key': key }) }}"
|
||||||
title="E-Mail-Text bearbeiten"
|
title="E-Mail-Text bearbeiten">
|
||||||
hx-get="{{ path('app_admin_system_email_text_edit', { 'key': key }) }}"
|
|
||||||
hx-target="body"
|
|
||||||
hx-swap="beforeend">
|
|
||||||
{{ icon('edit') }}
|
{{ icon('edit') }}
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -3,25 +3,5 @@
|
|||||||
{% block title %}Vorschau: {{ definition.label }}{% endblock %}
|
{% block title %}Vorschau: {{ definition.label }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="pb-4">
|
{% include 'admin/system/email_text/_preview.html.twig' %}
|
||||||
<span class="font-bold">Betreff:</span> {{ text.subject }}
|
|
||||||
</div>
|
|
||||||
{# The renderer escaped everything before it added a tag, see RenderedEmailTextDto. #}
|
|
||||||
<div class="border border-gray-200 rounded-md p-4">
|
|
||||||
{% if text.headline is not empty %}
|
|
||||||
<h1 class="text-xl font-bold pb-2">{{ text.headline | raw }}</h1>
|
|
||||||
{% endif %}
|
|
||||||
{# The paragraphs come from the mail body, where email/layout.html.twig styles every
|
|
||||||
<p> with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #}
|
|
||||||
<div class="[&>p]:pb-4 [&_a]:underline">
|
|
||||||
{{ text.bodyHtml | raw }}
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<span class="btn inline-block">Zum Portal</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="pt-4 text-sm text-gray-500">
|
|
||||||
Platzhalter sind als <code>[name]</code> dargestellt und werden beim Versand durch
|
|
||||||
die echten Werte ersetzt. Logo und Fußzeile der E-Mail sind hier nicht abgebildet.
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -179,6 +179,64 @@ class EmailTextRendererTest extends KernelTestCase
|
|||||||
$this->assertSame('Einsatz Ski & Snowboard', $rendered->subject);
|
$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('<p>Entwurfstext <strong>fett</strong></p>', $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<string, string|int|null> $placeholders
|
* @param array<string, string|int|null> $placeholders
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user