Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Classes/Utility/TagUtility.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Zeroseven\Pagebased\Utility;

use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use Zeroseven\Pagebased\Domain\Model\Demand\ObjectDemandInterface;
Expand Down Expand Up @@ -53,6 +54,45 @@ public static function collectTagsFromStrings(array $tagStrings): array

public static function getTags(ObjectDemandInterface $demand, RepositoryInterface $repository, bool $ignoreTagsFromDemand = null, int $languageUid = null): ?array
{
// When the nonglobal-tags feature is enabled, scope tags to the current rootline category.
if (GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('pagebased.nonglobalTags')) {
if (!$demand->{'getCategory'}()) {
// Resolve the category doktype for this specific repository to avoid matching
// category pages from a different registration in multi-registration installations.
$registrationDoktype = RegistrationService::getRegistrationByRepository($repository)
?->getCategory()
?->getDocumentType();

$currentPage = RootLineUtility::getCurrentPage();
$pagesAbove = RootLineUtility::collectPagesAbove($currentPage, true);

// Find the first category page in the rootline that belongs to this registration.
// Fall back to any registered category doktype when the registration cannot be resolved.
$categoryUid = null;
foreach ($pagesAbove as $page) {
if (!isset($page['doktype'])) {
continue;
}
$doktype = (int)$page['doktype'];
$matches = $registrationDoktype !== null
? $doktype === $registrationDoktype
: RegistrationService::getRegistrationByCategoryDocumentType($doktype) !== null;

if ($matches) {
$categoryUid = (int)$page['uid'];
break;
}
}

if ($categoryUid) {
$demand->{'setCategory'}($categoryUid);
} else {
// No category found in rootline → return null to avoid showing all tags
return null;
}
}
}

// Override language
if ($languageUid !== null) {
$querySettings = $repository->getDefaultQuerySettings();
Expand Down
9 changes: 9 additions & 0 deletions Tests/Functional/Fixtures/Database/pages_tags.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
pages,,,,,,,,,,,,,,,
,uid,pid,title,doktype,sorting,deleted,hidden,nav_hide,sys_language_uid,l10n_parent,_pagebased_registration,_pagebased_site,_pagebased_child_object,pagebased_tags
,1,0,Site Root,1,256,0,0,0,0,0,,1,0,
,10,1,Category A,199,128,0,0,0,0,0,,1,0,
,20,10,Object A1,1,128,0,0,0,0,0,test_news,1,0,"php,typo3"
,21,10,Object A2,1,256,0,0,0,0,0,test_news,1,0,"php,symfony"
,30,1,Category B,199,256,0,0,0,0,0,,1,0,
,31,30,Object B1,1,128,0,0,0,0,0,test_news,1,0,"javascript,typo3"
,40,1,Standalone Page,1,384,0,0,0,0,0,,1,0,
188 changes: 188 additions & 0 deletions Tests/Functional/Utility/TagUtilityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

declare(strict_types=1);

namespace Zeroseven\Pagebased\Tests\Functional\Utility;

use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
use Zeroseven\Pagebased\Registration\CategoryRegistration;
use Zeroseven\Pagebased\Registration\ObjectRegistration;
use Zeroseven\Pagebased\Registration\Registration;
use Zeroseven\Pagebased\Registration\RegistrationService;
use Zeroseven\Pagebased\Tests\Functional\Fixtures\Classes\TestCategory;
use Zeroseven\Pagebased\Tests\Functional\Fixtures\Classes\TestCategoryRepository;
use Zeroseven\Pagebased\Tests\Functional\Fixtures\Classes\TestObject;
use Zeroseven\Pagebased\Tests\Functional\Fixtures\Classes\TestObjectRepository;
use Zeroseven\Pagebased\Utility\RootLineUtility;
use Zeroseven\Pagebased\Utility\TagUtility;

/**
* Functional tests for TagUtility::getTags() with the pagebased.nonglobalTags feature flag.
*
* Fixture layout (pages_tags.csv):
* uid 1 → Site root (doktype=1)
* uid 10 → Category A (doktype=199)
* uid 20 → Object in Category A, tags: "php,typo3"
* uid 21 → Object in Category A, tags: "php,symfony"
* uid 30 → Category B (doktype=199)
* uid 31 → Object in Category B, tags: "javascript,typo3"
* uid 40 → Standalone page (doktype=1, no registered category in rootline)
*
* Feature flag OFF (default): tags are collected globally across all categories.
* Feature flag ON:
* - Category already set in demand → rootline detection is skipped.
* - No category in demand, category page found in rootline → demand is scoped automatically.
* - No category in demand, no category page in rootline → null returned.
*/
class TagUtilityTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = [
'typo3conf/ext/pagebased',
];

protected array $coreExtensionsToLoad = [
'core',
'frontend',
];

private TestObjectRepository $repository;

protected function setUp(): void
{
parent::setUp();

$this->bootstrapTestRegistration();
$this->importCSVDataSet(__DIR__ . '/../Fixtures/Database/pages_tags.csv');

$this->repository = $this->get(TestObjectRepository::class);

// Ensure the feature flag is off by default for each test.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = false;
}

protected function tearDown(): void
{
// Clear the static RootLineUtility cache so page-context changes between
// tests do not bleed into one another.
// Note: setAccessible(true) is intentionally omitted – it is a no-op since PHP 8.1
// and produces a deprecation notice on PHP 8.4.
$reflection = new \ReflectionProperty(RootLineUtility::class, 'cache');
$reflection->setValue(null, []);

Comment thread
teneris marked this conversation as resolved.
unset($_GET['id']);
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = false;

parent::tearDown();
}

private function bootstrapTestRegistration(): void
{
$objectRegistration = new ObjectRegistration('Test Object');
$objectRegistration->setClassName(TestObject::class);
$objectRegistration->setRepositoryClass(TestObjectRepository::class);

$categoryRegistration = new CategoryRegistration('Test Category');
$categoryRegistration->setClassName(TestCategory::class);
$categoryRegistration->setRepositoryClass(TestCategoryRepository::class);
$categoryRegistration->setDocumentType(199);

$registration = new Registration('test', 'test_news');
$registration->setObject($objectRegistration);
$registration->setCategory($categoryRegistration);

RegistrationService::addRegistration($registration);
}

// ---------------------------------------------------------------------------
// Feature flag OFF (default behaviour)
// ---------------------------------------------------------------------------

/** @test */
public function getTagsReturnsGlobalTagsWhenFeatureFlagIsOff(): void
{
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = false;

$demand = $this->repository->initializeDemand();
$tags = TagUtility::getTags($demand, $this->repository);

// All objects across all categories are included: php, typo3, symfony, javascript
self::assertNotNull($tags);
self::assertContains('php', $tags);
self::assertContains('typo3', $tags);
self::assertContains('symfony', $tags);
self::assertContains('javascript', $tags);
self::assertSame(['javascript', 'php', 'symfony', 'typo3'], $tags);
}

// ---------------------------------------------------------------------------
// Feature flag ON – rootline detection active
// ---------------------------------------------------------------------------

/** @test */
public function getTagsScopesTagsToCategoryFoundInRootlineWhenFlagIsOn(): void
{
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true;

// Simulate browsing object page 20 (child of Category A, uid=10)
$_GET['id'] = '20';

$demand = $this->repository->initializeDemand();
$tags = TagUtility::getTags($demand, $this->repository);

// Only objects in Category A (uid 20, 21): php, typo3, symfony
self::assertNotNull($tags);
self::assertSame(['php', 'symfony', 'typo3'], $tags);
self::assertNotContains('javascript', $tags);
}

/** @test */
public function getTagsReturnsNullWhenFlagIsOnButNoCategoryPageInRootline(): void
{
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true;

// Simulate browsing a standalone page (uid=40) with no registered category ancestor
$_GET['id'] = '40';

$demand = $this->repository->initializeDemand();
$result = TagUtility::getTags($demand, $this->repository);

self::assertNull($result);
}

/** @test */
public function getTagsSkipsRootlineDetectionWhenCategoryAlreadySetInDemand(): void
{
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true;

// Category B (uid=30) is set explicitly – rootline should not be consulted.
// $_GET['id'] points to a page outside any category to prove rootline is skipped.
$_GET['id'] = '40';

$demand = $this->repository->initializeDemand();
$demand->{'setCategory'}(30);

$tags = TagUtility::getTags($demand, $this->repository);

// Only objects in Category B (uid 31): javascript, typo3
self::assertNotNull($tags);
self::assertSame(['javascript', 'typo3'], $tags);
self::assertNotContains('php', $tags);
self::assertNotContains('symfony', $tags);
}

/** @test */
public function getTagsOnCategoryPageItselfResolvesCorrectlyWhenFlagIsOn(): void
{
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] = true;

// Simulate browsing the Category A page itself (uid=10, doktype=199)
$_GET['id'] = '10';

$demand = $this->repository->initializeDemand();
$tags = TagUtility::getTags($demand, $this->repository);

// collectPagesAbove with includingStartingPoint=true includes uid=10 itself
self::assertNotNull($tags);
self::assertSame(['php', 'symfony', 'typo3'], $tags);
}
}
4 changes: 4 additions & 0 deletions ext_localconf.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
\Zeroseven\Pagebased\Hooks\IconFactory\OverrideIconOverlay::register();
\Zeroseven\Pagebased\Middleware\RssFeed::registerCache();

// Opt-in: scope tag queries to the blog category found in the current page's rootline.
// Enable per installation in AdditionalConfiguration.php or via the Install Tool Features panel.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['pagebased.nonglobalTags'] ??= false;

// Tag query result cache – stores distinct tag lists per registration/category/language.
// TYPO3 clears this automatically when pages with matching tags are modified.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pagebased_tags'] ??= [
Expand Down