, P extends R>(Component: React.ComponentType) => {
+ return (props: T.Optionalize
): React.ReactElement => {
+ return (
+
+
+
+ );
+ };
+ };
+};
+
export default createStore;
export * from './types';
diff --git a/src/plugins/effects.ts b/src/plugins/effects.ts
index e06d73b9..8df04839 100644
--- a/src/plugins/effects.ts
+++ b/src/plugins/effects.ts
@@ -23,6 +23,13 @@ const effectsPlugin: T.Plugin = {
? model.effects(this.dispatch)
: model.effects;
+ this.validate([
+ [
+ typeof effects!== 'object',
+ `Invalid effects from Model(${model.name}), effects should return an object`,
+ ],
+ ]);
+
for (const effectName of Object.keys(effects)) {
this.validate([
[
diff --git a/src/plugins/effectsStateApis.tsx b/src/plugins/effectsStateApis.tsx
deleted file mode 100644
index 418a8e13..00000000
--- a/src/plugins/effectsStateApis.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from 'react';
-import * as T from '../types';
-import warning from '../utils/warning';
-
-let warnedUseModelActionsState = false;
-let warnedWithModelActionsState = false;
-
-/**
- * EffectsStateApis Plugin
- *
- * Plugin for provide store.useModelEffectsState
- */
-export default (): T.Plugin => {
- return {
- onStoreCreated(store: any) {
- function useModelEffectsState(name) {
- const dispatch = store.useModelDispatchers(name);
- const effectsLoading = store.useModelEffectsLoading ? store.useModelEffectsLoading(name) : {};
- const effectsError = store.useModelEffectsError ? store.useModelEffectsError(name) : {};
-
- const states = {};
- Object.keys(dispatch).forEach(key => {
- states[key] = {
- isLoading: effectsLoading[key],
- error: effectsError[key] ? effectsError[key].error : null,
- };
- });
- return states;
- };
-
- /**
- * @deprecated use `useModelEffectsState` instead
- */
- function useModelActionsState(name) {
- if (!warnedUseModelActionsState) {
- warnedUseModelActionsState = true;
- warning('`useModelActionsState` API has been detected, please use `useModelEffectsState` instead. \n\n\n Visit https://github.com/ice-lab/icestore/blob/master/docs/upgrade-guidelines.md#usemodelactionsstate to learn about how to upgrade.');
- }
- return useModelEffectsState(name);
- }
-
- const actionsSuffix = 'ActionsState';
- function createWithModelEffectsState(fieldSuffix: string = 'EffectsState') {
- return function(name: string, mapModelEffectsStateToProps?) {
- if (fieldSuffix === actionsSuffix && !warnedWithModelActionsState) {
- warnedWithModelActionsState = true;
- warning('`withModelActionsState` API has been detected, please use `withModelEffectsState` instead. \n\n\n Visit https://github.com/ice-lab/icestore/blob/master/docs/upgrade-guidelines.md#withmodelactionsstate to learn about how to upgrade.');
- }
-
- mapModelEffectsStateToProps = (mapModelEffectsStateToProps || ((effectsState) => ({ [`${name}${fieldSuffix}`]: effectsState })));
- return (Component) => {
- return (props): React.ReactElement => {
- const value = useModelEffectsState(name);
- const withProps = mapModelEffectsStateToProps(value);
- return (
-
- );
- };
- };
- };
- }
- return {
- useModelEffectsState,
- withModelEffectsState: createWithModelEffectsState(),
- useModelActionsState,
- withModelActionsState: createWithModelEffectsState(actionsSuffix),
- };
- },
- };
-};
diff --git a/src/plugins/error.tsx b/src/plugins/error.tsx
index cf6d338d..a10d3802 100644
--- a/src/plugins/error.tsx
+++ b/src/plugins/error.tsx
@@ -222,26 +222,5 @@ export default (config: ErrorConfig = {}): T.Plugin => {
this.dispatch[name][action] = effectWrapper;
});
},
- onStoreCreated(store: any) {
- function useModelEffectsError(name) {
- return store.useSelector(state => state.error.effects[name]);
- };
- function withModelEffectsError(name: string, mapModelEffectsErrorToProps?) {
- mapModelEffectsErrorToProps = (mapModelEffectsErrorToProps || ((errors) => ({ [`${name}EffectsError`]: errors })));
- return (Component) => {
- return (props): React.ReactElement => {
- const value = useModelEffectsError(name);
- const withProps = mapModelEffectsErrorToProps(value);
- return (
-
- );
- };
- };
- };
- return { useModelEffectsError, withModelEffectsError };
- },
};
};
diff --git a/src/plugins/loading.tsx b/src/plugins/loading.tsx
index aa8ba813..0937f60f 100644
--- a/src/plugins/loading.tsx
+++ b/src/plugins/loading.tsx
@@ -179,26 +179,5 @@ export default (config: LoadingConfig = {}): T.Plugin => {
this.dispatch[name][action] = effectWrapper;
});
},
- onStoreCreated(store: any) {
- function useModelEffectsLoading(name) {
- return store.useSelector(state => (state as any).loading.effects[name]);
- };
- function withModelEffectsLoading(name?: string, mapModelEffectsLoadingToProps?: any) {
- mapModelEffectsLoadingToProps = (mapModelEffectsLoadingToProps || ((loadings) => ({ [`${name}EffectsLoading`]: loadings })));
- return (Component) => {
- return (props): React.ReactElement => {
- const value = useModelEffectsLoading(name);
- const withProps = mapModelEffectsLoadingToProps(value);
- return (
-
- );
- };
- };
- };
- return { useModelEffectsLoading, withModelEffectsLoading };
- },
};
};
diff --git a/src/plugins/modelApis.tsx b/src/plugins/modelApis.tsx
index c5216b2c..5a9d758b 100644
--- a/src/plugins/modelApis.tsx
+++ b/src/plugins/modelApis.tsx
@@ -4,6 +4,9 @@ import warning from '../utils/warning';
let warnedUseModelActions = false;
let warnedWithModelActions = false;
+let warnedUseModelActionsState = false;
+let warnedWithModelActionsState = false;
+
/**
* ModelApis Plugin
@@ -14,30 +17,61 @@ export default (): T.Plugin => {
return {
onStoreCreated(store: any) {
// hooks
- function useModel(name: string) {
+ function useModel(name) {
const state = useModelState(name);
const dispatchers = useModelDispatchers(name);
return [state, dispatchers];
}
- function useModelState(name: string) {
+ function useModelState(name) {
const selector = store.useSelector(state => state[name]);
if (typeof selector !== "undefined") {
return selector;
}
throw new Error(`Not found model by namespace: ${name}.`);
}
- function useModelDispatchers(name: string) {
+ function useModelDispatchers(name) {
const dispatch = store.useDispatch();
if (dispatch[name]) {
return dispatch[name];
}
throw new Error(`Not found model by namespace: ${name}.`);
}
+ function useModelEffectsState(name) {
+ const dispatch = useModelDispatchers(name);
+ const effectsLoading = useModelEffectsLoading(name);
+ const effectsError = useModelEffectsError(name);
+
+ const states = {};
+ Object.keys(dispatch).forEach(key => {
+ states[key] = {
+ isLoading: effectsLoading[key],
+ error: effectsError[key] ? effectsError[key].error : null,
+ };
+ });
+ return states;
+ }
+ function useModelEffectsError(name) {
+ return store.useSelector(state => state.error ? state.error.effects[name] : undefined);
+ }
+ function useModelEffectsLoading(name) {
+ return store.useSelector(state => state.loading ? state.loading.effects[name] : undefined);
+ }
+
+ /**
+ * @deprecated use `useModelEffectsState` instead
+ */
+ function useModelActionsState(name) {
+ if (!warnedUseModelActionsState) {
+ warnedUseModelActionsState = true;
+ warning('`useModelActionsState` API has been detected, please use `useModelEffectsState` instead. \n\n\n Visit https://github.com/ice-lab/icestore/blob/master/docs/upgrade-guidelines.md#usemodelactionsstate to learn about how to upgrade.');
+ }
+ return useModelEffectsState(name);
+ }
/**
* @deprecated use `useModelDispatchers` instead.
*/
- function useModelActions(name: string) {
+ function useModelActions(name) {
if (!warnedUseModelActions) {
warnedUseModelActions = true;
warning('`useModelActions` API has been detected, please use `useModelDispatchers` instead. \n\n\n Visit https://github.com/ice-lab/icestore/blob/master/docs/upgrade-guidelines.md#usemodelactions to learn about how to upgrade.');
@@ -46,16 +80,18 @@ export default (): T.Plugin => {
}
// other apis
- function getModel(name: string) {
+ function getModel(name) {
return [getModelState(name), getModelDispatchers(name)];
}
- function getModelState(name: string) {
+ function getModelState(name) {
return store.getState()[name];
}
- function getModelDispatchers(name: string) {
+ function getModelDispatchers(name) {
return store.dispatch[name];
}
- function withModel(name: string, mapModelToProps?) {
+
+ // class component support
+ function withModel(name, mapModelToProps?) {
mapModelToProps = (mapModelToProps || ((model) => ({ [name]: model })));
return (Component) => {
return (props): React.ReactElement => {
@@ -72,8 +108,8 @@ export default (): T.Plugin => {
}
const actionsSuffix = 'Actions';
- function createWithModelDispatchers(fieldSuffix: string = 'Dispatchers') {
- return function withModelDispatchers(name: string, mapModelDispatchersToProps?) {
+ function createWithModelDispatchers(fieldSuffix = 'Dispatchers') {
+ return function withModelDispatchers(name, mapModelDispatchersToProps?) {
if (fieldSuffix === actionsSuffix && !warnedWithModelActions) {
warnedWithModelActions = true;
warning('`withModelActions` API has been detected, please use `withModelDispatchers` instead. \n\n\n Visit https://github.com/ice-lab/icestore/blob/master/docs/upgrade-guidelines.md#withmodelactions to learn about how to upgrade.');
@@ -93,13 +129,96 @@ export default (): T.Plugin => {
};
};
}
+ const withModelDispatchers = createWithModelDispatchers();
+
+ const actionsStateSuffix = 'ActionsState';
+ function createWithModelEffectsState(fieldSuffix = 'EffectsState') {
+ return function(name, mapModelEffectsStateToProps?) {
+ if (fieldSuffix === actionsStateSuffix && !warnedWithModelActionsState) {
+ warnedWithModelActionsState = true;
+ warning('`withModelActionsState` API has been detected, please use `withModelEffectsState` instead. \n\n\n Visit https://github.com/ice-lab/icestore/blob/master/docs/upgrade-guidelines.md#withmodelactionsstate to learn about how to upgrade.');
+ }
+
+ mapModelEffectsStateToProps = (mapModelEffectsStateToProps || ((effectsState) => ({ [`${name}${fieldSuffix}`]: effectsState })));
+ return (Component) => {
+ return (props): React.ReactElement => {
+ const value = useModelEffectsState(name);
+ const withProps = mapModelEffectsStateToProps(value);
+ return (
+
+ );
+ };
+ };
+ };
+ }
+ const withModelEffectsState = createWithModelEffectsState();
+
+ function withModelEffectsError(name, mapModelEffectsErrorToProps?) {
+ mapModelEffectsErrorToProps = (mapModelEffectsErrorToProps || ((errors) => ({ [`${name}EffectsError`]: errors })));
+ return (Component) => {
+ return (props): React.ReactElement => {
+ const value = useModelEffectsError(name);
+ const withProps = mapModelEffectsErrorToProps(value);
+ return (
+
+ );
+ };
+ };
+ }
+
+ function withModelEffectsLoading(name?, mapModelEffectsLoadingToProps?) {
+ mapModelEffectsLoadingToProps = (mapModelEffectsLoadingToProps || ((loadings) => ({ [`${name}EffectsLoading`]: loadings })));
+ return (Component) => {
+ return (props): React.ReactElement => {
+ const value = useModelEffectsLoading(name);
+ const withProps = mapModelEffectsLoadingToProps(value);
+ return (
+
+ );
+ };
+ };
+ }
+
+ function getModelAPIs(name) {
+ return {
+ useValue: () => useModel(name),
+ useState: () => useModelState(name),
+ useDispatchers: () => useModelDispatchers(name),
+ useEffectsState: () => useModelEffectsState(name),
+ useEffectsError: () => useModelEffectsError(name),
+ useEffectsLoading: () => useModelEffectsLoading(name),
+ getValue: () => getModel(name),
+ getState: () => getModelState(name),
+ getDispatchers: () => getModelDispatchers(name),
+ withValue: (mapToProps?) => withModel(name, mapToProps),
+ withDispatchers: (mapToProps?) => withModelDispatchers(name, mapToProps),
+ withEffectsState: (mapToProps?) => withModelEffectsState(name, mapToProps),
+ withEffectsError: (mapToProps?) => withModelEffectsError(name, mapToProps),
+ withEffectsLoading: (mapToProps?) => withModelEffectsLoading(name, mapToProps),
+ };
+ }
return {
+ getModelAPIs,
+
// Hooks
useModel,
useModelState,
useModelDispatchers,
+ useModelEffectsState,
+ useModelEffectsError,
+ useModelEffectsLoading,
useModelActions,
+ useModelActionsState,
// real time
getModel,
@@ -108,8 +227,12 @@ export default (): T.Plugin => {
// Class component support
withModel,
- withModelDispatchers: createWithModelDispatchers(),
+ withModelDispatchers,
+ withModelEffectsState,
+ withModelEffectsError,
+ withModelEffectsLoading,
withModelActions: createWithModelDispatchers(actionsSuffix),
+ withModelActionsState: createWithModelEffectsState(actionsStateSuffix),
};
},
};
diff --git a/src/plugins/provider.tsx b/src/plugins/provider.tsx
index d62108f5..77d552b5 100644
--- a/src/plugins/provider.tsx
+++ b/src/plugins/provider.tsx
@@ -28,7 +28,7 @@ export default ({ context }: ProviderConfig): T.Plugin => {
);
};
- return { Provider };
+ return { Provider, context };
},
};
};
diff --git a/src/types.ts b/src/types.ts
index aa7902f1..cfea9d72 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,6 +1,7 @@
import * as Redux from 'redux';
+import React from 'react';
-type Optionalize = Omit;
+export type Optionalize = Omit;
type PropType = Obj[Prop];
@@ -16,6 +17,7 @@ type EffectsState = {
type EffectsLoading = {
[K in keyof Effects]: boolean;
}
+
type EffectsError = {
[K in keyof Effects]: {
error: Error;
@@ -102,9 +104,11 @@ export type ExtractIModelFromModelConfig = [
export type ExtractIModelEffectsErrorFromModelConfig = EffectsError<
ExtractIModelDispatchersFromEffects>
>;
+
export type ExtractIModelEffectsLoadingFromModelConfig = EffectsLoading<
ExtractIModelDispatchersFromEffects>
>;
+
export type ExtractIModelEffectsStateFromModelConfig = EffectsState<
ExtractIModelDispatchersFromEffects>
>;
@@ -156,40 +160,59 @@ export interface Icestore<
subscribe(listener: () => void): Redux.Unsubscribe;
}
-interface EffectsErrorPluginAPI {
- useModelEffectsError(name: K): ExtractIModelEffectsErrorFromModelConfig;
- withModelEffectsError<
- K extends keyof M,
- F extends (effectsError: ExtractIModelEffectsErrorFromModelConfig) => Record
- >(name: K, mapModelEffectsErrorToProps?: F):
+interface UseModelEffectsError {
+ (name: K): ExtractIModelEffectsErrorFromModelConfig;
+}
+
+interface MapModelEffectsErrorToProps {
+ (effectsLoading: ExtractIModelEffectsErrorFromModelConfig): Record;
+}
+
+interface WithModelEffectsError = MapModelEffectsErrorToProps> {
+ (name: K, mapModelEffectsErrorToProps?: F):
, P extends R>(Component: React.ComponentType) =>
(props: Optionalize
) => React.ReactElement;
}
-interface EffectsLoadingPluginAPI {
- useModelEffectsLoading(name: K): ExtractIModelEffectsLoadingFromModelConfig;
- withModelEffectsLoading<
- K extends keyof M,
- F extends (effectsLoading: ExtractIModelEffectsLoadingFromModelConfig) => Record
- >(name: K, mapModelEffectsLoadingToProps?: F):
+interface ModelEffectsErrorAPI {
+ useModelEffectsError: UseModelEffectsError;
+ withModelEffectsError: WithModelEffectsError;
+}
+
+interface UseModelEffectsLoading {
+ (name: K): ExtractIModelEffectsLoadingFromModelConfig;
+}
+
+interface MapModelEffectsLoadingToProps {
+ (effectsLoading: ExtractIModelEffectsLoadingFromModelConfig): Record;
+}
+
+interface WithModelEffectsLoading = MapModelEffectsLoadingToProps> {
+ (name: K, mapModelEffectsLoadingToProps?: F):
, P extends R>(Component: React.ComponentType) =>
(props: Optionalize
) => React.ReactElement;
}
-interface UseModelEffectsState {
- (name: K): ExtractIModelEffectsStateFromModelConfig;
+interface ModelEffectsLoadingAPI {
+ useModelEffectsLoading: UseModelEffectsLoading;
+ withModelEffectsLoading: WithModelEffectsLoading;
+}
+
+interface UseModelEffectsState {
+ (name: K): ExtractIModelEffectsStateFromModelConfig;
}
-interface WithModelEffectsState {
- <
- K extends keyof M,
- F extends (effectsState: ExtractIModelEffectsStateFromModelConfig) => Record
- >(name: K, mapModelEffectsStateToProps?: F):
+interface MapModelEffectsStateToProps {
+ (effectsState: ExtractIModelEffectsStateFromModelConfig): Record;
+}
+
+interface WithModelEffectsState = MapModelEffectsStateToProps> {
+ (name: K, mapModelEffectsStateToProps?: F):
, P extends R>(Component: React.ComponentType) =>
(props: Optionalize
) => React.ReactElement;
}
-interface EffectsStatePluginAPI {
+interface ModelEffectsStateAPI {
useModelEffectsState: UseModelEffectsState;
/**
@@ -204,45 +227,101 @@ interface EffectsStatePluginAPI {
withModelActionsState: WithModelEffectsState;
}
-interface UseModelDispatchers {
- (name: K): ExtractIModelDispatchersFromModelConfig;
+interface UseModelState {
+ (name: K): ExtractIModelStateFromModelConfig;
+}
+
+interface ModelStateAPI {
+ useModelState: UseModelState;
+ getModelState: UseModelState;
}
-interface WithModelDispatchers {
- <
- K extends keyof M,
- F extends (model: ExtractIModelDispatchersFromModelConfig) => Record
- >(name: K, mapModelDispatchersToProps?: F):
+interface UseModelDispatchers {
+ (name: K): ExtractIModelDispatchersFromModelConfig;
+}
+
+interface MapModelDispatchersToProps {
+ (dispatchers: ExtractIModelDispatchersFromModelConfig): Record;
+}
+
+interface WithModelDispatchers = MapModelDispatchersToProps> {
+ (name: K, mapModelDispatchersToProps?: F):
, P extends R>(Component: React.ComponentType) =>
(props: Optionalize
) => React.ReactElement;
}
-interface ModelPluginAPI {
- useModel(name: K): ExtractIModelFromModelConfig;
- useModelState(name: K): ExtractIModelStateFromModelConfig;
+interface ModelDispathersAPI {
useModelDispatchers: UseModelDispatchers;
-
/**
* @deprecated use `useModelDispatchers` instead.
*/
useModelActions: UseModelDispatchers;
- getModel(name: K): ExtractIModelFromModelConfig;
- getModelState(name: K): ExtractIModelStateFromModelConfig;
- getModelDispatchers(name: K): ExtractIModelDispatchersFromModelConfig;
- withModel<
- K extends keyof M,
- F extends (model: ExtractIModelFromModelConfig) => Record
- >(name: K, mapModelToProps?: F):
- , P extends R>(Component: React.ComponentType) =>
- (props: Optionalize
) => React.ReactElement;
+ getModelDispatchers: UseModelDispatchers;
withModelDispatchers: WithModelDispatchers;
-
/**
* @deprecated use `withModelDispatchers` instead.
*/
withModelActions: WithModelDispatchers;
}
+interface UseModel {
+ (name: K): ExtractIModelFromModelConfig;
+}
+
+interface MapModelToProps {
+ (model: ExtractIModelFromModelConfig): Record;
+}
+
+interface WithModel = MapModelToProps> {
+ (name: K, mapModelToProps?: F):
+ , P extends R>(Component: React.ComponentType) =>
+ (props: Optionalize
) => React.ReactElement;
+}
+
+interface ModelValueAPI {
+ useModel: UseModel;
+ getModel: UseModel;
+ withModel: WithModel;
+}
+
+interface GetModelAPIsValue {
+ // ModelValueAPI
+ useValue: () => ReturnType>;
+ getValue: () => ReturnType>;
+ withValue: >(f?: F) => ReturnType>;
+ // ModelStateAPI
+ useModelState: () => ReturnType>;
+ getModelState: () => ReturnType>;
+ // ModelDispathersAPI
+ useDispatchers: () => ReturnType>;
+ getDispatchers: () => ReturnType>;
+ withDispatchers: >(f?: F) => ReturnType>;
+ // ModelEffectsLoadingAPI
+ useEffectsLoading: () => ReturnType>;
+ withEffectsLoading: >(f?: F) => ReturnType>;
+ // ModelEffectsErrorAPI
+ useEffectsError: () => ReturnType>;
+ withEffectsError: >(f?: F) => ReturnType>;
+ // ModelEffectsStateAPI
+ useModelEffectsState: () => ReturnType>;
+ withModelEffectsState: >(f?: F) => ReturnType>;
+}
+
+interface GetModelAPIs {
+ (name: K): GetModelAPIsValue;
+}
+
+type ModelAPI =
+ {
+ getModelAPIs: GetModelAPIs;
+ } &
+ ModelValueAPI &
+ ModelStateAPI &
+ ModelDispathersAPI &
+ ModelEffectsLoadingAPI &
+ ModelEffectsErrorAPI &
+ ModelEffectsStateAPI;
+
interface ProviderProps {
children: any;
initialStates?: any;
@@ -250,17 +329,17 @@ interface ProviderProps {
interface ProviderPluginAPI {
Provider: (props: ProviderProps) => JSX.Element;
+ context: React.Context<{ store: PresetIcestore }>;
}
+export type ExtractIModelAPIsFromModelConfig = ReturnType>;
+
export type PresetIcestore<
M extends Models = Models,
A extends Action = Action,
> = Icestore &
-ModelPluginAPI &
-ProviderPluginAPI &
-EffectsLoadingPluginAPI &
-EffectsErrorPluginAPI &
-EffectsStatePluginAPI;
+ModelAPI &
+ProviderPluginAPI;
export interface Action {
type: string;
@@ -276,7 +355,8 @@ export interface ModelEffects {
[key: string]: (
this: { [key: string]: (payload?: any, meta?: any) => Action },
payload: any,
- rootState: S
+ rootState?: S,
+ meta?: any
) => void;
}
diff --git a/tests/helpers/CounterComponent.tsx b/tests/helpers/CounterComponent.tsx
new file mode 100644
index 00000000..77fcf474
--- /dev/null
+++ b/tests/helpers/CounterComponent.tsx
@@ -0,0 +1,60 @@
+import React, { PureComponent } from 'react';
+import {
+ ExtractIModelFromModelConfig,
+ ExtractIModelDispatchersFromModelConfig,
+ ExtractIModelEffectsStateFromModelConfig,
+} from '../../src';
+import counterModel from './counter';
+
+interface CounterProps {
+ counter: ExtractIModelFromModelConfig;
+ children: React.ReactNode;
+}
+
+export default class Counter extends PureComponent {
+ render() {
+ const { counter, children } = this.props;
+ const [state, dispatchers] = counter;
+ const { count } = state;
+ return (
+
+ {count}
+ dispatchers.setState({ count: 1 })} />
+
+
+
+ {children}
+
+ );
+ }
+}
+
+interface CounterUseDispathcersProps {
+ counterDispatchers: ExtractIModelDispatchersFromModelConfig
;
+};
+export class CounterUseDispathcers extends PureComponent {
+ render() {
+ const { counterDispatchers } = this.props;
+ return (
+ counterDispatchers.reset()} />
+ );
+ }
+};
+
+interface CounterUseEffectsStateProps {
+ counterEffectsState: ExtractIModelEffectsStateFromModelConfig
;
+ children: React.ReactChild;
+}
+export class CounterUseEffectsState extends PureComponent {
+ render() {
+ const { counterEffectsState, children } = this.props;
+ return (
+
+
+ {JSON.stringify(counterEffectsState.asyncDecrement)}
+
+ {children}
+
+ );
+ }
+}
diff --git a/tests/helpers/counter.ts b/tests/helpers/counter.ts
new file mode 100644
index 00000000..73a97ca1
--- /dev/null
+++ b/tests/helpers/counter.ts
@@ -0,0 +1,64 @@
+import { delay } from './utils';
+
+export interface CounterState {
+ count: number;
+}
+
+const counter = {
+ state: {
+ count: 0,
+ },
+ reducers: {
+ increment: (prevState: CounterState) => prevState.count += 1,
+ decrement: (prevState: CounterState) => prevState.count -= 1,
+ reset: () => ({ count: 0 }),
+ },
+ effects: (dispatch) => ({
+ async asyncDecrement(_, rootState) {
+ if (rootState.counter.count <= 0) {
+ throw new Error('count should be greater than or equal to 0');
+ }
+ await delay(1000);
+ this.decrement();
+ },
+ }),
+};
+
+export const counterWithUnsupportEffects = {
+ state: {
+ a: 1,
+ },
+ effects: {
+ incrementA: (state, value) => {
+ return {
+ ...state,
+ a: state.a + value,
+ };
+ },
+ },
+};
+
+export const counterWithUnsupportActions = {
+ state: {
+ a: 1,
+ },
+ actions: {
+ incrementA: (state, value) => {
+ return {
+ ...state,
+ a: state.a + value,
+ };
+ },
+ },
+};
+
+export const counterWithNoImmer = {
+ state: {
+ count: 1,
+ },
+ reducers: {
+ increment: (prevState) => { return prevState.count + 1; },
+ },
+};
+
+export default counter;
diff --git a/tests/helpers/models.ts b/tests/helpers/models.ts
new file mode 100644
index 00000000..a9d65c80
--- /dev/null
+++ b/tests/helpers/models.ts
@@ -0,0 +1,2 @@
+export { default as todos } from './todos';
+export { default as user } from './user';
diff --git a/tests/helpers/todos.ts b/tests/helpers/todos.ts
new file mode 100644
index 00000000..7a7f281a
--- /dev/null
+++ b/tests/helpers/todos.ts
@@ -0,0 +1,45 @@
+import { delay } from './utils';
+
+export interface Todo {
+ name: string;
+ done?: boolean;
+}
+
+export interface TodosState {
+ dataSource: Todo[];
+}
+
+const todos = {
+ state: {
+ dataSource: [
+ {
+ name: 'Init',
+ done: false,
+ },
+ ],
+ },
+
+ reducers: {
+ addTodo(state: TodosState, todo: Todo) {
+ state.dataSource.push(todo);
+ },
+ removeTodo(state: TodosState, index: number) {
+ state.dataSource.splice(index, 1);
+ },
+ },
+
+ effects: (dispatch) => ({
+ add(todo, rootState, { store }) {
+ this.addTodo(todo);
+ dispatch.user.setTodos(store.getModelState('todos').dataSource.length);
+ },
+
+ async delete(index, rootState, { store }) {
+ await delay(1000);
+ this.removeTodo(index);
+ dispatch.user.setTodos(store.getModelState('todos').dataSource.length);
+ },
+ }),
+};
+
+export default todos;
diff --git a/tests/helpers/user.ts b/tests/helpers/user.ts
new file mode 100644
index 00000000..c348ab53
--- /dev/null
+++ b/tests/helpers/user.ts
@@ -0,0 +1,21 @@
+interface DataSourceState {
+ name: string;
+}
+class UserStateProps {
+ dataSource: DataSourceState = { name: 'testName' };
+
+ todos: number = 1;
+
+ auth: boolean = false;
+}
+
+const user = {
+ state: new UserStateProps,
+ reducers: {
+ setTodos(state: UserStateProps, todos: number) {
+ state.todos = todos;
+ },
+ },
+};
+
+export default user;
diff --git a/tests/helpers/utils.ts b/tests/helpers/utils.ts
new file mode 100644
index 00000000..d94746a6
--- /dev/null
+++ b/tests/helpers/utils.ts
@@ -0,0 +1 @@
+export const delay = (time) => new Promise((resolve) => setTimeout(() => resolve(), time));
diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx
index d0d1b424..c53e5966 100644
--- a/tests/index.spec.tsx
+++ b/tests/index.spec.tsx
@@ -1,7 +1,371 @@
-import { createStore } from '../src/index';
+/* eslint-disable react/jsx-filename-extension */
+import React, { useCallback } from "react";
+import * as rhl from "@testing-library/react-hooks";
+import * as rtl from "@testing-library/react";
+import createStore from "../src/index";
+import * as models from "./helpers/models";
+import counterModel, { counterWithUnsupportEffects, counterWithNoImmer } from "./helpers/counter";
+import Counter, { CounterUseDispathcers, CounterUseEffectsState } from './helpers/CounterComponent';
+import * as warning from '../src/utils/warning';
-describe('#Test', () => {
- test('should ok.', () => {
+describe("createStore", () => {
+ test("creteStore should be defined", () => {
expect(createStore).toBeDefined();
});
+
+ it("exposes the public API", () => {
+ const store = createStore(models);
+ const methods = Reflect.ownKeys(store);
+
+ expect(methods).toContain("Provider");
+ expect(methods).toContain("useModel");
+ expect(methods).toContain("getModel");
+ expect(methods).toContain("withModel");
+ expect(methods).toContain("useModelDispatchers");
+ expect(methods).toContain("withModelDispatchers");
+ expect(methods).toContain("useModelEffectsState");
+ expect(methods).toContain("withModelEffectsState");
+ expect(methods).toContain("getModelState");
+ expect(methods).toContain("getModelDispatchers");
+ });
+
+ it("create unsupported effects should console error", () => {
+ const spy = jest.spyOn(warning, "default");
+ createStore({ counterWithUnsupportEffects });
+ expect(spy).toHaveBeenCalled();
+ });
+
+ describe("Provider", () => {
+ afterEach(() => rtl.cleanup());
+ const store = createStore(models);
+ const { Provider } = store;
+
+ it("should not enforce one child", () => {
+ expect(() =>
+ rtl.render(
+
+
+ ,
+ ),
+ ).not.toThrow();
+
+ expect(() =>
+ rtl.render(
+
+
+
+ ,
+ ),
+ ).not.toThrow();
+ });
+ });
+
+ const renderHook = (callback, namespace, Provider, initialStates?: any) => {
+ return rhl.renderHook(() => callback(namespace), {
+ wrapper: (props) => (
+
+ {props.children}
+
+ ),
+ });
+ };
+
+ describe("function component model", () => {
+ afterEach(rhl.cleanup);
+
+ it("throw error when trying to use the inexisted model", () => {
+ const store = createStore(models);
+ const { Provider, useModel } = store;
+ const namespace = "test";
+ const { result } = renderHook(useModel, namespace, Provider);
+ expect(result.error).toEqual(
+ Error(`Not found model by namespace: ${namespace}.`),
+ );
+ });
+
+ describe("passes the initial states", () => {
+ const store = createStore(models);
+ const { Provider, useModel } = store;
+ const initialStates = {
+ todos: {
+ dataSource: [{ name: 'test', done: true }],
+ },
+ user: {
+ dataSource: [{ name: "test" }],
+ },
+ };
+
+ it("the models states should equal to the initialStates ", () => {
+ const { result: todosResult } = renderHook(useModel, "todos", Provider, initialStates);
+ const { result: userResult } = renderHook(useModel, "user", Provider, initialStates);
+ const [todosState] = todosResult.current;
+ const [userState] = userResult.current;
+ expect(todosState).toEqual(initialStates.todos);
+ expect(userState).toEqual(initialStates.user);
+ });
+
+ it('applies the reducer to the initial states', async () => {
+ const { result } = renderHook(useModel, "todos", Provider);
+
+ const [state, dispatchers] = result.current;
+ const todos = models.todos;
+
+ expect(state).toEqual(initialStates.todos);
+ expect(Reflect.ownKeys(dispatchers)).toEqual([
+ ...Reflect.ownKeys(todos.reducers),
+ ...Reflect.ownKeys(todos.effects(jest.fn)),
+ ]);
+
+ rhl.act(() => {
+ dispatchers.addTodo({ name: 'testReducers', done: false });
+ });
+ expect(result.current[0].dataSource).toEqual(
+ [
+ { name: 'test', done: true },
+ { name: 'testReducers', done: false },
+ ],
+ );
+ });
+ });
+
+ describe("not pass the initial states", () => {
+ const store = createStore(models);
+ const { Provider, useModel, useModelEffectsState } = store;
+
+ it("not pass the initial states", () => {
+ const { result: todosResult } = renderHook(useModel, "todos", Provider);
+ const { result: userResult } = renderHook(useModel, "user", Provider);
+ const [todosState] = todosResult.current;
+ const [userState] = userResult.current;
+ expect(todosState).toEqual({
+ dataSource: [
+ { name: 'Init', done: false },
+ ],
+ });
+ expect(userState).toEqual({
+ dataSource: { name: 'testName' },
+ todos: 1,
+ auth: false,
+ });
+ });
+
+ it('applies the reducer to the previous state', async () => {
+ const { result } = renderHook(useModel, "todos", Provider);
+
+ const [state, dispatchers] = result.current;
+ const todos = models.todos;
+
+ expect(state).toEqual(todos.state);
+ expect(Reflect.ownKeys(dispatchers)).toEqual([
+ ...Reflect.ownKeys(todos.reducers),
+ ...Reflect.ownKeys(todos.effects(jest.fn)),
+ ]);
+
+ rhl.act(() => {
+ dispatchers.addTodo({ name: 'testReducers', done: false });
+ });
+
+ expect(result.current[0].dataSource).toEqual(
+ [
+ { name: 'Init', done: false },
+ { name: 'testReducers', done: false },
+ ],
+ );
+ rhl.act(() => {
+ dispatchers.removeTodo(1);
+ });
+ expect(result.current[0].dataSource).toEqual([
+ { name: 'Init', done: false },
+ ]);
+ });
+
+ it('get model effects state', async () => {
+ // Define a new hooks for that renderHook api doesn't support render one more hooks
+ function useModelEffect(namespace) {
+ const [state, dispatchers] = useModel(namespace);
+ const effectsState = useModelEffectsState(namespace);
+
+ return { state, dispatchers, effectsState };
+ }
+
+ const { result, waitForNextUpdate } = renderHook(useModelEffect, 'todos', Provider);
+
+ expect(result.current.state.dataSource).toEqual(models.todos.state.dataSource);
+ rhl.act(() => {
+ result.current.dispatchers.delete(0, { store });
+ });
+
+ expect(result.current.effectsState.delete).toEqual({ isLoading: true, error: null });
+
+ await waitForNextUpdate();
+
+ expect(result.current.state.dataSource).toEqual([]);
+ expect(result.current.effectsState.delete).toEqual({ isLoading: false, error: null });
+ });
+ });
+ });
+
+ describe("class component model", () => {
+ afterEach(() => {
+ rtl.cleanup();
+ });
+
+ describe("passes the initial states", () => {
+ const initialStates = { counter: { count: 5 } };
+ const store = createStore({ counter: counterModel });
+ const { Provider, withModel } = store;
+
+ const WithModelCounter = withModel('counter')(Counter);
+
+ it('the counter model state should equal to the initialStates ', () => {
+ const tester = rtl.render();
+ const { getByTestId } = tester;
+ expect(getByTestId('count').innerHTML).toBe('5');
+ });
+
+ it('applies the reducer to the initial states', () => {
+ const tester = rtl.render();
+ const { getByTestId } = tester;
+ expect(getByTestId('count').innerHTML).toBe('5');
+
+ rtl.fireEvent.click(getByTestId('setState'));
+ expect(getByTestId('count').innerHTML).toBe('1');
+
+ rtl.fireEvent.click(getByTestId('decrement'));
+ expect(getByTestId('count').innerHTML).toBe('0');
+ });
+ });
+
+ describe("not passes the initial states", () => {
+ const store = createStore({ counter: counterModel });
+ const { Provider, withModel, withModelDispatchers, withModelEffectsState } = store;
+
+ const WithModelCounter = withModel('counter')(Counter);
+ const WithCounterUseDispathcers = withModelDispatchers('counter')(CounterUseDispathcers);
+ const WithCounterUseEffectsState = withModelEffectsState('counter')(CounterUseEffectsState);
+
+ it('the counter model state should equal to the previous state', () => {
+ const tester = rtl.render();
+ const { getByTestId } = tester;
+ expect(getByTestId('count').innerHTML).toBe('0');
+ });
+
+ it('applies the reducer to the previous states', () => {
+ const tester = rtl.render();
+ const { getByTestId } = tester;
+ expect(getByTestId('count').innerHTML).toBe('0');
+
+ rtl.fireEvent.click(getByTestId('setState'));
+ expect(getByTestId('count').innerHTML).toBe('1');
+
+ rtl.fireEvent.click(getByTestId('decrement'));
+ expect(getByTestId('count').innerHTML).toBe('0');
+ });
+
+ it('withDispatchers', () => {
+ const tester = rtl.render(
+
+
+
+
+ ,
+ );
+ const { getByTestId } = tester;
+ expect(getByTestId('count').innerHTML).toBe('0');
+
+ rtl.fireEvent.click(getByTestId('increment'));
+ expect(getByTestId('count').innerHTML).toBe('1');
+
+ rtl.fireEvent.click(getByTestId('reset'));
+ expect(getByTestId('count').innerHTML).toBe('0');
+ });
+
+ it('withModelEffectsState', async () => {
+ const container = (
+
+
+
+
+
+ );
+ const tester = rtl.render(container);
+ const { getByTestId } = tester;
+
+ expect(getByTestId('count').innerHTML).toBe('0');
+ rtl.fireEvent.click(getByTestId('asyncDecrement'));
+ await rtl.waitForDomChange();
+ expect(JSON.parse(getByTestId('decrementAsyncEffectsState').innerHTML).error).not.toBeNull();
+
+ rtl.fireEvent.click(getByTestId('increment'));
+ expect(getByTestId('count').innerHTML).toBe('1');
+
+ rtl.fireEvent.click(getByTestId('asyncDecrement'));
+ expect(getByTestId('decrementAsyncEffectsState').innerHTML).toBe('{"isLoading":true,"error":null}');
+
+ await rtl.waitForDomChange();
+ expect(getByTestId('decrementAsyncEffectsState').innerHTML).toBe('{"isLoading":false,"error":null}');
+ expect(getByTestId('count').innerHTML).toBe('0');
+ });
+ });
+ });
+
+ describe("get model api", () => {
+ afterEach(rtl.cleanup);
+
+ const store = createStore({ counter: counterModel });
+
+ function useCounter(initialValue = 0) {
+ const setCounter = useCallback(() => {
+ const [state, dispatchers] = store.getModel('counter');
+ if (state.count >= 10) {
+ return;
+ }
+ dispatchers.setState({ count: initialValue });
+ }, [initialValue]);
+ return { setCounter };
+ }
+ it('should set counter to updated initial value', () => {
+ let initialValue = 0;
+ const { result, rerender } = rhl.renderHook(() => useCounter(initialValue));
+
+ initialValue = 10;
+ rerender();
+ rhl.act(() => {
+ result.current.setCounter();
+ });
+ expect(store.getModelState('counter').count).toBe(10);
+
+ initialValue = 20;
+ rerender();
+ rhl.act(() => {
+ result.current.setCounter(); // fail to update the state
+ });
+ expect(store.getModelState('counter').count).toBe(10);
+ });
+ });
+
+ describe("createStore options", () => {
+ const mockFn = jest
+ .fn()
+ .mockReturnValueOnce(createStore({ counterWithNoImmer }, {
+ disableImmer: true,
+ }));
+
+ afterEach(() => {
+ rhl.cleanup();
+ });
+
+ it("disableImmer", () => {
+ const store = mockFn();
+ const { Provider, useModel } = store;
+ const { result } = renderHook(useModel, "counterWithNoImmer", Provider);
+
+ const [state, dispatchers] = result.current;
+ expect(state).toEqual(counterWithNoImmer.state);
+ rhl.act(() => {
+ dispatchers.increment();
+ });
+ expect(result.current[0]).toEqual(2);
+ });
+ });
});
diff --git a/tests/utils/appendReducer.spec.ts b/tests/utils/appendReducer.spec.ts
new file mode 100644
index 00000000..54a7fa22
--- /dev/null
+++ b/tests/utils/appendReducer.spec.ts
@@ -0,0 +1,21 @@
+import appendReducers from '../../src/utils/appendReducers';
+import { Models } from '../../src/types';
+
+const originModels = {
+ counter: {
+ state: 0,
+ },
+};
+
+describe('utils/appendReducers', () => {
+ it('apply no reducers', () => {
+ const models: Models = appendReducers(originModels);
+ expect(Reflect.ownKeys(models)).toEqual(['counter']);
+
+ const { counter } = models;
+ expect(Reflect.ownKeys(counter)).toEqual(['state', 'reducers']);
+
+ const { reducers } = counter;
+ expect(Reflect.ownKeys(reducers).length).toBe(2);
+ });
+});
diff --git a/tests/utils/converter.spec.ts b/tests/utils/converter.spec.ts
new file mode 100644
index 00000000..cae7a2e8
--- /dev/null
+++ b/tests/utils/converter.spec.ts
@@ -0,0 +1,31 @@
+import { convertEffects, convertActions } from '../../src/utils/converter';
+import { Models, ModelEffects } from '../../src';
+import { counterWithUnsupportEffects, counterWithUnsupportActions } from '../helpers/counter';
+import * as warning from '../../src/utils/warning';
+
+describe('utils/convert', () => {
+ it('withUnsupportEffects', () => {
+ const spy = jest.spyOn(warning, 'default');
+ const models: Models = convertEffects({ counter: counterWithUnsupportEffects });
+ expect(spy).toHaveBeenCalled();
+ spy.mockRestore();
+
+ const { counter } = models;
+ expect(Reflect.ownKeys(counter).includes('effects')).toBe(true);
+ const effects = counter.effects as (dispatch: any) => ModelEffects;
+ expect(Reflect.ownKeys(effects(jest.fn))).toEqual(['incrementA']);
+ });
+
+ it('withUnsupportActions', () => {
+ const spy = jest.spyOn(warning, 'default');
+ const models: Models = convertActions({ counter: counterWithUnsupportActions });
+ expect(spy).toHaveBeenCalled();
+ spy.mockRestore();
+
+ const { counter } = models;
+ expect(Reflect.ownKeys(counter).includes('effects')).toBe(true);
+
+ const effects = counter.effects as (dispatch: any) => ModelEffects;
+ expect(Reflect.ownKeys(effects(jest.fn))).toEqual(['incrementA']);
+ });
+});
diff --git a/tests/utils/validate.spec.ts b/tests/utils/validate.spec.ts
new file mode 100644
index 00000000..d7bdeec9
--- /dev/null
+++ b/tests/utils/validate.spec.ts
@@ -0,0 +1,17 @@
+import validate from '../../src/utils/validate';
+
+describe('utils/validate', () => {
+ it('will throw Error', () => {
+ const model: any = {};
+ expect(() => {
+ validate([[model.state === undefined, 'model state is required']]);
+ }).toThrowError(/^model state is required$/);
+ });
+
+ it('will throw Error', () => {
+ const model = { state: 0, name: 'test' };
+ expect(() => {
+ validate([[model.state === undefined, 'model state is required']]);
+ }).not.toThrowError();
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index e74127ea..40f13b0a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,7 +10,7 @@
"lib": ["es5", "dom"]
},
"files": [
- "src/index.ts"
+ "src/index.tsx"
],
"exclude": [
"node_modules"