Skip to content

Commit b3e5b22

Browse files
authored
Fix turbo final step submission issue (#3)
1 parent cc33b5f commit b3e5b22

4 files changed

Lines changed: 118 additions & 22 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ The frontend form is implemented as a Symfony form flow:
4545
- Flow state is stored in the session with a content-element-specific key.
4646
- The root form uses Contao CSRF options, so the hidden token field is `REQUEST_TOKEN` and validation uses `contao.csrf.token_manager`.
4747
- Successful step submissions use POST/Redirect/GET with HTTP 303 responses. Invalid submissions render
48-
the current form with HTTP 422 so Turbo can replace the frame with validation errors.
48+
the current form with HTTP 422. Frame submissions replace the frame; final `_top` submissions receive a
49+
complete page response so Turbo can display the validation errors without clearing the form.
4950

5051
Member fields are derived from DCA configuration. The mapper handles labels, help texts, mandatory fields, length constraints, selected `rgxp` rules, unique checks, multiple values, DCA options and compatible save callbacks.
5152

src/Controller/ContentElement/MultiStepRegistrationElementController.php

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@
1212
use Contao\CoreBundle\Twig\FragmentTemplate;
1313
use Contao\FormCaptcha;
1414
use Contao\MemberModel;
15+
use HeimrichHannot\MultiStepRegistration\EventListener\InvalidFormResponseListener;
1516
use HeimrichHannot\MultiStepRegistration\Form\DcaFormFieldMapper;
1617
use HeimrichHannot\MultiStepRegistration\Form\MemberRegistrationFlowType;
1718
use HeimrichHannot\MultiStepRegistration\Registration\EditableMemberFieldProvider;
1819
use HeimrichHannot\MultiStepRegistration\Registration\MemberRegistrationService;
1920
use HeimrichHannot\MultiStepRegistration\Registration\RegistrationFlowData;
2021
use HeimrichHannot\MultiStepRegistration\Registration\StepNormalizer;
2122
use Symfony\Component\Form\Flow\DataStorage\SessionDataStorage;
23+
use Symfony\Component\Form\Flow\FormFlowInterface;
2224
use Symfony\Component\Form\FormError;
2325
use Symfony\Component\Form\FormFactoryInterface;
24-
use Symfony\Component\Form\Flow\FormFlowInterface;
2526
use Symfony\Component\HttpFoundation\RedirectResponse;
2627
use Symfony\Component\HttpFoundation\Request;
2728
use Symfony\Component\HttpFoundation\RequestStack;
@@ -119,11 +120,15 @@ protected function getResponse(FragmentTemplate $template, ContentModel $model,
119120
$flow->addError(new FormError($GLOBALS['TL_LANG']['ERR']['passwordName'] ?? $this->translator->trans('frontend.password_matches_username', domain: 'huh_multi_step_registration')));
120121
}
121122

122-
if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished() && !$this->isCaptchaValid($model)) {
123-
$flow->addError(new FormError($GLOBALS['TL_LANG']['ERR']['captcha'] ?? $this->translator->trans('frontend.captcha_invalid', domain: 'huh_multi_step_registration')));
123+
$captcha = $this->shouldShowCaptcha($model, $flow) ? $this->createCaptcha($model) : null;
124+
125+
if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished() && null !== $captcha) {
126+
$captcha->validate();
124127
}
125128

126-
if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
129+
$captchaIsValid = null === $captcha || ! $captcha->hasErrors();
130+
131+
if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished() && $captchaIsValid) {
127132
$submittedData = $flow->getData();
128133

129134
if ($submittedData instanceof RegistrationFlowData) {
@@ -147,24 +152,33 @@ protected function getResponse(FragmentTemplate $template, ContentModel $model,
147152
}
148153
}
149154

150-
if ($flow->isSubmitted() && $flow->isValid()) {
155+
if ($flow->isSubmitted() && $flow->isValid() && $captchaIsValid) {
151156
// Triggers the clicked flow button handler before the PRG redirect.
152157
$flow->getStepForm();
153158

154159
return $this->redirectToCurrentRequest($request);
155160
}
156161

157-
$stepForm = $flow->getStepForm();
162+
// Do not let the finish button reset the flow before the captcha fallback is rendered.
163+
$stepForm = $captchaIsValid ? $flow->getStepForm() : $flow;
158164
$template->set('form', $stepForm->createView());
159-
$template->set('captcha', $this->shouldShowCaptcha($model, $stepForm) ? $this->createCaptcha($model)->parse() : null);
165+
$template->set('captcha', $captcha?->parse());
160166
$template->set('message', null);
161167
$template->set('steps', $steps);
162168
$template->set('is_last_step', $stepForm->getCursor()->isLastStep());
163169

164170
$response = $template->getResponse();
165171

166-
if ($flow->isSubmitted() && !$flow->isValid()) {
167-
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
172+
if ($flow->isSubmitted() && (! $flow->isValid() || ! $captchaIsValid)) {
173+
if ($request->headers->has('Turbo-Frame')) {
174+
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
175+
} elseif ($mainRequest = $this->requestStack->getMainRequest()) {
176+
// A non-successful fragment response aborts full-page rendering. Let the
177+
// fragment render successfully and apply the 422 to the completed page.
178+
$mainRequest->attributes->set(InvalidFormResponseListener::REQUEST_ATTRIBUTE, true);
179+
} else {
180+
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
181+
}
168182
}
169183

170184
return $response;
@@ -262,18 +276,6 @@ private function shouldShowCaptcha(ContentModel $model, mixed $flow): bool
262276
return !($model->msrDisableCaptcha ?? false) && method_exists($flow, 'getCursor') && $flow->getCursor()->isLastStep();
263277
}
264278

265-
private function isCaptchaValid(ContentModel $model): bool
266-
{
267-
if ($model->msrDisableCaptcha ?? false) {
268-
return true;
269-
}
270-
271-
$captcha = $this->createCaptcha($model);
272-
$captcha->validate();
273-
274-
return !$captcha->hasErrors();
275-
}
276-
277279
private function createCaptcha(ContentModel $model): FormCaptcha
278280
{
279281
$dca = [
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HeimrichHannot\MultiStepRegistration\EventListener;
6+
7+
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
8+
use Symfony\Component\HttpFoundation\Response;
9+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
10+
use Symfony\Component\HttpKernel\KernelEvents;
11+
12+
#[AsEventListener(KernelEvents::RESPONSE)]
13+
final class InvalidFormResponseListener
14+
{
15+
public const REQUEST_ATTRIBUTE = '_huh_multi_step_registration_invalid_form';
16+
17+
public function __invoke(ResponseEvent $event): void
18+
{
19+
if (! $event->isMainRequest()) {
20+
return;
21+
}
22+
23+
if (true !== $event->getRequest()->attributes->get(self::REQUEST_ATTRIBUTE)) {
24+
return;
25+
}
26+
27+
$event->getResponse()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
28+
}
29+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HeimrichHannot\MultiStepRegistration\Tests\EventListener;
6+
7+
use HeimrichHannot\MultiStepRegistration\EventListener\InvalidFormResponseListener;
8+
use PHPUnit\Framework\TestCase;
9+
use Symfony\Component\HttpFoundation\Request;
10+
use Symfony\Component\HttpFoundation\Response;
11+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
12+
use Symfony\Component\HttpKernel\HttpKernelInterface;
13+
14+
final class InvalidFormResponseListenerTest extends TestCase
15+
{
16+
public function testItMarksInvalidMainResponsesAsUnprocessable(): void
17+
{
18+
$request = new Request();
19+
$request->attributes->set(InvalidFormResponseListener::REQUEST_ATTRIBUTE, true);
20+
$response = new Response();
21+
$event = new ResponseEvent(
22+
$this->createMock(HttpKernelInterface::class),
23+
$request,
24+
HttpKernelInterface::MAIN_REQUEST,
25+
$response,
26+
);
27+
28+
(new InvalidFormResponseListener())($event);
29+
30+
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
31+
}
32+
33+
public function testItIgnoresUnmarkedResponses(): void
34+
{
35+
$response = new Response();
36+
$event = new ResponseEvent(
37+
$this->createMock(HttpKernelInterface::class),
38+
new Request(),
39+
HttpKernelInterface::MAIN_REQUEST,
40+
$response,
41+
);
42+
43+
(new InvalidFormResponseListener())($event);
44+
45+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
46+
}
47+
48+
public function testItIgnoresSubRequests(): void
49+
{
50+
$request = new Request();
51+
$request->attributes->set(InvalidFormResponseListener::REQUEST_ATTRIBUTE, true);
52+
$response = new Response();
53+
$event = new ResponseEvent(
54+
$this->createMock(HttpKernelInterface::class),
55+
$request,
56+
HttpKernelInterface::SUB_REQUEST,
57+
$response,
58+
);
59+
60+
(new InvalidFormResponseListener())($event);
61+
62+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
63+
}
64+
}

0 commit comments

Comments
 (0)