diff --git a/Classes/Dto/EditModeUrls.php b/Classes/Dto/EditModeUrls.php new file mode 100644 index 0000000..9c206df --- /dev/null +++ b/Classes/Dto/EditModeUrls.php @@ -0,0 +1,16 @@ + $parameters + * @param array $usedArguments + */ + public function __construct( + private array $parameters, + private readonly array $usedArguments, + private readonly ServerRequestInterface $request, + ) { + } + + /** + * @return array + */ + public function getParameters(): array + { + return $this->parameters; + } + + /** + * @param array $parameters + */ + public function setParameters(array $parameters): void + { + $this->parameters = $parameters; + } + + /** + * @return array + */ + public function getUsedArguments(): array + { + return $this->usedArguments; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } +} diff --git a/Classes/Service/EditModeService.php b/Classes/Service/EditModeService.php index c5f73b2..6930f18 100644 --- a/Classes/Service/EditModeService.php +++ b/Classes/Service/EditModeService.php @@ -5,9 +5,7 @@ namespace TYPO3\CMS\VisualEditor\Service; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\UriInterface; use RuntimeException; -use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; use TYPO3\CMS\Core\Domain\Record; use TYPO3\CMS\Core\Domain\RecordInterface; @@ -16,21 +14,17 @@ use TYPO3\CMS\Core\Localization\LanguageServiceFactory; use TYPO3\CMS\Core\Page\AssetCollector; use TYPO3\CMS\Core\Page\PageRenderer; -use TYPO3\CMS\Core\Routing\PageArguments; use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; use TYPO3\CMS\Core\Schema\TcaSchemaFactory; use TYPO3\CMS\Core\Site\Entity\SiteLanguage; -use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; use TYPO3\CMS\Frontend\Page\PageInformation; -use function array_replace_recursive; use function method_exists; final readonly class EditModeService { public function __construct( private AssetCollector $assetCollector, - private UriBuilder $uriBuilder, private PageRenderer $pageRenderer, private TcaSchemaFactory $tcaSchema, private LanguageServiceFactory $languageServiceFactory, @@ -39,6 +33,7 @@ public function __construct( private FormProtectionFactory $formProtectionFactory, private Typo3Version $typo3Version, private AllowedOriginService $allowedOriginService, + private UrlGenerationService $urlGenerationService, ) { } @@ -86,39 +81,19 @@ public function init(ServerRequestInterface $request): void throw new RuntimeException('Could not determine current site language', 3305745963); } - $isExtContainerInstalled = ExtensionManagementUtility::isLoaded('container'); - - $backendEditUrl = (string)$this->getBackendEditUrl($request); - - $newContentUrl = (string)$this->uriBuilder->buildUriFromRoute('new_content_element_wizard', [ - 'id' => $pageId, - 'colPos' => '__COL_POS__', - 'uid_pid' => '__UID_PID__', - ...($isExtContainerInstalled ? ['tx_container_parent' => '__TX_CONTAINER_PARENT__'] : []), - 'returnUrl' => $backendEditUrl, - ]); - - $editParams = [ - 'edit' => ['__TABLE__' => ['__UID__' => 'edit']], - 'returnUrl' => $backendEditUrl, - 'module' => 'web_edit', - ]; - $editContentUrl = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $editParams); - if ($this->typo3Version->getMajorVersion() >= 14) { - $editContentContextualUrl = (string)$this->uriBuilder->buildUriFromRoute('record_edit_contextual', $editParams); - } + $urls = $this->urlGenerationService->generateUrls($request); $veInfo = [ 'pageId' => $pageId, 'languageId' => $siteLanguage->getLanguageId(), 'showIdWithTitle' => !empty($this->getBeUser()->getTSConfig()['options.']['pageTree.']['showPageIdWithTitle']), - 'backendEditUrl' => $backendEditUrl, - 'newContentUrl' => $newContentUrl, - 'editContentUrl' => $editContentUrl, - 'editContentContextualUrl' => $editContentContextualUrl ?? null, + 'backendEditUrl' => $urls->backendEditUrl, + 'newContentUrl' => $urls->newContentUrl, + 'editContentUrl' => $urls->editContentUrl, + 'editContentContextualUrl' => $urls->editContentContextualUrl ?? null, 'allowNewContent' => $this->languageModeService->getAllowNewContent($pageInformation, $siteLanguage, $request), 'token' => $this->formProtectionFactory->createForType('backend')->generateToken('visual_editor', 'save'), - 'routeArguments' => (object)$this->flattenBracketKeys(['params' => $this->getUsedArguments($request)]), + 'routeArguments' => (object)$this->urlGenerationService->flattenBracketKeys(['params' => $this->urlGenerationService->getUsedArguments($request)]), 'allowedOrigins' => $this->allowedOriginService->getAllowedOrigins(), ]; $this->assetCollector->addInlineJavaScript( @@ -143,27 +118,6 @@ public function init(ServerRequestInterface $request): void } } - /** - * @param array> $input - * @return array - */ - private function flattenBracketKeys(array $input, string $prefix = ''): array - { - $result = []; - - foreach ($input as $key => $value) { - $newKey = $prefix === '' ? (string)$key : $prefix . '[' . $key . ']'; - - if (is_array($value)) { - $result += $this->flattenBracketKeys($value, $newKey); - } else { - $result[$newKey] = (string)$value; - } - } - - return $result; - } - public function canEditField(RecordInterface $record, string $field, ServerRequestInterface $request): bool { if (!$this->isEditMode($request)) { @@ -230,51 +184,6 @@ private function loadLanguageLabelsInline(): void } } - public function getBackendEditUrl(ServerRequestInterface $request): UriInterface - { - // backend and Frontend Context: determine current page id - $pageInformation = $request->getAttribute('frontend.page.information'); - if (!$pageInformation instanceof PageInformation) { - throw new RuntimeException('Could not determine current page information', 9965439961); - } - - $pageId = $pageInformation->getId(); - if (!$pageId) { - throw new RuntimeException('Could not determine current page id', 1768983081); - } - - $siteLanguage = $request->getAttribute('language'); - if (!$siteLanguage instanceof SiteLanguage) { - throw new RuntimeException('Could not determine current site language', 3305745963); - } - - $usedArguments = $this->getUsedArguments($request); - return $this->uriBuilder->buildUriFromRoute('web_edit', [ - 'id' => $pageId, - // the selected viewMode and languages are saved in be_user->uc - 'params' => $usedArguments, - ]); - } - - /** - * @return array>> - */ - public function getUsedArguments(ServerRequestInterface $request): array - { - $routing = $request->getAttribute('routing'); - if (!$routing instanceof PageArguments) { - throw new RuntimeException('Could not determine current routing context', 1773230232); - } - - $usedArguments = array_replace_recursive( - $routing->getArguments(), - $routing->getRouteArguments(), - ); - unset($usedArguments['cHash']); - unset($usedArguments['editMode']); - return $usedArguments; - } - private function getBeUser(): BackendUserAuthentication { $beUser = $GLOBALS['BE_USER']; diff --git a/Classes/Service/UrlGenerationService.php b/Classes/Service/UrlGenerationService.php new file mode 100644 index 0000000..fd7714e --- /dev/null +++ b/Classes/Service/UrlGenerationService.php @@ -0,0 +1,141 @@ +getAttribute('frontend.page.information'); + if (!$pageInformation instanceof PageInformation) { + throw new RuntimeException('Could not determine current page information', 9965439961); + } + + $pageId = $pageInformation->getId(); + + if (!$pageId) { + throw new RuntimeException('Could not determine current page id', 1768983081); + } + + $isExtContainerInstalled = ExtensionManagementUtility::isLoaded('container'); + + $backendEditUrl = (string)$this->getBackendEditUrl($request); + + $event = $this->eventDispatcher->dispatch( + new ModifyNewContentElementWizardUrlParameterEvent([ + 'id' => $pageId, + 'colPos' => '__COL_POS__', + 'uid_pid' => '__UID_PID__', + ...($isExtContainerInstalled ? ['tx_container_parent' => '__TX_CONTAINER_PARENT__'] : []), + 'returnUrl' => $backendEditUrl, + ], $this->getUsedArguments($request), $request), + ); + assert($event instanceof ModifyNewContentElementWizardUrlParameterEvent); + $parameters = $event->getParameters(); + + $newContentUrl = (string)$this->uriBuilder->buildUriFromRoute('new_content_element_wizard', $parameters); + + $editParams = [ + 'edit' => ['__TABLE__' => ['__UID__' => 'edit']], + 'returnUrl' => $backendEditUrl, + 'module' => 'web_edit', + ]; + $editContentUrl = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $editParams); + $editContentContextualUrl = null; + if ($this->typo3Version->getMajorVersion() >= 14) { + $editContentContextualUrl = (string)$this->uriBuilder->buildUriFromRoute('record_edit_contextual', $editParams); + } + + return new EditModeUrls($backendEditUrl, $newContentUrl, $editContentUrl, $editContentContextualUrl); + } + + public function getBackendEditUrl(ServerRequestInterface $request): UriInterface + { + // backend and Frontend Context: determine current page id + $pageInformation = $request->getAttribute('frontend.page.information'); + if (!$pageInformation instanceof PageInformation) { + throw new RuntimeException('Could not determine current page information', 9965439961); + } + + $pageId = $pageInformation->getId(); + if (!$pageId) { + throw new RuntimeException('Could not determine current page id', 1768983081); + } + + $siteLanguage = $request->getAttribute('language'); + if (!$siteLanguage instanceof SiteLanguage) { + throw new RuntimeException('Could not determine current site language', 3305745963); + } + + $usedArguments = $this->getUsedArguments($request); + return $this->uriBuilder->buildUriFromRoute('web_edit', [ + 'id' => $pageId, + // the selected viewMode and languages are saved in be_user->uc + 'params' => $usedArguments, + ]); + } + + /** + * @return array>> + */ + public function getUsedArguments(ServerRequestInterface $request): array + { + $routing = $request->getAttribute('routing'); + if (!$routing instanceof PageArguments) { + throw new RuntimeException('Could not determine current routing context', 1773230232); + } + + $usedArguments = array_replace_recursive( + $routing->getArguments(), + $routing->getRouteArguments(), + ); + unset($usedArguments['cHash']); + unset($usedArguments['editMode']); + return $usedArguments; + } + + /** + * @param array> $input + * @return array + */ + public function flattenBracketKeys(array $input, string $prefix = ''): array + { + $result = []; + + foreach ($input as $key => $value) { + $newKey = $prefix === '' ? (string)$key : $prefix . '[' . $key . ']'; + + if ($value === []) { + $result[$newKey] = ''; + } elseif (is_array($value)) { + $result += $this->flattenBracketKeys($value, $newKey); + } else { + $result[$newKey] = (string)$value; + } + } + + return $result; + } +} diff --git a/Classes/ViewHelpers/ImageViewHelper.php b/Classes/ViewHelpers/ImageViewHelper.php index e0db70b..c235516 100644 --- a/Classes/ViewHelpers/ImageViewHelper.php +++ b/Classes/ViewHelpers/ImageViewHelper.php @@ -16,6 +16,7 @@ use TYPO3\CMS\Extbase\Service\ImageService; use TYPO3\CMS\Fluid\ViewHelpers\ImageViewHelper as CoreImageViewHelper; use TYPO3\CMS\VisualEditor\Service\EditModeService; +use TYPO3\CMS\VisualEditor\Service\UrlGenerationService; use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractTagBasedViewHelper; use function htmlspecialchars; @@ -40,6 +41,7 @@ public function __construct( private readonly ImageService $imageService, private readonly EditModeService $editModeService, private readonly Typo3Version $typo3Version, + private readonly UrlGenerationService $urlGenerationService, ) { parent::__construct(); } @@ -103,7 +105,7 @@ private function buildVisualEditorPayload(ServerRequestInterface $request): ?arr return null; } - $backendEditUrl = (string)$this->editModeService->getBackendEditUrl($request); + $backendEditUrl = (string)$this->urlGenerationService->getBackendEditUrl($request); $editParams = [ 'edit' => [$table => [$uid => 'edit']], 'columnsOnly' => [$table => $fields], diff --git a/README.md b/README.md index 9ca8e1e..a7e7c97 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,41 @@ search for: ```` +## PSR-14 events + +### `ModifyNewContentElementWizardUrlParameterEvent` + +This event allows extensions to modify the URL parameters used to open the new content element wizard. It provides the current parameters, the route arguments used to render the frontend page, and the current request. + +For example, a listener can pass a frontend route argument to the wizard: + +````php +getUsedArguments()['tx_myextension']['context'] ?? null; + if ($context === null) { + return; + } + + $parameters = $event->getParameters(); + $parameters['myExtensionContext'] = $context; + $event->setParameters($parameters); + } +} +```` + +The additional parameter is then available from the request of TYPO3 Core's `ModifyNewContentElementWizardItemsEvent`, where it can be used, for example, to adjust the `defaultValues` of wizard items. + ## Known limitations - Wrapped content elements rendered with `f:render.contentArea` and `recordAs` are not currently supported for drag-and-drop. In this setup, drag handles may disappear. Move the wrapping markup into the content element rendering instead of wrapping elements at the `contentArea` level. diff --git a/Tests/Unit/Service/EditModeServiceTest.php b/Tests/Unit/Service/EditModeServiceTest.php deleted file mode 100644 index a0f8422..0000000 --- a/Tests/Unit/Service/EditModeServiceTest.php +++ /dev/null @@ -1,126 +0,0 @@ - $arguments - * @param array $routeArguments - * @param array $expected - * @throws Exception - */ - #[Test] - #[DataProvider('usedArgumentsDataProvider')] - public function getUsedArgumentsReplacesDuplicateRouteArguments(array $arguments, array $routeArguments, array $expected): void - { - $routing = $this->createStub(PageArguments::class); - $routing - ->method('getArguments') - ->willReturn($arguments); - $routing - ->method('getRouteArguments') - ->willReturn($routeArguments); - - $request = (new ServerRequest('GET', '/news/detail'))->withAttribute('routing', $routing); - - self::assertSame($expected, $this->createSubject()->getUsedArguments($request)); - } - - /** - * @return Generator, routeArguments: array, expected: array}> - */ - public static function usedArgumentsDataProvider(): Generator - { - yield 'keeps duplicate plugin route arguments scalar' => [ - 'arguments' => [ - 'tx_news_pi1' => [ - 'action' => 'detail', - 'controller' => 'News', - 'news' => '123', - ], - 'cHash' => 'will-be-removed', - 'editMode' => '1', - ], - 'routeArguments' => [ - 'tx_news_pi1' => [ - 'action' => 'detail', - 'controller' => 'News', - ], - ], - 'expected' => [ - 'tx_news_pi1' => [ - 'action' => 'detail', - 'controller' => 'News', - 'news' => '123', - ], - ], - ]; - - yield 'route arguments replace nested dynamic values' => [ - 'arguments' => [ - 'tx_news_pi1' => [ - 'action' => 'list', - 'controller' => 'News', - ], - 'category' => 'press', - ], - 'routeArguments' => [ - 'tx_news_pi1' => [ - 'action' => 'detail', - ], - ], - 'expected' => [ - 'tx_news_pi1' => [ - 'action' => 'detail', - 'controller' => 'News', - ], - 'category' => 'press', - ], - ]; - - yield 'keeps repeated query argument values as list' => [ - 'arguments' => [ - 'filter' => [ - '123', - '456', - ], - ], - 'routeArguments' => [], - 'expected' => [ - 'filter' => [ - '123', - '456', - ], - ], - ]; - } - - #[Test] - public function getUsedArgumentsThrowsExceptionIfRoutingIsMissing(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionCode(1773230232); - $this->expectExceptionMessage('Could not determine current routing context'); - - $this->createSubject()->getUsedArguments(new ServerRequest('GET', '/')); - } - - private function createSubject(): EditModeService - { - return (new ReflectionClass(EditModeService::class))->newInstanceWithoutConstructor(); - } -} diff --git a/Tests/Unit/Service/UrlGenerationServiceTest.php b/Tests/Unit/Service/UrlGenerationServiceTest.php new file mode 100644 index 0000000..b5c9d78 --- /dev/null +++ b/Tests/Unit/Service/UrlGenerationServiceTest.php @@ -0,0 +1,457 @@ + $arguments + * @param array $routeArguments + * @param array $expected + */ + #[Test] + #[DataProvider('usedArgumentsDataProvider')] + public function getUsedArgumentsReplacesDuplicateRouteArguments(array $arguments, array $routeArguments, array $expected): void + { + $routing = $this->createStub(PageArguments::class); + $routing + ->method('getArguments') + ->willReturn($arguments); + $routing + ->method('getRouteArguments') + ->willReturn($routeArguments); + + $request = (new ServerRequest('GET', '/news/detail'))->withAttribute('routing', $routing); + + self::assertSame($expected, $this->createSubject()->getUsedArguments($request)); + } + + /** + * @return Generator, routeArguments: array, expected: array}> + */ + public static function usedArgumentsDataProvider(): Generator + { + yield 'keeps duplicate plugin route arguments scalar' => [ + 'arguments' => [ + 'tx_news_pi1' => [ + 'action' => 'detail', + 'controller' => 'News', + 'news' => '123', + ], + 'cHash' => 'will-be-removed', + 'editMode' => '1', + ], + 'routeArguments' => [ + 'tx_news_pi1' => [ + 'action' => 'detail', + 'controller' => 'News', + ], + ], + 'expected' => [ + 'tx_news_pi1' => [ + 'action' => 'detail', + 'controller' => 'News', + 'news' => '123', + ], + ], + ]; + + yield 'route arguments replace nested dynamic values' => [ + 'arguments' => [ + 'tx_news_pi1' => [ + 'action' => 'list', + 'controller' => 'News', + ], + 'category' => 'press', + ], + 'routeArguments' => [ + 'tx_news_pi1' => [ + 'action' => 'detail', + ], + ], + 'expected' => [ + 'tx_news_pi1' => [ + 'action' => 'detail', + 'controller' => 'News', + ], + 'category' => 'press', + ], + ]; + + yield 'keeps repeated query argument values as list' => [ + 'arguments' => [ + 'filter' => [ + '123', + '456', + ], + ], + 'routeArguments' => [], + 'expected' => [ + 'filter' => [ + '123', + '456', + ], + ], + ]; + } + + #[Test] + public function getUsedArgumentsThrowsExceptionIfRoutingIsMissing(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(1773230232); + $this->expectExceptionMessage('Could not determine current routing context'); + + $this->createSubject()->getUsedArguments(new ServerRequest('GET', '/')); + } + + #[Test] + public function getBackendEditUrlReturnsUriForCurrentRequest(): void + { + self::assertSame( + $this->buildUri('web_edit', [ + 'id' => 42, + 'params' => $this->usedArguments(), + ]), + (string)$this->createSubject()->getBackendEditUrl($this->createRequest()), + ); + } + + #[Test] + public function generateUrlsReturnsUrlsForCurrentRequest(): void + { + $packageManagerProperty = new ReflectionProperty(ExtensionManagementUtility::class, 'packageManager'); + $originalPackageManager = $packageManagerProperty->getValue(); + $packageManager = $this->createStub(PackageManager::class); + $packageManager->method('isPackageActive')->willReturn(true); + ExtensionManagementUtility::setPackageManager($packageManager); + + $typo3Version = new Typo3Version(); + $backendEditUrl = $this->buildUri('web_edit', [ + 'id' => 42, + 'params' => $this->usedArguments(), + ]); + $editParameters = [ + 'edit' => ['__TABLE__' => ['__UID__' => 'edit']], + 'returnUrl' => $backendEditUrl, + 'module' => 'web_edit', + ]; + + try { + $urls = $this->createSubject()->generateUrls($this->createRequest()); + + self::assertSame($backendEditUrl, $urls->backendEditUrl); + self::assertSame($this->buildUri('new_content_element_wizard', [ + 'id' => 42, + 'colPos' => '__COL_POS__', + 'uid_pid' => '__UID_PID__', + 'tx_container_parent' => '__TX_CONTAINER_PARENT__', + 'returnUrl' => $backendEditUrl, + ]), $urls->newContentUrl); + self::assertSame($this->buildUri('record_edit', $editParameters), $urls->editContentUrl); + self::assertSame( + $typo3Version->getMajorVersion() >= 14 + ? $this->buildUri('record_edit_contextual', $editParameters) + : null, + $urls->editContentContextualUrl, + ); + } finally { + ExtensionManagementUtility::setPackageManager($originalPackageManager); + } + } + + #[Test] + public function generateUrlsOmitsContainerParentWhenContainerIsNotInstalled(): void + { + $packageManagerProperty = new ReflectionProperty(ExtensionManagementUtility::class, 'packageManager'); + $originalPackageManager = $packageManagerProperty->getValue(); + $packageManager = $this->createStub(PackageManager::class); + $packageManager->method('isPackageActive')->willReturn(false); + ExtensionManagementUtility::setPackageManager($packageManager); + + $backendEditUrl = $this->buildUri('web_edit', [ + 'id' => 42, + 'params' => $this->usedArguments(), + ]); + + try { + $urls = $this->createSubject()->generateUrls($this->createRequest()); + + self::assertSame($this->buildUri('new_content_element_wizard', [ + 'id' => 42, + 'colPos' => '__COL_POS__', + 'uid_pid' => '__UID_PID__', + 'returnUrl' => $backendEditUrl, + ]), $urls->newContentUrl); + } finally { + ExtensionManagementUtility::setPackageManager($originalPackageManager); + } + } + + #[Test] + public function generateUrlsUsesParametersModifiedByNewContentElementWizardEvent(): void + { + $request = $this->createRequest(); + $eventDispatcher = $this->createStub(EventDispatcherInterface::class); + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(function (object $event) use ($request): object { + self::assertInstanceOf(ModifyNewContentElementWizardUrlParameterEvent::class, $event); + self::assertSame(42, $event->getParameters()['id']); + self::assertSame('__COL_POS__', $event->getParameters()['colPos']); + self::assertSame('__UID_PID__', $event->getParameters()['uid_pid']); + self::assertSame($this->usedArguments(), $event->getUsedArguments()); + self::assertSame($request, $event->getRequest()); + + $event->setParameters(['eventParameter' => 'eventValue']); + return $event; + }); + + $urls = $this->createSubject($eventDispatcher)->generateUrls($request); + + self::assertSame( + $this->buildUri('new_content_element_wizard', ['eventParameter' => 'eventValue']), + $urls->newContentUrl, + ); + } + + /** + * @param array> $input + * @param array $expected + */ + #[Test] + #[DataProvider('flattenBracketKeysDataProvider')] + public function flattenBracketKeysFlattensNestedValuesAndCastsScalars(array $input, string $prefix, array $expected): void + { + self::assertSame($expected, $this->createSubject()->flattenBracketKeys($input, $prefix)); + } + + /** + * @return Generator, prefix: string, expected: array}> + */ + public static function flattenBracketKeysDataProvider(): Generator + { + yield 'nested values and scalar casts' => [ + 'input' => [ + 'params' => [ + 'tx_news_pi1' => [ + 'filters' => [ + 'news', + 'limit' => 10, + 'ratio' => 1.5, + 'enabled' => true, + 'disabled' => false, + 'unset' => null, + ], + ], + ], + ], + 'prefix' => '', + 'expected' => [ + 'params[tx_news_pi1][filters][0]' => 'news', + 'params[tx_news_pi1][filters][limit]' => '10', + 'params[tx_news_pi1][filters][ratio]' => '1.5', + 'params[tx_news_pi1][filters][enabled]' => '1', + 'params[tx_news_pi1][filters][disabled]' => '', + 'params[tx_news_pi1][filters][unset]' => '', + ], + ]; + + yield 'provided prefix wraps top-level keys' => [ + 'input' => [ + 'filter' => [ + 'category' => 'news', + 'tags' => [3, 7], + ], + ], + 'prefix' => 'params', + 'expected' => [ + 'params[filter][category]' => 'news', + 'params[filter][tags][0]' => '3', + 'params[filter][tags][1]' => '7', + ], + ]; + + yield 'sibling arrays and scalars retain numeric keys' => [ + 'input' => [ + 'items' => [2 => 'second', 0 => 'first'], + 'page' => 2, + ], + 'prefix' => '', + 'expected' => [ + 'items[2]' => 'second', + 'items[0]' => 'first', + 'page' => '2', + ], + ]; + + yield 'empty arrays are omitted' => [ + 'input' => [ + 'filters' => [], + 'nested' => ['empty' => []], + ], + 'prefix' => '', + 'expected' => [ + 'filters' => '', + 'nested[empty]' => '', + ], + ]; + + yield 'empty input stays empty with a prefix' => [ + 'input' => [], + 'prefix' => 'params', + 'expected' => [], + ]; + + yield 'prefixed empty arrays use an empty value' => [ + 'input' => ['filter' => []], + 'prefix' => 'params', + 'expected' => ['params[filter]' => ''], + ]; + + yield 'literal bracket key replaces nested key when it follows it' => [ + 'input' => [ + 'filter' => ['status' => 'nested'], + 'filter[status]' => 'literal', + ], + 'prefix' => '', + 'expected' => ['filter[status]' => 'literal'], + ]; + + yield 'literal bracket key is retained when it precedes nested key' => [ + 'input' => [ + 'filter[status]' => 'literal', + 'filter' => ['status' => 'nested'], + ], + 'prefix' => '', + 'expected' => ['filter[status]' => 'literal'], + ]; + } + + /** + * @return Generator + */ + public static function invalidRequestContextDataProvider(): Generator + { + yield 'page information is missing' => ['missingContext' => 'pageInformation', 'exceptionCode' => 9965439961, 'exceptionMessage' => 'Could not determine current page information']; + yield 'page id is zero' => ['missingContext' => 'pageId', 'exceptionCode' => 1768983081, 'exceptionMessage' => 'Could not determine current page id']; + yield 'site language is missing' => ['missingContext' => 'siteLanguage', 'exceptionCode' => 3305745963, 'exceptionMessage' => 'Could not determine current site language']; + yield 'routing is missing' => ['missingContext' => 'routing', 'exceptionCode' => 1773230232, 'exceptionMessage' => 'Could not determine current routing context']; + } + + #[Test] + #[DataProvider('invalidRequestContextDataProvider')] + public function getBackendEditUrlThrowsExceptionForInvalidRequestContext(string $missingContext, int $exceptionCode, string $exceptionMessage): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionCode($exceptionCode); + $this->expectExceptionMessage($exceptionMessage); + + $this->createSubject()->getBackendEditUrl($this->createRequest($missingContext)); + } + + #[Test] + #[DataProvider('invalidRequestContextDataProvider')] + public function generateUrlsThrowsExceptionForInvalidRequestContext(string $missingContext, int $exceptionCode, string $exceptionMessage): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionCode($exceptionCode); + $this->expectExceptionMessage($exceptionMessage); + + $this->createSubject()->generateUrls($this->createRequest($missingContext)); + } + + private function createSubject(?EventDispatcherInterface $eventDispatcher = null): UrlGenerationService + { + return new UrlGenerationService( + $eventDispatcher ?? new NoopEventDispatcher(), + $this->createUriBuilderStub(), + new Typo3Version(), + ); + } + + private function createUriBuilderStub(): UriBuilder + { + $uriBuilder = $this->createStub(UriBuilder::class); + $uriBuilder + ->method('buildUriFromRoute') + ->willReturnCallback(fn(string $routeName, array $parameters): Uri => new Uri($this->buildUri($routeName, $parameters))); + return $uriBuilder; + } + + private function createRequest(?string $missingContext = null): ServerRequestInterface + { + $request = new ServerRequest('GET', '/news/detail'); + if ($missingContext !== 'pageInformation') { + $pageInformation = new PageInformation(); + $pageInformation->setId($missingContext === 'pageId' ? 0 : 42); + $request = $request->withAttribute('frontend.page.information', $pageInformation); + } + + if ($missingContext !== 'siteLanguage') { + $request = $request->withAttribute('language', new SiteLanguage(0, 'en-US', new Uri('https://example.test/'), [])); + } + + if ($missingContext !== 'routing') { + return $request->withAttribute('routing', new PageArguments(42, '0', [ + 'tx_news_pi1' => [ + 'action' => 'detail', + 'controller' => 'News', + ], + ], [], [ + 'category' => 'press', + 'news' => '123', + 'cHash' => 'will-be-removed', + 'editMode' => '1', + ])); + } + + return $request; + } + + /** + * @return array> + */ + private function usedArguments(): array + { + return [ + 'tx_news_pi1' => [ + 'action' => 'detail', + 'controller' => 'News', + ], + 'category' => 'press', + 'news' => '123', + ]; + } + + /** + * @param array $parameters + */ + private function buildUri(string $routeName, array $parameters): string + { + return 'https://example.test/typo3/' . $routeName . '?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); + } +}