Skip to content
5 changes: 4 additions & 1 deletion src/lib/Twig/Components/AbstractChoiceInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ public function validate(array $props): array
$resolver
->define('name')
->required()
->allowedTypes('string');
->allowedTypes('string')
->allowedValues(static function (string $value): bool {
return trim($value) !== '';
});
$resolver
->define('checked')
->allowedTypes('bool')
Expand Down
39 changes: 0 additions & 39 deletions src/lib/Twig/Components/AbstractChoiceInputField.php

This file was deleted.

10 changes: 9 additions & 1 deletion src/lib/Twig/Components/Checkbox/ListField.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@

/**
* @phpstan-type CheckboxItem array{
* id: non-empty-string,
* value: string|int,
* label: string,
* disabled?: bool
* disabled?: bool,
* name?: string,
* required?: bool,
* attributes?: array<string, mixed>,
* label_attributes?: array<string, mixed>,
* inputWrapperClassName?: string,
* labelClassName?: string,
* checked?: bool
* }
* @phpstan-type CheckboxItems list<CheckboxItem>
*/
Expand Down
5 changes: 4 additions & 1 deletion src/lib/Twig/Components/ChoiceInputLabel.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ public function validate(array $props): array
$resolver
->define('for')
->required()
->allowedTypes('string');
->allowedTypes('string')
->allowedValues(static function (string $value): bool {
return trim($value) !== '';
});

return $resolver->resolve($props) + $props;
}
Expand Down
5 changes: 4 additions & 1 deletion src/lib/Twig/Components/LabelledChoiceInputTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ protected function validateLabelledProps(OptionsResolver $resolver): void
$resolver
->define('id')
->required()
->allowedTypes('string');
->allowedTypes('string')
->allowedValues(static function (string $value): bool {
return trim($value) !== '';
});
$resolver
->define('inputWrapperClassName')
->allowedTypes('string')
Expand Down
63 changes: 59 additions & 4 deletions src/lib/Twig/Components/ListFieldTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@

/**
* @phpstan-type ListItem array{
* id: non-empty-string,
* value: string|int,
* label: string,
* disabled?: bool,
* name?: string,
* required?: bool,
* attributes?: array<string, mixed>,
* label_attributes?: array<string, mixed>,
* inputWrapperClassName?: string,
* labelClassName?: string
* }
* @phpstan-type ListItems list<ListItem>
*/
Expand Down Expand Up @@ -53,10 +61,57 @@ protected function modifyListItem(array $item): array

protected function validateListFieldProps(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'items' => [],
]);
$resolver->setAllowedTypes('items', 'array');
$resolver
->define('items')
->default([])
->allowedTypes('array');

$resolver->setOptions('items', static function (OptionsResolver $itemsResolver): void {
$itemsResolver->setPrototype(true);
$itemsResolver
->define('id')
->required()
->allowedTypes('string')
->allowedValues(static fn (string $value): bool => trim($value) !== '');

$itemsResolver
->define('label')
->required()
->allowedTypes('string');

$itemsResolver
->define('value')
->required()
->allowedTypes('string', 'int');

$itemsResolver
->define('disabled')
->allowedTypes('bool');

$itemsResolver
->define('attributes')
->allowedTypes('array');

$itemsResolver
->define('label_attributes')
->allowedTypes('array');

$itemsResolver
->define('inputWrapperClassName')
->allowedTypes('string');

$itemsResolver
->define('labelClassName')
->allowedTypes('string');

$itemsResolver
->define('name')
->allowedTypes('string');

$itemsResolver
->define('required')
->allowedTypes('bool');
});

$resolver
->define('direction')
Expand Down
9 changes: 8 additions & 1 deletion src/lib/Twig/Components/RadioButton/ListField.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,16 @@

/**
* @phpstan-type RadioButtonItem array{
* id: non-empty-string,
* value: string|int,
* label: string,
* disabled?: bool
* disabled?: bool,
* name?: string,
* required?: bool,
* attributes?: array<string, mixed>,
* label_attributes?: array<string, mixed>,
* inputWrapperClassName?: string,
* labelClassName?: string
* }
* @phpstan-type RadioButtonItems list<RadioButtonItem>
*/
Expand Down
18 changes: 18 additions & 0 deletions tests/integration/Twig/Components/Checkbox/FieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,24 @@ public function testInvalidIndeterminateTypeCausesResolverErrorOnMount(): void
]));
}

public function testEmptyNameCausesResolverErrorOnMount(): void
{
$this->expectException(InvalidOptionsException::class);

$this->mountTwigComponent(Field::class, $this->baseProps([
'name' => '',
]));
}

public function testEmptyIdCausesResolverErrorOnMount(): void
{
$this->expectException(InvalidOptionsException::class);

$this->mountTwigComponent(Field::class, $this->baseProps([
'id' => '',
]));
}

public function testMissingRequiredOptionsCauseResolverErrorOnMount(): void
{
$this->expectException(MissingOptionsException::class);
Expand Down
11 changes: 11 additions & 0 deletions tests/integration/Twig/Components/Checkbox/InputTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ public function testInvalidIndeterminateTypeCausesResolverErrorOnMount(): void
]);
}

public function testEmptyNameCausesResolverErrorOnMount(): void
{
$this->expectException(InvalidOptionsException::class);

$this->mountTwigComponent(Input::class, [
'id' => 'agree',
'name' => '',
'value' => 'yes',
]);
}

public function testMissingRequiredOptionsCauseResolverErrorOnMount(): void
{
$this->expectException(MissingOptionsException::class);
Expand Down
7 changes: 7 additions & 0 deletions tests/integration/Twig/Components/ChoiceInputLabelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ public function testAttributesMergeClassAndData(): void
self::assertSame('custom', $label->attr('data-custom'), 'Custom data attribute should be rendered on the label.');
}

public function testEmptyForCausesResolverErrorOnMount(): void
{
$this->expectException(InvalidOptionsException::class);

$this->mountTwigComponent(ChoiceInputLabel::class, ['for' => '', 'content' => 'x']);
}

public function testInvalidForTypeCausesResolverErrorOnMount(): void
{
$this->expectException(InvalidOptionsException::class);
Expand Down
124 changes: 124 additions & 0 deletions tests/integration/Twig/Components/ListFieldTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Ibexa\Tests\Integration\DesignSystemTwig\Twig\Stub\DummyListFieldComponent;
use PHPUnit\Framework\TestCase;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Exception\MissingOptionsException;

final class ListFieldTraitTest extends TestCase
{
Expand Down Expand Up @@ -57,6 +58,129 @@ public function testDefaultsWhenNoOptionsProvided(): void
);
}

public function testItemMissingValueCausesResolverError(): void
{
$this->expectException(MissingOptionsException::class);

$this->getComponent()->resolve([
'items' => [
['label' => 'Alpha'],
],
]);
}

public function testItemMissingIdCausesResolverError(): void
{
$this->expectException(MissingOptionsException::class);

$this->getComponent()->resolve([
'items' => [
['label' => 'Alpha', 'value' => 'A'],
],
]);
}

public function testItemMissingLabelCausesResolverError(): void
{
$this->expectException(MissingOptionsException::class);

$this->getComponent()->resolve([
'items' => [
['value' => 'A'],
],
]);
}

/**
* @param array<string, mixed> $options
*
* @dataProvider invalidItemOptionsProvider
*/
public function testInvalidItemOptionsCauseResolverError(array $options): void
{
$this->expectException(InvalidOptionsException::class);

$this->getComponent()->resolve($options);
}

/**
* @return iterable<string, array{0: array<string, mixed>}>
*/
public static function invalidItemOptionsProvider(): iterable
{
yield 'invalid label/value types' => [
[
'items' => [
['id' => 'a', 'label' => 123, 'value' => new \stdClass()],
],
],
];

yield 'empty id' => [
[
'items' => [
['id' => ' ', 'label' => 'Alpha', 'value' => 'A'],
],
],
];

yield 'invalid disabled type' => [
[
'items' => [
['id' => 'a', 'label' => 'Alpha', 'value' => 'A', 'disabled' => 'yes'],
],
],
];

yield 'invalid attributes type' => [
[
'items' => [
['id' => 'a', 'label' => 'Alpha', 'value' => 'A', 'attributes' => 'oops'],
],
],
];

yield 'invalid label_attributes type' => [
[
'items' => [
['id' => 'a', 'label' => 'Alpha', 'value' => 'A', 'label_attributes' => 'oops'],
],
],
];

yield 'invalid input wrapper class name' => [
[
'items' => [
['id' => 'a', 'label' => 'Alpha', 'value' => 'A', 'inputWrapperClassName' => ['not-a-string']],
],
],
];

yield 'invalid label class name' => [
[
'items' => [
['id' => 'a', 'label' => 'Alpha', 'value' => 'A', 'labelClassName' => ['not-a-string']],
],
],
];

yield 'invalid name type' => [
[
'items' => [
['id' => 'a', 'label' => 'Alpha', 'value' => 'A', 'name' => ['array']],
],
],
];

yield 'invalid required type' => [
[
'items' => [
['id' => 'a', 'label' => 'Alpha', 'value' => 'A', 'required' => 'yes'],
],
],
];
}

public function testInvalidItemsTypeCausesResolverError(): void
{
$this->expectException(InvalidOptionsException::class);
Expand Down
Loading