Megamenu: rail display for page trees, cache tags, editorial promo boxes

- Page-tree entries get a "Page tree display" field (columns | rail |
  rail-compact), emitted as display + cardSize: Markets and Solutions
  show their main pages as a list and the sub-pages as image cards on
  hover. Page-tree items carry image (page media) and teaser (abstract)
  from stage 2. Additive; front ends ignoring display render columns.
- vitec:attach-page-media gains --depth=2 and falls back to the page's
  hero background before the grey placeholder; the 39 sub-solution
  pages got their hero image as page media.
- MegamenuJsonRenderer tags every page carrying the menu with the menu
  tables and tt_content_<uid>, so saving menu records refreshes all
  pages instead of waiting for a full flush.
- Products catalog: drop catalog.news; both promo boxes ("Product
  highlight", "Product news") are the entry's editorial teaser cards.
  ProvideInsightsSpotlight back to its previous state.
- Spec v1.29 and v1.30 (header version corrected), README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-02 17:35:23 +01:00
1 parent 9fb7f0ca77
commit 9211c8a395
10 files changed
+171 -37

No files matched your search

@@ -136,6 +136,19 @@ final class MenuBuilder
'columns' => $columns,
];
// Page-tree display (2026-11-02): same columns, two presentations -
// `columns` side by side, or `rail`: the columns as a list on the
// left, the hovered column's items as image cards on the right
// (`cardSize` regular|compact). Additive: a front end that does not
// know `display` keeps rendering columns.
if ($layout === 'pagetree') {
$display = (string)($entry['tree_display'] ?? 'columns');
$built['display'] = str_starts_with($display, 'rail') ? 'rail' : 'columns';
if ($built['display'] === 'rail') {
$built['cardSize'] = $display === 'rail-compact' ? 'compact' : 'regular';
}
}
// Catalog panels: the teaser cards serve as the featured tiles (left
// column), the catalog data itself comes from a listener of
// BuildCatalogEvent - this extension does not know the data source.
@@ -229,10 +242,20 @@ final class MenuBuilder
foreach ($this->pageRepository->getMenu($rootUid, '*', 'sorting', self::NAV_WHERE) as $child) {
$items = [];
foreach ($this->pageRepository->getMenu((int)$child['uid'], '*', 'sorting', self::NAV_WHERE) as $grandchild) {
$items[] = [
$item = [
'title' => (string)(($grandchild['nav_title'] ?? '') !== '' ? $grandchild['nav_title'] : $grandchild['title']),
'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj),
];
// card data for the `rail` display (2026-11-02), same rules
// as the spotlight nav: abstract as teaser, page media image
if ($stage >= 2) {
$teaser = trim((string)($grandchild['abstract'] ?? ''));
if ($teaser !== '') {
$item['teaser'] = $teaser;
}
$item['image'] = $this->image('pages', 'media', (int)$grandchild['uid']);
}
$items[] = $item;
}
$column = [
'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']),
@@ -7,6 +7,8 @@ namespace Evomedien\EvoMegamenuJson\UserFunc;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Service\MenuBuilder;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Cache\CacheDataCollectorInterface;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -24,10 +26,24 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
* TYPO3 v14 hands the ContentObjectRenderer in through the setter rather
* than the constructor; without it $this->cObj stays null and the element
* path cannot see which record it is rendering.
*
* Cache (2026-11-02): the menu is part of every cached page, but its records
* live on a storage folder - saving them cleared only that folder, and every
* page cached before kept the old menu until a full flush. The page is
* therefore tagged with the menu tables; the DataHandler flushes the tags
* `<table>` and `<table>_<uid>` of every record it writes, so any save in the
* menu refreshes all pages carrying it. Data the panels read from the page
* tree (pagetree, catalog, spotlight) is not covered and still needs a flush.
*/
final class MegamenuJsonRenderer
{
private const CTYPE = 'evomegamenujson_megamenu';
private const RECORD_TABLES = [
'tx_evomegamenujson_entry',
'tx_evomegamenujson_column',
'tx_evomegamenujson_item',
'tx_evomegamenujson_teaser',
];
protected ?ContentObjectRenderer $cObj = null;
@@ -63,6 +79,7 @@ final class MegamenuJsonRenderer
}
$menu = GeneralUtility::makeInstance(MenuBuilder::class)->build($uid, $cObj, $row);
$this->tagPageCache($cObj, $uid);
return (string)json_encode($menu, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable) {
@@ -71,6 +88,21 @@ final class MegamenuJsonRenderer
}
}
/** Tags the page cache with the menu element and tables (see class comment). */
private function tagPageCache(ContentObjectRenderer $cObj, int $contentUid): void
{
try {
$collector = $cObj->getRequest()->getAttribute('frontend.cache.collector');
if (!$collector instanceof CacheDataCollectorInterface) {
return;
}
$tags = array_map(static fn(string $table): CacheTag => new CacheTag($table), self::RECORD_TABLES);
$collector->addCacheTags(new CacheTag('tt_content_' . $contentUid), ...$tags);
} catch (\Throwable) {
// without tags the menu still renders, it just ages until a flush
}
}
/** uid of the first megamenu element on a storage folder, 0 when none. */
private function firstElementOn(int $pid): int
{
@@ -43,7 +43,7 @@ return [
'1' => [
'showitem' => '
--div--;' . $ll . 'tab.general,
hidden, title, link, layout,
hidden, title, link, layout, tree_display,
--div--;' . $ll . 'tab.columns,
menu_columns,
--div--;' . $ll . 'tab.teasers,
@@ -115,6 +115,22 @@ return [
],
],
],
'tree_display' => [
// page-tree panels only: same data, two presentations
'displayCond' => 'FIELD:layout:=:pagetree',
'label' => $ll . 'tree_display',
'description' => $ll . 'tree_display.description',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 'columns',
'items' => [
['label' => $ll . 'tree_display.columns', 'value' => 'columns'],
['label' => $ll . 'tree_display.rail', 'value' => 'rail'],
['label' => $ll . 'tree_display.rail-compact', 'value' => 'rail-compact'],
],
],
],
'menu_columns' => [
'label' => $ll . 'menu_columns',
+13 -1
View File
@@ -125,7 +125,15 @@ page, its children as the items. Hidden or nav-hidden pages leave the menu
with the next cache flush. Curated columns are ignored; teasers and the
featured slot stay editorial. From stage 2 a child page with media gets its
first image as `image` on the column - attach images in the page
properties and the panel turns visual by itself.
properties and the panel turns visual by itself. From stage 2 the items
carry `image` (first page media or `null`) and, when set, the page
`abstract` as `teaser`.
The entry field *Page tree display* picks the presentation and travels as
`display`: `columns` (default, columns side by side) or `rail` (the columns
as a list on the left, the hovered column's items as image cards on the
right) plus `cardSize` (`regular` | `compact`) for `rail`. A front end that
ignores `display` keeps rendering columns.
### Layout `spotlight`
@@ -163,5 +171,9 @@ The VITEC site package ships `ProvideInsightsSpotlight`, which supplies
entry.
* The renderer never throws: a broken menu returns an empty value rather than
taking the page payload down.
* Caching: every page carrying the menu is tagged with the menu tables and
`tt_content_<uid>` of the menu element, so saving any menu record refreshes
all of them. Panels built from the page tree (`pagetree`, `catalog`,
`spotlight`) pick up page changes with the next cache flush.
* Without `friendsoftypo3/headless` the element has no JSON envelope to render
into — the extension is built for headless installations.
@@ -27,6 +27,11 @@
<trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.pagetree"><source>Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.spotlight"><source>Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display"><source>Page tree display</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.description"><source>Columns: one column per child page with its children as links. List + cards: the child pages as a list on the left, the children of the hovered one as image cards on the right (images from the page media).</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.columns"><source>Columns side by side</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.rail"><source>List on the left, cards on hover</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.rail-compact"><source>List on the left, small cards on hover (for many sub-pages)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_columns"><source>Columns</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_columns.description"><source>One column per group. In the teaser layout only the first column is rendered, next to the cards.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_teasers"><source>Teaser cards</source></trans-unit>
@@ -8,6 +8,7 @@ CREATE TABLE tx_evomegamenujson_entry (
title varchar(255) DEFAULT '' NOT NULL,
link varchar(1024) DEFAULT '' NOT NULL,
layout varchar(30) DEFAULT 'columns' NOT NULL,
tree_display varchar(30) DEFAULT 'columns' NOT NULL,
menu_columns int(11) DEFAULT '0' NOT NULL,
menu_teasers int(11) DEFAULT '0' NOT NULL,
featured_enable tinyint(1) unsigned DEFAULT '0' NOT NULL,