- new project-neutral extension evo_megamenu_json (Evomedien): a Megamenu plugin holding entries, columns, items, teaser cards and a featured slot as nested records; presentation settings travel with the payload so the front end reads behaviour instead of hard-coding it; published as page.10.fields.megaMenu, driven by the site settings megamenu.contentUid and megamenu.storagePid - vitec:seed-megamenu fills one element from the live structure: 5 entries, 26 columns, 95 items, 4 story cards; items without a page yet are flagged pending so the front end falls back to the column link - vitec:debug-sets prints the resolved site set order - read-only diagnostics: check_megamenu_schema.php, check_typoscript_templates.php - docs: schema updates run via extension:setup (database:updateschema no longer exists in v14), and a JSON Accept header activates headless-mixed, which strips every set-added page field
304 lines
11 KiB
PHP
304 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\EvoMegamenuJson\Service;
|
|
|
|
use Doctrine\DBAL\ParameterType;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
|
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
|
|
use TYPO3\CMS\Core\Resource\FileRepository;
|
|
use TYPO3\CMS\Core\Service\FlexFormService;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
|
|
|
/**
|
|
* Turns one "Megamenu" plugin element into the payload the front end reads.
|
|
*
|
|
* The element carries its entries inline; every entry carries its columns
|
|
* (each with items) and, for the teaser layout, its cards. Presentation
|
|
* settings live in the element's FlexForm and travel with the menu, so the
|
|
* front end takes its behaviour from the payload instead of hard-coding it.
|
|
*
|
|
* Content stages (FlexForm `stage`) decide how much of the editorial detail
|
|
* is emitted: 1 = structure only, 2 = plus images and teaser lines,
|
|
* 3 = plus the featured slot. A front end built for stage 1 keeps working
|
|
* when the stage is raised - later stages only add keys.
|
|
*/
|
|
final class MenuBuilder
|
|
{
|
|
private const TABLE_CONTENT = 'tt_content';
|
|
private const TABLE_ENTRY = 'tx_evomegamenujson_entry';
|
|
private const TABLE_COLUMN = 'tx_evomegamenujson_column';
|
|
private const TABLE_ITEM = 'tx_evomegamenujson_item';
|
|
private const TABLE_TEASER = 'tx_evomegamenujson_teaser';
|
|
|
|
private const DEFAULT_SETTINGS = [
|
|
'stage' => 3,
|
|
'pendingBehaviour' => 'fallback',
|
|
'showCounts' => true,
|
|
'showViewAll' => true,
|
|
'viewAllLabel' => 'View all %s',
|
|
'openOn' => 'hover',
|
|
'closeDelay' => 140,
|
|
'columnsPerRow' => 4,
|
|
'panelWidth' => 'container',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly ConnectionPool $connectionPool,
|
|
private readonly FlexFormService $flexFormService,
|
|
private readonly PageRepository $pageRepository,
|
|
private readonly FileRepository $fileRepository,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string,mixed>|null $contentRow pass the row when it is already at hand
|
|
* @return array{settings:array<string,mixed>,entries:array<int,array<string,mixed>>}
|
|
*/
|
|
public function build(int $contentUid, ContentObjectRenderer $cObj, ?array $contentRow = null): array
|
|
{
|
|
$row = $contentRow ?? $this->fetchContentRow($contentUid);
|
|
if ($row === null) {
|
|
return ['settings' => self::DEFAULT_SETTINGS, 'entries' => []];
|
|
}
|
|
|
|
$settings = $this->settingsOf($row);
|
|
$stage = (int)$settings['stage'];
|
|
|
|
$entries = [];
|
|
foreach ($this->children(self::TABLE_ENTRY, (int)$row['uid']) as $entry) {
|
|
$entries[] = $this->buildEntry($entry, $stage, $cObj);
|
|
}
|
|
|
|
return ['settings' => $settings, 'entries' => $entries];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $entry
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function buildEntry(array $entry, int $stage, ContentObjectRenderer $cObj): array
|
|
{
|
|
$layout = (string)($entry['layout'] ?? 'columns') === 'teasers' ? 'teasers' : 'columns';
|
|
|
|
$columns = [];
|
|
foreach ($this->children(self::TABLE_COLUMN, (int)$entry['uid']) as $column) {
|
|
$items = [];
|
|
foreach ($this->children(self::TABLE_ITEM, (int)$column['uid']) as $item) {
|
|
$items[] = $this->buildItem($item, $stage, $cObj);
|
|
}
|
|
$columns[] = [
|
|
'title' => (string)$column['title'],
|
|
'link' => $this->url((string)$column['link'], $cObj),
|
|
'items' => $items,
|
|
];
|
|
}
|
|
|
|
$built = [
|
|
'title' => (string)$entry['title'],
|
|
'link' => $this->url((string)$entry['link'], $cObj),
|
|
'layout' => $layout,
|
|
'columns' => $columns,
|
|
];
|
|
|
|
if ($layout === 'teasers') {
|
|
$teasers = [];
|
|
foreach ($this->children(self::TABLE_TEASER, (int)$entry['uid']) as $teaser) {
|
|
$card = [
|
|
'kicker' => (string)$teaser['kicker'],
|
|
'title' => (string)$teaser['title'],
|
|
'link' => $this->url((string)$teaser['link'], $cObj),
|
|
];
|
|
if ($stage >= 2) {
|
|
$card['text'] = trim((string)($teaser['teasertext'] ?? ''));
|
|
$card['image'] = $this->image(self::TABLE_TEASER, 'image', (int)$teaser['uid']);
|
|
}
|
|
$teasers[] = $card;
|
|
}
|
|
$built['teasers'] = $teasers;
|
|
}
|
|
|
|
if ($stage >= 3) {
|
|
$built['featured'] = ((int)($entry['featured_enable'] ?? 0) === 1)
|
|
? [
|
|
'kicker' => (string)$entry['featured_kicker'],
|
|
'title' => (string)$entry['featured_title'],
|
|
'text' => trim((string)($entry['featured_text'] ?? '')),
|
|
'link' => $this->url((string)$entry['featured_link'], $cObj),
|
|
'image' => $this->image(self::TABLE_ENTRY, 'featured_image', (int)$entry['uid']),
|
|
]
|
|
: null;
|
|
}
|
|
|
|
return $built;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $item
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function buildItem(array $item, int $stage, ContentObjectRenderer $cObj): array
|
|
{
|
|
$built = [
|
|
'title' => (string)$item['title'],
|
|
'link' => $this->url((string)$item['link'], $cObj),
|
|
];
|
|
if ((int)($item['pending'] ?? 0) === 1) {
|
|
$built['pending'] = true;
|
|
}
|
|
if ($stage >= 2) {
|
|
$teaser = trim((string)($item['teaser'] ?? ''));
|
|
if ($teaser !== '') {
|
|
$built['teaser'] = $teaser;
|
|
}
|
|
$badge = trim((string)($item['badge'] ?? ''));
|
|
if ($badge !== '') {
|
|
$built['badge'] = $badge;
|
|
}
|
|
$built['image'] = $this->image(self::TABLE_ITEM, 'image', (int)$item['uid']);
|
|
}
|
|
return $built;
|
|
}
|
|
|
|
// ------------------------------------------------------------ internals
|
|
|
|
/** @return array<string,mixed>|null */
|
|
private function fetchContentRow(int $uid): ?array
|
|
{
|
|
if ($uid <= 0) {
|
|
return null;
|
|
}
|
|
$queryBuilder = $this->queryBuilder(self::TABLE_CONTENT);
|
|
$row = $queryBuilder->select('*')->from(self::TABLE_CONTENT)
|
|
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)))
|
|
->executeQuery()->fetchAssociative();
|
|
if ($row === false) {
|
|
return null;
|
|
}
|
|
return $this->overlay(self::TABLE_CONTENT, $row);
|
|
}
|
|
|
|
/**
|
|
* Children of one parent record, in backend sorting order, with the
|
|
* frontend restrictions (hidden, time, delete) applied and translations
|
|
* overlaid.
|
|
*
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function children(string $table, int $parentUid): array
|
|
{
|
|
$queryBuilder = $this->queryBuilder($table);
|
|
$rows = $queryBuilder->select('*')->from($table)
|
|
->where(
|
|
$queryBuilder->expr()->eq('parentid', $queryBuilder->createNamedParameter($parentUid, ParameterType::INTEGER)),
|
|
$queryBuilder->expr()->in('sys_language_uid', [-1, 0])
|
|
)
|
|
->orderBy('sorting', 'ASC')
|
|
->executeQuery()->fetchAllAssociative();
|
|
|
|
$overlaid = [];
|
|
foreach ($rows as $row) {
|
|
$translated = $this->overlay($table, $row);
|
|
if ($translated !== null) {
|
|
$overlaid[] = $translated;
|
|
}
|
|
}
|
|
return $overlaid;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $row
|
|
* @return array<string,mixed>|null null when the translation hides the record
|
|
*/
|
|
private function overlay(string $table, array $row): ?array
|
|
{
|
|
try {
|
|
$overlaid = $this->pageRepository->getLanguageOverlay($table, $row);
|
|
return is_array($overlaid) ? $overlaid : null;
|
|
} catch (\Throwable) {
|
|
return $row;
|
|
}
|
|
}
|
|
|
|
private function queryBuilder(string $table): QueryBuilder
|
|
{
|
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
|
|
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
|
return $queryBuilder;
|
|
}
|
|
|
|
/**
|
|
* FlexForm settings merged over the defaults, typed the way the front end
|
|
* expects them.
|
|
*
|
|
* @param array<string,mixed> $row
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function settingsOf(array $row): array
|
|
{
|
|
$flex = $this->flexFormService->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? ''));
|
|
$stored = is_array($flex['settings'] ?? null) ? $flex['settings'] : [];
|
|
$settings = array_merge(self::DEFAULT_SETTINGS, array_filter($stored, static fn($v): bool => $v !== '' && $v !== null));
|
|
|
|
$settings['stage'] = max(1, min(3, (int)$settings['stage']));
|
|
$settings['closeDelay'] = max(0, (int)$settings['closeDelay']);
|
|
$settings['columnsPerRow'] = max(2, min(6, (int)$settings['columnsPerRow']));
|
|
$settings['showCounts'] = (bool)$settings['showCounts'];
|
|
$settings['showViewAll'] = (bool)$settings['showViewAll'];
|
|
$settings['openOn'] = $settings['openOn'] === 'click' ? 'click' : 'hover';
|
|
$settings['panelWidth'] = $settings['panelWidth'] === 'full' ? 'full' : 'container';
|
|
$settings['pendingBehaviour'] = in_array($settings['pendingBehaviour'], ['fallback', 'mute', 'hide'], true)
|
|
? $settings['pendingBehaviour'] : 'fallback';
|
|
$settings['viewAllLabel'] = (string)$settings['viewAllLabel'];
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/** Resolved URL for a link field; '' when empty or unresolvable. */
|
|
private function url(string $link, ContentObjectRenderer $cObj): string
|
|
{
|
|
$link = trim($link);
|
|
if ($link === '') {
|
|
return '';
|
|
}
|
|
try {
|
|
return (string)$cObj->typoLink_URL(['parameter' => $link, 'forceAbsoluteUrl' => false]);
|
|
} catch (\Throwable) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* First file reference of a field as a plain object - url plus the
|
|
* metadata a menu needs. Null when nothing is attached.
|
|
*
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
private function image(string $table, string $field, int $uid): ?array
|
|
{
|
|
try {
|
|
$references = $this->fileRepository->findByRelation($table, $field, $uid);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
$reference = $references[0] ?? null;
|
|
if ($reference === null) {
|
|
return null;
|
|
}
|
|
try {
|
|
return [
|
|
'url' => $reference->getPublicUrl(),
|
|
'alternative' => (string)$reference->getProperty('alternative'),
|
|
'title' => (string)$reference->getProperty('title'),
|
|
'width' => (int)$reference->getProperty('width'),
|
|
'height' => (int)$reference->getProperty('height'),
|
|
];
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|