vue.jsvuejs3draggablevue-composition-apivuedraggable

Vue3 with Draggable - Nesting list - Reactivity


I try to make drag and drop list with Draggable plugin, but depend how i assing new value in watcher from component(second code block) that's responsible of nesting it work only in one way

Codesandbox - https://codesandbox.io/s/bold-aj-jwvh93?file=/src/App.vue

Main component list

<script setup>
import { ref, watch, reactive, onMounted } from 'vue';
import DraggableListNesting from 'COMPONENT/cms/DraggableListNesting.vue';

const props = defineProps(
{
    list:
    {
        type: Array,
        required: true,
        default: [],
    },
});

const list = ref(props.list);

const emits = defineEmits(
[
    'getChildrens',
    'updateList',
    'listUpdated',
]);

watch(() => props.list, (newList) =>
{
    list.value = newList;
},
{
    deep: true,
    flush: 'post'
});

const childrensUpdated = (args) =>
{
    for(let i = 0; i < list.value.length; i++)
    {
        if(list.value[i].id == args.parentElementId)
        {
            list.value[i].childrens = args.list;
            list.value[i].childrens_count = args.list.length;
        }
    }

    // list.value = Object.assign(args.list, list.value);
};
</script>

<template>
    <div>
        <DraggableListNesting
            v-model="list"
            :childrensUpdated="childrensUpdated"
            @getChildrens="(args) => emits('getChildrens', args)"
            @childrensUpdated="(args) => childrensUpdated(args)"
        />
    </div>
</template>

Nesting component - DraggableListNesting

<script setup>
import { ref, watch } from 'vue';
import Draggable from 'vuedraggable';
import DraggableItem from 'COMPONENT/cms/DraggableItem.vue';
import DraggableListNesting from 'COMPONENT/cms/DraggableListNesting.vue';

const props = defineProps(
{
    modelValue:
    {
        type: Array,
        required: true,
        default: [],
    },
    parentId:
    {
        type: [Number, Boolean],
        required: false,
        default: false,
    },
    childrensUpdated:
    {
        type: Function,
        required: true,
    },
});

const emits = defineEmits(
[
    'getChildrens',
    'childrensUpdated',
]);

const list = ref(props.modelValue);
const parentId = ref(props.parentId);

watch(() => props.modelValue, (newList) =>
{
    console.log('list before update by watch');
    console.log(list.value);
    console.log(newList);
    list.value = newList;

    // list.value = Object.assign(newList, list.value);
},
{
    deep: true,
    flush: 'post'
});
</script>

<template>
    <Draggable
        v-model="list"
        tag="ul"
        :item-key="item => item.id"
        :group="{name: 'edit_list_draggable_nested'}"
        @end="(...args) =>
        {
            emits('childrensUpdated', {list: list, parentElementId: parentId, depth: nestDepth});
        }"
        >
        <template #item="{ element }" :key="element.id">
            <div>
                {{ element.name }}
            </div>

            <li :data-draggable-item-id="element.id">
                <DraggableListNesting
                    v-model="element.childrens"
                    :parentId="element.id"
                    :childrensUpdated="childrensUpdated"
                    @getChildrens="(args) => emits('getChildrens', args)"
                    @childrensUpdated="(args) => childrensUpdated(args)"
                ></DraggableListNesting>
            </li>
        </template>
    </Draggable>
</template>

Some example data in list prop

[
    {
        "id": 16,
        "name": "Settings",
        "parent_id": null,
        "order": 16,
        'childrens': [
            {
                "id": 18,
                "name": "Logs",
                "parent_id": 16,
                "order": 18,
                childrens: [],
                "childrens_count": 1
            },
            {
                "id": 17,
                "name": "Backups",
                "parent_id": 16,
                "order": 17,
                childrens: [],
                "childrens_count": 0
            }
        ],
        "childrens_count": 2
    },
    {
        "id": 12,
        "name": "Analytics",
        "parent_id": null,
        "order": 12,
        childrens: [],
        "childrens_count": 0
    },
]

So for test i drag "Backups" (child of "Settings") from beign children into main list

Then if i stick with "list.value = newList"

Other wise if i try to do "list.value = Object.assign(newList, list.value);" (commented out)

After successful move ill need also to send axios request with parent id so list value have to be updated


Solution

  • I'm not sure what i've done, I think I just simplified your code. Basically, I think the root cause of the bug is that your code somehow doesn't update the v-model correctly (something like you used watchers to update data while the data might've been updated by v-model, while also, the v-model only updates the ref inside the component, but not update the data inside the parent of the component).

    I think the most important fix is with this code:

      <Draggable
        :model-value="list"
        tag="ul"
        :item-key="(item) => item.id"
        :group="{ name: 'edit_list_draggable_nested' }"
        @update:model-value="(newValue) => emitUpdate(newValue)"
      >
    

    Previously, it uses v-model, but now I used the v-model expand (:model-value, and @update:model-value="(newValue) => emitUpdate(newValue)"). And then, the emitUpdate will map the data to a new data.

    function emitUpdate(newList) {
      const updatedList = [...newList].map((nl) => ({
        ...nl,
        parent_id: props.parentId,
      }));
    
      list.value = updatedList;
      emits("update:modelValue", updatedList);
    }
    

    With this way, we can do something to the new data before we propagate the data to the parent component (we can assign the new parentId).

    Then finally, when every data is updated, we can watch the data in the root parent with this:

    watch(
      () => list,
      (newList) => {
        console.log("list changed", newList.value);
      },
      {
        deep: true,
        flush: "post",
      }
    );
    

    Whenever a drag event is happening, the above watcher will be triggered, and we can safely assume that the list ref is its most updated version.

    This is the forked sandbox:

    Edit bold-ellis-m3jn54

    EDIT

    OP pointed out in the comment that it still has a bug where the rendered items mismatched with the source data. I'm not sure what I did, but mainly, I just changed the element into using list[index], like so:

    <template #item="{ index }">
    ...
      <DraggableListNesting
        v-model="list[index].childrens"
        :parentId="list[index].id"
        :childrensUpdated="childrensUpdated"
        @getChildrens="(args) => emits('getChildrens', args)"
        @childrensUpdated="(args) => childrensUpdated(args)"
      ></DraggableListNesting>
    

    Also, for some reason, with this method, fixing the parent_id duplicates the items, so we can fix the parent_id recursively in the root with this function:

    function fixParentIds(newList, id) {
      newList.forEach((nl) => {
        nl.parent_id = id;
        fixParentIds(nl.childrens, nl.id);
      });
    }
    

    Here is the forked sandbox:

    Edit nostalgic-shamir-4dk8cr