diff --git a/test/client/unit/specs/libs/asyncResource.js b/test/client/unit/specs/libs/asyncResource.js
new file mode 100644
index 0000000000..3123dd505b
--- /dev/null
+++ b/test/client/unit/specs/libs/asyncResource.js
@@ -0,0 +1,151 @@
+import { asyncResourceFactory, loadAsyncResource } from 'client/libs/asyncResource';
+import axios from 'axios';
+import generateStore from 'client/store';
+import { sleep } from '../../../../helpers/sleep';
+
+describe('async resource', () => {
+ it('asyncResourceFactory', () => {
+ const resource = asyncResourceFactory();
+ expect(resource.loadingStatus).to.equal('NOT_LOADED');
+ expect(resource.data).to.equal(null);
+ expect(resource).to.not.equal(asyncResourceFactory);
+ });
+
+ describe('loadAsyncResource', () => {
+ context('errors', () => {
+ it('store is missing', () => {
+ expect(() => loadAsyncResource({})).to.throw;
+ });
+ it('path is missing', () => {
+ expect(() => loadAsyncResource({
+ store: 'store',
+ })).to.throw;
+ });
+ it('url is missing', () => {
+ expect(() => loadAsyncResource({
+ store: 'store',
+ path: 'path',
+ })).to.throw;
+ });
+ it('deserialize is missing', () => {
+ expect(() => loadAsyncResource({
+ store: 'store',
+ path: 'path',
+ url: 'url',
+ })).to.throw;
+ });
+ it('resource not found', () => {
+ const store = generateStore();
+
+ expect(() => loadAsyncResource({
+ store,
+ path: 'not existing path',
+ url: 'url',
+ deserialize: 'deserialize',
+ })).to.throw;
+ });
+
+ it('invalid loading status', () => {
+ const store = generateStore();
+ store.state.user.loadingStatus = 'INVALID';
+
+ expect(loadAsyncResource({
+ store,
+ path: 'user',
+ url: 'url',
+ deserialize: 'deserialize',
+ })).to.eventually.be.rejected;
+ });
+ });
+
+ it('returns the resource if it is already loaded and forceLoad is false', async () => {
+ const store = generateStore();
+ store.state.user.loadingStatus = 'LOADED';
+ store.state.user.data = {_id: 1};
+
+ sandbox.stub(axios, 'get');
+
+ const resource = await loadAsyncResource({
+ store,
+ path: 'user',
+ url: 'url',
+ deserialize: 'deserialize',
+ });
+
+ expect(resource).to.equal(store.state.user);
+ expect(axios.get).to.not.have.been.called;
+ });
+
+ it('load the resource if it is not loaded', async () => {
+ const store = generateStore();
+ store.state.user = asyncResourceFactory();
+
+ sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: {_id: 1}}}));
+
+ const resource = await loadAsyncResource({
+ store,
+ path: 'user',
+ url: '/api/v3/user',
+ deserialize (response) {
+ return response.data.data;
+ },
+ });
+
+ expect(resource).to.equal(store.state.user);
+ expect(resource.loadingStatus).to.equal('LOADED');
+ expect(resource.data._id).to.equal(1);
+ expect(axios.get).to.have.been.calledOnce;
+ });
+
+ it('load the resource if it is loaded but forceLoad is true', async () => {
+ const store = generateStore();
+ store.state.user.loadingStatus = 'LOADED';
+
+ sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: {_id: 1}}}));
+
+ const resource = await loadAsyncResource({
+ store,
+ path: 'user',
+ url: '/api/v3/user',
+ deserialize (response) {
+ return response.data.data;
+ },
+ forceLoad: true,
+ });
+
+ expect(resource).to.equal(store.state.user);
+ expect(resource.loadingStatus).to.equal('LOADED');
+ expect(resource.data._id).to.equal(1);
+ expect(axios.get).to.have.been.calledOnce;
+ });
+
+ it('does not send multiple requests if the resource is being loaded', async () => {
+ const store = generateStore();
+ store.state.user.loadingStatus = 'LOADING';
+
+ sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: {_id: 1}}}));
+
+ const resourcePromise = loadAsyncResource({
+ store,
+ path: 'user',
+ url: '/api/v3/user',
+ deserialize (response) {
+ return response.data.data;
+ },
+ forceLoad: true,
+ });
+
+ await sleep(0.1);
+ const userData = {_id: 1};
+
+ expect(store.state.user.loadingStatus).to.equal('LOADING');
+ expect(axios.get).to.not.have.been.called;
+ store.state.user.data = userData;
+ store.state.user.loadingStatus = 'LOADED';
+
+ const result = await resourcePromise;
+ expect(axios.get).to.not.have.been.called;
+ expect(result).to.equal(store.state.user);
+ });
+ });
+});
\ No newline at end of file
diff --git a/test/client/unit/specs/libs/deepFreeze.js b/test/client/unit/specs/libs/deepFreeze.js
index 5d028e969f..fd9cf559e8 100644
--- a/test/client/unit/specs/libs/deepFreeze.js
+++ b/test/client/unit/specs/libs/deepFreeze.js
@@ -1,7 +1,7 @@
import deepFreeze from 'client/libs/deepFreeze';
describe('deepFreeze', () => {
- it('works as expected', () => {
+ it('deeply freezes an object', () => {
let obj = {
a: 1,
b () {
diff --git a/test/client/unit/specs/plugins/i18n.js b/test/client/unit/specs/libs/i18n.js
similarity index 87%
rename from test/client/unit/specs/plugins/i18n.js
rename to test/client/unit/specs/libs/i18n.js
index df4840bb90..0d5974e138 100644
--- a/test/client/unit/specs/plugins/i18n.js
+++ b/test/client/unit/specs/libs/i18n.js
@@ -1,10 +1,10 @@
-import i18n from 'client/plugins/i18n';
+import i18n from 'client/libs/i18n';
import commoni18n from 'common/script/i18n';
import Vue from 'vue';
describe('i18n plugin', () => {
before(() => {
- i18n.install(Vue);
+ Vue.use(i18n);
});
it('adds $t to Vue.prototype', () => {
diff --git a/test/client/unit/specs/libs/store.js b/test/client/unit/specs/libs/store.js
new file mode 100644
index 0000000000..77dd9d3b4d
--- /dev/null
+++ b/test/client/unit/specs/libs/store.js
@@ -0,0 +1,178 @@
+import Vue from 'vue';
+import StoreModule, { mapState, mapGetters, mapActions } from 'client/libs/store';
+import { flattenAndNamespace } from 'client/libs/store/helpers/internals';
+
+describe('Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new StoreModule({ // eslint-disable-line babel/new-cap
+ state: {
+ name: 'test',
+ nested: {
+ name: 'nested state test',
+ },
+ },
+ getters: {
+ computedName ({ state }) {
+ return `${state.name} computed!`;
+ },
+ ...flattenAndNamespace({
+ nested: {
+ computedName ({ state }) {
+ return `${state.name} computed!`;
+ },
+ },
+ }),
+ },
+ actions: {
+ getName ({ state }, ...args) {
+ return [state.name, ...args];
+ },
+ ...flattenAndNamespace({
+ nested: {
+ getName ({ state }, ...args) {
+ return [state.name, ...args];
+ },
+ },
+ }),
+ },
+ });
+
+ Vue.use(StoreModule);
+ });
+
+ it('injects itself in all component', (done) => {
+ new Vue({ // eslint-disable-line no-new
+ store,
+ created () {
+ expect(this.$store).to.equal(store);
+ done();
+ },
+ });
+ });
+
+ it('can watch a function on the state', (done) => {
+ store.watch(state => state.name, (newName) => {
+ expect(newName).to.equal('test updated');
+ done();
+ });
+
+ store.state.name = 'test updated';
+ });
+
+ describe('getters', () => {
+ it('supports getters', () => {
+ expect(store.getters.computedName).to.equal('test computed!');
+ store.state.name = 'test updated';
+ expect(store.getters.computedName).to.equal('test updated computed!');
+ });
+
+ it('supports nested getters', () => {
+ expect(store.getters['nested:computedName']).to.equal('test computed!');
+ store.state.name = 'test updated';
+ expect(store.getters['nested:computedName']).to.equal('test updated computed!');
+ });
+ });
+
+ describe('actions', () => {
+ it('can dispatch an action', () => {
+ expect(store.dispatch('getName', 1, 2, 3)).to.deep.equal(['test', 1, 2, 3]);
+ });
+
+ it('can dispatch a nested action', () => {
+ expect(store.dispatch('nested:getName', 1, 2, 3)).to.deep.equal(['test', 1, 2, 3]);
+ });
+
+ it('throws an error is the action doesn\'t exists', () => {
+ expect(() => store.dispatched('wrong')).to.throw;
+ });
+ });
+
+ describe('helpers', () => {
+ it('mapState', (done) => {
+ new Vue({ // eslint-disable-line no-new
+ store,
+ data: {
+ title: 'internal',
+ },
+ computed: {
+ ...mapState(['name']),
+ ...mapState({
+ nameComputed (state, getters) {
+ return `${this.title} ${getters.computedName} ${state.name}`;
+ },
+ }),
+ ...mapState({nestedTest: 'nested.name'}),
+ },
+ created () {
+ expect(this.name).to.equal('test');
+ expect(this.nameComputed).to.equal('internal test computed! test');
+ expect(this.nestedTest).to.equal('nested state test');
+ done();
+ },
+ });
+ });
+
+ it('mapGetters', (done) => {
+ new Vue({ // eslint-disable-line no-new
+ store,
+ 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
+ store,
+ 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();
+ },
+ });
+ });
+
+ it('flattenAndNamespace', () => {
+ let result = flattenAndNamespace({
+ nested: {
+ computed ({ state }, ...args) {
+ return [state.name, ...args];
+ },
+ getName ({ state }, ...args) {
+ return [state.name, ...args];
+ },
+ },
+ nested2: {
+ getName ({ state }, ...args) {
+ return [state.name, ...args];
+ },
+ },
+ });
+
+ expect(Object.keys(result).length).to.equal(3);
+ expect(Object.keys(result).sort()).to.deep.equal(['nested2:getName', 'nested:computed', 'nested:getName']);
+ });
+ });
+});
diff --git a/test/client/unit/specs/store/actions/guilds.js b/test/client/unit/specs/store/actions/guilds.js
deleted file mode 100644
index 70f3a4f1d3..0000000000
--- a/test/client/unit/specs/store/actions/guilds.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { fetchAll as fetchAllGuilds } from 'client/store/actions/guilds';
-import axios from 'axios';
-import store from 'client/store';
-
-describe('guilds actions', () => {
- it('fetchAll', async () => {
- const guilds = [{_id: 1}];
- sandbox
- .stub(axios, 'get')
- .withArgs('/api/v3/groups?type=publicGuilds')
- .returns(Promise.resolve({data: {data: guilds}}));
-
- await fetchAllGuilds(store);
-
- expect(store.state.guilds).to.equal(guilds);
- });
-});
\ No newline at end of file
diff --git a/test/client/unit/specs/store/actions/tasks.js b/test/client/unit/specs/store/actions/tasks.js
index b95893da64..79683848c6 100644
--- a/test/client/unit/specs/store/actions/tasks.js
+++ b/test/client/unit/specs/store/actions/tasks.js
@@ -1,14 +1,54 @@
-import { fetchUserTasks } from 'client/store/actions/tasks';
import axios from 'axios';
-import store from 'client/store';
+import generateStore from 'client/store';
describe('tasks actions', () => {
- it('fetchUserTasks', async () => {
- const tasks = [{_id: 1}];
- sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
+ let store;
- await fetchUserTasks(store);
+ beforeEach(() => {
+ store = generateStore();
+ });
- expect(store.state.tasks).to.equal(tasks);
+ describe('fetchUserTasks', () => {
+ it('fetches user tasks', async () => {
+ expect(store.state.tasks.loadingStatus).to.equal('NOT_LOADED');
+ const tasks = [{_id: 1}];
+ sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
+
+ await store.dispatch('tasks:fetchUserTasks');
+
+ expect(store.state.tasks.data).to.equal(tasks);
+ expect(store.state.tasks.loadingStatus).to.equal('LOADED');
+ });
+
+ it('does not reload tasks by default', async () => {
+ const originalTask = [{_id: 1}];
+ store.state.tasks = {
+ loadingStatus: 'LOADED',
+ data: originalTask,
+ };
+
+ const tasks = [{_id: 2}];
+ sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
+
+ await store.dispatch('tasks:fetchUserTasks');
+
+ expect(store.state.tasks.data).to.equal(originalTask);
+ expect(store.state.tasks.loadingStatus).to.equal('LOADED');
+ });
+
+ it('can reload tasks if forceLoad is true', async () => {
+ store.state.tasks = {
+ loadingStatus: 'LOADED',
+ data: [{_id: 1}],
+ };
+
+ const tasks = [{_id: 2}];
+ sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
+
+ await store.dispatch('tasks:fetchUserTasks', true);
+
+ expect(store.state.tasks.data).to.equal(tasks);
+ expect(store.state.tasks.loadingStatus).to.equal('LOADED');
+ });
});
});
\ No newline at end of file
diff --git a/test/client/unit/specs/store/actions/user.js b/test/client/unit/specs/store/actions/user.js
index 7ba43194b0..5069e04087 100644
--- a/test/client/unit/specs/store/actions/user.js
+++ b/test/client/unit/specs/store/actions/user.js
@@ -1,14 +1,54 @@
-import { fetch as fetchUser } from 'client/store/actions/user';
import axios from 'axios';
-import store from 'client/store';
+import generateStore from 'client/store';
-describe('user actions', () => {
- it('fetch', async () => {
- const user = {_id: 1};
- sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
+describe('tasks actions', () => {
+ let store;
- await fetchUser(store);
+ beforeEach(() => {
+ store = generateStore();
+ });
- expect(store.state.user).to.equal(user);
+ describe('fetch', () => {
+ it('loads the user', async () => {
+ expect(store.state.user.loadingStatus).to.equal('NOT_LOADED');
+ const user = {_id: 1};
+ sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
+
+ await store.dispatch('user:fetch');
+
+ expect(store.state.user.data).to.equal(user);
+ expect(store.state.user.loadingStatus).to.equal('LOADED');
+ });
+
+ it('does not reload user by default', async () => {
+ const originalUser = {_id: 1};
+ store.state.user = {
+ loadingStatus: 'LOADED',
+ data: originalUser,
+ };
+
+ const user = {_id: 2};
+ sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
+
+ await store.dispatch('user:fetch');
+
+ expect(store.state.user.data).to.equal(originalUser);
+ expect(store.state.user.loadingStatus).to.equal('LOADED');
+ });
+
+ it('can reload user if forceLoad is true', async () => {
+ store.state.user = {
+ loadingStatus: 'LOADED',
+ data: {_id: 1},
+ };
+
+ const user = {_id: 2};
+ sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
+
+ await store.dispatch('user:fetch', true);
+
+ expect(store.state.user.data).to.equal(user);
+ expect(store.state.user.loadingStatus).to.equal('LOADED');
+ });
});
});
\ No newline at end of file
diff --git a/test/client/unit/specs/store/getters/tasks/getTagsFor.js b/test/client/unit/specs/store/getters/tasks/getTagsFor.js
new file mode 100644
index 0000000000..6e8ccbd353
--- /dev/null
+++ b/test/client/unit/specs/store/getters/tasks/getTagsFor.js
@@ -0,0 +1,16 @@
+import generateStore from 'client/store';
+
+describe('getTagsFor getter', () => {
+ it('returns the tags for a task', () => {
+ const store = generateStore();
+ store.state.user.data = {
+ tags: [
+ {id: 1, name: 'tag 1'},
+ {id: 2, name: 'tag 2'},
+ ],
+ };
+
+ const task = {tags: [2]};
+ expect(store.getters['tasks:getTagsFor'](task)).to.deep.equal(['tag 2']);
+ });
+});
\ No newline at end of file
diff --git a/test/client/unit/specs/store/getters/userGems.js b/test/client/unit/specs/store/getters/user/userGems.js
similarity index 88%
rename from test/client/unit/specs/store/getters/userGems.js
rename to test/client/unit/specs/store/getters/user/userGems.js
index f1a754822e..a6c515b42c 100644
--- a/test/client/unit/specs/store/getters/userGems.js
+++ b/test/client/unit/specs/store/getters/user/userGems.js
@@ -5,7 +5,7 @@ describe('userGems getter', () => {
expect(userGems({
state: {
user: {
- balance: 4.5,
+ data: {balance: 4.5},
},
},
})).to.equal(18);
diff --git a/test/client/unit/specs/store/store.js b/test/client/unit/specs/store/store.js
index 982ef7b5f5..53f9dd4a40 100644
--- a/test/client/unit/specs/store/store.js
+++ b/test/client/unit/specs/store/store.js
@@ -1,168 +1,8 @@
-import Vue from 'vue';
-import storeInjector from 'inject-loader?-vue!client/store';
-import { mapState, mapGetters, mapActions } from 'client/store';
-import { flattenAndNamespace } from 'client/store/helpers/internals';
+import generateStore from 'client/store';
+import Store from 'client/libs/store';
-describe('Store', () => {
- let injectedStore;
-
- beforeEach(() => {
- injectedStore = storeInjector({ // eslint-disable-line babel/new-cap
- './state': {
- name: 'test',
- },
- './getters': {
- computedName ({ state }) {
- return `${state.name} computed!`;
- },
- ...flattenAndNamespace({
- nested: {
- computedName ({ state }) {
- return `${state.name} computed!`;
- },
- },
- }),
- },
- './actions': {
- getName ({ state }, ...args) {
- return [state.name, ...args];
- },
- ...flattenAndNamespace({
- nested: {
- 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';
- });
-
- describe('getters', () => {
- 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!');
- });
-
- it('supports nested getters', () => {
- expect(injectedStore.getters['nested:computedName']).to.equal('test computed!');
- injectedStore.state.name = 'test updated';
- expect(injectedStore.getters['nested:computedName']).to.equal('test updated computed!');
- });
- });
-
- describe('actions', () => {
- it('can dispatch an action', () => {
- expect(injectedStore.dispatch('getName', 1, 2, 3)).to.deep.equal(['test', 1, 2, 3]);
- });
-
- it('can dispatch a nested action', () => {
- expect(injectedStore.dispatch('nested: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();
- },
- });
- });
-
- it('flattenAndNamespace', () => {
- let result = flattenAndNamespace({
- nested: {
- computed ({ state }, ...args) {
- return [state.name, ...args];
- },
- getName ({ state }, ...args) {
- return [state.name, ...args];
- },
- },
- nested2: {
- getName ({ state }, ...args) {
- return [state.name, ...args];
- },
- },
- });
-
- expect(Object.keys(result).length).to.equal(3);
- expect(Object.keys(result).sort()).to.deep.equal(['nested2:getName', 'nested:computed', 'nested:getName']);
- });
+describe('Application store', () => {
+ it('is an instance of Store', () => {
+ expect(generateStore()).to.be.an.instanceof(Store);
});
});
diff --git a/website/client/components/appHeader.vue b/website/client/components/appHeader.vue
index 96bad3a87b..382d199a53 100644
--- a/website/client/components/appHeader.vue
+++ b/website/client/components/appHeader.vue
@@ -1,6 +1,6 @@
#app-header.row
- avatar#header-avatar(:user="$store.state.user")
+ avatar#header-avatar(:user="user")
div
span.character-name {{user.profile.name}}
span.character-level Lvl {{user.stats.lvl}}
@@ -87,7 +87,7 @@
\ No newline at end of file
diff --git a/website/client/components/inventory/index.vue b/website/client/components/inventory/index.vue
index 5d9a4c0715..60e74d3ea4 100644
--- a/website/client/components/inventory/index.vue
+++ b/website/client/components/inventory/index.vue
@@ -7,8 +7,4 @@
router-link.nav-link(:to="{name: 'stable'}") {{ $t('stable') }}
.col-12
router-view
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/website/client/components/inventory/stable.vue b/website/client/components/inventory/stable.vue
index 8b1ed46d74..f055040cea 100644
--- a/website/client/components/inventory/stable.vue
+++ b/website/client/components/inventory/stable.vue
@@ -73,7 +73,7 @@
\ No newline at end of file
+
\ No newline at end of file
diff --git a/website/client/components/parentPage.vue b/website/client/components/parentPage.vue
index dd1a50aa98..1e0681ba50 100644
--- a/website/client/components/parentPage.vue
+++ b/website/client/components/parentPage.vue
@@ -2,8 +2,4 @@
.row
.col
router-view
-
-
-
+
\ No newline at end of file
diff --git a/website/client/components/social/guilds/discovery/index.vue b/website/client/components/social/guilds/discovery/index.vue
index e899fcf3b1..3102ade6b7 100644
--- a/website/client/components/social/guilds/discovery/index.vue
+++ b/website/client/components/social/guilds/discovery/index.vue
@@ -37,7 +37,6 @@
\ No newline at end of file
+
\ No newline at end of file
diff --git a/website/client/components/task.vue b/website/client/components/task.vue
index 0f5ca1451e..7256b4ba03 100644
--- a/website/client/components/task.vue
+++ b/website/client/components/task.vue
@@ -17,19 +17,17 @@ li
li(v-if="task.type === 'todo'") due date: {{task.date}}
li attribute {{task.attribute}}
li difficulty {{task.priority}}
- li tags {{taskTags}}
+ li tags {{getTagsFor(task)}}
\ No newline at end of file
diff --git a/website/client/components/userTasks.vue b/website/client/components/userTasks.vue
index 7f69b2d28a..6a19172b10 100644
--- a/website/client/components/userTasks.vue
+++ b/website/client/components/userTasks.vue
@@ -8,7 +8,7 @@
\ No newline at end of file
diff --git a/website/client/libs/asyncResource.js b/website/client/libs/asyncResource.js
new file mode 100644
index 0000000000..7d2ba8e394
--- /dev/null
+++ b/website/client/libs/asyncResource.js
@@ -0,0 +1,45 @@
+import get from 'lodash/get';
+import axios from 'axios';
+
+// Return an object used to describe, in the app state, an
+// async resource loaded from the server with its data and status.
+export function asyncResourceFactory () {
+ return {
+ loadingStatus: 'NOT_LOADED', // NOT_LOADED, LOADING, LOADED
+ data: null,
+ };
+}
+
+export function loadAsyncResource ({store, path, url, deserialize, forceLoad = false}) {
+ if (!store) throw new Error('"store" is required and must be the application store.');
+ if (!path) throw new Error('The path to the resource in the application state is required.');
+ if (!url) throw new Error('The resource\'s url on the server is required.');
+ if (!deserialize) throw new Error('A response deserialization function named must be passed as "deserialize".');
+
+ const resource = get(store.state, path);
+ if (!resource) throw new Error(`No resouce found at path "${path}".`);
+ const loadingStatus = resource.loadingStatus;
+
+ if (loadingStatus === 'LOADED' && !forceLoad) {
+ return Promise.resolve(resource);
+ } else if (loadingStatus === 'LOADING') {
+ return new Promise((resolve, reject) => {
+ const resourceWatcher = store.watch(state => get(state, `${path}.loadingStatus`), (newLoadingStatus) => {
+ resourceWatcher(); // remove the watcher
+ if (newLoadingStatus === 'LOADED') {
+ return resolve(resource);
+ } else {
+ return reject(); // TODO add reason?
+ }
+ });
+ });
+ } else if (loadingStatus === 'NOT_LOADED' || loadingStatus === 'LOADED' && forceLoad) {
+ return axios.get(url).then(response => { // TODO support more params
+ resource.loadingStatus = 'LOADED';
+ resource.data = deserialize(response);
+ return resource;
+ });
+ } else {
+ return Promise.reject(new Error(`Invalid loading status "${loadingStatus} for resource at "${path}".`));
+ }
+}
diff --git a/website/client/plugins/i18n.js b/website/client/libs/i18n.js
similarity index 61%
rename from website/client/plugins/i18n.js
rename to website/client/libs/i18n.js
index a24c5cc6b2..6f82caaa61 100644
--- a/website/client/plugins/i18n.js
+++ b/website/client/libs/i18n.js
@@ -1,10 +1,11 @@
-// Plugin to expose globally a '$t' method that calls common/i18n.t.
-// Can be used in templates.
+// Vue plugin to globally expose a '$t' method that calls common/i18n.t.
+// Can be anywhere inside vue as 'this.$t' or '$t' in templates.
+
+import i18n from 'common/script/i18n';
-import i18n from '../../common/script/i18n';
// Load all english translations
// TODO it's a workaround until proper translation loading works
-const context = require.context('../../common/locales/en', true, /\.(json)$/);
+const context = require.context('common/locales/en', true, /\.(json)$/);
const translations = {};
context.keys().forEach(filename => {
diff --git a/website/client/store/helpers/internals.js b/website/client/libs/store/helpers/internals.js
similarity index 100%
rename from website/client/store/helpers/internals.js
rename to website/client/libs/store/helpers/internals.js
diff --git a/website/client/store/helpers/public.js b/website/client/libs/store/helpers/public.js
similarity index 92%
rename from website/client/store/helpers/public.js
rename to website/client/libs/store/helpers/public.js
index 1569555daf..7b46aff7a7 100644
--- a/website/client/store/helpers/public.js
+++ b/website/client/libs/store/helpers/public.js
@@ -19,9 +19,11 @@ store implementation. mapMutations is not present because we do not use mutation
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.
+The code has been slightly changed to match our code style and to support nested paths.
*/
+import get from 'lodash/get';
+
function normalizeMap (map) {
return Array.isArray(map) ?
map.map(key => ({ key, val: key })) :
@@ -35,7 +37,7 @@ export function mapState (states) {
res[key] = function mappedState () {
return typeof val === 'function' ?
val.call(this, this.$store.state, this.$store.getters) :
- this.$store.state[val];
+ get(this.$store.state, val);
};
});
diff --git a/website/client/libs/store/index.js b/website/client/libs/store/index.js
new file mode 100644
index 0000000000..502c28828c
--- /dev/null
+++ b/website/client/libs/store/index.js
@@ -0,0 +1,83 @@
+import Vue from 'vue';
+
+// 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.
+
+export default class Store {
+ constructor ({state, getters, actions}) {
+ // Store actions
+ this._actions = actions;
+
+ // Store getters (computed properties), implemented as computed properties in the internal Vue VM
+ this.getters = {};
+
+ // 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(this);
+
+ Object.defineProperty(this.getters, key, {
+ get: () => this._vm[key],
+ });
+ });
+
+ // Setup internal Vue instance to make state and getters reactive
+ this._vm = new Vue({
+ data: { state },
+ computed: _computed,
+ });
+ }
+
+ // Return the store's state
+ get state () {
+ return this._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 = this._actions[type];
+
+ if (!action) throw new Error(`Action "${type}" not found.`);
+ return action(this, ...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 this._vm.$watch(() => getter(this.state), cb, options);
+ }
+
+ // Expose the store as this.$store in components
+ // Is automatically called when Vue.plugin(Store) is used
+ static install (_Vue) {
+ _Vue.mixin({
+ beforeCreate () {
+ const options = this.$options;
+ // store injection
+ if (options.store) {
+ this.$store = options.store;
+ } else if (options.parent && options.parent.$store) {
+ this.$store = options.parent.$store;
+ }
+ },
+ });
+ }
+}
+
+export {
+ mapState,
+ mapGetters,
+ mapActions,
+} from './helpers/public';
diff --git a/website/client/main.js b/website/client/main.js
index 13ba6611b8..c88af34fa2 100644
--- a/website/client/main.js
+++ b/website/client/main.js
@@ -6,9 +6,10 @@ import Vue from 'vue';
import axios from 'axios';
import AppComponent from './app';
import router from './router';
-import store from './store';
+import generateStore from './store';
+import StoreModule from './libs/store';
import './filters/registerGlobals';
-import i18n from './plugins/i18n';
+import i18n from './libs/i18n';
const IS_PRODUCTION = process.env.NODE_ENV === 'production'; // eslint-disable-line no-process-env
@@ -23,8 +24,9 @@ Vue.config.performance = !IS_PRODUCTION;
Vue.config.productionTip = IS_PRODUCTION;
Vue.use(i18n);
+Vue.use(StoreModule);
-// TODO just for the beginning
+// TODO just until we have proper authentication
let authSettings = localStorage.getItem('habit-mobile-settings');
if (authSettings) {
@@ -33,32 +35,35 @@ if (authSettings) {
axios.defaults.headers.common['x-api-key'] = authSettings.auth.apiToken;
}
-const app = new Vue({
+export default new Vue({
router,
+ store: generateStore(),
render: h => h(AppComponent),
+ beforeCreate () {
+ // Setup listener for title
+ this.$store.watch(state => state.title, (title) => {
+ document.title = title;
+ });
+
+ // Mount the app when user and tasks are loaded
+ const userDataWatcher = this.$store.watch(state => [state.user.data, state.tasks.data], ([user, tasks]) => {
+ if (user && user._id && Array.isArray(tasks)) {
+ userDataWatcher(); // remove the watcher
+ this.$mount('#app');
+ }
+ });
+
+ // Load the user and the user tasks
+ Promise.all([
+ this.$store.dispatch('user:fetch'),
+ this.$store.dispatch('tasks:fetchUserTasks'),
+ ]).catch((err) => {
+ console.error(err); // eslint-disable-line no-console
+ alert('Impossible to fetch user. Copy into localStorage a valid habit-mobile-settings object.');
+ });
+ },
mounted () { // Remove the loading screen when the app is mounted
let loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) document.body.removeChild(loadingScreen);
},
-});
-
-// Setup listener for title
-store.watch(state => state.title, (title) => {
- document.title = title;
-});
-
-// Mount the app when user and tasks are loaded
-let userDataWatcher = store.watch(state => [state.user, state.tasks], ([user, tasks]) => {
- if (user && user._id && Array.isArray(tasks)) {
- userDataWatcher(); // remove the watcher
- app.$mount('#app');
- }
-});
-
-// Load the user and the user tasks
-Promise.all([
- store.dispatch('user:fetch'),
- store.dispatch('tasks:fetchUserTasks'),
-]).catch(() => {
- alert('Impossible to fetch user. Copy into localStorage a valid habit-mobile-settings object.');
-});
+});
\ No newline at end of file
diff --git a/website/client/store/actions/guilds.js b/website/client/store/actions/guilds.js
deleted file mode 100644
index bab0d74bd5..0000000000
--- a/website/client/store/actions/guilds.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import axios from 'axios';
-
-export async function fetchAll (store) {
- let response = await axios.get('/api/v3/groups?type=publicGuilds');
- store.state.guilds = response.data.data;
-}
\ No newline at end of file
diff --git a/website/client/store/actions/index.js b/website/client/store/actions/index.js
index 4a43075408..a7244dc344 100644
--- a/website/client/store/actions/index.js
+++ b/website/client/store/actions/index.js
@@ -1,8 +1,7 @@
-import { flattenAndNamespace } from '../helpers/internals';
+import { flattenAndNamespace } from 'client/libs/store/helpers/internals';
import * as user from './user';
import * as tasks from './tasks';
-import * as guilds from './guilds';
// Actions should be named as 'actionName' and can be accessed as 'namespace:actionName'
// Example: fetch in user.js -> 'user:fetch'
@@ -10,7 +9,6 @@ import * as guilds from './guilds';
const actions = flattenAndNamespace({
user,
tasks,
- guilds,
});
export default actions;
\ No newline at end of file
diff --git a/website/client/store/actions/tasks.js b/website/client/store/actions/tasks.js
index 3fd777e8f8..d939810e3e 100644
--- a/website/client/store/actions/tasks.js
+++ b/website/client/store/actions/tasks.js
@@ -1,6 +1,13 @@
-import axios from 'axios';
+import { loadAsyncResource } from 'client/libs/asyncResource';
-export async function fetchUserTasks (store) {
- let response = await axios.get('/api/v3/tasks/user');
- store.state.tasks = response.data.data;
+export function fetchUserTasks (store, forceLoad = false) {
+ return loadAsyncResource({
+ store,
+ path: 'tasks',
+ url: '/api/v3/tasks/user',
+ deserialize (response) {
+ return response.data.data;
+ },
+ forceLoad,
+ });
}
\ No newline at end of file
diff --git a/website/client/store/actions/user.js b/website/client/store/actions/user.js
index ff151ddb57..03120727dd 100644
--- a/website/client/store/actions/user.js
+++ b/website/client/store/actions/user.js
@@ -1,6 +1,13 @@
-import axios from 'axios';
+import { loadAsyncResource } from 'client/libs/asyncResource';
-export async function fetch (store) { // eslint-disable-line no-shadow
- let response = await axios.get('/api/v3/user');
- store.state.user = response.data.data;
+export function fetch (store, forceLoad = false) { // eslint-disable-line no-shadow
+ return loadAsyncResource({
+ store,
+ path: 'user',
+ url: '/api/v3/user',
+ deserialize (response) {
+ return response.data.data;
+ },
+ forceLoad,
+ });
}
\ No newline at end of file
diff --git a/website/client/store/getters/index.js b/website/client/store/getters/index.js
index 6fe691d460..7a89a81187 100644
--- a/website/client/store/getters/index.js
+++ b/website/client/store/getters/index.js
@@ -1,11 +1,13 @@
-import { flattenAndNamespace } from '../helpers/internals';
+import { flattenAndNamespace } from 'client/libs/store/helpers/internals';
import * as user from './user';
+import * as tasks from './tasks';
// Getters should be named as 'getterName' and can be accessed as 'namespace:getterName'
// Example: gems in user.js -> 'user:gems'
const getters = flattenAndNamespace({
user,
+ tasks,
});
export default getters;
\ No newline at end of file
diff --git a/website/client/store/getters/tasks.js b/website/client/store/getters/tasks.js
new file mode 100644
index 0000000000..6e641c0708
--- /dev/null
+++ b/website/client/store/getters/tasks.js
@@ -0,0 +1,6 @@
+// Return all the tags belonging to an user task
+export function getTagsFor (store) {
+ return (task) => store.state.user.data.tags
+ .filter(tag => task.tags.indexOf(tag.id) !== -1)
+ .map(tag => tag.name);
+}
\ No newline at end of file
diff --git a/website/client/store/getters/user.js b/website/client/store/getters/user.js
index 645483d360..2950a5a36c 100644
--- a/website/client/store/getters/user.js
+++ b/website/client/store/getters/user.js
@@ -1,3 +1,3 @@
export function gems (store) {
- return store.state.user.balance * 4;
+ return store.state.user.data.balance * 4;
}
\ No newline at end of file
diff --git a/website/client/store/index.js b/website/client/store/index.js
index f4dfd92eb8..35728e9719 100644
--- a/website/client/store/index.js
+++ b/website/client/store/index.js
@@ -1,76 +1,25 @@
-import Vue from 'vue';
-import state from './state';
+import Store from 'client/libs/store';
+import deepFreeze from 'client/libs/deepFreeze';
+import content from 'common/script/content/index';
+import { asyncResourceFactory } from 'client/libs/asyncResource';
+
import actions from './actions';
import 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],
+// Export a function that generates the store and not the store directly
+// so that we can regenerate it multiple times for testing
+export default function () {
+ return new Store({
+ actions,
+ getters,
+ state: {
+ title: 'Habitica',
+ user: asyncResourceFactory(),
+ tasks: asyncResourceFactory(), // user tasks
+ // content data, frozen to prevent Vue from modifying it since it's static and never changes
+ // TODO apply freezing to the entire codebase (the server) and not only to the client side?
+ // NOTE this takes about 10-15ms on a fast computer
+ content: deepFreeze(content),
+ },
});
-});
-
-export default store;
-
-export {
- mapState,
- mapGetters,
- mapActions,
-} from './helpers/public';
-
-// Setup internal Vue instance to make state and getters reactive
-_vm = new Vue({
- data: { state },
- computed: _computed,
-});
-
-// Inject the store into all components as this.$store
-Vue.mixin({
- beforeCreate () {
- this.$store = store;
- },
-});
-
+}
\ No newline at end of file
diff --git a/website/client/store/state.js b/website/client/store/state.js
deleted file mode 100644
index 5939cf217c..0000000000
--- a/website/client/store/state.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import deepFreeze from '../libs/deepFreeze';
-import content from '../../common/script/content/index';
-
-const state = {
- title: 'Habitica',
- user: null,
- tasks: null, // user tasks
- guilds: null, // list of public guilds, not fetched initially
- // content data, frozen to prevent Vue from modifying it since it's static and never changes
- // TODO apply freezing to the entire codebase (the server) and not only to the client side?
- // NOTE this takes about 10-15ms on a fast computer
- content: deepFreeze(content),
-};
-
-export default state;
\ No newline at end of file