* 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:
Matteo Pagliazzi
2016-09-29 13:32:36 +02:00
committed by GitHub
parent 50e2731811
commit 257e932bc3
29 changed files with 394 additions and 123 deletions

View File

@@ -23,4 +23,6 @@ Gruntfile.js
gulpfile.js gulpfile.js
gulp gulp
webpack webpack
test/client test/client/e2e
test/client/unit/index.js
test/client/unit/karma.conf.js

1
.gitignore vendored
View File

@@ -14,7 +14,6 @@ npm-debug.log*
lib lib
website/client-old/bower_components website/client-old/bower_components
website/client-old/new-stuff.html website/client-old/new-stuff.html
website/build
newrelic_agent.log newrelic_agent.log
.bower-tmp .bower-tmp
.bower-registry .bower-registry

View File

@@ -5,7 +5,7 @@ import fs from 'fs';
// Code taken from https://www.artembutusov.com/webpack-semantic-ui/ // Code taken from https://www.artembutusov.com/webpack-semantic-ui/
// Relative to node_modules/semantic-ui-less // 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 // fix well known bug with default distribution
function fixFontPath (filename) { function fixFontPath (filename) {

View File

@@ -16,6 +16,7 @@
"babel-core": "^6.0.0", "babel-core": "^6.0.0",
"babel-loader": "^6.0.0", "babel-loader": "^6.0.0",
"babel-plugin-transform-async-to-module-method": "^6.8.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-polyfill": "^6.6.1",
"babel-preset-es2015": "^6.6.0", "babel-preset-es2015": "^6.6.0",
"babel-register": "^6.6.0", "babel-register": "^6.6.0",
@@ -114,8 +115,6 @@
"vue-loader": "^9.4.0", "vue-loader": "^9.4.0",
"vue-resource": "^1.0.2", "vue-resource": "^1.0.2",
"vue-router": "^2.0.0-rc.5", "vue-router": "^2.0.0-rc.5",
"vuex": "^2.0.0-rc.5",
"vuex-router-sync": "^3.0.0",
"webpack": "^1.12.2", "webpack": "^1.12.2",
"webpack-merge": "^0.8.3", "webpack-merge": "^0.8.3",
"winston": "^2.1.0", "winston": "^2.1.0",
@@ -147,6 +146,7 @@
"client:dev": "node webpack/dev-server.js", "client:dev": "node webpack/dev-server.js",
"client:build": "node webpack/build.js", "client:build": "node webpack/build.js",
"client:unit": "karma start test/client/unit/karma.conf.js --single-run", "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:e2e": "node test/client/e2e/runner.js",
"client:test": "npm run client:unit && npm run client:e2e", "client:test": "npm run client:unit && npm run client:e2e",
"start": "gulp run:dev", "start": "gulp run:dev",

5
test/client/.babelrc Normal file
View File

@@ -0,0 +1,5 @@
{
"presets": ["es2015"],
"plugins": ["transform-object-rest-spread"],
"comments": false
}

View File

@@ -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);
});
});

View 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();
},
});
});
});
});

View File

@@ -17,7 +17,7 @@ var baseConfig = {
extensions: ['', '.js', '.vue'], extensions: ['', '.js', '.vue'],
fallback: [path.join(__dirname, '../node_modules')], fallback: [path.join(__dirname, '../node_modules')],
alias: { alias: {
src: path.resolve(__dirname, '../website/client'), client: path.resolve(__dirname, '../website/client'),
assets: path.resolve(__dirname, '../website/client/assets'), assets: path.resolve(__dirname, '../website/client/assets'),
components: path.resolve(__dirname, '../website/client/components'), components: path.resolve(__dirname, '../website/client/components'),
}, },

3
website/build/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Ignore everything except this file so that the folder stays in git
*
!.gitignore

View File

@@ -1,4 +1,5 @@
{ {
"presets": ["es2015"], "presets": ["es2015"],
"plugins": ["transform-object-rest-spread"],
"comments": false "comments": false
} }

22
website/client/app.vue Normal file
View 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>

View File

@@ -0,0 +1,3 @@
// CSS that doesn't belong to any specific Vue compoennt
@import './semantic-ui/semantic.less';
@import './loading-screen';

View File

@@ -0,0 +1,4 @@
// Rendered outside Vue
#loading-screen {
height: 100%;
}

View File

@@ -79,8 +79,8 @@
/* Path to theme packages */ /* Path to theme packages */
@themesFolder : 'themes'; @themesFolder : 'themes';
/* Path to site override folder */ /* Path to site override folder - relative to node_modules/semantic-ui */
@siteFolder : '../../website/client/assets/semantic-ui/site'; @siteFolder : '../../website/client/assets/less/semantic-ui/site';
/******************************* /*******************************

View File

@@ -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>

View File

@@ -1,14 +1,18 @@
<template lang="pug"> <template lang="pug">
h1 {{ title }} #app-header
h1 {{title}}
ul
li
router-link(to='/') Home
li
router-link(to='/page') Another Page
</template> </template>
<script> <script>
import { mapState } from 'vuex'; import { mapState } from '../store';
export default { export default {
computed: mapState([ computed: mapState(['title']),
'title',
]),
}; };
</script> </script>

View File

@@ -1,13 +1,24 @@
<template lang="pug"> <template lang="pug">
p {{ msg }} div
p {{ msg }}
p Welcome back {{profileName}}!
p You have {{tasksCount}} tasks!
</template> </template>
<script> <script>
import { mapState, mapGetters } from '../store';
export default { export default {
data () { data () {
return { return {
msg: 'You\'re on the Home page!', msg: 'You\'re on the Home page!',
}; };
}, },
computed: {
...mapState({
tasksCount: (state) => state.tasks.length,
}),
...mapGetters(['profileName']),
},
}; };
</script> </script>

View File

@@ -3,15 +3,13 @@
require('babel-polyfill'); require('babel-polyfill');
import Vue from 'vue'; import Vue from 'vue';
import VuexRouterSync from 'vuex-router-sync';
import VueResource from 'vue-resource'; import VueResource from 'vue-resource';
import AppComponent from './components/app'; import AppComponent from './app';
import router from './router'; import router from './router';
import store from './vuex/store'; import store from './store';
Vue.use(VueResource);
// TODO just for the beginning // TODO just for the beginning
Vue.use(VueResource);
let authSettings = localStorage.getItem('habit-mobile-settings'); let authSettings = localStorage.getItem('habit-mobile-settings');
@@ -21,12 +19,8 @@ if (authSettings) {
Vue.http.headers.common['x-api-key'] = authSettings.auth.apiToken; Vue.http.headers.common['x-api-key'] = authSettings.auth.apiToken;
} }
// Sync Vuex and Router const app = new Vue({
VuexRouterSync.sync(store, router);
const app = new Vue({ // eslint-disable-line no-new
router, router,
store,
render: h => h(AppComponent), render: h => h(AppComponent),
mounted () { // Remove the loading screen when the app is mounted mounted () { // Remove the loading screen when the app is mounted
let loadingScreen = document.getElementById('loading-screen'); 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) => { store.watch(state => state.title, (title) => {
document.title = title; document.title = title;
}); });
// Mount the app when the user is loaded // Mount the app when user and tasks are loaded
let userWatcher = store.watch(state => state.user, (user) => { let userDataWatcher = store.watch(state => [state.user, state.tasks], ([user, tasks]) => {
if (user && user._id) { if (user && user._id && tasks && tasks.length) {
userWatcher(); // remove the watcher userDataWatcher(); // remove the watcher
app.$mount('#app'); app.$mount('#app');
} }
}); });
// Load the user // Load the user and the user tasks
store.dispatch('fetchUser') Promise.all([
.catch(() => { store.dispatch('fetchUser'),
alert('Impossible to fetch user. Copy into localStorage a valid habit-mobile-settings object.'); store.dispatch('fetchUserTasks'),
}); ]).catch(() => {
alert('Impossible to fetch user. Copy into localStorage a valid habit-mobile-settings object.');
});

View 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;
}

View 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;
}

View 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;
}

View 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;
},
});

View File

@@ -0,0 +1,7 @@
const state = {
title: 'Habitica',
user: null,
tasks: null, // user tasks
};
export default state;

View File

@@ -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;
}

View File

@@ -1,7 +0,0 @@
export function SET_TITLE (state, title) {
state.title = title;
}
export function SET_USER (state, userJson) {
state.user = userJson;
}

View File

@@ -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,
});