Skip to content

Commit 79f1a76

Browse files
committed
Add onRoutesCreated callback to PageDataBuilder
Fires after phaseThree (routes created) but before phaseFour (positions), passing direct child PageBuilders so fixture code can reference child route paths when building parent content.
1 parent 6e60680 commit 79f1a76

5 files changed

Lines changed: 178 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ PageBuilder
400400
401401
PageDataBuilder
402402
->nested(Closure): void (Closure receives CwaFixtureBuilder with parent context)
403+
->onRoutesCreated(Closure): self (Closure receives array<PageBuilder> of direct child page builders; called after phaseThree so child route paths are available)
403404
->getRoute(): ?Route
404405
405406
GroupBuilder
@@ -431,11 +432,96 @@ The builder manages persisting in the correct order. Roughly:
431432
4. `flush()`
432433
5. Call `RouteGenerator::create()` for all auto-routed entities (parents before children — breadth-first)
433434
6. `flush()` — routes now have paths
435+
6.5. Call `onRoutesCreated` callbacks on any `PageDataBuilder` that registered one, passing the child `PageBuilder` instances tracked during `evaluateNested()`. The callback mutates already-persisted entity properties (e.g. sets `HtmlContent.html` with real child paths). Followed by a `flush()` to persist those changes.
434436
7. Create ComponentPositions and nav-bar links (which may reference routes created in step 5)
435437
8. Final `flush()`
436438

437439
`->getRoute(routeName)` and `PageDataBuilder/PageBuilder->getRoute()` are only valid after step 5 completes. The builder defers all closures to the correct phase internally. Closures registered against GroupBuilder via `->add()` or `->pageDataPosition()` are evaluated in phase 7. The `->nested()` closure is evaluated during phase 5 so parent routes exist before child routes are generated.
438440

441+
### `onRoutesCreated` — implementation plan
442+
443+
**Use case:** A `PageData` entity has a component whose content must reference child page URLs (e.g. an `HtmlContent` with links to the child pages). Child routes don't exist at entity-creation time, so the content must be set after `phaseThree`.
444+
445+
**Required changes in `CwaFixtureBuilder`:**
446+
447+
In `evaluateNested()`, record which page refs were registered by each nested closure and store them on the `PageDataBuilder`:
448+
449+
```php
450+
foreach ($this->pageDataSpecs as $spec) {
451+
$closure = $spec['builder']->getNestedClosure();
452+
if (null === $closure) continue;
453+
$beforePageRefs = array_keys($this->pageSpecs);
454+
$this->parentContext = $spec['builder']->getPageData();
455+
$closure($this);
456+
$this->parentContext = null;
457+
$addedRefs = array_diff(array_keys($this->pageSpecs), $beforePageRefs);
458+
$spec['builder']->setChildPageRefs(array_values($addedRefs));
459+
}
460+
```
461+
462+
Add a new `phaseThreePointFive()` called between `phaseThree()` and `phaseFour()` in `flush()`:
463+
464+
```php
465+
private function phaseThreePointFive(): void
466+
{
467+
$hasChanges = false;
468+
foreach ($this->pageDataSpecs as $spec) {
469+
$cb = $spec['builder']->getOnRoutesCreated();
470+
if (null === $cb) continue;
471+
$childBuilders = array_values(array_filter(array_map(
472+
fn($ref) => $this->pageSpecs[$ref]['builder'] ?? null,
473+
$spec['builder']->getChildPageRefs(),
474+
)));
475+
$cb($childBuilders);
476+
$hasChanges = true;
477+
}
478+
if ($hasChanges) {
479+
$this->manager->flush();
480+
}
481+
}
482+
```
483+
484+
**Required changes in `PageDataBuilder`:**
485+
486+
```php
487+
private ?\Closure $onRoutesCreated = null;
488+
private array $childPageRefs = [];
489+
490+
public function onRoutesCreated(\Closure $cb): self { $this->onRoutesCreated = $cb; return $this; }
491+
public function getOnRoutesCreated(): ?\Closure { return $this->onRoutesCreated; }
492+
public function setChildPageRefs(array $refs): void { $this->childPageRefs = $refs; }
493+
public function getChildPageRefs(): array { return $this->childPageRefs; }
494+
```
495+
496+
**App usage (`AppScaffold`):**
497+
498+
```php
499+
$intro = new HtmlContent();
500+
$intro->setPublishedAt(new \DateTime());
501+
$topicPageData->introContent = $intro; // persisted in phaseOne via cascade
502+
503+
$topicBuilder = $cwa->pageData($topicPageData, template: 'nested-topic-template', routeName: 'topic-1');
504+
505+
$topicBuilder->nested(function (CwaFixtureBuilder $child) use ($chapters) {
506+
foreach ($chapters as $j => $chapter) {
507+
$child->page(sprintf('topic-1-chapter-%d', $j + 1), 'NestedSubPageTemplate', layout: 'main',
508+
configure: fn(PageBuilder $p) => $p->title($chapter['title'])->group('primary')->add(...)
509+
);
510+
}
511+
});
512+
513+
$topicBuilder->onRoutesCreated(function (array $childBuilders) use ($intro) {
514+
$links = implode(' | ', array_map(
515+
fn(PageBuilder $b) => sprintf('<a href="%s">%s</a>', $b->getRoute()->getPath(), $b->getPage()->getTitle()),
516+
$childBuilders
517+
));
518+
$intro->html = sprintf('<p>Introduction to Topic 1. Chapters: %s</p>', $links);
519+
// No persist() needed — entity is already managed; flush() in phaseThreePointFive picks it up
520+
});
521+
```
522+
523+
**Key constraint:** The `HtmlContent` (or any entity updated in the callback) must already be persisted before `onRoutesCreated` fires — i.e. set on the `PageData` entity before passing to `->pageData()` so phaseOne cascades it. The callback only mutates properties on already-managed entities; it does not call `persist()`.
524+
439525
### What the builder handles invisibly
440526

441527
- `TimestampedDataPersister->persistTimestampedFields($entity, true)` on every entity

src/Fixture/Builder/PageDataBuilder.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,47 @@
1717
class PageDataBuilder
1818
{
1919
private ?\Closure $nestedClosure = null;
20+
private ?\Closure $onRoutesCreated = null;
21+
private array $childPageRefs = [];
2022

2123
public function __construct(private readonly AbstractPageData $pageData)
2224
{
2325
}
2426

25-
public function nested(\Closure $configure): void
27+
public function nested(\Closure $configure): self
2628
{
2729
$this->nestedClosure = $configure;
30+
31+
return $this;
2832
}
2933

3034
public function getNestedClosure(): ?\Closure
3135
{
3236
return $this->nestedClosure;
3337
}
3438

39+
public function onRoutesCreated(\Closure $cb): self
40+
{
41+
$this->onRoutesCreated = $cb;
42+
43+
return $this;
44+
}
45+
46+
public function getOnRoutesCreated(): ?\Closure
47+
{
48+
return $this->onRoutesCreated;
49+
}
50+
51+
public function setChildPageRefs(array $refs): void
52+
{
53+
$this->childPageRefs = $refs;
54+
}
55+
56+
public function getChildPageRefs(): array
57+
{
58+
return $this->childPageRefs;
59+
}
60+
3561
public function getPageData(): AbstractPageData
3662
{
3763
return $this->pageData;

src/Fixture/CwaFixtureBuilder.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ public function flush(): void
197197
$this->evaluateNested();
198198
$this->phaseTwo();
199199
$this->phaseThree();
200+
$this->phaseThreePointFive();
200201
$this->initialFlushDone = true;
201202
}
202203

@@ -213,9 +214,12 @@ private function evaluateNested(): void
213214
if (null === $closure) {
214215
continue;
215216
}
217+
$beforePageRefs = array_keys($this->pageSpecs);
216218
$this->parentContext = $spec['builder']->getPageData();
217219
$closure($this);
218220
$this->parentContext = null;
221+
$addedRefs = array_values(array_diff(array_keys($this->pageSpecs), $beforePageRefs));
222+
$spec['builder']->setChildPageRefs($addedRefs);
219223
}
220224

221225
foreach ($this->pageSpecs as $spec) {
@@ -381,6 +385,28 @@ private function phaseThree(): void
381385
$this->manager->flush();
382386
}
383387

388+
private function phaseThreePointFive(): void
389+
{
390+
$hasChanges = false;
391+
392+
foreach ($this->pageDataSpecs as $spec) {
393+
$cb = $spec['builder']->getOnRoutesCreated();
394+
if (null === $cb) {
395+
continue;
396+
}
397+
$childBuilders = array_values(array_filter(array_map(
398+
fn ($ref) => $this->pageSpecs[$ref]['builder'] ?? null,
399+
$spec['builder']->getChildPageRefs(),
400+
)));
401+
$cb($childBuilders);
402+
$hasChanges = true;
403+
}
404+
405+
if ($hasChanges) {
406+
$this->manager->flush();
407+
}
408+
}
409+
384410
private function phaseFour(): void
385411
{
386412
$hasPositions = false;
@@ -482,7 +508,7 @@ private function persistWithAssociations(object $entity): void
482508
}
483509
if (is_iterable($related)) {
484510
foreach ($related as $item) {
485-
if (is_object($item)) {
511+
if (\is_object($item)) {
486512
$this->persistWithAssociations($item);
487513
}
488514
}

src/Resources/config/services.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@
102102
use Silverback\ApiComponentsBundle\Factory\User\Mailer\WelcomeEmailFactory;
103103
use Silverback\ApiComponentsBundle\Factory\User\UserFactory;
104104
use Silverback\ApiComponentsBundle\Filter\OrSearchFilter;
105+
use Silverback\ApiComponentsBundle\Fixture\CwaFixtureBuilder;
105106
use Silverback\ApiComponentsBundle\Flysystem\FilesystemFactory;
106107
use Silverback\ApiComponentsBundle\Flysystem\FilesystemProvider;
107108
use Silverback\ApiComponentsBundle\Form\Type\User\ChangePasswordType;
108109
use Silverback\ApiComponentsBundle\Form\Type\User\NewEmailAddressType;
109110
use Silverback\ApiComponentsBundle\Form\Type\User\PasswordUpdateType;
110111
use Silverback\ApiComponentsBundle\Form\Type\User\UserLoginType;
111112
use Silverback\ApiComponentsBundle\Form\Type\User\UserRegisterType;
112-
use Silverback\ApiComponentsBundle\Fixture\CwaFixtureBuilder;
113113
use Silverback\ApiComponentsBundle\Helper\ComponentPosition\ComponentPositionSortValueHelper;
114114
use Silverback\ApiComponentsBundle\Helper\Form\FormCachePurger;
115115
use Silverback\ApiComponentsBundle\Helper\Form\FormSubmitHelper;

tests/Fixture/CwaFixtureBuilderTest.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
use Doctrine\Persistence\ObjectManager;
1616
use PHPUnit\Framework\TestCase;
1717
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
18-
use Silverback\ApiComponentsBundle\Entity\Core\AbstractPage;
1918
use Silverback\ApiComponentsBundle\Entity\Core\AbstractPageData;
2019
use Silverback\ApiComponentsBundle\Entity\Core\ComponentGroup;
2120
use Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition;
@@ -171,7 +170,7 @@ public function test_layout_group_creates_component_group_with_correct_propertie
171170

172171
$iriConverter = $this->createStub(IriConverterInterface::class);
173172
$iriConverter->method('getIriFromResource')->willReturnCallback(
174-
static fn ($resource) => is_string($resource) ? '/_/some_components' : '/_api/_/layouts/test-uuid'
173+
static fn ($resource) => \is_string($resource) ? '/_/some_components' : '/_api/_/layouts/test-uuid'
175174
);
176175

177176
$builder = $this->makeBuilder($em, iriConverter: $iriConverter);
@@ -460,6 +459,7 @@ public function test_phases_one_to_three_run_only_once_across_multiple_flushes()
460459
$route->setPath('/' . spl_object_id($entity));
461460
$route->setName((string) spl_object_id($entity));
462461
$entity->setRoute($route);
462+
463463
return $route;
464464
});
465465

@@ -471,6 +471,41 @@ public function test_phases_one_to_three_run_only_once_across_multiple_flushes()
471471
$builder->flush(); // second flush must NOT call routeGenerator->create() again
472472
}
473473

474+
public function test_on_routes_created_fires_after_child_routes_exist_with_child_builders(): void
475+
{
476+
$parentPageData = new class extends AbstractPageData {};
477+
$capturedBuilders = null;
478+
$capturedChildRoute = null;
479+
480+
$builder = $this->makeBuilder(routeGenerator: $this->autoRouteGenerator());
481+
$builder->layout('main', 'CwaLayoutPrimary');
482+
$builder->pageData($parentPageData)
483+
->nested(static function (CwaFixtureBuilder $child): void {
484+
$child->page('chapter', 'ChapterTemplate', layout: 'main');
485+
})
486+
->onRoutesCreated(static function (array $childBuilders) use (&$capturedBuilders, &$capturedChildRoute): void {
487+
$capturedBuilders = $childBuilders;
488+
$capturedChildRoute = $childBuilders[0]->getRoute()?->getPath();
489+
});
490+
$builder->flush();
491+
492+
$this->assertIsArray($capturedBuilders);
493+
$this->assertCount(1, $capturedBuilders);
494+
$this->assertNotNull($capturedChildRoute, 'Child route path should be available inside onRoutesCreated');
495+
}
496+
497+
public function test_on_routes_created_not_called_when_no_callback_registered(): void
498+
{
499+
$called = false;
500+
$parentPageData = new class extends AbstractPageData {};
501+
502+
$builder = $this->makeBuilder(routeGenerator: $this->autoRouteGenerator());
503+
$builder->pageData($parentPageData);
504+
$builder->flush();
505+
506+
$this->assertFalse($called); // trivially passes; confirms no exception thrown
507+
}
508+
474509
public function test_parent_pagedata_route_created_before_child_pagedata_route(): void
475510
{
476511
$createOrder = [];

0 commit comments

Comments
 (0)