From d9d7c6943200112c47f108e6a9a176ef174c876f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 18 Mar 2017 18:33:08 +0100 Subject: [PATCH] Client: async resources, make store reusable, move plugins and add getTaskFor getter (#8575) Add library to manage async resource Make Store reusable for easier testing Move plugin to libs Add getTaskFor getter with tests --- test/client/unit/specs/libs/asyncResource.js | 151 +++++++++++++++ test/client/unit/specs/libs/deepFreeze.js | 2 +- .../unit/specs/{plugins => libs}/i18n.js | 4 +- test/client/unit/specs/libs/store.js | 178 ++++++++++++++++++ .../client/unit/specs/store/actions/guilds.js | 17 -- test/client/unit/specs/store/actions/tasks.js | 54 +++++- test/client/unit/specs/store/actions/user.js | 56 +++++- .../specs/store/getters/tasks/getTagsFor.js | 16 ++ .../store/getters/{ => user}/userGems.js | 2 +- test/client/unit/specs/store/store.js | 170 +---------------- website/client/components/appHeader.vue | 6 +- website/client/components/appMenu.vue | 4 +- website/client/components/inventory/index.vue | 6 +- .../client/components/inventory/stable.vue | 2 +- website/client/components/page.vue | 6 +- website/client/components/parentPage.vue | 6 +- .../social/guilds/discovery/index.vue | 4 - .../guilds/discovery/publicGuildItem.vue | 6 +- .../client/components/social/guilds/guild.vue | 6 +- website/client/components/social/index.vue | 6 +- website/client/components/task.vue | 12 +- website/client/components/userTasks.vue | 4 +- website/client/libs/asyncResource.js | 45 +++++ website/client/{plugins => libs}/i18n.js | 9 +- .../{ => libs}/store/helpers/internals.js | 0 .../client/{ => libs}/store/helpers/public.js | 6 +- website/client/libs/store/index.js | 83 ++++++++ website/client/main.js | 57 +++--- website/client/store/actions/guilds.js | 6 - website/client/store/actions/index.js | 4 +- website/client/store/actions/tasks.js | 15 +- website/client/store/actions/user.js | 15 +- website/client/store/getters/index.js | 4 +- website/client/store/getters/tasks.js | 6 + website/client/store/getters/user.js | 2 +- website/client/store/index.js | 93 +++------ website/client/store/state.js | 15 -- 37 files changed, 694 insertions(+), 384 deletions(-) create mode 100644 test/client/unit/specs/libs/asyncResource.js rename test/client/unit/specs/{plugins => libs}/i18n.js (87%) create mode 100644 test/client/unit/specs/libs/store.js delete mode 100644 test/client/unit/specs/store/actions/guilds.js create mode 100644 test/client/unit/specs/store/getters/tasks/getTagsFor.js rename test/client/unit/specs/store/getters/{ => user}/userGems.js (88%) create mode 100644 website/client/libs/asyncResource.js rename website/client/{plugins => libs}/i18n.js (61%) rename website/client/{ => libs}/store/helpers/internals.js (100%) rename website/client/{ => libs}/store/helpers/public.js (92%) create mode 100644 website/client/libs/store/index.js delete mode 100644 website/client/store/actions/guilds.js create mode 100644 website/client/store/getters/tasks.js delete mode 100644 website/client/store/state.js 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 @@ - - \ 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