I wrote a “tree” component in vue3, with some slots to data-display customization.
It works well, but some days ago I found a problem. The case is:
I need an input
into a slot of a a tree-node, and the v-model
of the input is the field displayed into the same node.
I was expecting the value in the node to be automatically updated with the value of the input (because of reactivity), but this is not happening and I cannot understand what I am doing wrong.
The value in my model is correctly updated, because if i force-update the tree (with a :key
), the valued shown in the tree-node updates as expected.
Here is some code.
tree.vue
(my main component)
// tree.vue
<template>
<div :class="wrapperClasses">
<tree-node v-for="(item, index) in items" :key="index"
:item="item"
:level="0"
:first-child="index == 0"
:last-child="index == items.length - 1"
:key-accessor="keyAccessor"
:text-accessor="textAccessor"
:children-accessor="childrenAccessor"
:is-leaf-fn="isLeafFn"
:node-classes-fn="nodeClassesFn"
@icon-click="(item, data, event) => $emit('icon-click', item, data, event)"
@item-collapsed="(item, data) => $emit('item-collapsed', item, data)"
@item-expanded="(item, data) => $emit('item-expanded', item, data)"
@node-mounted="(item, data) => $emit('node-mounted', item, data)"
>
<template v-for="(slotName, idx) in Object.keys($slots)"
:key="`${index}-${idx}`"
v-slot:[slotName]="slotData"
>
<slot :name="slotName" v-bind="slotData"></slot>
</template>
</tree-node>
</div>
</template>
<script>
import TreeNode from './tree-node.vue';
export default {
name: 'pn-tree',
props: {
items: {
type: Array,
default: [],
},
keyAccessor: {
type: [String, Function],
default: 'key',
},
textAccessor: {
type: [String, Function],
default: 'value',
},
childrenAccessor: {
type: [String, Function],
default: 'children',
},
isLeafFn: {
type: Function,
default: (item, vm) => !vm.childrenList.length,
},
nodeClassesFn: {
type: Function,
default: (item, vm) => '',
},
},
computed: {
wrapperClasses(){
return {
'pn-tree': true,
'root': true,
};
},
},
components: {
TreeNode,
}
};
</script>
<style lang="scss">
// not relevant :: omitted
</style>
tree-node.vue
(recursive component for data display)
<template>
<div :class="wrapperClasses" :key="key">
<div class="icon"
@click="onIconClick"
>
<slot name="icon" v-bind="slotData">
<pn-icon
:lr-icon="isLeaf ? 'simple-circle' : (collapsed ? 'angle-right' : 'angle-down')"
></pn-icon>
</slot>
</div>
<div class="text">
<slot name="text" v-bind="slotData">
{{ text }}
</slot>
</div>
<div class="right-slot">
<slot name="right-slot" v-bind="slotData"></slot>
</div>
<div class="children">
<tree-node v-for="(child, index) in childrenList" :key="index"
:item="child"
:level="level + 1"
:last-child="index == childrenList.length - 1"
:key-accessor="keyAccessor"
:text-accessor="textAccessor"
:children-accessor="childrenAccessor"
:is-leaf-fn="isLeafFn"
:node-classes-fn="nodeClassesFn"
@icon-click="(item, data, event) => $emit('icon-click', item, data, event)"
@item-collapsed="(item, data) => $emit('item-collapsed', item, data)"
@item-expanded="(item, data) => $emit('item-expanded', item, data)"
@node-mounted="(item, data) => $emit('node-mounted', item, data)"
>
<template v-for="(slotName, index) in Object.keys($slots)"
:key="index"
v-slot:[slotName]="slotData"
>
<slot :name="slotName" v-bind="slotData"></slot>
</template>
</tree-node>
</div>
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue';
import { PnIcon } from 'xxx';
let TreeNode = {
name: 'pn-tree-node',
props: {
item: {
type: Object,
required: true,
},
keyAccessor: {
type: [String, Function],
default: 'key',
},
textAccessor: {
type: [String, Function],
default: 'value',
},
childrenAccessor: {
type: [String, Function],
default: 'children',
},
level : {
type: Number,
default: 0,
},
firstChild: {
type: Boolean,
default: false,
},
lastChild: {
type: Boolean,
default: false,
},
isLeafFn: {
type: Function,
default: (item, vm) => !vm.childrenList.length,
},
nodeClassesFn: {
type: Function,
default: (item, vm) => '',
},
},
data(){
return {
collapsed: false,
loading: false,
};
},
computed: {
wrapperClasses(){
return {
'node': true,
[`level-${this.level}`]: true,
'loading': this.loading,
'collapsed': this.collapsed,
'expanded': !this.collapsed,
'single-root-node': this.singleRootNode,
'first-child': this.firstChild,
'last-child': this.lastChild,
'leaf': this.isLeaf,
...this.nodeClassesObj,
};
},
singleRootNode(){
return this.level == 0 && this.firstChild && this.lastChild;
},
key(){
return (typeof this.keyAccessor == 'function' ? this.keyAccessor(this.item) : this.item[this.keyAccessor]) || '';
},
text(){
return (typeof this.textAccessor == 'function' ? this.textAccessor(this.item) : this.item[this.textAccessor]) || '';
},
childrenList(){
return (typeof this.childrenAccessor == 'function' ? this.childrenAccessor(this.item) : this.item[this.childrenAccessor]) || [];
},
isLeaf(){
return this.isLeafFn(this.item, this);
},
nodeClassesObj(){
let nodeClasses = this.nodeClassesFn(this.item, this);
if (!nodeClasses) {
return {};
}
if (typeof nodeClasses == 'string') {
return {
[nodeClasses]: true,
};
}
if (Array.isArray(nodeClasses)) {
return nodeClasses
.reduce((a, x) => {
a[x] = true;
return a;
}, {})
;
}
return nodeClasses;
},
slotData(){
return {
item: this.item,
component: this,
};
},
},
methods: {
onIconClick(event){
this.$emit('icon-click', this.item, this.slotData, event);
if (!this.isLeaf) {
this.setCollapsed(!this.collapsed);
}
},
setLoading(loading) {
this.loading = !!loading;
},
setCollapsed(collapsed) {
this.collapsed = !!collapsed;
this.$emit(this.collapsed ? 'item-collapsed' : 'item-expanded', this.item, this.slotData);
},
},
mounted(){
this.$emit('node-mounted', this.item, this.slotData);
},
};
TreeNode.components = {
PnIcon,
// TreeNode: () => import('./tree-node.vue'),
TreeNode: defineAsyncComponent(() => Promise.resolve(TreeNode)),
}
export default TreeNode;
</script>
<style lang="scss">
// not relevant :: omitted
</style>
tree-tester.vue
(just a test for my tree.vue component…just add @input="refresher++"
into the input
to force refresh)
<template>
<div>
<pn-tree
:key="refresher"
:items="rootItems"
key-accessor="code"
text-accessor="description"
:children-accessor="getChildrenList"
@icon-click="onIconClick"
@item-collapsed="onItemCollapsed"
@item-expanded="onItemExpanded"
@node-mounted="onNodeMounted"
>
<template #right-slot="{ item }">
<input
v-model="item.description"
></input>
</template>
</pn-tree>
</div>
</template>
<script>
import { PnTree } from './tree.vue';
export default {
data(){
return {
refresher: 0,
};
},
computed: {
geographicDataList(){
return [{
code: 'CND',
description: 'Canada',
},{
code: 'USA',
description: 'USA',
},{
code: 'AL',
description: 'Alabama',
parentCode: 'USA',
},{
code: 'AK',
description: 'Alaska',
parentCode: 'USA',
}]
},
rootItems(){
return (this.geographicDataList || [])
.filter(x => !x.parentCode)
;
},
},
methods: {
getChildrenList(item){
return (this.geographicDataList || [])
.filter(x => x.parentCode == item.code)
;
},
onIconClick(item, data, event) {
console.log('onIconClick', item, data, event);
},
onItemCollapsed(item, data) {
console.log('onItemCollapsed', item, data);
},
onItemExpanded(item, data) {
console.log('onItemExpanded', item, data);
},
onNodeMounted(item, data) {
console.log('onNodeMounted', item, data);
data.component.setCollapsed(item.code != 'USA');
},
},
mounted(){
},
watch: {
geographicDataList: {
deep: true,
handler() {
console.log('watch geographicDataList', this.geographicDataList);
},
},
},
components: {
PnTree,
},
};
</script>
<style scoped>
.pn-tree {
width: 450px;
}
</style>
Any help will be very appreciated.
Thank you very much in advance.