From 12159d12a0e09f8442d10471bda096da43705687 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 16 Jan 2020 18:10:25 +0100 Subject: [PATCH] refactor: put actions inside the store --- __tests__/getters.spec.ts | 24 ++++---- __tests__/pinia/stores/cart.ts | 28 ++++++--- __tests__/pinia/stores/user.ts | 61 ++++++++++++------- __tests__/ssr/app/store.ts | 11 ++-- __tests__/store.patch.spec.ts | 17 +++--- __tests__/store.spec.ts | 34 ++++++----- __tests__/subscriptions.spec.ts | 9 ++- __tests__/tds/store.test-d.ts | 8 ++- src/index.ts | 7 +-- src/pinia.ts | 4 +- src/store.ts | 102 ++++++++++++++++++++++++-------- src/types.ts | 4 +- tsconfig.json | 1 + 13 files changed, 208 insertions(+), 102 deletions(-) diff --git a/__tests__/getters.spec.ts b/__tests__/getters.spec.ts index 61b585e5..a70d09f0 100644 --- a/__tests__/getters.spec.ts +++ b/__tests__/getters.spec.ts @@ -1,15 +1,19 @@ -import { createStore } from '../src' +import { createStore, setActiveReq } from '../src' describe('Store', () => { - const useStore = createStore( - 'main', - () => ({ - name: 'Eduardo', - }), - { - upperCaseName: ({ name }) => name.toUpperCase(), - } - ).bind(null, true) + const useStore = () => { + // create a new store + setActiveReq({}) + return createStore({ + id: 'main', + state: () => ({ + name: 'Eduardo', + }), + getters: { + upperCaseName: ({ name }) => name.toUpperCase(), + }, + })() + } it('adds getters to the store', () => { const store = useStore() diff --git a/__tests__/pinia/stores/cart.ts b/__tests__/pinia/stores/cart.ts index 1f93406b..b1d2dece 100644 --- a/__tests__/pinia/stores/cart.ts +++ b/__tests__/pinia/stores/cart.ts @@ -1,12 +1,13 @@ import { createStore } from '../../../src' -import { useUserStore } from './user' +import { useUserStore, UserStore } from './user' +import { PiniaStore, ExtractGettersFromStore } from 'src/store' -export const useCartStore = createStore( - 'cart', - () => ({ +export const useCartStore = createStore({ + id: 'cart', + state: () => ({ rawItems: [] as string[], }), - { + getters: { items: state => state.rawItems.reduce((items, item) => { const existingItem = items.find(it => it.name === item) @@ -19,8 +20,21 @@ export const useCartStore = createStore( return items }, [] as { name: string; amount: number }[]), - } -) + }, +}) + +export type CartStore = ReturnType + +// const a: PiniaStore<{ +// u: UserStore +// c: CartStore +// }> + +// a.cart + +// const getters: ExtractGettersFromStore + +// getters.items export function addItem(name: string) { const store = useCartStore() diff --git a/__tests__/pinia/stores/user.ts b/__tests__/pinia/stores/user.ts index 4697483a..5f97a49c 100644 --- a/__tests__/pinia/stores/user.ts +++ b/__tests__/pinia/stores/user.ts @@ -1,13 +1,49 @@ -import { createStore } from '../../../src' +import { createStore, WrapStoreWithId } from 'src/store' -export const useUserStore = createStore('user', () => ({ - name: 'Eduardo', - isAdmin: true, -})) +function apiLogin(a: string, p: string) { + if (a === 'ed' && p === 'ed') return Promise.resolve({ isAdmin: true }) + return Promise.reject(new Error('invalid credentials')) +} + +export const useUserStore = createStore({ + id: 'user', + state: () => ({ + name: 'Eduardo', + isAdmin: true, + }), + actions: { + async login(user: string, password: string) { + const userData = await apiLogin(user, password) + + this.patch({ + name: user, + ...userData, + }) + }, + + logout() { + this.login('a', 'b').then(() => {}) + + this.patch({ + name: '', + isAdmin: false, + }) + }, + }, + getters: { + test: state => state.name.toUpperCase(), + }, +}) + +export type UserStore = ReturnType + +// let a: WrapStoreWithId export function logout() { const store = useUserStore() + store.login('e', 'e').then(() => {}) + store.patch({ name: '', isAdmin: false, @@ -15,18 +51,3 @@ export function logout() { // we could do other stuff like redirecting the user } - -function apiLogin(a: string, p: string) { - if (a === 'ed' && p === 'ed') return Promise.resolve({ isAdmin: true }) - return Promise.reject(new Error('invalid credentials')) -} - -export async function login(user: string, password: string) { - const store = useUserStore() - const userData = await apiLogin(user, password) - - store.patch({ - name: user, - ...userData, - }) -} diff --git a/__tests__/ssr/app/store.ts b/__tests__/ssr/app/store.ts index ca1cbf3b..4a40174c 100644 --- a/__tests__/ssr/app/store.ts +++ b/__tests__/ssr/app/store.ts @@ -1,6 +1,9 @@ import { createStore } from '../../../src' -export const useStore = createStore('main', () => ({ - counter: 0, - name: 'anon', -})) +export const useStore = createStore({ + id: 'main', + state: () => ({ + counter: 0, + name: 'anon', + }), +}) diff --git a/__tests__/store.patch.spec.ts b/__tests__/store.patch.spec.ts index 3c181a42..90352926 100644 --- a/__tests__/store.patch.spec.ts +++ b/__tests__/store.patch.spec.ts @@ -4,13 +4,16 @@ describe('store.patch', () => { const useStore = () => { // create a new store setActiveReq({}) - return createStore('main', () => ({ - a: true, - nested: { - foo: 'foo', - a: { b: 'string' }, - }, - }))() + return createStore({ + id: 'main', + state: () => ({ + a: true, + nested: { + foo: 'foo', + a: { b: 'string' }, + }, + }), + })() } it('patches a property without touching the rest', () => { diff --git a/__tests__/store.spec.ts b/__tests__/store.spec.ts index 124af976..e2048f70 100644 --- a/__tests__/store.spec.ts +++ b/__tests__/store.spec.ts @@ -6,13 +6,16 @@ describe('Store', () => { // create a new store req = {} setActiveReq(req) - return createStore('main', () => ({ - a: true, - nested: { - foo: 'foo', - a: { b: 'string' }, - }, - }))() + return createStore({ + id: 'main', + state: () => ({ + a: true, + nested: { + foo: 'foo', + a: { b: 'string' }, + }, + }), + })() } it('sets the initial state', () => { @@ -28,13 +31,16 @@ describe('Store', () => { it('can hydrate the state', () => { setActiveReq({}) - const useStore = createStore('main', () => ({ - a: true, - nested: { - foo: 'foo', - a: { b: 'string' }, - }, - })) + const useStore = createStore({ + id: 'main', + state: () => ({ + a: true, + nested: { + foo: 'foo', + a: { b: 'string' }, + }, + }), + }) setStateProvider({ set: () => {}, diff --git a/__tests__/subscriptions.spec.ts b/__tests__/subscriptions.spec.ts index 6b867a93..bdf393ef 100644 --- a/__tests__/subscriptions.spec.ts +++ b/__tests__/subscriptions.spec.ts @@ -4,9 +4,12 @@ describe('Subscriptions', () => { const useStore = () => { // create a new store setActiveReq({}) - return createStore('main', () => ({ - name: 'Eduardo', - }))() + return createStore({ + id: 'main', + state: () => ({ + name: 'Eduardo', + }), + })() } let store: ReturnType diff --git a/__tests__/tds/store.test-d.ts b/__tests__/tds/store.test-d.ts index b018fb45..d41b6875 100644 --- a/__tests__/tds/store.test-d.ts +++ b/__tests__/tds/store.test-d.ts @@ -1,8 +1,12 @@ import { createStore } from '../../src' import { expectType, expectError } from 'tsd' -const useStore = createStore('name', () => ({ a: 'on' as 'on' | 'off' }), { - upper: state => state.a.toUpperCase(), +const useStore = createStore({ + id: 'name', + state: () => ({ a: 'on' as 'on' | 'off' }), + getters: { + upper: state => state.a.toUpperCase(), + }, }) const store = useStore() diff --git a/src/index.ts b/src/index.ts index 0640b0ca..e451ba6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,2 @@ -export { - createStore, - CombinedStore, - setActiveReq, - setStateProvider, -} from './store' +export { createStore, Store, setActiveReq, setStateProvider } from './store' export { StateTree, StoreGetter } from './types' diff --git a/src/pinia.ts b/src/pinia.ts index 24f22188..49907b3e 100644 --- a/src/pinia.ts +++ b/src/pinia.ts @@ -1,4 +1,4 @@ -import { Store, StoreGetter, StateTree, StoreGetters } from './types' +import { Store, StoreGetter, StateTree, StoreWithGetters } from './types' import { CombinedStore, buildStore } from './store' export type CombinedState< @@ -39,7 +39,7 @@ export type CombinedGetters< [k in keyof S]: S[k] extends ( ...args: any[] ) => CombinedStore - ? StoreGetters + ? StoreWithGetters : never } diff --git a/src/store.ts b/src/store.ts index e0b40c96..548fa3af 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,12 +1,12 @@ import { ref, watch, computed } from '@vue/composition-api' -import { Ref } from '@vue/composition-api/dist/reactivity' +import { Ref, UnwrapRef } from '@vue/composition-api/dist/reactivity' import { StateTree, - Store, + StoreWithState, SubscriptionCallback, DeepPartial, isPlainObject, - StoreGetters, + StoreWithGetters, StoreGetter, } from './types' import { useStoreDevtools } from './devtools' @@ -32,18 +32,58 @@ function innerPatch( return target } -/** - * NOTE: by allowing users to name stores correctly, they can nest them the way - * they want, no? like user/cart - */ +export interface StoreAction { + (...args: any[]): any +} -export type CombinedStore< +// in this type we forget about this because otherwise the type is recursive +type StoreWithActions> = { + [k in keyof A]: A[k] extends (this: infer This, ...args: infer P) => infer R + ? (this: This, ...args: P) => R + : never +} + +// has the actions without the context (this) for typings +export type Store< Id extends string, S extends StateTree, - G extends Record> -> = Store & StoreGetters + G extends Record>, + A extends Record +> = StoreWithState & StoreWithGetters & StoreWithActions -// TODO: allow buildStore to start with an initial state for hydration +export type WrapStoreWithId< + S extends Store +> = S extends Store + ? { + [k in Id]: Store + } + : never + +export type ExtractGettersFromStore = S extends Store< + any, + infer S, + infer G, + any +> + ? { + [k in keyof G]: ReturnType + } + : never + +export type PiniaStore< + P extends Record> +> = P extends Record + ? { + [Id in P[name]['id']]: P[name] extends Store< + Id, + infer S, + infer G, + infer A + > + ? StoreWithGetters + : never + } + : never /** * Creates a store instance @@ -53,14 +93,15 @@ export type CombinedStore< export function buildStore< Id extends string, S extends StateTree, - G extends Record> + G extends Record>, + A extends Record >( id: Id, buildState: () => S, getters: G = {} as G, + actions: A = {} as A, initialState?: S | undefined - // methods: Record -): CombinedStore { +): Store { const state: Ref = ref(initialState || buildState()) let isListening = true @@ -108,7 +149,7 @@ export function buildStore< state.value = buildState() } - const storeWithState: Store = { + const storeWithState: StoreWithState = { id, // it is replaced below by a getter state: state.value, @@ -119,7 +160,7 @@ export function buildStore< } // @ts-ignore we have to build it - const computedGetters: StoreGetters = {} + const computedGetters: StoreWithGetters = {} for (const getterName in getters) { const method = getters[getterName] // @ts-ignore @@ -143,6 +184,7 @@ export function buildStore< }, }) + // @ts-ignore TODO: actions return store } @@ -165,7 +207,7 @@ export const getActiveReq = () => activeReq const storesMap = new WeakMap< NonNullObject, - Record> + Record> >() /** @@ -173,7 +215,7 @@ const storesMap = new WeakMap< */ interface StateProvider { get(): Record - set(store: CombinedStore): any + set(store: Store): any } /** @@ -190,23 +232,30 @@ function getInitialState(id: string): StateTree | undefined { return provider && provider.get()[id] } -function setInitialState(store: CombinedStore): void { +function setInitialState(store: Store): void { const provider = stateProviders.get(getActiveReq()) if (provider) provider.set(store) } /** * Creates a `useStore` function that retrieves the store instance - * @param id id of the store we are creating - * @param buildState function that returns a state - * @param getters optional object of getters + * @param options */ export function createStore< Id extends string, S extends StateTree, - G extends Record> ->(id: Id, buildState: () => S, getters: G = {} as G) { - return function useStore(): CombinedStore { + G extends Record>, + A extends Record +>(options: { + id: Id + state: () => S + getters?: G + // allow actions use other actions + actions?: A & ThisType & StoreWithGetters> +}) { + const { id, state: buildState, getters, actions } = options + + return function useStore(): Store { const req = getActiveReq() let stores = storesMap.get(req) if (!stores) storesMap.set(req, (stores = {})) @@ -217,8 +266,11 @@ export function createStore< id, buildState, getters, + actions, getInitialState(id) ) + // save a reference to the initial state + // TODO: this implies that replacing the store cannot be done by the user because we are relying on the object reference setInitialState(store) if (isClient) useStoreDevtools(store) } diff --git a/src/types.ts b/src/types.ts index ea88e470..c8776ad8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,14 +28,14 @@ export type SubscriptionCallback = ( state: S ) => void -export type StoreGetters< +export type StoreWithGetters< S extends StateTree, G extends Record> > = { [k in keyof G]: G[k] extends StoreGetter ? Ref : never } -export interface Store { +export interface StoreWithState { /** * Unique identifier of the store */ diff --git a/tsconfig.json b/tsconfig.json index 58590615..ebd97242 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "esnext", "noEmit": true, "strict": true, + "noImplicitThis": true, "composite": true, "esModuleInterop": true, "moduleResolution": "node", -- 2.47.2