From 257e932bc39460205458811b5cb7909179aea6a3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 29 Sep 2016 13:32:36 +0200 Subject: [PATCH] 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 --- .eslintignore | 4 +- .gitignore | 1 - gulp/gulp-semanticui.js | 2 +- package.json | 4 +- test/client/.babelrc | 5 + test/client/unit/specs/Hello.spec.js | 16 --- test/client/unit/specs/store.spec.js | 120 ++++++++++++++++++ webpack/webpack.base.conf.js | 2 +- website/build/.gitignore | 3 + website/client/.babelrc | 1 + website/client/app.vue | 22 ++++ website/client/assets/less/index.less | 3 + .../client/assets/less/loading-screen.less | 4 + .../assets/{ => less}/semantic-ui/README.md | 0 .../{ => less}/semantic-ui/semantic.less | 0 .../semantic-ui/site/globals/site.variables | 0 .../{ => less}/semantic-ui/theme.config | 4 +- website/client/components/app.vue | 34 ----- .../{siteHeader.vue => appHeader.vue} | 14 +- website/client/components/home.vue | 13 +- website/client/main.js | 36 +++--- website/client/store/actions.js | 25 ++++ website/client/store/getters.js | 15 +++ website/client/store/helpers.js | 67 ++++++++++ website/client/store/index.js | 76 +++++++++++ website/client/store/state.js | 7 + website/client/vuex/actions.js | 15 --- website/client/vuex/mutations.js | 7 - website/client/vuex/store.js | 17 --- 29 files changed, 394 insertions(+), 123 deletions(-) create mode 100644 test/client/.babelrc delete mode 100644 test/client/unit/specs/Hello.spec.js create mode 100644 test/client/unit/specs/store.spec.js create mode 100644 website/build/.gitignore create mode 100644 website/client/app.vue create mode 100644 website/client/assets/less/index.less create mode 100644 website/client/assets/less/loading-screen.less rename website/client/assets/{ => less}/semantic-ui/README.md (100%) rename website/client/assets/{ => less}/semantic-ui/semantic.less (100%) rename website/client/assets/{ => less}/semantic-ui/site/globals/site.variables (100%) rename website/client/assets/{ => less}/semantic-ui/theme.config (94%) delete mode 100644 website/client/components/app.vue rename website/client/components/{siteHeader.vue => appHeader.vue} (50%) create mode 100644 website/client/store/actions.js create mode 100644 website/client/store/getters.js create mode 100644 website/client/store/helpers.js create mode 100644 website/client/store/index.js create mode 100644 website/client/store/state.js delete mode 100644 website/client/vuex/actions.js delete mode 100644 website/client/vuex/mutations.js delete mode 100644 website/client/vuex/store.js diff --git a/.eslintignore b/.eslintignore index 0b1b7762af..b45194ca1c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -23,4 +23,6 @@ Gruntfile.js gulpfile.js gulp webpack -test/client \ No newline at end of file +test/client/e2e +test/client/unit/index.js +test/client/unit/karma.conf.js \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6aba9a7ba7..25c43b1a7e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/gulp/gulp-semanticui.js b/gulp/gulp-semanticui.js index b19025bf92..ad0570351f 100644 --- a/gulp/gulp-semanticui.js +++ b/gulp/gulp-semanticui.js @@ -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) { diff --git a/package.json b/package.json index a8112efddd..e86f0d1748 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/client/.babelrc b/test/client/.babelrc new file mode 100644 index 0000000000..7743d07e4f --- /dev/null +++ b/test/client/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": ["es2015"], + "plugins": ["transform-object-rest-spread"], + "comments": false +} \ No newline at end of file diff --git a/test/client/unit/specs/Hello.spec.js b/test/client/unit/specs/Hello.spec.js deleted file mode 100644 index 4bba03c74e..0000000000 --- a/test/client/unit/specs/Hello.spec.js +++ /dev/null @@ -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); - }); -}); diff --git a/test/client/unit/specs/store.spec.js b/test/client/unit/specs/store.spec.js new file mode 100644 index 0000000000..a8eb66c381 --- /dev/null +++ b/test/client/unit/specs/store.spec.js @@ -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(); + }, + }); + }); + }); +}); diff --git a/webpack/webpack.base.conf.js b/webpack/webpack.base.conf.js index 0f3a02ee8a..b871e84bfe 100644 --- a/webpack/webpack.base.conf.js +++ b/webpack/webpack.base.conf.js @@ -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'), }, diff --git a/website/build/.gitignore b/website/build/.gitignore new file mode 100644 index 0000000000..808ea3e139 --- /dev/null +++ b/website/build/.gitignore @@ -0,0 +1,3 @@ +# Ignore everything except this file so that the folder stays in git +* +!.gitignore \ No newline at end of file diff --git a/website/client/.babelrc b/website/client/.babelrc index 0595f3ffcc..7743d07e4f 100644 --- a/website/client/.babelrc +++ b/website/client/.babelrc @@ -1,4 +1,5 @@ { "presets": ["es2015"], + "plugins": ["transform-object-rest-spread"], "comments": false } \ No newline at end of file diff --git a/website/client/app.vue b/website/client/app.vue new file mode 100644 index 0000000000..60e790a57c --- /dev/null +++ b/website/client/app.vue @@ -0,0 +1,22 @@ + + + + + + + diff --git a/website/client/assets/less/index.less b/website/client/assets/less/index.less new file mode 100644 index 0000000000..2c10e8ff9c --- /dev/null +++ b/website/client/assets/less/index.less @@ -0,0 +1,3 @@ +// CSS that doesn't belong to any specific Vue compoennt +@import './semantic-ui/semantic.less'; +@import './loading-screen'; \ No newline at end of file diff --git a/website/client/assets/less/loading-screen.less b/website/client/assets/less/loading-screen.less new file mode 100644 index 0000000000..4451c61996 --- /dev/null +++ b/website/client/assets/less/loading-screen.less @@ -0,0 +1,4 @@ +// Rendered outside Vue +#loading-screen { + height: 100%; +} diff --git a/website/client/assets/semantic-ui/README.md b/website/client/assets/less/semantic-ui/README.md similarity index 100% rename from website/client/assets/semantic-ui/README.md rename to website/client/assets/less/semantic-ui/README.md diff --git a/website/client/assets/semantic-ui/semantic.less b/website/client/assets/less/semantic-ui/semantic.less similarity index 100% rename from website/client/assets/semantic-ui/semantic.less rename to website/client/assets/less/semantic-ui/semantic.less diff --git a/website/client/assets/semantic-ui/site/globals/site.variables b/website/client/assets/less/semantic-ui/site/globals/site.variables similarity index 100% rename from website/client/assets/semantic-ui/site/globals/site.variables rename to website/client/assets/less/semantic-ui/site/globals/site.variables diff --git a/website/client/assets/semantic-ui/theme.config b/website/client/assets/less/semantic-ui/theme.config similarity index 94% rename from website/client/assets/semantic-ui/theme.config rename to website/client/assets/less/semantic-ui/theme.config index 17f6cb812f..ff740a2185 100644 --- a/website/client/assets/semantic-ui/theme.config +++ b/website/client/assets/less/semantic-ui/theme.config @@ -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'; /******************************* diff --git a/website/client/components/app.vue b/website/client/components/app.vue deleted file mode 100644 index 8438505b97..0000000000 --- a/website/client/components/app.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - - - diff --git a/website/client/components/siteHeader.vue b/website/client/components/appHeader.vue similarity index 50% rename from website/client/components/siteHeader.vue rename to website/client/components/appHeader.vue index ed2204514c..84b65391a0 100644 --- a/website/client/components/siteHeader.vue +++ b/website/client/components/appHeader.vue @@ -1,14 +1,18 @@ diff --git a/website/client/components/home.vue b/website/client/components/home.vue index 7254a38615..9b92995ba6 100644 --- a/website/client/components/home.vue +++ b/website/client/components/home.vue @@ -1,13 +1,24 @@ \ No newline at end of file diff --git a/website/client/main.js b/website/client/main.js index 9440cedaf5..7ec2e55dc4 100644 --- a/website/client/main.js +++ b/website/client/main.js @@ -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(() => { - alert('Impossible to fetch user. Copy into localStorage a valid habit-mobile-settings object.'); - }); +// 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.'); +}); diff --git a/website/client/store/actions.js b/website/client/store/actions.js new file mode 100644 index 0000000000..0128908104 --- /dev/null +++ b/website/client/store/actions.js @@ -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; +} \ No newline at end of file diff --git a/website/client/store/getters.js b/website/client/store/getters.js new file mode 100644 index 0000000000..137a7bb410 --- /dev/null +++ b/website/client/store/getters.js @@ -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; +} \ No newline at end of file diff --git a/website/client/store/helpers.js b/website/client/store/helpers.js new file mode 100644 index 0000000000..1569555daf --- /dev/null +++ b/website/client/store/helpers.js @@ -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; +} \ No newline at end of file diff --git a/website/client/store/index.js b/website/client/store/index.js new file mode 100644 index 0000000000..6d2bae633e --- /dev/null +++ b/website/client/store/index.js @@ -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; + }, +}); + diff --git a/website/client/store/state.js b/website/client/store/state.js new file mode 100644 index 0000000000..3f8836bd21 --- /dev/null +++ b/website/client/store/state.js @@ -0,0 +1,7 @@ +const state = { + title: 'Habitica', + user: null, + tasks: null, // user tasks +}; + +export default state; \ No newline at end of file diff --git a/website/client/vuex/actions.js b/website/client/vuex/actions.js deleted file mode 100644 index 667c7635f9..0000000000 --- a/website/client/vuex/actions.js +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/website/client/vuex/mutations.js b/website/client/vuex/mutations.js deleted file mode 100644 index 7127bb2049..0000000000 --- a/website/client/vuex/mutations.js +++ /dev/null @@ -1,7 +0,0 @@ -export function SET_TITLE (state, title) { - state.title = title; -} - -export function SET_USER (state, userJson) { - state.user = userJson; -} \ No newline at end of file diff --git a/website/client/vuex/store.js b/website/client/vuex/store.js deleted file mode 100644 index 9dc10fd8f3..0000000000 --- a/website/client/vuex/store.js +++ /dev/null @@ -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, -}); \ No newline at end of file