Skip to content

Commit 52ca386

Browse files
committed
Add backend hints for missing Turbo entries
1 parent feb0ab7 commit 52ca386

12 files changed

Lines changed: 545 additions & 0 deletions

File tree

.docs/build/DECISIONS.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,3 +449,63 @@ Auch Feature 1 erfordert eine Schemaergänzung (FEATURES.md §0.6 ist entspreche
449449
Das neue Feld in `tl_content` wird durch Contaos reguläre Schema-Migration
450450
angelegt (Doctrine integer, unsigned, Default 0), wie im Feature-1-Prompt
451451
explizit verlangt. Keine eigene Migrationsklasse ist nötig.
452+
453+
## D15: Backend-Hinweis bei fehlendem Turbo-Entry (17.09.2026)
454+
455+
Drei `config.onload`-Callbacks prüfen Q&A-Inhaltselemente im Edit-Modus,
456+
Bühnenseiten im Edit-Modus sowie den Einstieg ins Q&A-Modul. Ein gemeinsamer
457+
Prüfservice erkennt beide Turbo-Entries über die Konstanten `DEFAULT` und
458+
`NO_DRIVER`; ein Meldungsservice übersetzt die Info und verhindert doppelte
459+
Meldungen pro Request. Die notwendige veränderliche Guard-Eigenschaft ist die
460+
begründete Ausnahme von `readonly`. Ein erfolgreicher Check verbraucht den
461+
Guard nicht, damit ein späterer fehlender Entry noch gemeldet werden kann.
462+
463+
Alternative `onsubmit`: Ein Hinweis erst nach dem Speichern käme zu spät und
464+
würde den Moduleinstieg nicht abdecken. Andere Inhaltstypen verlassen den
465+
Callback vor jeder Modell- oder Schemaabfrage. Composer und Service-Konfiguration
466+
bleiben unverändert, das Encore-Bundle wird nicht als Klasse referenziert.
467+
468+
### Nachgelesene Belege
469+
470+
Die folgenden Encore-Pfade liegen ausdrücklich im **DDEV-Projekt** unter
471+
`/home/dev/Kunden/contao/contao_0507/vendor/heimrichhannot/contao-encore-bundle/`:
472+
473+
| Fakt | Pfad |
474+
| --- | --- |
475+
| Feldname `encoreEntries` | `src/Dca/EncoreEntriesSelectField.php::NAME_DEFAULT` |
476+
| `blob NULL`, serialisierte Zeilen mit `entry` und optional `active` | `src/EventListener/DcaField/EncoreEntriesSelectFieldListener.php::onLoadDataContainer()` |
477+
| Aktiv-Checkbox für beide Tabellen | `contao/dca/tl_layout.php`, `contao/dca/tl_page.php` |
478+
| Fehlendes oder null `active` gilt als aktiv, sonst PHP-Truthy | `src/Asset/PageEntrypoints.php::generatePageEntrypoints()`; aktueller Nachfolger `src/EntryPoint/EntryPointsBuilder.php::build()` verwendet `active ?? true` |
479+
| Layout und Seitenkette werden gesammelt | `src/Asset/PageEntrypoints.php::collectPageEntries()`, `src/EntryPoint/EntryPointsBuilder.php::build()` |
480+
| `addEncore` ist Voraussetzung für die gesamte Seite | `src/Helper/ConfigurationHelper.php::isEnabledOnPage()`; `src/DataContainer/LayoutContainer.php::onLoadCallback()` |
481+
482+
Im Bundle-Repository nachgelesen:
483+
484+
| API | Pfad unter `vendor/` |
485+
| --- | --- |
486+
| Turbo-Konstanten | `heimrichhannot/contao-ux-turbo-encore/src/EncoreExtension.php` |
487+
| Info-Meldung und Adapter-Vorbild | `contao/core-bundle/contao/library/Contao/Message.php::addInfo()`, `contao/core-bundle/src/EventListener/DataContainer/LegacyTemplatesListener.php` |
488+
| Callback-Attribut, Request- und Record-Zugriff | `contao/core-bundle/src/DependencyInjection/Attribute/AsCallback.php`, `contao/core-bundle/src/EventListener/DataContainer/PreviewLinkListener.php` |
489+
| Record ist Array oder null | `contao/core-bundle/contao/classes/DataContainer.php::getCurrentRecord()`, `contao/core-bundle/contao/drivers/DC_Table.php::getCurrentRecord()` |
490+
| Vererbtes Layout und Trail | `contao/core-bundle/contao/models/PageModel.php::findWithDetails()` / `loadDetails()` |
491+
| Modelle, rohe optionale Felder, Deserialisierung | `contao/core-bundle/contao/models/ArticleModel.php`, `contao/core-bundle/contao/models/LayoutModel.php`, `contao/core-bundle/contao/library/Contao/Model.php::findById()` / `row()`, `contao/core-bundle/contao/library/Contao/StringUtil.php::deserialize()` |
492+
| Testbare Adapter | `contao/core-bundle/src/Framework/ContaoFramework.php::getAdapter()`, `contao/core-bundle/src/Framework/Adapter.php` |
493+
494+
### Präzisierungen gegenüber dem Prompt
495+
496+
* Ohne `addEncore` im effektiven Layout sind auch Seiten-Entries unwirksam:
497+
Die Seitenprüfung gibt deshalb sofort false zurück, statt nur den Layout-Blob
498+
zu überspringen. Die globale Prüfung bleibt die spezifizierte Suche nach
499+
Konfiguration, ohne Zuordnung sämtlicher Seiten zu Layouts.
500+
* Der Session-Callback benötigt keinen Datensatz und keinen Edit-Modus; sonst
501+
würde die gemeinsame Record-Regel des Prompts den ausdrücklich verlangten
502+
Hinweis beim Moduleinstieg verhindern. Ohne Request tut er nichts.
503+
* `trail` enthält auch die Seite selbst und gegebenenfalls 0. Diese werden
504+
übersprungen; die Seite wird zuletzt direkt geprüft.
505+
* Schema-Spalten werden einmal je globaler Prüfung für **beide** Tabellen
506+
geprüft, einschließlich `addencore`. Optionale Modellfelder werden über
507+
`row()` gelesen; fehlende Encore-Felder verursachen keine Fehler.
508+
* `PageEntrypoints` ist im installierten Vendor als deprecated markiert. Der
509+
aktuelle `EntryPointsBuilder` bestätigt die verwendete Zeilen-Semantik.
510+
Wie §3.3 vorgegeben prüft der Service das Vorhandensein aktiver Zeilen,
511+
nicht den fertigen Build oder programmatische Encore-Event-Overrides.

.docs/build/SPEC.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,3 +1020,13 @@ Die Bühne gruppiert unbeantwortete vor beantworteten Fragen und erhält die
10201020
gewählte Sortierung je Gruppe. Teilnehmer behalten ihre Vote-Sortierung mit
10211021
Text-Badge und ohne Vote-Button an beantworteten Fragen. Geschlossene Sessions
10221022
zeigen Gruppen und Badges ohne Aktionen. Kein Antworttext oder Antwortzeitpunkt.
1023+
1024+
## Backend-Hinweis zur Turbo-Konfiguration (Feature 3)
1025+
1026+
Beim Bearbeiten von Q&A-Inhaltselementen und Bühnenseiten wird die wirksame
1027+
Seite auf einen aktiven Turbo-Entry in Layout, Elternseiten oder Seite geprüft.
1028+
Das wirksame Layout muss `addEncore` aktivieren. Im Q&A-Modul sowie bei nicht
1029+
bestimmbarer Seite erfolgt die globale Konfigurationsprüfung. Fehlt ein Entry,
1030+
erscheint höchstens eine übersetzte Info-Meldung pro Request. Andere Element-
1031+
und Seitentypen lösen keine Prüfung aus. Es gibt weder automatische Aktivierung
1032+
noch Frontend-Hinweis oder Build-Prüfung (FEATURES.md §3, Entscheidung D15).

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ head scripts exposing the instance as `window.Turbo`, which the Q&A entry
3232
reuses instead of bundling a second copy. If neither is active, the Q&A
3333
JavaScript logs an error to the browser console.
3434

35+
The back end shows an informational hint when editing Q&A content elements or
36+
stage pages if no Turbo entry is active for that page. The Q&A module also
37+
checks whether a Turbo entry is configured anywhere in the project. Page-level
38+
entries require Encore to be enabled in the effective layout (`addEncore`).
39+
The hint checks configuration only, not the generated Encore build.
40+
3541
Consequence: the host project needs an Encore build (`yarn`/`webpack`). See
3642
the Encore Bundle documentation for the project setup.
3743

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HeimrichHannot\QnaBundle\Asset;
6+
7+
use Contao\CoreBundle\Framework\ContaoFramework;
8+
use Contao\LayoutModel;
9+
use Contao\PageModel;
10+
use Contao\StringUtil;
11+
use Doctrine\DBAL\Connection;
12+
use HeimrichHannot\ContaoUxTurboEncore\EncoreExtension as TurboEncoreExtension;
13+
14+
final readonly class TurboEntryAvailability
15+
{
16+
public function __construct(private ContaoFramework $framework, private Connection $connection)
17+
{
18+
}
19+
20+
public function isActiveForPage(PageModel $page): bool
21+
{
22+
$page->loadDetails();
23+
$layout = $this->framework->getAdapter(LayoutModel::class)->findById($page->layout);
24+
25+
if (null === $layout || !($layout->row()['addEncore'] ?? false)) {
26+
return false;
27+
}
28+
29+
if ($this->containsActiveTurboEntry($layout->row()['encoreEntries'] ?? null)) {
30+
return true;
31+
}
32+
33+
foreach ($page->trail as $id) {
34+
if ((!\is_int($id) && !\is_string($id)) || (int) $id <= 0 || (int) $id === (int) $page->id) {
35+
continue;
36+
}
37+
38+
$parent = $this->framework->getAdapter(PageModel::class)->findById($id);
39+
if (null !== $parent && $this->containsActiveTurboEntry($parent->row()['encoreEntries'] ?? null)) {
40+
return true;
41+
}
42+
}
43+
44+
return $this->containsActiveTurboEntry($page->row()['encoreEntries'] ?? null);
45+
}
46+
47+
public function isActiveAnywhere(): bool
48+
{
49+
$schema = $this->connection->createSchemaManager();
50+
$layoutColumns = $schema->listTableColumns('tl_layout');
51+
$pageColumns = $schema->listTableColumns('tl_page');
52+
53+
if (!isset($layoutColumns['encoreentries'], $layoutColumns['addencore'], $pageColumns['encoreentries'])) {
54+
return false;
55+
}
56+
57+
foreach (["SELECT encoreEntries FROM tl_layout WHERE addEncore = '1'", 'SELECT encoreEntries FROM tl_page WHERE encoreEntries IS NOT NULL'] as $sql) {
58+
foreach ($this->connection->iterateColumn($sql) as $blob) {
59+
if ($this->containsActiveTurboEntry($blob)) {
60+
return true;
61+
}
62+
}
63+
}
64+
65+
return false;
66+
}
67+
68+
private function containsActiveTurboEntry(mixed $blob): bool
69+
{
70+
foreach ($this->framework->getAdapter(StringUtil::class)->deserialize($blob, true) as $row) {
71+
if (\is_array($row) && \in_array($row['entry'] ?? null, [TurboEncoreExtension::DEFAULT, TurboEncoreExtension::NO_DRIVER], true) && ($row['active'] ?? true)) {
72+
return true;
73+
}
74+
}
75+
76+
return false;
77+
}
78+
}

src/Asset/TurboHintMessenger.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HeimrichHannot\QnaBundle\Asset;
6+
7+
use Contao\CoreBundle\Framework\ContaoFramework;
8+
use Contao\Message;
9+
use Contao\PageModel;
10+
use Symfony\Contracts\Translation\TranslatorInterface;
11+
12+
/** The mutable request guard prevents duplicate hints from multiple callbacks. */
13+
final class TurboHintMessenger
14+
{
15+
private bool $warned = false;
16+
17+
public function __construct(private readonly TurboEntryAvailability $availability, private readonly ContaoFramework $framework, private readonly TranslatorInterface $translator)
18+
{
19+
}
20+
21+
public function warn(?PageModel $page): void
22+
{
23+
if ($this->warned) {
24+
return;
25+
}
26+
27+
if (null !== $page ? $this->availability->isActiveForPage($page) : $this->availability->isActiveAnywhere()) {
28+
return;
29+
}
30+
31+
$this->warned = true;
32+
$key = null !== $page ? 'qna.backend.turbo_missing_page' : 'qna.backend.turbo_missing_global';
33+
$this->framework->getAdapter(Message::class)->addInfo($this->translator->trans($key, [], 'contao_default'));
34+
}
35+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HeimrichHannot\QnaBundle\EventListener\DataContainer\Content;
6+
7+
use Contao\ArticleModel;
8+
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
9+
use Contao\CoreBundle\Framework\ContaoFramework;
10+
use Contao\DataContainer;
11+
use Contao\PageModel;
12+
use HeimrichHannot\QnaBundle\Asset\TurboHintMessenger;
13+
use Symfony\Component\HttpFoundation\RequestStack;
14+
15+
#[AsCallback(table: 'tl_content', target: 'config.onload')]
16+
final readonly class ConfigOnloadListener
17+
{
18+
public function __construct(private RequestStack $requestStack, private ContaoFramework $framework, private TurboHintMessenger $messenger)
19+
{
20+
}
21+
22+
public function __invoke(DataContainer $dataContainer): void
23+
{
24+
if ('edit' !== $this->requestStack->getCurrentRequest()?->query->get('act')) {
25+
return;
26+
}
27+
28+
$record = $dataContainer->getCurrentRecord();
29+
if (null === $record || !\in_array($record['type'] ?? null, ['qna_session_list', 'qna_session_reader'], true)) {
30+
return;
31+
}
32+
33+
$page = null;
34+
$pid = $record['pid'] ?? null;
35+
if ('tl_article' === ($record['ptable'] ?? null) && (\is_int($pid) || \is_string($pid))) {
36+
$article = $this->framework->getAdapter(ArticleModel::class)->findById($pid);
37+
if (null !== $article) {
38+
$page = $this->framework->getAdapter(PageModel::class)->findWithDetails($article->pid);
39+
}
40+
}
41+
42+
$this->messenger->warn($page);
43+
}
44+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HeimrichHannot\QnaBundle\EventListener\DataContainer\Page;
6+
7+
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
8+
use Contao\CoreBundle\Framework\ContaoFramework;
9+
use Contao\DataContainer;
10+
use Contao\PageModel;
11+
use HeimrichHannot\QnaBundle\Asset\TurboHintMessenger;
12+
use Symfony\Component\HttpFoundation\RequestStack;
13+
14+
#[AsCallback(table: 'tl_page', target: 'config.onload')]
15+
final readonly class ConfigOnloadListener
16+
{
17+
public function __construct(private RequestStack $requestStack, private ContaoFramework $framework, private TurboHintMessenger $messenger)
18+
{
19+
}
20+
21+
public function __invoke(DataContainer $dataContainer): void
22+
{
23+
if ('edit' !== $this->requestStack->getCurrentRequest()?->query->get('act')) {
24+
return;
25+
}
26+
27+
$record = $dataContainer->getCurrentRecord();
28+
if (null === $record || 'qna_stage' !== ($record['type'] ?? null)) {
29+
return;
30+
}
31+
32+
$id = $record['id'] ?? null;
33+
$page = \is_int($id) || \is_string($id) ? $this->framework->getAdapter(PageModel::class)->findWithDetails($id) : null;
34+
$this->messenger->warn($page);
35+
}
36+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HeimrichHannot\QnaBundle\EventListener\DataContainer\Session;
6+
7+
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
8+
use Contao\DataContainer;
9+
use HeimrichHannot\QnaBundle\Asset\TurboHintMessenger;
10+
use Symfony\Component\HttpFoundation\RequestStack;
11+
12+
#[AsCallback(table: 'tl_qna_session', target: 'config.onload')]
13+
final readonly class ConfigOnloadListener
14+
{
15+
public function __construct(private RequestStack $requestStack, private TurboHintMessenger $messenger)
16+
{
17+
}
18+
19+
public function __invoke(DataContainer $dataContainer): void
20+
{
21+
if (null !== $this->requestStack->getCurrentRequest()) {
22+
$this->messenger->warn(null);
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)