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 = ''; protected $slug = '';
/**
* Page uid presenting this solution (TCA type "group", allowed: pages).
*
* @var int
*/
protected $detailPage = 0;
/** /**
* @var string * @var string
*/ */
@@ -86,6 +93,22 @@ class Solution extends AbstractEntity
$this->slug = $slug; $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 * @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; namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\LinkResolver;
use Evomedien\Vitec\Service\RteResolver; use Evomedien\Vitec\Service\RteResolver;
use Evomedien\Vitec\Service\UsecaseSerializer; use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable; use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService; use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/** /**
* UserFunc: render all VITEC markets as JSON (headless). * UserFunc: render all VITEC markets as JSON (headless).
@@ -168,29 +168,8 @@ class MarketListJsonRenderer
'subtitle' => (string)($r['subtitle'] ?? ''), 'subtitle' => (string)($r['subtitle'] ?? ''),
'teaser' => (string)($r['teaser'] ?? ''), 'teaser' => (string)($r['teaser'] ?? ''),
'description' => RteResolver::html($r['description'] ?? ''), '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), '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\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService; use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\LinkResolver;
use Evomedien\Vitec\Service\RteResolver; use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService; use TYPO3\CMS\Extbase\Service\ImageService;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/** /**
* UserFunc to render a single market as JSON for headless output. * UserFunc to render a single market as JSON for headless output.
@@ -161,7 +161,7 @@ class MarketShowJsonRenderer
'subtitle' => (string)($market['subtitle'] ?? ''), 'subtitle' => (string)($market['subtitle'] ?? ''),
'teaser' => (string)($market['teaser'] ?? ''), 'teaser' => (string)($market['teaser'] ?? ''),
'description' => RteResolver::html($market['description'] ?? ''), '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), 'categories' => $this->getMarketCategories($uid),
'image' => $this->getMarketImage($uid), 'image' => $this->getMarketImage($uid),
]; ];
@@ -271,25 +271,4 @@ class MarketShowJsonRenderer
]; ];
}, $categories); }, $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; namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\LinkResolver;
use Evomedien\Vitec\Service\RteResolver; use Evomedien\Vitec\Service\RteResolver;
use Evomedien\Vitec\Service\UsecaseSerializer; use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable; 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 [ return [
'uid' => $uid, 'uid' => $uid,
'title' => (string)($row['title'] ?? ''), 'title' => (string)($row['title'] ?? ''),
@@ -171,6 +172,7 @@ class ModelcardJsonRenderer
'subtitle' => (string)($row['subtitle'] ?? ''), 'subtitle' => (string)($row['subtitle'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''), 'teaser' => (string)($row['teaser'] ?? ''),
'description' => RteResolver::html($row['description'] ?? ''), 'description' => RteResolver::html($row['description'] ?? ''),
'detailUrl' => LinkResolver::pageUrl((int)($row['detail_page'] ?? 0)),
'image' => $serializer->image($uid, 'image', self::MODEL_TABLES[$modelType], true), '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\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService; use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\LinkResolver;
use Evomedien\Vitec\Service\RteResolver; use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService; use TYPO3\CMS\Extbase\Service\ImageService;
@@ -160,6 +161,7 @@ class SolutionShowJsonRenderer
'subtitle' => (string)($solution['subtitle'] ?? ''), 'subtitle' => (string)($solution['subtitle'] ?? ''),
'teaser' => (string)($solution['teaser'] ?? ''), 'teaser' => (string)($solution['teaser'] ?? ''),
'description' => RteResolver::html($solution['description'] ?? ''), 'description' => RteResolver::html($solution['description'] ?? ''),
'detailUrl' => LinkResolver::pageUrl((int)($solution['detail_page'] ?? 0)),
'categories' => $this->getSolutionCategories($uid), 'categories' => $this->getSolutionCategories($uid),
'image' => $this->getSolutionImage($uid), 'image' => $this->getSolutionImage($uid),
]; ];

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc; namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\LinkResolver;
use Evomedien\Vitec\Service\UsecaseSerializer; use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable; use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool; 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 private function resolvePageUrl(int $pageUid): string
{ {
try { return LinkResolver::pageUrl($pageUid) ?? '';
$cObj = GeneralUtility::makeInstance(\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::class);
return (string)$cObj->typoLink_URL(['parameter' => (string)$pageUid]);
} catch (\Throwable $e) {
return '';
}
} }
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc; namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\LinkResolver;
use Evomedien\Vitec\Service\UsecaseSerializer; use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable; use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
@@ -153,14 +154,14 @@ class UsecaseShowJsonRenderer
return (int)($row['uid'] ?? 0); 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 private function resolvePageUrl(int $pageUid): string
{ {
try { return LinkResolver::pageUrl($pageUid) ?? '';
$cObj = GeneralUtility::makeInstance(\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::class);
return (string)$cObj->typoLink_URL(['parameter' => (string)$pageUid]);
} catch (\Throwable $e) {
return '';
}
} }
} }

View File

@@ -24,7 +24,7 @@ return [
], ],
'types' => [ 'types' => [
'1' => [ '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: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:language, sys_language_uid, l10n_parent, l10n_diffsource,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime', --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime',
@@ -129,6 +129,17 @@ return [
'eval' => 'uniqueInPid', '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' => [ 'subtitle' => [
'exclude' => true, 'exclude' => true,
'label' => 'Subtitle', 'label' => 'Subtitle',

View File

@@ -155,6 +155,7 @@ CREATE TABLE tx_vitec_domain_model_solution (
l10n_diffsource mediumblob, l10n_diffsource mediumblob,
title varchar(255) DEFAULT '' NOT NULL, title varchar(255) DEFAULT '' NOT NULL,
slug 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, subtitle varchar(255) DEFAULT '' NOT NULL,
teaser varchar(255) DEFAULT '' NOT NULL, teaser varchar(255) DEFAULT '' NOT NULL,
description text, description text,