From e52f7dcff89aeda4cdf1535f4e47b3eb406b546e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 22 Sep 2026 12:53:51 +0200 Subject: [PATCH] feat: post application forms to MyEP with label-keyed extra values Forms built in the backend form editor use generated element identifiers (text-1, multicheckbox-2, ...) that carry no meaning and cannot be renamed without breaking the other finishers configured on the form. ApplicationApiFinisher therefore sends two halves: a small mapped core (firstName, lastName, email, ...) that a developer configures per form and MyEP can rely on, plus every remaining submitted value keyed by the element label for a human to read. Each submission also carries where it came from: the form identifier and its editor-given name, plus the uid and canonical URL of the page it was submitted from - the same definition is embedded on several pages, so the page is what distinguishes them. The URL is generated through the site router in the visitor's language, like AddReferrerFinisher does it. Labels come from the form definition rather than TranslationService, so the keys do not shift with the site language. Values are normalized for JSON: dates to Y-m-d, uploads to the file name, multi-value elements to a list, empty values dropped, unsupported objects skipped with a log warning. Like BpnApiFinisher the request failure is only logged, so a MyEP outage never costs the application mail. The endpoint (POST applications) does not exist in MyEP yet; the contract handed over to that team lives in .ddev/plans/MYEP-APPLICATION-ENDPOINT.md. Until it is built the finisher is registered but attached to no form. Co-Authored-By: Claude Opus 5 --- .../ep_products/Classes/MyEP/ApiClient.php | 14 +++ .../Classes/Form/AbstractMappedFinisher.php | 119 +++++++++++++++++- .../Classes/Form/ApplicationApiFinisher.php | 83 ++++++++++++ .../Configuration/Yaml/BaseSetup.yaml | 2 + .../Yaml/FormFinishersBackend.yaml | 29 +++++ .../Yaml/FormFinishersFrontend.yaml | 8 ++ 6 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 public/typo3conf/ext/ep_theme/Classes/Form/ApplicationApiFinisher.php diff --git a/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php b/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php index 6dea91e0..edb82c03 100644 --- a/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php +++ b/public/typo3conf/ext/ep_products/Classes/MyEP/ApiClient.php @@ -105,6 +105,20 @@ class ApiClient implements LoggerAwareInterface $this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'contactform'); } + /** + * @throws ApiException + */ + public function submitApplication(array $applicationData): void + { + $response = $this->request('POST', 'applications', [ + 'json' => $applicationData, + ]); + + // A conflict means the submission is already known, which is the normal answer to a + // double-clicked submit button and not worth reporting. + $this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'applications'); + } + /** * @throws ApiException */ diff --git a/public/typo3conf/ext/ep_theme/Classes/Form/AbstractMappedFinisher.php b/public/typo3conf/ext/ep_theme/Classes/Form/AbstractMappedFinisher.php index 0c48a151..aa271ca6 100644 --- a/public/typo3conf/ext/ep_theme/Classes/Form/AbstractMappedFinisher.php +++ b/public/typo3conf/ext/ep_theme/Classes/Form/AbstractMappedFinisher.php @@ -4,25 +4,43 @@ 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 { - protected function buildPayload(): array + /** + * Resolves the 'mapping' option into remote name => local form element identifier. + */ + protected function parseMapping(): array { - $values = []; - $formValues = $this->finisherContext->getFormValues(); + $mappings = []; $mappingOption = (string)$this->parseOption('mapping'); - $mappings = GeneralUtility::trimExplode(',', $mappingOption, true); - foreach ($mappings as $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 || false === array_key_exists($local, $formValues)) { + if ('' === $remote || '' === $local) { + continue; + } + + $mappings[$remote] = $local; + } + + return $mappings; + } + + protected function buildPayload(): array + { + $values = []; + $formValues = $this->finisherContext->getFormValues(); + + foreach ($this->parseMapping() as $remote => $local) { + if (false === array_key_exists($local, $formValues)) { continue; } @@ -32,6 +50,95 @@ abstract class AbstractMappedFinisher extends AbstractFinisher 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) diff --git a/public/typo3conf/ext/ep_theme/Classes/Form/ApplicationApiFinisher.php b/public/typo3conf/ext/ep_theme/Classes/Form/ApplicationApiFinisher.php new file mode 100644 index 00000000..0f9817ca --- /dev/null +++ b/public/typo3conf/ext/ep_theme/Classes/Form/ApplicationApiFinisher.php @@ -0,0 +1,83 @@ +apiClient = $apiClient; + } + + protected function executeInternal() + { + try { + $payload = $this->buildPayload(); + if ([] === $payload) { + $this->logWarning('Application 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->submitApplication($payload); + } catch (\Throwable $e) { + $this->logWarning('Application API finisher request failed.', [ + 'exception' => $e, + ]); + } + } + + /** + * The page the form was submitted from. The URL is generated through the site router in the + * visitor's language, the same way AddReferrerFinisher does it, so it is the page's canonical + * URL rather than the submit request with its form arguments attached. + */ + private function resolvePageContext(): array + { + if (false === isset($GLOBALS['TSFE']) || 0 === (int)$GLOBALS['TSFE']->id) { + return []; + } + + $pageUid = (int)$GLOBALS['TSFE']->id; + $context = ['pageUid' => $pageUid]; + + try { + $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid); + $context['pageUrl'] = (string)$site->getRouter()->generateUri($pageUid, [ + '_language' => $GLOBALS['TSFE']->getLanguage(), + ]); + } catch (\Throwable $e) { + // An unroutable page must not cost the submission, so the uid is sent without a URL. + $this->logWarning('Application API finisher could not resolve the page URL.', [ + 'pageUid' => $pageUid, + 'exception' => $e, + ]); + } + + return $context; + } +} diff --git a/public/typo3conf/ext/ep_theme/Configuration/Yaml/BaseSetup.yaml b/public/typo3conf/ext/ep_theme/Configuration/Yaml/BaseSetup.yaml index 7aa95971..79da4479 100644 --- a/public/typo3conf/ext/ep_theme/Configuration/Yaml/BaseSetup.yaml +++ b/public/typo3conf/ext/ep_theme/Configuration/Yaml/BaseSetup.yaml @@ -23,6 +23,8 @@ TYPO3: finishersDefinition: BpnApiFinisher: implementationClassName: 'EP\EpTheme\Form\BpnApiFinisher' + ApplicationApiFinisher: + implementationClassName: 'EP\EpTheme\Form\ApplicationApiFinisher' WebhookFinisher: implementationClassName: 'EP\EpTheme\Form\WebhookFinisher' AddReferrerFinisher: diff --git a/public/typo3conf/ext/ep_theme/Configuration/Yaml/FormFinishersBackend.yaml b/public/typo3conf/ext/ep_theme/Configuration/Yaml/FormFinishersBackend.yaml index fffa595f..25d87450 100644 --- a/public/typo3conf/ext/ep_theme/Configuration/Yaml/FormFinishersBackend.yaml +++ b/public/typo3conf/ext/ep_theme/Configuration/Yaml/FormFinishersBackend.yaml @@ -12,6 +12,9 @@ TYPO3: 1000: value: 'BpnApiFinisher' label: 'BusPro Finisher' + 1010: + value: 'ApplicationApiFinisher' + label: 'Application Finisher' 1050: value: 'WebhookFinisher' label: 'Webhook Finisher' @@ -34,6 +37,25 @@ TYPO3: propertyPath: 'options.mapping' propertyValidators: 10: 'NotEmpty' + 1010: + identifier: 'ApplicationApiFinisher' + editors: + __inheritances: + 10: 'TYPO3.CMS.Form.mixins.formElementMixins.BaseCollectionEditorsMixin' + 100: + label: 'Application Finisher' + 110: + identifier: 'mapping' + templateName: 'Inspector-TextEditor' + label: 'Mapping' + propertyPath: 'options.mapping' + propertyValidators: + 10: 'NotEmpty' + 120: + identifier: 'exclude' + templateName: 'Inspector-TextEditor' + label: 'Exclude' + propertyPath: 'options.exclude' 1050: identifier: 'WebhookFinisher' editors: @@ -70,6 +92,13 @@ TYPO3: predefinedDefaults: options: mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1' + ApplicationApiFinisher: + formEditor: + iconIdentifier: 'form-finisher' + predefinedDefaults: + options: + mapping: 'firstName:text-1,lastName:text-2,email:email-1,phone:telephone-1' + exclude: 'captcha,privacypolicyaccepted' WebhookFinisher: formEditor: iconIdentifier: 'form-finisher' diff --git a/public/typo3conf/ext/ep_theme/Configuration/Yaml/FormFinishersFrontend.yaml b/public/typo3conf/ext/ep_theme/Configuration/Yaml/FormFinishersFrontend.yaml index 2ed440f3..fbd9d991 100644 --- a/public/typo3conf/ext/ep_theme/Configuration/Yaml/FormFinishersFrontend.yaml +++ b/public/typo3conf/ext/ep_theme/Configuration/Yaml/FormFinishersFrontend.yaml @@ -11,6 +11,14 @@ TYPO3: options: identifier: 'BpnApiFinisher' mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1' + ApplicationApiFinisher: + implementationClassName: 'EP\EpTheme\Form\ApplicationApiFinisher' + formEditor: + label: 'Application Finisher' + options: + identifier: 'ApplicationApiFinisher' + mapping: 'firstName:text-1,lastName:text-2,email:email-1,phone:telephone-1' + exclude: 'captcha,privacypolicyaccepted' WebhookFinisher: implementationClassName: 'EP\EpTheme\Form\WebhookFinisher' formEditor: