Unify list plugins, add solution list and per-story detail page

- vitec_usecaselist: layout + record selection like marketlist
- new plugin vitec_solutionlist (spec 7.13.3, key "solutions")
- tx_vitec_domain_model_usecase: detail_page -> resolved detailUrl
  (also gives story cards in vitec_modelcard a link for the first time)
- "Show Toolbar" checkbox on product/market/solution/usecase lists
- spec bumped to v1.9

BREAKING: vitec_productlist and vitec_usecaselist now emit an object
instead of a bare array. Consumers must read products.products and
usecases.usecases. Also found: productlist had a configurable layout
that was never serialised; it is emitted now, but keeps its own 0-3
vocabulary instead of grid/list/carousel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 16:29:06 +02:00
parent 4ef3ad0026
commit 12eb40614b
23 changed files with 559 additions and 28 deletions

View File

@@ -45,6 +45,19 @@ class SolutionController extends ActionController
* *
* @return ResponseInterface * @return ResponseInterface
*/ */
/**
* action list
*
* Intentionally empty: in headless mode the JSON is produced by
* SolutionListJsonRenderer through TypoScript, so this action never runs.
* It exists because configurePlugin() needs a target - same as
* MarketController::listAction().
*/
public function listAction(): ResponseInterface
{
return $this->htmlResponse();
}
public function showAction(): ResponseInterface public function showAction(): ResponseInterface
{ {
$selectedSolutionId = (int)$this->settings['solution']; $selectedSolutionId = (int)$this->settings['solution'];

View File

@@ -8,6 +8,7 @@ use Evomedien\Vitec\UserFunc\ProductShowJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer; use Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer; use Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketListJsonRenderer; use Evomedien\Vitec\UserFunc\MarketListJsonRenderer;
use Evomedien\Vitec\UserFunc\SolutionListJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketShowJsonRenderer; use Evomedien\Vitec\UserFunc\MarketShowJsonRenderer;
use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer; use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer; use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
@@ -119,6 +120,7 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'], 'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'],
'vitec_marketshow' => [MarketShowJsonRenderer::class, 'market'], 'vitec_marketshow' => [MarketShowJsonRenderer::class, 'market'],
'vitec_marketlist' => [MarketListJsonRenderer::class, 'markets'], 'vitec_marketlist' => [MarketListJsonRenderer::class, 'markets'],
'vitec_solutionlist' => [SolutionListJsonRenderer::class, 'solutions'],
'vitec_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'], 'vitec_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'],
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'], 'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'], 'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],

View File

@@ -19,6 +19,14 @@ class Usecase extends AbstractEntity
*/ */
protected $slug = ''; protected $slug = '';
/**
* Page that presents this story. 0 = none; the list plugin then falls back
* to its own "Single PID" detail page plus the slug.
*
* @var int
*/
protected $detailPage = 0;
/** /**
* subtitle * subtitle
* *
@@ -110,6 +118,16 @@ class Usecase extends AbstractEntity
return $this->slug; return $this->slug;
} }
public function getDetailPage(): int
{
return $this->detailPage;
}
public function setDetailPage(int $detailPage): void
{
$this->detailPage = $detailPage;
}
/** /**
* @param string $slug * @param string $slug
*/ */

View File

@@ -10,6 +10,7 @@ use Evomedien\Vitec\UserFunc\ProductShowJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer; use Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer; use Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketListJsonRenderer; use Evomedien\Vitec\UserFunc\MarketListJsonRenderer;
use Evomedien\Vitec\UserFunc\SolutionListJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketShowJsonRenderer; use Evomedien\Vitec\UserFunc\MarketShowJsonRenderer;
use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer; use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer; use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
@@ -77,6 +78,7 @@ final class ContentElementResolver
'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'], 'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'],
'vitec_marketshow' => [MarketShowJsonRenderer::class, 'market'], 'vitec_marketshow' => [MarketShowJsonRenderer::class, 'market'],
'vitec_marketlist' => [MarketListJsonRenderer::class, 'markets'], 'vitec_marketlist' => [MarketListJsonRenderer::class, 'markets'],
'vitec_solutionlist' => [SolutionListJsonRenderer::class, 'solutions'],
'vitec_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'], 'vitec_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'],
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'], 'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'], 'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Evomedien\Vitec\Service; namespace Evomedien\Vitec\Service;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\LinkResolver;
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\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -42,6 +43,10 @@ final class UsecaseSerializer
'uid' => $uid, 'uid' => $uid,
'title' => (string)($u['title'] ?? ''), 'title' => (string)($u['title'] ?? ''),
'slug' => (string)($u['slug'] ?? ''), 'slug' => (string)($u['slug'] ?? ''),
// The story's own detail page, when one is set. null means "none",
// and the list plugin then falls back to its Single PID plus the
// slug - which is how every story behaved before the field existed.
'detailUrl' => LinkResolver::pageUrl((int)($u['detail_page'] ?? 0)),
'subtitle' => (string)($u['subtitle'] ?? ''), 'subtitle' => (string)($u['subtitle'] ?? ''),
'teaser' => (string)($u['teaser'] ?? ''), 'teaser' => (string)($u['teaser'] ?? ''),
'cardImage' => $this->image($uid, 'card_image', null, true), 'cardImage' => $this->image($uid, 'card_image', null, true),

View File

@@ -18,7 +18,7 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
* UserFunc: render all VITEC markets as JSON (headless). * UserFunc: render all VITEC markets as JSON (headless).
* *
* Output under content.markets: * Output under content.markets:
* { "layout": "grid|list|carousel", "markets": [ … ] } * { "layout": "grid|list|carousel|50-50", "markets": [ … ] }
* *
* Selection semantics (FlexForm `settings.markets`): * Selection semantics (FlexForm `settings.markets`):
* - nothing selected -> ALL visible markets, alphabetical by title * - nothing selected -> ALL visible markets, alphabetical by title
@@ -152,6 +152,7 @@ class MarketListJsonRenderer
$response = [ $response = [
'layout' => $layout, 'layout' => $layout,
'showToolbar' => (bool)($settings['showtoolbar'] ?? false),
'markets' => $markets, 'markets' => $markets,
]; ];

View File

@@ -152,20 +152,30 @@ class ProductListJsonRenderer
$productsData[] = $this->serializeProduct($product); $productsData[] = $this->serializeProduct($product);
} }
// Envelope with layout and toolbar flag, matching the other list
// plugins. This turned the payload from a bare array into an object:
// the front end has to read `products.products` instead of
// iterating `products` directly. `layout` was configurable in the
// FlexForm all along but had never been emitted - note that product
// layouts are numbered 0..3 here, not the grid/list/carousel
// vocabulary the other lists use.
$response = [
'layout' => (string)($settings['layout'] ?? '0'),
'showToolbar' => (bool)($settings['showtoolbar'] ?? false),
'products' => $productsData,
];
if ($debugMode) { if ($debugMode) {
return json_encode([ $response['debug'] = [
'products' => $productsData, 'pageId' => (int)($GLOBALS['TSFE']->id ?? 0),
'debug' => [ 'categoryUids' => $categoryUids,
'pageId' => (int)($GLOBALS['TSFE']->id ?? 0), 'productCount' => count($productsData),
'categoryUids' => $categoryUids, 'settings' => $settings,
'productCount' => count($productsData), 'fallbackFiles' => $this->collectFallbackFilesFromProducts($productsData),
'settings' => $settings, ];
'fallbackFiles' => $this->collectFallbackFilesFromProducts($productsData),
],
]);
} }
return json_encode($productsData); return json_encode($response);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ''; return '';
} }

View File

@@ -0,0 +1,196 @@
<?php
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 solutions as JSON (headless).
*
* Output under content.solutions:
* { "layout": "grid|list|carousel|50-50", "solutions": [ … ] }
*
* This is the Solution counterpart the architecture spec asks for in clause
* 7.13.2 - deliberately the same shape as the market list, so the front end can
* serve both with one component.
*
* Selection semantics (FlexForm `settings.solutions`):
* - nothing selected -> ALL visible solutions, alphabetical by title
* (tx_vitec_domain_model_solution has no `sorting` column, same as market)
* - solutions selected -> exactly those, in the order the editor arranged
* them in the FlexForm
*
* The page carrying the plugin is never used as a filter.
*
* Each item uses the same shape as the card payload (ModelcardJsonRenderer,
* model type "solution") and carries `detailUrl`, resolved from the record's
* own `detail_page`. Image resolution is delegated to
* UsecaseSerializer::image() rather than re-implemented inline (clause 9.2).
*
* `render()` = top-level plugin / page discovery. `renderForRecord()` = one
* specific tt_content row (reused by ContainerChildrenProcessor for nested
* plugins). Exception-safe.
*/
class SolutionListJsonRenderer
{
private ?ContentObjectRenderer $cObj = null;
/**
* TYPO3 v14 hands the ContentObjectRenderer over through this setter only -
* ContentObjectRenderer::callUserFunction() duck-types it with
* is_callable([$classObj, 'setContentObjectRenderer']). Without the method
* $this->cObj stays null, the cObj branch of render() never fires and the
* call falls through to page discovery, which picks the FIRST element of
* this CType on the page rather than the one actually being rendered.
*/
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
private const TABLE = 'tx_vitec_domain_model_solution';
private const CTYPE = 'vitec_solutionlist';
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$row = is_array($this->cObj?->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === self::CTYPE) {
return $this->renderForRecord($row);
}
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
$pageId = (int)$pageInfo->getId();
}
}
if ($pageId <= 0) {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
}
if ($pageId <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter(self::CTYPE, ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($ces)) {
return '';
}
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$layout = (string)($settings['layout'] ?? 'grid');
$debugMode = (bool)($settings['debug'] ?? false);
$selectedUids = GeneralUtility::intExplode(',', (string)($settings['solutions'] ?? ''), true);
// Always fetch the full (small) table once - that way a selected
// record that has meanwhile been hidden or deleted simply drops out.
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$rows = $qb
->select('*')
->from(self::TABLE)
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
if ($selectedUids === []) {
$solutions = array_map(
fn(array $r): array => $this->serializeSolution($r, $serializer),
$rows
);
} else {
// Keep the editor's FlexForm order. A SQL IN() would return the
// rows in storage order, so the sequence is rebuilt here.
$byUid = [];
foreach ($rows as $r) {
$byUid[(int)$r['uid']] = $r;
}
$solutions = [];
foreach ($selectedUids as $uid) {
if (isset($byUid[$uid])) {
$solutions[] = $this->serializeSolution($byUid[$uid], $serializer);
}
}
}
$response = [
'layout' => $layout,
'showToolbar' => (bool)($settings['showtoolbar'] ?? false),
'solutions' => $solutions,
];
if ($debugMode) {
$response['debug'] = [
'count' => count($solutions),
'selected' => $selectedUids,
'settings' => $settings,
];
}
return (string)json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string,mixed> $r
* @return array<string,mixed>
*/
private function serializeSolution(array $r, UsecaseSerializer $serializer): array
{
$uid = (int)$r['uid'];
return [
'uid' => $uid,
'title' => (string)($r['title'] ?? ''),
'slug' => (string)($r['slug'] ?? ''),
'subtitle' => (string)($r['subtitle'] ?? ''),
'teaser' => (string)($r['teaser'] ?? ''),
'description' => RteResolver::html($r['description'] ?? ''),
'detailUrl' => LinkResolver::pageUrl((int)($r['detail_page'] ?? 0)),
'image' => $serializer->image($uid, 'image', self::TABLE, true),
];
}
}

View File

@@ -20,6 +20,11 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
* specific tt_content row (reused by ContainerChildrenProcessor for nested * specific tt_content row (reused by ContainerChildrenProcessor for nested
* usecase-list plugins). All serialisation is delegated to UsecaseSerializer. * usecase-list plugins). All serialisation is delegated to UsecaseSerializer.
* Exception-safe. * Exception-safe.
*
* Payload: { "layout": "grid|list|carousel|50-50", "usecases": [ … ] }
*
* The editor may pick individual stories in the FlexForm; the arranged order is
* the display order. No selection means all stories, featured first.
*/ */
class UsecaseListJsonRenderer class UsecaseListJsonRenderer
{ {
@@ -109,6 +114,8 @@ class UsecaseListJsonRenderer
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? ''); $flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? []; $settings = $flexFormData['settings'] ?? [];
$debugMode = (bool)($settings['debug'] ?? false); $debugMode = (bool)($settings['debug'] ?? false);
$layout = (string)($settings['layout'] ?? 'grid');
$selectedUids = GeneralUtility::intExplode(',', (string)($settings['usecases'] ?? ''), true);
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE); $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$rows = $qb $rows = $qb
@@ -139,25 +146,59 @@ class UsecaseListJsonRenderer
$detailBase = $parent !== '' ? $parent : $detailPath; $detailBase = $parent !== '' ? $parent : $detailPath;
} }
// Editor-picked stories keep the order they were arranged in. The
// full (small) table is fetched once and the sequence rebuilt here:
// a SQL IN() would hand the rows back in storage order, and a story
// that has meanwhile been hidden simply drops out. Same approach as
// MarketListJsonRenderer.
if ($selectedUids !== []) {
$byUid = [];
foreach ($rows as $r) {
$byUid[(int)$r['uid']] = $r;
}
$ordered = [];
foreach ($selectedUids as $uid) {
if (isset($byUid[$uid])) {
$ordered[] = $byUid[$uid];
}
}
$rows = $ordered;
}
$usecases = array_map( $usecases = array_map(
static function (array $u) use ($serializer, $detailBase): array { static function (array $u) use ($serializer, $detailBase): array {
$item = $serializer->serializeListItem($u); $item = $serializer->serializeListItem($u);
$item['detailUrl'] = ($detailBase !== '' && ($item['slug'] ?? '') !== '') // A story with its own detail page wins; only the others
? $detailBase . '/' . ltrim((string)$item['slug'], '/') // fall back to the Single PID plus the slug, so nothing
: null; // changes for the stories that have no page assigned.
if (($item['detailUrl'] ?? null) === null) {
$item['detailUrl'] = ($detailBase !== '' && ($item['slug'] ?? '') !== '')
? $detailBase . '/' . ltrim((string)$item['slug'], '/')
: null;
}
return $item; return $item;
}, },
$rows $rows
); );
// Envelope with the layout, matching vitec_marketlist. This changed
// the payload from a bare array to an object - the front end has to
// read `usecases.usecases` instead of iterating `usecases` directly.
$response = [
'layout' => $layout,
'showToolbar' => (bool)($settings['showtoolbar'] ?? false),
'usecases' => $usecases,
];
if ($debugMode) { if ($debugMode) {
return (string)json_encode([ $response['debug'] = [
'usecases' => $usecases, 'count' => count($usecases),
'debug' => ['count' => count($usecases), 'settings' => $settings], 'selected' => $selectedUids,
]); 'settings' => $settings,
];
} }
return (string)json_encode($usecases); return (string)json_encode($response);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ''; return '';
} }

View File

@@ -28,11 +28,24 @@
<label>Carousel</label> <label>Carousel</label>
<value>carousel</value> <value>carousel</value>
</numIndex> </numIndex>
<numIndex index="3">
<label>50 / 50</label>
<value>50-50</value>
</numIndex>
</items> </items>
<default>grid</default> <default>grid</default>
</config> </config>
</settings.layout> </settings.layout>
<settings.showtoolbar>
<label>Show Toolbar</label>
<description>Show the toolbar above the list.</description>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.showtoolbar>
<settings.markets> <settings.markets>
<label>Markets</label> <label>Markets</label>
<description>Leave empty to show all markets alphabetically. If you select markets, exactly those are shown — in the order you arrange them here (use the arrows to sort).</description> <description>Leave empty to show all markets alphabetically. If you select markets, exactly those are shown — in the order you arrange them here (use the arrows to sort).</description>

View File

@@ -92,6 +92,14 @@
</items> </items>
</config> </config>
</settings.layout> </settings.layout>
<settings.showtoolbar>
<label>Show Toolbar</label>
<description>Show the toolbar above the list.</description>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.showtoolbar>
</el> </el>
</ROOT> </ROOT>
</sDEF> </sDEF>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<T3DataStructure>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>Solution List Settings</sheetTitle>
<type>array</type>
<el>
<settings.layout>
<label>Layout</label>
<description>Display variant for the solution cards.</description>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>Grid</label>
<value>grid</value>
</numIndex>
<numIndex index="1">
<label>List</label>
<value>list</value>
</numIndex>
<numIndex index="2">
<label>Carousel</label>
<value>carousel</value>
</numIndex>
<numIndex index="3">
<label>50 / 50</label>
<value>50-50</value>
</numIndex>
</items>
<default>grid</default>
</config>
</settings.layout>
<settings.showtoolbar>
<label>Show Toolbar</label>
<description>Show the toolbar above the list.</description>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.showtoolbar>
<settings.solutions>
<label>Solutions</label>
<description>Leave empty to show all solutions alphabetically. If you select solutions, exactly those are shown — in the order you arrange them here (use the arrows to sort).</description>
<config>
<type>select</type>
<renderType>selectMultipleSideBySide</renderType>
<foreign_table>tx_vitec_domain_model_solution</foreign_table>
<foreign_table_where>AND tx_vitec_domain_model_solution.hidden = 0 AND tx_vitec_domain_model_solution.deleted = 0 ORDER BY tx_vitec_domain_model_solution.title</foreign_table_where>
<size>8</size>
<minitems>0</minitems>
<maxitems>999</maxitems>
</config>
</settings.solutions>
<settings.debug>
<label>Allow Debug Output</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.debug>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>

View File

@@ -6,6 +6,57 @@
<sheetTitle>Usecase List</sheetTitle> <sheetTitle>Usecase List</sheetTitle>
<type>array</type> <type>array</type>
<el> <el>
<settings.layout>
<label>Layout</label>
<description>Display variant for the success story cards.</description>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>Grid</label>
<value>grid</value>
</numIndex>
<numIndex index="1">
<label>List</label>
<value>list</value>
</numIndex>
<numIndex index="2">
<label>Carousel</label>
<value>carousel</value>
</numIndex>
<numIndex index="3">
<label>50 / 50</label>
<value>50-50</value>
</numIndex>
</items>
<default>grid</default>
</config>
</settings.layout>
<settings.showtoolbar>
<label>Show Toolbar</label>
<description>Show the toolbar above the list.</description>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.showtoolbar>
<settings.usecases>
<label>Success Stories</label>
<description>Leave empty to show all stories (featured first, then alphabetically). If you select stories, exactly those are shown — in the order you arrange them here (use the arrows to sort).</description>
<config>
<type>select</type>
<renderType>selectMultipleSideBySide</renderType>
<foreign_table>tx_vitec_domain_model_usecase</foreign_table>
<foreign_table_where>AND tx_vitec_domain_model_usecase.hidden = 0 AND tx_vitec_domain_model_usecase.deleted = 0 AND tx_vitec_domain_model_usecase.hideonwebsite = 0 ORDER BY tx_vitec_domain_model_usecase.title</foreign_table_where>
<size>8</size>
<minitems>0</minitems>
<maxitems>999</maxitems>
</config>
</settings.usecases>
<settings.singlePid> <settings.singlePid>
<label>Single PID (detail page)</label> <label>Single PID (detail page)</label>
<description>Page that holds the "Show single VITEC Success Story" plugin. Used to build the detail links in the JSON output.</description> <description>Page that holds the "Show single VITEC Success Story" plugin. Used to build the detail links in the JSON output.</description>

View File

@@ -84,6 +84,10 @@ return [
'provider' => SvgIconProvider::class, 'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-marketlist.svg', 'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-marketlist.svg',
], ],
'vitec-plugin-solutionlist' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-solutionlist.svg',
],
'vitec-import' => [ 'vitec-import' => [
'provider' => SvgIconProvider::class, 'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-import.svg', 'source' => 'EXT:vitec/Resources/Public/Icons/vitec-import.svg',

View File

@@ -113,6 +113,18 @@ tt_content {
} }
} }
vitec_solutionlist < lib.contentElementWithHeader
vitec_solutionlist {
fields {
content {
fields {
solutions = USER
solutions.userFunc = Evomedien\Vitec\UserFunc\SolutionListJsonRenderer->render
}
}
}
}
vitec_solutionshow < lib.contentElementWithHeader vitec_solutionshow < lib.contentElementWithHeader
vitec_solutionshow { vitec_solutionshow {
fields { fields {

View File

@@ -111,6 +111,16 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
'FILE:EXT:vitec/Configuration/FlexForms/Marketlist.xml' 'FILE:EXT:vitec/Configuration/FlexForms/Marketlist.xml'
); );
ExtensionUtility::registerPlugin(
'Vitec',
'Solutionlist',
'VITEC Solution List',
'vitec-plugin-solutionlist',
'vitec',
'All solutions as grid, list or carousel.',
'FILE:EXT:vitec/Configuration/FlexForms/Solutionlist.xml'
);
// Change frame_class to allow multiple selections. // Change frame_class to allow multiple selections.
$GLOBALS['TCA']['tt_content']['columns']['frame_class']['config']['renderType'] = 'selectCheckBox'; $GLOBALS['TCA']['tt_content']['columns']['frame_class']['config']['renderType'] = 'selectCheckBox';

View File

@@ -26,7 +26,7 @@ return [
'1' => [ '1' => [
'showitem' => 'showitem' =>
'--div--;General, '--div--;General,
title, slug, subtitle, title, slug, detail_page, subtitle,
--div--;List view, --div--;List view,
card_image, customer_logo, teaser, card_image, customer_logo, teaser,
--div--;Detail · Hero, --div--;Detail · Hero,
@@ -109,6 +109,17 @@ return [
'eval' => 'uniqueInPid', 'eval' => 'uniqueInPid',
], ],
], ],
'detail_page' => [
'exclude' => true,
'label' => 'Detail Page',
'description' => 'Page that presents this story. Resolved to a URL in the JSON output; leave empty to keep using the list plugin\'s Single PID plus the slug.',
'config' => [
'type' => 'group',
'allowed' => 'pages',
'size' => 1,
'maxitems' => 1,
],
],
'subtitle' => [ 'subtitle' => [
'label' => 'Subtitle', 'label' => 'Subtitle',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'default' => ''], 'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'default' => ''],

View File

@@ -3,9 +3,9 @@
| | | | | |
|---|---| |---|---|
| **Document identifier** | EVOVITECHL001 | | **Document identifier** | EVOVITECHL001 |
| **Version** | 1.8 | | **Version** | 1.9 |
| **Status** | Released | | **Status** | Released |
| **Date** | 20260813 | | **Date** | 20260815 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) | | **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
| **Owner** | evomedien — VITEC relaunch | | **Owner** | evomedien — VITEC relaunch |
@@ -22,6 +22,7 @@
| 1.6 | 20260806 | **Interface change (additive):** `tx_vitec_domain_model_download` gained a second category field `type` (display taxonomy; MM rows distinguished by `fieldname`), emitted as the string `type` in the downloadcard, downloadcardcollection and datasheets payloads — analogous to `filetype`. New CLI command `vitec:import-downloads` migrates the oldsite downloads (Collateral directory only; idempotent by slug; files fetched resumably; duplicate `file_url`s merged). | | 1.6 | 20260806 | **Interface change (additive):** `tx_vitec_domain_model_download` gained a second category field `type` (display taxonomy; MM rows distinguished by `fieldname`), emitted as the string `type` in the downloadcard, downloadcardcollection and datasheets payloads — analogous to `filetype`. New CLI command `vitec:import-downloads` migrates the oldsite downloads (Collateral directory only; idempotent by slug; files fetched resumably; duplicate `file_url`s merged). |
| 1.7 | 20260806 | **Robustness:** the import normalizes legacy filenames on fetch so every imported file matches the version convention (`__NN_A``__NN-A`, `___NN``__NN`, `__NNA``__NN-A`, bare `__NN``__NN-A` as initial revision), and the three download renderers gained a `filepath` fallback in `getDownloadFile()` (FAL → convention → filepath) as a safety net for anything that still escapes it. Extends the B4 duplication (three copies of the fallback) — consolidation target remains a shared fileresolver service (10.2). | | 1.7 | 20260806 | **Robustness:** the import normalizes legacy filenames on fetch so every imported file matches the version convention (`__NN_A``__NN-A`, `___NN``__NN`, `__NNA``__NN-A`, bare `__NN``__NN-A` as initial revision), and the three download renderers gained a `filepath` fallback in `getDownloadFile()` (FAL → convention → filepath) as a safety net for anything that still escapes it. Extends the B4 duplication (three copies of the fallback) — consolidation target remains a shared fileresolver service (10.2). |
| 1.8 | 20260813 | **Defect fix and interface change (additive).** Content Blocks never carried the Core *Appearance* tab: `layout`, `frame_class` — including the VITEC frame classes — `space_before_class`, `space_after_class`, `sectionIndex` and `linkToTop` were unreachable for editors on all nine blocks. Added centrally for every `vitec_*` type (7.7); the `appearance` envelope is unchanged, its values were merely always default. Side effect: those six columns now also appear raw inside `data` on toplevel blocks (B13), and `appearance.layout` is represented differently on the two envelope paths (B14). `intro-paragraph` gained `background_color` (7.7). `vitec_eventlist` gained the layout `regions`, emitting a `regions` array built from the region categories below parent 104; the event payload is specified for the first time (7.14). B11 and B12 recorded as resolved. | | 1.8 | 20260813 | **Defect fix and interface change (additive).** Content Blocks never carried the Core *Appearance* tab: `layout`, `frame_class` — including the VITEC frame classes — `space_before_class`, `space_after_class`, `sectionIndex` and `linkToTop` were unreachable for editors on all nine blocks. Added centrally for every `vitec_*` type (7.7); the `appearance` envelope is unchanged, its values were merely always default. Side effect: those six columns now also appear raw inside `data` on toplevel blocks (B13), and `appearance.layout` is represented differently on the two envelope paths (B14). `intro-paragraph` gained `background_color` (7.7). `vitec_eventlist` gained the layout `regions`, emitting a `regions` array built from the region categories below parent 104; the event payload is specified for the first time (7.14). B11 and B12 recorded as resolved. |
| 1.9 | 20260815 | **Interface change, partly breaking.** The four list plugins were unified: every one of them now emits an object carrying `layout`, the new `showToolbar` flag and its payload array. `vitec_usecaselist` and `vitec_productlist` previously emitted a **bare array** — front ends reading them have to move one level down (7.3, 7.14, 8). New plugin **`vitec_solutionlist`** (`SolutionListJsonRenderer`, key `solutions`), the counterpart 7.13.2 had been asking for since v1.2. `tx_vitec_domain_model_usecase` gained `detail_page`, emitted as the resolved `detailUrl` in the story card shape and therefore also in the `vitec_modelcard` story branch, which had carried no link at all until now. Noted: `vitec_productlist` had a configurable `layout` that was never serialised, and its vocabulary (`0``3`) differs from the other lists. |
This document is drafted in the style of, and adopts the terminology conventions of, This document is drafted in the style of, and adopts the terminology conventions of,
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
@@ -383,10 +384,27 @@ a lean variant of the envelope:
### 7.3 Payload keys ### 7.3 Payload keys
| Kind | Key | Cardinality | | Kind | Key | Cardinality |
|---|---|---| |---|---|---|
| List plugin | plural noun (`products`, `usecases`, `news → items`) | array | | List plugin | plural noun (`products`, `usecases`, `markets`, `solutions`, `news → items`) | **object** (see below) |
| Detail plugin | singular noun (`product`, `usecase`, `market`, `solution`) | object | | Detail plugin | singular noun (`product`, `usecase`, `market`, `solution`) | object |
| Container | `items` | array of `{config, contentElements}` | | Container | `items` | array of `{config, contentElements}` |
**List envelope (since v1.9).** The four record list plugins — `vitec_productlist`,
`vitec_usecaselist`, `vitec_marketlist`, `vitec_solutionlist` — share one shape:
```jsonc
{ "layout": "grid", // display variant, see the note below
"showToolbar": false, // render the toolbar above the list
"<plural>": [ ] } // products | usecases | markets | solutions
```
`vitec_usecaselist` and `vitec_productlist` emitted a bare array before v1.9; a consumer has
to read `<key>.<key>` now. `layout` uses `grid | list | carousel | 50-50` everywhere **except**
`vitec_productlist`, which keeps its own `0``3` ("Default", "Variation 13") — unifying it
would move existing content onto a different layout and needs a migration, so it was left
alone.
`news_*` is not part of this: it keeps its own `{mode, items|news, settings}` envelope (7.6).
### 7.4 Container payload ### 7.4 Container payload
```jsonc ```jsonc
{ {
@@ -761,8 +779,23 @@ in v1.3 on the assumption that lists only need `teaser`, which turned out to be
practice. Image resolution is delegated to `UsecaseSerializer::image()` per 9.2; this practice. Image resolution is delegated to `UsecaseSerializer::image()` per 9.2; this
renderer duplicates no FAL logic. renderer duplicates no FAL logic.
A Solution list counterpart does not exist yet. When it is added it **should** reuse this #### 7.13.3 Solution list payload (`vitec_solutionlist`)
shape under `content.solutions`. Added in v1.9, and deliberately the same shape as 7.13.2 — `SolutionListJsonRenderer` emits
under `content.solutions`:
```jsonc
{ "layout": "grid", "showToolbar": false,
"solutions": [
{ "uid": 7, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
"description": "<p>…</p>", // RTE HTML, resolved per 9.11
"detailUrl": "/solutions/iptv/", // from the record's detail_page; null when unset
"image": { "url": "…", "srcset": [ ] } } ] }
```
Selection and ordering follow 7.13.2 exactly: nothing selected means all visible solutions
alphabetically, a selection means precisely those in the arranged order.
`tx_vitec_domain_model_solution` has **no `sorting` column** either, which is why the
unselected case falls back to alphabetical rather than to a backenddefined order.
### 7.14 Event list payload (`vitec_eventlist`) ### 7.14 Event list payload (`vitec_eventlist`)
`EventlistJsonRenderer` emits, under `content.eventlist`: `EventlistJsonRenderer` emits, under `content.eventlist`:
@@ -834,6 +867,7 @@ remaining present in `events`.
| `vitec_usecaselist` | `< lib.…WithHeader` | UsecaseListJsonRenderer → **UsecaseSerializer** | `usecases` | list | | `vitec_usecaselist` | `< lib.…WithHeader` | UsecaseListJsonRenderer → **UsecaseSerializer** | `usecases` | list |
| `vitec_usecaseshow` | `< lib.…WithHeader` | UsecaseShowJsonRenderer → **UsecaseSerializer** | `usecase` | detail | | `vitec_usecaseshow` | `< lib.…WithHeader` | UsecaseShowJsonRenderer → **UsecaseSerializer** | `usecase` | detail |
| `vitec_marketlist` | `< lib.…WithHeader` | MarketListJsonRenderer | `markets` | list (global) | | `vitec_marketlist` | `< lib.…WithHeader` | MarketListJsonRenderer | `markets` | list (global) |
| `vitec_solutionlist` | `< lib.…WithHeader` | SolutionListJsonRenderer | `solutions` | list (global) |
| `vitec_marketshow` | `< lib.…WithHeader` | MarketShowJsonRenderer | `market` | detail | | `vitec_marketshow` | `< lib.…WithHeader` | MarketShowJsonRenderer | `market` | detail |
| `vitec_solutionshow` | `< lib.…WithHeader` | SolutionShowJsonRenderer | `solution` | detail | | `vitec_solutionshow` | `< lib.…WithHeader` | SolutionShowJsonRenderer | `solution` | detail |
| `vitec_downloadcard` | `< lib.…WithHeader` | DownloadcardJsonRenderer | `downloadcard` | detail | | `vitec_downloadcard` | `< lib.…WithHeader` | DownloadcardJsonRenderer | `downloadcard` | detail |

View File

@@ -0,0 +1,9 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<!--
Headless mode: the JSON for vitec_solutionlist is produced by
Evomedien\Vitec\UserFunc\SolutionListJsonRenderer, wired in
Configuration/TypoScript via the Vitecset setup. This template only exists so
SolutionController::listAction() has one to render if the plugin is ever
reached through the regular Extbase path.
-->
</html>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="#26358c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3.5" width="8" height="8" rx="1.5"/>
<rect x="13" y="3.5" width="8" height="8" rx="1.5"/>
<rect x="3" y="13.5" width="8" height="7" rx="1.5"/>
<rect x="13" y="13.5" width="8" height="7" rx="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -104,6 +104,17 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1750000000] = [
] ]
); );
ExtensionUtility::configurePlugin(
'Vitec',
'Solutionlist',
[
\Evomedien\Vitec\Controller\SolutionController::class => 'list'
],
[
\Evomedien\Vitec\Controller\SolutionController::class => 'list'
]
);
ExtensionUtility::configurePlugin( ExtensionUtility::configurePlugin(
'Vitec', 'Vitec',
'Solutionshow', 'Solutionshow',

View File

@@ -95,6 +95,7 @@ CREATE TABLE tx_vitec_product_related_mm (
CREATE TABLE tx_vitec_domain_model_usecase ( CREATE TABLE tx_vitec_domain_model_usecase (
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,
card_image int(11) unsigned DEFAULT '0' NOT NULL, card_image int(11) unsigned DEFAULT '0' NOT NULL,

View File

@@ -25,8 +25,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>VITEC</title> <title>VITEC</title>
<script type="module" crossorigin src="/_frontend/assets/index-CA5rSLMo.js"></script> <script type="module" crossorigin src="/_frontend/assets/index-ARhFRF4y.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-C7oUhBog.css"> <link rel="stylesheet" crossorigin href="/_frontend/assets/index-BUkc9k85.css">
</head> </head>
<body> <body>
<div id="page"></div> <div id="page"></div>