mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 14:47:53 +01:00
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:
151
test/client/unit/specs/libs/asyncResource.js
Normal file
151
test/client/unit/specs/libs/asyncResource.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
178
test/client/unit/specs/libs/store.js
Normal file
178
test/client/unit/specs/libs/store.js
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
const tasks = [{_id: 1}];
|
|
||||||
sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
|
|
||||||
|
|
||||||
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
const user = {_id: 1};
|
|
||||||
sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
|
|
||||||
|
|
||||||
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
16
test/client/unit/specs/store/getters/tasks/getTagsFor.js
Normal file
16
test/client/unit/specs/store/getters/tasks/getTagsFor.js
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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']);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -7,8 +7,4 @@
|
|||||||
router-link.nav-link(:to="{name: 'stable'}") {{ $t('stable') }}
|
router-link.nav-link(:to="{name: 'stable'}") {{ $t('stable') }}
|
||||||
.col-12
|
.col-12
|
||||||
router-view
|
router-view
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {};
|
|
||||||
</script>
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -3,8 +3,4 @@
|
|||||||
.col
|
.col
|
||||||
h2 Page
|
h2 Page
|
||||||
p {{ $route.path }}
|
p {{ $route.path }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default { };
|
|
||||||
</script>
|
|
||||||
@@ -2,8 +2,4 @@
|
|||||||
.row
|
.row
|
||||||
.col
|
.col
|
||||||
router-view
|
router-view
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default { };
|
|
||||||
</script>
|
|
||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,8 +7,4 @@
|
|||||||
router-link.nav-link(:to="{name: 'guildsDiscovery'}") {{ $t('guilds') }}
|
router-link.nav-link(:to="{name: 'guildsDiscovery'}") {{ $t('guilds') }}
|
||||||
.col-12
|
.col-12
|
||||||
router-view
|
router-view
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {};
|
|
||||||
</script>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
45
website/client/libs/asyncResource.js
Normal file
45
website/client/libs/asyncResource.js
Normal 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}".`));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 => {
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
83
website/client/libs/store/index.js
Normal file
83
website/client/libs/store/index.js
Normal 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';
|
||||||
@@ -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.');
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
6
website/client/store/getters/tasks.js
Normal file
6
website/client/store/getters/tasks.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
Reference in New Issue
Block a user