Skip to content

Commit c09c7bb

Browse files
committed
Add pageDataClass to ComponentPosition with write-side validation (#170)
ComponentPosition now stores pageDataClass (FQCN) alongside pageDataProperty. Both fields must be set together or both null. ComponentPositionValidator validates: pageDataClass is a known PageData resource, pageDataProperty is a component-typed property on that class, and the resolved type is in allowedComponents if configured. GroupBuilder.pageDataPosition() takes pageDataClass as its new first argument.
1 parent 6c92a59 commit c09c7bb

11 files changed

Lines changed: 179 additions & 25 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,8 @@ class AppScaffold extends AbstractCwaScaffold
317317
// Blog article template (isTemplate: true, pageDataProperty positions, no route)
318318
$cwa->page('blog-template', 'BlogPageTemplate', layout: 'main', isTemplate: true, fn(PageBuilder $page) =>
319319
$page->group('primary', fn(GroupBuilder $g) => $g
320-
->pageDataPosition('image') // dynamic — resolved from BlogArticleData.image at render time
321-
->pageDataPosition('htmlContent')
320+
->pageDataPosition(BlogArticleData::class, 'image') // dynamic — resolved from BlogArticleData.image at render time
321+
->pageDataPosition(BlogArticleData::class, 'htmlContent')
322322
)
323323
);
324324

@@ -332,7 +332,7 @@ class AppScaffold extends AbstractCwaScaffold
332332
// Topic template (isTemplate: true, pageDataProperty for per-instance intro content)
333333
$cwa->page('topic-template', 'NestedTopicTemplate', layout: 'main', isTemplate: true, fn(PageBuilder $page) =>
334334
$page->group('primary', fn(GroupBuilder $g) => $g
335-
->pageDataPosition('introContent')
335+
->pageDataPosition(NestedPageData::class, 'introContent')
336336
)
337337
);
338338

@@ -412,7 +412,7 @@ PageDataBuilder
412412
413413
GroupBuilder
414414
->add(AbstractComponent, ?sort): self (sort defaults to insertion order × 10)
415-
->pageDataPosition(propertyName, ?sort): self (creates ComponentPosition with pageDataProperty set)
415+
->pageDataPosition(pageDataClass, propertyName, ?sort): self (creates ComponentPosition with pageDataClass and pageDataProperty set)
416416
```
417417

418418
### Route auto-generation rules
@@ -576,13 +576,15 @@ Related: Nuxt module issue `components-web-app/cwa-nuxt-module#224` Bug 2.
576576

577577
---
578578

579-
### #170 — Component group `allowedComponents` does not validate `pageDataProperty` positions on write
579+
### ~~#170 — Component group `allowedComponents` does not validate `pageDataProperty` positions on write~~ — FIXED
580580

581-
**Read side fixed** (commit `2305ad89`): `ComponentPositionNormalizer.normalizeForPageData()` now skips populating the component if the resolved type is not in `componentGroup.allowedComponents`. Direct-component write-side validation already works via `ComponentPositionValidator`.
581+
**Read side fixed** (commit `2305ad89`): `ComponentPositionNormalizer.normalizeForPageData()` now skips populating the component if the resolved type is not in `componentGroup.allowedComponents`.
582582

583-
**`pageDataProperty` write-side still open:** When creating a `ComponentPosition` with `pageDataProperty` set, no validation is done against `allowedComponents`. The property name is just a string — the component type isn't known until render time.
583+
**Write side fixed** (pending commit): `ComponentPosition` now stores a `pageDataClass` (FQCN) alongside `pageDataProperty`. `ComponentPositionValidator` validates the pair on every POST/PATCH: (1) `pageDataClass` must be a known API-registered PageData resource; (2) `pageDataProperty` must be a component-typed property on that class; (3) the resolved component type must be in `componentGroup.allowedComponents` if set. Both fields must be set together (entity-level `Assert\Expression` constraint).
584584

585-
**Agreed plan:** The module must send `pageDataClass` (FQCN of the PageData entity, e.g. `"App\\Entity\\ConferenceData"`) alongside `pageDataProperty` in POST/PATCH. The API can then resolve the property type and validate against `allowedComponents`. Requires coordinated change in both projects. See issue #170 (reopened) and nuxt module CLAUDE.md section on `allowedComponents` for the full spec.
585+
**`CwaFixtureBuilder` updated:** `GroupBuilder.pageDataPosition()` now takes `pageDataClass` as its first argument: `->pageDataPosition(string $pageDataClass, string $propertyName, ?int $sort = null)`.
586+
587+
**Nuxt module action required:** The module must now send `pageDataClass` alongside `pageDataProperty` in POST/PATCH requests to `/_/component_positions`. See nuxt module CLAUDE.md for the consumer-side spec.
586588

587589
Related Nuxt module issue: `components-web-app/cwa-nuxt-module#151`.
588590

features/bootstrap/DoctrineContext.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,7 @@ public function theComponentPositionHasTheDynamicReference(string $ref)
732732
/** @var ComponentPosition $componentPosition */
733733
$componentPosition = $this->iriConverter->getResourceFromIri($this->restContext->resources['position_0']);
734734
$componentPosition->setPageDataProperty($ref);
735+
$componentPosition->pageDataClass = PageDataWithComponent::class;
735736
$this->manager->flush();
736737
}
737738

@@ -748,6 +749,7 @@ public function thereIsAPageDataResourceWithRoutePath(?string $path): void
748749

749750
$componentPosition = new ComponentPosition();
750751
$componentPosition->pageDataProperty = 'component';
752+
$componentPosition->pageDataClass = PageDataWithComponent::class;
751753
$componentPosition->componentGroup = $componentGroup;
752754
$componentPosition->sortValue = 0;
753755
$this->timestampedHelper->persistTimestampedFields($componentPosition, true);
@@ -799,6 +801,7 @@ public function thereIsAPageDataWithDraftComponentInPageDataPropertyPosition(str
799801

800802
$componentPosition = new ComponentPosition();
801803
$componentPosition->pageDataProperty = 'publishableComponent';
804+
$componentPosition->pageDataClass = PageDataWithComponent::class;
802805
$componentPosition->componentGroup = $componentGroup;
803806
$componentPosition->sortValue = 0;
804807
$this->timestampedHelper->persistTimestampedFields($componentPosition, true);
@@ -846,6 +849,7 @@ public function thereIsAPageDataWithPublishedComponentInPageDataPropertyPosition
846849

847850
$componentPosition = new ComponentPosition();
848851
$componentPosition->pageDataProperty = 'publishableComponent';
852+
$componentPosition->pageDataClass = PageDataWithComponent::class;
849853
$componentPosition->componentGroup = $componentGroup;
850854
$componentPosition->sortValue = 0;
851855
$this->timestampedHelper->persistTimestampedFields($componentPosition, true);
@@ -894,6 +898,7 @@ public function thereIsAPageDataPropertyPositionWithDisallowedComponentType(stri
894898

895899
$componentPosition = new ComponentPosition();
896900
$componentPosition->pageDataProperty = 'publishableComponent';
901+
$componentPosition->pageDataClass = PageDataWithComponent::class;
897902
$componentPosition->componentGroup = $componentGroup;
898903
$componentPosition->sortValue = 0;
899904
$this->timestampedHelper->persistTimestampedFields($componentPosition, true);
@@ -942,6 +947,7 @@ public function thereIsAPageDataPropertyPositionWithAllowedComponentType(string
942947

943948
$componentPosition = new ComponentPosition();
944949
$componentPosition->pageDataProperty = 'component';
950+
$componentPosition->pageDataClass = PageDataWithComponent::class;
945951
$componentPosition->componentGroup = $componentGroup;
946952
$componentPosition->sortValue = 0;
947953
$this->timestampedHelper->persistTimestampedFields($componentPosition, true);
@@ -1132,8 +1138,10 @@ public function abstractThereIsADummyComponentInPageDataAndAPosition(AbstractCom
11321138

11331139
if ($dummyComponent instanceof DummyComponent) {
11341140
$componentPosition->pageDataProperty = 'component';
1141+
$componentPosition->pageDataClass = PageDataWithComponent::class;
11351142
} elseif ($dummyComponent instanceof DummyPublishableComponent) {
11361143
$componentPosition->pageDataProperty = 'publishableComponent';
1144+
$componentPosition->pageDataClass = PageDataWithComponent::class;
11371145
}
11381146

11391147
$this->restContext->resources['page_data_component'] = $this->iriConverter->getIriFromResource($dummyComponent);

features/main/component_position.feature

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,68 @@ Feature: Component positions
158158
And I send a "GET" request to the resource "position_2"
159159
And the response status code should be 200
160160
And the JSON node "sortValue" should be equal to the number 2
161+
162+
@loginUser
163+
Scenario: Cannot create a dynamic position with pageDataProperty but no pageDataClass
164+
Given there is a ComponentGroup with 0 components
165+
When I send a "POST" request to "/_/component_positions" with data:
166+
| componentGroup | pageDataProperty |
167+
| resource[component_group] | component |
168+
Then the response status code should be 422
169+
170+
@loginUser
171+
Scenario: Cannot create a dynamic position with pageDataClass but no pageDataProperty
172+
Given there is a ComponentGroup with 0 components
173+
When I send a "POST" request to "/_/component_positions" with data:
174+
| componentGroup | pageDataClass |
175+
| resource[component_group] | Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\PageDataWithComponent |
176+
Then the response status code should be 422
177+
178+
@loginUser
179+
Scenario: Cannot create a dynamic position with a pageDataClass that is not a known PageData resource
180+
Given there is a ComponentGroup with 0 components
181+
When I send a "POST" request to "/_/component_positions" with data:
182+
| componentGroup | pageDataProperty | pageDataClass |
183+
| resource[component_group] | component | App\NotAPageDataClass |
184+
Then the response status code should be 422
185+
186+
@loginUser
187+
Scenario: Cannot create a dynamic position where pageDataProperty is not a component-typed property on the pageDataClass
188+
Given there is a ComponentGroup with 0 components
189+
When I send a "POST" request to "/_/component_positions" with data:
190+
| componentGroup | pageDataProperty | pageDataClass |
191+
| resource[component_group] | notAProperty | Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\PageDataWithComponent |
192+
Then the response status code should be 422
193+
194+
@loginUser
195+
Scenario: Cannot create a dynamic position where the resolved component type is not in allowedComponents
196+
Given there is a ComponentGroup with 0 components
197+
And the ComponentGroup has the allowedComponent "/component/dummy_components"
198+
When I send a "POST" request to "/_/component_positions" with data:
199+
| componentGroup | pageDataProperty | pageDataClass |
200+
| resource[component_group] | publishableComponent | Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\PageDataWithComponent |
201+
Then the response status code should be 422
202+
203+
@loginUser
204+
Scenario: Can create a dynamic position with a valid pageDataClass and pageDataProperty
205+
Given there is a ComponentGroup with 0 components
206+
When I send a "POST" request to "/_/component_positions" with data:
207+
| componentGroup | pageDataProperty | pageDataClass |
208+
| resource[component_group] | component | Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\PageDataWithComponent |
209+
Then the response status code should be 201
210+
211+
@loginUser
212+
Scenario: Can create a dynamic position where the component type matches allowedComponents
213+
Given there is a ComponentGroup with 0 components
214+
And the ComponentGroup has the allowedComponent "/component/dummy_components"
215+
When I send a "POST" request to "/_/component_positions" with data:
216+
| componentGroup | pageDataProperty | pageDataClass |
217+
| resource[component_group] | component | Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\PageDataWithComponent |
218+
Then the response status code should be 201
219+
220+
@loginAdmin
221+
Scenario: An admin can read pageDataClass from a dynamic position
222+
Given there is a PageData resource with the route path "/page-data"
223+
When I send a "GET" request to the resource "component_position"
224+
Then the response status code should be 200
225+
And the JSON node "pageDataClass" should be equal to "Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\PageDataWithComponent"

features/main/dynamic_page.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Feature: Dynamic pages
7272
When I send a "GET" request to the resource "component_position"
7373
Then the response status code should be 200
7474
And the JSON node "pageDataProperty" should be equal to "component"
75+
And the JSON node "pageDataClass" should be equal to "Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\PageDataWithComponent"
7576
And the JSON node "component" should be null
7677

7778
@loginAdmin
@@ -81,6 +82,7 @@ Feature: Dynamic pages
8182
When I send a "GET" request to the resource "component_position"
8283
Then the response status code should be 200
8384
And the JSON node "pageDataProperty" should be equal to "component"
85+
And the JSON node "pageDataClass" should be equal to "Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\PageDataWithComponent"
8486
And the JSON node "component" should be null
8587

8688
Scenario: A published pageDataProperty component is returned for anonymous users

src/Entity/Core/ComponentPosition.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
#[AcbAssert\ComponentPosition]
3333
#[Assert\Expression(
3434
'!(this.component == null & this.pageDataProperty == null)',
35-
message: 'Please specify either a component or pageDataProperty.',
35+
message: 'Please specify either a component or both pageDataProperty and pageDataClass.',
36+
)]
37+
#[Assert\Expression(
38+
'!(this.pageDataProperty != null & this.pageDataClass == null) && !(this.pageDataProperty == null & this.pageDataClass != null)',
39+
message: 'pageDataProperty and pageDataClass must both be set or both be null.',
3640
)]
3741
class ComponentPosition
3842
{
@@ -54,6 +58,10 @@ class ComponentPosition
5458
#[Groups(['ComponentPosition:read:role_admin', 'ComponentPosition:write'])]
5559
public ?string $pageDataProperty = null;
5660

61+
#[ORM\Column(name: 'page_data_class', nullable: true)]
62+
#[Groups(['ComponentPosition:read:role_admin', 'ComponentPosition:write'])]
63+
public ?string $pageDataClass = null;
64+
5765
#[ORM\Column(name: 'sort_value', type: 'integer')]
5866
#[Assert\NotNull]
5967
#[Groups(['ComponentPosition:read', 'ComponentPosition:write', 'AbstractComponent:cwa_resource:write'])]

src/Fixture/Builder/GroupBuilder.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ public function add(AbstractComponent $component, ?int $sort = null): self
3636
return $this;
3737
}
3838

39-
public function pageDataPosition(string $propertyName, ?int $sort = null): self
39+
public function pageDataPosition(string $pageDataClass, string $propertyName, ?int $sort = null): self
4040
{
41-
$this->pageDataPositions[] = ['property' => $propertyName, 'sort' => $sort ?? $this->nextSort];
41+
$this->pageDataPositions[] = ['class' => $pageDataClass, 'property' => $propertyName, 'sort' => $sort ?? $this->nextSort];
4242
$this->nextSort += 10;
4343

4444
return $this;

src/Fixture/CwaFixtureBuilder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ private function createPositions(GroupBuilder $groupBuilder): bool
510510
$position = new ComponentPosition();
511511
$position->sortValue = $item['sort'];
512512
$position->pageDataProperty = $item['property'];
513+
$position->pageDataClass = $item['class'];
513514
$position->componentGroup = $componentGroup;
514515
$this->timestampedPersister->persistTimestampedFields($position, true);
515516
$this->manager->persist($position);

src/Resources/config/services.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@
342342
->class(ComponentPositionValidator::class)
343343
->args([
344344
new Reference(IriConverterInterface::class),
345+
new Reference('silverback.metadata_provider.page_data'),
345346
])
346347
->tag('validator.constraint_validator');
347348
$services->alias(ComponentPositionValidator::class, 'silverback.api_components.validator.component_position');

src/Validator/Constraints/ComponentPosition.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class ComponentPosition extends Constraint
2323
{
2424
public string $message = 'The IRI `{{ iri }}` is not permitted to be added to the collection `{{ reference }}`. Allowed IRIs: {{ allowed }}';
2525
public string $restrictedMessage = 'The IRI `{{ iri }}` must be specifically allowed within the collection {{ reference }}';
26+
public string $invalidPageDataClassMessage = '`{{ class }}` is not a known PageData resource class';
27+
public string $invalidPageDataPropertyMessage = '`{{ property }}` is not a component-typed property on `{{ class }}`';
2628

2729
public function getTargets(): string|array
2830
{

0 commit comments

Comments
 (0)