feat: forms on TYPO3 13, loaded into the static pages with htmx
The static export replaced every EXT:form form with a contact notice. The forms are back, rendered by TYPO3 13 and embedded in the static pages: - Page type 1704 (/<page>/form) renders only the form plugin of a page, inside a .form-island element. Every form posts to that URL (renderingOptions.additionalParams), because on www the page URL is a static file that never reaches TYPO3. htmx swaps the island in place: validation errors, multi-step pages and confirmations. A redirect to an htmx request becomes HX-Redirect (HtmxRedirectMiddleware), so Redirect finishers send the browser to the thank-you page. - While a request runs, an overlay with a spinner (as in MyE&P) covers the form. Styled inside the island: the static pages load the CSS of the TYPO3 10 export. - The bw_captcha image comes from /<page>/captcha for the same reason. Both suffixes are in the PageType enhancer of every site with forms (semesterende gets one with default '' to keep its URLs). - Ported from TYPO3 10: form setup, templates and partials, the MyE&P address finisher, the job application finisher with the MyE&P Team API client (epTeamApi* in secrets.php), the page URL/referrer finisher with ReferrerMiddleware (?ref=), the e-mail layout without the system footer, bw_captcha 5 and form_element_linked_checkbox 6. The address check forms are dropped. - German language packs (EXT:form messages): availableLanguages = de, fetched on every deploy (typo3:language:update). - The checkbox partials passed null to VHS' substring, which PHP 8.5 reports as deprecated. - DDEV no longer turns deprecations into exceptions, matching production: EXT:form's RenderFormValueViewHelper uses null as an array offset for empty optional fields (core bug, also in 13.4.37). The static pages get their placeholders from the exporter in EP/website, see .ddev/plans/static-export-form-islands.patch (local only). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
59e4a2d49b
commit
925e24397b
54 files changed
+2500
-3
No files matched your search
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Form;
|
||||
|
||||
use TYPO3\CMS\Core\Log\LogManager;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
|
||||
use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher;
|
||||
|
||||
abstract class AbstractMappedFinisher extends AbstractFinisher
|
||||
{
|
||||
/**
|
||||
* Resolves the 'mapping' option into remote name => local form element identifier.
|
||||
*/
|
||||
protected function parseMapping(): array
|
||||
{
|
||||
$mappings = [];
|
||||
$mappingOption = (string)$this->parseOption('mapping');
|
||||
|
||||
foreach (GeneralUtility::trimExplode(',', $mappingOption, true) as $mapping) {
|
||||
$parts = GeneralUtility::trimExplode(':', $mapping, true, 2);
|
||||
if (2 !== count($parts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$remote, $local] = $parts;
|
||||
if ('' === $remote || '' === $local) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mappings[$remote] = $local;
|
||||
}
|
||||
|
||||
return $mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped values, keyed by the remote name the receiving side expects. Values pass through
|
||||
* normalizeValue() just like the labeled ones do, so a date element arrives as a Y-m-d string
|
||||
* instead of a serialized DateTime. A value that does not survive normalization keeps its key:
|
||||
* the remote names make up the request shape and dropping one would change it.
|
||||
*/
|
||||
protected function buildPayload(): array
|
||||
{
|
||||
$values = [];
|
||||
$formValues = $this->finisherContext->getFormValues();
|
||||
|
||||
foreach ($this->parseMapping() as $remote => $local) {
|
||||
if (false === array_key_exists($local, $formValues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$remote] = $this->normalizeValue($formValues[$local], $local);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every submitted value that the mapping does not already cover, keyed by the element label
|
||||
* as the editor sees it in the form editor. Forms built in the backend use generated
|
||||
* identifiers (text-1, singleselect-2, ...) that carry no meaning and cannot be renamed
|
||||
* without breaking existing finisher options, so the label is the only stable description
|
||||
* of what was asked.
|
||||
*
|
||||
* The label is taken from the form definition, not through TranslationService: a translated
|
||||
* label would change the payload keys with the site language, and these keys are stored.
|
||||
*/
|
||||
protected function buildLabeledValues(): array
|
||||
{
|
||||
$formValues = $this->finisherContext->getFormValues();
|
||||
$skip = array_merge(
|
||||
array_values($this->parseMapping()),
|
||||
GeneralUtility::trimExplode(',', (string)$this->parseOption('exclude'), true)
|
||||
);
|
||||
|
||||
// Keyed by identifier in form order, and contains only real form elements - iterating
|
||||
// this instead of the form values keeps the collection in the order of the form and
|
||||
// leaves out anything that is not an element the visitor filled in.
|
||||
$elements = $this->finisherContext->getFormRuntime()->getFormDefinition()->getElements();
|
||||
|
||||
$collected = [];
|
||||
foreach ($elements as $identifier => $element) {
|
||||
if (true === in_array($identifier, $skip, true) || false === array_key_exists($identifier, $formValues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->normalizeValue($formValues[$identifier], $identifier);
|
||||
if (null === $value || '' === $value || [] === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim($element->getLabel());
|
||||
if ('' === $label) {
|
||||
$label = $identifier;
|
||||
}
|
||||
if (true === array_key_exists($label, $collected)) {
|
||||
$label .= ' (' . $identifier . ')';
|
||||
}
|
||||
|
||||
$collected[$label] = $value;
|
||||
}
|
||||
|
||||
return $collected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*
|
||||
* @return mixed null when the value has no JSON representation
|
||||
*/
|
||||
protected function normalizeValue($value, string $identifier)
|
||||
{
|
||||
if (true === is_array($value)) {
|
||||
$normalized = [];
|
||||
foreach ($value as $item) {
|
||||
$item = $this->normalizeValue($item, $identifier);
|
||||
if (null !== $item && '' !== $item) {
|
||||
$normalized[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('Y-m-d');
|
||||
}
|
||||
|
||||
if ($value instanceof FileReference) {
|
||||
$resource = $value->getOriginalResource();
|
||||
|
||||
return null !== $resource ? $resource->getName() : null;
|
||||
}
|
||||
|
||||
if (true === is_object($value)) {
|
||||
$this->logWarning('Form value skipped while collecting labeled values: unsupported type.', [
|
||||
'element' => $identifier,
|
||||
'class' => get_class($value),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function logWarning(string $message, array $context = []): void
|
||||
{
|
||||
GeneralUtility::makeInstance(LogManager::class)
|
||||
->getLogger(static::class)
|
||||
->warning($message, array_merge([
|
||||
'finisher' => static::class,
|
||||
], $context));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Form;
|
||||
|
||||
use EP\EpTheme\Middleware\ReferrerMiddleware;
|
||||
use TYPO3\CMS\Core\Routing\PageArguments;
|
||||
use TYPO3\CMS\Core\Site\Entity\Site;
|
||||
use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher;
|
||||
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
|
||||
|
||||
/**
|
||||
* Adds the URL of the form's page and, if known, the page the visitor came from (see
|
||||
* ReferrerMiddleware) to the submission, so the e-mail finishers include them.
|
||||
*/
|
||||
class AddReferrerFinisher extends AbstractFinisher
|
||||
{
|
||||
protected function executeInternal()
|
||||
{
|
||||
$this->addCurrentUrlField();
|
||||
$this->addReferrerField();
|
||||
}
|
||||
|
||||
private function addReferrerField(): void
|
||||
{
|
||||
$frontendUser = $this->finisherContext->getRequest()->getAttribute('frontend.user');
|
||||
if (!$frontendUser instanceof FrontendUserAuthentication) {
|
||||
return;
|
||||
}
|
||||
|
||||
$referrer = $frontendUser->getKey('ses', ReferrerMiddleware::SESSION_KEY);
|
||||
if (null === $referrer) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addField('referrer', 'Referrer', $referrer);
|
||||
|
||||
$frontendUser->setKey('ses', ReferrerMiddleware::SESSION_KEY, null);
|
||||
$frontendUser->storeSessionData();
|
||||
}
|
||||
|
||||
private function addCurrentUrlField(): void
|
||||
{
|
||||
$request = $this->finisherContext->getRequest();
|
||||
$routing = $request->getAttribute('routing');
|
||||
$site = $request->getAttribute('site');
|
||||
if (!$routing instanceof PageArguments || !$site instanceof Site) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The page itself, not the form page type the submission was posted to
|
||||
$url = (string)$site->getRouter()->generateUri($routing->getPageId(), [
|
||||
'_language' => $request->getAttribute('language'),
|
||||
]);
|
||||
$this->addField('pageurl', 'URL', $url);
|
||||
}
|
||||
|
||||
private function addField(string $identifier, string $label, string $value): void
|
||||
{
|
||||
$field = $this->finisherContext
|
||||
->getFormRuntime()
|
||||
->getFormDefinition()
|
||||
->getPageByIndex(0)
|
||||
->createElement($identifier, 'Text');
|
||||
|
||||
$field->setDefaultValue($value);
|
||||
$field->setDataType('string');
|
||||
$field->setLabel($label);
|
||||
|
||||
// Finisher variable for potential later use
|
||||
$this->finisherContext->getFinisherVariableProvider()->add(
|
||||
$this->shortFinisherIdentifier,
|
||||
$identifier,
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Form;
|
||||
|
||||
use EP\EpProducts\EpTeam\ApiClient;
|
||||
use TYPO3\CMS\Core\Routing\PageArguments;
|
||||
use TYPO3\CMS\Core\Site\Entity\Site;
|
||||
|
||||
/**
|
||||
* Sends job applications to MyE&P Team (POST /api/application-forms).
|
||||
*/
|
||||
class ApplicationApiFinisher extends AbstractMappedFinisher
|
||||
{
|
||||
public function __construct(private readonly ApiClient $apiClient) {}
|
||||
|
||||
protected function executeInternal()
|
||||
{
|
||||
try {
|
||||
$payload = $this->buildPayload();
|
||||
if ([] === $payload) {
|
||||
$this->logWarning('Application form API finisher skipped: the mapping produced no values.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$formDefinition = $this->finisherContext->getFormRuntime()->getFormDefinition();
|
||||
$formLabel = trim($formDefinition->getLabel());
|
||||
|
||||
$payload['form'] = $formDefinition->getIdentifier();
|
||||
// The name the editor gave the form, so the receiving side can label a submission
|
||||
// without keeping a list of identifiers. Taken from the definition rather than
|
||||
// through TranslationService: a translated label would differ per site language.
|
||||
$payload['formLabel'] = '' !== $formLabel ? $formLabel : $formDefinition->getIdentifier();
|
||||
$payload['submittedAt'] = (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
|
||||
$payload = array_merge($payload, $this->resolvePageContext());
|
||||
$payload['details'] = $this->buildLabeledValues();
|
||||
|
||||
$this->apiClient->submitApplicationForm($payload);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logWarning('Application form API finisher request failed.', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvePageContext(): array
|
||||
{
|
||||
$request = $this->finisherContext->getRequest();
|
||||
$routing = $request->getAttribute('routing');
|
||||
if (!$routing instanceof PageArguments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pageUid = $routing->getPageId();
|
||||
$context = ['pageUid' => $pageUid];
|
||||
|
||||
try {
|
||||
/** @var Site $site */
|
||||
$site = $request->getAttribute('site');
|
||||
// The page itself, not the form page type the submission was posted to
|
||||
$context['pageUrl'] = (string)$site->getRouter()->generateUri($pageUid, [
|
||||
'_language' => $request->getAttribute('language'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// An unroutable page must not cost the submission, so the uid is sent without a URL.
|
||||
$this->logWarning('Application form API finisher could not resolve the page URL.', [
|
||||
'pageUid' => $pageUid,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Form;
|
||||
|
||||
use EP\EpProducts\MyEP\ApiClient;
|
||||
|
||||
/**
|
||||
* Registers the sender's address in BusPro through MyE&P (POST contactform). A failing API never
|
||||
* costs the visitor the submission: the e-mail finisher still runs.
|
||||
*/
|
||||
class BpnApiFinisher extends AbstractMappedFinisher
|
||||
{
|
||||
public function __construct(private readonly ApiClient $apiClient) {}
|
||||
|
||||
protected function executeInternal()
|
||||
{
|
||||
try {
|
||||
$values = $this->buildPayload();
|
||||
if ([] !== $values) {
|
||||
$this->apiClient->registerAddress($values);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logWarning('Bpn API finisher request failed.', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* htmx follows a redirect inside its XHR and would swap the target page into the form. A redirect
|
||||
* answering an htmx request (e.g. the Redirect finisher of a form) becomes HX-Redirect instead,
|
||||
* which sends the browser itself to the new location.
|
||||
*/
|
||||
class HtmxRedirectMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(private readonly ResponseFactoryInterface $responseFactory) {}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$response = $handler->handle($request);
|
||||
|
||||
if ($request->getHeaderLine('HX-Request') !== 'true'
|
||||
|| $response->getStatusCode() < 300
|
||||
|| $response->getStatusCode() >= 400
|
||||
|| !$response->hasHeader('Location')
|
||||
) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $this->responseFactory->createResponse(200)
|
||||
->withHeader('HX-Redirect', $response->getHeaderLine('Location'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpTheme\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
|
||||
|
||||
/**
|
||||
* Links to a form page carry the uid of the page they come from (?ref=123, e.g. the enquiry
|
||||
* button of a product page). Its URL is kept in the session until AddReferrerFinisher puts it
|
||||
* into the submission. On static pages the parameter reaches TYPO3 with the request that loads
|
||||
* the form (see the form placeholder of the static export).
|
||||
*/
|
||||
class ReferrerMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public const SESSION_KEY = 'referrer';
|
||||
|
||||
public function __construct(private readonly SiteFinder $siteFinder) {}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$ref = $request->getQueryParams()['ref'] ?? null;
|
||||
$frontendUser = $request->getAttribute('frontend.user');
|
||||
|
||||
if (!is_numeric($ref) || !$frontendUser instanceof FrontendUserAuthentication) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$url = (string)$this->siteFinder->getSiteByPageId((int)$ref)->getRouter()->generateUri((int)$ref);
|
||||
$frontendUser->setKey('ses', self::SESSION_KEY, $url);
|
||||
$frontendUser->storeSessionData();
|
||||
} catch (SiteNotFoundException) {
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user