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
This commit is contained in:
Matteo Pagliazzi
2017-03-18 18:33:08 +01:00
committed by GitHub
parent 03d6c459bf
commit d9d7c69432
37 changed files with 694 additions and 384 deletions

View File

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

View File

@@ -1,7 +1,7 @@
import deepFreeze from 'client/libs/deepFreeze'; import deepFreeze from 'client/libs/deepFreeze';
describe('deepFreeze', () => { describe('deepFreeze', () => {
it('works as expected', () => { it('deeply freezes an object', () => {
let obj = { let obj = {
a: 1, a: 1,
b () { b () {

View File

@@ -1,10 +1,10 @@
import i18n from 'client/plugins/i18n'; import i18n from 'client/libs/i18n';
import commoni18n from 'common/script/i18n'; import commoni18n from 'common/script/i18n';
import Vue from 'vue'; import Vue from 'vue';
describe('i18n plugin', () => { describe('i18n plugin', () => {
before(() => { before(() => {
i18n.install(Vue); Vue.use(i18n);
}); });
it('adds $t to Vue.prototype', () => { it('adds $t to Vue.prototype', () => {

View File

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

View File

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

View File

@@ -1,14 +1,54 @@
import { fetchUserTasks } from 'client/store/actions/tasks';
import axios from 'axios'; import axios from 'axios';
import store from 'client/store'; import generateStore from 'client/store';
describe('tasks actions', () => { describe('tasks actions', () => {
it('fetchUserTasks', async () => { let store;
beforeEach(() => {
store = generateStore();
});
describe('fetchUserTasks', () => {
it('fetches user tasks', async () => {
expect(store.state.tasks.loadingStatus).to.equal('NOT_LOADED');
const tasks = [{_id: 1}]; const tasks = [{_id: 1}];
sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}})); sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
await fetchUserTasks(store); await store.dispatch('tasks:fetchUserTasks');
expect(store.state.tasks).to.equal(tasks); 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');
});
}); });
}); });

View File

@@ -1,14 +1,54 @@
import { fetch as fetchUser } from 'client/store/actions/user';
import axios from 'axios'; import axios from 'axios';
import store from 'client/store'; import generateStore from 'client/store';
describe('user actions', () => { describe('tasks actions', () => {
it('fetch', async () => { let store;
beforeEach(() => {
store = generateStore();
});
describe('fetch', () => {
it('loads the user', async () => {
expect(store.state.user.loadingStatus).to.equal('NOT_LOADED');
const user = {_id: 1}; const user = {_id: 1};
sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}})); sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
await fetchUser(store); await store.dispatch('user:fetch');
expect(store.state.user).to.equal(user); 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');
});
}); });
}); });

View File

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

View File

@@ -5,7 +5,7 @@ describe('userGems getter', () => {
expect(userGems({ expect(userGems({
state: { state: {
user: { user: {
balance: 4.5, data: {balance: 4.5},
}, },
}, },
})).to.equal(18); })).to.equal(18);

View File

@@ -1,168 +1,8 @@
import Vue from 'vue'; import generateStore from 'client/store';
import storeInjector from 'inject-loader?-vue!client/store'; import Store from 'client/libs/store';
import { mapState, mapGetters, mapActions } from 'client/store';
import { flattenAndNamespace } from 'client/store/helpers/internals';
describe('Store', () => { describe('Application store', () => {
let injectedStore; it('is an instance of Store', () => {
expect(generateStore()).to.be.an.instanceof(Store);
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']);
});
}); });
}); });

View File

@@ -1,6 +1,6 @@
<template lang="pug"> <template lang="pug">
#app-header.row #app-header.row
avatar#header-avatar(:user="$store.state.user") avatar#header-avatar(:user="user")
div div
span.character-name {{user.profile.name}} span.character-name {{user.profile.name}}
span.character-level Lvl {{user.stats.lvl}} span.character-level Lvl {{user.stats.lvl}}
@@ -87,7 +87,7 @@
<script> <script>
import Avatar from './avatar'; import Avatar from './avatar';
import { mapState } from '../store'; import { mapState } from 'client/libs/store';
import { toNextLevel } from '../../common/script/statHelpers'; import { toNextLevel } from '../../common/script/statHelpers';
import { MAX_HEALTH as maxHealth } from '../../common/script/constants'; import { MAX_HEALTH as maxHealth } from '../../common/script/constants';
@@ -108,7 +108,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['user']), ...mapState({user: 'user.data'}),
maxMP () { maxMP () {
return statsComputed(this.user).maxMP; return statsComputed(this.user).maxMP;
}, },

View File

@@ -130,14 +130,14 @@ $active-purple: #6133b4;
</style> </style>
<script> <script>
import { mapState, mapGetters } from '../store'; import { mapState, mapGetters } from 'client/libs/store';
export default { export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
userGems: 'user:gems', userGems: 'user:gems',
}), }),
...mapState(['user']), ...mapState({user: 'user.data'}),
}, },
}; };
</script> </script>

View File

@@ -8,7 +8,3 @@
.col-12 .col-12
router-view router-view
</template> </template>
<script>
export default {};
</script>

View File

@@ -73,7 +73,7 @@
</style> </style>
<script> <script>
import { mapState } from '../../store'; import { mapState } from 'client/libs/store';
import each from 'lodash/each'; import each from 'lodash/each';
export default { export default {

View File

@@ -4,7 +4,3 @@
h2 Page h2 Page
p {{ $route.path }} p {{ $route.path }}
</template> </template>
<script>
export default { };
</script>

View File

@@ -3,7 +3,3 @@
.col .col
router-view router-view
</template> </template>
<script>
export default { };
</script>

View File

@@ -37,7 +37,6 @@
<script> <script>
import axios from 'axios'; import axios from 'axios';
import MugenScroll from 'vue-mugen-scroll'; import MugenScroll from 'vue-mugen-scroll';
import { mapState } from 'client/store';
import PublicGuildItem from './publicGuildItem'; import PublicGuildItem from './publicGuildItem';
import { GUILDS_PER_PAGE } from 'common/script/constants'; import { GUILDS_PER_PAGE } from 'common/script/constants';
@@ -51,9 +50,6 @@ export default {
guilds: [], guilds: [],
}; };
}, },
computed: {
...mapState(['guilds']),
},
created () { created () {
this.fetchGuilds(); this.fetchGuilds();
}, },

View File

@@ -15,14 +15,14 @@
</style> </style>
<script> <script>
import { mapState } from '../../../../store'; import { mapState } from 'client/libs/store';
import groupUtilities from '../../../../mixins/groupsUtilities'; import groupUtilities from 'client/mixins/groupsUtilities';
export default { export default {
mixins: [groupUtilities], mixins: [groupUtilities],
props: ['guild'], props: ['guild'],
computed: { computed: {
...mapState(['user']), ...mapState({user: 'user.data'}),
isMember () { isMember () {
return this.isMemberOfGroup(this.user, this.guild); return this.isMemberOfGroup(this.user, this.guild);
}, },

View File

@@ -33,8 +33,8 @@
</style> </style>
<script> <script>
import axios from 'axios'; import axios from 'axios';
import groupUtilities from '../../../mixins/groupsUtilities'; import groupUtilities from 'client/mixins/groupsUtilities';
import { mapState } from '../../../store'; import { mapState } from 'client/libs/store';
export default { export default {
mixins: [groupUtilities], mixins: [groupUtilities],
@@ -45,7 +45,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['user']), ...mapState({user: 'user.data'}),
isMember () { isMember () {
return this.isMemberOfGroup(this.user, this.guild); return this.isMemberOfGroup(this.user, this.guild);
}, },

View File

@@ -8,7 +8,3 @@
.col-12 .col-12
router-view router-view
</template> </template>
<script>
export default {};
</script>

View File

@@ -17,19 +17,17 @@ li
li(v-if="task.type === 'todo'") due date: {{task.date}} li(v-if="task.type === 'todo'") due date: {{task.date}}
li attribute {{task.attribute}} li attribute {{task.attribute}}
li difficulty {{task.priority}} li difficulty {{task.priority}}
li tags {{taskTags}} li tags {{getTagsFor(task)}}
</template> </template>
<script> <script>
import { mapState, mapGetters } from 'client/libs/store';
export default { export default {
props: ['task'], props: ['task'],
computed: { computed: {
taskTags () { ...mapState({user: 'user.data'}),
let taskTags = this.task.tags; ...mapGetters({getTagsFor: 'tasks:getTagsFor'}),
return this.$store.state.user.tags
.filter(tag => taskTags.indexOf(tag.id) !== -1)
.map(tag => tag.name);
},
}, },
}; };
</script> </script>

View File

@@ -8,7 +8,7 @@
<script> <script>
import Task from './task'; import Task from './task';
import { mapState } from '../store'; import { mapState } from 'client/libs/store';
export default { export default {
components: { components: {
@@ -20,7 +20,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['tasks']), ...mapState({tasks: 'tasks.data'}),
}, },
}; };
</script> </script>

View File

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

View File

@@ -1,10 +1,11 @@
// Plugin to expose globally a '$t' method that calls common/i18n.t. // Vue plugin to globally expose a '$t' method that calls common/i18n.t.
// Can be used in templates. // 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 // Load all english translations
// TODO it's a workaround until proper translation loading works // 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 = {}; const translations = {};
context.keys().forEach(filename => { context.keys().forEach(filename => {

View File

@@ -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 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) { function normalizeMap (map) {
return Array.isArray(map) ? return Array.isArray(map) ?
map.map(key => ({ key, val: key })) : map.map(key => ({ key, val: key })) :
@@ -35,7 +37,7 @@ export function mapState (states) {
res[key] = function mappedState () { res[key] = function mappedState () {
return typeof val === 'function' ? return typeof val === 'function' ?
val.call(this, this.$store.state, this.$store.getters) : val.call(this, this.$store.state, this.$store.getters) :
this.$store.state[val]; get(this.$store.state, val);
}; };
}); });

View File

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

View File

@@ -6,9 +6,10 @@ import Vue from 'vue';
import axios from 'axios'; import axios from 'axios';
import AppComponent from './app'; import AppComponent from './app';
import router from './router'; import router from './router';
import store from './store'; import generateStore from './store';
import StoreModule from './libs/store';
import './filters/registerGlobals'; 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 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.config.productionTip = IS_PRODUCTION;
Vue.use(i18n); 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'); let authSettings = localStorage.getItem('habit-mobile-settings');
if (authSettings) { if (authSettings) {
@@ -33,32 +35,35 @@ if (authSettings) {
axios.defaults.headers.common['x-api-key'] = authSettings.auth.apiToken; axios.defaults.headers.common['x-api-key'] = authSettings.auth.apiToken;
} }
const app = new Vue({ export default new Vue({
router, router,
store: generateStore(),
render: h => h(AppComponent), 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 mounted () { // Remove the loading screen when the app is mounted
let loadingScreen = document.getElementById('loading-screen'); let loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) document.body.removeChild(loadingScreen); 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.');
});

View File

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

View File

@@ -1,8 +1,7 @@
import { flattenAndNamespace } from '../helpers/internals'; import { flattenAndNamespace } from 'client/libs/store/helpers/internals';
import * as user from './user'; import * as user from './user';
import * as tasks from './tasks'; import * as tasks from './tasks';
import * as guilds from './guilds';
// Actions should be named as 'actionName' and can be accessed as 'namespace:actionName' // Actions should be named as 'actionName' and can be accessed as 'namespace:actionName'
// Example: fetch in user.js -> 'user:fetch' // Example: fetch in user.js -> 'user:fetch'
@@ -10,7 +9,6 @@ import * as guilds from './guilds';
const actions = flattenAndNamespace({ const actions = flattenAndNamespace({
user, user,
tasks, tasks,
guilds,
}); });
export default actions; export default actions;

View File

@@ -1,6 +1,13 @@
import axios from 'axios'; import { loadAsyncResource } from 'client/libs/asyncResource';
export async function fetchUserTasks (store) { export function fetchUserTasks (store, forceLoad = false) {
let response = await axios.get('/api/v3/tasks/user'); return loadAsyncResource({
store.state.tasks = response.data.data; store,
path: 'tasks',
url: '/api/v3/tasks/user',
deserialize (response) {
return response.data.data;
},
forceLoad,
});
} }

View File

@@ -1,6 +1,13 @@
import axios from 'axios'; import { loadAsyncResource } from 'client/libs/asyncResource';
export async function fetch (store) { // eslint-disable-line no-shadow export function fetch (store, forceLoad = false) { // eslint-disable-line no-shadow
let response = await axios.get('/api/v3/user'); return loadAsyncResource({
store.state.user = response.data.data; store,
path: 'user',
url: '/api/v3/user',
deserialize (response) {
return response.data.data;
},
forceLoad,
});
} }

View File

@@ -1,11 +1,13 @@
import { flattenAndNamespace } from '../helpers/internals'; import { flattenAndNamespace } from 'client/libs/store/helpers/internals';
import * as user from './user'; import * as user from './user';
import * as tasks from './tasks';
// Getters should be named as 'getterName' and can be accessed as 'namespace:getterName' // Getters should be named as 'getterName' and can be accessed as 'namespace:getterName'
// Example: gems in user.js -> 'user:gems' // Example: gems in user.js -> 'user:gems'
const getters = flattenAndNamespace({ const getters = flattenAndNamespace({
user, user,
tasks,
}); });
export default getters; export default getters;

View File

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

View File

@@ -1,3 +1,3 @@
export function gems (store) { export function gems (store) {
return store.state.user.balance * 4; return store.state.user.data.balance * 4;
} }

View File

@@ -1,76 +1,25 @@
import Vue from 'vue'; import Store from 'client/libs/store';
import state from './state'; import deepFreeze from 'client/libs/deepFreeze';
import content from 'common/script/content/index';
import { asyncResourceFactory } from 'client/libs/asyncResource';
import actions from './actions'; import actions from './actions';
import getters from './getters'; import getters from './getters';
// Central application store for Habitica // Export a function that generates the store and not the store directly
// Heavily inspired to Vuex (https://github.com/vuejs/vuex) with a very // so that we can regenerate it multiple times for testing
// similar internal implementation (thanks!), main difference is the absence of mutations. export default function () {
return new Store({
// Create a Vue instance (defined below) detatched from any DOM element to handle app data actions,
let _vm; getters,
state: {
// The actual store interface title: 'Habitica',
const store = { user: asyncResourceFactory(),
// App wide computed properties, calculated as computed properties in the internal VM tasks: asyncResourceFactory(), // user tasks
getters: {}, // content data, frozen to prevent Vue from modifying it since it's static and never changes
// Return the store's state // TODO apply freezing to the entire codebase (the server) and not only to the client side?
get state () { // NOTE this takes about 10-15ms on a fast computer
return _vm.$data.state; content: deepFreeze(content),
}, },
// 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 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;
},
});

View File

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