diff --git a/src/LiveComponent/doc/communication.rst b/src/LiveComponent/doc/communication.rst new file mode 100644 index 0000000000..94a2503d36 --- /dev/null +++ b/src/LiveComponent/doc/communication.rst @@ -0,0 +1,668 @@ +Communication between Components +================================ + +Nested Components +----------------- + +Need to nest one live component inside another one? No problem! As a +rule of thumb, **each component exists in its own, isolated universe**. +This means that if a parent component re-renders, it won't automatically +cause the child to re-render (but it *can* - keep reading). Or, if +a model in a child updates, it won't also update that model in its parent +(but it *can* - keep reading). + +The parent-child system is *smart*. And with a few tricks +(:ref:`such as the key prop for lists of embedded components `), +you can make it behave exactly like you need. + +.. _child-component-independent-rerender: + +Each component re-renders independent of one another +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If a parent component re-renders, this won't, by default, cause any child +components to re-render, but you *can* make it do that. Let's look at an +example of a todo list component with a child that renders the total number of +todo items: + +.. code-block:: html+twig + + {# templates/components/TodoList.html.twig #} +
+ + + {% for todo in todos %} + ... + {% endfor %} + + {{ component('TodoFooter', { + count: todos|length + }) }} +
+ +Suppose the user updates the ``listName`` model and the parent component +re-renders. In this case, the child component will *not* re-render by design: +each component lives in its own universe. + +.. versionadded:: 2.8 + + The ``updateFromParent`` option was added in Live Components 2.8. Previously, + a child would re-render when *any* props passed into it changed. + +However, if the user adds a *new* todo item then we *do* want the ``TodoFooter`` +child component to re-render: using the new ``count`` value. To trigger this, +in the ``TodoFooter`` component, add the ``updateFromParent`` option:: + + #[LiveComponent()] + class TodoFooter + { + #[LiveProp(updateFromParent: true)] + public int $count = 0; + } + +Now, when the parent component re-renders, if the value of the ``count`` prop +changes, the child will make a second Ajax request to re-render itself. + +.. note:: + + To work, the name of the prop that's passed when rendering the ``TodoFooter`` + component must match the property name that has the ``updateFromParent`` - e.g. + ``{{ component('TodoFooter', { count: todos|length }) }}``. If you pass in a + different name and set the ``count`` property via a `mount() `_ method, the + child component will not re-render correctly. + +Child components keep their modifiable LiveProp values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +What if the ``TodoFooter`` component in the previous example also has +an ``isVisible`` ``LiveProp(writable: true)`` property which starts as +``true`` but can be changed (via a link click) to ``false``. Will +re-rendering the child when ``count`` changes cause this to be reset back to its +original value? Nope! When the child component re-renders, it will keep the +current value for all props, except for those that are marked as +``updateFromParent``. + +What if you *do* want your entire child component to re-render (including +resetting writable live props) when some value in the parent changes? This +can be done by manually giving your component an ``id`` attribute +that will change if the component should be totally re-rendered: + +.. code-block:: html+twig + + {# templates/components/TodoList.html.twig #} +
+ + + {{ component('TodoFooter', { + count: todos|length, + id: 'todo-footer-'~todos|length + }) }} +
+ +In this case, if the number of todos change, then the ``id`` +attribute of the component will also change. This signals that the +component should re-render itself completely, discarding any writable +LiveProp values. + +Actions in a child do not affect the parent +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Again, each component is its own, isolated universe! For example, +suppose your child component has: + +.. code-block:: html + + + +When the user clicks that button, it will attempt to call the ``save`` +action in the *child* component only, even if the ``save`` action +actually only exists in the parent. The same is true for ``data-model``, +though there is some special handling for this case (see next point). + +Communicating with a Parent Component +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are two main ways to communicate from a child component to a parent +component: + +1. :ref:`Emitting events ` + + The most flexible way to communicate: any information can be sent + from the child to the parent. + +2. :ref:`Updating a parent model from a child ` + + Useful as a simple way to "synchronize" a child model with a parent + model: when the child model changes, the parent model will also change. + +.. _data-model: +.. _update-parent-model: + +Updating a Parent Model from a Child +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Suppose a child component has a: + +.. code-block:: html + + + +
+ {{ value|markdown_to_html }} +
+ + +Notice that ``MarkdownTextarea`` allows a dynamic ``name`` +attribute to be passed in. This makes that component re-usable in any +form. + +.. _rendering-loop-of-elements: + +Rendering Quirks with List of Elements +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you're rendering a list of elements in your component, to help LiveComponents +understand which element is which between re-renders (i.e. if something re-orders +or removes some of those elements), you can add a ``id`` attribute to +each element + +.. code-block:: html+twig + + {# templates/components/Invoice.html.twig #} + {% for lineItem in lineItems %} +
+ {{ lineItem.name }} +
+ {% endfor %} + +.. _key-prop: + +Rendering Quirks with List of Embedded Components +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Imagine your component renders a list of child components and +the list changes as the user types into a search box... or by clicking +"delete" on an item. In this case, the wrong children may be removed +or existing child components may not disappear when they should. + +.. versionadded:: 2.8 + + The ``key`` prop was added in Symfony UX Live Component 2.8. + +To fix this, add a ``key`` prop to each child component that's unique +to that component: + +.. code-block:: twig + + {# templates/components/InvoiceCreator.html.twig #} + {% for lineItem in invoice.lineItems %} + {{ component('InvoiceLineItemForm', { + lineItem: lineItem, + key: lineItem.id, + }) }} + {% endfor %} + +The ``key`` will be used to generate an ``id`` attribute, +which will be used to identify each child component. You can +also pass in a ``id`` attribute directly, but ``key`` is +a bit more convenient. + +.. _rendering-loop-new-element: + +Tricks with a Loop + a "New" Item +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Let's get fancier. After looping over the current line items, you +decide to render one more component to create a *new* line item. +In that case, you can pass in a ``key`` set to something like ``new_line_item``: + +.. code-block:: twig + + {# templates/components/InvoiceCreator.html.twig #} + // ... loop and render the existing line item components + + {{ component('InvoiceLineItemForm', { + key: 'new_line_item', + }) }} + +Imagine you also have a ``LiveAction`` inside of ``InvoiceLineItemForm`` +that saves the new line item to the database. To be extra fancy, +it emits a ``lineItem:created`` event to the parent:: + + // src/Twig/InvoiceLineItemForm.php + // ... + + #[AsLiveComponent] + final class InvoiceLineItemForm + { + // ... + + #[LiveProp] + #[Valid] + public ?InvoiceLineItem $lineItem = null; + + #[PostMount] + public function postMount(): void + { + if (!$this->lineItem) { + $this->lineItem = new InvoiceLineItem(); + } + } + + #[LiveAction] + public function save(EntityManagerInterface $entityManager) + { + if (!$this->lineItem->getId()) { + $this->emit('lineItem:created', $this->lineItem); + } + + $entityManager->persist($this->lineItem); + $entityManager->flush(); + } + } + +Finally, the parent ``InvoiceCreator`` component listens to this +so that it can re-render the line items (which will now contain the +newly-saved item):: + + // src/Twig/InvoiceCreator.php + // ... + + #[AsLiveComponent] + final class InvoiceCreator + { + // ... + + #[LiveListener('lineItem:created')] + public function addLineItem() + { + // no need to do anything here: the component will re-render + } + } + +This will work beautifully: when a new line item is saved, the ``InvoiceCreator`` +component will re-render and the newly saved line item will be displayed along +with the extra ``new_line_item`` component at the bottom. + +But something surprising might happen: the ``new_line_item`` component won't +update! It will *keep* the data and props that were there a moment ago (i.e. the +form fields will still have data in them) instead of rendering a fresh, empty component. + +Why? When live components re-renders, it thinks the existing ``key: new_line_item`` +component on the page is the *same* new component that it's about to render. And +because the props passed into that component haven't changed, it doesn't see any +reason to re-render it. + +To fix this, you have two options: + +\1) Make the ``key`` dynamic so it will be different after adding a new item: + +.. code-block:: twig + + {{ component('InvoiceLineItemForm', { + key: 'new_line_item_'~lineItems|length, + }) }} + +\2) Reset the state of the ``InvoiceLineItemForm`` component after it's saved:: + + // src/Twig/InvoiceLineItemForm.php + // ... + + #[AsLiveComponent] + class InvoiceLineItemForm + { + // ... + + #[LiveAction] + public function save(EntityManagerInterface $entityManager) + { + $isNew = null === $this->lineItem->getId(); + + $entityManager->persist($this->lineItem); + $entityManager->flush(); + + if ($isNew) { + // reset the state of this component + $this->emit('lineItem:created', $this->lineItem); + $this->lineItem = new InvoiceLineItem(); + // if you're using ValidatableComponentTrait + $this->clearValidation(); + } + } + } + +.. _passing-blocks: + +Passing Content (Blocks) to Components +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Passing content via blocks to Live components works completely the same way you would `pass content to Twig Components`_. +Except with one important difference: when a component is re-rendered, any variables defined only in the +"outside" template will not be available. For example, this won't work: + +.. code-block:: twig + + {# templates/some_page.html.twig #} + {% set message = 'Variables from the outer part of the template are only available during the initial render' %} + + {% component Alert %} + {% block content %}{{ message }}{% endblock %} + {% endcomponent %} + +Local variables do remain available: + +.. code-block:: twig + + {# templates/some_page.html.twig #} + {% component Alert %} + {% block content %} + {% set message = 'this works during re-rendering!' %} + {{ message }} + {% endblock %} + {% endcomponent %} + +Emitting Events +--------------- + +.. versionadded:: 2.8 + + The ability to emit events was added in Live Components 2.8. + +Events allow you to communicate between any two components that live +on your page. + +Emitting an Event +~~~~~~~~~~~~~~~~~ + +There are three ways to emit an event: + +.. versionadded:: 2.16 + + The ``data-live-event-param`` attribute was added in Live Components 2.16. + Previously, it was called ``data-event``. + +1. From Twig: + + .. code-block:: html+twig + + + {{ form_end(form) }} + + +That's it! The result is incredible! As you finish changing each field, the +component automatically re-renders - including showing any validation +errors for that field! Amazing! + +How this works: + +#. The ``ComponentWithFormTrait`` has a ``$formValues`` writable ``LiveProp`` + containing the value for every field in your form. +#. When the user changes a field, that key in ``$formValues`` is updated and + an Ajax request is sent to re-render. +#. During that Ajax call, the form is submitted using ``$formValues``, the + form re-renders, and the page is updated. + +Build the "New Post" Form Component +----------------------------------- + +The previous component can already be used to edit an existing post or create +a new post. For a new post, either pass in a new ``Post`` object to ``initialFormData``, +or omit it entirely to let the ``initialFormData`` property default to ``null``: + +.. code-block:: twig + + {# templates/post/new.html.twig #} + {# ... #} + + {{ component('PostForm', { + form: form + }) }} + +Submitting the Form via a LiveAction +------------------------------------ + +The simplest way to handle your form submit is directly in your component via +a :ref:`LiveAction `:: + + // ... + use Doctrine\ORM\EntityManagerInterface; + use Symfony\UX\LiveComponent\Attribute\LiveAction; + + class PostForm extends AbstractController + { + // ... + + #[LiveAction] + public function save(EntityManagerInterface $entityManager) + { + // Submit the form! If validation fails, an exception is thrown + // and the component is automatically re-rendered with the errors + $this->submitForm(); + + /** @var Post $post */ + $post = $this->getForm()->getData(); + $entityManager->persist($post); + $entityManager->flush(); + + $this->addFlash('success', 'Post saved!'); + + return $this->redirectToRoute('app_post_show', [ + 'id' => $post->getId(), + ]); + } + } + +Next, tell the ``form`` element to use this action: + +.. code-block:: twig + + {# templates/components/PostForm.html.twig #} + {# ... #} + + {{ form_start(form, { + attr: { + 'data-action': 'live#action:prevent', + 'data-live-action-param': 'save' + } + }) }} + +Now, when the form is submitted, it will execute the ``save()`` method +via Ajax. If the form fails validation, it will re-render with the +errors. And if it's successful, it will redirect. + +Submitting with a Normal Symfony Controller +------------------------------------------- + +If you prefer, you can submit the form via a Symfony controller. To do +this, create your controller like normal, including the submit logic:: + + // src/Controller/PostController.php + class PostController extends AbstractController + { + #[Route('/admin/post/{id}/edit', name: 'app_post_edit')] + public function edit(Request $request, Post $post, EntityManagerInterface $entityManager): Response + { + $form = $this->createForm(PostType::class, $post); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // save, redirect, etc + } + + return $this->render('post/edit.html.twig', [ + 'post' => $post, + 'form' => $form, // use $form->createView() in Symfony <6.2 + ]); + } + } + +If validation fails, you'll want the live component to render with the form +errors instead of creating a fresh form. To do that, pass the ``form`` variable +into the component: + +.. code-block:: twig + + {# templates/post/edit.html.twig #} + {{ component('PostForm', { + initialFormData: post, + form: form + }) }} + +Using Form Data in a LiveAction +------------------------------- + +Each time an Ajax call is made to re-render the live component the form is +automatically submitted using the latest data. + +However, there are two important things to know: + +#. When a ``LiveAction`` is executed, the form has **not** yet been submitted. +#. The ``initialFormData`` property is **not** updated until after the form is + submitted. + +If you need to access the latest data in a ``LiveAction``, you can manually submit +the form:: + + // ... + + #[LiveAction] + public function save() + { + // $this->initialFormData will *not* contain the latest data yet! + + // submit the form + $this->submitForm(); + + // now you can access the latest data + $post = $this->getForm()->getData(); + // (same as above) + $post = $this->initialFormData; + } + +.. tip:: + + If you don't call ``$this->submitForm()``, it's called automatically + before the component is re-rendered. + +Dynamically Updating the Form In a LiveAction +--------------------------------------------- + +When an Ajax call is made to re-render the live component (whether that's +due to a model change or a LiveAction), the form is submitted using a +``$formValues`` property from ``ComponentWithFormTrait`` that contains the +latest data from the form. + +Sometimes, you need to update something on the form dynamically from a ``LiveAction``. +For example, suppose you have a "Generate Title" button that, when clicked, will +generate a title based on the content of the post. + +To do this, you **must** update the ``$this->formValues`` property directly +before the form is submitted:: + + // ... + + #[LiveAction] + public function generateTitle() + { + // this works! + // (the form will be submitted automatically after this method, now with the new title) + $this->formValues['title'] = '... some auto-generated-title'; + + // this would *not* work + // $this->submitForm(); + // $post = $this->getForm()->getData(); + // $post->setTitle('... some auto-generated-title'); + } + +This is tricky. The ``$this->formValues`` property is an array of the raw form +data on the frontend and contains only scalar values (e.g. strings, integers, booleans +and arrays). By updating this property, the form will submit as *if* the user had +typed the new ``title`` into the form. The form will then be re-rendered with the +new data. + +.. note:: + + If the field you're updating is an object in your code - like an entity object + corresponding to an ``EntityType`` field - you need to use the value that's + used on the frontend of your form. For an entity, that's the ``id``:: + + $this->formValues['author'] = $author->getId(); + +Why not just update the ``$post`` object directly? Once you submit the form, the +"form view" (data, errors, etc for the frontend) has already been created. Changing +the ``$post`` object has no effect. Even modifying ``$this->initialFormData`` +before submitting the form has no effect: the actual, submitted ``title`` would +override that. + +Form Rendering Problems +----------------------- + +For the most part, rendering a form inside a component works +beautifully. But there are a few situations when your form may not +behave how you want. + +**A) Text Boxes Removing Trailing Spaces** + +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 +disappear! + +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 +field to ``false``:: + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + // ... + ->add('content', TextareaType::class, [ + 'trim' => false, + ]) + ; + } + +**B) ``PasswordType`` loses the password on re-render** + +If you're using the ``PasswordType``, when the component re-renders, the +input will become blank! That's because, by default, the +``PasswordType`` does not re-fill the ```` after +a submit. + +To fix this, set the ``always_empty`` option to ``false`` in your form:: + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + // ... + ->add('plainPassword', PasswordType::class, [ + 'always_empty' => false, + ]) + ; + } + +Resetting the Form +------------------ + +.. versionadded:: 2.10 + + The ``resetForm()`` method was added in LiveComponent 2.10. + +After submitting a form via an action, you might want to "reset" the form +back to its initial state so you can use it again. Do that by calling +``resetForm()`` in your action instead of redirecting:: + + #[LiveAction] + public function save(EntityManagerInterface $entityManager) + { + // ... + + $this->resetForm(); + } + +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 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 App\Entity\BlogPost; + use Symfony\Component\Form\AbstractType; + use Symfony\Component\Form\Extension\Core\Type\CollectionType; + use Symfony\Component\Form\FormBuilderInterface; + use Symfony\Component\OptionsResolver\OptionsResolver; + + 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 App\Entity\BlogPost; + use App\Entity\Comment; + use App\Form\BlogPostFormType; + 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; + + #[AsLiveComponent] + class BlogPostCollectionType extends AbstractController + { + use ComponentWithFormTrait; + use DefaultActionTrait; + + #[LiveProp] + public Post $initialFormData; + + protected function instantiateForm(): FormInterface + { + return $this->createForm(BlogPostFormType::class, $this->initialFormData); + } + + #[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:: html+twig + + + {{ form_start(form) }} + {{ form_row(form.title) }} + +

Comments:

+ {% for key, commentForm in form.comments %} + + + {{ form_widget(commentForm) }} + {% endfor %} + + {# avoid an extra label for this field #} + {% do form.comments.setRendered %} + + + + + {{ form_end(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. + +Using LiveCollectionType +------------------------ + +.. versionadded:: 2.2 + + The ``LiveCollectionType`` and the ``LiveCollectionTrait`` was added in LiveComponent 2.2. + +The ``LiveCollectionType`` uses the same method described above, but in +a generic way, so it needs even less code. This form type adds an 'Add' +and a 'Delete' button for each row by default, which work out of the box +thanks to the ``LiveCollectionTrait``. + +Let's take the same example as before, a "Blog Post" form with an embedded "Comment" forms +via the ``LiveCollectionType``:: + + namespace App\Form; + + use App\Entity\BlogPost; + use Symfony\Component\Form\AbstractType; + use Symfony\Component\Form\FormBuilderInterface; + use Symfony\Component\OptionsResolver\OptionsResolver; + use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; + + class BlogPostFormType extends AbstractType + { + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('title', TextType::class) + // ... + ->add('comments', LiveCollectionType::class, [ + 'entry_type' => CommentFormType::class, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults(['data_class' => BlogPost::class]); + } + } + +Now, create a Twig component to render the form:: + + namespace App\Twig; + + use App\Entity\BlogPost; + use App\Form\BlogPostFormType; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\Form\FormInterface; + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; + use Symfony\UX\LiveComponent\Attribute\LiveProp; + use Symfony\UX\LiveComponent\DefaultActionTrait; + use Symfony\UX\LiveComponent\LiveCollectionTrait; + + #[AsLiveComponent] + class BlogPostCollectionType extends AbstractController + { + use LiveCollectionTrait; + use DefaultActionTrait; + + #[LiveProp] + public BlogPost $initialFormData; + + protected function instantiateForm(): FormInterface + { + return $this->createForm(BlogPostFormType::class, $this->initialFormData); + } + } + +There is no need for a custom template just render the form as usual: + +.. code-block:: html+twig + +
+ {{ form(form) }} +
+ +This automatically renders add and delete buttons that are connected to the live component. +If you want to customize how the buttons and the collection rows are rendered, you can use +`Symfony's built-in form theming techniques`_, but you should note that, the buttons are not +part of the form tree. + +.. note:: + + Under the hood, ``LiveCollectionType`` adds ``button_add`` and + ``button_delete`` fields to the form in a special way. These fields + are not added as regular form fields, so they are not part of the form + tree, but only the form view. The ``button_add`` is added to the + collection view variables and a ``button_delete`` is added to each + item view variables. + +Here are some examples of these techniques. + +If you only want to customize some attributes, the simplest to use the options in the form type:: + + // ... + $builder + // ... + ->add('comments', LiveCollectionType::class, [ + 'entry_type' => CommentFormType::class, + 'label' => false, + 'button_delete_options' => [ + 'label' => 'X', + 'attr' => [ + 'class' => 'btn btn-outline-danger', + ], + ] + ]) + ; + +Inline rendering: + +.. code-block:: html+twig + +
+ {{ form_start(form) }} + {{ form_row(form.title) }} + +

Comments:

+ {% for key, commentForm in form.comments %} + {# render a delete button for every row #} + {{ form_row(commentForm.vars.button_delete, { label: 'X', attr: { class: 'btn btn-outline-danger' } }) }} + + {# render rest of the comment form #} + {{ form_row(commentForm, { label: false }) }} + {% endfor %} + + {# render the add button #} + {{ form_widget(form.comments.vars.button_add, { label: '+ Add comment', attr: { class: 'btn btn-outline-primary' } }) }} + + {# render rest of the form #} + {{ form_row(form) }} + + + {{ form_end(form) }} +
+ +Override the specific block for comment items: + +.. code-block:: html+twig + + {% form_theme form 'components/_form_theme_comment_list.html.twig' %} + +
+ {{ form_start(form) }} + {{ form_row(form.title) + +

Comments:

+
    + {{ form_row(form.comments, { skip_add_button: true }) }} +
+ + {# render rest of the form #} + {{ form_row(form) }} + + + {{ form_end(form) }} +
+ + +.. code-block:: html+twig + + {# templates/components/_form_theme_comment_list.html.twig #} + {%- block _blog_post_form_comments_entry_row -%} +
  • + {{ form_row(form.content, { label: false }) }} + {{ form_row(button_delete, { label: 'X', attr: { class: 'btn btn-outline-danger' } }) }} +
  • + {% endblock %} + +.. note:: + + You may put the form theme into the component template and use ``{% form_theme form _self %}``. However, + because the component template doesn't extend anything, it will not work as expected, you must point + ``form_theme`` to a separate template. See `How to Work with Form Themes`_. + +Override the generic buttons and collection entry: + +The ``add`` and ``delete`` buttons are rendered as separate ``ButtonType`` form +types and can be customized like a normal form type via the ``live_collection_button_add`` +and ``live_collection_button_delete`` block prefix respectively: + +.. code-block:: html+twig + + {% block live_collection_button_add_widget %} + {% set attr = attr|merge({'class': attr.class|default('btn btn-ghost')}) %} + {% set translation_domain = false %} + {% set label_html = true %} + {%- set label -%} + + + + {{ 'form.collection.button.add.label'|trans({}, 'forms') }} + {%- endset -%} + {{ block('button_widget') }} + {% endblock live_collection_button_add_widget %} + +To control how each row is rendered you can override the blocks related to the ``LiveCollectionType``. This +works the same way as `the traditional collection type`_, but you should use ``live_collection_*`` +and ``live_collection_entry_*`` as prefixes instead. + +For example, by default the add button is placed after the items (the comments in our case). Let's move it before them. + +.. code-block:: twig + + {%- block live_collection_widget -%} + {%- if button_add is defined and not button_add.rendered -%} + {{ form_row(button_add) }} + {%- endif -%} + {{ block('form_widget') }} + {%- endblock -%} + +Now add a div around each row: + +.. code-block:: html+twig + + {%- block live_collection_entry_row -%} +
    + {{ block('form_row') }} + {%- if button_delete is defined and not button_delete.rendered -%} + {{ form_row(button_delete) }} + {%- endif -%} +
    + {%- endblock -%} + +As another example, let's create a general bootstrap 5 theme for the live +collection type, rendering every item in a table row: + +.. code-block:: html+twig + + {%- block live_collection_widget -%} + + + + {% for child in form|last %} + + {% endfor %} + + + + + {{ block('form_widget') }} + +
    {{ form_label(child) }}
    + {%- if skip_add_button|default(false) is same as(false) and button_add is defined and not button_add.rendered -%} + {{ form_widget(button_add, { label: '+ Add Item', attr: { class: 'btn btn-outline-primary' } }) }} + {%- endif -%} + {%- endblock -%} + + {%- block live_collection_entry_row -%} + + {% for child in form %} + {{- form_row(child, { label: false }) -}} + {% endfor %} + + {{- form_row(button_delete, { label: 'X', attr: { class: 'btn btn-outline-danger' } }) -}} + + + {%- endblock -%} + +To render the add button later in the template, you can skip rendering it initially with ``skip_add_button``, +then render it manually after: + +.. code-block:: html+twig + + + + + + + + + + + {{ form_row(form.todoItems, { skip_add_button: true }) }} + +
    ItemPriority
    + + {{ form_widget(form.todoItems.vars.button_add, { label: '+ Add Item', attr: { class: 'btn btn-outline-primary' } }) }} \ No newline at end of file diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 7b7e7ca1c4..d4c3ea9dca 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -924,6 +924,71 @@ The following hooks are available (along with the arguments that are passed): * ``loading.state:finished`` args ``(element: HTMLElement)`` * ``model:set`` args ``(model: string, value: any, component: Component)`` +Dispatching Browser/JavaScript Events +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Sometimes you may want to dispatch a JavaScript event from your component. You +could use this to signal, for example, that a modal should close:: + + use Symfony\UX\LiveComponent\ComponentToolsTrait; + // ... + + class MyComponent + { + use ComponentToolsTrait; + + #[LiveAction] + public function saveProduct() + { + // ... + + $this->dispatchBrowserEvent('modal:close'); + } + } + +This will dispatch a ``modal:close`` event on the top-level element of +your component. It's often handy to listen to this event in a custom +Stimulus controller - like this for Bootstrap's modal: + +.. code-block:: javascript + + // assets/controllers/bootstrap-modal-controller.js + import { Controller } from '@hotwired/stimulus'; + import Modal from 'bootstrap/js/dist/modal'; + + export default class extends Controller { + modal = null; + + initialize() { + this.modal = Modal.getOrCreateInstance(this.element); + window.addEventListener('modal:close', () => this.modal.hide()); + } + } + +Just make sure this controller is attached to the modal element: + +.. code-block:: html+twig + + + +You can also pass data to the event:: + + $this->dispatchBrowserEvent('product:created', [ + 'product' => $product->getId(), + ]); + +This becomes the ``detail`` property of the event: + +.. code-block:: javascript + + window.addEventListener('product:created', (event) => { + console.log(event.detail.product); + }); + Loading States -------------- @@ -1333,2068 +1398,524 @@ The files will be available in a regular ``$request->files`` files bag:: need to specify ``multiple`` attribute on HTML element and end ``name`` with ``[]``. -.. _forms: +.. _validation: -Forms ------ +Validation (without a Form) +--------------------------- -A component can also help render a `Symfony form`_, either the entire -form (useful for automatic validation as you type) or just one or some -fields (e.g. a markdown preview for a ``textarea`` or `dependent form fields`_. +.. note:: -Rendering an Entire Form in a Component -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + If your component :ref:`contains a form `, then validation + is built-in automatically. Follow those docs for more details. -Suppose you have a ``PostType`` form class that's bound to a ``Post`` -entity and you'd like to render this in a component so that you can get -instant validation as the user types:: +If you're building a form *without* using Symfony's form +component, you *can* still validate your data. - namespace App\Form; +First use the ``ValidatableComponentTrait`` and add any constraints you +need:: - use App\Entity\Post; - use Symfony\Component\Form\AbstractType; - use Symfony\Component\Form\FormBuilderInterface; - use Symfony\Component\OptionsResolver\OptionsResolver; + use App\Entity\User; + use Symfony\Component\Validator\Constraints as Assert; + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; + use Symfony\UX\LiveComponent\Attribute\LiveProp; + use Symfony\UX\LiveComponent\ValidatableComponentTrait; - class PostType extends AbstractType + #[AsLiveComponent] + class EditUser { - public function buildForm(FormBuilderInterface $builder, array $options) - { - $builder - ->add('title') - ->add('slug') - ->add('content') - ; - } - - public function configureOptions(OptionsResolver $resolver) - { - $resolver->setDefaults([ - 'data_class' => Post::class, - ]); - } - } - -Great! In the template for some page (e.g. an "Edit post" page), render a -``PostForm`` component that we will create next: - -.. code-block:: html+twig + use ValidatableComponentTrait; - {# templates/post/edit.html.twig #} - {% extends 'base.html.twig' %} + #[LiveProp(writable: ['email', 'plainPassword'])] + #[Assert\Valid] + public User $user; - {% block body %} -

    Edit Post

    + #[LiveProp] + #[Assert\IsTrue] + public bool $agreeToTerms = false; + } - {{ component('PostForm', { - initialFormData: post, - }) }} - {% endblock %} +Be sure to add the ``IsValid`` attribute/annotation to any property +where you want the object on that property to also be validated. -Ok: time to build that ``PostForm`` component! The Live Components -package comes with a special trait - ``ComponentWithFormTrait`` - to -make it easy to deal with forms:: +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 +track of which models have been updated and only stores the errors for +those fields on re-render. - namespace App\Twig\Components; +You can also trigger validation of your *entire* object manually in an +action:: - use App\Entity\Post; - use App\Form\PostType; - use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; - use Symfony\Component\Form\FormInterface; - use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; - use Symfony\UX\LiveComponent\Attribute\LiveProp; - use Symfony\UX\LiveComponent\ComponentWithFormTrait; - use Symfony\UX\LiveComponent\DefaultActionTrait; + use Symfony\UX\LiveComponent\Attribute\LiveAction; #[AsLiveComponent] - class PostForm extends AbstractController + class EditUser { - use DefaultActionTrait; - use ComponentWithFormTrait; - - /** - * The initial data used to create the form. - */ - #[LiveProp] - public ?Post $initialFormData = null; + // ... - protected function instantiateForm(): FormInterface + #[LiveAction] + public function save() { - // we can extend AbstractController to get the normal shortcuts - return $this->createForm(PostType::class, $this->initialFormData); + // this will throw an exception if validation fails + $this->validate(); + + // perform save operations } } -The trait forces you to create an ``instantiateForm()`` method, which is -used each time the component is rendered via AJAX. To recreate the *same* -form as the original, we pass in the ``initialFormData`` property and set it -as a ``LiveProp``. - -The template for this component will render the form, which is available -as ``form`` thanks to the trait: +If validation fails, an exception is thrown, but the component will be +re-rendered. In your template, render errors using an ``_errors`` variable: .. code-block:: html+twig - {# templates/components/PostForm.html.twig #} -
    - {{ form_start(form) }} - {{ form_row(form.title) }} - {{ form_row(form.slug) }} - {{ form_row(form.content) }} - - - {{ form_end(form) }} -
    - -That's it! The result is incredible! As you finish changing each field, the -component automatically re-renders - including showing any validation -errors for that field! Amazing! - -How this works: - -#. The ``ComponentWithFormTrait`` has a ``$formValues`` writable ``LiveProp`` - containing the value for every field in your form. -#. When the user changes a field, that key in ``$formValues`` is updated and - an Ajax request is sent to re-render. -#. During that Ajax call, the form is submitted using ``$formValues``, the - form re-renders, and the page is updated. - -Build the "New Post" Form Component -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The previous component can already be used to edit an existing post or create -a new post. For a new post, either pass in a new ``Post`` object to ``initialFormData``, -or omit it entirely to let the ``initialFormData`` property default to ``null``: + {% if _errors.has('post.content') %} +
    + {{ _errors.get('post.content') }} +
    + {% endif %} + -.. code-block:: twig + {% if _errors.has('agreeToTerms') %} +
    + {{ _errors.get('agreeToTerms') }} +
    + {% endif %} + - {# templates/post/new.html.twig #} - {# ... #} + - {{ component('PostForm', { - form: form - }) }} +Once a component has been validated, the component will "remember" that +it has been validated. This means that, if you edit a field and the +component re-renders, it will be validated again. -Submitting the Form via a LiveAction -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Resetting Validation Errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The simplest way to handle your form submit is directly in your component via -a :ref:`LiveAction `:: +If you want to clear validation errors (e.g. so you can reuse the form again), +you can call the ``resetValidation()`` method:: // ... - use Doctrine\ORM\EntityManagerInterface; - use Symfony\UX\LiveComponent\Attribute\LiveAction; - - class PostForm extends AbstractController + class EditUser { // ... #[LiveAction] - public function save(EntityManagerInterface $entityManager) + public function save() { - // Submit the form! If validation fails, an exception is thrown - // and the component is automatically re-rendered with the errors - $this->submitForm(); - - /** @var Post $post */ - $post = $this->getForm()->getData(); - $entityManager->persist($post); - $entityManager->flush(); - - $this->addFlash('success', 'Post saved!'); + // validate, save, etc - return $this->redirectToRoute('app_post_show', [ - 'id' => $post->getId(), - ]); + // reset your live props to the original state + $this->user = new User(); + $this->agreeToTerms = false; + // clear the validation state + $this->resetValidation(); } } -Next, tell the ``form`` element to use this action: +Real-Time Validation on Change +------------------------------ -.. code-block:: twig +As soon as validation is enabled, each field will be validated the +moment that its model is updated. By default, that happens in the +``input`` event, so when the user types into text fields. Often, +that's too much (e.g. you want a user to finish typing their full email +address before validating it). - {# templates/components/PostForm.html.twig #} - {# ... #} +To validate only on "change", use the ``on(change)`` modifier: - {{ form_start(form, { - attr: { - 'data-action': 'live#action:prevent', - 'data-live-action-param': 'save' - } - }) }} +.. code-block:: html+twig -Now, when the form is submitted, it will execute the ``save()`` method -via Ajax. If the form fails validation, it will re-render with the -errors. And if it's successful, it will redirect. + -Submitting with a Normal Symfony Controller -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Deferring / Lazy Loading Components +----------------------------------- -If you prefer, you can submit the form via a Symfony controller. To do -this, create your controller like normal, including the submit logic:: +When a page loads, all components are rendered immediately. If a component is +heavy to render, you can defer its rendering until after the page has loaded. +This is done by making an Ajax call to load the component's real content either +as soon as the page loads (``defer``) or when the component becomes visible +(``lazy``). - // src/Controller/PostController.php - class PostController extends AbstractController - { - #[Route('/admin/post/{id}/edit', name: 'app_post_edit')] - public function edit(Request $request, Post $post, EntityManagerInterface $entityManager): Response - { - $form = $this->createForm(PostType::class, $post); - $form->handleRequest($request); +.. note:: - if ($form->isSubmitted() && $form->isValid()) { - // save, redirect, etc - } + Behind the scenes, your component *is* created & mounted during the initial + page load, but its template isn't rendered. So keep your heavy work to + methods in your component (e.g. ``getProducts()``) that are only called + from the component's template. - return $this->render('post/edit.html.twig', [ - 'post' => $post, - 'form' => $form, // use $form->createView() in Symfony <6.2 - ]); - } - } +Loading "defer" (Ajax on Load) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If validation fails, you'll want the live component to render with the form -errors instead of creating a fresh form. To do that, pass the ``form`` variable -into the component: +.. versionadded:: 2.13.0 -.. code-block:: twig + The ability to defer loading a component was added in Live Components 2.13. - {# templates/post/edit.html.twig #} - {{ component('PostForm', { - initialFormData: post, - form: form - }) }} +If a component is heavy to render, you can defer rendering it until after +the page has loaded. To do this, add a ``loading="defer"`` attribute: -Using Form Data in a LiveAction -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: html+twig -Each time an Ajax call is made to re-render the live component the form is -automatically submitted using the latest data. + {# With the HTML syntax #} + -However, there are two important things to know: +.. code-block:: twig -#. When a ``LiveAction`` is executed, the form has **not** yet been submitted. -#. The ``initialFormData`` property is **not** updated until after the form is - submitted. + {# With the component function #} + {{ component('SomeHeavyComponent', { loading: 'defer' }) }} -If you need to access the latest data in a ``LiveAction``, you can manually submit -the form:: +This renders an empty ``
    `` tag, but triggers an Ajax call to render the +real component once the page has loaded. - // ... +Loading "lazy" (Ajax when Visible) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - #[LiveAction] - public function save() - { - // $this->initialFormData will *not* contain the latest data yet! +.. versionadded:: 2.17.0 - // submit the form - $this->submitForm(); + The ability to load a component "lazily" was added in Live Components 2.17. - // now you can access the latest data - $post = $this->getForm()->getData(); - // (same as above) - $post = $this->initialFormData; - } +The ``lazy`` option is similar to ``defer``, but it defers the loading of +the component until it's in the viewport. This is useful for components that +are far down the page and are not needed until the user scrolls to them. -.. tip:: +To use this, set a ``loading="lazy"`` attribute to your component: - If you don't call ``$this->submitForm()``, it's called automatically - before the component is re-rendered. +.. code-block:: html+twig -Dynamically Updating the Form In a LiveAction -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + {# With the HTML syntax #} + -When an Ajax call is made to re-render the live component (whether that's -due to a model change or a LiveAction), the form is submitted using a -``$formValues`` property from ``ComponentWithFormTrait`` that contains the -latest data from the form. +.. code-block:: twig -Sometimes, you need to update something on the form dynamically from a ``LiveAction``. -For example, suppose you have a "Generate Title" button that, when clicked, will -generate a title based on the content of the post. + {# With the Twig syntax #} + {{ component('SomeHeavyComponent', { loading: 'lazy' }) }} -To do this, you **must** update the ``$this->formValues`` property directly -before the form is submitted:: +This renders an empty ``
    `` tag. The real component is only rendered when +it appears in the viewport. - // ... - - #[LiveAction] - public function generateTitle() - { - // this works! - // (the form will be submitted automatically after this method, now with the new title) - $this->formValues['title'] = '... some auto-generated-title'; - - // this would *not* work - // $this->submitForm(); - // $post = $this->getForm()->getData(); - // $post->setTitle('... some auto-generated-title'); - } - -This is tricky. The ``$this->formValues`` property is an array of the raw form -data on the frontend and contains only scalar values (e.g. strings, integers, booleans -and arrays). By updating this property, the form will submit as *if* the user had -typed the new ``title`` into the form. The form will then be re-rendered with the -new data. - -.. note:: - - If the field you're updating is an object in your code - like an entity object - corresponding to an ``EntityType`` field - you need to use the value that's - used on the frontend of your form. For an entity, that's the ``id``:: - - $this->formValues['author'] = $author->getId(); - -Why not just update the ``$post`` object directly? Once you submit the form, the -"form view" (data, errors, etc for the frontend) has already been created. Changing -the ``$post`` object has no effect. Even modifying ``$this->initialFormData`` -before submitting the form has no effect: the actual, submitted ``title`` would -override that. - -Form Rendering Problems -~~~~~~~~~~~~~~~~~~~~~~~ - -For the most part, rendering a form inside a component works -beautifully. But there are a few situations when your form may not -behave how you want. - -**A) Text Boxes Removing Trailing Spaces** - -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 -disappear! - -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 -field to ``false``:: - - public function buildForm(FormBuilderInterface $builder, array $options) - { - $builder - // ... - ->add('content', TextareaType::class, [ - 'trim' => false, - ]) - ; - } - -**B) ``PasswordType`` loses the password on re-render** - -If you're using the ``PasswordType``, when the component re-renders, the -input will become blank! That's because, by default, the -``PasswordType`` does not re-fill the ```` after -a submit. - -To fix this, set the ``always_empty`` option to ``false`` in your form:: - - public function buildForm(FormBuilderInterface $builder, array $options) - { - $builder - // ... - ->add('plainPassword', PasswordType::class, [ - 'always_empty' => false, - ]) - ; - } - -Resetting the Form -~~~~~~~~~~~~~~~~~~ - -.. versionadded:: 2.10 - - The ``resetForm()`` method was added in LiveComponent 2.10. - -After submitting a form via an action, you might want to "reset" the form -back to its initial state so you can use it again. Do that by calling -``resetForm()`` in your action instead of redirecting:: - - #[LiveAction] - public function save(EntityManagerInterface $entityManager) - { - // ... - - $this->resetForm(); - } - -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 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 App\Entity\BlogPost; - use Symfony\Component\Form\AbstractType; - use Symfony\Component\Form\Extension\Core\Type\CollectionType; - use Symfony\Component\Form\FormBuilderInterface; - use Symfony\Component\OptionsResolver\OptionsResolver; - - 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 App\Entity\BlogPost; - use App\Entity\Comment; - use App\Form\BlogPostFormType; - 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; - - #[AsLiveComponent] - class BlogPostCollectionType extends AbstractController - { - use ComponentWithFormTrait; - use DefaultActionTrait; - - #[LiveProp] - public Post $initialFormData; - - protected function instantiateForm(): FormInterface - { - return $this->createForm(BlogPostFormType::class, $this->initialFormData); - } - - #[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:: html+twig - - - {{ form_start(form) }} - {{ form_row(form.title) }} - -

    Comments:

    - {% for key, commentForm in form.comments %} - - - {{ form_widget(commentForm) }} - {% endfor %} - - {# avoid an extra label for this field #} - {% do form.comments.setRendered %} - - - - - {{ form_end(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. - -Using LiveCollectionType -~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionadded:: 2.2 - - The ``LiveCollectionType`` and the ``LiveCollectionTrait`` was added in LiveComponent 2.2. - -The ``LiveCollectionType`` uses the same method described above, but in -a generic way, so it needs even less code. This form type adds an 'Add' -and a 'Delete' button for each row by default, which work out of the box -thanks to the ``LiveCollectionTrait``. - -Let's take the same example as before, a "Blog Post" form with an embedded "Comment" forms -via the ``LiveCollectionType``:: - - namespace App\Form; - - use App\Entity\BlogPost; - use Symfony\Component\Form\AbstractType; - use Symfony\Component\Form\FormBuilderInterface; - use Symfony\Component\OptionsResolver\OptionsResolver; - use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; - - class BlogPostFormType extends AbstractType - { - public function buildForm(FormBuilderInterface $builder, array $options) - { - $builder - ->add('title', TextType::class) - // ... - ->add('comments', LiveCollectionType::class, [ - 'entry_type' => CommentFormType::class, - ]) - ; - } - - public function configureOptions(OptionsResolver $resolver) - { - $resolver->setDefaults(['data_class' => BlogPost::class]); - } - } - -Now, create a Twig component to render the form:: - - namespace App\Twig; - - use App\Entity\BlogPost; - use App\Form\BlogPostFormType; - use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; - use Symfony\Component\Form\FormInterface; - use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; - use Symfony\UX\LiveComponent\Attribute\LiveProp; - use Symfony\UX\LiveComponent\DefaultActionTrait; - use Symfony\UX\LiveComponent\LiveCollectionTrait; - - #[AsLiveComponent] - class BlogPostCollectionType extends AbstractController - { - use LiveCollectionTrait; - use DefaultActionTrait; - - #[LiveProp] - public BlogPost $initialFormData; - - protected function instantiateForm(): FormInterface - { - return $this->createForm(BlogPostFormType::class, $this->initialFormData); - } - } - -There is no need for a custom template just render the form as usual: - -.. code-block:: html+twig - -
    - {{ form(form) }} -
    - -This automatically renders add and delete buttons that are connected to the live component. -If you want to customize how the buttons and the collection rows are rendered, you can use -`Symfony's built-in form theming techniques`_, but you should note that, the buttons are not -part of the form tree. - -.. note:: - - Under the hood, ``LiveCollectionType`` adds ``button_add`` and - ``button_delete`` fields to the form in a special way. These fields - are not added as regular form fields, so they are not part of the form - tree, but only the form view. The ``button_add`` is added to the - collection view variables and a ``button_delete`` is added to each - item view variables. - -Here are some examples of these techniques. - -If you only want to customize some attributes, the simplest to use the options in the form type:: - - // ... - $builder - // ... - ->add('comments', LiveCollectionType::class, [ - 'entry_type' => CommentFormType::class, - 'label' => false, - 'button_delete_options' => [ - 'label' => 'X', - 'attr' => [ - 'class' => 'btn btn-outline-danger', - ], - ] - ]) - ; - -Inline rendering: - -.. code-block:: html+twig - -
    - {{ form_start(form) }} - {{ form_row(form.title) }} - -

    Comments:

    - {% for key, commentForm in form.comments %} - {# render a delete button for every row #} - {{ form_row(commentForm.vars.button_delete, { label: 'X', attr: { class: 'btn btn-outline-danger' } }) }} - - {# render rest of the comment form #} - {{ form_row(commentForm, { label: false }) }} - {% endfor %} - - {# render the add button #} - {{ form_widget(form.comments.vars.button_add, { label: '+ Add comment', attr: { class: 'btn btn-outline-primary' } }) }} - - {# render rest of the form #} - {{ form_row(form) }} - - - {{ form_end(form) }} -
    - -Override the specific block for comment items: - -.. code-block:: html+twig - - {% form_theme form 'components/_form_theme_comment_list.html.twig' %} - -
    - {{ form_start(form) }} - {{ form_row(form.title) - -

    Comments:

    -
      - {{ form_row(form.comments, { skip_add_button: true }) }} -
    - - {# render rest of the form #} - {{ form_row(form) }} - - - {{ form_end(form) }} -
    - - -.. code-block:: html+twig - - {# templates/components/_form_theme_comment_list.html.twig #} - {%- block _blog_post_form_comments_entry_row -%} -
  • - {{ form_row(form.content, { label: false }) }} - {{ form_row(button_delete, { label: 'X', attr: { class: 'btn btn-outline-danger' } }) }} -
  • - {% endblock %} - -.. note:: - - You may put the form theme into the component template and use ``{% form_theme form _self %}``. However, - because the component template doesn't extend anything, it will not work as expected, you must point - ``form_theme`` to a separate template. See `How to Work with Form Themes`_. - -Override the generic buttons and collection entry: - -The ``add`` and ``delete`` buttons are rendered as separate ``ButtonType`` form -types and can be customized like a normal form type via the ``live_collection_button_add`` -and ``live_collection_button_delete`` block prefix respectively: - -.. code-block:: html+twig - - {% block live_collection_button_add_widget %} - {% set attr = attr|merge({'class': attr.class|default('btn btn-ghost')}) %} - {% set translation_domain = false %} - {% set label_html = true %} - {%- set label -%} - - - - {{ 'form.collection.button.add.label'|trans({}, 'forms') }} - {%- endset -%} - {{ block('button_widget') }} - {% endblock live_collection_button_add_widget %} - -To control how each row is rendered you can override the blocks related to the ``LiveCollectionType``. This -works the same way as `the traditional collection type`_, but you should use ``live_collection_*`` -and ``live_collection_entry_*`` as prefixes instead. - -For example, by default the add button is placed after the items (the comments in our case). Let's move it before them. - -.. code-block:: twig - - {%- block live_collection_widget -%} - {%- if button_add is defined and not button_add.rendered -%} - {{ form_row(button_add) }} - {%- endif -%} - {{ block('form_widget') }} - {%- endblock -%} - -Now add a div around each row: - -.. code-block:: html+twig - - {%- block live_collection_entry_row -%} -
    - {{ block('form_row') }} - {%- if button_delete is defined and not button_delete.rendered -%} - {{ form_row(button_delete) }} - {%- endif -%} -
    - {%- endblock -%} - -As another example, let's create a general bootstrap 5 theme for the live -collection type, rendering every item in a table row: - -.. code-block:: html+twig - - {%- block live_collection_widget -%} - - - - {% for child in form|last %} - - {% endfor %} - - - - - {{ block('form_widget') }} - -
    {{ form_label(child) }}
    - {%- if skip_add_button|default(false) is same as(false) and button_add is defined and not button_add.rendered -%} - {{ form_widget(button_add, { label: '+ Add Item', attr: { class: 'btn btn-outline-primary' } }) }} - {%- endif -%} - {%- endblock -%} - - {%- block live_collection_entry_row -%} - - {% for child in form %} - {{- form_row(child, { label: false }) -}} - {% endfor %} - - {{- form_row(button_delete, { label: 'X', attr: { class: 'btn btn-outline-danger' } }) -}} - - - {%- endblock -%} - -To render the add button later in the template, you can skip rendering it initially with ``skip_add_button``, -then render it manually after: - -.. code-block:: html+twig - - - - - - - - - - - {{ form_row(form.todoItems, { skip_add_button: true }) }} - -
    ItemPriority
    - - {{ form_widget(form.todoItems.vars.button_add, { label: '+ Add Item', attr: { class: 'btn btn-outline-primary' } }) }} - -.. _validation: - -Validation (without a Form) ---------------------------- - -.. note:: - - If your component :ref:`contains a form `, then validation - is built-in automatically. Follow those docs for more details. - -If you're building a form *without* using Symfony's form -component, you *can* still validate your data. - -First use the ``ValidatableComponentTrait`` and add any constraints you -need:: - - use App\Entity\User; - use Symfony\Component\Validator\Constraints as Assert; - use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; - use Symfony\UX\LiveComponent\Attribute\LiveProp; - use Symfony\UX\LiveComponent\ValidatableComponentTrait; - - #[AsLiveComponent] - class EditUser - { - use ValidatableComponentTrait; - - #[LiveProp(writable: ['email', 'plainPassword'])] - #[Assert\Valid] - public User $user; - - #[LiveProp] - #[Assert\IsTrue] - public bool $agreeToTerms = false; - } - -Be sure to add the ``IsValid`` attribute/annotation to any property -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 -track of which models have been updated and only stores the errors for -those fields on re-render. - -You can also trigger validation of your *entire* object manually in an -action:: - - use Symfony\UX\LiveComponent\Attribute\LiveAction; - - #[AsLiveComponent] - class EditUser - { - // ... - - #[LiveAction] - public function save() - { - // this will throw an exception if validation fails - $this->validate(); - - // perform save operations - } - } - -If validation fails, an exception is thrown, but the component will be -re-rendered. In your template, render errors using an ``_errors`` variable: - -.. code-block:: html+twig - - {% if _errors.has('post.content') %} -
    - {{ _errors.get('post.content') }} -
    - {% endif %} - - - {% if _errors.has('agreeToTerms') %} -
    - {{ _errors.get('agreeToTerms') }} -
    - {% endif %} - - - - -Once a component has been validated, the component will "remember" that -it has been validated. This means that, if you edit a field and the -component re-renders, it will be validated again. - -Resetting Validation Errors -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you want to clear validation errors (e.g. so you can reuse the form again), -you can call the ``resetValidation()`` method:: - - // ... - class EditUser - { - // ... - - #[LiveAction] - public function save() - { - // validate, save, etc - - // reset your live props to the original state - $this->user = new User(); - $this->agreeToTerms = false; - // clear the validation state - $this->resetValidation(); - } - } - -Real-Time Validation on Change ------------------------------- - -As soon as validation is enabled, each field will be validated the -moment that its model is updated. By default, that happens in the -``input`` event, so when the user types into text fields. Often, -that's too much (e.g. you want a user to finish typing their full email -address before validating it). - -To validate only on "change", use the ``on(change)`` modifier: - -.. code-block:: html+twig - - - -Deferring / Lazy Loading Components ------------------------------------ - -When a page loads, all components are rendered immediately. If a component is -heavy to render, you can defer its rendering until after the page has loaded. -This is done by making an Ajax call to load the component's real content either -as soon as the page loads (``defer``) or when the component becomes visible -(``lazy``). - -.. note:: - - Behind the scenes, your component *is* created & mounted during the initial - page load, but its template isn't rendered. So keep your heavy work to - methods in your component (e.g. ``getProducts()``) that are only called - from the component's template. - -Loading "defer" (Ajax on Load) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionadded:: 2.13.0 - - The ability to defer loading a component was added in Live Components 2.13. - -If a component is heavy to render, you can defer rendering it until after -the page has loaded. To do this, add a ``loading="defer"`` attribute: - -.. code-block:: html+twig - - {# With the HTML syntax #} - - -.. code-block:: twig - - {# With the component function #} - {{ component('SomeHeavyComponent', { loading: 'defer' }) }} - -This renders an empty ``
    `` tag, but triggers an Ajax call to render the -real component once the page has loaded. - -Loading "lazy" (Ajax when Visible) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionadded:: 2.17.0 - - The ability to load a component "lazily" was added in Live Components 2.17. - -The ``lazy`` option is similar to ``defer``, but it defers the loading of -the component until it's in the viewport. This is useful for components that -are far down the page and are not needed until the user scrolls to them. - -To use this, set a ``loading="lazy"`` attribute to your component: - -.. code-block:: html+twig - - {# With the HTML syntax #} - - -.. code-block:: twig - - {# With the Twig syntax #} - {{ component('SomeHeavyComponent', { loading: 'lazy' }) }} - -This renders an empty ``
    `` tag. The real component is only rendered when -it appears in the viewport. - -Defer or Lazy? -~~~~~~~~~~~~~~ - -The ``defer`` and ``lazy`` options may seem similar, but they serve different -purposes: -* ``defer`` is useful for components that are heavy to render but are required - when the page loads. -* ``lazy`` is useful for components that are not needed until the user scrolls - to them (and may even never be rendered). - -Loading content -~~~~~~~~~~~~~~~ - -You can define some content to be rendered while the component is loading, either -inside the component template (the ``placeholder`` macro) or from the calling template -(the ``loading-template`` attribute and the ``loadingContent`` block). - -.. versionadded:: 2.16.0 - - Defining a placeholder macro into the component template was added in Live Components 2.16.0. - -In the component template, define a ``placeholder`` macro, outside of the -component's main content. This macro will be called when the component is deferred: - -.. code-block:: html+twig - - {# templates/recommended-products.html.twig #} -
    - {# This will be rendered when the component is fully loaded #} - {% for product in this.products %} -
    {{ product.name }}
    - {% endfor %} -
    - - {% macro placeholder(props) %} - {# This content will (only) be rendered as loading content #} - - {% endmacro %} - -The ``props`` argument contains the props passed to the component. -You can use it to customize the placeholder content. Let's say your -component shows a certain number of products (defined with the ``size`` -prop). You can use it to define a placeholder that shows the same -number of rows: - -.. code-block:: html+twig - - {# In the calling template #} - - -.. code-block:: html+twig - - {# In the component template #} - {% macro placeholder(props) %} - {% for i in 1..props.size %} -
    - ... -
    - {% endfor %} - {% endmacro %} - -To customize the loading content from the calling template, you can use -the ``loading-template`` option to point to a template: - -.. code-block:: html+twig - - {# With the HTML syntax #} - - - {# With the component function #} - {{ component('SomeHeavyComponent', { loading: 'defer', loading-template: 'spinning-wheel.html.twig' }) }} - -Or override the ``loadingContent`` block: - -.. code-block:: html+twig - - {# With the HTML syntax #} - - Custom Loading Content... - - - {# With the component tag #} - {% component SomeHeavyComponent with { loading: 'defer' } %} - {% block loadingContent %}Loading...{% endblock %} - {% endcomponent %} - -When ``loading-template`` or ``loadingContent`` is defined, the ``placeholder`` -macro is ignored. - -To change the initial tag from a ``div`` to something else, use the ``loading-tag`` option: - -.. code-block:: twig - - {{ component('SomeHeavyComponent', { loading: 'defer', loading-tag: 'span' }) }} - -Polling -------- - -You can also use "polling" to continually refresh a component. On the -**top-level** element for your component, add ``data-poll``: - -.. code-block:: diff - -
    - -This will make a request every 2 seconds to re-render the component. You -can change this by adding a ``delay()`` modifier. When you do this, you -need to be specific that you want to call the ``$render`` method. To -delay for 500ms: - -.. code-block:: html+twig - -
    - -You can also trigger a specific "action" instead of a normal re-render: - -.. code-block:: html+twig - -
    - -Changing the URL when a LiveProp changes ----------------------------------------- - -.. versionadded:: 2.14 - - The ``url`` option was introduced in Live Components 2.14. - -If you want the URL to update when a ``LiveProp`` changes, you can do that with the ``url`` option:: - - // src/Twig/Components/SearchModule.php - namespace App\Twig\Components; - - use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; - use Symfony\UX\LiveComponent\Attribute\LiveProp; - use Symfony\UX\LiveComponent\DefaultActionTrait; - - #[AsLiveComponent] - class SearchModule - { - use DefaultActionTrait; - - #[LiveProp(writable: true, url: true)] - public string $query = ''; - } - -Now, when the user changes the value of the ``query`` prop, a query parameter in the URL will be updated to reflect the -new state of your component, for example: ``https://my.domain/search?query=my+search+string``. - -If you load this URL in your browser, the ``LiveProp`` value will be initialized using the query string -(e.g. ``my search string``). - -.. note:: - - The URL is changed via ``history.replaceState()``. So no new entry is added. - -Supported Data Types -~~~~~~~~~~~~~~~~~~~~ - -You can use scalars, arrays and objects in your URL bindings: - -============================================ ================================================= -JavaScript ``prop`` value URL representation -============================================ ================================================= -``'some search string'`` ``prop=some+search+string`` -``42`` ``prop=42`` -``['foo', 'bar']`` ``prop[0]=foo&prop[1]=bar`` -``{ foo: 'bar', baz: 42 }`` ``prop[foo]=bar&prop[baz]=42`` - - -When a page is loaded with a query parameter that's bound to a ``LiveProp`` (e.g. ``/search?query=my+search+string``), -the value - ``my search string`` - goes through the hydration system before it's set onto the property. If a value can't -be hydrated, it will be ignored. - -Multiple Query Parameter Bindings -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can use as many URL bindings as you want in your component. To ensure the state is fully represented in the URL, -all bound props will be set as query parameters, even if their values didn't change. - -For example, if you declare the following bindings:: - - // ... - #[AsLiveComponent] - class SearchModule - { - #[LiveProp(writable: true, url: true)] - public string $query = ''; - - #[LiveProp(writable: true, url: true)] - public string $mode = 'fulltext'; - - // ... - } - - -And you only set the ``query`` value, then your URL will be updated to -``https://my.domain/search?query=my+query+string&mode=fulltext``. - -Controlling the Query Parameter Name -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionadded:: 2.17 - - The ``as`` option was added in LiveComponents 2.17. - - -Instead of using the prop's field name as the query parameter name, you can use the ``as`` option in your ``LiveProp`` -definition:: - - // ... - use Symfony\UX\LiveComponent\Metadata\UrlMapping; - - #[AsLiveComponent] - class SearchModule - { - #[LiveProp(writable: true, url: new UrlMapping(as: 'q'))] - public string $query = ''; - - // ... - } - -Then the ``query`` value will appear in the URL like ``https://my.domain/search?q=my+query+string``. - -If you need to change the parameter name on a specific page, you can leverage the :ref:`modifier ` option:: - - // ... - use Symfony\UX\LiveComponent\Metadata\UrlMapping; - - #[AsLiveComponent] - class SearchModule - { - #[LiveProp(writable: true, url: true, modifier: 'modifyQueryProp')] - public string $query = ''; - - #[LiveProp] - public ?string $alias = null; - - public function modifyQueryProp(LiveProp $liveProp): LiveProp - { - if ($this->alias) { - $liveProp = $liveProp->withUrl(new UrlMapping(as: $this->alias)); - } - return $liveProp; - } - } - -.. code-block:: html+twig - - - -This way you can also use the component multiple times in the same page and avoid collisions in parameter names: - -.. code-block:: html+twig - - - - -Validating the Query Parameter Values -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Like any writable ``LiveProp``, because the user can modify this value, you should consider adding -:ref:`validation `. When you bind a ``LiveProp`` to the URL, the initial value is not automatically -validated. To validate it, you have to set up a `PostMount hook`_:: - - // ... - use Symfony\Component\Validator\Constraints as Assert; - use Symfony\UX\LiveComponent\ValidatableComponentTrait; - use Symfony\UX\TwigComponent\Attribute\PostMount; - - #[AsLiveComponent] - class SearchModule - { - use ValidatableComponentTrait; - - #[LiveProp(writable: true, url: true)] - public string $query = ''; - - #[LiveProp(writable: true, url: true)] - #[Assert\NotBlank] - public string $mode = 'fulltext'; - - #[PostMount] - public function postMount(): void - { - // Validate 'mode' field without throwing an exception, so the component can - // be mounted anyway and a validation error can be shown to the user - if (!$this->validateField('mode', false)) { - // Do something when validation fails - } - } - - // ... - } - -.. note:: - - You can use `validation groups`_ if you want to use specific validation rules only in the PostMount hook. - -.. _emit: - -Communication Between Components: Emitting Events -------------------------------------------------- - -.. versionadded:: 2.8 - - The ability to emit events was added in Live Components 2.8. - -Events allow you to communicate between any two components that live -on your page. - -Emitting an Event -~~~~~~~~~~~~~~~~~ - -There are three ways to emit an event: - -.. versionadded:: 2.16 - - The ``data-live-event-param`` attribute was added in Live Components 2.16. - Previously, it was called ``data-event``. - -1. From Twig: - - .. code-block:: html+twig - - +The ``props`` argument contains the props passed to the component. +You can use it to customize the placeholder content. Let's say your +component shows a certain number of products (defined with the ``size`` +prop). You can use it to define a placeholder that shows the same +number of rows: -When the user clicks that button, it will attempt to call the ``save`` -action in the *child* component only, even if the ``save`` action -actually only exists in the parent. The same is true for ``data-model``, -though there is some special handling for this case (see next point). +.. code-block:: html+twig -Communicating with a Parent Component -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + {# In the calling template #} + -There are two main ways to communicate from a child component to a parent -component: +.. code-block:: html+twig -1. :ref:`Emitting events ` + {# In the component template #} + {% macro placeholder(props) %} + {% for i in 1..props.size %} +
    + ... +
    + {% endfor %} + {% endmacro %} - The most flexible way to communicate: any information can be sent - from the child to the parent. +To customize the loading content from the calling template, you can use +the ``loading-template`` option to point to a template: -2. :ref:`Updating a parent model from a child ` +.. code-block:: html+twig - Useful as a simple way to "synchronize" a child model with a parent - model: when the child model changes, the parent model will also change. + {# With the HTML syntax #} + -.. _data-model: -.. _update-parent-model: + {# With the component function #} + {{ component('SomeHeavyComponent', { loading: 'defer', loading-template: 'spinning-wheel.html.twig' }) }} -Updating a Parent Model from a Child -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Or override the ``loadingContent`` block: -Suppose a child component has a: +.. code-block:: html+twig -.. code-block:: html + {# With the HTML syntax #} + + Custom Loading Content... + - - -
    - {{ value|markdown_to_html }} -
    -
    - -Notice that ``MarkdownTextarea`` allows a dynamic ``name`` -attribute to be passed in. This makes that component re-usable in any -form. +Now, when the user changes the value of the ``query`` prop, a query parameter in the URL will be updated to reflect the +new state of your component, for example: ``https://my.domain/search?query=my+search+string``. -.. _rendering-loop-of-elements: +If you load this URL in your browser, the ``LiveProp`` value will be initialized using the query string +(e.g. ``my search string``). -Rendering Quirks with List of Elements -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. note:: -If you're rendering a list of elements in your component, to help LiveComponents -understand which element is which between re-renders (i.e. if something re-orders -or removes some of those elements), you can add a ``id`` attribute to -each element + The URL is changed via ``history.replaceState()``. So no new entry is added. -.. code-block:: html+twig +Supported Data Types +~~~~~~~~~~~~~~~~~~~~ - {# templates/components/Invoice.html.twig #} - {% for lineItem in lineItems %} -
    - {{ lineItem.name }} -
    - {% endfor %} +You can use scalars, arrays and objects in your URL bindings: -.. _key-prop: +============================================ ================================================= +JavaScript ``prop`` value URL representation +============================================ ================================================= +``'some search string'`` ``prop=some+search+string`` +``42`` ``prop=42`` +``['foo', 'bar']`` ``prop[0]=foo&prop[1]=bar`` +``{ foo: 'bar', baz: 42 }`` ``prop[foo]=bar&prop[baz]=42`` -Rendering Quirks with List of Embedded Components -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Imagine your component renders a list of child components and -the list changes as the user types into a search box... or by clicking -"delete" on an item. In this case, the wrong children may be removed -or existing child components may not disappear when they should. +When a page is loaded with a query parameter that's bound to a ``LiveProp`` (e.g. ``/search?query=my+search+string``), +the value - ``my search string`` - goes through the hydration system before it's set onto the property. If a value can't +be hydrated, it will be ignored. -.. versionadded:: 2.8 +Multiple Query Parameter Bindings +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The ``key`` prop was added in Symfony UX Live Component 2.8. +You can use as many URL bindings as you want in your component. To ensure the state is fully represented in the URL, +all bound props will be set as query parameters, even if their values didn't change. -To fix this, add a ``key`` prop to each child component that's unique -to that component: +For example, if you declare the following bindings:: -.. code-block:: twig + // ... + #[AsLiveComponent] + class SearchModule + { + #[LiveProp(writable: true, url: true)] + public string $query = ''; - {# templates/components/InvoiceCreator.html.twig #} - {% for lineItem in invoice.lineItems %} - {{ component('InvoiceLineItemForm', { - lineItem: lineItem, - key: lineItem.id, - }) }} - {% endfor %} + #[LiveProp(writable: true, url: true)] + public string $mode = 'fulltext'; -The ``key`` will be used to generate an ``id`` attribute, -which will be used to identify each child component. You can -also pass in a ``id`` attribute directly, but ``key`` is -a bit more convenient. + // ... + } -.. _rendering-loop-new-element: -Tricks with a Loop + a "New" Item -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +And you only set the ``query`` value, then your URL will be updated to +``https://my.domain/search?query=my+query+string&mode=fulltext``. -Let's get fancier. After looping over the current line items, you -decide to render one more component to create a *new* line item. -In that case, you can pass in a ``key`` set to something like ``new_line_item``: +Controlling the Query Parameter Name +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: twig +.. versionadded:: 2.17 - {# templates/components/InvoiceCreator.html.twig #} - // ... loop and render the existing line item components + The ``as`` option was added in LiveComponents 2.17. - {{ component('InvoiceLineItemForm', { - key: 'new_line_item', - }) }} -Imagine you also have a ``LiveAction`` inside of ``InvoiceLineItemForm`` -that saves the new line item to the database. To be extra fancy, -it emits a ``lineItem:created`` event to the parent:: +Instead of using the prop's field name as the query parameter name, you can use the ``as`` option in your ``LiveProp`` +definition:: - // src/Twig/InvoiceLineItemForm.php // ... + use Symfony\UX\LiveComponent\Metadata\UrlMapping; #[AsLiveComponent] - final class InvoiceLineItemForm + class SearchModule { - // ... - - #[LiveProp] - #[Valid] - public ?InvoiceLineItem $lineItem = null; - - #[PostMount] - public function postMount(): void - { - if (!$this->lineItem) { - $this->lineItem = new InvoiceLineItem(); - } - } - - #[LiveAction] - public function save(EntityManagerInterface $entityManager) - { - if (!$this->lineItem->getId()) { - $this->emit('lineItem:created', $this->lineItem); - } + #[LiveProp(writable: true, url: new UrlMapping(as: 'q'))] + public string $query = ''; - $entityManager->persist($this->lineItem); - $entityManager->flush(); - } + // ... } -Finally, the parent ``InvoiceCreator`` component listens to this -so that it can re-render the line items (which will now contain the -newly-saved item):: +Then the ``query`` value will appear in the URL like ``https://my.domain/search?q=my+query+string``. + +If you need to change the parameter name on a specific page, you can leverage the :ref:`modifier ` option:: - // src/Twig/InvoiceCreator.php // ... + use Symfony\UX\LiveComponent\Metadata\UrlMapping; #[AsLiveComponent] - final class InvoiceCreator + class SearchModule { - // ... + #[LiveProp(writable: true, url: true, modifier: 'modifyQueryProp')] + public string $query = ''; - #[LiveListener('lineItem:created')] - public function addLineItem() + #[LiveProp] + public ?string $alias = null; + + public function modifyQueryProp(LiveProp $liveProp): LiveProp { - // no need to do anything here: the component will re-render + if ($this->alias) { + $liveProp = $liveProp->withUrl(new UrlMapping(as: $this->alias)); + } + return $liveProp; } } -This will work beautifully: when a new line item is saved, the ``InvoiceCreator`` -component will re-render and the newly saved line item will be displayed along -with the extra ``new_line_item`` component at the bottom. - -But something surprising might happen: the ``new_line_item`` component won't -update! It will *keep* the data and props that were there a moment ago (i.e. the -form fields will still have data in them) instead of rendering a fresh, empty component. +.. code-block:: html+twig -Why? When live components re-renders, it thinks the existing ``key: new_line_item`` -component on the page is the *same* new component that it's about to render. And -because the props passed into that component haven't changed, it doesn't see any -reason to re-render it. + -To fix this, you have two options: +This way you can also use the component multiple times in the same page and avoid collisions in parameter names: -\1) Make the ``key`` dynamic so it will be different after adding a new item: +.. code-block:: html+twig -.. code-block:: twig + + - {{ component('InvoiceLineItemForm', { - key: 'new_line_item_'~lineItems|length, - }) }} +Validating the Query Parameter Values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -\2) Reset the state of the ``InvoiceLineItemForm`` component after it's saved:: +Like any writable ``LiveProp``, because the user can modify this value, you should consider adding +:ref:`validation `. When you bind a ``LiveProp`` to the URL, the initial value is not automatically +validated. To validate it, you have to set up a `PostMount hook`_:: - // src/Twig/InvoiceLineItemForm.php // ... + use Symfony\Component\Validator\Constraints as Assert; + use Symfony\UX\LiveComponent\ValidatableComponentTrait; + use Symfony\UX\TwigComponent\Attribute\PostMount; #[AsLiveComponent] - class InvoiceLineItemForm + class SearchModule { - // ... + use ValidatableComponentTrait; - #[LiveAction] - public function save(EntityManagerInterface $entityManager) - { - $isNew = null === $this->lineItem->getId(); + #[LiveProp(writable: true, url: true)] + public string $query = ''; - $entityManager->persist($this->lineItem); - $entityManager->flush(); + #[LiveProp(writable: true, url: true)] + #[Assert\NotBlank] + public string $mode = 'fulltext'; - if ($isNew) { - // reset the state of this component - $this->emit('lineItem:created', $this->lineItem); - $this->lineItem = new InvoiceLineItem(); - // if you're using ValidatableComponentTrait - $this->clearValidation(); + #[PostMount] + public function postMount(): void + { + // Validate 'mode' field without throwing an exception, so the component can + // be mounted anyway and a validation error can be shown to the user + if (!$this->validateField('mode', false)) { + // Do something when validation fails } } - } - -.. _passing-blocks: -Passing Content (Blocks) to Components -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Passing content via blocks to Live components works completely the same way you would `pass content to Twig Components`_. -Except with one important difference: when a component is re-rendered, any variables defined only in the -"outside" template will not be available. For example, this won't work: - -.. code-block:: twig - - {# templates/some_page.html.twig #} - {% set message = 'Variables from the outer part of the template are only available during the initial render' %} - - {% component Alert %} - {% block content %}{{ message }}{% endblock %} - {% endcomponent %} + // ... + } -Local variables do remain available: +.. note:: -.. code-block:: twig + You can use `validation groups`_ if you want to use specific validation rules only in the PostMount hook. - {# templates/some_page.html.twig #} - {% component Alert %} - {% block content %} - {% set message = 'this works during re-rendering!' %} - {{ message }} - {% endblock %} - {% endcomponent %} +.. _emit: Advanced Functionality ---------------------- @@ -3769,3 +2290,13 @@ promise. However, any internal implementation in the JavaScript files .. _`locale route parameter`: https://symfony.com/doc/current/translation.html#the-locale-and-the-url .. _`setting the locale in the request`: https://symfony.com/doc/current/translation.html#translation-locale .. _`Stimulus action parameter`: https://stimulus.hotwired.dev/reference/actions#action-parameters + +Learn more +---------- + +How to: + .. toctree:: + :maxdepth: 1 + + /form + /communication \ No newline at end of file