Skip to content

Commit 5321217

Browse files
committed
✨ Add helper functions
1 parent 3572e07 commit 5321217

File tree

9 files changed

+305
-17
lines changed

9 files changed

+305
-17
lines changed

README.md

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
[Storeon] is a tiny event-based Redux-like state manager without dependencies. `@storeon/vue` package helps to connect store with [Vue] to provide a better performance and developer experience while remaining so tiny.
99

10-
- **Size**. 136 bytes (+ Storeon itself) instead of ~3kB of [Vuex] (minified and gzipped).
10+
- **Size**. 160 bytes (+ Storeon itself) instead of ~3kB of [Vuex] (minified and gzipped).
1111
- **Ecosystem**. Many additional [tools] can be combined with a store.
1212
- **Speed**. It tracks what parts of state were changed and re-renders only components based on the changes.
1313

@@ -97,15 +97,74 @@ export default {
9797
</script>
9898
```
9999

100+
### The `mapState` Helper
101+
102+
When a component needs to make use of multiple store state properties, declaring all these computed properties can get repetitive and verbose. To deal with this we can make use of the `mapState` helper which generates computed getter functions for us, saving us some keystrokes:
103+
104+
```js
105+
import { mapState } from '@storeon/vue/helpers'
106+
107+
export default {
108+
computed: mapState({
109+
// arrow functions can make the code very succinct!
110+
count: state => state.count,
111+
// passing the string value 'count' is same as `state => state.count`
112+
countAlias: 'count',
113+
// to access local state with `this`, a normal function must be used
114+
countPlusLocalState (state) {
115+
return state.count + this.localCount
116+
}
117+
})
118+
}
119+
```
120+
121+
We can also pass a string array to `mapState` when the name of a mapped computed property is the same as a state sub tree name.
122+
123+
```js
124+
import { mapState } from '@storeon/vue/helpers'
125+
126+
export default {
127+
computed: mapState([
128+
// map this.count to storeon.state.count
129+
'count'
130+
])
131+
}
132+
```
133+
134+
### The `mapDispatch` Helper
135+
136+
You can dispatch actions in components with `this.$storeon.dispatch('xxx')`, or use the `mapDispatch` helper which maps component methods to `store.dispatch` calls:
137+
138+
```js
139+
import { mapDispatch } from '@storeon/vue/helpers'
140+
141+
export default {
142+
methods: {
143+
...mapDispatch([
144+
// map `this.inc()` to `this.$storeon.dispatch('increment')`
145+
'inc',
146+
// map `this.incBy(amount)` to `this.$storeon.dispatch('incBy', amount)`
147+
'incBy'
148+
]),
149+
...mapDispatch({
150+
// map `this.add()` to `this.$storeon.dispatch('inc')`
151+
add: 'inc'
152+
})
153+
}
154+
}
155+
156+
```
157+
100158
## Using with TypeScript
101159

102-
Plugin add to Vue’s global/instance properties and component options. In these cases, type declarations are needed to make plugins compile in TypeScript. We can declare an instance property `$storeon` and `$state` with type `StoreonStore<State, Events>`. You can also declare component options `store`:
160+
Plugin add to Vue’s global/instance properties and component options. In these cases, type declarations are needed to make plugins compile in TypeScript. We can declare an instance property `$storeon` with type `StoreonStore<State, Events>`. You can also declare component options `store`:
103161

104162
#### `typing.d.ts`
105163

106164
```ts
107165
import Vue, { ComponentOptions } from 'vue'
108166
import { StoreonStore } from 'storeon'
167+
import { StoreonVueStore } from '@storeon/vue'
109168
import { State, Events } from './store'
110169

111170
declare module 'vue/types/options' {
@@ -116,8 +175,20 @@ declare module 'vue/types/options' {
116175

117176
declare module 'vue/types/vue' {
118177
interface Vue {
119-
$storeon: StoreonStore<State, Events>;
120-
$state: State;
178+
$storeon: StoreonVueStore<State, Events>;
121179
}
122180
}
123181
```
182+
183+
To let TypeScript properly infer types inside Vue component options, you need to define components with `Vue.component` or `Vue.extend`:
184+
185+
```diff
186+
-export default {
187+
+export default Vue.extend({
188+
methods: {
189+
inc() {
190+
this.$storeon.dispatch('inc')
191+
}
192+
}
193+
};
194+
```

helpers/index.d.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Vue from 'vue';
2+
import { StoreonDispatch } from 'storeon';
3+
4+
type Computed = () => any;
5+
type ActionMethod = (...args: any[]) => Promise<any>;
6+
type InlineComputed<T extends Function> = T extends (...args: any[]) => infer R ? () => R : never
7+
type InlineMethod<T extends (fn: any, ...args: any[]) => any> = T extends (fn: any, ...args: infer Args) => infer R ? (...args: Args) => R : never
8+
type CustomVue = Vue & Record<string, any>;
9+
10+
interface Mapper<R> {
11+
<Key extends string>(map: Key[]): { [K in Key]: R };
12+
<Map extends Record<string, string>>(map: Map): { [K in keyof Map]: R };
13+
}
14+
15+
interface MapperForState {
16+
<S, Map extends Record<string, (this: CustomVue, state: S, getters: any) => any> = {}>(
17+
map: Map
18+
): { [K in keyof Map]: InlineComputed<Map[K]> };
19+
}
20+
21+
interface MapperForAction {
22+
<Map extends Record<string, (this: CustomVue, dispatch: StoreonDispatch<any>, ...args: any[]) => any>>(
23+
map: Map
24+
): { [K in keyof Map]: InlineMethod<Map[K]> };
25+
}
26+
27+
export declare const mapState: Mapper<Computed> & MapperForState;
28+
export declare const mapActions: Mapper<ActionMethod> & MapperForAction;

helpers/index.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
const mapState = states => {
2+
let res = {}
3+
if (process.env.NODE_ENV !== 'production' && !isValidMap(states)) {
4+
console.error('Mapper parameter must be either an Array or an Object')
5+
}
6+
normalizeMap(states).forEach(({ key, val }) => {
7+
res[key] = function () {
8+
let state = this.$storeon.state
9+
10+
return typeof val === 'function' ? val.call(this, state) : state[val]
11+
}
12+
})
13+
return res
14+
}
15+
16+
const mapDispatch = events => {
17+
let res = {}
18+
if (process.env.NODE_ENV !== 'production' && !isValidMap(events)) {
19+
console.error('Mapper parameter must be either an Array or an Object')
20+
}
21+
normalizeMap(events).forEach(({ key, val }) => {
22+
res[key] = function (...args) {
23+
let dispatch = this.$storeon.dispatch
24+
25+
if (typeof val === 'function') {
26+
return val.apply(this, [dispatch].concat(args))
27+
} else {
28+
return dispatch.apply(this.$storeon, [val].concat(args))
29+
}
30+
}
31+
})
32+
return res
33+
}
34+
35+
module.exports = { mapState, mapDispatch }
36+
37+
function normalizeMap (map) {
38+
if (!isValidMap(map)) {
39+
return []
40+
}
41+
42+
if (Array.isArray(map)) {
43+
return map.map(key => ({ key, val: key }))
44+
} else {
45+
return Object.keys(map).map(key => ({ key, val: map[key] }))
46+
}
47+
}
48+
49+
function isObject (obj) {
50+
return obj !== null && typeof obj === 'object'
51+
}
52+
53+
function isValidMap (map) {
54+
return Array.isArray(map) || isObject(map)
55+
}

index.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
import _Vue from 'vue'
2+
import { StoreonStore } from 'storeon'
23

34
export declare function StoreonVue(Vue: typeof _Vue): void
5+
6+
export declare type StoreonVueStore<State = unknown, Events = any> =
7+
StoreonStore<State, Events> & { state: State }

index.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ function StoreonVue (Vue) {
66

77
if (store) {
88
this.$storeon = store
9-
this.$state = Vue.observable(store.get())
9+
this.$storeon.state = Vue.observable(store.get())
1010

1111
this._unbind = store.on('@changed', (_, changed) => {
12-
Object.assign(this.$state, changed)
12+
Object.assign(this.$storeon.state, changed)
1313
})
1414
} else if (parent && parent.$storeon) {
1515
this.$storeon = parent.$storeon
16-
this.$state = parent.$state
1716
}
1817
},
1918
beforeDestroy () {

package.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@storeon/vue",
33
"version": "0.5.0",
4-
"description": "A tiny (150 bytes) connector for Storeon and Vue",
4+
"description": "A tiny (160 bytes) connector for Storeon and Vue",
55
"main": "index.js",
66
"types": "index.d.ts",
77
"scripts": {
@@ -19,6 +19,10 @@
1919
"url": "https://github.com/storeon/vue/issues"
2020
},
2121
"homepage": "https://github.com/storeon/vue#readme",
22+
"files": [
23+
"index.d.ts",
24+
"helpers/"
25+
],
2226
"peerDependencies": {
2327
"storeon": "^2.0.0",
2428
"vue": "^2.6.0"
@@ -70,7 +74,15 @@
7074
"import": {
7175
"index.js": "{ StoreonVue }"
7276
},
73-
"limit": "136 B"
77+
"limit": "160 B"
78+
},
79+
{
80+
"name": "core + helpers",
81+
"import": {
82+
"index.js": "{ StoreonVue }",
83+
"helpers/index.js": "{ mapState, mapDispatch }"
84+
},
85+
"limit": "342 B"
7486
}
7587
],
7688
"eslintConfig": {

test/helpers.test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
const Vue = require('vue')
2+
3+
const { createStore } = require('./utils')
4+
const { StoreonVue } = require('../index')
5+
const { mapState, mapDispatch } = require('../helpers')
6+
7+
it('mapState (array)', () => {
8+
let store = createStore()
9+
Vue.use(StoreonVue)
10+
let vm = new Vue({
11+
store,
12+
computed: mapState(['count'])
13+
})
14+
expect(vm.count).toBe(0)
15+
store.dispatch('inc')
16+
expect(vm.count).toBe(1)
17+
})
18+
19+
it('mapState (object)', () => {
20+
let store = createStore()
21+
Vue.use(StoreonVue)
22+
let vm = new Vue({
23+
store,
24+
computed: mapState({
25+
a: state => {
26+
return state.count + 1
27+
}
28+
})
29+
})
30+
expect(vm.a).toBe(1)
31+
store.dispatch('inc')
32+
expect(vm.a).toBe(2)
33+
})
34+
35+
it('mapState (with undefined states)', () => {
36+
jest.spyOn(console, 'error')
37+
let store = createStore()
38+
Vue.use(StoreonVue)
39+
let vm = new Vue({
40+
store,
41+
computed: mapState('foo')
42+
})
43+
expect(vm.count).toBeUndefined()
44+
expect(console.error).toHaveBeenCalledWith(
45+
'Mapper parameter must be either an Array or an Object'
46+
)
47+
})
48+
49+
it('mapDispatch (array)', () => {
50+
let store = createStore()
51+
jest.spyOn(store, 'dispatch')
52+
Vue.use(StoreonVue)
53+
let vm = new Vue({
54+
store,
55+
methods: mapDispatch(['inc', 'foo/set'])
56+
})
57+
vm.inc()
58+
expect(store.dispatch).toHaveBeenCalledWith('inc')
59+
expect(store.dispatch).not.toHaveBeenCalledWith('foo/set')
60+
vm['foo/set']()
61+
expect(store.dispatch).toHaveBeenCalledWith('foo/set')
62+
})
63+
64+
it('mapDispatch (object)', () => {
65+
let store = createStore()
66+
jest.spyOn(store, 'dispatch')
67+
Vue.use(StoreonVue)
68+
let vm = new Vue({
69+
store,
70+
methods: mapDispatch({
71+
foo: 'inc',
72+
bar: 'foo/set'
73+
})
74+
})
75+
vm.bar('bar')
76+
expect(store.dispatch).toHaveBeenCalledWith('foo/set', 'bar')
77+
expect(store.dispatch).not.toHaveBeenCalledWith('inc')
78+
expect(vm.$storeon.state.foo).toBe('bar')
79+
vm.foo()
80+
expect(store.dispatch).toHaveBeenCalledWith('inc')
81+
})
82+
83+
it('mapDispatch (function)', () => {
84+
let store = createStore()
85+
jest.spyOn(store, 'dispatch')
86+
Vue.use(StoreonVue)
87+
let vm = new Vue({
88+
store,
89+
methods: mapDispatch({
90+
foo (dispatch, arg) {
91+
dispatch('a', arg + 'bar')
92+
}
93+
})
94+
})
95+
vm.foo('foo')
96+
expect(store.dispatch.mock.calls[0][1]).toBe('foobar')
97+
})
98+
99+
it('mapDispatch (with undefined actions)', () => {
100+
jest.spyOn(console, 'error')
101+
let store = createStore()
102+
jest.spyOn(store, 'dispatch')
103+
Vue.use(StoreonVue)
104+
let vm = new Vue({
105+
store,
106+
methods: mapDispatch('inc')
107+
})
108+
expect(vm.count).toBeUndefined()
109+
expect(store.dispatch).not.toHaveBeenCalled()
110+
expect(console.error).toHaveBeenCalledWith(
111+
'Mapper parameter must be either an Array or an Object'
112+
)
113+
})

test/index.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const { mountComponent, ComponentWithChild, Child } = require('./utils')
44

55
it('should render with initial state', () => {
66
let { store, wrapper } = mountComponent()
7-
expect(wrapper.vm.$state).toEqual(store.get())
7+
expect(wrapper.vm.$storeon.state).toEqual(store.get())
88
})
99

1010
it('should provide Storeon', () => {
@@ -21,13 +21,13 @@ it('should unsubscribe only on root destroy', () => {
2121
let child = wrapper.find(Child)
2222

2323
store.dispatch('inc')
24-
expect(wrapper.vm.$state.count).toBe(1)
24+
expect(wrapper.vm.$storeon.state.count).toBe(1)
2525
child.destroy()
2626
store.dispatch('inc')
27-
expect(wrapper.vm.$state.count).toBe(2)
27+
expect(wrapper.vm.$storeon.state.count).toBe(2)
2828
wrapper.destroy()
2929
store.dispatch('inc')
30-
expect(wrapper.vm.$state.count).toBe(2)
30+
expect(wrapper.vm.$storeon.state.count).toBe(2)
3131
})
3232

3333
it('should update view on dispatch', async () => {

0 commit comments

Comments
 (0)