Vue 3 access child component from slots - vue.js

I am currently working on a custom validation and would like to, if possible, access a child components and call a method in there.
Form wrapper
<template>
<form #submit.prevent="handleSubmit">
<slot></slot>
</form>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
setup(props, { slots }) {
const validate = (): boolean => {
if (slots.default) {
slots.default().forEach((vNode) => {
if (vNode.props && vNode.props.rules) {
if (vNode.component) {
vNode.component.emit('validate');
}
}
});
}
return false;
};
const handleSubmit = (ev: any): void => {
validate();
};
return {
handleSubmit,
};
},
});
</script>
When I call slot.default() I get proper list of child components and can see their props. However, vNode.component is always null
My code is based from this example but it is for vue 2.
If someone can help me that would be great, or is this even possible to do.

I found another solution, inspired by quasar framework.
Form component provide() bind and unbind function.
bind() push validate function to an array and store in Form component.
Input component inject the bind and unbind function from parent Form component.
run bind() with self validate() function and uid
Form listen submit event from submit button.
run through all those validate() array, if no problem then emit('submit')
Form Component
import {
defineComponent,
onBeforeUnmount,
onMounted,
reactive,
toRefs,
provide
} from "vue";
export default defineComponent({
name: "Form",
emits: ["submit"],
setup(props, { emit }) {
const state = reactive({
validateComponents: []
});
provide("form", {
bind,
unbind
});
onMounted(() => {
state.form.addEventListener("submit", onSubmit);
});
onBeforeUnmount(() => {
state.form.removeEventListener("submit", onSubmit);
});
function bind(component) {
state.validateComponents.push(component);
}
function unbind(uid) {
const index = state.validateComponents.findIndex(c => c.uid === uid);
if (index > -1) {
state.validateComponents.splice(index, 1);
}
}
function validate() {
let valid = true;
for (const component of state.validateComponents) {
const result = component.validate();
if (!result) {
valid = false;
}
}
return valid;
}
function onSubmit() {
const valid = validate();
if (valid) {
emit("submit");
}
}
}
});
Input Component
import { defineComponent } from "vue";
export default defineComponent({
name: "Input",
props: {
rules: {
default: () => [],
type: Array
},
modelValue: {
default: null,
type: String
}
}
setup(props) {
const form = inject("form");
const uid = getCurrentInstance().uid;
onMounted(() => {
form.bind({ validate, uid });
});
onBeforeUnmount(() => {
form.unbind(uid);
});
function validate() {
// validate logic here
let result = true;
props.rules.forEach(rule => {
const value = rule(props.modelValue);
if(!value) result = value;
})
return result;
}
}
});
Usage
<template>
<form #submit="onSubmit">
<!-- rules function -->
<input :rules="[(v) => true]">
<button label="submit form" type="submit">
</form>
</template>

In the link you provided, Linus mentions using $on and $off to do this. These have been removed in Vue 3, but you could use the recommended mitt library.
One way would be to dispatch a submit event to the child components and have them emit a validate event when they receive a submit. But maybe you don't have access to add this to the child components?
JSFiddle Example
<div id="app">
<form-component>
<one></one>
<two></two>
<three></three>
</form-component>
</div>
const emitter = mitt();
const ChildComponent = {
setup(props, { emit }) {
emitter.on('submit', () => {
console.log('Child submit event handler!');
if (props && props.rules) {
emit('validate');
}
});
},
};
function makeChild(name) {
return {
...ChildComponent,
template: `<input value="${name}" />`,
};
}
const formComponent = {
template: `
<form #submit.prevent="handleSubmit">
<slot></slot>
<button type="submit">Submit</button>
</form>
`,
setup() {
const handleSubmit = () => emitter.emit('submit');
return { handleSubmit };
},
};
const app = Vue.createApp({
components: {
formComponent,
one: makeChild('one'),
two: makeChild('two'),
three: makeChild('three'),
}
});
app.mount('#app');

Related

Mixing Vue Composables with Class Based Components

I'm attempting to mix composables into a class based component setup, as part of a slow migration from Vue 2 to Vue 3. However, I am struggling to referenced return values from the setup function within the class itself.
I have something similar to:
#Component({
setup() {
const fullscreenElement = ref<HTMLElement | undefined>();
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(fullscreenElement);
return {
fullscreenElement,
isFullscreen,
toggleFullscreen,
};
},
})
export default class MyClassComponent extends Vue {
// How to access isFullscreen et al. here ??
}
As in the above example, I can't seem to reference how I would use e.g., isFullscreen etc from within the component itself?
Docs:
ref()
Computed Properties
Composables
const { ref, computed, createApp } = Vue;
const useFullscreen = function() {
const _isFullscreen = ref(false);
const isFullscreenFunc = function() {
return _isFullscreen;
}
const isFullscreenComputed = computed(function() {
return _isFullscreen;
})
const toggleFullscreen = function() {
_isFullscreen.value = !_isFullscreen.value;
}
return {isFullscreenFunc, isFullscreenComputed, toggleFullscreen}
}
const MyComponent = {
setup() {
const { isFullscreenFunc, isFullscreenComputed, toggleFullscreen } = useFullscreen();
return {
toggleFullscreen,
isFullscreenFunc,
isFullscreenComputed
}
},
methods: {
toggle() {
this.toggleFullscreen();
},
show() {
alert(`isFullscreenFunc: ${this.isFullscreenFunc().value}\n isFullscreenComputed: ${this.isFullscreenComputed.value}`);
}
},
template: `
<div>
isFullscreenFunc: {{isFullscreenFunc().value}}<br /><br />
isFullscreenComputed: {{isFullscreenComputed.value}}
<br/><br/><button type="button" #click="toggle()">toggle</button>
<button type="button" #click="show()">show</button>
</div>`
}
const App = {
components: {
MyComponent
}
}
const app = createApp(App)
app.mount('#app')
<div id="app">
<my-component>
</my-component>
</div>
<script src="https://unpkg.com/vue#3/dist/vue.global.prod.js"></script>

How to forceUpdate sibling component in VueJS

I have created a component which have two child components AddContactList and ViewContactLists, Here I need to forceUpdate ViewContactList when new entry inserted from AddContactList component
This is AddContactList components script
<script>
export default {
data() {
return {
fields: {},
errors: {},
success: false,
loaded: true,
}
},
methods: {
submit() {
if (this.loaded) {
this.loaded = false;
this.success = false;
this.errors = {};
console.log('Loading..');
axios.post('/submit', this.fields).then(response => {
this.fields = {}; //Clear input fields.
this.loaded = true;
this.success = true;
console.log('done..');
// --Here I need to update ViewContactList component
}).catch(error => {
this.loaded = true;
if (error.response.status === 422) {
console.log(error.response.data.errors)
this.errors = error.response.data.errors || {};
}
});
}
},
},
}
</script>
This is ViewContactList components script
<script>
import pagination from 'laravel-vue-pagination'
export default {
name:"ContactList",
components:{
pagination
},
data(){
return {
ContactList:{
type:Object,
default:null
}
}
},
mounted(){
this.list()
},
methods:{
async list(page=1){
await axios.get(`/getContactLists?page=${page}`).then(({data})=>{
this.ContactList = data
}).catch(({ response })=>{
console.error(response)
})
}
}
}
</script>
You can simply achieve this by emitting an event on successful save to the parent and then from parent component you can invoke the contact list component method with the help of ref.
Live demo :
Vue.component('childone', {
props: ['childmsg', 'childOneRef'],
template: `<p>{{ childmsg }} <button #click="$emit('savesuccess')">Add Contact</button></p>`
});
Vue.component('childtwo', {
props: ['childmsg', 'childoneref'],
template: '<p>{{ childmsg }}</p>',
methods: {
getupdtedList() {
console.log('Contact List call');
}
}
});
var app = new Vue({
el: '#app',
methods: {
callViewContactListCompMethod() {
this.$refs.contactListRef.getupdtedList();
}
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<childone #savesuccess="callViewContactListCompMethod" childmsg="This is a child 1 message">
</childone>
<childtwo ref="contactListRef" childmsg="This is a child 2 message">
</childtwo>
</div>
If your parent component is ContactComponent
ContactComponent
|--AddContactList
|--ViewContactList
When you insert contact in add contact list emit the #addList event to the parent component.
Then pass the contact list as a props to ViewContactList.
when the props is changed, the ViewContactList component will be re-rendered automatically.

$forceUpdate (or similar) in vue3 - How implement it?

I have a Vue component. This component has a computed prop & a watch:
const resetComponent = computed(()=>{
return store.state.filtros.room_amount
})
watch(resetComponent, () => {
if(resetComponent.value.compare == '>' && resetComponent.value.valor == '' ){
console.log('RESET COMPONENT')
}
})
My console.log('RESET COMPONENT') runs correctly, when it should.
But instead, i want re-render all my component, that is, return to its initial state. There's some way?
This is my full component
<template>
<FiltroCantidad :data="data" />
</template>
<script>
import SelectButton from "primevue/selectbutton";
import FiltroCantidad from "../utils/FiltroCantidad.vue";
import { computed, ref, watch } from "vue";
import { useStore } from 'vuex';
export default {
setup(props, context) {
const store = useStore()
const data = ref({
label: "Ambientes",
value: "room_amount",
action: "roomAmountAction",
});
const resetComponent = computed(()=>{
return store.state.filtros.room_amount
})
watch(resetComponent, () => {
if(resetComponent.value.compare == '>' && resetComponent.value.valor == '' ){
console.log('RESET COMPONENT')
}
})
return { data, resetComponent };
},
components: {
SelectButton,
FiltroCantidad,
},
};
</script>
One way to re-render the component is to apply a key attribute that changes:
<FiltroCantidad :data="data" :key="myKey" />
export default {
setup() {
//...
const myKey = ref(0)
watch(resetComponent, () => {
if(/* need to reset */) {
myKey.value++
}
})
return { myKey }
}
}

Application gvies me a "Cannot read property" error, but only the layout is affected

I am really scratching my head at this.
I am making a CRUD application, and this problem started when I was working on the Edit component.
I am getting the error Cannot read property 'id' of null
BUT! The interesting thing is that the data actually DOES get updated, both in the application and on the server side.
The error however affects the layout. First of all, the delete button appears two places in the template instead of one, and instead of redirecting me to the main page when I update, the main page appears like a new div on the edit page. I have no idea what is going on.
Here are the different components/composables:
The Details component: Here the information about a specific document is stored based on it's ID.
<template>
<div v-if="playlist" class="playlist-details">
<div class="playlist-info">
<div class="cover">
<img :src="playlist.coverUrl">
</div>
<h2> {{ playlist.title }}</h2>
<p> {{ playlist.description }} </p>
</div>
</div>
<button #click="handleDelete">Delete</button>
<EditSong :playlist="playlist" />
</template>
<script>
import EditSong from '../components/EditSong'
import useDocument from '../composables/useDocument'
import getDocument from '../composables/getDocument'
import useStorage from '../composables/useStorage'
import { useRouter } from "vue-router";
export default {
props: ['id'],
components: { EditSong },
setup(props) {
const { document: playlist } = getDocument('playlists', props.id)
const { deleteDoc } = useDocument('playlists', props.id)
const router = useRouter();
const { deleteImage } = useStorage()
const handleDelete = async () => {
await deleteImage(playlist.value.filePath)
await deleteDoc()
confirm('Do you wish to delete this content?')
router.push({ name: "Home" });
}
return {
playlist,
handleDelete
}
}
}
</script>
Here is the Edit component: This is where I edit and update the data inside the Details component. This is where I am getting the TypeError.
It has something to do with the props.playlist.id field
<template>
<div class="edit-song">
<form #submit.prevent="handleSubmit">
<input type="text" required placeholder="title" v-model="title">
<input type="text" required placeholder="description" v-model="description">
<button v-if="!isPending">Update</button>
<button v-else disabled>Updating...</button>
</form>
</div>
</template>
<script>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import useDocument from '../composables/useDocument'
import useCollection from '../composables/useCollection'
export default {
props: ['playlist'],
setup(props) {
const title = ref('')
const description = ref('')
const { updateDoc } = useDocument('playlists', props.playlist.id)
const { error } = useCollection()
const isPending = ref(false)
const router = useRouter();
const handleSubmit = async () => {
await updateDoc({
title: title.value,
description: description.value,
})
isPending.value = false
if(!error.value) {
router.push({ name: "Home" })
}
}
return {
title,
description,
handleSubmit,
isPending,
error
}
}
}
</script>
And last, this is the Update composable: that stores the update function
import { ref } from 'vue'
import { projectFirestore } from '../firebase/config'
const useDocument = (collection, id) => {
const error = ref(null)
const isPending = ref(false)
let docRef = projectFirestore.collection(collection).doc(id)
const updateDoc = async (updates) => {
isPending.value = true
error.value = null
try {
const res = await docRef.update(updates)
isPending.value = false
return res
}catch(err) {
console.log(err.message)
isPending.value = false
error.value = 'Could not update document'
}
}
return {
error,
isPending,
updateDoc
}
}
export default useDocument
The likely scenario is getDocument() returns a ref to null for document, which gets updated asynchronously:
const getDocument = (collection, id) => {
const document = ref(null)
someAsyncFunc(() => {
document.value = {...}
})
return {
document
}
}
Since the document (renamed to playlist) is bound to the EditSong component, it receives both the initial value (null) and then the asynchronously populated value, which leads to the behavior you're seeing.
One solution is to conditionally render EditSong on playlist:
<EditSong v-if="playlist" :playlist="playlist" />
Another is to move the updateDoc initialization into handleSubmit, and add a null-check there:
const handleSubmit = async () => {
if (!props.playlist) return
const { updateDoc } = useDocument('playlists', props.playlist.id)
await updateDoc(...)
}

Migrating "detect click outside" custom directive from Vue 2 to Vue 3

Based on this question Detect click outside element and this answer https://stackoverflow.com/a/42389266, I'm trying to migrate the directive from Vue 2 to Vue 3. It seems that binding.expression and vnode.context not exists more. How can I make it work?
app.directive('click-outside', {
beforeMount (el, binding, vnode) {
el.clickOutsideEvent = function (event) {
if (!(el === event.target || el.contains(event.target))) {
vnode.context[binding.expression](event);
}
};
document.body.addEventListener('click', el.clickOutsideEvent);
},
unmounted (el) {
document.body.removeEventListener('click', el.clickOutsideEvent);
}
});
You can use binding.value instead like this:
const { createApp } = Vue;
const highlightEl = (color ) => (event, el) => {
if (el) {
el.style.background = color;
} else {
event.target.style.background = color;
}
}
const clearHighlightEl = (event, el) => {
if (el) {
el.style.background = '';
} else {
event.target.style.background = '';
}
}
const app = Vue.createApp({
setup() {
return {
highlightEl,
clearHighlightEl
}
}
})
app.directive('click-outside', {
mounted(el, binding, vnode) {
el.clickOutsideEvent = function(event) {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event, el);
}
};
document.body.addEventListener('click', el.clickOutsideEvent);
},
unmounted(el) {
document.body.removeEventListener('click', el.clickOutsideEvent);
}
});
app.mount('#app')
<script src="https://unpkg.com/vue#3.0.0-rc.11/dist/vue.global.prod.js"></script>
<div id="app">
<h1 v-click-outside="highlightEl('yellow')" #click="clearHighlightEl">Element 1</h1>
<p v-click-outside="highlightEl('#FFCC77')" #click="clearHighlightEl">Element 2</p>
</div>
out of the context, there's an easier way in vue3 with composition.
Link to Vueuse ClickOutside (Vue 3)
Link to Vueuse ClickOutside(Vue 2)
<template>
<div ref="target">
Hello world
</div>
<div>
Outside element
</div>
</template>
<script>
import { ref } from 'vue'
import { onClickOutside } from '#vueuse/core'
export default {
setup() {
const target = ref(null)
onClickOutside(target, (event) => console.log(event))
return { target }
}
}
</script>
you can use ref to find out if the element contains the element clicked
<template>
<div ref="myref">
Hello world
</div>
<div>
Outside element
</div>
</template>
<script>
export default {
data() {
return {
show=false
}
},
mounted(){
let self = this;
document.addEventListener('click', (e)=> {
if (self.$refs.myref !==undefined && self.$refs.myref.contains(e.target)===false) {
//click outside!
self.show = false;
}
})
}
}
</script>
vue2 solution:
<script>
export default {
name: 'onClickOutside',
props: ['clickOutside'],
mounted() {
const listener = e => {
if (e.target === this.$el || this.$el.contains(e.target)) {
return
}
this.clickOutside()
}
document.addEventListener('click', listener)
this.$once('hook:beforeDestroy', () => document.removeEventListener('click', listener))
},
render() {
return this.$slots.default[0]
},
}
</script>
vue3:
<script>
import { getCurrentInstance, onMounted, onBeforeUnmount, ref, defineComponent } from 'vue'
export default defineComponent({
name: 'OnClickOutside',
props: ['clickOutside'],
setup(props, { emit, attrs, slots }) {
const vm = getCurrentInstance()
const listener = event => {
const isClickInside = vm.subTree.children.some(element => {
const el = element.el
return event.target === el || el.contains(event.target)
})
if (isClickInside) {
console.log('clickInside')
return
}
props.clickOutside && props.clickOutside()
}
onMounted(() => {
document.addEventListener('click', listener)
})
onBeforeUnmount(() => {
document.removeEventListener('click', listener)
})
return () => slots.default()
},
})
</script>