From e3d7de34cad5334f1d0a756f916fc578245cb640 Mon Sep 17 00:00:00 2001 From: Ingo Fabbri Date: Tue, 11 Aug 2026 14:04:39 +0200 Subject: [PATCH 1/3] [BUGFIX] Preserve page layout position after container actions --- .../Backend/Grid/ContainerGridColumnItem.php | 30 ++++++++++++++++ .../Backend/Service/NewContentUrlBuilder.php | 9 +++-- Tests/Acceptance/Backend/LayoutCest.php | 19 ++++++++++ .../Service/NewContentUriBuilderTest.php | 36 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/Classes/Backend/Grid/ContainerGridColumnItem.php b/Classes/Backend/Grid/ContainerGridColumnItem.php index 81da16db..490f77a7 100644 --- a/Classes/Backend/Grid/ContainerGridColumnItem.php +++ b/Classes/Backend/Grid/ContainerGridColumnItem.php @@ -85,4 +85,34 @@ public function getNewContentAfterUrl(): string } return $this->newContentUrl; } + + public function getDeleteUrl(): string + { + return parent::getDeleteUrl() . '#element-' . $this->table . '-' . $this->getDeleteReturnTargetUid(); + } + + protected function getDeleteReturnTargetUid(): int + { + /** @var array|RecordInterface $record */ + $record = $this->record; + $recordUid = $record instanceof RecordInterface + ? $record->getUid() + : (int)$record['uid']; + $siblings = array_values($this->container->getChildrenByColPos((int)$this->column->getColumnNumber())); + + foreach ($siblings as $index => $sibling) { + if ((int)$sibling['uid'] !== $recordUid) { + continue; + } + if (isset($siblings[$index - 1])) { + return (int)$siblings[$index - 1]['uid']; + } + if (isset($siblings[$index + 1])) { + return (int)$siblings[$index + 1]['uid']; + } + break; + } + + return $this->container->getUid(); + } } diff --git a/Classes/Backend/Service/NewContentUrlBuilder.php b/Classes/Backend/Service/NewContentUrlBuilder.php index 37bb47b4..24c325d6 100644 --- a/Classes/Backend/Service/NewContentUrlBuilder.php +++ b/Classes/Backend/Service/NewContentUrlBuilder.php @@ -66,7 +66,7 @@ protected function getNewContentEditUrl(Container $container, int $columnNumber, 'defVals' => [ 'tt_content' => $ttContentDefVals, ], - 'returnUrl' => $this->getReturnUrl(), + 'returnUrl' => $this->getReturnUrlWithAnchor(abs($target)), ]; return (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters); } @@ -80,7 +80,7 @@ protected function getNewContentWizardUrl(PageLayoutContext $context, Container 'colPos' => $columnNumber, 'tx_container_parent' => $container->getUidOfLiveWorkspace(), 'uid_pid' => $uidPid, - 'returnUrl' => $this->getReturnUrl(), + 'returnUrl' => $this->getReturnUrlWithAnchor(abs($uidPid)), ]; return (string)$this->uriBuilder->buildUriFromRoute('new_content_element_wizard', $urlParameters); } @@ -98,4 +98,9 @@ protected function getReturnUrl(): string } return (string)$request->getAttribute('normalizedParams')->getRequestUri(); } + + protected function getReturnUrlWithAnchor(int $recordUid): string + { + return $this->getReturnUrl() . '#element-tt_content-' . $recordUid; + } } diff --git a/Tests/Acceptance/Backend/LayoutCest.php b/Tests/Acceptance/Backend/LayoutCest.php index 2c811f6e..d081c2bc 100644 --- a/Tests/Acceptance/Backend/LayoutCest.php +++ b/Tests/Acceptance/Backend/LayoutCest.php @@ -198,6 +198,25 @@ public function canCreateContentElementInContainer(BackendTester $I, PageTree $p $I->click('Close'); $I->waitForElementNotVisible('#t3js-ui-block'); $I->canSeeElement($contentInContainerColumn); + $I->assertSame('#element-tt_content-1', $I->executeJS('return window.location.hash')); + } + + public function deleteChildReturnsToContainer(BackendTester $I, PageTree $pageTree): void + { + $I->clickLayoutModuleButton(); + $pageTree->openPath(['home', 'pageWithContainer-3']); + $I->wait(0.2); + $I->switchToContentFrame(); + $deleteUrl = $I->grabAttributeFrom('#element-tt_content-810 .t3js-modal-trigger', 'href'); + + $I->assertSame('element-tt_content-800', parse_url($deleteUrl, PHP_URL_FRAGMENT)); + $I->click('#element-tt_content-810 .t3js-modal-trigger'); + $I->switchToIFrame(); + $I->waitForModal(); + $I->click('Delete', '.modal-footer'); + $I->switchToContentFrame(); + $I->waitForElementNotVisible('#element-tt_content-810'); + $I->assertSame('#element-tt_content-800', $I->executeJS('return window.location.hash')); } public function canCreateContentElementInTranslatedContainerInFreeMode(BackendTester $I, PageTree $pageTree) diff --git a/Tests/Functional/Backend/Service/NewContentUriBuilderTest.php b/Tests/Functional/Backend/Service/NewContentUriBuilderTest.php index 753b951b..d02fd26a 100644 --- a/Tests/Functional/Backend/Service/NewContentUriBuilderTest.php +++ b/Tests/Functional/Backend/Service/NewContentUriBuilderTest.php @@ -51,6 +51,31 @@ public function getNewContentUrlAfterChildContainsUidOfLiveWorkspaceAsContainerP $newContentUriBuilder = new NewContentUrlBuilder($tcaRegistry, $containerColumnConfigurationService, $containerService, $uriBuilder); $newContentUrl = $newContentUriBuilder->getNewContentUrlAfterChild($pageLayoutContext, $container, 111, 112, null); self::assertStringContainsString('tx_container_parent=1', $newContentUrl, 'should container uid of live workspace record'); + self::assertStringEndsWith('#element-tt_content-112', $this->getReturnUrlParameter($newContentUrl)); + } + + #[Test] + public function getNewContentEditUrlAfterChildContainsReturnAnchor(): void + { + $container = new Container(['uid' => 2], []); + $pageLayoutContext = $this->getMockBuilder(PageLayoutContext::class) + ->disableOriginalConstructor() + ->getMock(); + $tcaRegistry = $this->getMockBuilder(Registry::class) + ->disableOriginalConstructor() + ->getMock(); + $containerColumnConfigurationService = $this->getMockBuilder(ContainerColumnConfigurationService::class) + ->disableOriginalConstructor() + ->getMock(); + $containerService = $this->getMockBuilder(ContainerService::class) + ->disableOriginalConstructor() + ->getMock(); + $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + $newContentUriBuilder = new NewContentUrlBuilder($tcaRegistry, $containerColumnConfigurationService, $containerService, $uriBuilder); + + $newContentUrl = $newContentUriBuilder->getNewContentUrlAfterChild($pageLayoutContext, $container, 111, 112, ['CType' => 'header']); + + self::assertStringEndsWith('#element-tt_content-112', $this->getReturnUrlParameter($newContentUrl)); } #[Test] @@ -72,11 +97,14 @@ public function getNewContentUrlAtTopOfColumnContainsUidOfLiveWorkspaceAsContain $containerColumnConfigurationService->expects(self::once())->method('isMaxitemsReached')->willReturn(false); $containerService = $this->getMockBuilder(ContainerService::class) ->disableOriginalConstructor() + ->onlyMethods(['getNewContentElementAtTopTargetInColumn']) ->getMock(); + $containerService->expects(self::once())->method('getNewContentElementAtTopTargetInColumn')->willReturn(-2); $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); $newContentUriBuilder = new NewContentUrlBuilder($tcaRegistry, $containerColumnConfigurationService, $containerService, $uriBuilder); $newContentUrl = $newContentUriBuilder->getNewContentUrlAtTopOfColumn($pageLayoutContext, $container, 111, null); self::assertStringContainsString('tx_container_parent=1', $newContentUrl, 'should container uid of live workspace record'); + self::assertStringEndsWith('#element-tt_content-2', $this->getReturnUrlParameter($newContentUrl)); } #[Test] @@ -104,4 +132,12 @@ public function getNewContentUrlAtTopOfColumnReturnsNullIfMaxitemsIsReached(): v $newContentUrl = $newContentUriBuilder->getNewContentUrlAtTopOfColumn($pageLayoutContext, $container, 111, null); self::assertNull($newContentUrl); } + + private function getReturnUrlParameter(string $url): string + { + parse_str((string)parse_url($url, PHP_URL_QUERY), $queryParameters); + self::assertArrayHasKey('returnUrl', $queryParameters); + self::assertIsString($queryParameters['returnUrl']); + return $queryParameters['returnUrl']; + } } From d6061a711fb92d5165fd319465605ab245e3f9da Mon Sep 17 00:00:00 2001 From: Ingo Fabbri Date: Thu, 15 Oct 2026 22:07:46 +0200 Subject: [PATCH 2/3] [TASK] Drop delete position handling --- .../Backend/Grid/ContainerGridColumnItem.php | 30 ------------------- Tests/Acceptance/Backend/LayoutCest.php | 18 ----------- 2 files changed, 48 deletions(-) diff --git a/Classes/Backend/Grid/ContainerGridColumnItem.php b/Classes/Backend/Grid/ContainerGridColumnItem.php index 490f77a7..81da16db 100644 --- a/Classes/Backend/Grid/ContainerGridColumnItem.php +++ b/Classes/Backend/Grid/ContainerGridColumnItem.php @@ -85,34 +85,4 @@ public function getNewContentAfterUrl(): string } return $this->newContentUrl; } - - public function getDeleteUrl(): string - { - return parent::getDeleteUrl() . '#element-' . $this->table . '-' . $this->getDeleteReturnTargetUid(); - } - - protected function getDeleteReturnTargetUid(): int - { - /** @var array|RecordInterface $record */ - $record = $this->record; - $recordUid = $record instanceof RecordInterface - ? $record->getUid() - : (int)$record['uid']; - $siblings = array_values($this->container->getChildrenByColPos((int)$this->column->getColumnNumber())); - - foreach ($siblings as $index => $sibling) { - if ((int)$sibling['uid'] !== $recordUid) { - continue; - } - if (isset($siblings[$index - 1])) { - return (int)$siblings[$index - 1]['uid']; - } - if (isset($siblings[$index + 1])) { - return (int)$siblings[$index + 1]['uid']; - } - break; - } - - return $this->container->getUid(); - } } diff --git a/Tests/Acceptance/Backend/LayoutCest.php b/Tests/Acceptance/Backend/LayoutCest.php index d081c2bc..389b25c1 100644 --- a/Tests/Acceptance/Backend/LayoutCest.php +++ b/Tests/Acceptance/Backend/LayoutCest.php @@ -201,24 +201,6 @@ public function canCreateContentElementInContainer(BackendTester $I, PageTree $p $I->assertSame('#element-tt_content-1', $I->executeJS('return window.location.hash')); } - public function deleteChildReturnsToContainer(BackendTester $I, PageTree $pageTree): void - { - $I->clickLayoutModuleButton(); - $pageTree->openPath(['home', 'pageWithContainer-3']); - $I->wait(0.2); - $I->switchToContentFrame(); - $deleteUrl = $I->grabAttributeFrom('#element-tt_content-810 .t3js-modal-trigger', 'href'); - - $I->assertSame('element-tt_content-800', parse_url($deleteUrl, PHP_URL_FRAGMENT)); - $I->click('#element-tt_content-810 .t3js-modal-trigger'); - $I->switchToIFrame(); - $I->waitForModal(); - $I->click('Delete', '.modal-footer'); - $I->switchToContentFrame(); - $I->waitForElementNotVisible('#element-tt_content-810'); - $I->assertSame('#element-tt_content-800', $I->executeJS('return window.location.hash')); - } - public function canCreateContentElementInTranslatedContainerInFreeMode(BackendTester $I, PageTree $pageTree) { //@depends canCreateContainer From a3d9430b074a67b0397c7cf9524933dae1b4146b Mon Sep 17 00:00:00 2001 From: Ingo Fabbri Date: Thu, 15 Oct 2026 22:11:57 +0200 Subject: [PATCH 3/3] [TEST] Cover direct creation redirect anchor --- .../Backend/Service/NewContentUriBuilderTest.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Tests/Functional/Backend/Service/NewContentUriBuilderTest.php b/Tests/Functional/Backend/Service/NewContentUriBuilderTest.php index 87184806..da41f6b1 100644 --- a/Tests/Functional/Backend/Service/NewContentUriBuilderTest.php +++ b/Tests/Functional/Backend/Service/NewContentUriBuilderTest.php @@ -165,13 +165,14 @@ public function getNewContentUrlAtTopOfColumnWithSingleAllowedCTypeAndSaveAndClo ], 'saveAndClose' => true, ]; - $newContentUrl = urldecode($this->getNewContentUrlAtTopOfColumnWithSingleAllowedCType('tx_container_test')); - self::assertStringContainsString('/record/commit?', $newContentUrl); - self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[header\]=Example header/', $newContentUrl); - self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[CType\]=tx_container_test/', $newContentUrl); - self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[colPos\]=111/', $newContentUrl); - self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[tx_container_parent\]=1/', $newContentUrl); - self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[pid\]=-5/', $newContentUrl); + $newContentUrl = $this->getNewContentUrlAtTopOfColumnWithSingleAllowedCType('tx_container_test'); + $decodedNewContentUrl = urldecode($newContentUrl); + self::assertStringContainsString('/record/commit?', $decodedNewContentUrl); + self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[header\]=Example header/', $decodedNewContentUrl); + self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[CType\]=tx_container_test/', $decodedNewContentUrl); + self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[colPos\]=111/', $decodedNewContentUrl); + self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[tx_container_parent\]=1/', $decodedNewContentUrl); + self::assertMatchesRegularExpression('/data\[tt_content\]\[NEW[^\]]+\]\[pid\]=-5/', $decodedNewContentUrl); self::assertStringEndsWith('#element-tt_content-5', $this->getRedirectParameter($newContentUrl)); }