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';
describe('deepFreeze', () => {
it('works as expected', () => {
it('deeply freezes an object', () => {
let obj = {
a: 1,
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 Vue from 'vue';
describe('i18n plugin', () => {
before(() => {
i18n.install(Vue);
Vue.use(i18n);
});
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 store from 'client/store';
import generateStore from 'client/store';
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}];
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 store from 'client/store';
import generateStore from 'client/store';
describe('user actions', () => {
it('fetch', async () => {
describe('tasks actions', () => {
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};
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({
state: {
user: {
balance: 4.5,
data: {balance: 4.5},
},
},
})).to.equal(18);

View File

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

View File

@@ -1,6 +1,6 @@
<template lang="pug">
#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 @@
<script>
import Avatar from './avatar';
import { mapState } from '../store';
import { mapState } from 'client/libs/store';
import { toNextLevel } from '../../common/script/statHelpers';
import { MAX_HEALTH as maxHealth } from '../../common/script/constants';
@@ -108,7 +108,7 @@ export default {
};
},
computed: {
...mapState(['user']),
...mapState({user: 'user.data'}),
maxMP () {
return statsComputed(this.user).maxMP;
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)}}
</template>
<script>
import { mapState, mapGetters } from 'client/libs/store';
export default {
props: ['task'],
computed: {
taskTags () {
let taskTags = this.task.tags;
return this.$store.state.user.tags
.filter(tag => taskTags.indexOf(tag.id) !== -1)
.map(tag => tag.name);
},
...mapState({user: 'user.data'}),
...mapGetters({getTagsFor: 'tasks:getTagsFor'}),
},
};
</script>

View File

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

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

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

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

View File

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

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

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

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) {
return store.state.user.balance * 4;
return store.state.user.data.balance * 4;
}

View File

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