Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[LiveComponents] Edit a collection in a LiveComponent without using a form: onUpdated hook not called #2290

Open
matthieumastadenis opened this issue Oct 21, 2024 · 0 comments

Comments

@matthieumastadenis
Copy link

TL;DR: How to use "onUpdated" hook with a multidimensional array like the following as a LiveProp?

   /** @var array<int, array{
    *   name       : string,
    *   supplyMode : string|null,
    * }> $products
    */
   #[LiveProp(writable: true, onUpdated: 'onProductsUpdated')]
   public array $products = [];

Hello,

I work on a component for editing a Prestation entity. Because this component will be complex (with multiple tabs, each one dedicated to editing a subset of the entity's properties), I can't easily use one unique form for this.

I figured out I probably had two solutions :

  • create a child component for each of these tabs, which is probably the best solution but also a bit tedious
  • use one unique component with several custom LiveModels instead of using ComponentWithFormTrait

In order to avoid the creation of multiple forms and multiple child components (and also to satisfy my curiosity regarding the possibilities), I decided to try the second solution. But things got complicated when I wanted to update Prestation.products, which is a collection of related Product entities.

The documentation recommends to use a form with a CollectionType for that kind of scenario. But, like I tried to explain above, using a ComponentWithFormTrait would force me to have one unique form in my component when I'd actually need several (one per tab). So I tried to recreate a way to update my collection using only LiveProps, without any form.

This is what I did so far (note: for better readability I simplified the following code as much as possible by removing most things which are not related to my current issue) :

#[AsLiveComponent]
class EditPrestation extends AbstractController
{
    use DefaultActionTrait;

    #[LiveProp(
        writable  : [ 'name' ],
        onUpdated : [ 'name' => 'onNameUpdated' ],
    )]
    public Prestation $prestation;

    /** @var array<int, array{
     *   name       : string,
     *   supplyMode : string|null,
     * }> $products
     */
    #[LiveProp(writable: true, onUpdated: 'onProductsUpdated')]
    public array $products = [];

    #[LiveProp(writable: true)]
    public string $tab = 'general';

    public MenuDefinition $menu;

    /** @var SupplyMode[]  */
    public array $supplyModes = [];

    public function __construct(
        protected readonly EntityManagerInterface $manager,
    ) {

    }

    public function __invoke(): void
    {
        $this->menu        = $this->createMenu();
        $this->supplyModes = SupplyMode::cases();
    }

    public function mount(
        Prestation $prestation,
        string     $tab = 'general',
    ): void {
        $this->prestation = $prestation;
        $this->tab        = $tab;

        foreach ($prestation->getProducts() as $product) {
            $this->products[$product->getId()] = [
                'name'       => $product->getName(),
                'supplyMode' => $product->getSupplyMode()?->value,
            ];
        }

        $this->__invoke();
    }

    public function onNameUpdated(): void
    {
        // This method is an example of how I manage the edition of a simple string property of my Prestation entity.
        // This works well and is not related to my current issue.
        $this->manager->persist($this->prestation);
        $this->manager->flush();
        $this->addFlash('success', 'Prestation modifiée');
    }

    public function onProductsUpdated(): void
    {
        // This is where I would normally hydrate and persist Product entities, based on the $this->products LiveProp
        // Problem: this method seems to never be called
        return;
    }

    #[LiveAction]
    public function addProduct(): void
    {
        // This method handles the "Add Product" button.
        // This works well and is not related to my current issue.
        $nb      = \count($this->prestation->getProducts()) + 1;
        $product = new Product(
            name       : "Produit N°$nb",
            prestation : $this->prestation,
            supplyMode : null,
        );

        $this->prestation->addProduct($product);
        $this->manager->persist($this->prestation);
        $this->manager->persist($product);
        $this->manager->flush();

        $this->products[$product->getId()] = [
            'name'       => $product->getName(),
            'supplyMode' => $product->getSupplyMode()?->value,
        ];

        $this->addFlash('success', "Produit N°$nb ajouté");
        $this->__invoke();
    }

    protected function createMenu(): MenuDefinition
    {
        // This is where I create the menu with the different tabs. 
        // This works well and is not related to my current issue.
    }
}
<div {{ attributes.defaults({ class : 'prestation' }) }}>
    <twig:UI:Flashes :flashes="app.flashes" />

    <div class="prestation_aside">
        <twig:UI:Menu :menu="menu" />
    </div>

    <div class="prestation_main">
        {% if tab == 'general' %}
            {# This section of the component is not directly related to my current issue.
            I kept it as an example of how I manage the edition of the Prestation.name 
            property which is a simple string. #}
            <twig:UI:Title:Section>Général</twig:UI:Title:Section>
            <div class="form_wrapper">
                <div class="form_grid form_grid-2_columns">
                    <div class="form_label">
                        <label for="prestation_name" class="label">Nom de la Prestation</label>
                    </div>
                    <div class="form_field">
                        <input
                            type="text"
                            id="prestation_name"
                            value="{{ prestation.name }}"
                            data-model="prestation.name"
                        >
                    </div>
                </div>
            </div>
        {% elseif tab == 'produits' %}
            {# Below is the section related to my issue. 
            Notice the data-model attributes on the <input> and <select> tags: #}
            <twig:UI:Title:Section>Produits</twig:UI:Title:Section>
            <twig:UI:Button liveAction="addProduct">Ajouter un Produit</twig:UI:Button>
            {% if products|length > 0 %}
                <div class="form_wrapper">
                    <div class="form_grid form_grid-2_columns">
                        {% for productId, productData in products %}
                            <div class="form_label">
                                <label for="product_{{ productId }}_name" class="label">Nom</label>
                            </div>
                            <div class="form_field">
                                <input
                                    type="text"
                                    id="product_{{ productId }}_name"
                                    value="{{ productData.name }}"
                                    data-model="products.{{ productId }}.name"
                                >
                            </div>
                            <div class="form_label">
                                <label for="product_{{ productId }}_supplyMode" class="label">Mode d'Appro.</label>
                            </div>
                            <div class="form_field">
                                <select 
                                    id="product_{{ productId }}_supplyMode"
                                    data-model="products.{{ productId }}.supplyMode"
                                >
                                    <option
                                        value="null"
                                        {% if productData.supplyMode == null %}selected{% endif %}
                                    >Indéterminé</option>
                                    {% for supplyMode in supplyModes %}
                                        <option
                                            value="{{ supplyMode.value }}"
                                            {% if supplyMode.value == productData.supplyMode %}selected{% endif %}
                                        >{{ supplyMode.description }}</option>
                                    {% endfor %}
                                </select>
                            </div>
                        {% endfor %}
                    </div>
                </div>
            {% endif %}
        {% endif %}
    </div>
</div>

This kind of works... except the onProductsUpdated() method is never called. I'm not sure I did right by using data-model="products.{{ productId }}.name" and data-model="products.{{ productId }}.supplyMode". Is this actually possible, or is there another way?

If this is not possible, I'll accept it and break my component into several child components, each with its own dedicated form. But in the meantime I'd like to know if there is a way to achieve what I'm trying to do here.

Thanks for your help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant