Skip to content

Commit afb59b2

Browse files
[Live] Adding nicer validation layer + a few other small fixes
1 parent 178cd82 commit afb59b2

File tree

9 files changed

+200
-28
lines changed

9 files changed

+200
-28
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ public User $user;
4848
cannot be hydrated onto the object during a re-render, the last valid
4949
value is used.
5050

51+
- When using `ValidatableComponentTrait`, a new `_errors` variable is sent
52+
to the template, which is easier to use!
53+
5154
- Several bug fixes to parent - child components - see #700.
5255

5356
- Fixed handling of boolean attributes to a component - see #710.

src/LiveComponent/doc/index.rst

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,13 +1351,14 @@ This is possible thanks to the team work of two pieces:
13511351
yet by the user, its validation errors are cleared so that they
13521352
aren't displayed.
13531353

1354-
Easier "New Form" Component
1355-
~~~~~~~~~~~~~~~~~~~~~~~~~~~
1354+
Making the Post Object Optional for a "New Form" Component
1355+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
13561356

13571357
The previous component could be used to edit an existing post or create
1358-
a new post. But, the way it's currently written, a ``post`` object *must*
1359-
be passed to the ``component()`` function so the `$post` property is set.
1360-
But you can make that optional by adding a ``mount()`` method::
1358+
a new post. But either way, the component *requires* you to pass it
1359+
a ``post`` property.
1360+
1361+
Tou can make that optional by adding a ``mount()`` method::
13611362

13621363
#[AsLiveComponent('post_form')]
13631364
class PostFormComponent extends AbstractController
@@ -2000,27 +2001,26 @@ action::
20002001
}
20012002

20022003
If validation fails, an exception is thrown, but the component will be
2003-
re-rendered. In your template, render errors using the ``getError()``
2004-
method:
2004+
re-rendered. In your template, render errors using an ``_errors`` variable:
20052005

20062006
.. code-block:: html+twig
20072007

2008-
{% if this.getError('post.content') %}
2008+
{% if _errors.has('post.content') %}
20092009
<div class="error">
2010-
{{ this.getError('post.content').message }}
2010+
{{ _errors.get('post.content') }}
20112011
</div>
20122012
{% endif %}
20132013
<textarea
20142014
data-model="post.content"
2015-
class="{{ this.getError('post.content') ? 'has-error' : '' }}"
2015+
class="{{ _errors.has('post.content') ? 'is-invalid' : '' }}"
20162016
></textarea>
20172017

2018-
{% if this.getError('agreeToTerms') %}
2018+
{% if _errors.has('agreeToTerms') %}
20192019
<div class="error">
2020-
{{ this.getError('agreeToTerms').message }}
2020+
{{ _errors.get('agreeToTerms') }}
20212021
</div>
20222022
{% endif %}
2023-
<input type="checkbox" data-model="agreeToTerms" class="{{ this.getError('agreeToTerms') ? 'has-error' : '' }}"/>
2023+
<input type="checkbox" data-model="agreeToTerms" class="{{ _errors.has('agreeToTerms') ? 'is-invalid' : '' }}"/>
20242024

20252025
<button
20262026
type="submit"
@@ -2048,7 +2048,7 @@ To validate only on "change", use the ``on(change)`` modifier:
20482048
<input
20492049
type="email"
20502050
data-model="on(change)|user.email"
2051-
class="{{ this.getError('post.content') ? 'has-error' : '' }}"
2051+
class="{{ _errors.has('post.content') ? 'is-invalid' : '' }}"
20522052
>
20532053

20542054
Polling
@@ -2406,7 +2406,7 @@ attribute to the child:
24062406
{# templates/components/post_form.html.twig #}
24072407
{{ component('textarea_field', {
24082408
dataModel: 'content',
2409-
error: this.getError('content'),
2409+
error: _errors.get('content'),
24102410
}) }}
24112411
24122412
This does two things:
@@ -2547,9 +2547,6 @@ form.
25472547
Rendering Quirks with List of Embedded Components
25482548
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
25492549

2550-
Rendering Quirks with List of Embedded Components
2551-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2552-
25532550
Imagine your component renders a list of child components and
25542551
the list changes as the user types into a search box... or by clicking
25552552
"delete" on an item. In this case, the wrong children may be removed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Component;
4+
5+
use Symfony\Component\Validator\ConstraintViolation;
6+
7+
class ComponentValidationErrors implements \Countable
8+
{
9+
/**
10+
* @var array<string, ConstraintViolation[]>
11+
*/
12+
private array $errors = [];
13+
14+
public function count(): int
15+
{
16+
return \count($this->errors);
17+
}
18+
19+
public function has(string $propertyPath): bool
20+
{
21+
return null !== $this->get($propertyPath);
22+
}
23+
24+
public function get(string $propertyPath): ?string
25+
{
26+
$all = $this->getAll($propertyPath);
27+
28+
return $all[0] ?? null;
29+
}
30+
31+
/**
32+
* @return string[]
33+
*/
34+
public function getAll(string $propertyPath): array
35+
{
36+
return array_map(function (ConstraintViolation $violation) {
37+
return $violation->getMessage();
38+
}, $this->errors[$propertyPath] ?? []);
39+
}
40+
41+
/**
42+
* @return ConstraintViolation[]
43+
*/
44+
public function getViolations(string $propertyPath): array
45+
{
46+
return $this->errors[$propertyPath] ?? [];
47+
}
48+
49+
public function set(string $propertyName, array $constraintViolations): void
50+
{
51+
$this->errors[$propertyName] = $constraintViolations;
52+
}
53+
54+
public function setAll(array $errors): void
55+
{
56+
$this->errors = $errors;
57+
}
58+
59+
public function clear(): void
60+
{
61+
$this->errors = [];
62+
}
63+
}

src/LiveComponent/src/ValidatableComponentTrait.php

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use Symfony\Contracts\Service\Attribute\Required;
1717
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1818
use Symfony\UX\LiveComponent\Attribute\PostHydrate;
19+
use Symfony\UX\LiveComponent\Component\ComponentValidationErrors;
20+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
1921

2022
/**
2123
* @author Ryan Weaver <[email protected]>
@@ -25,7 +27,7 @@
2527
trait ValidatableComponentTrait
2628
{
2729
private ?ComponentValidatorInterface $componentValidator = null;
28-
private array $validationErrors = [];
30+
private ?ComponentValidationErrors $validationErrors = null;
2931

3032
/**
3133
* Tracks whether this entire component has been validated.
@@ -55,9 +57,9 @@ public function validate(bool $throw = true): void
5557
// set fields back to empty, as now the *entire* object is validated.
5658
$this->validatedFields = [];
5759
$this->isValidated = true;
58-
$this->validationErrors = $this->getValidator()->validate($this);
60+
$this->getValidationErrors()->setAll($this->getValidator()->validate($this));
5961

60-
if ($throw && \count($this->validationErrors) > 0) {
62+
if ($throw && \count($this->getValidationErrors()) > 0) {
6163
throw new UnprocessableEntityHttpException('Component validation failed');
6264
}
6365
}
@@ -76,7 +78,7 @@ public function validateField(string $propertyName, bool $throw = true): void
7678
}
7779

7880
$errors = $this->getValidator()->validateField($this, $propertyName);
79-
$this->validationErrors[$propertyName] = $errors;
81+
$this->getValidationErrors()->set($propertyName, $errors);
8082

8183
if ($throw && \count($errors) > 0) {
8284
throw new UnprocessableEntityHttpException(sprintf('The "%s" field of the component failed validation.', $propertyName));
@@ -88,20 +90,28 @@ public function validateField(string $propertyName, bool $throw = true): void
8890
*/
8991
public function getError(string $propertyPath): ?ConstraintViolation
9092
{
91-
return $this->validationErrors[$propertyPath][0] ?? null;
93+
$violations = $this->getValidationErrors()->getViolations($propertyPath);
94+
95+
return $violations[0] ?? null;
96+
}
97+
98+
#[ExposeInTemplate('_errors')]
99+
public function getErrorsObject(): ComponentValidationErrors
100+
{
101+
return $this->getValidationErrors();
92102
}
93103

94104
/**
95105
* @return ConstraintViolation[]
96106
*/
97107
public function getErrors(string $propertyPath): array
98108
{
99-
return $this->validationErrors[$propertyPath] ?? [];
109+
return $this->getValidationErrors()->getAll($propertyPath);
100110
}
101111

102112
public function isValid(): bool
103113
{
104-
return 0 === \count($this->validationErrors);
114+
return 0 === \count($this->getValidationErrors());
105115
}
106116

107117
/**
@@ -111,7 +121,7 @@ public function clearValidation(): void
111121
{
112122
$this->isValidated = false;
113123
$this->validatedFields = [];
114-
$this->validationErrors = [];
124+
$this->getValidationErrors()->clear();
115125
}
116126

117127
/**
@@ -150,4 +160,13 @@ private function getValidator(): ComponentValidatorInterface
150160

151161
return $this->componentValidator;
152162
}
163+
164+
private function getValidationErrors(): ComponentValidationErrors
165+
{
166+
if (null === $this->validationErrors) {
167+
$this->validationErrors = new ComponentValidationErrors();
168+
}
169+
170+
return $this->validationErrors;
171+
}
153172
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
4+
5+
use Symfony\Component\Validator\Constraints\Length;
6+
use Symfony\Component\Validator\Constraints\NotBlank;
7+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
8+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
9+
use Symfony\UX\LiveComponent\DefaultActionTrait;
10+
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
11+
12+
#[AsLiveComponent('validating_component', csrf: false)]
13+
final class ValidatingComponent
14+
{
15+
use DefaultActionTrait;
16+
use ValidatableComponentTrait;
17+
18+
#[LiveProp(writable: true)]
19+
#[NotBlank]
20+
#[Length(min: 3)]
21+
public string $name = '';
22+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<div{{ attributes }}>
2+
<div>Name: {{ name }}</div>
3+
<div>Has Error: {{ _errors.has('name') ? 'yes' : 'no' }}</div>
4+
<div>Error: "{{ _errors.get('name') }}"</div>
5+
</div>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Symfony\UX\LiveComponent\Tests\Functional\Form;
15+
16+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
17+
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
18+
use Zenstruck\Browser\Test\HasBrowser;
19+
use Zenstruck\Foundry\Test\Factories;
20+
use Zenstruck\Foundry\Test\ResetDatabase;
21+
22+
class ValidatableComponentTraitTest extends KernelTestCase
23+
{
24+
use Factories;
25+
use HasBrowser;
26+
use LiveComponentTestHelper;
27+
use ResetDatabase;
28+
29+
public function testFormValuesRebuildAfterFormChanges(): void
30+
{
31+
$dehydratedProps = $this->dehydrateComponent($this->mountComponent('validating_component'))->getProps();
32+
33+
$createUrl = function (array $props, array $updated = []) {
34+
return '/_components/validating_component?props='.urlencode(json_encode($props)).'&updated='.urlencode(json_encode($updated));
35+
};
36+
37+
$browser = $this->browser();
38+
$browser
39+
->get($createUrl($dehydratedProps))
40+
->assertSuccessful()
41+
->assertContains('Has Error: no')
42+
->assertContains('Error: ""')
43+
;
44+
45+
$crawler = $browser
46+
->get($createUrl($dehydratedProps, ['name' => 'h', 'validatedFields' => ['name']]))
47+
->assertSuccessful()
48+
->assertContains('Has Error: yes')
49+
->assertContains('Error: "This value is too short. It should have 3 characters or more."')
50+
->crawler()
51+
;
52+
53+
$div = $crawler->filter('[data-controller="live"]');
54+
$dehydratedProps = json_decode($div->attr('data-live-props-value'), true);
55+
56+
// make a normal GET request with no updates and verify validation still happens
57+
$browser
58+
->get($createUrl($dehydratedProps))
59+
->assertSuccessful()
60+
->assertContains('Has Error: yes')
61+
;
62+
}
63+
}

src/TwigComponent/src/ComponentFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public function mountFromObject(object $component, array $data, ComponentMetadat
9191
}
9292

9393
if (!\is_scalar($value) && null !== $value) {
94-
throw new \LogicException(sprintf('Unable to use "%s" (%s) as an attribute. Attributes must be scalar or null. If you meant to mount this value on "%s", make sure "$%1$s" is a writable property or create a mount() method with a "$%1$s" argument.', $key, get_debug_type($value), $component::class));
94+
throw new \LogicException(sprintf('A "%s" prop was passed when creating the "%s" component. No matching %s property or mount() argument was found, so we attempted to use this as an HTML attribute. But, the value is not a scalar (it\'s a %s). Did you mean to pass this to your component or is there a typo on its name?', $key, $componentMetadata->getName(), $key, get_debug_type($value)));
9595
}
9696
}
9797

src/TwigComponent/tests/Integration/ComponentFactoryTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public function testExceptionThrownIfRequiredMountParameterIsMissingFromPassedDa
9393
public function testExceptionThrownIfUnableToWritePassedDataToPropertyAndIsNotScalar(): void
9494
{
9595
$this->expectException(\LogicException::class);
96-
$this->expectExceptionMessage('Unable to use "service" (stdClass) as an attribute. Attributes must be scalar or null.');
96+
$this->expectExceptionMessage('But, the value is not a scalar (it\'s a stdClass)');
9797

9898
$this->createComponent('component_a', ['propB' => 'B', 'service' => new \stdClass()]);
9999
}

0 commit comments

Comments
 (0)