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 @@
+
+
+
+#app.ui.fluid.container
+ app-header
+ router-view.view
+
+
+
+
+
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 @@
-
-#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
-
-
-
-
-
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 @@
-h1 {{ title }}
+#app-header
+ h1 {{title}}
+ ul
+ li
+ router-link(to='/') Home
+ li
+ router-link(to='/page') Another Page
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 @@
-p {{ msg }}
+div
+ p {{ msg }}
+ p Welcome back {{profileName}}!
+ p You have {{tasksCount}} tasks!
\ 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