mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-16 22:27:26 +01:00
Vue Store (#8071)
* vue: use our own store in place of vuex * vue store: add getters, watcher and use internal vue instance * vue store: better state getter and credits to Vuex * vue store: $watch -> watch * vuex store: pass store to getters and fix typos * add comments to store, start writing tests * fix unit tests and add missing ones * cleanup components, add less folder, fetch tassks * use Vuex helpers * pin vuex version * move semantic-ui theme to assets/less, keep website/build empty but in git * import helpers from vuex
This commit is contained in:
@@ -23,4 +23,6 @@ Gruntfile.js
|
||||
gulpfile.js
|
||||
gulp
|
||||
webpack
|
||||
test/client
|
||||
test/client/e2e
|
||||
test/client/unit/index.js
|
||||
test/client/unit/karma.conf.js
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,7 +14,6 @@ npm-debug.log*
|
||||
lib
|
||||
website/client-old/bower_components
|
||||
website/client-old/new-stuff.html
|
||||
website/build
|
||||
newrelic_agent.log
|
||||
.bower-tmp
|
||||
.bower-registry
|
||||
|
||||
@@ -5,7 +5,7 @@ import fs from 'fs';
|
||||
// Code taken from https://www.artembutusov.com/webpack-semantic-ui/
|
||||
|
||||
// Relative to node_modules/semantic-ui-less
|
||||
const SEMANTIC_THEME_PATH = '../../website/client/assets/semantic-ui/theme.config';
|
||||
const SEMANTIC_THEME_PATH = '../../website/client/assets/less/semantic-ui/theme.config';
|
||||
|
||||
// fix well known bug with default distribution
|
||||
function fixFontPath (filename) {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"babel-core": "^6.0.0",
|
||||
"babel-loader": "^6.0.0",
|
||||
"babel-plugin-transform-async-to-module-method": "^6.8.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.16.0",
|
||||
"babel-polyfill": "^6.6.1",
|
||||
"babel-preset-es2015": "^6.6.0",
|
||||
"babel-register": "^6.6.0",
|
||||
@@ -114,8 +115,6 @@
|
||||
"vue-loader": "^9.4.0",
|
||||
"vue-resource": "^1.0.2",
|
||||
"vue-router": "^2.0.0-rc.5",
|
||||
"vuex": "^2.0.0-rc.5",
|
||||
"vuex-router-sync": "^3.0.0",
|
||||
"webpack": "^1.12.2",
|
||||
"webpack-merge": "^0.8.3",
|
||||
"winston": "^2.1.0",
|
||||
@@ -147,6 +146,7 @@
|
||||
"client:dev": "node webpack/dev-server.js",
|
||||
"client:build": "node webpack/build.js",
|
||||
"client:unit": "karma start test/client/unit/karma.conf.js --single-run",
|
||||
"client:unit:watch": "karma start test/client/unit/karma.conf.js",
|
||||
"client:e2e": "node test/client/e2e/runner.js",
|
||||
"client:test": "npm run client:unit && npm run client:e2e",
|
||||
"start": "gulp run:dev",
|
||||
|
||||
5
test/client/.babelrc
Normal file
5
test/client/.babelrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"presets": ["es2015"],
|
||||
"plugins": ["transform-object-rest-spread"],
|
||||
"comments": false
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import Vue from 'vue';
|
||||
// import Hello from 'src/components/Hello';
|
||||
|
||||
describe('Hello.vue', () => {
|
||||
xit('should render correct contents', () => {
|
||||
const vm = new Vue({
|
||||
el: document.createElement('div'),
|
||||
render: (h) => h(Hello),
|
||||
});
|
||||
expect(vm.$el.querySelector('.hello h1').textContent).to.equal('Hello Vue!');
|
||||
});
|
||||
|
||||
it('should make assertions', () => {
|
||||
expect(true).to.equal(true);
|
||||
});
|
||||
});
|
||||
120
test/client/unit/specs/store.spec.js
Normal file
120
test/client/unit/specs/store.spec.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import Vue from 'vue';
|
||||
import storeInjector from 'inject?-vue!client/store';
|
||||
import { mapState, mapGetters, mapActions } from 'client/store';
|
||||
|
||||
describe('Store', () => {
|
||||
let injectedStore;
|
||||
|
||||
beforeEach(() => {
|
||||
injectedStore = storeInjector({ // eslint-disable-line babel/new-cap
|
||||
'./state': {
|
||||
name: 'test',
|
||||
},
|
||||
'./getters': {
|
||||
computedName ({ state }) {
|
||||
return `${state.name} computed!`;
|
||||
},
|
||||
},
|
||||
'./actions': {
|
||||
getName ({ state }, ...args) {
|
||||
return [state.name, ...args];
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
});
|
||||
|
||||
it('injects itself in all component', (done) => {
|
||||
new Vue({ // eslint-disable-line no-new
|
||||
created () {
|
||||
expect(this.$store).to.equal(injectedStore);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('can watch a function on the state', (done) => {
|
||||
injectedStore.watch(state => state.name, (newName) => {
|
||||
expect(newName).to.equal('test updated');
|
||||
done();
|
||||
});
|
||||
|
||||
injectedStore.state.name = 'test updated';
|
||||
});
|
||||
|
||||
it('supports getters', () => {
|
||||
expect(injectedStore.getters.computedName).to.equal('test computed!');
|
||||
injectedStore.state.name = 'test updated';
|
||||
expect(injectedStore.getters.computedName).to.equal('test updated computed!');
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
it('can be dispatched', () => {
|
||||
expect(injectedStore.dispatch('getName', 1, 2, 3)).to.deep.equal(['test', 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('throws an error is the action doesn\'t exists', () => {
|
||||
expect(() => injectedStore.dispatched('wrong')).to.throw;
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('mapState', (done) => {
|
||||
new Vue({ // eslint-disable-line no-new
|
||||
data: {
|
||||
title: 'internal',
|
||||
},
|
||||
computed: {
|
||||
...mapState(['name']),
|
||||
...mapState({
|
||||
nameComputed (state, getters) {
|
||||
return `${this.title} ${getters.computedName} ${state.name}`;
|
||||
},
|
||||
}),
|
||||
},
|
||||
created () {
|
||||
expect(this.name).to.equal('test');
|
||||
expect(this.nameComputed).to.equal('internal test computed! test');
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('mapGetters', (done) => {
|
||||
new Vue({ // eslint-disable-line no-new
|
||||
data: {
|
||||
title: 'internal',
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['computedName']),
|
||||
...mapGetters({
|
||||
nameComputedTwice: 'computedName',
|
||||
}),
|
||||
},
|
||||
created () {
|
||||
expect(this.computedName).to.equal('test computed!');
|
||||
expect(this.nameComputedTwice).to.equal('test computed!');
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('mapActions', (done) => {
|
||||
new Vue({ // eslint-disable-line no-new
|
||||
data: {
|
||||
title: 'internal',
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['getName']),
|
||||
...mapActions({
|
||||
getNameRenamed: 'getName',
|
||||
}),
|
||||
},
|
||||
created () {
|
||||
expect(this.getName('123')).to.deep.equal(['test', '123']);
|
||||
expect(this.getNameRenamed('123')).to.deep.equal(['test', '123']);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ var baseConfig = {
|
||||
extensions: ['', '.js', '.vue'],
|
||||
fallback: [path.join(__dirname, '../node_modules')],
|
||||
alias: {
|
||||
src: path.resolve(__dirname, '../website/client'),
|
||||
client: path.resolve(__dirname, '../website/client'),
|
||||
assets: path.resolve(__dirname, '../website/client/assets'),
|
||||
components: path.resolve(__dirname, '../website/client/components'),
|
||||
},
|
||||
|
||||
3
website/build/.gitignore
vendored
Normal file
3
website/build/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Ignore everything except this file so that the folder stays in git
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"presets": ["es2015"],
|
||||
"plugins": ["transform-object-rest-spread"],
|
||||
"comments": false
|
||||
}
|
||||
22
website/client/app.vue
Normal file
22
website/client/app.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<!-- Entry point component for the entire app -->
|
||||
|
||||
<template lang="pug">
|
||||
#app.ui.fluid.container
|
||||
app-header
|
||||
router-view.view
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppHeader from './components/appHeader';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AppHeader,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
// Load CSS that doesn't belong to any specific component
|
||||
@import './assets/less/index';
|
||||
</style>
|
||||
3
website/client/assets/less/index.less
Normal file
3
website/client/assets/less/index.less
Normal file
@@ -0,0 +1,3 @@
|
||||
// CSS that doesn't belong to any specific Vue compoennt
|
||||
@import './semantic-ui/semantic.less';
|
||||
@import './loading-screen';
|
||||
4
website/client/assets/less/loading-screen.less
Normal file
4
website/client/assets/less/loading-screen.less
Normal file
@@ -0,0 +1,4 @@
|
||||
// Rendered outside Vue
|
||||
#loading-screen {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -79,8 +79,8 @@
|
||||
/* Path to theme packages */
|
||||
@themesFolder : 'themes';
|
||||
|
||||
/* Path to site override folder */
|
||||
@siteFolder : '../../website/client/assets/semantic-ui/site';
|
||||
/* Path to site override folder - relative to node_modules/semantic-ui */
|
||||
@siteFolder : '../../website/client/assets/less/semantic-ui/site';
|
||||
|
||||
|
||||
/*******************************
|
||||
@@ -1,34 +0,0 @@
|
||||
<template lang="pug">
|
||||
#app.ui.fluid.container
|
||||
site-header
|
||||
p Welcome back {{user.profile.name}}!
|
||||
ul
|
||||
li
|
||||
router-link(to='/') Home
|
||||
li
|
||||
router-link(to='/page') Another Page
|
||||
router-view.view
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SiteHeader from './siteHeader';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SiteHeader,
|
||||
},
|
||||
|
||||
computed: mapState(['user']),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '../assets/semantic-ui/semantic.less';
|
||||
|
||||
// Element is rendered outside of Vue because it cannot wait for JS to be loaded
|
||||
// Placing CSS here so it benefits from pre-processing
|
||||
#loading-screen {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,18 @@
|
||||
<template lang="pug">
|
||||
h1 {{ title }}
|
||||
#app-header
|
||||
h1 {{title}}
|
||||
ul
|
||||
li
|
||||
router-link(to='/') Home
|
||||
li
|
||||
router-link(to='/page') Another Page
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex';
|
||||
import { mapState } from '../store';
|
||||
|
||||
export default {
|
||||
computed: mapState([
|
||||
'title',
|
||||
]),
|
||||
computed: mapState(['title']),
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
<template lang="pug">
|
||||
p {{ msg }}
|
||||
div
|
||||
p {{ msg }}
|
||||
p Welcome back {{profileName}}!
|
||||
p You have {{tasksCount}} tasks!
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from '../store';
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
msg: 'You\'re on the Home page!',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
tasksCount: (state) => state.tasks.length,
|
||||
}),
|
||||
...mapGetters(['profileName']),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -3,15 +3,13 @@
|
||||
require('babel-polyfill');
|
||||
|
||||
import Vue from 'vue';
|
||||
import VuexRouterSync from 'vuex-router-sync';
|
||||
import VueResource from 'vue-resource';
|
||||
import AppComponent from './components/app';
|
||||
import AppComponent from './app';
|
||||
import router from './router';
|
||||
import store from './vuex/store';
|
||||
|
||||
Vue.use(VueResource);
|
||||
import store from './store';
|
||||
|
||||
// TODO just for the beginning
|
||||
Vue.use(VueResource);
|
||||
|
||||
let authSettings = localStorage.getItem('habit-mobile-settings');
|
||||
|
||||
@@ -21,12 +19,8 @@ if (authSettings) {
|
||||
Vue.http.headers.common['x-api-key'] = authSettings.auth.apiToken;
|
||||
}
|
||||
|
||||
// Sync Vuex and Router
|
||||
VuexRouterSync.sync(store, router);
|
||||
|
||||
const app = new Vue({ // eslint-disable-line no-new
|
||||
const app = new Vue({
|
||||
router,
|
||||
store,
|
||||
render: h => h(AppComponent),
|
||||
mounted () { // Remove the loading screen when the app is mounted
|
||||
let loadingScreen = document.getElementById('loading-screen');
|
||||
@@ -34,21 +28,23 @@ const app = new Vue({ // eslint-disable-line no-new
|
||||
},
|
||||
});
|
||||
|
||||
// Setup listener for title that is outside Vue's scope
|
||||
// Setup listener for title
|
||||
store.watch(state => state.title, (title) => {
|
||||
document.title = title;
|
||||
});
|
||||
|
||||
// Mount the app when the user is loaded
|
||||
let userWatcher = store.watch(state => state.user, (user) => {
|
||||
if (user && user._id) {
|
||||
userWatcher(); // remove the watcher
|
||||
// Mount the app when user and tasks are loaded
|
||||
let userDataWatcher = store.watch(state => [state.user, state.tasks], ([user, tasks]) => {
|
||||
if (user && user._id && tasks && tasks.length) {
|
||||
userDataWatcher(); // remove the watcher
|
||||
app.$mount('#app');
|
||||
}
|
||||
});
|
||||
|
||||
// Load the user
|
||||
store.dispatch('fetchUser')
|
||||
.catch(() => {
|
||||
// Load the user and the user tasks
|
||||
Promise.all([
|
||||
store.dispatch('fetchUser'),
|
||||
store.dispatch('fetchUserTasks'),
|
||||
]).catch(() => {
|
||||
alert('Impossible to fetch user. Copy into localStorage a valid habit-mobile-settings object.');
|
||||
});
|
||||
});
|
||||
|
||||
25
website/client/store/actions.js
Normal file
25
website/client/store/actions.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
export function setTitle (store, title) {
|
||||
store.state.title = title;
|
||||
}
|
||||
|
||||
export function fetchUser (store) {
|
||||
let promise = Vue.http.get('/api/v3/user');
|
||||
|
||||
promise.then((response) => {
|
||||
store.state.user = response.body.data;
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function fetchUserTasks (store) {
|
||||
let promise = Vue.http.get('/api/v3/tasks/user');
|
||||
|
||||
promise.then((response) => {
|
||||
store.state.tasks = response.body.data;
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
15
website/client/store/getters.js
Normal file
15
website/client/store/getters.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export function profileName ({ state }) {
|
||||
let userProfileName = state.user.profile && state.user.profile.name;
|
||||
|
||||
if (!userProfileName) {
|
||||
if (state.user.auth.local && state.user.auth.local.username) {
|
||||
userProfileName = state.user.auth.local.username;
|
||||
} else if (state.user.auth.facebook) {
|
||||
userProfileName = state.user.auth.facebook.displayName || state.user.auth.facebook.username;
|
||||
} else {
|
||||
userProfileName = 'Anonymous';
|
||||
}
|
||||
}
|
||||
|
||||
return userProfileName;
|
||||
}
|
||||
67
website/client/store/helpers.js
Normal file
67
website/client/store/helpers.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2016 Evan You
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
mapState, mapGetters and mapActions taken from Vuex v2.0.0-rc.6 as they're compatible with our
|
||||
store implementation. mapMutations is not present because we do not use mutations.
|
||||
|
||||
Source code https://github.com/vuejs/vuex/blob/v2.0.0-rc.6/src/helpers.js
|
||||
|
||||
The code has been slightly changed to match our code style.
|
||||
*/
|
||||
|
||||
function normalizeMap (map) {
|
||||
return Array.isArray(map) ?
|
||||
map.map(key => ({ key, val: key })) :
|
||||
Object.keys(map).map(key => ({ key, val: map[key] }));
|
||||
}
|
||||
|
||||
export function mapState (states) {
|
||||
const res = {};
|
||||
|
||||
normalizeMap(states).forEach(({ key, val }) => {
|
||||
res[key] = function mappedState () {
|
||||
return typeof val === 'function' ?
|
||||
val.call(this, this.$store.state, this.$store.getters) :
|
||||
this.$store.state[val];
|
||||
};
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export function mapGetters (getters) {
|
||||
const res = {};
|
||||
|
||||
normalizeMap(getters).forEach(({ key, val }) => {
|
||||
res[key] = function mappedGetter () {
|
||||
return this.$store.getters[val];
|
||||
};
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export function mapActions (actions) {
|
||||
const res = {};
|
||||
|
||||
normalizeMap(actions).forEach(({ key, val }) => {
|
||||
res[key] = function mappedAction (...args) {
|
||||
return this.$store.dispatch.apply(this.$store, [val].concat(args)); // eslint-disable-line prefer-spread
|
||||
};
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
76
website/client/store/index.js
Normal file
76
website/client/store/index.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import Vue from 'vue';
|
||||
import state from './state';
|
||||
import * as actions from './actions';
|
||||
import * as getters from './getters';
|
||||
|
||||
// Central application store for Habitica
|
||||
// Heavily inspired to Vuex (https://github.com/vuejs/vuex) with a very
|
||||
// similar internal implementation (thanks!), main difference is the absence of mutations.
|
||||
|
||||
// Create a Vue instance (defined below) detatched from any DOM element to handle app data
|
||||
let _vm;
|
||||
|
||||
// The actual store interface
|
||||
const store = {
|
||||
// App wide computed properties, calculated as computed properties in the internal VM
|
||||
getters: {},
|
||||
// Return the store's state
|
||||
get state () {
|
||||
return _vm.$data.state;
|
||||
},
|
||||
// Actions should be called using store.dispatch(ACTION_NAME, ...ARGS)
|
||||
// They get passed the store instance and any additional argument passed to dispatch()
|
||||
dispatch (type, ...args) {
|
||||
let action = actions[type];
|
||||
|
||||
if (!action) throw new Error(`Action "${type}" not found.`);
|
||||
return action(store, ...args);
|
||||
},
|
||||
// Watch data on the store's state
|
||||
// Internally it uses vm.$watch and accept the same argument except
|
||||
// for the first one that must be a getter function to which the state is passed
|
||||
// For documentation see https://vuejs.org/api/#vm-watch
|
||||
watch (getter, cb, options) {
|
||||
if (typeof getter !== 'function') {
|
||||
throw new Error('The first argument of store.watch must be a function.');
|
||||
}
|
||||
|
||||
return _vm.$watch(() => getter(state), cb, options);
|
||||
},
|
||||
};
|
||||
|
||||
// Setup getters
|
||||
const _computed = {};
|
||||
|
||||
Object.keys(getters).forEach(key => {
|
||||
let getter = getters[key];
|
||||
|
||||
// Each getter is compiled to a computed property on the internal VM
|
||||
_computed[key] = () => getter(store);
|
||||
|
||||
Object.defineProperty(store.getters, key, {
|
||||
get: () => _vm[key],
|
||||
});
|
||||
});
|
||||
|
||||
// Setup internal Vue instance to make state and getters reactive
|
||||
_vm = new Vue({
|
||||
data: { state },
|
||||
computed: _computed,
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
export {
|
||||
mapState,
|
||||
mapGetters,
|
||||
mapActions,
|
||||
} from './helpers';
|
||||
|
||||
// Inject the store into all components as this.$store
|
||||
Vue.mixin({
|
||||
beforeCreate () {
|
||||
this.$store = store;
|
||||
},
|
||||
});
|
||||
|
||||
7
website/client/store/state.js
Normal file
7
website/client/store/state.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const state = {
|
||||
title: 'Habitica',
|
||||
user: null,
|
||||
tasks: null, // user tasks
|
||||
};
|
||||
|
||||
export default state;
|
||||
@@ -1,15 +0,0 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
export function setTitle (store, title) {
|
||||
store.commit('SET_TITLE', title);
|
||||
}
|
||||
|
||||
export function fetchUser (store) {
|
||||
let promise = Vue.http.get('/api/v3/user');
|
||||
|
||||
promise.then(response => {
|
||||
store.commit('SET_USER', response.body.data);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function SET_TITLE (state, title) {
|
||||
state.title = title;
|
||||
}
|
||||
|
||||
export function SET_USER (state, userJson) {
|
||||
state.user = userJson;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import * as mutations from './mutations';
|
||||
import * as actions from './actions';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
const state = {
|
||||
title: 'Habitica',
|
||||
user: {},
|
||||
};
|
||||
|
||||
export default new Vuex.Store({
|
||||
state,
|
||||
mutations,
|
||||
actions,
|
||||
});
|
||||
Reference in New Issue
Block a user