symfony / ux

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

[LiveComponent] Prevent uncollapse item on refresh #702

Closed bastien70 closed 1 year ago

bastien70 commented 1 year ago

Hello, I'm using LiveComponent to render an hardcore form with a lot of collections.

This form has collections inside collapsed div. When clicking the button to "add" or "remove" a collection item, the item is added/removed but all is "uncollapsed".

Example :

https://user-images.githubusercontent.com/53140475/220677572-1852e200-3e9e-4e8b-bd86-7109fbf66755.mp4

Unfortunately, I cannot add "data-live-ignore" in the div containing the collapsed content since otherwise the buttons allowing to add/remove an element from a collection will no longer refresh the fields.

I had another idea which was to set up a system that would save in the entities whether it's collapsed or not, and at refresh, would allow me to put everything back as it should be, but I find it restrictive, and I wonder if there isn't a better way?

This is how I render the form :

<ul class="list-group">
        <li class="list-group-item bg-danger text-white">
            <span class="badge bg-success rounded-pill"></span>
            <span class="ms-2 fs-5">Éléments de preuve et questionnaire</span>
        </li>
        {% for sous_dossier_index, sous_dossier_form in form.sousDossiers.children %}
            {% set sous_dossier = sous_dossier_form.vars.data %}
            <li class="list-group-item" style="background-color: #f8f9fa;" data-bs-toggle="collapse" data-bs-target="#sous_dossier_{{ sous_dossier_index }}" aria-expanded="false" aria-controls="sous_dossier_{{ sous_dossier_index }}">
                <div class="d-flex justify-content-between align-items-center" role="button">
                    <span class="fs-4">{{ sous_dossier.name }}</span>
                    <span class="badge bg-warning rounded-pill">&nbsp;</span>
                </div>
                <div id="sous_dossier_{{ sous_dossier_index }}" class="collapse">
                        <div class="row row-cols-1 row-cols-md-2 g-4">
                            {% for sous_dossier_item_index, sous_dossier_item_form in sous_dossier_form.items %}
                                {% set sous_dossier_item = sous_dossier_item_form.vars.data %}
                                <div class="col">
                                    <div class="card">
                                        <div class="card-header">{{ sous_dossier_item }}</div>
                                        <div class="card-body">
                                            {% set files_item = sous_dossier_item_form.files.children %}

                                            {% for file_index, file_item in files_item %}
                                                <button type="button"
                                                        class="btn btn-link text-danger"
                                                        data-action="live#action"
                                                        data-action-name="removeItem(sousDossierIndex={{ sous_dossier_index }}, sousDossierItemIndex={{ sous_dossier_item_index }}, fileIndex={{ file_index }})">
                                                    Retirer
                                                </button>
                                                {{ form_widget(file_item, {'attr': {'data-model': 'on(change)|norender|' ~ file_item.vars.full_name }}) }}
                                            {% endfor %}
                                        </div>
                                        <div class="card-footer">
                                            <button type="button"
                                                    class="btn btn-link float-start"
                                                    data-action="live#action"
                                                    data-action-name="addItem(sousDossierIndex={{ sous_dossier_index }}, sousDossierItemIndex={{ sous_dossier_item_index }})">
                                                Ajouter un élément
                                            </button>
                                            <button type="button" class="btn btn-sm btn-secondary float-end">
                                                <i class="fas fa-arrow-right"></i>
                                            </button>
                                        </div>
                                    </div>
                                </div>
                            {% endfor %}
                        </div>
                </div>
            </li>
        {% endfor %}
    </ul>

And in the component, my two liveAction to add or remove an item :

    #[LiveAction]
    public function removeItem(#[LiveArg] int $sousDossierIndex, #[LiveArg] int $sousDossierItemIndex, #[LiveArg] int $fileIndex): void
    {
        if (isset($this->formValues['sousDossiers'][$sousDossierIndex]['items'][$sousDossierItemIndex]['files'][$fileIndex])) {
            unset($this->formValues['sousDossiers'][$sousDossierIndex]['items'][$sousDossierItemIndex]['files'][$fileIndex]);
        }
    }

    #[LiveAction]
    public function addItem(#[LiveArg] int $sousDossierIndex, #[LiveArg] int $sousDossierItemIndex): void
    {
        $this->formValues['sousDossiers'][$sousDossierIndex]['items'][$sousDossierItemIndex]['files'][] = [];
    }

Thanks for your help!

weaverryan commented 1 year ago

Hi @bastien70!

I think the longer answer to this is that we need a smarter re-render system, which is something I've been thinking about for awhile. The problem is that, when you expand an item, some CSS classes are probably added to "expand" that element. Then, on re-render, those CSS classes are missing from the Twig code, so LiveComponents removes them. We don't want that :).

In theory, we could "monitor" for any custom changes made by external JavaScript and be sure to not "undo" those on re-render.

That doesn't help you right now, so let's think of a shorter-term solution :). One idea: create a custom Stimulus controller. Use the hooks system to listen on render:started. This is triggered right after an Ajax call has finished, but before it's been applied to the page. The idea would be to "detect" which item is currently open and save it to some local variable / property on your controller. Then, listen again to render:finished and "re-open" that item - e.g. trigger a click on it or manually re-add the classes it needs. However, it's very possible that this would cause the item to quickly close then re-open again... so we'll need to see if it works in practice. We could also add some way in the render:started hook to modify the HTML that will be applied so that you could add the classes there. Or, we may need some hooks inside of morphdom (the library that applies the "diff" of the latest html onto your component) so that you could run some code whenever an individual "element" is being updated. That would allow you to see that the existing element is one that is open, and add the necessary classes to the "new" element so that they're not removed.

Let me know if any of this helps :)

bastien70 commented 1 year ago

Hey Ryan!

Indeed, a "show" class is added on the div carrying the "collapse" class when you click on it (and disappears when you click on it again).

I have never used a hook before, but I will try tomorrow with pleasure!

I had also thought of an update idea, I don't know if it's easily added to the library, but a system with a Twig tag that would allow you to define specific places not to be updated, which could be more efficient than the "data-live-ignore" attribute which is effective for the entire element.

For example :

    <li class="list-group-item" data-bs-toggle="collapse" data-bs-target="#sous_dossier" aria-expanded="false" aria-controls="sous_dossier">
        <div class="d-flex justify-content-between align-items-center" role="button">
            <span class="fs-4">My title</span>
            <span class="badge bg-warning rounded-pill">&nbsp;</span>
        </div>
        {% apply data_live_ignore %}
                <div id="sous_dossier" class="collapse">
            {% endapply %}
            Collapsed content here !
        </div>
    </li>

Here, we would have the "apply data_live_ignore" tag that I could assign only to the opening of the element that will contain the class that will be added/removed.

And so when refreshing, this part should not be refreshed.

But I imagine that this kind of thing must be complex to set up?

bastien70 commented 1 year ago

Hey it's me! (Mariooo)

I am doing the custom controller with the hooks.

The problem is that the render:started seems to fire after the divs uncollapse.

For example, I have an element that is collapsed, and so the div has class collapse and class show.

Except that at render:started, everything uncollapses directly before the render:started custom script starts, and therefore my divs that had the show class no longer have it at this moment, that's odd

Update: Mmh no actually it's the collapsed div in which I add a collection element that uncollapses before the started. The others that were collapsed do keep the show class during render:start.

So I just have to retrieve from which div there was a triggered event, allowing me to also add the show class to it.

Status updates to follow :D

Update 2 : Ok so I confirm that I can put back in collapse show the div other than the one for which I add an element.

I'm still looking for a way to find from which div an Ajax request is triggered, allowing me to go back to the collapsed element and add the show class to it at the end, but impossible

Here is the current script :

// assets/controllers/some-custom-controller.js
// ...
import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';

export default class extends Controller {
    async initialize() {
        this.component = await getComponent(this.element);

        let collapsedElementsIds = [];

        this.component.on('render:started', (component) => {

            // console.log(component);
            collapsedElementsIds = [];
            document.querySelectorAll('.show').forEach(function (element) {
               collapsedElementsIds.push(element.id);
            });
            // do something after the component re-renders
        });

        this.component.on('render:finished', (component) => {
            collapsedElementsIds.forEach(function(element) {
                document.getElementById(element).classList.add('show');
            })
            // do something after the component re-renders
        });
    }
}

Update 3 : In the meantime, I found a "hacky" way to put back in "collapse show" the div on which I add or delete an element.

In my component, I added a collapseId property. And while adding/removing an element, I save in this property the ID of the element responsible for the action.

Then in the Twig file, I create a condition that checks that the collapseId property is equal to the current element's ID, in which case, I add the show class to it.

And in combination with the custom controller, it does the job.

But actually, we still have this little closing/opening effect which isn't very pretty, which is a shame, but I imagine that I can't do better