import { Component } from 'react';
import {
UnknownAction,
compose,
Dispatch as ReduxDispatch,
Middleware,
Observable,
Reducer as ReduxReducer,
Store as ReduxStore,
StoreEnhancer,
} from 'redux';
import { O } from 'ts-toolbelt';
export type ReduxAction = UnknownAction;
/**
* Picks only the keys of a certain type
*/
type KeysOfType = {
[K in keyof A]-?: A[K] extends B ? K : never;
}[keyof A];
/**
* This allows you to narrow keys of an object type that are index signature
* based.
*
* Based on answer from here:
* https://stackoverflow.com/questions/56422807/narrowing-a-type-to-its-properties-that-are-index-signatures/56423972#56423972
*/
type IndexSignatureKeysOfType = {
[K in keyof A]: A[K] extends { [key: string]: any } | { [key: number]: any }
? string extends keyof A[K]
? K
: number extends keyof A[K]
? K
: never
: never;
}[keyof A];
type InvalidObjectTypes = string | Array | RegExp | Date | Function;
type IncludesDeep3 = O.Includes<
Obj,
M
> extends 1
? 1
: O.Includes<
{
[P in keyof Obj]: Obj[P] extends object ? O.Includes : 0;
},
1
>;
type IncludesDeep2 = O.Includes<
Obj,
M
> extends 1
? 1
: O.Includes<
{
[P in keyof Obj]: Obj[P] extends object ? IncludesDeep3 : 0;
},
1
>;
type IncludesDeep = O.Includes<
Obj,
M
> extends 1
? 1
: O.Includes<
{
[P in keyof Obj]: Obj[P] extends object ? IncludesDeep2 : 0;
},
1
>;
type StateResolver<
Model extends object,
StoreModel extends object,
Result = any,
> = (state: State, storeState: State) => Result;
type StateResolvers =
| []
| Array>;
type AnyFunction = (...args: any[]) => any;
type ActionEmitterTypes = Action | Thunk;
type ActionListenerTypes = ActionOn | ThunkOn;
type ActionTypes =
| ActionEmitterTypes
| ActionListenerTypes
| EffectOn;
interface ActionCreator {
(payload: Payload): void;
type: string;
z__creator: 'actionWithPayload';
}
interface ThunkCreator {
(payload: Payload extends undefined ? void : Payload): Result;
type: string;
startType: string;
successType: string;
failType: string;
z__creator: 'thunkWithPayload';
}
type ActionOrThunkCreator =
| ActionCreator
| ThunkCreator;
type Helpers = {
dispatch: Dispatch;
fail: AnyFunction;
getState: () => State;
getStoreActions: () => Actions;
getStoreState: () => State;
injections: Injections;
meta: Meta;
};
// #region Helpers
/**
* This utility will pull the state within an action out of the Proxy form into
* a natural form, allowing you to console.log or inspect it.
*
* @param state - The action state
*
* @example
*
* ```typescript
* import { debug, action } from 'easy-peasy';
*
* const model = {
* todos: [],
* addTodo: action((state, payload) => {
* console.log(debug(state));
* state.todos.push(payload);
* console.log(debug(state));
* })
* }
* ```
*/
export function debug(
state: StateDraft,
): StateDraft;
// #endregion
// #region Listeners
type ValidListenerProperties = {
[P in keyof ActionsModel]: P extends IndexSignatureKeysOfType
? never
: ActionsModel[P] extends ActionListenerTypes
? P
: ActionsModel[P] extends object
? IncludesDeep extends 1
? P
: never
: never;
}[keyof ActionsModel];
type ListenerMapper<
ActionsModel extends object,
K extends keyof ActionsModel,
> = {
[P in K]: ActionsModel[P] extends ActionOn
? ActionCreator>
: ActionsModel[P] extends ThunkOn
? ThunkCreator, any>
: ActionsModel[P] extends object
? RecursiveListeners
: ActionsModel[P];
};
type RecursiveListeners = ListenerMapper<
ActionsModel,
ValidListenerProperties
>;
/**
* Filters a model into a type that represents the listener actions/thunks
*
* @example
*
* type OnlyListeners = Listeners;
*/
export type Listeners = RecursiveListeners;
// #endregion
// #region Actions
type ValidActionProperties = {
[P in keyof ActionsModel]: P extends IndexSignatureKeysOfType
? never
: ActionsModel[P] extends ActionEmitterTypes
? P
: ActionsModel[P] extends object
? IncludesDeep extends 1
? P
: never
: never;
}[keyof ActionsModel];
type ActionMapper = {
[P in K]: ActionsModel[P] extends Action
? ActionCreator
: ActionsModel[P] extends Thunk
? ActionsModel[P]['payload'] extends void
? ThunkCreator
: ThunkCreator
: ActionsModel[P] extends object
? RecursiveActions
: ActionsModel[P];
};
type RecursiveActions = ActionMapper<
ActionsModel,
ValidActionProperties
>;
/**
* Filters a model into a type that represents the action/thunk creators.
*
* @example
*
* ```typescript
* import { Actions, useStoreActions } from 'easy-peasy';
* import { StoreModel } from './my-store';
*
* function MyComponent() {
* const doSomething = useStoreActions(
* (actions: Actions) => actions.doSomething
* );
* }
* ```
*/
export type Actions = RecursiveActions;
// #endregion
// #region State
type StateTypes = Computed | Reducer | ActionTypes;
type StateMapper = {
[P in keyof StateModel]: StateModel[P] extends Generic
? T
: P extends IndexSignatureKeysOfType
? StateModel[P]
: StateModel[P] extends Computed
? StateModel[P]['result']
: StateModel[P] extends Reducer
? StateModel[P]['result']
: StateModel[P] extends object
? StateModel[P] extends InvalidObjectTypes
? StateModel[P]
: IncludesDeep extends 1
? RecursiveState
: StateModel[P]
: StateModel[P];
};
type FilterActionTypes = {
[K in keyof Model as Model[K] extends ActionTypes ? never : K]: Model[K];
};
type RecursiveState = StateMapper<
FilterActionTypes
>;
/**
* Filters a model into a type that represents the state only (i.e. no actions)
*
* @example
*
* ```typescript
* import { State, useStoreState } from 'easy-peasy';
* import { StoreModel } from './my-store';
*
* function MyComponent() {
* const stuff = useStoreState((state: State) => state.stuff);
* }
* ```
*/
export type State = RecursiveState;
// #endregion
// #region Store + Config + Creation
/**
* Creates a store.
*
* https://easy-peasy.dev/docs/api/create-store.html
*
* @example
*
* ```typescript
* import { createStore } from 'easy-peasy';
*
* interface StoreModel {
* todos: string[];
* }
*
* const store = createStore({
* todos: []
* });
* ```
*/
export function createStore<
StoreModel extends object = {},
InitialState extends undefined | object = undefined,
Injections extends object = {},
>(
model: StoreModel,
config?: EasyPeasyConfig,
): Store>;
/**
* Configuration interface for stores.
*
* @example
*
* ```typescript
* import { createStore } from 'easy-peasy';
* import model from './my-model';
*
* const store = createStore(model, {
* devTools: false,
* name: 'MyConfiguredStore',
* });
* ```
*/
export interface EasyPeasyConfig<
InitialState extends undefined | object = undefined,
Injections extends object = {},
> {
compose?: typeof compose;
devTools?: boolean | object;
disableImmer?: boolean;
enhancers?: StoreEnhancer[];
initialState?: InitialState;
injections?: Injections;
middleware?: Array>;
mockActions?: boolean;
name?: string;
version?: number;
reducerEnhancer?: (reducer: ReduxReducer) => ReduxReducer;
}
export interface MockedAction {
type: string;
[key: string]: any;
}
export interface AddModelResult {
resolveRehydration: () => Promise;
}
/**
* An Easy Peasy store. This is essentially a Redux store with additional enhanced
* APIs attached.
*
* @example
*
* ```typescript
* import { Store } from 'easy-peasy';
* import { StoreModel } from './store';
*
* type MyEasyPeasyStore = Store;
* ```
*/
export interface Store<
StoreModel extends object = {},
StoreConfig extends EasyPeasyConfig = EasyPeasyConfig<
undefined,
{}
>,
> extends ReduxStore> {
addModel: (
key: string,
modelSlice: ModelSlice,
) => AddModelResult;
clearMockedActions: () => void;
dispatch: Dispatch;
getActions: () => Actions;
getListeners: () => Listeners;
getMockedActions: () => MockedAction[];
persist: {
clear: () => Promise;
flush: () => Promise;
resolveRehydration: () => Promise;
};
reconfigure: (model: NewStoreModel) => void;
removeModel: (key: string) => void;
/**
* Interoperability point for observable/reactive libraries.
* @returns {observable} A minimal observable of state changes.
* For more information, see the observable proposal:
* https://github.com/tc39/proposal-observable
*/
[Symbol.observable](): Observable>;
}
// #endregion
// #region Dispatch
/**
* Enhanced version of the Redux Dispatch with action creators bound to it
*
* @example
*
* import { Dispatch } from 'easy-peasy';
* import { StoreModel } from './store';
*
* type DispatchWithActions = Dispatch;
*/
export type Dispatch<
StoreModel extends object = {},
Action extends ReduxAction = UnknownAction,
> = Actions & ReduxDispatch;
// #endregion
// #region Types shared by ActionOn and ThunkOn
type Target = ActionOrThunkCreator | string;
type TargetResolver = (
actions: Actions,
storeActions: Actions,
) => Target | Array;
interface TargetPayload {
type: string;
payload: Payload;
result?: any;
error?: Error;
resolvedTargets: Array;
}
type PayloadFromResolver<
Resolver extends TargetResolver,
Resolved = ReturnType,
> = Resolved extends string
? any
: Resolved extends ActionOrThunkCreator
? Payload
: Resolved extends Array
? T extends string
? any
: T extends ActionOrThunkCreator
? Payload
: T
: unknown;
// #endregion
// #region Thunk
type Meta = {
path: string[];
parent: string[];
};
/**
* Declares a thunk against your model type definition.
*
* https://easy-peasy.dev/docs/typescript-api/thunk.html
*
* @param Model - The model that the thunk is being bound to.
* @param Payload - The type of the payload expected. Set to undefined if none.
* @param Injections - The type for the injections provided to the store
* @param StoreModel - The root model type for the store. Useful if using getStoreState helper.
* @param Result - The type for the expected return from the thunk.
*
* @example
*
* import { Thunk } from 'easy-peasy';
*
* interface TodosModel {
* todos: Array;
* addTodo: Thunk;
* }
*/
export interface Thunk<
Model extends object,
Payload = undefined,
Injections = any,
StoreModel extends object = {},
Result = any,
> {
type: 'thunk';
payload: Payload;
result: Result;
}
/**
* Declares an thunk against your model.
*
* Thunks are typically used to encapsulate side effects and are able to
* dispatch other actions.
*
* https://easy-peasy.dev/docs/api/thunk.html
*
* @example
*
* ```typescript
* import { thunk } from 'easy-peasy';
*
* const store = createStore({
* login: thunk(async (actions, payload) => {
* const user = await loginService(payload);
* actions.loginSucceeded(user);
* })
* });
* ```
*/
export function thunk<
Model extends object = {},
Payload = undefined,
Injections = any,
StoreModel extends object = {},
Result = any,
>(
thunk: (
actions: Actions,
payload: Payload,
helpers: Helpers,
) => Result,
): Thunk;
// #endregion
// #region Listener Thunk
export interface ThunkOn<
Model extends object,
Injections = any,
StoreModel extends object = {},
> {
type: 'thunkOn';
}
export function thunkOn<
Model extends object = {},
Injections = any,
StoreModel extends object = {},
Resolver extends TargetResolver = TargetResolver<
Model,
StoreModel
>,
>(
targetResolver: Resolver,
handler: (
actions: Actions,
target: TargetPayload>,
helpers: Helpers,
) => any,
): ThunkOn;
// #endregion
// #region Action
/**
* Represents an action.
*
* @example
*
* import { Action } from 'easy-peasy';
*
* interface Model {
* todos: Array;
* addTodo: Action;
* }
*/
export type Action = {
type: 'action';
payload: Payload;
result: void | State;
};
/**
* @param {boolean} [immer=true] - If true, the action will be wrapped in an immer produce call. Otherwise, the action will update the state directly.
**/
interface Config {
immer?: boolean;
}
/**
* Declares an action.
*
* https://easy-peasy.dev/docs/api/action
*
* @example
*
* import { action } from 'easy-peasy';
*
* const store = createStore({
* count: 0,
* increment: action((state) => {
* state.count += 1;
* })
* });
*/
export function action(
action: (state: State, payload: Payload) => void | State,
config?: Config,
): Action;
// #endregion
// #region Listener Action
export interface ActionOn<
Model extends object = {},
StoreModel extends object = {},
> {
type: 'actionOn';
result: void | State;
}
export function actionOn<
Model extends object,
StoreModel extends object,
Resolver extends TargetResolver,
>(
targetResolver: Resolver,
handler: (
state: State,
target: TargetPayload>,
) => void | State,
config?: Config,
): ActionOn;
// #endregion
// #region Computed
/**
* Represents a computed property.
*
* @example
*
* import { Computed } from 'easy-peasy';
*
* interface Model {
* products: Array;
* totalPrice: Computed;
* }
*/
export interface Computed<
Model extends object,
Result,
StoreModel extends object = {},
> {
type: 'computed';
result: Result;
}
type DefaultComputationFunc = (
state: State,
) => Result;
type ExtractReturnTypes any)[]> = [
...{
[K in keyof T]: T[K] extends (...args: any[]) => infer R ? R : never;
},
];
export function computed<
Model extends object = {},
Result = void,
StoreModel extends object = {},
Resolvers extends StateResolvers = StateResolvers<
Model,
StoreModel
>,
>(
resolversOrCompFunc: Resolvers | DefaultComputationFunc,
compFunc?: (...args: ExtractReturnTypes) => Result,
): Computed;
// #endregion
// #region EffectOn
export interface EffectOn<
Model extends object = {},
StoreModel extends object = {},
Injections = any,
> {
type: 'effectOn';
}
type Change> = {
prev: ExtractReturnTypes;
current: ExtractReturnTypes;
action: {
type: string;
payload?: any;
};
};
export type Dispose = () => any;
export function effectOn<
Model extends object = {},
StoreModel extends object = {},
Injections = any,
Resolvers extends StateResolvers = StateResolvers<
Model,
StoreModel
>,
>(
dependencies: Resolvers,
effect: (
actions: Actions,
change: Change,
helpers: Helpers,
) => undefined | void | Dispose | Promise,
): EffectOn;
// #endregion
// #region Reducer
/**
* A reducer type.
*
* Useful when declaring your model.
*
* @example
*
* import { Reducer } from 'easy-peasy';
*
* interface Model {
* router: Reducer;
* }
*/
export type Reducer = {
type: 'reducer';
result: State;
};
/**
* Allows you to declare a custom reducer to manage a bit of your state.
*
* https://github.com/ctrlplusb/easy-peasy#reducerfn
*
* @example
*
* import { reducer } from 'easy-peasy';
*
* const store = createStore({
* counter: reducer((state = 1, action) => {
* switch (action.type) {
* case 'INCREMENT': return state + 1;
* default: return state;
* }
* })
* });
*/
export function reducer(
reducer: ReduxReducer,
config?: Config,
): Reducer;
// #endregion
// #region Generics
/**
* Used to declare generic state on a model.
*
* @example
*
* interface MyGenericModel {
* value: Generic;
* setValue: Action, T>;
* }
*
* const numberModel: MyGenericModel = {
* value: generic(1337),
* setValue: action((state, value) => {
* state.value = value;
* })
* };
*/
export class Generic {
type: 'ezpz__generic';
}
/**
* Used to assign a generic state value against a model.
*
* @example
*
* interface MyGenericModel {
* value: Generic;
* setValue: Action, T>;
* }
*
* const numberModel: MyGenericModel = {
* value: generic(1337),
* setValue: action((state, value) => {
* state.value = value;
* })
* };
*/
export function generic(value: T): Generic;
// #endregion Generics
// #region Hooks
/**
* A React Hook allowing you to use state within your component.
*
* https://easy-peasy.dev/docs/api/use-store-state.html
*
* Note: you can create a pre-typed version of this hook via "createTypedHooks"
*
* @example
*
* import { useStoreState, State } from 'easy-peasy';
* import { StoreModel } from './store';
*
* function MyComponent() {
* const todos = useStoreState((state: State) => state.todos.items);
* return todos.map(todo => );
* }
*/
export function useStoreState<
StoreState extends State = State<{}>,
Result = any,
>(
mapState: (state: StoreState) => Result,
equalityFn?: (prev: Result, next: Result) => boolean,
): Result;
/**
* A React Hook allowing you to use actions within your component.
*
* https://easy-peasy.dev/docs/api/use-store-actions.html
*
* Note: you can create a pre-typed version of this hook via "createTypedHooks"
*
* @example
*
* import { useStoreActions, Actions } from 'easy-peasy';
*
* function MyComponent() {
* const addTodo = useStoreActions((actions: Actions) => actions.todos.add);
* return ;
* }
*/
export function useStoreActions<
StoreActions extends Actions = Actions<{}>,
Result = any,
>(mapActions: (actions: StoreActions) => Result): Result;
/**
* A react hook that returns the store instance.
*
* https://easy-peasy.dev/docs/api/use-store.html
*
* Note: you can create a pre-typed version of this hook via "createTypedHooks"
*
* @example
*
* import { useStore } from 'easy-peasy';
*
* function MyComponent() {
* const store = useStore();
* return {store.getState().foo}
;
* }
*/
export function useStore<
StoreModel extends object = {},
StoreConfig extends EasyPeasyConfig = EasyPeasyConfig<
undefined,
{}
>,
>(): Store;
/**
* A React Hook allowing you to use the store's dispatch within your component.
*
* https://easypeasy.now.sh/docs/api/use-store-dispatch.html
*
* Note: you can create a pre-typed version of this hook via "createTypedHooks"
*
* @example
*
* import { useStoreDispatch } from 'easy-peasy';
*
* function MyComponent() {
* const dispatch = useStoreDispatch();
* return dispatch({ type: 'ADD_TODO', payload: todo })} />;
* }
*/
export function useStoreDispatch<
StoreModel extends object = {},
>(): Dispatch;
/**
* A utility function used to create pre-typed hooks.
*
* https://easypeasy.now.sh/docs/api/create-typed-hooks.html
*
* @example
* import { StoreModel } from './store';
*
* const { useStoreActions, useStoreState, useStoreDispatch, useStore } = createTypedHooks();
*
* useStoreActions(actions => actions.todo.add); // fully typed
*/
export function createTypedHooks(): {
useStoreActions: (
mapActions: (actions: Actions) => Result,
) => Result;
useStoreDispatch: () => Dispatch;
useStoreState: (
mapState: (state: State) => Result,
equalityFn?: (prev: Result, next: Result) => boolean,
) => Result;
useStore: () => Store;
};
// #endregion
// #region StoreProvider
/**
* Exposes the store to your app (and hooks).
*
* https://easypeasy.now.sh/docs/api/store-provider.html
*
* @example
*
* import { StoreProvider } from 'easy-peasy';
* import store from './store';
*
* ReactDOM.render(
*
*
*
* );
*/
export class StoreProvider extends Component<{
store: Store;
children?: React.ReactNode;
}> {}
// #endregion
// #region Context + Local Stores
interface StoreModelInitializer<
StoreModel extends object,
RuntimeModel extends undefined | object,
> {
(runtimeModel?: RuntimeModel): StoreModel;
}
export function createContextStore<
StoreModel extends object = {},
Injections extends object = {},
RuntimeModel extends undefined | object = StoreModel,
StoreConfig extends EasyPeasyConfig = EasyPeasyConfig<
undefined,
Injections
>,
>(
model: StoreModel | StoreModelInitializer,
config?: StoreConfig,
): {
Provider: React.FC<{
children?: React.ReactNode;
runtimeModel?: RuntimeModel;
injections?: Injections | ((previousInjections: Injections) => Injections);
}>;
useStore: () => Store;
useStoreState: (
mapState: (state: State) => Result,
equalityFn?: (prev: Result, next: Result) => boolean,
) => Result;
useStoreActions: (
mapActions: (actions: Actions) => Result,
) => Result;
useStoreDispatch: () => Dispatch;
useStoreRehydrated: () => boolean;
};
export function useLocalStore<
StoreModel extends object = {},
StoreConfig extends EasyPeasyConfig = EasyPeasyConfig<
undefined,
{}
>,
>(
modelCreator: (prevState?: State) => StoreModel,
dependencies?: any[],
storeConfig?: (
prevState?: State,
prevConfig?: StoreConfig,
) => StoreConfig,
): [State, Actions, Store];
// #endregion
// #region Persist
export interface PersistStorage {
getItem: (key: string) => any | Promise;
setItem: (key: string, data: any) => void | Promise;
removeItem: (key: string) => void | Promise;
}
export interface Transformer {
in?: (data: any, key: string, fullState?: any) => any;
out?: (data: any, key: string, fullState?: any) => any;
}
export interface PersistConfig {
allow?: Array;
deny?: Array;
mergeStrategy?: 'mergeDeep' | 'mergeShallow' | 'overwrite';
migrations?: {
migrationVersion: number;
[key: number]: (
state: Partial,
) => void;
};
storage?: 'localStorage' | 'sessionStorage' | PersistStorage;
transformers?: Array;
}
export interface TransformConfig {
blacklist?: Array;
whitelist?: Array;
}
export function createTransform(
inbound?: (data: any, key: string, fullState?: any) => any,
outbound?: (data: any, key: string, fullState?: any) => any,
config?: TransformConfig,
): Transformer;
export function persist(
model: Model,
config?: PersistConfig,
): Model;
export function useStoreRehydrated(): boolean;
// #endregion