');
+ });
+});
diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts
index 4e70af6e376..b3185ac9a92 100644
--- a/src/LiveComponent/assets/test/tools.ts
+++ b/src/LiveComponent/assets/test/tools.ts
@@ -2,7 +2,7 @@ import { Application } from '@hotwired/stimulus';
import LiveController from '../src/live_controller';
import { waitFor } from '@testing-library/dom';
import fetchMock from 'fetch-mock-jest';
-import { buildSearchParams } from '../src/http_data_helper';
+import MockOptions = jest.MockOptions;
const TestData = class {
constructor(controller, element) {
@@ -64,17 +64,20 @@ const initLiveComponent = (url, data) => {
* @param {Object} sentData The *expected* data that should be sent to the server
* @param {function} renderCallback Function that will render the component
* @param {function|null} changeDataCallback Specify if you want to change the data before rendering
+ * @param {MockOptions} options Options passed to fetchMock
*/
-const mockRerender = (sentData, renderCallback, changeDataCallback = null) => {
- const params = new URLSearchParams('');
+const mockRerender = (sentData: any, renderCallback, changeDataCallback = null, options: MockOptions = {}) => {
+ const params = new URLSearchParams({
+ data: JSON.stringify(sentData)
+ });
- const url = `end:?${buildSearchParams(params, sentData).toString()}`;
+ const url = `end:?${params.toString()}`;
if (changeDataCallback) {
changeDataCallback(sentData);
}
- fetchMock.mock(url, renderCallback(sentData));
+ fetchMock.mock(url, renderCallback(sentData), options);
}
export { startStimulus, getControllerElement, initLiveComponent, mockRerender };
diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json
index 21ff0e06f7e..5e9c66a0942 100644
--- a/src/LiveComponent/composer.json
+++ b/src/LiveComponent/composer.json
@@ -34,6 +34,7 @@
"doctrine/doctrine-bundle": "^2.0",
"doctrine/orm": "^2.7",
"symfony/dependency-injection": "^5.4|^6.0",
+ "symfony/form": "^5.4|^6.0",
"symfony/framework-bundle": "^5.4|^6.0",
"symfony/phpunit-bridge": "^6.0",
"symfony/security-csrf": "^5.4|^6.0",
diff --git a/src/LiveComponent/phpunit.xml.dist b/src/LiveComponent/phpunit.xml.dist
index c86748b796a..54548d66017 100644
--- a/src/LiveComponent/phpunit.xml.dist
+++ b/src/LiveComponent/phpunit.xml.dist
@@ -10,7 +10,7 @@
>
-
+
diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php
index bcd0f20092e..0e23ee2cbf1 100644
--- a/src/LiveComponent/src/ComponentWithFormTrait.php
+++ b/src/LiveComponent/src/ComponentWithFormTrait.php
@@ -17,6 +17,8 @@
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\UX\LiveComponent\Attribute\BeforeReRender;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
+use Symfony\UX\LiveComponent\Util\LiveFormUtility;
+use Symfony\UX\TwigComponent\Attribute\PostMount;
/**
* @author Ryan Weaver
@@ -63,17 +65,29 @@ trait ComponentWithFormTrait
*/
abstract protected function instantiateForm(): FormInterface;
- /**
- * Override in your class if you need extra mounted values.
- *
- * Call $this->setForm($form) manually in that situation
- * if you're passing in an initial form.
- */
- public function mount(?FormView $form = null)
+ #[PostMount]
+ public function postMount(array $data): array
{
- if ($form) {
- $this->setForm($form);
+ // allow the FormView object to be passed into the component() as "form"
+ if (\array_key_exists('form', $data)) {
+ $this->formView = $data['form'];
+ unset($data['form']);
+
+ if ($this->formView) {
+ // if a FormView is passed in and it contains any errors, then
+ // we mark that this entire component has been validated so that
+ // all validation errors continue showing on re-render
+ if (LiveFormUtility::doesFormContainAnyErrors($this->formView)) {
+ $this->isValidated = true;
+ $this->validatedFields = [];
+ }
+ }
}
+
+ // set the formValues from the initial form view's data
+ $this->initializeFormValues();
+
+ return $data;
}
/**
@@ -87,7 +101,7 @@ public function mount(?FormView $form = null)
public function submitFormOnRender(): void
{
if (!$this->getFormInstance()->isSubmitted()) {
- $this->submitForm(false);
+ $this->submitForm($this->isValidated);
}
}
@@ -103,18 +117,6 @@ public function getForm(): FormView
return $this->formView;
}
- /**
- * Call this from mount() if your component receives a FormView.
- *
- * If your are not passing a FormView into your component, you
- * don't need to call this directly: the form will be set for
- * you from your instantiateForm() method.
- */
- public function setForm(FormView $form): void
- {
- $this->formView = $form;
- }
-
public function getFormName(): string
{
if (!$this->formName) {
@@ -124,18 +126,19 @@ public function getFormName(): string
return $this->formName;
}
- public function getFormValues(): array
+ private function initializeFormValues(): void
{
- if (null === $this->formValues) {
- $this->formValues = $this->extractFormValues($this->getForm());
- }
-
- return $this->formValues;
+ $this->formValues = $this->extractFormValues($this->getForm());
}
private function submitForm(bool $validateAll = true): void
{
- $this->getFormInstance()->submit($this->formValues);
+ if (null !== $this->formView) {
+ throw new \LogicException('The submitForm() method is being called, but the FormView has already been built. Are you calling $this->getForm() - which creates the FormView - before submitting the form?');
+ }
+
+ $form = $this->getFormInstance();
+ $form->submit($this->formValues);
if ($validateAll) {
// mark the entire component as validated
@@ -146,10 +149,19 @@ private function submitForm(bool $validateAll = true): void
// we only want to validate fields in validatedFields
// but really, everything is validated at this point, which
// means we need to clear validation on non-matching fields
- $this->clearErrorsForNonValidatedFields($this->getFormInstance(), $this->getFormName());
+ $this->clearErrorsForNonValidatedFields($form, $form->getName());
}
- if (!$this->getFormInstance()->isValid()) {
+ // re-extract the "view" values in case the submitted data
+ // changed the underlying data or structure of the form
+ $this->formValues = $this->extractFormValues($this->getForm());
+ // remove any validatedFields that do not exist in data anymore
+ $this->validatedFields = LiveFormUtility::removePathsNotInData(
+ $this->validatedFields ?? [],
+ [$form->getName() => $this->formValues],
+ );
+
+ if (!$form->isValid()) {
throw new UnprocessableEntityHttpException('Form validation failed in component');
}
}
@@ -166,7 +178,13 @@ private function extractFormValues(FormView $formView): array
$values = [];
foreach ($formView->children as $child) {
$name = $child->vars['name'];
- if (\count($child->children) > 0) {
+
+ // if there are children, expand their values recursively
+ // UNLESS the field is "expanded": in that case the value
+ // is already correct. For example, an expanded ChoiceType with
+ // options "text" and "phone" would already have a value in the format
+ // ["text"] (assuming "text" is checked and "phone" is not).
+ if (!($child->vars['expanded'] ?? false) && \count($child->children) > 0) {
$values[$name] = $this->extractFormValues($child);
continue;
@@ -192,7 +210,7 @@ private function getFormInstance(): FormInterface
return $this->formInstance;
}
- private function clearErrorsForNonValidatedFields(Form $form, $currentPath = ''): void
+ private function clearErrorsForNonValidatedFields(Form $form, string $currentPath = ''): void
{
if (!$currentPath || !\in_array($currentPath, $this->validatedFields, true)) {
$form->clearErrors();
diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
index 1f8ed8c8bd9..2fa51fecd49 100644
--- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
+++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
@@ -16,8 +16,6 @@
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Reference;
-use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\ComponentValidator;
use Symfony\UX\LiveComponent\ComponentValidatorInterface;
@@ -71,7 +69,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])
->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer'])
->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator'])
- ->addTag('container.service_subscriber')
+ ->addTag('container.service_subscriber') // csrf
;
$container->register('ux.live_component.twig.component_extension', LiveComponentTwigExtension::class)
@@ -80,11 +78,9 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
$container->register('ux.live_component.twig.component_runtime', LiveComponentRuntime::class)
->setArguments([
- new Reference('twig'),
new Reference('ux.live_component.component_hydrator'),
new Reference('ux.twig_component.component_factory'),
- new Reference(UrlGeneratorInterface::class),
- new Reference(CsrfTokenManagerInterface::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE),
+ new Reference('router'),
])
->addTag('twig.runtime')
;
@@ -95,7 +91,8 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
$container->register('ux.live_component.add_attributes_subscriber', AddLiveAttributesSubscriber::class)
->addTag('kernel.event_subscriber')
- ->addTag('container.service_subscriber', ['key' => LiveComponentRuntime::class, 'id' => 'ux.live_component.twig.component_runtime'])
+ ->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator'])
+ ->addTag('container.service_subscriber') // csrf, twig & router
;
$container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class);
diff --git a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php
index ab7297623cc..02213565be9 100644
--- a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php
+++ b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php
@@ -4,10 +4,14 @@
use Psr\Container\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
-use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime;
+use Symfony\UX\LiveComponent\LiveComponentHydrator;
use Symfony\UX\TwigComponent\ComponentAttributes;
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
+use Symfony\UX\TwigComponent\MountedComponent;
+use Twig\Environment;
/**
* @author Kevin Bond
@@ -25,11 +29,7 @@ public function onPreRender(PreRenderEvent $event): void
return;
}
- /** @var ComponentAttributes $attributes */
- $attributes = $this->container->get(LiveComponentRuntime::class)
- ->getLiveAttributes($event->getComponent(), $event->getMetadata())
- ;
-
+ $attributes = $this->getLiveAttributes($event->getMountedComponent());
$variables = $event->getVariables();
if (isset($variables['attributes']) && $variables['attributes'] instanceof ComponentAttributes) {
@@ -50,7 +50,32 @@ public static function getSubscribedEvents(): array
public static function getSubscribedServices(): array
{
return [
- LiveComponentRuntime::class,
+ LiveComponentHydrator::class,
+ UrlGeneratorInterface::class,
+ Environment::class,
+ '?'.CsrfTokenManagerInterface::class,
];
}
+
+ private function getLiveAttributes(MountedComponent $mounted): ComponentAttributes
+ {
+ $name = $mounted->getName();
+ $url = $this->container->get(UrlGeneratorInterface::class)->generate('live_component', ['component' => $name]);
+ $data = $this->container->get(LiveComponentHydrator::class)->dehydrate($mounted);
+ $twig = $this->container->get(Environment::class);
+
+ $attributes = [
+ 'data-controller' => 'live',
+ 'data-live-url-value' => twig_escape_filter($twig, $url, 'html_attr'),
+ 'data-live-data-value' => twig_escape_filter($twig, json_encode($data, \JSON_THROW_ON_ERROR), 'html_attr'),
+ ];
+
+ if ($this->container->has(CsrfTokenManagerInterface::class)) {
+ $attributes['data-live-csrf-value'] = $this->container->get(CsrfTokenManagerInterface::class)
+ ->getToken($name)->getValue()
+ ;
+ }
+
+ return new ComponentAttributes($attributes);
+ }
}
diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
index dd54f30f051..a89a7fcef69 100644
--- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
+++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
@@ -33,6 +33,7 @@
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\ComponentRenderer;
+use Symfony\UX\TwigComponent\MountedComponent;
/**
* @author Kevin Bond
@@ -73,6 +74,8 @@ public function onKernelRequest(RequestEvent $event): void
$action = $request->get('action', 'get');
$componentName = (string) $request->get('component');
+ $request->attributes->set('_component_name', $componentName);
+
try {
/** @var ComponentMetadata $metadata */
$metadata = $this->container->get(ComponentFactory::class)->metadataFor($componentName);
@@ -84,8 +87,6 @@ public function onKernelRequest(RequestEvent $event): void
throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.', $metadata->getClass(), $componentName));
}
- $request->attributes->set('_component_metadata', $metadata);
-
if ('get' === $action) {
$defaultAction = trim($metadata->get('default_action', '__invoke'), '()');
@@ -122,10 +123,13 @@ public function onKernelController(ControllerEvent $event): void
return;
}
- $data = array_merge(
- $request->query->all(),
- $request->request->all()
- );
+ if ($request->query->has('data')) {
+ // ?data=
+ $data = json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR);
+ } else {
+ // OR body of the request is JSON
+ $data = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR);
+ }
if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) {
throw new \RuntimeException('Not a valid live component.');
@@ -141,9 +145,13 @@ public function onKernelController(ControllerEvent $event): void
throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, \get_class($component)));
}
- $this->container->get(LiveComponentHydrator::class)->hydrate($component, $data);
+ $mounted = $this->container->get(LiveComponentHydrator::class)->hydrate(
+ $component,
+ $data,
+ $request->attributes->get('_component_name')
+ );
- $request->attributes->set('_component', $component);
+ $request->attributes->set('_mounted_component', $mounted);
if (!\is_string($queryString = $request->query->get('args'))) {
return;
@@ -167,7 +175,7 @@ public function onKernelView(ViewEvent $event): void
return;
}
- $response = $this->createResponse($request->attributes->get('_component'), $request);
+ $response = $this->createResponse($request->attributes->get('_mounted_component'), $request);
$event->setResponse($response);
}
@@ -184,14 +192,14 @@ public function onKernelException(ExceptionEvent $event): void
return;
}
- $component = $request->attributes->get('_component');
+ $mounted = $request->attributes->get('_mounted_component');
// in case the exception was too early somehow
- if (!$component) {
+ if (!$mounted) {
return;
}
- $response = $this->createResponse($component, $request);
+ $response = $this->createResponse($mounted, $request);
$event->setResponse($response);
}
@@ -229,15 +237,16 @@ public static function getSubscribedEvents(): array
];
}
- private function createResponse(object $component, Request $request): Response
+ private function createResponse(MountedComponent $mounted, Request $request): Response
{
+ $component = $mounted->getComponent();
+
foreach (AsLiveComponent::beforeReRenderMethods($component) as $method) {
$component->{$method->name}();
}
$html = $this->container->get(ComponentRenderer::class)->render(
- $component,
- $request->attributes->get('_component_metadata')
+ $mounted,
);
return new Response($html);
diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php
index e79ced7a9f0..5d7e76207d0 100644
--- a/src/LiveComponent/src/LiveComponentHydrator.php
+++ b/src/LiveComponent/src/LiveComponentHydrator.php
@@ -17,6 +17,8 @@
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LivePropContext;
use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException;
+use Symfony\UX\TwigComponent\ComponentAttributes;
+use Symfony\UX\TwigComponent\MountedComponent;
/**
* @author Kevin Bond
@@ -29,6 +31,7 @@ final class LiveComponentHydrator
{
private const CHECKSUM_KEY = '_checksum';
private const EXPOSED_PROP_KEY = '_id';
+ private const ATTRIBUTES_KEY = '_attributes';
/** @var PropertyHydratorInterface[] */
private iterable $propertyHydrators;
@@ -45,8 +48,10 @@ public function __construct(iterable $propertyHydrators, PropertyAccessorInterfa
$this->secret = $secret;
}
- public function dehydrate(object $component): array
+ public function dehydrate(MountedComponent $mounted): array
{
+ $component = $mounted->getComponent();
+
foreach (AsLiveComponent::preDehydrateMethods($component) as $method) {
$component->{$method->name}();
}
@@ -100,15 +105,24 @@ public function dehydrate(object $component): array
}
}
+ if ($attributes = $mounted->getAttributes()->all()) {
+ $data[self::ATTRIBUTES_KEY] = $attributes;
+ $readonlyProperties[] = self::ATTRIBUTES_KEY;
+ }
+
$data[self::CHECKSUM_KEY] = $this->computeChecksum($data, $readonlyProperties);
return $data;
}
- public function hydrate(object $component, array $data): void
+ public function hydrate(object $component, array $data, string $componentName): MountedComponent
{
$readonlyProperties = [];
+ if (isset($data[self::ATTRIBUTES_KEY])) {
+ $readonlyProperties[] = self::ATTRIBUTES_KEY;
+ }
+
/** @var LivePropContext[] $propertyContexts */
$propertyContexts = iterator_to_array(AsLiveComponent::liveProps($component));
@@ -129,7 +143,9 @@ public function hydrate(object $component, array $data): void
$this->verifyChecksum($data, $readonlyProperties);
- unset($data[self::CHECKSUM_KEY]);
+ $attributes = new ComponentAttributes($data[self::ATTRIBUTES_KEY] ?? []);
+
+ unset($data[self::CHECKSUM_KEY], $data[self::ATTRIBUTES_KEY]);
foreach ($propertyContexts as $context) {
$property = $context->reflectionProperty();
@@ -187,6 +203,8 @@ public function hydrate(object $component, array $data): void
foreach (AsLiveComponent::postHydrateMethods($component) as $method) {
$component->{$method->name}();
}
+
+ return new MountedComponent($componentName, $component, $attributes);
}
private function computeChecksum(array $data, array $readonlyProperties): string
diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst
index 41891718cc3..6b161ead31a 100644
--- a/src/LiveComponent/src/Resources/doc/index.rst
+++ b/src/LiveComponent/src/Resources/doc/index.rst
@@ -114,7 +114,7 @@ Oh, and just one more step! Import a routing file from the bundle:
That's it! We're ready!
-Making your Component “Live”
+Making your Component "Live"
----------------------------
If you haven't already, check out the `Twig Component`_
@@ -143,7 +143,7 @@ Suppose you've already built a basic Twig component::
{{ this.randomNumber }}
-To transform this into a “live” component (i.e. one that can be
+To transform this into a "live" component (i.e. one that can be
re-rendered live on the frontend), replace the component's
``AsTwigComponent`` attribute with ``AsLiveComponent`` and add the
``DefaultActionTrait``:
@@ -231,14 +231,14 @@ when rendering the component:
{{ component('random_number', { min: 5, max: 500 }) }}
But what's up with those ``LiveProp`` attributes? A property with the
-``LiveProp`` attribute becomes a “stateful” property for this component.
-In other words, each time we click the “Generate a new number!” button,
+``LiveProp`` attribute becomes a "stateful" property for this component.
+In other words, each time we click the "Generate a new number!" button,
when the component re-renders, it will *remember* the original values
for the ``$min`` and ``$max`` properties and generate a random number
between 5 and 500. If you forgot to add ``LiveProp``, when the component
re-rendered, those two values would *not* be set on the object.
-In short: LiveProps are “stateful properties”: they will always be set
+In short: LiveProps are "stateful properties": they will always be set
when rendering. Most properties will be LiveProps, with common
exceptions being properties that hold services (these don't need to be
stateful because they will be autowired each time before the component
@@ -249,30 +249,15 @@ Component Attributes
.. versionadded:: 2.1
- The ``HasAttributes`` trait was added in TwigComponents 2.1.
+ Component attributes were added in TwigComponents 2.1.
`Component attributes`_ allows you to render your components with extra
props that are are converted to html attributes and made available in
your component's template as an ``attributes`` variable. When used on
-live components, these props are persisted between renders. You can enable
-this feature by having your live component use the ``HasAttributesTrait``:
+live components, these props are persisted between renders.
-.. code-block:: diff
-
- // ...
- use Symfony\UX\LiveComponent\Attribute\LiveProp;
- + use Symfony\UX\TwigComponent\HasAttributesTrait;
-
- #[AsLiveComponent('random_number')]
- class RandomNumberComponent
- {
- + use HasAttributesTrait;
-
- #[LiveProp]
- public int $min = 0;
-
-Now, when rendering your component, you can pass html attributes
-as props and these will be added to ``attributes``:
+When rendering your component, you can pass html attributes as props and
+these will be added to ``attributes``:
.. code-block:: twig
@@ -282,7 +267,7 @@ as props and these will be added to ``attributes``:
>
-data-action=“live#update”: Re-rendering on LiveProp Change
+data-action="live#update": Re-rendering on LiveProp Change
----------------------------------------------------------
Could we allow the user to *choose* the ``$min`` and ``$max`` values and
@@ -320,7 +305,7 @@ that field! Yes, as you type in a box, the component automatically
updates to reflect the new number!
Well, actually, we're missing one step. By default, a ``LiveProp`` is
-“read only”. For security purposes, a user cannot change the value of a
+"read only". For security purposes, a user cannot change the value of a
``LiveProp`` and re-render the component unless you allow it with the
``writable=true`` option:
@@ -354,7 +339,7 @@ method has built-in debouncing: it waits for a 150ms pause before
sending an Ajax request to re-render. This is built in, so you don't
need to think about it.
-Lazy Updating on “blur” or “change” of a Field
+Lazy Updating on "blur" or "change" of a Field
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Sometimes, you might want a field to re-render only after the user has
@@ -375,7 +360,7 @@ happens, add it to the ``data-action`` call:
The ``data-action="change->live#update"`` syntax is standard Stimulus
syntax, which says:
- When the “change” event occurs, call the ``update`` method on the
+ When the "change" event occurs, call the ``update`` method on the
``live`` controller.
.. _deferring-a-re-render-until-later:
@@ -397,7 +382,7 @@ clicked). To do that, use the ``updateDefer`` method:
+ data-action="live#updateDefer"
>
-Now, as you type, the ``max`` “model” will be updated in JavaScript, but
+Now, as you type, the ``max`` "model" will be updated in JavaScript, but
it won't, yet, make an Ajax call to re-render the component. Whenever
the next re-render *does* happen, the updated ``max`` value will be
used.
@@ -500,7 +485,7 @@ changes until loading has taken longer than a certain amount of time:
Actions
-------
-Live components require a single “default action” that is used to
+Live components require a single "default action" that is used to
re-render it. By default, this is an empty ``__invoke()`` method and can
be added with the ``DefaultActionTrait``. Live components are actually
Symfony controllers so you can add the normal controller
@@ -508,7 +493,7 @@ attributes/annotations (ie ``@Cache``/``@Security``) to either the
entire class just a single action.
You can also trigger custom actions on your component. Let's pretend we
-want to add a “Reset Min/Max” button to our “random number” component
+want to add a "Reset Min/Max" button to our "random number" component
that, when clicked, sets the min/max numbers back to a default value.
First, add a method with a ``LiveAction`` attribute above it that does
@@ -549,7 +534,7 @@ will trigger the ``resetMinMax()`` method! After calling that method,
the component will re-render like normal, using the new ``$min`` and
``$max`` properties!
-You can also add several “modifiers” to the action:
+You can also add several "modifiers" to the action:
.. code-block:: twig
@@ -562,7 +547,7 @@ You can also add several “modifiers” to the action:
The ``prevent`` modifier would prevent the form from submitting
(``event.preventDefault()``). The ``debounce(300)`` modifier will add
-300ms of “debouncing” before the action is executed. In other words, if
+300ms of "debouncing" before the action is executed. In other words, if
you click really fast 5 times, only one Ajax request will be made!
Actions & Services
@@ -807,9 +792,9 @@ make it easy to deal with forms::
* The `fieldName` option is needed in this situation because
* the form renders fields with names like `name="post[title]"`.
* We set `fieldName: ''` so that this live prop doesn't collide
- * with that data. The value - initialFormData - could be anything.
+ * with that data. The value - data - could be anything.
*/
- #[LiveProp(fieldName: 'initialFormData')]
+ #[LiveProp(fieldName: 'data')]
public ?Post $post = null;
/**
@@ -881,14 +866,14 @@ This is possible thanks to a few interesting pieces:
the user, its validation errors are cleared so that they aren't
rendered.
-Handling “Cannot dehydrate an unpersisted entity” Errors.
+Handling "Cannot dehydrate an unpersisted entity" Errors.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you're building a form to create a *new* entity, then when you render
the component, you may be passing in a new, non-persisted entity.
For example, imagine you create a ``new Post()`` in your controller,
-pass this “not-yet-persisted” entity into your template as a ``post``
+pass this "not-yet-persisted" entity into your template as a ``post``
variable and pass *that* into your component:
.. code-block:: twig
@@ -906,8 +891,8 @@ If you do this, you'll likely see this error:
The problem is that the Live component system doesn't know how to
transform this object into something that can be sent to the frontend,
-called “dehydration”. If an entity has already been saved to the
-database, its “id” is sent to the frontend. But if the entity hasn't
+called "dehydration". If an entity has already been saved to the
+database, its "id" is sent to the frontend. But if the entity hasn't
been saved yet, that's not possible.
The solution is to pass ``null`` into your component instead of a
@@ -933,10 +918,10 @@ behave how you want.
If you're re-rendering a field on the ``input`` event (that's the
default event on a field, which is fired each time you type in a text
-box), then if you type a “space” and pause for a moment, the space will
+box), then if you type a "space" and pause for a moment, the space will
disappear!
-This is because Symfony text fields “trim spaces” automatically. When
+This is because Symfony text fields "trim spaces" automatically. When
your component re-renders, the space will disappear… as the user is
typing! To fix this, either re-render on the ``change`` event (which
fires after the text box loses focus) or set the ``trim`` option of your
@@ -1070,6 +1055,152 @@ section above) is to add:
+ data-action="change->live#update"
>
+Using Actions to Change your Form: CollectionType
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Symfony's `CollectionType`_ can be used to embed a collection of
+embedded forms including allowing the user to dynamically add or remove
+them. Live components can accomplish make this all possible while
+writing zero JavaScript.
+
+For example, imagine a "Blog Post" form with an embedded "Comment" forms
+via the ``CollectionType``::
+
+ namespace App\Form;
+
+ use Symfony\Component\Form\AbstractType;
+ use Symfony\Component\Form\Extension\Core\Type\CollectionType;
+ use Symfony\Component\Form\FormBuilderInterface;
+ use Symfony\Component\OptionsResolver\OptionsResolver;
+ use App\Entity\BlogPost;
+
+ class BlogPostFormType extends AbstractType
+ {
+ public function buildForm(FormBuilderInterface $builder, array $options)
+ {
+ $builder
+ ->add('title', TextType::class)
+ // ...
+ ->add('comments', CollectionType::class, [
+ 'entry_type' => CommentFormType::class,
+ 'allow_add' => true,
+ 'allow_delete' => true,
+ 'by_reference' => false,
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver)
+ {
+ $resolver->setDefaults(['data_class' => BlogPost::class]);
+ }
+ }
+
+Now, create a Twig component to render the form::
+
+ namespace App\Twig;
+
+ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+ use Symfony\Component\Form\FormInterface;
+ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+ use Symfony\UX\LiveComponent\Attribute\LiveAction;
+ use Symfony\UX\LiveComponent\ComponentWithFormTrait;
+ use Symfony\UX\LiveComponent\DefaultActionTrait;
+ use App\Entity\BlogPost;
+ use App\Entity\Comment;
+ use App\Form\BlogPostFormType;
+
+ #[AsLiveComponent('blog_post_collection_type')]
+ class BlogPostCollectionTypeComponent extends AbstractController
+ {
+ use ComponentWithFormTrait;
+ use DefaultActionTrait;
+
+ #[LiveProp]
+ public BlogPost $post;
+
+ protected function instantiateForm(): FormInterface
+ {
+ return $this->createForm(BlogPostFormType::class, $this->post);
+ }
+
+ #[LiveAction]
+ public function addComment()
+ {
+ // "formValues" represents the current data in the form
+ // this modifies the form to add an extra comment
+ // the result: another embedded comment form!
+ // change "comments" to the name of the field that uses CollectionType
+ $this->formValues['comments'][] = [];
+ }
+
+ #[LiveAction]
+ public function removeComment(#[LiveArg] int $index)
+ {
+ unset($this->formValues['comments'][$index]);
+ }
+ }
+
+The template for this component has two jobs: (1) render the form
+like normal and (2) include links that trigger the ``addComment()``
+and ``removeComment()`` actions:
+
+.. code-block:: twig
+
+
+ {% for key, commentForm in this.form.comments %}
+
+
+ {{ form_widget(commentForm) }}
+ {% endfor %}
+
+
+ {# avoid an extra label for this field #}
+ {% do this.form.comments.setRendered %}
+
+
+
+
+ {{ form_end(this.form) }}
+
+
+Done! Behind the scenes, it works like this:
+
+A) When the user clicks "+ Add Comment", an Ajax request is sent that
+triggers the ``addComment()`` action.
+
+B) ``addComment()`` modifies ``formValues``, which you can think of as
+the raw "POST" data of your form.
+
+C) Still during the Ajax request, the ``formValues`` are "submitted"
+into your form. The new key inside of ``$this->formValues['comments']``
+tells the ``CollectionType`` that you want a new, embedded form.
+
+D) The form is rendered - now with another embedded form! - and the
+Ajax call returns with the form (with the new embedded form).
+
+When the user clicks ``removeComment()``, a similar process happens.
+
+.. note::
+
+ When working with Doctrine entities, add ``orphanRemoval: true``
+ and ``cascade={"persist"}`` to your ``OneToMany`` relationship.
+ In this example, these options would be added to the ``OneToMany``
+ attribute above the ``Post.comments`` property. These help new
+ items save and deletes any items whose embedded forms are removed.
+
Modifying Embedded Properties with the "exposed" Option
-------------------------------------------------------
@@ -1091,7 +1222,7 @@ edited::
public Post $post;
}
-In the template, let's render an HTML form *and* a “preview” area where
+In the template, let's render an HTML form *and* a "preview" area where
the user can see, as they type, what the post will look like (including
rendered the ``content`` through a Markdown filter from the
``twig/markdown-extra`` library):
@@ -1187,7 +1318,7 @@ where you want the object on that property to also be validated.
Thanks to this setup, the component will now be automatically validated
on each render, but in a smart way: a property will only be validated
-once its “model” has been updated on the frontend. The system keeps
+once its "model" has been updated on the frontend. The system keeps
track of which models have been updated
(e.g. ``data-action="live#update"``) and only stores the errors for
those fields on re-render.
@@ -1229,7 +1360,7 @@ method:
class="{{ this.getError('post.content') ? 'has-error' : '' }}"
>{{ post.content }}
-Once a component has been validated, the component will “rememeber” that
+Once a component has been validated, the component will "rememeber" that
it has been validated. This means that, if you edit a field and the
component re-renders, it will be validated again.
@@ -1238,7 +1369,7 @@ Real Time Validation
As soon as you enable validation, each field will automatically be
validated when its model is updated. For example, if you want a single
-field to be validated “on change” (when you change the field and then
+field to be validated "on change" (when you change the field and then
blur the field), update the model via the ``change`` event:
.. code-block:: twig
@@ -1251,13 +1382,13 @@ blur the field), update the model via the ``change`` event:
When the component re-renders, it will signal to the server that this
one field should be validated. Like with normal validation, once an
-individual field has been validated, the component “remembers” that, and
+individual field has been validated, the component "remembers" that, and
re-validates it on each render.
Polling
-------
-You can also use “polling” to continually refresh a component. On the
+You can also use "polling" to continually refresh a component. On the
**top-level** element for your component, add ``data-poll``:
.. code-block:: diff
@@ -1279,7 +1410,7 @@ delay for 500ms:
data-poll="delay(500)|$render"
>
-You can also trigger a specific “action” instead of a normal re-render:
+You can also trigger a specific "action" instead of a normal re-render:
.. code-block:: twig
@@ -1313,7 +1444,7 @@ component is its own, isolated universe.
But this is not always what you want. For example, suppose you have a
parent component that renders a form and a child component that renders
-one field in that form. When you click a “Save” button on the parent
+one field in that form. When you click a "Save" button on the parent
component, that validates the form and re-renders with errors -
including a new ``error`` value that it passes into the child:
@@ -1376,7 +1507,7 @@ event is dispatched. All components automatically listen to this event.
This means that, when the ``markdown_value`` model is updated in the
child component, *if* the parent component *also* has a model called
``markdown_value`` it will *also* be updated. This is done as a
-“deferred” update
+"deferred" update
(i.e. :ref:`updateDefer() `).
If the model name in your child component (e.g. ``markdown_value``) is
@@ -1519,6 +1650,20 @@ form. But it also makes sure that when the ``textarea`` changes, both
the ``value`` model in ``MarkdownTextareaComponent`` *and* the
``post.content`` model in ``EditPostcomponent`` will be updated.
+Skipping Updating Certain Elements
+----------------------------------
+
+Sometimes you may have an element inside a component that you do *not* want to
+change whenever your component re-renders. For example, some elements managed by
+third-party JavaScript or a form element that is not bound to a model... where you
+don't want a re-render to reset the data the user has entered.
+
+To handle this, add the ``data-live-ignore`` attribute to the element:
+
+.. code-block:: html
+
+
+
Backward Compatibility promise
------------------------------
@@ -1539,3 +1684,4 @@ bound to Symfony's BC policy for the moment.
.. _`dependent form fields`: https://symfony.com/doc/current/form/dynamic_form_modification.html#dynamic-generation-for-submitted-forms
.. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html
.. _`Component attributes`: https://symfony.com/bundles/ux-twig-component/current/index.html#component-attributes
+.. _`CollectionType`: https://symfony.com/doc/current/form/form_collections.html
diff --git a/src/LiveComponent/src/Twig/LiveComponentRuntime.php b/src/LiveComponent/src/Twig/LiveComponentRuntime.php
index 65856865719..d843da6c2a5 100644
--- a/src/LiveComponent/src/Twig/LiveComponentRuntime.php
+++ b/src/LiveComponent/src/Twig/LiveComponentRuntime.php
@@ -12,12 +12,8 @@
namespace Symfony\UX\LiveComponent\Twig;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\UX\LiveComponent\LiveComponentHydrator;
-use Symfony\UX\TwigComponent\ComponentAttributes;
use Symfony\UX\TwigComponent\ComponentFactory;
-use Symfony\UX\TwigComponent\ComponentMetadata;
-use Twig\Environment;
/**
* @author Kevin Bond
@@ -27,37 +23,17 @@
final class LiveComponentRuntime
{
public function __construct(
- private Environment $twig,
private LiveComponentHydrator $hydrator,
private ComponentFactory $factory,
private UrlGeneratorInterface $urlGenerator,
- private ?CsrfTokenManagerInterface $csrfTokenManager = null
) {
}
public function getComponentUrl(string $name, array $props = []): string
{
- $component = $this->factory->create($name, $props);
- $params = ['component' => $name] + $this->hydrator->dehydrate($component);
+ $mounted = $this->factory->create($name, $props);
+ $params = ['component' => $name] + $this->hydrator->dehydrate($mounted);
return $this->urlGenerator->generate('live_component', $params);
}
-
- public function getLiveAttributes(object $component, ComponentMetadata $metadata): ComponentAttributes
- {
- $url = $this->urlGenerator->generate('live_component', ['component' => $metadata->getName()]);
- $data = $this->hydrator->dehydrate($component);
-
- $attributes = [
- 'data-controller' => 'live',
- 'data-live-url-value' => twig_escape_filter($this->twig, $url, 'html_attr'),
- 'data-live-data-value' => twig_escape_filter($this->twig, json_encode($data, \JSON_THROW_ON_ERROR), 'html_attr'),
- ];
-
- if ($this->csrfTokenManager) {
- $attributes['data-live-csrf-value'] = $this->csrfTokenManager->getToken($metadata->getName())->getValue();
- }
-
- return new ComponentAttributes($attributes);
- }
}
diff --git a/src/LiveComponent/src/Util/LiveFormUtility.php b/src/LiveComponent/src/Util/LiveFormUtility.php
new file mode 100644
index 00000000000..085f090bfc9
--- /dev/null
+++ b/src/LiveComponent/src/Util/LiveFormUtility.php
@@ -0,0 +1,60 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\LiveComponent\Util;
+
+use Symfony\Component\Form\FormView;
+
+final class LiveFormUtility
+{
+ /**
+ * Removes the "paths" not present in the $data array.
+ *
+ * Given an array of paths - ['name', 'post.title', 'post.description'] -
+ * and a $data array - ['name' => 'Ryan', 'post' => ['title' => 'Hi there!']] -
+ * this removes any "paths" not present in the array.
+ */
+ public static function removePathsNotInData(array $paths, array $data): array
+ {
+ return array_values(array_filter($paths, static function ($path) use ($data) {
+ $parts = explode('.', $path);
+ while (\count($parts) > 0) {
+ $part = $parts[0];
+ if (!\array_key_exists($part, $data)) {
+ return false;
+ }
+
+ // reset $parts and go to the next level
+ unset($parts[0]);
+ $parts = array_values($parts);
+ $data = $data[$part];
+ }
+
+ // key was found at all levels
+ return true;
+ }));
+ }
+
+ public static function doesFormContainAnyErrors(FormView $formView): bool
+ {
+ if (($formView->vars['errors'] ?? null) && \count($formView->vars['errors']) > 0) {
+ return true;
+ }
+
+ foreach ($formView->children as $childView) {
+ if (self::doesFormContainAnyErrors($childView)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/LiveComponent/tests/Fixture/Component/Component1.php b/src/LiveComponent/tests/Fixtures/Component/Component1.php
similarity index 87%
rename from src/LiveComponent/tests/Fixture/Component/Component1.php
rename to src/LiveComponent/tests/Fixtures/Component/Component1.php
index f1f7a255bca..dbf40a126a7 100644
--- a/src/LiveComponent/tests/Fixture/Component/Component1.php
+++ b/src/LiveComponent/tests/Fixtures/Component/Component1.php
@@ -9,13 +9,13 @@
* file that was distributed with this source code.
*/
-namespace Symfony\UX\LiveComponent\Tests\Fixture\Component;
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
-use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
/**
* @author Kevin Bond
diff --git a/src/LiveComponent/tests/Fixture/Component/Component2.php b/src/LiveComponent/tests/Fixtures/Component/Component2.php
similarity index 96%
rename from src/LiveComponent/tests/Fixture/Component/Component2.php
rename to src/LiveComponent/tests/Fixtures/Component/Component2.php
index a52c060dedd..51ca3415a40 100644
--- a/src/LiveComponent/tests/Fixture/Component/Component2.php
+++ b/src/LiveComponent/tests/Fixtures/Component/Component2.php
@@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
-namespace Symfony\UX\LiveComponent\Tests\Fixture\Component;
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
diff --git a/src/LiveComponent/tests/Fixture/Component/Component3.php b/src/LiveComponent/tests/Fixtures/Component/Component3.php
similarity index 92%
rename from src/LiveComponent/tests/Fixture/Component/Component3.php
rename to src/LiveComponent/tests/Fixtures/Component/Component3.php
index 983dc03eee3..f038e0d6b64 100644
--- a/src/LiveComponent/tests/Fixture/Component/Component3.php
+++ b/src/LiveComponent/tests/Fixtures/Component/Component3.php
@@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
-namespace Symfony\UX\LiveComponent\Tests\Fixture\Component;
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
diff --git a/src/LiveComponent/tests/Fixture/Component/Component4.php b/src/LiveComponent/tests/Fixtures/Component/Component4.php
similarity index 94%
rename from src/LiveComponent/tests/Fixture/Component/Component4.php
rename to src/LiveComponent/tests/Fixtures/Component/Component4.php
index 0461b69c9b6..71719ba6304 100644
--- a/src/LiveComponent/tests/Fixture/Component/Component4.php
+++ b/src/LiveComponent/tests/Fixtures/Component/Component4.php
@@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
-namespace Symfony\UX\LiveComponent\Tests\Fixture\Component;
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\BeforeReRender;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
diff --git a/src/LiveComponent/tests/Fixture/Component/Component5.php b/src/LiveComponent/tests/Fixtures/Component/Component5.php
similarity index 89%
rename from src/LiveComponent/tests/Fixture/Component/Component5.php
rename to src/LiveComponent/tests/Fixtures/Component/Component5.php
index 43703f1b359..db48bf86f89 100644
--- a/src/LiveComponent/tests/Fixture/Component/Component5.php
+++ b/src/LiveComponent/tests/Fixtures/Component/Component5.php
@@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
-namespace Symfony\UX\LiveComponent\Tests\Fixture\Component;
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
diff --git a/src/LiveComponent/tests/Fixture/Component/Component6.php b/src/LiveComponent/tests/Fixtures/Component/Component6.php
similarity index 94%
rename from src/LiveComponent/tests/Fixture/Component/Component6.php
rename to src/LiveComponent/tests/Fixtures/Component/Component6.php
index e1d1bcc529f..1d928135be6 100644
--- a/src/LiveComponent/tests/Fixture/Component/Component6.php
+++ b/src/LiveComponent/tests/Fixtures/Component/Component6.php
@@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
-namespace Symfony\UX\LiveComponent\Tests\Fixture\Component;
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
diff --git a/src/LiveComponent/tests/Fixture/Component/ComponentWithAttributes.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php
similarity index 66%
rename from src/LiveComponent/tests/Fixture/Component/ComponentWithAttributes.php
rename to src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php
index 5f79b50a9f0..535b785e6ab 100644
--- a/src/LiveComponent/tests/Fixture/Component/ComponentWithAttributes.php
+++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php
@@ -1,10 +1,9 @@
@@ -13,5 +12,4 @@
final class ComponentWithAttributes
{
use DefaultActionTrait;
- use HasAttributesTrait;
}
diff --git a/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php b/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php
new file mode 100644
index 00000000000..1686b9a9987
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php
@@ -0,0 +1,56 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
+
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Form\FormInterface;
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveAction;
+use Symfony\UX\LiveComponent\Attribute\LiveArg;
+use Symfony\UX\LiveComponent\ComponentWithFormTrait;
+use Symfony\UX\LiveComponent\DefaultActionTrait;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\BlogPost;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Comment;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Form\BlogPostFormType;
+
+#[AsLiveComponent('form_with_collection_type')]
+class FormWithCollectionTypeComponent extends AbstractController
+{
+ use ComponentWithFormTrait;
+ use DefaultActionTrait;
+
+ public BlogPost $post;
+
+ public function __construct()
+ {
+ $this->post = new BlogPost();
+ // start with 1 comment
+ $this->post->comments[] = new Comment();
+ }
+
+ protected function instantiateForm(): FormInterface
+ {
+ return $this->createForm(BlogPostFormType::class, $this->post);
+ }
+
+ #[LiveAction]
+ public function addComment()
+ {
+ $this->formValues['comments'][] = [];
+ }
+
+ #[LiveAction]
+ public function removeComment(#[LiveArg] int $index)
+ {
+ unset($this->formValues['comments'][$index]);
+ }
+}
diff --git a/src/LiveComponent/tests/Fixtures/Dto/BlogPost.php b/src/LiveComponent/tests/Fixtures/Dto/BlogPost.php
new file mode 100644
index 00000000000..b57f2cfe721
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/Dto/BlogPost.php
@@ -0,0 +1,26 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto;
+
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+
+class BlogPost
+{
+ #[NotBlank(message: 'The title field should not be blank')]
+ public $title;
+
+ #[Length(min: 100, minMessage: 'The content field is too short')]
+ public $content;
+
+ public $comments = [];
+}
diff --git a/src/LiveComponent/tests/Fixtures/Dto/Comment.php b/src/LiveComponent/tests/Fixtures/Dto/Comment.php
new file mode 100644
index 00000000000..37b245a96bc
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/Dto/Comment.php
@@ -0,0 +1,19 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto;
+
+class Comment
+{
+ public ?string $content;
+
+ public ?BlogPost $blogPost;
+}
diff --git a/src/LiveComponent/tests/Fixture/Entity/Entity1.php b/src/LiveComponent/tests/Fixtures/Entity/Entity1.php
similarity index 87%
rename from src/LiveComponent/tests/Fixture/Entity/Entity1.php
rename to src/LiveComponent/tests/Fixtures/Entity/Entity1.php
index 48f63093302..4187ad5c486 100644
--- a/src/LiveComponent/tests/Fixture/Entity/Entity1.php
+++ b/src/LiveComponent/tests/Fixtures/Entity/Entity1.php
@@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
-namespace Symfony\UX\LiveComponent\Tests\Fixture\Entity;
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Entity;
use Doctrine\ORM\Mapping as ORM;
diff --git a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php
new file mode 100644
index 00000000000..4c302871c32
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php
@@ -0,0 +1,46 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Form;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\CollectionType;
+use Symfony\Component\Form\Extension\Core\Type\TextareaType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\BlogPost;
+
+class BlogPostFormType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options)
+ {
+ $builder
+ ->add('title', TextType::class)
+ ->add('content', TextareaType::class)
+ ->add('comments', CollectionType::class, [
+ 'entry_type' => CommentFormType::class,
+ 'allow_add' => true,
+ 'allow_delete' => true,
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver)
+ {
+ $resolver->setDefaults([
+ 'csrf_protection' => false,
+ 'data_class' => BlogPost::class,
+ ]);
+ }
+}
diff --git a/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php b/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php
new file mode 100644
index 00000000000..c836fef70b1
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php
@@ -0,0 +1,38 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Form;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\TextareaType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Comment;
+
+class CommentFormType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options)
+ {
+ $builder
+ ->add('content', TextareaType::class)
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver)
+ {
+ $resolver->setDefaults([
+ 'csrf_protection' => false,
+ 'data_class' => Comment::class,
+ ]);
+ }
+}
diff --git a/src/LiveComponent/tests/Fixture/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php
similarity index 84%
rename from src/LiveComponent/tests/Fixture/Kernel.php
rename to src/LiveComponent/tests/Fixtures/Kernel.php
index 14b5e059efc..cc1be180a41 100644
--- a/src/LiveComponent/tests/Fixture/Kernel.php
+++ b/src/LiveComponent/tests/Fixtures/Kernel.php
@@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
-namespace Symfony\UX\LiveComponent\Tests\Fixture;
+namespace Symfony\UX\LiveComponent\Tests\Fixtures;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Psr\Log\NullLogger;
@@ -22,11 +22,12 @@
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
use Symfony\UX\LiveComponent\LiveComponentBundle;
-use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1;
-use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2;
-use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component3;
-use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component6;
-use Symfony\UX\LiveComponent\Tests\Fixture\Component\ComponentWithAttributes;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component1;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component3;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component6;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Component\ComponentWithAttributes;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithCollectionTypeComponent;
use Symfony\UX\TwigComponent\TwigComponentBundle;
use Twig\Environment;
@@ -68,6 +69,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
$c->register(Component3::class)->setAutoconfigured(true)->setAutowired(true);
$c->register(Component6::class)->setAutoconfigured(true)->setAutowired(true);
$c->register(ComponentWithAttributes::class)->setAutoconfigured(true)->setAutowired(true);
+ $c->register(FormWithCollectionTypeComponent::class)->setAutoconfigured(true)->setAutowired(true);
$c->loadFromExtension('framework', [
'secret' => 'S3CRET',
@@ -78,7 +80,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
]);
$c->loadFromExtension('twig', [
- 'default_path' => '%kernel.project_dir%/tests/Fixture/templates',
+ 'default_path' => '%kernel.project_dir%/tests/Fixtures/templates',
]);
$c->loadFromExtension('doctrine', [
@@ -90,8 +92,8 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
'Test' => [
'is_bundle' => false,
'type' => 'annotation',
- 'dir' => '%kernel.project_dir%/tests/Fixture/Entity',
- 'prefix' => 'Symfony\UX\LiveComponent\Tests\Fixture\Entity',
+ 'dir' => '%kernel.project_dir%/tests/Fixtures/Entity',
+ 'prefix' => 'Symfony\UX\LiveComponent\Tests\Fixtures\Entity',
'alias' => 'Test',
],
],
diff --git a/src/LiveComponent/tests/Fixture/templates/component_url.html.twig b/src/LiveComponent/tests/Fixtures/templates/component_url.html.twig
similarity index 100%
rename from src/LiveComponent/tests/Fixture/templates/component_url.html.twig
rename to src/LiveComponent/tests/Fixtures/templates/component_url.html.twig
diff --git a/src/LiveComponent/tests/Fixture/templates/components/component1.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/component1.html.twig
similarity index 100%
rename from src/LiveComponent/tests/Fixture/templates/components/component1.html.twig
rename to src/LiveComponent/tests/Fixtures/templates/components/component1.html.twig
diff --git a/src/LiveComponent/tests/Fixture/templates/components/component2.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/component2.html.twig
similarity index 100%
rename from src/LiveComponent/tests/Fixture/templates/components/component2.html.twig
rename to src/LiveComponent/tests/Fixtures/templates/components/component2.html.twig
diff --git a/src/LiveComponent/tests/Fixture/templates/components/component6.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/component6.html.twig
similarity index 100%
rename from src/LiveComponent/tests/Fixture/templates/components/component6.html.twig
rename to src/LiveComponent/tests/Fixtures/templates/components/component6.html.twig
diff --git a/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig
new file mode 100644
index 00000000000..56334201bc5
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig
@@ -0,0 +1,3 @@
+
+ {{ form(this.form) }}
+
diff --git a/src/LiveComponent/tests/Fixture/templates/template1.html.twig b/src/LiveComponent/tests/Fixtures/templates/template1.html.twig
similarity index 100%
rename from src/LiveComponent/tests/Fixture/templates/template1.html.twig
rename to src/LiveComponent/tests/Fixtures/templates/template1.html.twig
diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
index 80ef23e3892..6225d823c20 100644
--- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
+++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php
@@ -14,10 +14,7 @@
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\UX\LiveComponent\LiveComponentHydrator;
-use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1;
-use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2;
-use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component6;
-use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
use Symfony\UX\TwigComponent\ComponentFactory;
use Zenstruck\Browser\Response\HtmlResponse;
use Zenstruck\Browser\Test\HasBrowser;
@@ -42,7 +39,6 @@ public function testCanRenderComponentAsHtml(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');
- /** @var Component1 $component */
$component = $factory->create('component1', [
'prop1' => $entity = create(Entity1::class)->object(),
'prop2' => $date = new \DateTime('2021-03-05 9:23'),
@@ -54,7 +50,7 @@ public function testCanRenderComponentAsHtml(): void
$this->browser()
->throwExceptions()
- ->get('/_components/component1?'.http_build_query($dehydrated))
+ ->get('/_components/component1?data='.urlencode(json_encode($dehydrated)))
->assertSuccessful()
->assertHeaderContains('Content-Type', 'html')
->assertContains('Prop1: '.$entity->id)
@@ -72,15 +68,12 @@ public function testCanExecuteComponentAction(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');
- /** @var Component2 $component */
- $component = $factory->create('component2');
-
- $dehydrated = $hydrator->dehydrate($component);
+ $dehydrated = $hydrator->dehydrate($factory->create('component2'));
$token = null;
$this->browser()
->throwExceptions()
- ->get('/_components/component2?'.http_build_query($dehydrated))
+ ->get('/_components/component2?data='.urlencode(json_encode($dehydrated)))
->assertSuccessful()
->assertHeaderContains('Content-Type', 'html')
->assertContains('Count: 1')
@@ -88,8 +81,9 @@ public function testCanExecuteComponentAction(): void
// get a valid token to use for actions
$token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value');
})
- ->post('/_components/component2/increase?'.http_build_query($dehydrated), [
+ ->post('/_components/component2/increase', [
'headers' => ['X-CSRF-TOKEN' => $token],
+ 'body' => json_encode($dehydrated),
])
->assertSuccessful()
->assertHeaderContains('Content-Type', 'html')
@@ -159,16 +153,13 @@ public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');
- /** @var Component2 $component */
- $component = $factory->create('component2');
-
- $dehydrated = $hydrator->dehydrate($component);
+ $dehydrated = $hydrator->dehydrate($factory->create('component2'));
$this->browser()
->visit('/render-template/template1')
->assertSuccessful()
->assertSee('BeforeReRenderCalled: No')
- ->get('/_components/component2?'.http_build_query($dehydrated))
+ ->get('/_components/component2?data='.urlencode(json_encode($dehydrated)))
->assertSuccessful()
->assertSee('BeforeReRenderCalled: Yes')
;
@@ -182,15 +173,12 @@ public function testCanRedirectFromComponentAction(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');
- /** @var Component2 $component */
- $component = $factory->create('component2');
-
- $dehydrated = $hydrator->dehydrate($component);
+ $dehydrated = $hydrator->dehydrate($factory->create('component2'));
$token = null;
$this->browser()
->throwExceptions()
- ->get('/_components/component2?'.http_build_query($dehydrated))
+ ->get('/_components/component2?data='.urlencode(json_encode($dehydrated)))
->assertSuccessful()
->use(function (HtmlResponse $response) use (&$token) {
// get a valid token to use for actions
@@ -198,17 +186,19 @@ public function testCanRedirectFromComponentAction(): void
})
->interceptRedirects()
// with no custom header, it redirects like a normal browser
- ->post('/_components/component2/redirect?'.http_build_query($dehydrated), [
+ ->post('/_components/component2/redirect', [
'headers' => ['X-CSRF-TOKEN' => $token],
+ 'body' => json_encode($dehydrated),
])
->assertRedirectedTo('/')
// with custom header, a special 204 is returned
- ->post('/_components/component2/redirect?'.http_build_query($dehydrated), [
+ ->post('/_components/component2/redirect', [
'headers' => [
'Accept' => 'application/vnd.live-component+html',
'X-CSRF-TOKEN' => $token,
],
+ 'body' => json_encode($dehydrated),
])
->assertStatus(204)
->assertHeaderEquals('Location', '/')
@@ -223,19 +213,13 @@ public function testInjectsLiveArgs(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');
- /** @var Component6 $component */
- $component = $factory->create('component6');
-
- $dehydrated = $hydrator->dehydrate($component);
+ $dehydrated = $hydrator->dehydrate($factory->create('component6'));
$token = null;
- $dehydratedWithArgs = array_merge($dehydrated, [
- 'args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']),
- ]);
-
+ $argsQueryParams = http_build_query(['args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3'])]);
$this->browser()
->throwExceptions()
- ->get('/_components/component6?'.http_build_query($dehydrated))
+ ->get('/_components/component6?data='.urlencode(json_encode($dehydrated)).'&'.$argsQueryParams)
->assertSuccessful()
->assertHeaderContains('Content-Type', 'html')
->assertContains('Arg1: not provided')
@@ -245,8 +229,9 @@ public function testInjectsLiveArgs(): void
// get a valid token to use for actions
$token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value');
})
- ->post('/_components/component6/inject?'.http_build_query($dehydratedWithArgs), [
+ ->post('/_components/component6/inject?'.$argsQueryParams, [
'headers' => ['X-CSRF-TOKEN' => $token],
+ 'body' => json_encode($dehydrated),
])
->assertSuccessful()
->assertHeaderContains('Content-Type', 'html')
diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
new file mode 100644
index 00000000000..7be4c894edf
--- /dev/null
+++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
@@ -0,0 +1,149 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener;
+
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\DomCrawler\Crawler;
+use Symfony\Component\Form\FormFactoryInterface;
+use Symfony\UX\LiveComponent\LiveComponentHydrator;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithCollectionTypeComponent;
+use Symfony\UX\LiveComponent\Tests\Fixtures\Form\BlogPostFormType;
+use Symfony\UX\TwigComponent\ComponentFactory;
+use Zenstruck\Browser\Response\HtmlResponse;
+use Zenstruck\Browser\Test\HasBrowser;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+
+/**
+ * @author Jakub Caban
+ */
+class ComponentWithFormTest extends KernelTestCase
+{
+ use Factories;
+ use HasBrowser;
+ use ResetDatabase;
+
+ public function testFormValuesRebuildAfterFormChanges(): void
+ {
+ /** @var LiveComponentHydrator $hydrator */
+ $hydrator = self::getContainer()->get('ux.live_component.component_hydrator');
+ /** @var ComponentFactory $factory */
+ $factory = self::getContainer()->get('ux.twig_component.component_factory');
+ $component = $factory->create('form_with_collection_type');
+
+ $dehydrated = $hydrator->dehydrate($component);
+ $token = null;
+
+ $this->browser()
+ ->get('/_components/form_with_collection_type?data='.urlencode(json_encode($dehydrated)))
+ ->use(function (HtmlResponse $response) use (&$dehydrated, &$token) {
+ // mimic user typing
+ $dehydrated['blog_post_form']['content'] = 'changed description by user';
+ $dehydrated['validatedFields'] = ['blog_post_form.content'];
+ $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value');
+ })
+
+ // post to action, which will add a new embedded comment
+ ->post('/_components/form_with_collection_type/addComment', [
+ 'body' => json_encode($dehydrated),
+ 'headers' => ['X-CSRF-TOKEN' => $token],
+ ])
+ ->assertStatus(422)
+ // look for original embedded form
+ ->assertContains('