beforeRouteLeave doesn't work imediately when using with modal and emit function - vue.js

I have a Vue application with many child components. In my case, I have some parent-child components like this. The problem is that in some child components, I have a section to edit information. In case the user has entered some information and router to another page but has not saved it, a modal will be displayed to warn the user. I followed the instructions on beforeRouteLeave and it work well but I got a problem. When I click the Yes button from the modal, I'll emit a function #yes='confirm' to the parent component. In the confirm function, I'll set this.isConfirm = true. Then I check this variable inside beforeRouteLeave to confirm navigate. But in fact, when I press the Yes button in modal, the screen doesn't redirect immediately. I have to click one more time to redirect. Help me with this case

You can create a base component like the following one - and then inherit (extend) from it all your page/route-level components where you want to implement the functionality (warning about unsaved data):
<template>
<div />
</template>
<script>
import events, { PAGE_LEAVE } from '#/events';
export default
{
name: 'BasePageLeave',
beforeRouteLeave(to, from, next)
{
events.$emit(PAGE_LEAVE, to, from, next);
}
};
</script>
events.js is simply a global event bus.
Then, in your page-level component you will do something like this:
<template>
<div>
.... your template ....
<!-- Unsaved changes -->
<ConfirmPageLeave :value="modified" />
</div>
</template>
<script>
import BasePage from 'src/components/BasePageLeave';
import ConfirmPageLeave from 'src/components/dialogs/ConfirmPageLeave';
export default
{
name: 'MyRouteName',
components:
{
ConfirmPageLeave,
},
extends: BasePage,
data()
{
return {
modified: false,
myData:
{
... the data that you want to track and show a warning
}
};
}.
watch:
{
myData:
{
deep: true,
handler()
{
this.modified = true;
}
}
},
The ConfirmPageLeave component is the modal dialog which will be shown when the data is modified AND the user tries to navigate away:
<template>
<v-dialog v-model="showUnsavedWarning" persistent>
<v-card flat>
<v-card-title class="pa-2">
<v-spacer />
<v-btn icon #click="showUnsavedWarning = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pt-2 pb-3 text-h6">
<div class="text-h4 pb-4">{{ $t('confirm_page_leave') }}</div>
<div>{{ $t('unsaved_changes') }}</div>
</v-card-text>
<v-card-actions class="justify-center px-3 pb-3">
<v-btn class="mr-4 px-4" outlined large tile #click="showUnsavedWarning = false">{{ $t('go_back') }}</v-btn>
<v-btn class="ml-4 px-4" large tile depressed color="error" #click="ignoreUnsaved">{{ $t('ignore_changes') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import events, { PAGE_LEAVE } from '#/events';
export default
{
name: 'ConfirmPageLeave',
props:
{
value:
{
// whether the monitored data has been modified
type: Boolean,
default: false
}
},
data()
{
return {
showUnsavedWarning: false,
nextRoute: null,
};
},
watch:
{
showUnsavedWarning(newVal)
{
if (!newVal)
{
this.nextRoute = null;
}
},
},
created()
{
events.$on(PAGE_LEAVE, this.discard);
window.addEventListener('beforeunload', this.pageLeave);
},
beforeDestroy()
{
events.$off(PAGE_LEAVE, this.discard);
window.removeEventListener('beforeunload', this.pageLeave);
},
methods:
{
discard(to, from, next)
{
if (this.value)
{
this.nextRoute = next;
this.showUnsavedWarning = true;
}
else next();
},
pageLeave(e)
{
if (this.value)
{
const confirmationMsg = this.$t('leave_page');
(e || window.event).returnValue = confirmationMsg;
return confirmationMsg;
}
},
ignoreUnsaved()
{
this.showUnsavedWarning = false;
if (this.nextRoute) this.nextRoute();
},
}
};
</script>
<i18n>
{
"en": {
"confirm_page_leave": "Unsaved changes",
"unsaved_changes": "If you leave this page, any unsaved changes will be lost.",
"ignore_changes": "Leave page",
"go_back": "Cancel",
"leave_page": "You're leaving the page but there are unsaved changes.\nPress OK to ignore changes and leave the page or CANCEL to stay on the page."
}
}
</i18n>

Related

Vue3 objects from Array only rendering after making a small change in component

ers,
Experiencing a strange rendering issue. I am grabbing user data from localForage located in my Vuex store in a promise in the following component:
<template>
<div>
<h1>Users available for test {{ $route.params.id }}</h1>
<v-form>
<div v-if="this.import_complete">
<UserList
:users="users"
/>
</div>
</v-form>
</div>
</template>
<script>
import UserList from './UserList.vue';
export default {
name: 'UserManagement',
components: {
UserList,
},
data: () => ({
users: [],
import_complete: false,
}),
mounted() {
Promise.resolve(this.$store.getters.getUsersByTestId(
this.$route.params.testId,
)).then((value) => {
this.users = value;
this.import_complete = true;
});
},
};
</script>
Since it's a promise, I am setting a boolean import_complete to true, and a div in the template is only passing through the data as a prop when this boolean is true
Next, I am consuming the data in another template, in a for loop.
<template>
<div>
<v-container>
<v-banner v-for="user in this.users" :key="user.index">
{{ user.index }} {{ user.name }} {{ user.profile }}
<template v-slot:actions>
<router-link
:to="`/usering/${user.test}/user/${user.index}`">
<v-btn text color="primary">Open usering analysis</v-btn>
</router-link>
<v-btn text color="warning" #click="deleteUser(user.index)">Delete</v-btn>
</template>
</v-banner>
</v-container>
</div>
</template>
<script>
export default {
name: 'UserList',
props: {
users: Object,
},
methods: {
deleteUser(index) {
this.$store.dispatch('delete_user', index);
},
},
mounted() {
console.log('mounted user list, here come the users');
console.log(this.users);
},
};
</script>
The thing is, the first time it doesn't show anything. Only when I make a tiny change in the last component (can be an Enter followed by a save command) and suddenly the users are displayed on the page.
Interestingly, in the first scenario, the user's array is already filled, I see it in the console (created in the mount method) as well in the Chrome developer Vue tab.
It's probably some kind of Vue thing I am missing? Does someone have a clue?
[edit]
I've changed the code to this, so directly invoking the localForage. It seems to work, but I would still like to understand why the other code won't work.
this.test = this.$store.getters.getTestByTestId(this.$route.params.testId);
this.test.store.iterate((value, key) => {
if (key === (`user${this.$route.params.userId}`)) {
this.user = value;
}
}).then(() => {
this.dataReady = true;
}).catch((err) => {
// This code runs if there were any errors
console.log(err);
});

Vue: How to change a value of state and use it in other page and change page structure at starting by it?

In this project, we can login from login.vue by clicking login button and if it is success then we can see Lnb.vue in dashboard.vue
I thought if i code like this then pageSso will be 1 when I check the checkbox in login.vue in Lnb.vue then it will not show only "Account" menu.
When I used console.log(pageSso) at mounted cycle it showed pageSso was 0. What would be the problem?
store/store.js
export const state = () => ({
pageSso: 0,
})
export const getters = {
pageSso: (state) => state.pageSso,
}
export const mutations = {
setPageSso(state, data) {
console.log('mutations setPageSso data', data)
state.pageSso = data
}
}
export const actions = {
setPageSso({
commit
}, data) {
console.log('actions setPageSso data', data)
commit('setPageSso', data)
},
}
pages/login.vue
<template>
<input
class="checkbox_sso"
type="checkbox"
v-model="sso"
true-value="1"
false-value="0" >SSO checkbox
<button class="point" #click="submit">login</button>
</template>
<script>
export default {
data() {
return {
sso: '',
}
},
computed: {},
methods: {
submit() {
this.$store.dispatch('store/setPageSso', this.sso)
//this.$store.dispatch('store/login', data)
},
</script>
pages/dashboard.vue
<template>
<div class="base flex">
<Lnb />
<div class="main">
<Gnb />
<nuxt-child />
</div>
</div>
</template>
<script>
import Lnb from '#/components/Lnb'
import Gnb from '#/components/Gnb'
export default {
components: {
Lnb,
Gnb
},
mounted() {},
}
</script>
components/Lnb.vue
<template>
<ul>
<li :class="{ active: navbarState == 7 ? true : false }">
<a href="/dashboard/settings">
<img src="../assets/images/ico_settings.svg" alt="icon" /> Settings
</a>
</li>
<li v-show="pageSso != 1" :class="{ active: navbarState == 8 ? true : false }">
<a href="/dashboard/user">
<img src="../assets/images/ico_user.svg" alt="icon" />
Account
</a>
</li>
</ul>
</template>
<script>
import {
mapState
} from 'vuex'
export default {
data() {
return {}
},
computed: {
...mapState('store', {
// navbarState: (state) => state.navbarState,
pageSso: (state) => state.pageSso,
}),
},
mounted() {
console.log('pageSso ->', this.pageSso);
},
methods: {
},
}
</script>
Your console.log(pageSso) logs 0 because the mounted hook of Lnb.vue happens once, and it happens when the component is inserted into the DOM.
You insert Lnb into the DOM unconditionally in this line of dashboard.vue:
<Lnb />
and this is roughly when it's mounted hook is triggered.
Your pageSso seems to be changed only after you triggered the submit() method, which — I guess — happens way later, when you submit the login form.
Your Lnb.vue currently is always mounted. If you don't want to show it until pageSso is equal to 1, add a v-if on it in dashboard.vue like this:
<Lnb v-if="pageSso === 1" />
You currently don't have pageSso variable in dashboard.vue, you must take it from the store.
N.B.: Mind the difference between v-show and v-if directives: v-show only hides the component with display: none; while v-if actually removes or inserts the component from/to the DOM. With v-show, the component gets mounted even if you don't see it. With v-if, the component's mounted hook will fire each time the condition evaluates to true.

Copy text upon clicking on icon in v-text-field

I'm trying to figure out how to allow users to copy their login details when they click the copy icon. How to get the value of the relevant v-text-field?
I thought I should use #click:append and link it to a method. However, I struggle how to get a value.
<template>
<v-card class="col-12 col-md-8 col-lg-6 p-6 px-16" elevation="4">
<div class="title h2 mb-10 text-uppercase text-center">
Success
<v-icon color="green" x-large>
mdi-check-circle
</v-icon>
</div>
<v-text-field
:value="newAccount.login"
label="Login"
outlined
readonly
append-icon="mdi-content-copy"
#click:append="copy('login')"
></v-text-field>
<v-text-field
:value="newAccount.password"
label="Password"
outlined
readonly
append-icon="mdi-content-copy"
></v-text-field>
</v-card>
</template>
<script>
export default {
props: ["newAccount"],
data() {
return {
copied: false,
};
},
methods: {
copy(target) {
if (target === "login") {
console.log("login is clicked");
}
},
},
computed: {},
};
</script>
The value of the v-text-field is available from its value property. Apply a template ref on the v-text-field to get a reference to the component programmatically from vm.$refs, then use .value off of that:
<template>
<v-text-field
ref="login"
#click:append="copy('login')"
></v-text-field>
</template>
<script>
export default {
methods: {
copy(field) {
console.log('value', this.$refs[field].value)
}
}
}
</script>
Alternatively, you could access the nested template ref of v-text-field's <input>, which has a ref named "input", so copy() would access it from this.$refs[field].$refs.input. Then, you could select() the text value, and execute a copy command:
export default {
methods: {
copy(field) {
const input = this.$refs[field].$refs.input
input.select()
document.execCommand('copy')
input.setSelectionRange(0,0) // unselect
}
}
}
demo

VueJS - How to pass function to global component

I have a confirm dialog, which should be shown when users perform delete action. I need to make it works globally (Many pages can use this component by passing confirm message and delete function to it). However, I haven't found a way to pass a function to this component.
Thanks in advance!
ConfirmDialog component:
<template>
<v-dialog
v-model="show"
persistent
max-width="350"
>
<v-card>
<v-card-text class="text-xs-center headline lighten-2" primary-title>
{{ message }}
</v-card-text>
<v-card-actions class="justify-center">
<v-btn color="back" dark #click="close">キャンセル</v-btn>
<v-btn color="primary" dark>削除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
data () {
return {
show: false,
message: ''
}
},
created: function () {
this.$store.watch(state => state.confirmDialog.show, () => {
const msg = this.$store.state.confirmDialog.message
if (msg !== '') {
this.show = true
this.message = this.$store.state.confirmDialog.message
} else {
this.show = false
this.message = ''
}
})
},
methods: {
close () {
this.$store.commit('closeDialog')
}
}
}
</script>
ConfirmDialog store:
export default {
state: {
show: false,
message: '',
submitFunction: {}
},
getters: {
},
mutations: {
showDialog (state, { message, submitFunction }) {
state.show = true
state.message = message
state.submitFunction = submitFunction
},
closeDialog (state) {
state.show = false
state.message = ''
}
}
}
you can get and set states easily.
try getting the value of show with ...mapState
ConfirmDialog.vue :
<template>
<v-dialog
v-if="show"
persistent
max-width="350"
>
<v-card>
<v-card-text class="text-xs-center headline lighten-2" primary-title>
{{ message }}
</v-card-text>
<v-card-actions class="justify-center">
<v-btn color="back" dark #click="close">キャンセル</v-btn>
<v-btn color="primary" dark>削除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { mapState } from 'vuex';
export default {
data () {
return {
show: false,
message: ''
}
},
methods: {
close () {
this.$store.commit('closeDialog')
}
},
computed: {
...mapState({
show: 'show'
})
}
}
</script>
The store is, as the name says, a store. You have a centralized tree where you save data, not functionalities. Another reason is that functions are not serializable.
You could create this component in a global way by injecting the function as prop or by using emit and handling the functionality in the parent.

Vue emmitting component property appears to skip parent

I've been stuck on this for a long time now and I can't find anything remotely similar to this problem online.
Context: Simple restaurant review website.
My application needs to be able to take webcam photos using WebRTC or upload photos from storage. I built a component to do this called AddImageDialog that lets the user select either webcam or upload a stored image by storing the image URL in prop on this component and then emitting it. The parent then handles where to place the image.
One part of my program that requires this is the NewReview component, that allows 5 photos to be uploaded, all using the same AddImageComponent.
There is a page for users to upload a new restaurant They may also leave a review on this page, so the Review component with the AddImageDialog is imported onto this page. This page must also allow the user to upload an official photo for the venue, so I have reused my AddImagedDialog on this page.
The problem... When uploading review photos (on the new restaurant page) whenever uploading via webcam, everything works fine but when uploading a stored image, the image seems to be emmitted but skips the NewReview parent component and jumps right back to the grandparent - NewRestaurant.
AddImageDialog.vue
<template>
<div>
<v-layout row justify-center>
<v-dialog v-model="thisShowDialog" max-width="550px">
<v-card height="350px">
<v-card-title>
Take a new photo or choose an existing one...
</v-card-title>
<v-card-text>
<div>
<!-- Add a new photo option -->
<div class="clickBox left" #click="showCamera = true">
<v-container fill-height>
<v-layout align-center>
<v-flex>
<v-icon size="40pt" class="enlarge">add_a_photo</v-icon>
</v-flex>
</v-layout>
</v-container>
</div>
<!-- Add photo from library -->
<div class="clickBox right" #click="uploadImage">
<v-container fill-height>
<v-flex>
<input type="file" class="form-control" id="file-input" v-on:change="onFileChange">
<v-icon size="40pt" class="enlarge">photo_library</v-icon>
</v-flex>
</v-container>
</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
</v-layout>
<Camera
:showCamera.sync="showCamera"
:photoData.sync="thisPhotoData">
</Camera>
</div>
</template>
<script>
import Camera from '#/components/Camera'
export default {
props: ['showDialog', 'photoData'],
data () {
return {
thisShowDialog: false,
showCamera: false,
thisPhotoData: null
}
},
methods: {
uploadImage () {
var fileUpload = document.getElementById('file-input') // createElement('input', {type: 'file'})
fileUpload.click()
},
onFileChange (event) {
let files = event.target.files || event.dataTransfer.files
if (!files.length) {
return
}
this.createImage(files[0])
this.thisShowDialog = false
this.thisShowDialog = false
},
createImage (file) {
let reader = new FileReader()
reader.onload = e => {
alert('reader loaded')
this.thisPhotoData = e.target.result
}
reader.readAsDataURL(file)
}
},
watch: {
showDialog () {
this.thisShowDialog = this.showDialog
},
thisShowDialog () {
this.$emit('update:showDialog', this.thisShowDialog)
},
showCamera () {
// when hiding the camera, leave this component too.
if (!this.showCamera) {
this.thisShowDialog = false
}
},
thisPhotoData () {
alert('emmiting from add image comp')
this.$emit('update:photoData', this.thisPhotoData)
}
},
components: {
Camera
}
}
</script>
Sorry to post so much code, but I literally have no idea what's causing this problem.
You will notice I have tried to get both webcam and image uploads to work in the same way by assigning to a `photoData' variable and emitting it whenever it's been changed. I'm confused as to why they behave differently.
NewReview.vue
(this is the component being skipped by stored imaged uploads)
<template>
<!-- LEAVE A REVIEW -->
<v-expansion-panel class="my-4" popout>
<v-expansion-panel-content expand-icon="mode_edit">
<div slot="header">Been here before? Leave a review...</div>
<v-card class="px-3">
<v-alert :value="isLoggedIn" type="warning">
You are not logged in. Log in.
</v-alert>
<v-form v-model="validReview">
<!-- Title -->
<v-text-field
name="title"
label="Review Title"
v-model="thisReview.title"
:rules="reviewTitleRules">
</v-text-field>
<!-- Body -->
<v-text-field
name="body"
label="Review Body"
multi-line
v-model="thisReview.reviewBody"
:rules="reviewBodyRules">
</v-text-field>
<v-card-actions>
<!-- Submit -->
<v-btn v-if="canSubmit" :disabled="!validReview" class="primary" #click="submitReview">Submit</v-btn>
<!-- Select Rating -->
<star-rating-input
class="ratingSelectStyle"
:star-size="25"
active-color="#E53935"
v-model="thisReview.reviewRating"
:show-rating="false">
</star-rating-input>
<v-spacer />
<!-- Add and View 5 review images -->
<div v-for="i in 5" :key="i" #click="addImage(i-1)">
<review-image
:photoData="reviewPhotos[i-1]"
:id="i"
></review-image>
</div>
<!-- Add Images Component -->
<AddImageDialog
:showDialog.sync="showCamera"
:photoData.sync="photoData"
></AddImageDialog>
</v-card-actions>
</v-form>
</v-card>
</v-expansion-panel-content>
</v-expansion-panel>
</template>
<script>
import RestaurantService from '../services/RestaurantService'
import ReviewImage from '#/components/ReviewImage'
import StarRatingInput from 'vue-star-rating'
import {mapGetters} from 'vuex'
import AddImageDialog from '#/components/AddImageDialog'
export default {
props: ['review', 'restaurantID'],
data () {
return {
showCamera: false,
photoData: '',
reviewPhotos: ['', '', '', '', ''],
currentReviewPhoto: 0,
thisReview: this.review || {
title: '',
reviewBody: '',
reviewRating: 5,
user: 'Anon'
},
validReview: true,
reviewTitleRules: [
s => !!s || 'Title is required',
s => s.length >= 5 || 'Title must be at least 5 characters'
],
reviewBodyRules: [
s => !!s || 'Body is required',
s => s.length >= 10 || 'Body must be at least 10 characters'
]
}
},
methods: {
addImage (i) {
this.currentReviewPhoto = i
this.showCamera = true
// the rest of this is handled by watching for photodata to be changed by the camera component.
},
setName () {
this.thisReview.user = this.$store.state.isLoggedIn ? this.$store.state.user.name : 'Anon'
},
submitReview: async function () {
try {
await RestaurantService.submitReview(this.restaurantID, this.thisReview).then(res => {
if (res.status === 200) {
this.$store.dispatch('setNoticeText', 'Review Submitted')
}
})
} catch (err) {
if (err.response.status === 404) {
// console.log('404 - Failed to submit')
this.$store.dispatch('setNoticeText', '404 - Failed to submit review')
} else {
this.$store.dispatch('setNoticeText', 'An unexpected problem has occured. [' + err.response.status + ']')
}
}
}
},
computed: {
...mapGetters({isLoaded: 'isLoaded'}),
canSubmit () {
if (this.restaurantID) {
return true
} else {
return false
}
},
isLoggedIn () {
if (this.isLoaded && !this.$store.state.isLoggedIn) {
return true
} else {
return false
}
}
},
watch: {
thisReview: {
handler () {
this.$emit('update:review', this.thisReview)
},
deep: true
},
isLoaded () {
if (this.isLoaded) {
this.setName()
}
},
photoData () {
if (this.photoData !== '') {
this.reviewPhotos[this.currentReviewPhoto] = this.photoData
this.photoData = ''
}
}
},
components: {
StarRatingInput,
ReviewImage,
AddImageDialog
},
mounted () {
this.setName()
}
}
</script>
This works by defining 5 images for a review and an array of 5 strings, then depending on what image was selected, assigning the emmitted image from AddImageDialog to the appropriate array by watching for photo data. As mention, this watcher function will work with the webcam, but is skipped when uploading images.
I'm not even sure if whatever is causing this problem is in this code, but any suggestions would be greatly appreciated :)