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: