Vue3 with Draggable - Nesting list - Reactivity

481 Views Asked by At

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"

  • "Backups" are correctly moved from "Settings" into main list, But when watcher run itself list.value have correct already modified list but newList get's old list without "Backups" yet
  • When i move "Analytics" to be child of an other item its all fine

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

  • Unnesting "Backups" from nesting into main list is all fine
  • But when i try nest "Analytics" as child of another item its only visualy added to be an child of "Setting" (for example) but "Settings" object in list prop dont have him as child

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

2

There are 2 best solutions below

5
On BEST ANSWER

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

2
On

When you use Object.assign(newList, list.value), it merges the properties from newList into list.value, but it doesn’t replace list.value with a new object, which might be necessary for Vue to detect it as a change.

Consider creating a deep copy of the newList before assigning it to list.value. This can be done using JSON methods (JSON.parse(JSON.stringify(newList))) or a deep copy utility function. This ensures that you’re working with a fresh object, which can help Vue’s reactivity system detect changes.

Update:

Here’s a modified version of your code implementing some suggestions:

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

// In your update logic
const childrensUpdated = (args) => {
    Vue.set(list.value, args.index, args.newData); // Example of using Vue.set
    // Rest of your logic...
};