Skip to content

Commit f8a8062

Browse files
committed
Fix manifest missing component IRIs and Layout componentGroups embedding
Two bugs fixed: 1. Layout.componentGroups was returning embedded objects instead of IRI strings. AP4 reads readableLink from getter methods, not property declarations — override getComponentGroups() in Layout with #[ApiProperty(readableLink: false, writableLink: false)] and add explicit normalization/denormalization context with Layout:read/write serialization groups. 2. resource_manifest was missing component IRIs from ComponentPositions that use pageDataProperty. AP4 auto-computes readableLink=false for ComponentPosition.component (AbstractComponent has no Route:manifest:read fields), so AP4 returns the component as an IRI string rather than an embedded object. ManifestDepthGroupTrait was only collecting @id values from embedded arrays, not string IRIs. Fix: collect top-level string IRIs in collectCurrentDepth, but skip blank node resources (/.well-known/genid/) since AP4 assigns genid @ids to plain objects like ResourceMetadata — their string properties are internal metadata, not consumer-facing API resources. PageDataNormalizer injects cwa_current_page_data into context when serializing under Route:manifest:read so ComponentPositionNormalizer can resolve pageDataProperty slots without an HTTP request.
1 parent 028c742 commit f8a8062

8 files changed

Lines changed: 207 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,23 @@ Key current group assignments:
112112
- `AbstractPageData`: `page` (the Page template IRI) → `Route:manifest:read`
113113
- `AbstractPage`: `route`, `parentPage`, `parentPageData``Route:manifest:read`
114114

115+
### Bug: `Layout.componentGroups` returns embedded objects instead of IRIs
116+
117+
**Symptom (discovered 2026-06-16):** The navigation bar is empty for unauthenticated users. No failed requests appear in the network tab — the layout's component group contents are simply never fetched.
118+
119+
**Root cause:** `GET /_api/_/layouts/{uuid}` returns `componentGroups` as **full embedded objects**:
120+
```json
121+
"componentGroups": [{ "@id": "/_api/_/component_groups/...", "location": "top", "componentPositions": [...] }]
122+
```
123+
124+
The module's `fetchAssociatedResources` expects all associated property values to be **IRI strings** (this is both the module's contract and the caching architecture principle: "Never embed related resource data — always return IRIs"). Receiving objects instead of strings causes a silent TypeError (`object.split is not a function`) that is swallowed by `fetchBatch`, so the component groups are never stored and `CwaComponentGroup` finds nothing.
125+
126+
**Why page content still works:** The manifest response includes page component group IRIs directly in `resource_iris`, so they are fetched as standalone resources in the manifest batch. Layouts have no manifest and rely entirely on `fetchAssociatedResources`.
127+
128+
**Required fix:** Change the `Layout` serialization group so `componentGroups` is serialized as an array of IRI strings only (not embedded objects). Check `ComponentGroup.componentPositions` for the same issue — the module also expects these to be IRI strings.
129+
130+
The module will be updated with a defensive `@id` extraction as a fallback, but the correct fix is here: the API must return IRIs, not embedded objects, for all associated resource properties.
131+
115132
### API endpoints
116133

117134
| Endpoint | Purpose |
@@ -196,6 +213,139 @@ Currently `parentPage` is only in `Route:manifest:read`. It needs to be added to
196213

197214
A Behat test should cover: `GET /_/pages` response includes `parentPage` for a page that has one set.
198215

216+
### Outstanding — UUID-based manifest must walk the `parentPage` chain
217+
218+
**Bug (discovered 2026-06-16):** When the Nuxt module admin accesses a nested `Page` entity directly via its admin URL (e.g. `/_cwa/%2F_api%2F_%2Fpages%2F{child-uuid}`), the fetcher calls `GET /_api/_/resource_manifest/{child-uuid}`. The module code is correct: it uses `irisByDepth[0]` as the parent depth and renders `pageIriAtDepth(depth)` for each level. However, the admin admin page displays only a placeholder (no parent content) because the manifest endpoint currently returns only the accessed page in a single depth group — it does not walk the `parentPage`/`parentPageData` chain upward.
219+
220+
**Required fix:** `ResourceManifestNormalizer` (or `ResourceManifestStateProvider`) when resolving by Page UUID must walk the `parentPage`/`parentPageData` chain to the root and produce `resource_iris: string[][]` with one inner array per depth level, root first — exactly as the route-path path does when the manifest normalizer walks the embedded parent sub-tree via the `Route:manifest:read` group.
221+
222+
For a chapter `Page` entity whose `parentPage` is a topic `Page`:
223+
```json
224+
{
225+
"resource_iris": [
226+
["/_/pages/topic-uuid", "/_/component_groups/...", ...],
227+
["/_/pages/chapter-uuid", "/_/component_groups/...", ...]
228+
]
229+
}
230+
```
231+
232+
The fix should mirror what `RouteNormalizer` does when following `parentPage`/`parentPageData` during route-based manifest generation. The `ManifestDepthGroupTrait` `buildDepthGroups` should already handle this if the correct sub-tree is passed in — check whether `ResourceManifestNormalizer` is passing the full serialized entity (including embedded parent data) or only the top-level page object.
233+
234+
A Behat test should cover: `GET /_/resource_manifest/{child-page-uuid}` for a page with `parentPage` set returns `resource_iris` with two depth groups (parent resources first, child resources last).
235+
236+
---
237+
238+
## Feature: CwaFixtureBuilder
239+
240+
> **Status: Design agreed, not yet implemented.**
241+
242+
A fluent builder API that lets developers scaffold CWA website structure (layouts, pages, component groups, components, routes) in Doctrine fixture code with minimal boilerplate. The Doctrine Fixtures Bundle handles execution; this feature adds the ergonomic PHP API on top.
243+
244+
### Dream developer API
245+
246+
```php
247+
class AppScaffold extends AbstractCwaScaffold
248+
{
249+
public function build(CwaFixtureBuilder $cwa): void
250+
{
251+
$cwa->layout('default', 'CwaLayout', function(LayoutBuilder $layout) {
252+
$layout->group('navigation', fn(GroupBuilder $g) => $g
253+
->add(new NavigationLink('Home', '/'))
254+
->add(new NavigationLink('Blog', '/blog'))
255+
);
256+
});
257+
258+
$cwa->page('home', 'HomePage', layout: 'default', route: '/', function(PageBuilder $page) {
259+
$page->group('hero', fn(GroupBuilder $g) => $g->add(new HtmlContent('<h1>Welcome</h1>')));
260+
$page->group('body', fn(GroupBuilder $g) => $g->add(new HtmlContent('Intro text')));
261+
});
262+
263+
// Complex pages extract cleanly to private methods via PHP 8.1 first-class callables
264+
$cwa->page('blog', 'BlogPage', layout: 'default', route: '/blog', $this->buildBlog(...));
265+
266+
$cwa->page('conference', 'ConferencePage', layout: 'default', route: '/conference', $this->buildConference(...));
267+
}
268+
269+
private function buildBlog(PageBuilder $page): void
270+
{
271+
$page->group('listing', fn(GroupBuilder $g) => $g->add(new Collection()));
272+
$page->nested($this->buildBlogArticles(...));
273+
}
274+
275+
private function buildBlogArticles(CwaFixtureBuilder $cwa): void
276+
{
277+
foreach ($this->articles() as $data) {
278+
// route auto-generated: /blog/first-post (parent path + slug from title via RouteGenerator)
279+
$cwa->pageData(new BlogArticleData(title: $data['title']), template: 'blog-article');
280+
}
281+
}
282+
283+
private function buildConference(PageBuilder $page): void
284+
{
285+
$page->group('details', fn(GroupBuilder $g) => $g->add(new HtmlContent('Details')));
286+
$page->nested(function(CwaFixtureBuilder $cwa) {
287+
// /conference/programme, /conference/speakers — auto-prefixed via RouteGenerator
288+
$cwa->pageData(new ConferenceData(title: 'Programme'), template: 'conference-section');
289+
$cwa->pageData(new ConferenceData(title: 'Speakers'), template: 'conference-section');
290+
});
291+
}
292+
}
293+
```
294+
295+
### Integration — `AbstractCwaScaffold` IS the fixture
296+
297+
```php
298+
abstract class AbstractCwaScaffold implements FixtureInterface
299+
{
300+
public function __construct(private CwaFixtureBuilder $cwa) {}
301+
302+
public function load(ObjectManager $manager): void
303+
{
304+
$this->build($this->cwa->withManager($manager));
305+
}
306+
307+
abstract public function build(CwaFixtureBuilder $cwa): void;
308+
}
309+
```
310+
311+
Register `AppScaffold` as a service; it's ready to use as a Doctrine fixture with no extra boilerplate.
312+
313+
### Builder shape
314+
315+
```
316+
CwaFixtureBuilder
317+
->layout(ref, uiComponent, ?Closure) → LayoutBuilder
318+
->group(name, ?allow[], Closure) → LayoutBuilder (closure receives GroupBuilder)
319+
->page(ref, uiComponent, layout, ?route, ?Closure) → PageBuilder
320+
->group(name, Closure) → PageBuilder
321+
->nested(Closure) → PageBuilder (CwaFixtureBuilder in closure has parent context)
322+
->pageData(AbstractPageData, ?template, ?Closure) → PageDataBuilder
323+
->route(path) → PageDataBuilder
324+
325+
GroupBuilder
326+
->add(AbstractComponent, ?sort) → GroupBuilder (sort defaults to insertion order)
327+
```
328+
329+
### Route auto-generation rules
330+
331+
| Situation | Result |
332+
|---|---|
333+
| `route: '/'` explicit | uses that path |
334+
| no `route:` on `->page()` | no Route created (it's a template) |
335+
| `->pageData(...)` inside `->nested()`, no route | calls `RouteGenerator` with parent context → `/parent-path/slug-from-title` |
336+
| `->pageData(...)` at top level, no route | no Route created (draft) |
337+
338+
### What the builder handles invisibly
339+
340+
- `TimestampedDataPersister` called on each entity
341+
- `$manager->persist()` for all entities
342+
- Deduplication by reference (call `->layout('default', ...)` twice → same entity returned)
343+
- `ComponentPosition` wrapping and sort order
344+
- `setRoute()` / `setPageData()` bidirectional linking
345+
- Parent context propagation through `->nested()` so `parentPage`/`parentPageData` is set automatically
346+
347+
---
348+
199349
### Design decisions
200350

201351
- **No `$nested` boolean** — parent = nested, full stop. The presence of `$parentPage`/`$parentPageData` is the complete signal.

features/main/layout.feature

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,10 @@ Feature: Layout resources
109109
Then the response status code should be 200
110110
And the JSON node "member" should have "1" elements
111111
And the JSON node "member[0].reference" should be equal to "primary"
112+
113+
@loginUser
114+
Scenario: Layout componentGroups are returned as IRI strings, not embedded objects
115+
Given there is a ComponentGroup in a Page and a Layout
116+
When I send a "GET" request to the resource "layout"
117+
Then the response status code should be 200
118+
And the JSON node "componentGroups[0]" should be equal to the IRI of the resource "component_group"

features/main/route.feature

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ Feature: Route resources
126126
Then the response status code should be 200
127127
And the JSON node "route" should be equal to the string "/_/routes//my-route"
128128

129+
Scenario: The manifest includes component IRIs resolved from pageDataProperty positions
130+
Given there is a PageData resource with the route path "/my-route"
131+
When I send a "GET" request to "/_/resource_manifest//my-route"
132+
Then the response status code should be 200
133+
And the JSON node "resource_iris[0][5]" should match the regex "/\/component\/dummy_components\/[a-z0-9\-]+/"
134+
129135
Scenario: The manifest for a nested PageData route includes parent resource IRIs grouped by depth
130136
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
131137
When I send a "GET" request to "/_/resource_manifest//conference/programme"

src/Entity/Core/Layout.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Silverback\ApiComponentsBundle\Entity\Utility\UiTrait;
2525
use Silverback\ApiComponentsBundle\Filter\OrSearchFilter;
2626
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
27+
use Symfony\Component\Serializer\Attribute\Groups;
2728
use Symfony\Component\Validator\Constraints as Assert;
2829
use Symfony\Component\Validator\Mapping\ClassMetadata;
2930

@@ -33,7 +34,12 @@
3334
#[ORM\Entity]
3435
#[ORM\Table(name: 'layout')]
3536
#[Silverback\Timestamped]
36-
#[ApiResource(mercure: true, order: ['createdAt' => 'DESC'])]
37+
#[ApiResource(
38+
normalizationContext: ['groups' => ['Layout:read']],
39+
denormalizationContext: ['groups' => ['Layout:write']],
40+
mercure: true,
41+
order: ['createdAt' => 'DESC'],
42+
)]
3743
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'reference'], arguments: ['orderParameterName' => 'order'])]
3844
#[ApiFilter(OrSearchFilter::class, properties: ['reference' => 'ipartial', 'uiComponent' => 'ipartial'])]
3945
#[UniqueEntity(fields: ['reference'], message: 'There is already a Layout with that reference.')]
@@ -45,15 +51,18 @@ class Layout
4551

4652
#[ORM\Column]
4753
#[Assert\NotBlank(message: 'Please enter a reference.')]
54+
#[Groups(['Layout:read', 'Layout:write'])]
4855
public string $reference;
4956

5057
#[ORM\OneToMany(targetEntity: Page::class, mappedBy: 'layout')]
5158
#[ApiProperty(writable: false)]
59+
#[Groups(['Layout:read'])]
5260
public Collection $pages;
5361

5462
#[ORM\ManyToMany(targetEntity: ComponentGroup::class, inversedBy: 'layouts')]
5563
#[ORM\JoinColumn(onDelete: 'CASCADE')]
5664
#[ORM\InverseJoinColumn(onDelete: 'CASCADE')]
65+
#[Groups(['Layout:read', 'Layout:write'])]
5766
private Collection $componentGroups;
5867

5968
public function __construct()
@@ -63,6 +72,12 @@ public function __construct()
6372
$this->pages = new ArrayCollection();
6473
}
6574

75+
#[ApiProperty(readableLink: false, writableLink: false)]
76+
public function getComponentGroups(): Collection|array
77+
{
78+
return $this->componentGroups;
79+
}
80+
6681
public static function loadValidatorMetadata(ClassMetadata $metadata): void
6782
{
6883
$metadata->addPropertyConstraint('uiComponent', new Assert\NotBlank(

src/Entity/Utility/UiTrait.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Doctrine\Common\Collections\Collection;
1616
use Doctrine\ORM\Mapping as ORM;
1717
use Silverback\ApiComponentsBundle\Entity\Core\ComponentGroup;
18+
use Symfony\Component\Serializer\Attribute\Groups;
1819

1920
/**
2021
* @author Daniel West <daniel@silverback.is>
@@ -24,9 +25,11 @@
2425
trait UiTrait
2526
{
2627
#[ORM\Column(nullable: true)]
28+
#[Groups(['Layout:read', 'Layout:write'])]
2729
public ?string $uiComponent = null;
2830

2931
#[ORM\Column(type: 'json', nullable: true)]
32+
#[Groups(['Layout:read', 'Layout:write'])]
3033
public ?array $uiClassNames = null;
3134

3235
/**

src/Serializer/Normalizer/ComponentPositionNormalizer.php

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public function normalize($object, $format = null, array $context = []): float|a
9393
$staticComponent = $object->component;
9494
$resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object);
9595

96-
$object = $this->normalizeForPageData($object);
96+
$object = $this->normalizeForPageData($object, $context);
9797
if ($object->pageDataProperty) {
9898
$resourceMetadata->setIsDynamicPosition(true);
9999
try {
@@ -142,19 +142,25 @@ private function normalizePublishableComponent(AbstractComponent $component)
142142
return $draft ?? $component;
143143
}
144144

145-
private function normalizeForPageData(ComponentPosition $object): ComponentPosition
145+
private function normalizeForPageData(ComponentPosition $object, array $context): ComponentPosition
146146
{
147-
if (!$object->pageDataProperty || !$this->requestStack->getCurrentRequest()) {
148-
return $object;
149-
}
150-
try {
151-
$pageData = $this->pageDataProvider->getPageData();
152-
} catch (UnprocessableEntityHttpException $e) {
153-
// when serializing for mercure, we do not need the path header
147+
if (!$object->pageDataProperty) {
154148
return $object;
155149
}
156150

157-
if (!$pageData) {
151+
if (isset($context['cwa_current_page_data'])) {
152+
$pageData = $context['cwa_current_page_data'];
153+
} elseif ($this->requestStack->getCurrentRequest()) {
154+
try {
155+
$pageData = $this->pageDataProvider->getPageData();
156+
} catch (UnprocessableEntityHttpException $e) {
157+
// when serializing for mercure, we do not need the path header
158+
return $object;
159+
}
160+
if (!$pageData) {
161+
return $object;
162+
}
163+
} else {
158164
return $object;
159165
}
160166

src/Serializer/Normalizer/PageDataNormalizer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ public function normalize($object, $format = null, array $context = []): float|a
4747
$resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object);
4848
$resourceMetadata->setPageDataMetadata($metadata);
4949

50+
if (\in_array('Route:manifest:read', $context['groups'] ?? [], true)) {
51+
$context['cwa_current_page_data'] = $object;
52+
}
53+
5054
return $this->normalizer->normalize($object, $format, $context);
5155
}
5256

src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,13 @@ private function collectCurrentDepth(array $resource, array $iris, array $parent
4646
$iris[] = $id;
4747
}
4848

49+
$isBlankNode = isset($resource['@id']) && str_contains($resource['@id'], '/.well-known/genid/');
50+
4951
foreach ($resource as $key => $value) {
5052
if (!\is_array($value)) {
53+
if (!$isBlankNode && \is_string($value) && !str_starts_with($key, '@') && str_starts_with($value, '/') && !$this->shouldSkipIri($value) && !\in_array($value, $iris, true)) {
54+
$iris[] = $value;
55+
}
5156
continue;
5257
}
5358

0 commit comments

Comments
 (0)