the-urlist / BlazorSortable

A sortable list component for Blazor
MIT License
150 stars 22 forks source link

How to work with dynamic lists? #2

Open MarvinKlein1508 opened 8 months ago

MarvinKlein1508 commented 8 months ago

Hi!

First off, nice work! I've tried to implement it within my BlazorForms project, which allows the user to built rich interactive forms using drag & drop and blazor.

My issue is that I cannot figure out how to work with dynamic lists. For example I have a class which hols a list of another class and this one is holding another list of another class again. Basically this:

public class Form
{
    public List<FormRow> Rows { get; set; } = [];
}
public class FormRow
{
    public List<FormElement> Elements { get; set; } = [];
}

public class FormElement
{
    public string Name { get; set; } = string.Empty;
    public FormElement(string name)
    {
        Name = name;
    }
}

One instanciated model may look like this:

public Form MyForm { get; set; } = new Form
{
    Rows =
        [
            new FormRow()
            {
                Elements =
                [
                    new FormElement("Hello World 1"),
                    new FormElement("Hello World 2"),
                    new FormElement("Hello World 3"),
                    new FormElement("Hello World 4"),
                ]
            },
            new FormRow()
            {
                Elements =
                [
                    new FormElement("Hello World 5"),
                    new FormElement("Hello World 6"),
                    new FormElement("Hello World 7"),
                    new FormElement("Hello World 8"),
                ]
            },
        ]
};

Using generics I pass the source list to my sorting function like this:

private void SortList<T>((int oldIndex, int newIndex) indices, List<T> items)
{
    // deconstruct the tuple
    var (oldIndex, newIndex) = indices;

    var itemToMove = items[oldIndex];
    items.RemoveAt(oldIndex);

    if (newIndex < items.Count)
    {
        items.Insert(newIndex, itemToMove);
    }
    else
    {
        items.Add(itemToMove);
    }

    StateHasChanged();
}

The list itself is implemented like this:

<SortableList Id="@Guid.NewGuid().ToString()" Items="MyForm.Rows" OnUpdate="(indices) => SortList<FormRow>(indices, MyForm.Rows)" Context="row">
    <SortableItemTemplate>
        <div class="has-border has-background-white has-cursor-pointer">
            <p class="is-size-4 p-2 ml-4">
                <div class="form-row">
                    This is a row.
                    <SortableList Id="@Guid.NewGuid().ToString()" Items="row.Elements" OnUpdate="(indices) => SortList<FormElement>(indices, row.Elements)" Context="element">
                        <SortableItemTemplate>
                            <div class="has-border has-background-white has-cursor-pointer">
                                <div class="form-element">
                                    @element.Name
                                </div>
                            </div>
                        </SortableItemTemplate>
                    </SortableList>
                </div>
            </p>
        </div>
    </SortableItemTemplate>
</SortableList>

This works fine for me. I'm able to sort all rows and all elements. However I want to be able to move one FormElement to another FormRow. So basically remove it from source and then add it to the target list.

But there is no way for me to determine the target list. The List of FormRow from the Form class can have unlimited amount of rows.

Do you have any clue how this can be achieved?

VaclavElias commented 8 months ago

Exactly! Here comes the rescue, but not battlefield tested. And I am sorry, not doing PR, so use and re-use as desired. Note the added FromId, ToId (also in the .js) with the list id. Your logic then needs to work with the strings to identify the list. You might need to change your logic/list, so you have an id for each list.

Also, I am returning the list itself, which seems easier to work with in my case. Obviosly, if you are moving across multiple lists, you need to identify the other list through the Id.

public class SortAction<T>()
{
    public required int OldIndex { get; set; }
    public required int NewIndex { get; set; }
    public required string ToId { get; set; }
    public required string FromId { get; set; }
    public required List<T> Items { get; set; }
}

SortableList.razor

@using System.Collections.Generic
@using System.Diagnostics.CodeAnalysis
@inject IJSRuntime JS
@typeparam T
<div id="@Id" class="@Class">
    @foreach (var item in Items)
    {
        @if (SortableItemTemplate is not null)
        {
            @SortableItemTemplate(item)
        }
    }
    @if (Footer is not null)
    {
        @Footer
    }
</div>
@code {
    [Parameter] public RenderFragment<T>? SortableItemTemplate { get; set; }
    [Parameter] public RenderFragment? Footer { get; set; }

    [Parameter, AllowNull] public List<T> Items { get; set; }

    [Parameter] public EventCallback<SortAction<T>> OnUpdate { get; set; }

    [Parameter] public EventCallback<SortAction<T>> OnRemove { get; set; }

    [Parameter]
    public string Id { get; set; } = Guid.NewGuid().ToString();

    [Parameter]
    public string Group { get; set; } = Guid.NewGuid().ToString();

    [Parameter]
    public string? Pull { get; set; }

    [Parameter]
    public bool Put { get; set; } = true;

    [Parameter]
    public bool Sort { get; set; } = true;

    [Parameter]
    public string Handle { get; set; } = string.Empty;

    [Parameter]
    public string? Filter { get; set; }

    [Parameter] public string? Class { get; set; }

    private DotNetObjectReference<SortableList<T>>? selfReference;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            selfReference = DotNetObjectReference.Create(this);
            var module = await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Core/SortableList.razor.js");
            await module.InvokeAsync<string>("init", Id, Group, Pull, Put, Sort, Handle, Filter, selfReference);
        }
    }

    [JSInvokable]
    public void OnUpdateJS(int oldIndex, int newIndex, string toId, string fromId)
    {
        Console.WriteLine($"OnUpdateJS: {oldIndex} {newIndex} {toId} {fromId}");

        // invoke the OnUpdate event passing in the oldIndex and the newIndex
        OnUpdate.InvokeAsync(new SortAction<T>
            {
                OldIndex = oldIndex,
                NewIndex = newIndex,
                ToId = toId,
                FromId = fromId,
                Items = Items
            });
    }

    [JSInvokable]
    public void OnRemoveJS(int oldIndex, int newIndex, string toId, string fromId)
    {
        Console.WriteLine($"OnRemoveJS: {oldIndex} {newIndex} {toId} {fromId}");

        // remove the item from the list
        OnRemove.InvokeAsync(new SortAction<T>
            {
                OldIndex = oldIndex,
                NewIndex = newIndex,
                ToId = toId,
                FromId = fromId,
                Items = Items
            });
    }

    public void Dispose() => selfReference?.Dispose();
}

SortableList.razor.js

export function init(id, group, pull, put, sort, handle, filter, component) {
    var sortable = new Sortable(document.getElementById(id), {
        animation: 200,
        group: {
            name: group,
            pull: pull || true,
            put: put
        },
        filter: filter || undefined,
        sort: sort,
        forceFallback: true,
        handle: handle || undefined,
        onUpdate: (event) => {
            // Revert the DOM to match the .NET state
            event.item.remove();
            event.to.insertBefore(event.item, event.to.childNodes[event.oldIndex]);

            // Notify .NET to update its model and re-render
            component.invokeMethodAsync('OnUpdateJS', event.oldDraggableIndex, event.newDraggableIndex, event.to.id, event.from.id);
        },
        onRemove: (event) => {
            if (event.pullMode === 'clone') {
                // Remove the clone
                event.clone.remove();
            }

            event.item.remove();
            event.from.insertBefore(event.item, event.from.childNodes[event.oldIndex]);

            // Notify .NET to update its model and re-render
            component.invokeMethodAsync('OnRemoveJS', event.oldDraggableIndex, event.newDraggableIndex, event.to.id, event.from.id);
        }
    });
}
MarvinKlein1508 commented 8 months ago

Hi @VaclavElias

thanks I think this will help me a lot. I'll keep you informed once I tested it with my implementation.

burkeholland commented 8 months ago

@MarvinKlein1508 let me know if this works well, and I'll open this PR to add these additional properties. Very nice callout from @VaclavElias.

VaclavElias commented 8 months ago

It works for me well. I use it in Kanban, so I need 3 lists, and the only way is to use the provided ids. Also, no hacking here, just using additional parameters provided by the JS library itself.

I like using SortAction because it provides me also the items right away. Works in my case. It could provide also an item itself (old?), but I didn't explore that option at the moment because I use it only in my kanban 😀