symfony / ux

Symfony UX initiative: a JavaScript ecosystem for Symfony
https://ux.symfony.com/
MIT License
820 stars 297 forks source link

[LiveComponent] Mount hooks not executed when re-rendering a live component or calling one of the actions #1181

Closed mahono closed 11 months ago

mahono commented 11 months ago

Hi, I love the Live Components!

Unfortunately I seem to misunderstand something or I found a bug:

I need the component to execute a method with the PostMount hook in order to access services and load entities from the database to prepare some more variables. It works nice when initially rendering the component with the "main" request.

But when I try to re-render the component or execute an action it does not execute the mount hooks.

Is this intended behavior? Can I somehow enable it?

Or in other words, I need a way in the PHP code of the live component to call a method after LiveProps were set and before the action method is executed. What would be the best solution for that? I would like to avoid having to add a $this->init() call at the begin of every action.

I'm a bit lost here because I also have another issue with form (for some reason the form is not initialized with data; but that is another topic) and I try to sort out the issues. I'm afraid that the form issue is caused by a wrong order of my code and the form handling code from the ComponentWithFormTrait.

Any help is appreciated. Thanks!

smnandre commented 11 months ago

Not sure if that answer your question, but hooks on liveprops seem to be what you're looking for

If you want to run custom code after a specific LiveProp is updated, you can do it by adding an onUpdated option set to a public method name on the component

And you're right about the PostMount: the mount method is called just once.

The mount() method is called just one time: immediately after your component is instantiated.

weaverryan commented 11 months ago

But when I try to re-render the component or execute an action it does not execute the mount hooks. Is this intended behavior? Can I somehow enable it?

Yea, the entire mount process is called just once when the component is originally created. The purpose of this step is to set up any properties on your component. Then, each prop should have a #[LiveProp] above it. That's what makes the property "persist" between actions / re-renders.

However, depending on what you want to do, setting the data from your services onto properties and adding LiveProp above them might not be the right solution. Let's think of an example where you have a component that queries the database for matching products and renders them. In this case, there is no point (and you don't really want to) to query for the Product objects and set them on a property. Instead, query and calculate them lazily via a method:

class ProductList
{
    // not strictly necessary for this example - but imagine the user can change this via a text box
    #[LiveProp(writable:true)]
    public string $search = '';

    public function __construct(private ProductRepository $productRepository)
    {
     }

    #[ExposeInTemplate]
    public function getProducts()
    {
        return $this->productRepository->search($this->search);
    }
}

That's it :). You can now call this via this.products in your template or just as products, thanks to the ExposeInTemplate. The search property "persists" between re-renders / actions. But you don't need your product results to do that: you can query and grab them fresh each time your component renders.

Let me know if this helps - or if i'm WAY misunderstanding your situation :)

mahono commented 11 months ago

Thanks for your reply. I didn't get it from the docs that "once" is literally meant. It totally makes sense. Maybe add a hint that mount is not called on re-render to make it clear.

What I was trying to do is creating a component that is basically a modal that loads a form to edit an entity. I use another component to select the ID of the record I wanna edit.

For this I need to load the record based on a prop. I will try the ExposeInTemplate trick. I wasn't aware that it also works for getter methods.

I solved it earlier today by just executing a $this->setup() method at the beginning of each live action.

For the form problem: I wanted a simple way to submit the form just by clicking on a "done" button. This is not possible out of the box because the initializeForm() method of the ComponentWithFormTrait is also only called once (PostMount).

So, I solved this by executing these lines

$this->formValues = $this->extractFormValues($this->getFormView());
$this->shouldAutoSubmitForm = false;

in the action that loads the form for the desired record.

Ugly but it works now. (Of course I also had to change getDataModelValue() to return 'norender|*'.

Maybe it would make sense to provide either an alternative trait or an option to switch to this alternative form submit mode?

mahono commented 11 months ago

Closing because the origin issue has been solved. Thanks.

weaverryan commented 11 months ago

Hey!

About the form side of the problem, I don’t think I quite understand. If there’s something we can improve, it’s not clear yet, but maybe you can help :).

You mentioned you wanted to submit on a “done” button (assuming to a LiveAction). That’s a very normal thing to do - normally you would call $this->submitForm() in the LiveAction and then save. Can you tell me what problem you were having and how you used those 2 lines? Which action were those in?

Or perhaps the problem was when switching the form from one entity to another - maybe the form stayed with the old values? In that case, we might need to call resetForm()… and perhaps that might need to be called only when that one entity id changes (I’m doing some thinking / rambling out loud).

Cheers!

mahono commented 11 months ago

Basically I have two live components, one to manage a collection of files and another to edit "file contents" like title, description, tags, etc. in a separate component. Each time the user clicks an edit button on one of the files, it loads the edit form into a modal by calling the "editContents" live action. I did it so that the modal becomes visible when clicking on "edit".

On form submit the "saveContents" action is called.

I'm using the event that is sent at the end of saveContents() to close the modal again. Everything works really fine now. But it might not be the optimal approach.

So, what I'm missing would be an additional method in ComponentWithFormTrait to do what I'm doing manually in the editContents() method. Mostly because it's code that I am not supposed to call from "outside" the trait. But I didn't find another way.

Like I said, I want to submit the whole form "on submit", not "on change".

Here is the code of the "edit" component:


#[AsLiveComponent]
class FileEdit
{
    use ComponentWithFormTrait;
    use ComponentToolsTrait;

    /**
     * The ID of the file.
     */
    #[LiveProp(writable: true)]
    public ?string $fileId = null;

    private function setup(): void
    {
        // Do something "before" each action call.
    }

    #[LiveAction]
    public function editContents()
    {
        $this->setup();

        // Because the PostMount hook is not executed on re-render,
        // we need to execute this manually to create the form.
        $this->formValues = $this->extractFormValues($this->getFormView());

        // This disables the validation of the form during the PreReRender hook.
        $this->shouldAutoSubmitForm = false;
    }

    private function getDataModelValue(): ?string
    {
        // https://symfony.com/bundles/ux-live-component/current/index.html#deferring-a-re-render-until-later
        // Don't render the component no matter what field is changed
        // (this is what the * is for) until the "Done" button is clicked.
        return 'norender|*';
    }

    #[LiveAction]
    public function saveContents()
    {
        $this->setup();

        $this->submitForm();
        // After submitForm(), the form is valid.

        $fileContentsDto = $this->getForm()->getData();

        // save the changes here

        // Reset the form so that it can be re-used again.
        $this->resetForm();

        // Send the browser event because there is no other way to let the frontend know that the form was submitted.
        $this->dispatchBrowserEvent('file-edit:afterSaveContents', ['fileId' => $this->fileId]);
    }

    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(/* .. */);
    }
}