Toggling Vuetify navigation drawer v-modelled to Vuex variable - javascript

(I spied some similar questions, but none seemed to address my problem, however please refer if I missed something.)
I'm using Vue, Vuex, and Vuetify. Working from the "Google Keep" example layout, I factored out the NavDrawer and AppBar components. I'm having some trouble getting the NavDrawer toggle to work, however.
Before implementing Vuex, I used props and events, going through the parent component. With Vuex, my code is as follows:
main.js:
const store = new Vuex.Store({
state: {
drawerState: null
},
mutations: {
toggleDrawerState(state) {
state.drawerState = !state.drawerState
}
}
})
AppBar.vue:
<template>
<v-app-bar app clipped-left class="primary">
<v-app-bar-nav-icon #click="toggleDrawer()"/>
</v-app-bar>
</template>
<script>
export default {
name: 'AppBar',
methods: {
toggleDrawer: function() {
this.$store.commit('toggleDrawerState')
}
}
}
</script>
NavigationDrawer.vue:
<template>
<v-navigation-drawer v-model="drawerState" app clipped color="grey lighten-4">
<!-- content -->
</v-navigation-drawer>
</template>
<script>
export default {
name: 'NavigationDrawer',
computed: {
drawerState: {
get: function() { return this.$store.state.drawerState },
set: () => null
}
}
}
</script>
The set: () => null in the NavDrawer's computed properties is because I at first set it to call the mutation, which resulted in a feedback loop of toggling.
Now, my problem is that, given an initial v-model value of null, Vuetify has the Drawer open on desktop and closed on mobile. And when the drawerState = !drawerState is called, the null is made true, but that just keeps the drawer open on desktop, meaning that the button has to be clicked again to close the drawer. After that initial double-trigger problem, it works fine on desktop. On mobile, however, it always needs two triggers. (I say mobile, but really I just resized my browser window down.) I assume this is because when resizing (or on load), the drawer automatically hides, but has no way to update the Vuex store boolean, meaning that a double trigger is necessary.
My question is thus: what is the standard or best-practice way to implement Vuetify's navdrawer, so as to toggle it from another component? I thought that the state (whether open or closed) might be directly stored, but there are no "opened" or "closed" events to access it by. (E.g. this question has no answers.) It works fine on the example page, but how can that be adapted to work as child components?
Thanks!

It's a good option to use Vuex getters and refer to them in your computed getter, as well as retrieving the new value in the computed setter and using that to commit the mutation in the store. So your store would become:
const store = new Vuex.Store({
state: {
drawerState: false
},
mutations: {
toggleDrawerState (state, data) {
state.drawerState = data
}
},
getters : {
drawerState: (state) => state.drawerState
}
})
And your navigation drawer component would become:
<template>
<v-navigation-drawer v-model="drawerState" app clipped color="grey lighten-4">
<!-- content -->
</v-navigation-drawer>
</template>
<script>
export default {
name: 'NavigationDrawer',
computed: {
drawerState: {
get () { return this.$store.getters.drawerState },
set (v) { return this.$store.commit('toggleDrawerState', v) }
}
}
}
</script>
Here is a codepen to show a full example:
https://codepen.io/JamieCurnow/pen/wvaZeRe

Related

Mixin for destroyed Vue component is still listening for events

I have a parent component that conditionally renders one of two child components:
<template>
<div>
<!-- other code that changes conditional rendering -->
<folders v-if="isSearchingInFolders" :key="1234"></folders>
<snippets v-if="!isSearchingInFolders" :key="5678"></snippets>
</div>
</template>
Each of these components use the same mixin (searchMixin) locally like so:
<template>
<div>
<div>
<snippet
v-for="item in items"
:snippet="item"
:key="item.id">
</snippet>
<img v-if="busy" src="/icons/loader-grey.svg" width="50">
</div>
<button #click="getItems">Get More</button>
</div>
</template>
<script>
import searchMixin from './mixins/searchMixin';
import Snippet from './snippet';
export default {
components: { Snippet },
mixins: [searchMixin],
data() {
return {
resourceName: 'snippets'
}
},
}
</script>
Each of the components is functionally equivalent with some slightly different markup, so for the purposes of this example Folders can be substituted with Snippets and vice versa.
The mixin I am using looks like this (simplified):
import axios from 'axios'
import { EventBus } from '../event-bus';
export default {
data() {
return {
hasMoreItems: true,
busy: false,
items: []
}
},
created() {
EventBus.$on('search', this.getItems)
this.getItems();
},
destroyed() {
this.$store.commit('resetSearchParams')
},
computed: {
endpoint() {
return `/${this.resourceName}/search`
},
busyOrMaximum() {
return this.busy || !this.hasMoreItems;
}
},
methods: {
getItems(reset = false) {
<!-- get the items and add them to this.items -->
}
}
}
In the parent component when I toggle the rendering by changing the isSearchingInFolders variable the expected component is destroyed and removed from the DOM (I have checked this by logging from the destroyed() lifecycle hook. However the searchMixin that was included in that component does not appear to be destroyed and still appears to listen for events. This means that when the EventBus.$on('search', this.getItems) line is triggered after changing which component is actively rendered from the parent, this.getItems() is triggered twice. Once for folders and once for snippets!
I was expecting the mixins for components to be destroyed along with the components themselves. Have I misunderstood how component destruction works?
Yes, when you pass an event handler as you do EventBus keeps the reference to the function you passed into. That prevents the destruction of the component object. So you need clear the reference by calling EventBus.$off so that the component can be destructed. So your destroy event hook should look like this:
destroyed() {
this.$store.commit('resetSearchParams')
EventBus.$off('search', this.getItems)
},

How to change the value of v-overlay if getters are available in component?

In the component of Vue.js application I take information from the Vuex storage. Inside that component, I want to show v-overlay (preloader) until the data from storage will not be available. How correctly to make it?
<template>
<v-navigation-drawer
v-model="open"
absolute
right>
<v-overlay
:absolute="absolute"
:opacity="opacity"
:value="overlay">
<v-progress-circular
indeterminate
size="64">
</v-progress-circular>
</v-overlay>
<v-checkbox
v-if="!overlay"
hide-details
v-model="selectedGenders"
v-for="gender in genders"
:label="gender"
:value="gender"
:key="gender">
</v-checkbox>
<v-checkbox
v-if="!overlay"
hide-details
v-model="selectedIncomeRanges"
v-for="incomeRange in incomeRanges"
:label="incomeRange"
:value="incomeRange"
:key="incomeRange">
</v-checkbox>
</v-navigation-drawer>
</template>
<script>
import {
mapGetters,
mapActions
} from 'vuex'
export default {
name: 'RightNavigationDrawer',
props: {
open: {
type: Boolean,
default: false
}
},
data () {
return {
absolute: true,
opacity: 0.8,
overlay: true,
selectedGenders: [],
selectedIncomeRanges: []
}
},
mounted () {
this.getGenders()
this.getIncomeRanges()
},
computed: mapGetters('customStore', [
'genders',
'incomeRanges'
]),
methods: mapActions('customStore', [
'getGenders',
'getIncomeRanges'
])
}
</script>
Ideally, you would track the loading status in vuex so that it's available in all of your components, like your other vuex state. In your store, create a loading boolean state. Next, create a loading action that will be called in the component like:
loadData({ commit, dispatch }) {
commit('SET_LOADING', true);
const loader1 = dispatch('getGenders')
const loader2 = dispatch('getIncomeRanges')
Promise.all([loader1, loader2]).then(() => {
commit('SET_LOADING', false);
})
}
Promise.all takes an array of promises from your loading actions and will not resolve until all of those promises have resolved. Just make sure that your getGenders and getIncomeRanges actions return promises as well. Now, in your component, map only loading and loadData:
...mapState('customStore', ['loading']),
...mapActions('customStore', ['loadData'])
Change mounted to call this action:
mounted() {
this.loadData()
}
Now you can check loading anywhere instead of overlay in all of your components. This is a superior pattern because now loading is stored only once in vuex with your other state, and is available in all of your components, rather than being managed and passed locally.
Here is a demo where I'm simulating AJAX calls with a timeout. (The example uses a single file to manage vuex + vue so it will look slightly different, but shouldn't be too hard to follow.)
Put a watch on overlay (getter from Vuex storage).Let say the value is this.overlay = true
watch: {
overlay (val) {
val && setTimeout(() => {
this.overlay = false
}, 3000)
},
},
Follow code pen

Wait for VueX value to load, before loading component

When a user tries to directly navigate load a component url, an http call is made in my vuex actions, which will define a value in my state once it resolves.
I don't want to load my component until the http call is resolved, and the state value is defined.
For Example, in my component
export default {
computed: {
...mapState({
// ** this value needs to load before component mounted() runs **
asyncListValues: state => state.asyncListValues
})
},
mounted () {
// ** I need asyncListValues to be defined before this runs **
this.asyncListValues.forEach((val) => {
// do stuff
});
}
}
How can I make my component wait for asyncListValues to load, before loading my component?
One way to do it is to store state values.
For example, if your store relies on single API, you would do something like this. However, for multiple APIs, it's a good idea to store each api load state individually, or using a dedicated object for each API.
There are usualy 4 states that you can have, which I prefer to have in a globally accessible module:
// enums.js
export default {
INIT: 0,
LOADING: 1,
ERROR: 2,
LOADED: 3
};
Then, you can have the variable stored in the vuex state, where the apiState is initialized with INIT. you can also initialize the array with [], but that shouldn't be necessary.
import ENUM from "#/enums";
// store.js
export default new Vuex.Store({
state: {
apiState: ENUM.INIT,
accounts: [],
// ...other state
},
mutations: {
updateAccounts (state, accounts) {
state.accounts = accounts;
state.apiState = ENUM.LOADED;
},
setApiState (state, apiState) {
state.apiState = apiState;
},
},
actions: {
loadAccounts ({commit) {
commit('setApiState', ENUM.LOADING);
someFetchInterface()
.then(data=>commit('updateAccounts', data))
.catch(err=>commit('setApiState', ENUM.ERROR))
}
}
});
Then, by adding some computed variables, you can toggle which component is shown. The benefit of using state is that you can easily identify the Error state, and show a loading animation when state is not ready.
<template>
<ChildComponent v-if="apiStateLoaded"/>
<Loader v-if="apiStateLoading"/>
<Error v-if="apiStateError"/>
</template>
<script>
import ENUM from "#/enums";
export default {
computed: {
...mapState({
apiState: state=> state.apiState
}),
apiStateLoaded() {
return this.apiState === ENUM.LOADED;
},
apiStateLoading() {
return this.apiState === ENUM.LOADING || this.apiState === ENUM.INIT;
},
apiStateError() {
return this.apiState === ENUM.ERROR;
},
})
}
</script>
aside... I use this pattern to manage my applications as a state machine. While this example utilizes vuex, it can be adapted to use in a component, using Vue.observable (vue2.6+) or ref (vue3).
Alternatively, if you just initialize your asyncListValues in the store with an empty array [], you can avoid errors that expect an array.
Since you mentioned vue-router in your question, you can use beforeRouteEnter which is made to defer the rendering of a component.
For example, if you have a route called "photo":
import Photo from "../page/Photo.vue";
new VueRouter({
mode: "history",
routes: [
{ name: "home", path: "/", component: Home },
{ name: "photo", path: "/photo", component: Photo }
]
});
You can use the beforeRouteEnter like this:
<template>
<div>
Photo rendered here
</div>
</template>
<script>
export default {
beforeRouteEnter: async function(to, from, next) {
try {
await this.$store.dispatch("longRuningHttpCall");
next();
} catch(exception) {
next(exception);
}
}
}
</script>
What it does is, waiting for the action to finish, updating your state like you want, and then the call to next() will tell the router to continue the process (rendering the component inside the <router-view></router-view>).
Tell me if you need an ES6-less example (if you do not use this syntax for example).
You can check the official documentation of beforeRouteEnter on this page, you will also discover you can also put it at the route level using beforeEnter.
One approach would be to split your component into two different components. Your new parent component could handle fetching the data and rendering the child component once the data is ready.
ParentComponent.vue
<template>
<child-component v-if="asyncListValues && asyncListValues.length" :asyncListValues="asyncListValues"/>
<div v-else>Placeholder</div>
</template>
<script>
export default {
computed: {
...mapState({
asyncListValues: state => state.asyncListValues
})
}
}
</script>
ChildComponent.vue
export default {
props: ["asyncListValues"],
mounted () {
this.asyncListValues.forEach((val) => {
// do stuff
});
}
}
Simple way for me:
...
watch: {
vuexvalue(newVal) {
if (newVal == 'XXXX')
this.loadData()
}
}
},
computed: {
...mapGetters(['vuexvalue'])
}
Building on some of the other answers, if you're using Router, you can solve the problem by only calling RouterView when the state has been loaded.
Start with #daniel's approach of setting a stateLoaded flag when the state has been loaded. I'll just keep it simple here with a two-state flag, but you can elaborate as you like:
const store = createStore({
state () {
return {
mysettings: {}, // whatever state you need
stateLoaded: false,
}
},
mutations: {
set_state (state, new_settings) {
state.settings = new_settings;
state.stateLoaded = true;
},
}
}
Then, in app.vue you'll have something like this:
<div class="content">
<RouterView/>
</div>
Change this to:
<div class="content">
<RouterView v-if="this.$store.state.stateLoaded"/>
</div>
The v-if won't even attempt to do anything with RouterView until the (reactive) stateLoaded flag goes true. Therefore, anything you're rendering with the Router won't get called, and so there won't be any undefined state variables in it when it does get loaded.
You can of course build on this with a v-else to perhaps show a "Loading..." screen or something, just in case the state loading takes longer than expected. Using #daniel's multi-state flag, you could even report if there was a problem loading the state, and offer a Retry button or something.

Shared vuex state in a web-extension (dead object issues)

I'm trying to use a shared vue.js state in a web extension.
The state is stored in the background script's DOM and rendered in a popup page.
First attempt
My first attempt was to use a simple store without vuex:
background.js
var store = {
count: 0
};
popup.js
browser.runtime.getBackgroundPage().then(bg => {
var store = bg.store;
var vue = new Vue({
el: '#app',
data: {
state: store
},
})
})
popup.html
<div id="app">
<p>{{ state.count }}</p>
<p>
<button #click="state.count++">+</button>
</p>
</div>
<script src="vue.js"></script>
<script src="popup.js"></script>
This works the first time the popup is opened (you can increment the counter and the value is updated) but when the popup is opened a second time, rendering fails with [Vue warn]: Error in render: "TypeError: can't access dead object". This seems to be caused by the fact that vue.js instance from the first popup modified the store by setting its own getter/setters, which have now been deleted since the first popup was closed, making the shared state unusable. This seems to be unavoidable, so I decided I'd give vuex a try.
Second attempt
background.js
var store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment: state => state.count++,
}
})
popup.js
browser.runtime.getBackgroundPage().then(bg => {
var store = bg.store;
var vue = new Vue({
el: '#app',
computed: {
count () {
return store.state.count
}
},
methods: {
increment () {
store.commit('increment')
},
}
});
})
popup.html
<div id="app">
<p>{{ count }}</p>
<p>
<button #click="increment">+</button>
</p>
</div>
<script src="vue.js"></script>
<script src="popup.js"></script>
Unfortunately, this doesn't work either. You can open the popup and see the current counter value, but incrementing it doesn't update the view (you need to reopen the popup to see the new value).
When taking the same code but with the store declared in popup.js, the code works as intended so this should work, but for some reason it doesn't
My questions:
Is vue.js unable to handle this use case at all?
If so, would other frameworks (angular, react, ...) work here?
This does not work because your vue instance is not the same in background and popup. So, when you get the state from background, the watchers on the state makes react the Vue inside the background page, not the view in popup.
You can achieve that by using the same store in background and popup and synchronizing the state between both. To synchronize the state, you can use the excellent plugin vuex-shared-mutations which uses localStorage to propagate mutations through different instances of a store. In your store, add
import createMutationsSharer from 'vuex-shared-mutations'
//...
export default new Vuex.Store({
//...
plugins: [createMutationsSharer({ predicate: ['increment'] })],
});
Now your popup is reacting to the button and your background is incremented. If you reopen the popup, count is 0 because you created a new store. You now need to load the initial state when the popup is initialized :
store.js :
export default new Vuex.Store({
state: {
count: 0
},
mutations: {
increment: state => state.count++,
setCount (state, count) {
state.count = count
},
},
plugins: [createMutationsSharer({ predicate: ['increment'] })],
actions: {
getCount ({ commit }) {
browser.runtime.sendMessage({type: "storeinit", key: "count"}).then(count => {
commit('setCount', count)
})
}
}
});
background.js
import store from './store';
browser.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'storeinit') {
return Promise.resolve(store.state[message.key]);
}
});
popup.js
import store from '../store';
var vue = new Vue({
//...
created () {
this.$store.dispatch('getCount')
}
});
These difficulties are not vue related, react users have use a proxy to propagate state in browser extension : react-chrome-redux
Sorry for the delay, I create a node module for it:
https://github.com/MitsuhaKitsune/vuex-webextensions
The module use the webextensions messaging API for sync all store instances on the webextension.
The install are like another vuex plugins, you can check it on the Readme.
If you have any question or feedback just tell me here or on github issues.

How to design a store in Vuex to handle clicks in nested, custom components?

I'm trying to design a store to manage the events of my Vuex application. This far, I have the following.
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
const state = { dataRows: [], activeDataRow: {} };
const mutations = {
UPDATE_DATA(state, data) { state.dataRows = data; state.activeDataRow = {}; },
};
export default new Vuex.Store({ state, mutations });
I'm going to have a number of list items that are supposed to change the value of the data in the store when clicked. The design of the root component App and the menu bar Navigation is as follows (there will be a bunch of actions in the end so I've collected them in the file actions.js).
<template>
<div id="app">
<navigation></navigation>
</div>
</template>
<script>
import navigation from "./navigation.vue"
export default { components: { navigation } }
</script>
<template>
<div id="nav-bar">
<ul>
<li onclick="console.log('Clickaroo... ');">Plain JS</li>
<li #click="updateData">Action Vuex</li>
</ul>
</div>
</template>
<script>
import { updateData } from "../vuex_app/actions";
export default {
vuex: {
getters: { activeDataRow: state => state.activeDataRow },
actions: { updateData }
}
}
</script>
Clicking on the first list item shows the output in the console. However, when clicking on the second one, there's nothing happening, so I'm pretty sure that the event isn't dispatched at all. I also see following error when the page's being rendered:
Property or method "updateData" is not defined on the instance but referenced during render. Make sure to declare reactive data properties in the data option.
I'm very new to Vuex so I'm only speculating. Do I need to put in reference to the updateData action in the store, alongside with state and mutations? How do I do that? What/where's the "data option" that the error message talks about? Isn't it my components state and it's properties?
Why the error
You are getting the error, because when you have <li #click="updateData"> in the template, it looks for a method updateData in the vue component which it does not find, so it throws the error. To resolve this, you need to add corresponding methods in the vue component like following:
<script>
import { updateData } from "../vuex_app/actions";
export default {
vuex: {
getters: { activeDataRow: state => state.activeDataRow },
actions: { updateData }
},
methods:{
updateData: () => this.$store.dispatch("updateData")
}
}
</script>
What this.$store.dispatch("updateData") is doing is calling your vuex actions as documented here.
What/where's the "data option"
You don't have any data properties defined, data properties for a vue component can be used, if you want to use that only in that component. If you have data which needs to be accessed across multiple components, you can use vuex state as I believe you are doing.
Following is the way to have data properties for a vue component:
<script>
import { updateData } from "../vuex_app/actions";
export default {
date: {
return {
data1 : 'data 1',
data2 : {
nesteddata: 'data 2'
}
}
}
vuex: {
getters: { activeDataRow: state => state.activeDataRow },
actions: { updateData }
},
methods:{
updateData: () => this.$store.dispatch("updateData")
}
}
</script>
You can use these data properties in the views, have computed properies based on it, or create watchers on it and many more.

Categories

Resources