From 48da8bccd8d86032f95166b405c6306747e3c50d Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Mon, 10 Aug 2026 15:27:16 +0200 Subject: [PATCH] Solution detail page and a single LinkResolver Closes two annex items that turned out to be the same defect seen from two sides: markets could link to a detail page, solutions could not, and the code that turns a page uid into a URL existed four times over. detail_page for Solution (B-11) - ext_tables.sql, TCA (group on pages, maxitems 1, in showitem behind slug) and Domain\Model\Solution mirror the Market field exactly - SolutionShowJsonRenderer emits detailUrl - ModelcardJsonRenderer serialized no detailUrl at all: its shared market|solution branch never carried the field. Market modelcards therefore gain a link here too, not just solutions. Service\LinkResolver (B-12) - one implementation replaces four private copies (Market list/show, Usecase list/show). The copies had drifted: Market returned null on failure, Usecase an empty string. The contract is now explicit -- null means "no link". - Usecase keeps a thin resolvePageUrl() wrapper appending ?? '', because it concatenates paths and a null would tear the strings apart. - LocationsJsonRenderer and NewsJsonRenderer stay out on purpose: they resolve a full parameter construct resp. slug paths plus canonical, which is not page-uid-to-URL and does not fit the same contract. Requires on the server, the column exists in code only: vendor/bin/typo3 database:updateschema "*.add,*.change" vendor/bin/typo3 cache:flush --- .../vitec/Classes/Domain/Model/Solution.php | 23 +++++++++ .../vitec/Classes/Service/LinkResolver.php | 48 +++++++++++++++++++ .../UserFunc/MarketListJsonRenderer.php | 25 +--------- .../UserFunc/MarketShowJsonRenderer.php | 25 +--------- .../UserFunc/ModelcardJsonRenderer.php | 4 +- .../UserFunc/SolutionShowJsonRenderer.php | 2 + .../UserFunc/UsecaseListJsonRenderer.php | 15 +++--- .../UserFunc/UsecaseShowJsonRenderer.php | 15 +++--- .../TCA/tx_vitec_domain_model_solution.php | 13 ++++- packages/vitec/ext_tables.sql | 1 + 10 files changed, 109 insertions(+), 62 deletions(-) create mode 100644 packages/vitec/Classes/Service/LinkResolver.php diff --git a/packages/vitec/Classes/Domain/Model/Solution.php b/packages/vitec/Classes/Domain/Model/Solution.php index c478ae5..02db470 100644 --- a/packages/vitec/Classes/Domain/Model/Solution.php +++ b/packages/vitec/Classes/Domain/Model/Solution.php @@ -18,6 +18,13 @@ class Solution extends AbstractEntity */ protected $slug = ''; + /** + * Page uid presenting this solution (TCA type "group", allowed: pages). + * + * @var int + */ + protected $detailPage = 0; + /** * @var string */ @@ -86,6 +93,22 @@ class Solution extends AbstractEntity $this->slug = $slug; } + /** + * @return int + */ + public function getDetailPage(): int + { + return $this->detailPage; + } + + /** + * @param int $detailPage + */ + public function setDetailPage(int $detailPage): void + { + $this->detailPage = $detailPage; + } + /** * @return string */ diff --git a/packages/vitec/Classes/Service/LinkResolver.php b/packages/vitec/Classes/Service/LinkResolver.php new file mode 100644 index 0000000..cfc2a46 --- /dev/null +++ b/packages/vitec/Classes/Service/LinkResolver.php @@ -0,0 +1,48 @@ +typoLink_URL(['parameter' => (string)$pageUid]); + + return $url !== '' ? $url : null; + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/packages/vitec/Classes/UserFunc/MarketListJsonRenderer.php b/packages/vitec/Classes/UserFunc/MarketListJsonRenderer.php index 2a17bae..8af115b 100644 --- a/packages/vitec/Classes/UserFunc/MarketListJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/MarketListJsonRenderer.php @@ -5,13 +5,13 @@ declare(strict_types=1); namespace Evomedien\Vitec\UserFunc; use Doctrine\DBAL\ParameterType; +use Evomedien\Vitec\Service\LinkResolver; use Evomedien\Vitec\Service\RteResolver; use Evomedien\Vitec\Service\UsecaseSerializer; use TYPO3\CMS\Core\Attribute\AsAllowedCallable; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Service\FlexFormService; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; /** * UserFunc: render all VITEC markets as JSON (headless). @@ -168,29 +168,8 @@ class MarketListJsonRenderer 'subtitle' => (string)($r['subtitle'] ?? ''), 'teaser' => (string)($r['teaser'] ?? ''), 'description' => RteResolver::html($r['description'] ?? ''), - 'detailUrl' => $this->detailUrl((int)($r['detail_page'] ?? 0)), + 'detailUrl' => LinkResolver::pageUrl((int)($r['detail_page'] ?? 0)), 'image' => $serializer->image($uid, 'image', self::TABLE, true), ]; } - - /** - * Resolve the `detail_page` uid to a URL. The frontend cannot do anything - * with a raw page uid, so the link is built server side — same convention - * as `headerLink` and LocationsJsonRenderer. - * - * Returns null when no page is set or the link cannot be resolved. - */ - private function detailUrl(int $pageUid): ?string - { - if ($pageUid <= 0) { - return null; - } - try { - $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); - $url = $cObj->typoLink_URL(['parameter' => (string)$pageUid]); - return $url !== '' ? $url : null; - } catch (\Throwable $e) { - return null; - } - } } diff --git a/packages/vitec/Classes/UserFunc/MarketShowJsonRenderer.php b/packages/vitec/Classes/UserFunc/MarketShowJsonRenderer.php index 980088b..88ef70d 100755 --- a/packages/vitec/Classes/UserFunc/MarketShowJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/MarketShowJsonRenderer.php @@ -10,10 +10,10 @@ use Doctrine\DBAL\ParameterType; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Service\FlexFormService; +use Evomedien\Vitec\Service\LinkResolver; use Evomedien\Vitec\Service\RteResolver; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Service\ImageService; -use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; /** * UserFunc to render a single market as JSON for headless output. @@ -161,7 +161,7 @@ class MarketShowJsonRenderer 'subtitle' => (string)($market['subtitle'] ?? ''), 'teaser' => (string)($market['teaser'] ?? ''), 'description' => RteResolver::html($market['description'] ?? ''), - 'detailUrl' => $this->detailUrl((int)($market['detail_page'] ?? 0)), + 'detailUrl' => LinkResolver::pageUrl((int)($market['detail_page'] ?? 0)), 'categories' => $this->getMarketCategories($uid), 'image' => $this->getMarketImage($uid), ]; @@ -271,25 +271,4 @@ class MarketShowJsonRenderer ]; }, $categories); } - - /** - * Resolve the `detail_page` uid to a URL. The frontend cannot do anything - * with a raw page uid, so the link is built server side — same convention - * as `headerLink` and LocationsJsonRenderer. - * - * Returns null when no page is set or the link cannot be resolved. - */ - private function detailUrl(int $pageUid): ?string - { - if ($pageUid <= 0) { - return null; - } - try { - $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); - $url = $cObj->typoLink_URL(['parameter' => (string)$pageUid]); - return $url !== '' ? $url : null; - } catch (\Throwable $e) { - return null; - } - } } diff --git a/packages/vitec/Classes/UserFunc/ModelcardJsonRenderer.php b/packages/vitec/Classes/UserFunc/ModelcardJsonRenderer.php index 58638c6..365c1ad 100755 --- a/packages/vitec/Classes/UserFunc/ModelcardJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/ModelcardJsonRenderer.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Evomedien\Vitec\UserFunc; use Doctrine\DBAL\ParameterType; +use Evomedien\Vitec\Service\LinkResolver; use Evomedien\Vitec\Service\RteResolver; use Evomedien\Vitec\Service\UsecaseSerializer; use TYPO3\CMS\Core\Attribute\AsAllowedCallable; @@ -163,7 +164,7 @@ class ModelcardJsonRenderer ]; } - // market | solution — identical field set. + // market | solution — identical field set, both carry `detail_page`. return [ 'uid' => $uid, 'title' => (string)($row['title'] ?? ''), @@ -171,6 +172,7 @@ class ModelcardJsonRenderer 'subtitle' => (string)($row['subtitle'] ?? ''), 'teaser' => (string)($row['teaser'] ?? ''), 'description' => RteResolver::html($row['description'] ?? ''), + 'detailUrl' => LinkResolver::pageUrl((int)($row['detail_page'] ?? 0)), 'image' => $serializer->image($uid, 'image', self::MODEL_TABLES[$modelType], true), ]; } diff --git a/packages/vitec/Classes/UserFunc/SolutionShowJsonRenderer.php b/packages/vitec/Classes/UserFunc/SolutionShowJsonRenderer.php index 2757157..de83c5e 100755 --- a/packages/vitec/Classes/UserFunc/SolutionShowJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/SolutionShowJsonRenderer.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\ParameterType; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Service\FlexFormService; +use Evomedien\Vitec\Service\LinkResolver; use Evomedien\Vitec\Service\RteResolver; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Service\ImageService; @@ -160,6 +161,7 @@ class SolutionShowJsonRenderer 'subtitle' => (string)($solution['subtitle'] ?? ''), 'teaser' => (string)($solution['teaser'] ?? ''), 'description' => RteResolver::html($solution['description'] ?? ''), + 'detailUrl' => LinkResolver::pageUrl((int)($solution['detail_page'] ?? 0)), 'categories' => $this->getSolutionCategories($uid), 'image' => $this->getSolutionImage($uid), ]; diff --git a/packages/vitec/Classes/UserFunc/UsecaseListJsonRenderer.php b/packages/vitec/Classes/UserFunc/UsecaseListJsonRenderer.php index 59df63a..47b9df2 100755 --- a/packages/vitec/Classes/UserFunc/UsecaseListJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/UsecaseListJsonRenderer.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Evomedien\Vitec\UserFunc; use Doctrine\DBAL\ParameterType; +use Evomedien\Vitec\Service\LinkResolver; use Evomedien\Vitec\Service\UsecaseSerializer; use TYPO3\CMS\Core\Attribute\AsAllowedCallable; use TYPO3\CMS\Core\Database\ConnectionPool; @@ -146,14 +147,14 @@ class UsecaseListJsonRenderer } } - /** Resolve a page uid to its frontend path (route enhancer aware). */ + /** + * Resolve a page uid to its frontend path (route enhancer aware). + * + * Callers here build paths by concatenation, so the empty string stays the + * "no link" value; LinkResolver's null is coalesced away. + */ private function resolvePageUrl(int $pageUid): string { - try { - $cObj = GeneralUtility::makeInstance(\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::class); - return (string)$cObj->typoLink_URL(['parameter' => (string)$pageUid]); - } catch (\Throwable $e) { - return ''; - } + return LinkResolver::pageUrl($pageUid) ?? ''; } } diff --git a/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php b/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php index 3143e20..f9bc676 100755 --- a/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/UsecaseShowJsonRenderer.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Evomedien\Vitec\UserFunc; use Doctrine\DBAL\ParameterType; +use Evomedien\Vitec\Service\LinkResolver; use Evomedien\Vitec\Service\UsecaseSerializer; use TYPO3\CMS\Core\Attribute\AsAllowedCallable; use TYPO3\CMS\Core\Database\ConnectionPool; @@ -153,14 +154,14 @@ class UsecaseShowJsonRenderer return (int)($row['uid'] ?? 0); } - /** Resolve a page uid to its frontend path (route enhancer aware). */ + /** + * Resolve a page uid to its frontend path (route enhancer aware). + * + * Callers here build paths by concatenation, so the empty string stays the + * "no link" value; LinkResolver's null is coalesced away. + */ private function resolvePageUrl(int $pageUid): string { - try { - $cObj = GeneralUtility::makeInstance(\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::class); - return (string)$cObj->typoLink_URL(['parameter' => (string)$pageUid]); - } catch (\Throwable $e) { - return ''; - } + return LinkResolver::pageUrl($pageUid) ?? ''; } } diff --git a/packages/vitec/Configuration/TCA/tx_vitec_domain_model_solution.php b/packages/vitec/Configuration/TCA/tx_vitec_domain_model_solution.php index d481f56..19d2cb9 100644 --- a/packages/vitec/Configuration/TCA/tx_vitec_domain_model_solution.php +++ b/packages/vitec/Configuration/TCA/tx_vitec_domain_model_solution.php @@ -24,7 +24,7 @@ return [ ], 'types' => [ '1' => [ - 'showitem' => 'title, slug, subtitle, teaser, description, image, + 'showitem' => 'title, slug, detail_page, subtitle, teaser, description, image, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent, l10n_diffsource, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime', @@ -129,6 +129,17 @@ return [ 'eval' => 'uniqueInPid', ], ], + 'detail_page' => [ + 'exclude' => true, + 'label' => 'Detail Page', + 'description' => 'Page that presents this solution. Resolved to a URL in the JSON output; leave empty for no link.', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages', + 'size' => 1, + 'maxitems' => 1, + ], + ], 'subtitle' => [ 'exclude' => true, 'label' => 'Subtitle', diff --git a/packages/vitec/ext_tables.sql b/packages/vitec/ext_tables.sql index 8019ba0..dde4e9a 100755 --- a/packages/vitec/ext_tables.sql +++ b/packages/vitec/ext_tables.sql @@ -155,6 +155,7 @@ CREATE TABLE tx_vitec_domain_model_solution ( l10n_diffsource mediumblob, title varchar(255) DEFAULT '' NOT NULL, slug varchar(255) DEFAULT '' NOT NULL, + detail_page int(11) DEFAULT '0' NOT NULL, subtitle varchar(255) DEFAULT '' NOT NULL, teaser varchar(255) DEFAULT '' NOT NULL, description text,