Skip to content

Commit f85cf3d

Browse files
committed
Add cascadeChildPaths, route children endpoint, and CwaFixtureBuilder
- PATCH /_/routes/{id} accepts cascadeChildPaths: true to update all child route paths sharing the old prefix and create redirects; intermediate flush before creating redirects avoids Doctrine INSERT-before-UPDATE conflict - GET /_/routes/{id}/children returns recursive child tree (route IRI, path, children) for admin UI navigation; admin-only, no Hydra wrapper - CwaFixtureBuilder: layout/page/pageData builders with 4-phase flush, GroupBuilder for component groups and pageDataProperty positions, nested() on PageDataBuilder and PageBuilder to set parent relationships and auto-persist child entities after parent flush
1 parent e634982 commit f85cf3d

20 files changed

Lines changed: 1339 additions & 42 deletions

CLAUDE.md

Lines changed: 22 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -197,71 +197,52 @@ This means:
197197
1. **`$parentPage` and `$parentPageData` on `AbstractPage`**`Assert\Expression` constraint, getters/setters, computed `getParentPageRoute()`, ORM attributes on both `Page` and `AbstractPageData`
198198
2. **`$nested` removed from `AbstractPage`** — property, getter, setter, ORM mapping, and schema entry all removed. Parent = nested, always.
199199
3. **`$route`, `$parentPage`, `$parentPageData` in `Route:manifest:read`** — parent sub-tree IRIs appear in `resource_iris` automatically via the normalizer walk
200-
4. **Behat tests**`features/main/route.feature`: nested PageData and nested Page manifests both tested; `features/main/page.feature`: create with parentPage (201), create with parentPageData (201), both set (422), PATCH to set parentPage (200), flat PageData manifest (200), nested PageData manifest (200)
200+
4. **Behat tests**`features/main/route.feature`: nested PageData and nested Page manifests both tested; `features/main/page.feature`: create with parentPage (201), create with parentPageData (201), both set (422), PATCH to set parentPage (200), flat PageData manifest (200), nested PageData manifest (200), nested Page manifest (200)
201201
5. **`/_/resource_manifest/{id}` unified endpoint**`ResourceManifest` DTO (`src/ApiResource/ResourceManifest.php`) with `ResourceManifestStateProvider` resolving route paths (starts with `/`) or UUIDs (Page then AbstractPageData). `ResourceManifestVoter` delegates access control to `RouteVoter` or `AbstractRoutableVoter`. `ResourceManifestNormalizer` produces `{ "resource_iris": string[][] }` using the shared `ManifestDepthGroupTrait`.
202202
6. **`ManifestDepthGroupTrait`** (`src/Serializer/Normalizer/Trait/ManifestDepthGroupTrait.php`) — `buildDepthGroups`, `collectCurrentDepth`, `shouldSkipIri` extracted and shared between `RouteNormalizer` and `ResourceManifestNormalizer`
203203
7. **`pageDataProperty` component IRIs in manifests**`PageDataNormalizer` injects `cwa_current_page_data` into the serialization context when `Route:manifest:read` is active. `ComponentPositionNormalizer.normalizeForPageData()` reads this context key and resolves `pageDataProperty` positions during manifest generation without requiring an HTTP `path` header. `ManifestDepthGroupTrait.collectCurrentDepth()` now also collects string IRI values from non-blank-node subresources (AP4 returns component IRIs as strings when `AbstractComponent` has no `Route:manifest:read` fields). Blank node resources (`/.well-known/genid/...`) are excluded from string IRI collection to avoid leaking internal metadata IRIs (e.g. `pageDataMetadata`). Behat test in `features/main/route.feature` covers `resource_iris[0][5]` matching a DummyComponent IRI.
204204
8. **`Layout.componentGroups` returns IRI strings** — see fixed bug above. Behat test in `features/main/layout.feature` covers `componentGroups[0]` equal to the component group IRI.
205205

206-
### Outstanding — `parentPage` in standard Page read group
206+
### ~~Outstanding — `parentPage` in standard Page read group~~ — ALREADY WORKS
207207

208-
**Requirement (discovered 2026-06-15):** The Nuxt module's admin parent-page picker must filter out descendants of the current page to prevent circular parent chains (e.g. A → B → A). The picker is populated from `GET /_/pages` (via `useParentPageLoader`). To detect descendants client-side, each page in that collection response must include its own `parentPage` IRI.
208+
`parentPage` already appears in `GET /_/pages` responses for pages that have a parent set. Because `Page` has no explicit `normalizationContext`, AP4 does not inject a `groups` key into the Symfony serializer context. Without a `groups` key, the Symfony serializer ignores all `#[Groups]` annotations and serializes all accessible properties — including `parentPage` via `getParentPage()`. No code change was needed; Behat test added to `features/main/page.feature` for coverage.
209209

210-
Currently `parentPage` is only in `Route:manifest:read`. It needs to be added to whatever serialization group drives the `/_/pages` collection read (e.g. `Page:read` or a shared `AbstractPage:read` group). This satisfies the principle of least exposure — there is a concrete consumer (the admin picker descendant-filter).
210+
### ~~Bug: PATCH `/_/pages/{uuid}` throws 500 when body contains `componentGroups`~~ — FIXED
211211

212-
A Behat test should cover: `GET /_/pages` response includes `parentPage` for a page that has one set.
212+
**Symptom (discovered 2026-06-17):** Saving a Page from the Nuxt admin modal triggered a 500/422 when the PATCH body contained `componentGroups` as embedded JSON-LD objects (AP4 tried to denormalize the component positions collection, which caused `ArrayCollection::$sortValue` access errors).
213213

214-
### Bug: PATCH `/_/pages/{uuid}` throws 500 when body contains `componentGroups`
214+
**Fix (committed 2026-06-17):** Overrode `getComponentGroups()` in `Page.php` with `#[ApiProperty(writable: false)]`. AP4 now ignores `componentGroups` during deserialization — whether sent as IRI strings or embedded objects. Component groups are managed via their own endpoints.
215215

216-
**Symptom (discovered 2026-06-17):** Saving a Page from the Nuxt admin modal returns:
217-
```
218-
500: Warning: Undefined property: Doctrine\Common\Collections\ArrayCollection::$sortValue
219-
```
220-
221-
**Trigger payload:** The Nuxt module admin modal PATCHes the full resource data including `componentGroups` as an array (either IRI strings or embedded JSON-LD objects, depending on what was returned in the GET). Example body:
222-
```json
223-
{
224-
"@type": "Page",
225-
"reference": "My Page",
226-
"layout": "/_/layouts/uuid",
227-
"componentGroups": ["/_/component_groups/uuid1", "/_/component_groups/uuid2"]
228-
}
229-
```
230-
231-
**Likely root cause:** The Symfony deserializer processes the `componentGroups` array and, during denormalization of `ComponentGroup` entities, either a lifecycle callback or the `ComponentPositionNormalizer` accesses `$sortValue` on the `ComponentGroup.componentPositions` `ArrayCollection` as if it were a scalar property — rather than iterating over individual `ComponentPosition` entities. This results in PHP trying to read `$sortValue` on the `ArrayCollection` object itself.
232-
233-
**Where to look:**
234-
- `ComponentGroup` entity — any `#[ORM\PrePersist]` / `#[ORM\PreUpdate]` listener that reads `componentPositions->sortValue`
235-
- `ComponentPositionNormalizer` — any code path triggered during `PATCH` denormalization that accesses the position collection
236-
- Symfony's denormalization of `ComponentGroup.componentPositions` when the PATCH body includes inline `componentGroups`
216+
**Behat tests:** Two scenarios in `features/main/page.feature` — PATCH with IRI-string componentGroups (200), PATCH with embedded componentGroups including positions (200).
237217

238-
**Workaround (Nuxt module, committed 2026-06-17):** `PageAdminModal` passes `excludeFields: ['componentGroups']` to `useItemPage`, so `componentGroups` is stripped from the PATCH body before sending. This bypasses the bug without fixing it. A proper API-side fix is still needed so that PATCH requests including `componentGroups` do not 500.
218+
---
239219

240-
**Test to write:** Behat scenario — `PATCH /_/pages/{uuid}` with `componentGroups` in the request body returns 200, not 500.
241220

242221
---
243222

244-
### Outstanding — UUID-based manifest must walk the `parentPage` chain
223+
## API contracts: Route UI/UX (from Nuxt module design discussion)
224+
225+
### 1. Cascade child path update on route PATCH — COMPLETE ✓
245226

246-
**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.
227+
`PATCH /_/routes/{id}` accepts optional `cascadeChildPaths: true` boolean. When set and `path` changes, `RouteEventListener.onPostWrite` walks direct children via `AbstractPage.parentPage`/`parentPageData`, updates their route paths (prefixing with the new parent path), and creates redirects from old to new child paths. Children whose path does not start with the old prefix are ignored. Intermediate flush required before creating child redirects (Doctrine processes INSERTs before UPDATEs; flushing path changes first frees old paths in the DB). Behat tests in `features/main/route.feature`.
247228

248-
**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.
229+
### 2. Route children endpoint — COMPLETE ✓
249230

250-
For a chapter `Page` entity whose `parentPage` is a topic `Page`:
231+
`GET /_/routes/{id}/children` returns the recursive child tree for a specific route. Admin-only (`ROLE_ADMIN`). Response:
251232
```json
252233
{
253-
"resource_iris": [
254-
["/_/pages/topic-uuid", "/_/component_groups/...", ...],
255-
["/_/pages/chapter-uuid", "/_/component_groups/...", ...]
234+
"children": [
235+
{
236+
"route": "/_/routes//conference/programme",
237+
"path": "/conference/programme",
238+
"children": []
239+
}
256240
]
257241
}
258242
```
243+
Each node embeds its direct children recursively so the full sub-tree is visible in one request. Children are plain objects (not IRIs) since you need the tree structure to navigate it; `route` and `path` are the only data fields per node — all other route detail is fetched via the IRI.
259244

260-
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.
261-
262-
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).
263-
264-
---
245+
Implementation: `RouteChildren` + `RouteChildrenNode` DTOs, `RouteChildrenStateProvider`, `RouteChildrenNormalizer`. Custom `Get` operation added to `Route` at `/routes/{id}/children`. The standard Route `Get` requirement updated to `(?!.+\/(?:redirects|children)$).+` to exclude both sub-resource suffixes. Behat tests in `features/main/route.feature`.
265246

266247
## Feature: CwaFixtureBuilder
267248

features/bootstrap/DoctrineContext.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,29 @@ public function thereIsAComponentGroupInAPage()
367367
$this->manager->flush();
368368
}
369369

370+
/**
371+
* @Given there is a valid Page with a ComponentGroup
372+
*/
373+
public function thereIsAValidPageWithAComponentGroup(): void
374+
{
375+
$layout = $this->thereIsALayout();
376+
377+
$page = new Page();
378+
$page->reference = 'page-with-group';
379+
$page->uiComponent = 'TestComponent';
380+
$page->isTemplate = false;
381+
$page->layout = $layout;
382+
$this->timestampedHelper->persistTimestampedFields($page, true);
383+
$this->manager->persist($page);
384+
385+
$group = $this->thereIsAComponentGroupWithComponents(1);
386+
$page->addComponentGroup($group);
387+
$this->manager->persist($page);
388+
$this->manager->flush();
389+
390+
$this->restContext->resources['page'] = $this->iriConverter->getIriFromResource($page);
391+
}
392+
370393
/**
371394
* @Given /^there is a ComponentGroup in a Page and a Layout$/
372395
*/
@@ -786,10 +809,49 @@ public function thereIsANestedPageResource(string $childPath, string $parentPath
786809
$this->manager->persist($childRoute);
787810
$this->restContext->resources['page'] = $this->iriConverter->getIriFromResource($childPage);
788811
$this->restContext->resources['page_route'] = $this->iriConverter->getIriFromResource($childRoute);
812+
$this->restContext->resources['page_manifest'] = '/_/resource_manifest/' . $childPage->getId();
789813

790814
$this->manager->flush();
791815
}
792816

817+
/**
818+
* @When I patch the page with the component group in the request body
819+
*/
820+
public function iPatchPageWithComponentGroupInBody(): void
821+
{
822+
$cgIri = $this->restContext->resources['component_group'];
823+
$this->restContext->iSendARequestToTheResourceWithBody(
824+
'PATCH',
825+
'page',
826+
null,
827+
new PyStringNode([sprintf('{"componentGroups": ["%s"]}', $cgIri)], 0)
828+
);
829+
}
830+
831+
/**
832+
* @When I patch the page with an embedded component group in the request body
833+
*/
834+
public function iPatchPageWithEmbeddedComponentGroupInBody(): void
835+
{
836+
$cgIri = $this->restContext->resources['component_group'];
837+
$posIri = $this->restContext->resources['position_0'];
838+
$body = json_encode([
839+
'componentGroups' => [
840+
[
841+
'@id' => $cgIri,
842+
'@type' => 'ComponentGroup',
843+
'componentPositions' => [['@id' => $posIri, 'sortValue' => 0]],
844+
],
845+
],
846+
]);
847+
$this->restContext->iSendARequestToTheResourceWithBody(
848+
'PATCH',
849+
'page',
850+
null,
851+
new PyStringNode([$body], 0)
852+
);
853+
}
854+
793855
/**
794856
* @When I patch the PageData with the property :property and resource :resource
795857
*/
@@ -1228,6 +1290,18 @@ public function theRouteShouldRedirectTo(string $oldPath, string $newPath): void
12281290
Assert::assertEquals($newPath, $route->getRedirect()->getPath());
12291291
}
12301292

1293+
/**
1294+
* @Then the Route :path should not redirect
1295+
*/
1296+
public function theRouteShouldNotRedirect(string $path): void
1297+
{
1298+
$this->manager->clear();
1299+
$repository = $this->manager->getRepository(Route::class);
1300+
$route = $repository->findOneBy(['path' => $path]);
1301+
Assert::assertNotNull($route, sprintf('Expected route "%s" to exist.', $path));
1302+
Assert::assertNull($route->getRedirect(), sprintf('Expected route "%s" to have no redirect, but it does.', $path));
1303+
}
1304+
12311305
/**
12321306
* @Then /^(\d+) refresh token(?:s)? should exist$/
12331307
*/

features/main/page.feature

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,31 @@ Feature: Page resources
126126
Then the response status code should be 200
127127
And the JSON node "resource_iris" should have 2 elements
128128
And the JSON node "resource_iris[1][0]" should be equal to the IRI of the resource "page_data"
129+
130+
@loginAdmin
131+
Scenario: I can PATCH a page when componentGroups are included in the request body
132+
Given there is a valid Page with a ComponentGroup
133+
When I patch the page with the component group in the request body
134+
Then the response status code should be 200
135+
136+
@loginAdmin
137+
Scenario: I can PATCH a page when embedded componentGroups with positions are included in the request body
138+
Given there is a valid Page with a ComponentGroup
139+
When I patch the page with an embedded component group in the request body
140+
Then the response status code should be 200
141+
142+
@loginAdmin
143+
Scenario: The page collection includes parentPage when set
144+
Given there is a Page
145+
And there is a page with parent page "page"
146+
When I send a "GET" request to "/_/pages?order[reference]=asc"
147+
Then the response status code should be 200
148+
And the JSON node "member[0].parentPage" should be equal to the IRI of the resource "page"
149+
150+
@loginAdmin
151+
Scenario: I can get a resource manifest for a nested Page by UUID
152+
Given there is a Page resource with the route path "/conference/programme" nested within the route "/conference"
153+
When I send a "GET" request to the resource "page_manifest"
154+
Then the response status code should be 200
155+
And the JSON node "resource_iris" should have 2 elements
156+
And the JSON node "resource_iris[1][0]" should be equal to the IRI of the resource "page"

features/main/route.feature

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,66 @@ Feature: Route resources
9090
And the JSON should be valid according to the schema file "route.schema.json"
9191
And the Route "/original" should redirect to "/new"
9292

93+
@loginAdmin
94+
Scenario: PATCH a route path with cascadeChildPaths updates child routes and creates redirects
95+
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
96+
When I send a "PATCH" request to the resource "parent_route" with data:
97+
| path | cascadeChildPaths |
98+
| /new-conference | true |
99+
Then the response status code should be 200
100+
And the JSON node "path" should be equal to the string "/new-conference"
101+
And the Route "/conference" should redirect to "/new-conference"
102+
And the Route "/conference/programme" should redirect to "/new-conference/programme"
103+
104+
@loginAdmin
105+
Scenario: PATCH a route path without cascadeChildPaths does not cascade to children
106+
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
107+
When I send a "PATCH" request to the resource "parent_route" with data:
108+
| path |
109+
| /new-conference |
110+
Then the response status code should be 200
111+
And the Route "/conference/programme" should not redirect
112+
113+
@loginAdmin
114+
Scenario: PATCH with cascadeChildPaths but path unchanged causes no cascade
115+
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
116+
When I send a "PATCH" request to the resource "parent_route" with data:
117+
| path | cascadeChildPaths |
118+
| /conference | true |
119+
Then the response status code should be 200
120+
And the Route "/conference/programme" should not redirect
121+
122+
@loginAdmin
123+
Scenario: PATCH with cascadeChildPaths ignores children with diverged route paths
124+
Given there is a PageData resource with the route path "/talks" nested within the route "/conference"
125+
When I send a "PATCH" request to the resource "parent_route" with data:
126+
| path | cascadeChildPaths |
127+
| /new-conference | true |
128+
Then the response status code should be 200
129+
And the Route "/talks" should not redirect
130+
131+
@loginAdmin
132+
Scenario: GET a route's children returns recursive tree with path and IRI per node
133+
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
134+
When I send a "GET" request to the resource "parent_route" and the postfix "/children"
135+
Then the response status code should be 200
136+
And the JSON node "children" should have 1 element
137+
And the JSON node "children[0].route" should be equal to "/_/routes//conference/programme"
138+
And the JSON node "children[0].path" should be equal to the string "/conference/programme"
139+
And the JSON node "children[0].children" should have 0 elements
140+
141+
@loginAdmin
142+
Scenario: GET children for a leaf route returns an empty children array
143+
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
144+
When I send a "GET" request to the resource "page_data_route" and the postfix "/children"
145+
Then the response status code should be 200
146+
And the JSON node "children" should have 0 elements
147+
148+
Scenario: Anonymous users cannot access route children
149+
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
150+
When I send a "GET" request to the resource "parent_route" and the postfix "/children"
151+
Then the response status code should be 401
152+
93153
@loginUser
94154
Scenario: I generate a route for a path that already exists and the new route is generated with a postfix
95155
Given there is a PageData resource with the route path "/unnamed-page"

0 commit comments

Comments
 (0)