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
This commit is contained in:
2026-08-10 15:27:16 +02:00
parent 1011162bb1
commit 48da8bccd8
10 changed files with 109 additions and 62 deletions

View File

@@ -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
*/

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Resolves a page uid to its frontend URL.
*
* A headless renderer cannot hand a raw page uid to the frontend — React has no
* way to turn `12` into a path. Every field that carries a link (`detailUrl`,
* `headerLink`, the usecase list's item links) is therefore resolved server side.
*
* The resolution goes through `typoLink_URL`, so route enhancers and the slug of
* the target page are honoured; building the path by hand would bypass both.
*
* This lived as a private copy in four renderers (Market list/show, Usecase
* list/show). The copies had drifted in their failure handling — two returned
* null, two an empty string — which is why the contract is spelled out here:
* **null means "no link"**, and callers that need a string coalesce it.
*/
final class LinkResolver
{
/**
* Returns null when no page is set or the link cannot be resolved.
*
* Never throws: a broken link must not take down the whole JSON response,
* so a failure downgrades to "no link" like an empty field would.
*/
public static function pageUrl(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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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),
];
}

View File

@@ -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),
];

View File

@@ -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) ?? '';
}
}

View File

@@ -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) ?? '';
}
}

View File

@@ -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',

View File

@@ -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,