From abf623eb1a88f05054da88b640daa94f34899d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B8=E6=96=87=E6=B6=9B?= Date: Fri, 24 Apr 2020 14:21:21 +0800 Subject: [PATCH] Release 1.4.0 (#103) * chore: new version * fix: add meta type in ModelEffects (#102) * Add @ice/store Unit Test (#84) * chore: add dependency * test: add helper and init test * test: add createStore test * test: add withModel withModelActions withModelEffectsState test * test: add throw error when getting unexist model * chore: new version * test: add global actions test * test: add createModel test * test: add createUseContainer test * chore: createModel * test: add createContainer test * test: update test cases * test: add useModel test * test: add class component test * test: update class component test * test: add createStore options test * test: add appendReducer test * test: add appendReducer test * chore: lint * test: add utils test * docs: add badge * chore: remove unnecessary comment * test: update converter test * feat: add comment to pr * fix: fix by the comments * chore: update codecov config * test: update test * test: update converter test case * test: update examples * test: update todos example * chore: update modelEffects type Co-authored-by: alvinhui * fix: effects should return an object (#105) * fix: effects should return an object * chore: lint * Better example (#106) * chore: add classComponent example * chore: rename async function * chore: visibility logic * docs: add examples section * feat: withModel (#104) * feat: withModel init * refactor: model apis * feat: model for withModel * feat: ts support for withModel * chore: typo * chore: lint * test: disable loading and error * test: mock value * chore: playload && meta is option * chore: typo * docs: withModel * refactor: class and function component demo * chore: class component support * chore: remove console * chore: lint * chore: withModel example * chore: remove product * chore: undo * chore: typo * Docs: Migrating From Redux (#108) * chore: example @ice/store version Co-authored-by: Hengchang Lu <44047106+luhc228@users.noreply.github.com> --- README.md | 25 +- README.zh-CN.md | 25 +- codecov.yml | 10 +- docs/api.md | 133 ++++++- docs/api.zh-CN.md | 157 +++++++- docs/migration.md | 280 +++++++++++++ docs/migration.zh-CN.md | 280 +++++++++++++ docs/recipes.md | 2 +- docs/recipes.zh-CN.md | 2 +- docs/upgrade-guidelines.md | 14 +- docs/upgrade-guidelines.zh-CN.md | 14 +- examples/classComponent/.gitgnore | 1 + examples/classComponent/package.json | 35 ++ examples/classComponent/public/index.html | 15 + .../src/components/Product/Class.tsx | 42 ++ .../src/components/Product/Function.tsx | 28 ++ .../src/components/Product/Product.tsx | 13 + .../src/components/Product/index.ts | 4 + .../src/components/Product/model.ts | 5 + .../src/components/User/Class.tsx | 36 ++ .../src/components/User/Function.tsx | 15 + .../src/components/User/User.tsx | 13 + .../src/components/User/index.ts | 4 + examples/classComponent/src/index.tsx | 19 + examples/classComponent/src/models/index.ts | 11 + examples/classComponent/src/models/user.ts | 5 + .../classComponent/src/react-app-env.d.ts | 1 + examples/classComponent/src/store.ts | 11 + examples/classComponent/tsconfig.json | 25 ++ examples/counter/src/index.tsx | 6 +- examples/migration-redux-1/package.json | 31 ++ examples/migration-redux-1/public/index.html | 15 + examples/migration-redux-1/src/App.js | 37 ++ examples/migration-redux-1/src/index.js | 28 ++ .../src/reducers/dolphins.js | 15 + .../migration-redux-1/src/reducers/sharks.js | 15 + examples/migration-redux-2/package.json | 31 ++ examples/migration-redux-2/public/index.html | 15 + examples/migration-redux-2/src/App.js | 36 ++ examples/migration-redux-2/src/index.js | 27 ++ .../migration-redux-2/src/models/sharks.js | 6 + .../src/reducers/dolphins.js | 15 + examples/migration-redux-3/package.json | 31 ++ examples/migration-redux-3/public/index.html | 15 + examples/migration-redux-3/src/App.js | 41 ++ examples/migration-redux-3/src/index.js | 13 + .../migration-redux-3/src/models/dolphins.js | 6 + .../migration-redux-3/src/models/sharks.js | 6 + examples/migration-redux-3/src/redux.js | 6 + examples/migration-redux-3/src/store.js | 10 + examples/migration-redux-4/package.json | 31 ++ examples/migration-redux-4/public/index.html | 15 + examples/migration-redux-4/src/App.js | 45 +++ examples/migration-redux-4/src/index.js | 13 + .../migration-redux-4/src/models/dolphins.js | 6 + .../migration-redux-4/src/models/sharks.js | 6 + examples/migration-redux-4/src/redux.js | 6 + examples/migration-redux-4/src/store.js | 10 + examples/todos/package.json | 4 +- examples/todos/src/components/AddTodo.tsx | 27 ++ examples/todos/src/components/Car.tsx | 16 - examples/todos/src/components/Footer.tsx | 33 ++ examples/todos/src/components/Todo.tsx | 30 ++ examples/todos/src/components/TodoAdd.tsx | 23 -- examples/todos/src/components/TodoList.tsx | 88 +++-- .../todos/src/components/TodoListClass.tsx | 54 --- examples/todos/src/components/Todos.tsx | 28 -- examples/todos/src/components/User.tsx | 32 -- examples/todos/src/index.tsx | 26 +- examples/todos/src/models/car.ts | 10 - examples/todos/src/models/index.ts | 8 +- examples/todos/src/models/todos.ts | 63 ++- examples/todos/src/models/user.ts | 38 -- examples/todos/src/models/visibilityFilter.ts | 16 + examples/withModel/.gitgnore | 1 + examples/withModel/package.json | 34 ++ examples/withModel/public/index.html | 15 + examples/withModel/src/index.tsx | 36 ++ examples/withModel/src/react-app-env.d.ts | 1 + examples/withModel/tsconfig.json | 25 ++ package.json | 20 +- src/{index.ts => index.tsx} | 33 +- src/plugins/effects.ts | 7 + src/plugins/effectsStateApis.tsx | 73 ---- src/plugins/error.tsx | 21 - src/plugins/loading.tsx | 21 - src/plugins/modelApis.tsx | 145 ++++++- src/plugins/provider.tsx | 2 +- src/types.ts | 176 ++++++--- tests/helpers/CounterComponent.tsx | 60 +++ tests/helpers/counter.ts | 64 +++ tests/helpers/models.ts | 2 + tests/helpers/todos.ts | 45 +++ tests/helpers/user.ts | 21 + tests/helpers/utils.ts | 1 + tests/index.spec.tsx | 370 +++++++++++++++++- tests/utils/appendReducer.spec.ts | 21 + tests/utils/converter.spec.ts | 31 ++ tests/utils/validate.spec.ts | 17 + tsconfig.json | 2 +- 100 files changed, 2935 insertions(+), 567 deletions(-) create mode 100644 docs/migration.md create mode 100644 docs/migration.zh-CN.md create mode 100644 examples/classComponent/.gitgnore create mode 100644 examples/classComponent/package.json create mode 100644 examples/classComponent/public/index.html create mode 100644 examples/classComponent/src/components/Product/Class.tsx create mode 100644 examples/classComponent/src/components/Product/Function.tsx create mode 100644 examples/classComponent/src/components/Product/Product.tsx create mode 100644 examples/classComponent/src/components/Product/index.ts create mode 100644 examples/classComponent/src/components/Product/model.ts create mode 100644 examples/classComponent/src/components/User/Class.tsx create mode 100644 examples/classComponent/src/components/User/Function.tsx create mode 100644 examples/classComponent/src/components/User/User.tsx create mode 100644 examples/classComponent/src/components/User/index.ts create mode 100644 examples/classComponent/src/index.tsx create mode 100644 examples/classComponent/src/models/index.ts create mode 100644 examples/classComponent/src/models/user.ts create mode 100644 examples/classComponent/src/react-app-env.d.ts create mode 100644 examples/classComponent/src/store.ts create mode 100644 examples/classComponent/tsconfig.json create mode 100644 examples/migration-redux-1/package.json create mode 100644 examples/migration-redux-1/public/index.html create mode 100644 examples/migration-redux-1/src/App.js create mode 100644 examples/migration-redux-1/src/index.js create mode 100644 examples/migration-redux-1/src/reducers/dolphins.js create mode 100644 examples/migration-redux-1/src/reducers/sharks.js create mode 100644 examples/migration-redux-2/package.json create mode 100644 examples/migration-redux-2/public/index.html create mode 100644 examples/migration-redux-2/src/App.js create mode 100644 examples/migration-redux-2/src/index.js create mode 100644 examples/migration-redux-2/src/models/sharks.js create mode 100644 examples/migration-redux-2/src/reducers/dolphins.js create mode 100644 examples/migration-redux-3/package.json create mode 100644 examples/migration-redux-3/public/index.html create mode 100644 examples/migration-redux-3/src/App.js create mode 100644 examples/migration-redux-3/src/index.js create mode 100644 examples/migration-redux-3/src/models/dolphins.js create mode 100644 examples/migration-redux-3/src/models/sharks.js create mode 100644 examples/migration-redux-3/src/redux.js create mode 100644 examples/migration-redux-3/src/store.js create mode 100644 examples/migration-redux-4/package.json create mode 100644 examples/migration-redux-4/public/index.html create mode 100644 examples/migration-redux-4/src/App.js create mode 100644 examples/migration-redux-4/src/index.js create mode 100644 examples/migration-redux-4/src/models/dolphins.js create mode 100644 examples/migration-redux-4/src/models/sharks.js create mode 100644 examples/migration-redux-4/src/redux.js create mode 100644 examples/migration-redux-4/src/store.js create mode 100644 examples/todos/src/components/AddTodo.tsx delete mode 100644 examples/todos/src/components/Car.tsx create mode 100644 examples/todos/src/components/Footer.tsx create mode 100644 examples/todos/src/components/Todo.tsx delete mode 100644 examples/todos/src/components/TodoAdd.tsx delete mode 100644 examples/todos/src/components/TodoListClass.tsx delete mode 100644 examples/todos/src/components/Todos.tsx delete mode 100644 examples/todos/src/components/User.tsx delete mode 100644 examples/todos/src/models/car.ts delete mode 100644 examples/todos/src/models/user.ts create mode 100644 examples/todos/src/models/visibilityFilter.ts create mode 100644 examples/withModel/.gitgnore create mode 100644 examples/withModel/package.json create mode 100644 examples/withModel/public/index.html create mode 100644 examples/withModel/src/index.tsx create mode 100644 examples/withModel/src/react-app-env.d.ts create mode 100644 examples/withModel/tsconfig.json rename src/{index.ts => index.tsx} (71%) delete mode 100644 src/plugins/effectsStateApis.tsx create mode 100644 tests/helpers/CounterComponent.tsx create mode 100644 tests/helpers/counter.ts create mode 100644 tests/helpers/models.ts create mode 100644 tests/helpers/todos.ts create mode 100644 tests/helpers/user.ts create mode 100644 tests/helpers/utils.ts create mode 100644 tests/utils/appendReducer.spec.ts create mode 100644 tests/utils/converter.spec.ts create mode 100644 tests/utils/validate.spec.ts diff --git a/README.md b/README.md index c70f7c0f..47f3cb22 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ English | [简体中文](./README.zh-CN.md) [![NPM downloads](http://img.shields.io/npm/dm/@ice/store.svg?style=flat)](https://npmjs.org/package/@ice/store) [![Known Vulnerabilities](https://snyk.io/test/npm/@ice/store/badge.svg)](https://snyk.io/test/npm/@ice/store) [![David deps](https://img.shields.io/david/ice-lab/icestore.svg?style=flat-square)](https://david-dm.org/ice-lab/icestore) +[![codecov](https://codecov.io/gh/ice-lab/icestore/branch/master/graph/badge.svg)](https://codecov.io/gh/ice-lab/icestore) @@ -53,7 +54,7 @@ const counter = { decrement:(prevState) => prevState - 1, }, effects: () => ({ - async decrementAsync() { + async asyncDecrement() { await delay(1000); this.decrement(); }, @@ -71,12 +72,12 @@ const store = createStore(models); const { useModel } = store; function Counter() { const [ count, dispatchers ] = useModel('counter'); - const { increment, decrementAsync } = dispatchers; + const { increment, asyncDecrement } = dispatchers; return (
{count} - +
); } @@ -103,17 +104,19 @@ icestore requires React 16.8.0 or later. npm install @ice/store --save ``` -## API +## Documents -[docs/api](./docs/api.md) +- [API](./docs/api.md) +- [Recipes](./docs/recipes.md) +- [Upgrade Guidelines](./docs/upgrade-guidelines.md) +- [Migration](./docs/migration.md) -## Recipes +## Examples -[docs/recipes](./docs/recipes.md) - -## Upgrade Guidelines - -[docs/upgrade-guidelines](./docs/upgrade-guidelines.md) +- [Counter](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/counter) +- [Todos](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/todos) +- [Class Component Support](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/classComponent) +- [withModel](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/withModel) ## Browser Compatibility diff --git a/README.zh-CN.md b/README.zh-CN.md index ffbc66da..aa313d64 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,6 +10,7 @@ [![NPM downloads](http://img.shields.io/npm/dm/@ice/store.svg?style=flat)](https://npmjs.org/package/@ice/store) [![Known Vulnerabilities](https://snyk.io/test/npm/@ice/store/badge.svg)](https://snyk.io/test/npm/@ice/store) [![David deps](https://img.shields.io/david/ice-lab/icestore.svg?style=flat-square)](https://david-dm.org/ice-lab/icestore) +[![codecov](https://codecov.io/gh/ice-lab/icestore/branch/master/graph/badge.svg)](https://codecov.io/gh/ice-lab/icestore)
@@ -53,7 +54,7 @@ const counter = { decrement:(prevState) => prevState - 1, }, effects: () => ({ - async decrementAsync() { + async asyncDecrement() { await delay(1000); this.decrement(); }, @@ -71,12 +72,12 @@ const store = createStore(models); const { useModel } = store; function Counter() { const [ count, dispatchers ] = useModel('counter'); - const { increment, decrementAsync } = dispatchers; + const { increment, asyncDecrement } = dispatchers; return (
{count} - +
); } @@ -103,17 +104,19 @@ ReactDOM.render(, rootElement); npm install @ice/store --save ``` -## API +## 文档 -[docs/api](./docs/api.zh-CN.md) +- [API](./docs/api.zh-CN.md) +- [更多技巧](./docs/recipes.zh-CN.md) +- [从老版本升级](./docs/upgrade-guidelines.zh-CN.md) +- [从其他方案迁移](./docs/migration.zh-CN.md) -## 更多技巧 +## 示例 -[docs/recipes](./docs/recipes.zh-CN.md) - -## 从老版本升级 - -[docs/upgrade-guidelines](./docs/upgrade-guidelines.zh-CN.md) +- [Counter](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/counter) +- [Todos](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/todos) +- [Class Component Support](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/classComponent) +- [withModel](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/withModel) ## 浏览器支持 diff --git a/codecov.yml b/codecov.yml index 1be6c967..9255ed44 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,4 +1,12 @@ -comment: off +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false + require_base: no + require_head: yes + branches: + - "master" + coverage: status: project: diff --git a/docs/api.md b/docs/api.md index c63d5079..bb8bef73 100644 --- a/docs/api.md +++ b/docs/api.md @@ -51,25 +51,23 @@ const model = { ##### reducers -`reducers: { [string]: (prevState, payload) => any }` +`reducers: { [string]: (state, payload) => any }` An object of functions that change the model's state. These functions take the model's previous state and a payload, use mutable method to achieve immutable state. These should be pure functions relying only on the state and payload args to compute the next state. For code that relies on the "outside world" (impure functions like api calls, etc.), use effects. +e.g.: + ```js -const todo = { +const todos = { state: [ { - todo: 'Learn typescript', + title: 'Learn typescript', done: true, }, - { - todo: 'Try immer', - done: false, - }, ], reducers: { - done(state) { - state.push({ todo: 'Tweet about it' }); // array updated directly + foo(state) { + state.push({ title: 'Tweet about it' }); // array updated directly state[1].done = true; }, }, @@ -92,11 +90,43 @@ const count = { See [docs/recipes](./recipes.md#immutable-description) for more details. +The second parameter of reducer is the parameter passed when calling: + +```js +const todos = { + state: [ + { + title: 'Learn typescript', + done: true, + }, + ], + reducers: { + // correct + add(state, todo) { + state.push(todo); + }, + // wrong + add(state, title, done) { + state.push({ title, done }); + }, + }, +}; + +// use: +function Component() { + const { add } = store.useModelDispathers('todos'); + function handleClick () { + add({ title: 'Learn React', done: false }); // correct + add('Learn React', false); // wrong + } +} +``` + ##### effects `effects: (dispatch) => ({ [string]: (payload, rootState) => void })` -An object of functions that can handle the world outside of the model. Effects provide a simple way of handling async actions when used with async/await. In effects, call `this.reducerFoo` to update model's state: +An object of functions that can handle the world outside of the model. These functions take payload and rootstate, sprovide a simple way of handling async actions when used with async/await. In effects, call `this.reducerFoo` to update model's state: ```js const counter = { @@ -105,7 +135,7 @@ const counter = { decrement:(prevState) => prevState - 1, }, effects: () => ({ - async decrementAsync() { + async asyncDecrement() { await delay(1000); // do some asynchronous operations this.decrement(); // pass the result to a local reducer }, @@ -397,6 +427,19 @@ export default withModel( )(TodoList); ``` +#### useModelState + +`useModelState(name: string): state` + +The hooks use the state of the model and subscribe to its updates. + +```js +function FunctionComponent() { + const state = useModelState('counter'); + console.log(state.value); +} +``` + #### useModelDispatchers `useModelDispatchers(name: string): dispatchers` @@ -573,3 +616,71 @@ function FunctionComponent() { ); } ``` + +## withModel + +`withModel(model, mapModelToProps?, options?)(ReactFunctionComponent)` + +This method is used to quickly use model in component. + +```js +import { withModel } from '@ice/store'; +import model from './model'; + +function Todos({ model }) { + const { + useState, + useDispatchers, + useEffectsState, + getState, + getDispatchers, + } = model; + const [ state, dispatchers ] = useValue(); +} + +export default withModel(model)(Todos); +``` + +### Arguments + +#### modelConfig + +Consistent with modelConfig in the createStore. + +#### mapModelToProps + +`mapModelToProps = (model) => ({ model })` + +Use this function to customize the value mapped to the component, for example: + +```js +import { withModel } from '@ice/store'; +import model from './model'; + +function Todos({ todo }) { + const [ state, dispatchers ] = todo.useValue(); +} + +export default withModel(model, function(model) { + return { todo: model }; +})(Todos); +``` + +#### options + +The same with createStore. + +### Returns + +- useValue +- useState +- useDispathers +- useEffectsState +- getValue +- getState +- getDispatchers +- withValue +- withDispatchers +- withModelEffectsState + +Its usage refers to the return value of createStore. diff --git a/docs/api.zh-CN.md b/docs/api.zh-CN.md index 1fef6724..0144c1d0 100644 --- a/docs/api.zh-CN.md +++ b/docs/api.zh-CN.md @@ -37,6 +37,20 @@ const { #### models +`createStore({ [string]: modelConfig });` + +```js +import { createStore } from '@ice/store' + +const count = { + state: 0, +}; + +createStore({ + count +}); +``` + ##### state `state: any`: 必填 @@ -51,32 +65,32 @@ const model = { ##### reducers -`reducers: { [string]: (prevState, payload) => any }` +`reducers: { [string]: (state, payload) => any }` + +一个改变该模型状态的函数集合。这些方法以模型的上一次 state 和一个 payload 作为入参,在方法中使用可变的方式来更新状态。 +这些方法应该是仅依赖于 state 和 payload 参数来计算下一个 state 的纯函数。对于有副作用的函数,请使用 effects。 -一个改变该模型状态的函数集合。这些方法以模型的上一次 state 和一个 payload 作为入参,在方法中使用可变的方式来更新状态。这些方法应该是仅依赖于 state 和 payload 参数来计算下一个 state 的纯函数。对于有副作用的函数,请使用 effects。 +一个简单的示例: ```js -const todo = { +const todos = { state: [ { - todo: 'Learn typescript', + title: 'Learn typescript', done: true, }, - { - todo: 'Try immer', - done: false, - }, ], reducers: { - done(state) { - state.push({ todo: 'Tweet about it' }); // 直接更新了数组 + foo(state) { + state.push({ title: 'Tweet about it' }); // 直接更新了数组 state[1].done = true; }, }, -} +}; ``` -icestore 内部是通过调用 [immer](https://github.com/immerjs/immer) 来实现可变状态的。Immer 只支持对普通对象和数组的变化检测,所以像字符串或数字这样的类型需要返回一个新值。 例如: +icestore 内部是通过调用 [immer](https://github.com/immerjs/immer) 来实现可变状态的。 +Immer 只支持对普通对象和数组的变化检测,所以像字符串或数字这样的类型需要返回一个新值。 例如: ```js const count = { @@ -92,11 +106,43 @@ const count = { 参考 [docs/recipes](./recipes.zh-CN.md#可变状态的说明) 了解更多。 +reducer 的第二个参数即是调用时传递的参数: + +```js +const todos = { + state: [ + { + title: 'Learn typescript', + done: true, + }, + ], + reducers: { + // 正确用法 + add(state, todo) { + state.push(todo); + }, + // 错误用法 + add(state, title, done) { + state.push({ title, done }); + }, + }, +}; + +// 使用时: +function Component() { + const { add } = store.useModelDispathers('todos'); + function handleClick () { + add({ title: 'Learn React', done: false }); // 正确用法 + add('Learn React', false); // 错误用法 + } +} +``` + ##### effects `effects: (dispatch) => ({ [string]: (payload, rootState) => void })` -一个可以处理该模型副作用的函数集合。Effects 适用于进行异步调用、[模型联动](recipes.zh-CN.md#模型联动)等场景。在 effects 内部,通过调用 `this.reducerFoo` 来更新模型状态: +一个可以处理该模型副作用的函数集合。这些方法以 payload 和 rootState 作为入参,适用于进行异步调用、[模型联动](recipes.zh-CN.md#模型联动)等场景。在 effects 内部,通过调用 `this.reducerFoo` 来更新模型状态: ```js const counter = { @@ -105,7 +151,7 @@ const counter = { decrement:(prevState) => prevState - 1, }, effects: () => ({ - async decrementAsync() { + async asyncDecrement() { await delay(1000); // 进行一些异步操作 this.decrement(); // 调用模型 reducers 内的方法来更新状态 }, @@ -113,7 +159,7 @@ const counter = { }; ``` -> 注意:如果您正在使用 TypeScript 并且配置了编译选项 `noImplicitThis: ture`,则会遇到类似 "Property 'setState' does not exist on type" 的编译错误。您可以通过删除该编译选项,或者使用下面示例中的 `dispatch.model.reducer` 来避免此错误。 +> 注意:如果您正在使用 TypeScript ,并且配置了编译选项 `noImplicitThis: ture`,则会遇到类似 "Property 'setState' does not exist on type" 的编译错误。您可以通过删除该编译选项,或者使用下面示例中的 `dispatch.model.reducer` 来避免此错误。 ###### 同名处理 @@ -397,6 +443,19 @@ export default withModel( )(TodoList); ``` +#### useModelState + +`useModelState(name: string): state` + +通过该 hooks 使用模型的状态并订阅其更新。 + +```js +function FunctionComponent() { + const state = useModelState('counter'); + console.log(state.value); +} +``` + #### useModelDispatchers `useModelDispatchers(name: string): dispatchers` @@ -573,3 +632,71 @@ function FunctionComponent() { ); } ``` + +## withModel + +`withModel(model, mapModelToProps?, options?)(ReactFunctionComponent)` + +该方法用于在组件中快速使用 Model。 + +```js +import { withModel } from '@ice/store'; +import model from './model'; + +function Todos({ model }) { + const { + useState, + useDispatchers, + useEffectsState, + getState, + getDispatchers, + } = model; + const [ state, dispatchers ] = useValue(); +} + +export default withModel(model)(Todos); +``` + +### 参数 + +#### modelConfig + +与 createStore 方法中的 modelConfig 一致。 + +#### mapModelToProps + +`mapModelToProps = (model) => ({ model })` + +使用该函数来自定义映射到组件中的值,使用示例: + +```js +import { withModel } from '@ice/store'; +import model from './model'; + +function Todos({ todo }) { + const [ state, dispatchers ] = todo.useValue(); +} + +export default withModel(model, function(model) { + return { todo: model }; +})(Todos); +``` + +#### options + +与 createStore 方法中的 options 一致。 + +### 返回值 + +- useValue +- useState +- useDispathers +- useEffectsState +- getValue +- getState +- getDispatchers +- withValue +- withDispatchers +- withModelEffectsState + +其用法参考 createStore 的返回值。 diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 00000000..61f0a477 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,280 @@ +--- +id: migration +title: Migration +--- + +## Migrating From Redux + +We provide a gradual solution to allow your project to be partially migrated from Redux to icestore. + +> Requires React 16.8.0 or later & React-Redux 7.0.0 or later. + +### Step 1: Migrating createStore + +See: [CodeSandbox](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/migration-redux-1?module=/src/index.js) + +#### Redux createStore + +```js +import { createStore, combineReducers } from 'redux'; + +import sharks from './reducers/sharks'; +import dolphins from './reducers/dolphins'; + +const rootReducer = combineReducers({ + sharks, + dolphins +}); +const store = createStore(rootReducer); +``` + +#### icestore createStore + +```js +import { createStore } from 'icestore'; + +import sharks from './reducers/sharks'; +import dolphins from './reducers/dolphins'; + +// Using createStore from icestore +const store = createStore( + { /* No models */ }, + { + redux: { + reducers: { + sharks, + dolphins + } + } + } +); +``` + +### Step 2: Mix reducers & models + +You can locally and incrementally replace the Redux Reducer in your project with icestore Model. + +See: [CodeSandbox](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/migration-redux-2?module=/src/index.js) + +#### Declaration + +##### Redux's Reducer + +```js +const INCREMENT = 'sharks/increment'; + +export const incrementSharks = (payload) => ({ + type: INCREMENT, + payload, +}); + +export default (state = 0, action) => { + switch(action.type) { + case INCREMENT: + return state + action.payload; + default: + return state; + } +}; +``` + +##### icestore's Model + +```js +export default { + state: 0, + reducers: { + increment: (state, payload) => state + payload + } +} +``` + +#### Consumer + +##### Redux in mapDispatch + +```js +import { connect } from 'react-redux'; +import { incrementSharks } from './reducers/sharks'; +import { incrementDolphins } from './reducers/dolphins'; + +const mapDispatch = dispatch => ({ + incrementSharks: () => dispatch(incrementSharks(1)), + incrementDolphins: () => dispatch(incrementDolphins(1)) +}); + +export default connect(undefined, mapDispatch)(ReactComponent); +``` + +##### icestore in mapDispatch + +```js +import { connect } from 'react-redux'; +import { incrementDolphins } from './reducers/dolphins'; + +const mapDispatch = dispatch => ({ + // important!!! + incrementSharks: () => dispatch.sharks.increment(1), + incrementDolphins: () => dispatch(incrementDolphins(1)), +}); + +export default connect(undefined, mapDispatch)(ReactComponent); +``` + +### Step 3: Migrating Provider + +See: [CodeSandbox](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/migration-redux-3?module=/src/index.js) + +#### Migrating from react-redux Provider + +##### react-redux + +```js +import { Provider } from 'react-redux'; +import App from './App'; +import store from './store'; + +const Root = () => ( + + + +); +``` + +##### icestore + +```js +import App from './App'; +import store from './store'; + +const Root = () => ( + + + +); +``` + +#### react-redux Hooks compatible + +##### Origin + +```js +import { useSelector, useDispatch } from 'react-redux'; + +export default function() { + const sharks = useSelector(state => state.sharks); + const dispatch = useDispatch(); + dispatch.sharks.increment(); +} +``` + +##### Now + +```js +import { createSelectorHook, createDispatchHook } from 'react-redux'; +import store from './store'; + +// Create Redux hooks using the context provided by the store +const useSelector = createSelectorHook(store.context); +const useDispatch = createDispatchHook(store.context); + +export default function() { + const sharks = useSelector(state => state.sharks); + const dispatch = useDispatch(); + dispatch.sharks.increment(); +} +``` + +#### react-redux connect compatible + +##### Origin + +```js +import { connect } from 'react-redux'; + +export default connect( + mapState, + mapDispatch +)(ReactComponent); +``` + +##### Now + +```js +import { connect } from 'react-redux'; +import store from './store'; + +export default connect( + mapState, + mapDispatch, + mergeProps, + + // Pass the context provided by the store to the connect function + { context: store.context } +)(ReactComponent); +``` + +### Step 4: Migrating From react-redux + +You can locally and incrementally replace the react Redux API in your project with the icestore API. + +See: [CodeSandbox](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/migration-redux-4?module=/src/index.js) + +#### Migrating From react-redux Hooks + +```js +import { useSelector, useDispatch } from './reudx'; +import store from './store'; + +function Component (){ + // const sharks = useSelector(state => state.sharks); + const sharks = store.useModelState('sharks'); + // const dispatch = useDispatch(); + // dispatch.sharks.increment(); + const dispatchers = store.useModelDispathers('sharks'); + dispatchers.increment(); +} +``` + +#### Migrating From react-redux connect + +##### Origin + +```js +import { connect } from 'react-reudx'; + +function Count(props) { + console.log(props.dolphins); + props.incrementDolphins(); +} + +const mapState = state => ({ + dolphins: state.dolphins +}); + +const mapDispatch = dispatch => ({ + incrementDolphins: dispatch.dolphins.increment +}); + +export default connect( + mapState, + mapDispatch, + undefined, + { context: store.context } +)(Count); +``` + +##### Now + +```js +import store from './store'; +const { withModel } = store; + +function Count(props) { + const [dolphins, { increment }] = props.dolphins; + console.log(dolphins); + increment(); +} + +withModel('dolphins')(Count); +``` diff --git a/docs/migration.zh-CN.md b/docs/migration.zh-CN.md new file mode 100644 index 00000000..51ae5d05 --- /dev/null +++ b/docs/migration.zh-CN.md @@ -0,0 +1,280 @@ +--- +id: migration +title: Migration +--- + +## 从 Redux 迁移 + +我们提供了渐进式的方案使得您的项目可以局部从 Redux 迁移到 icestore。 + +> 请确保在项目中使用的 react-redux >= 7.0.0 且 react >= 16.8.0 。 + +### 第一步:替换 createStore 方法 + +参考:[CodeSandbox](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/migration-redux-1?module=/src/index.js) + +#### Redux 创建 Store 的方式 + +```js +import { createStore, combineReducers } from 'redux'; + +import sharks from './reducers/sharks'; +import dolphins from './reducers/dolphins'; + +const rootReducer = combineReducers({ + sharks, + dolphins +}); +const store = createStore(rootReducer); +``` + +#### icestore 创建 Store 的方式 + +```js +import { createStore } from 'icestore'; + +import sharks from './reducers/sharks'; +import dolphins from './reducers/dolphins'; + +// 使用 icestore 的 createStore 方法 +const store = createStore( + { /* No models */ }, + { + redux: { + reducers: { + sharks, + dolphins + } + } + } +); +``` + +### 第二步:将 reducer 替换为 model + +您可以局部渐进式地将项目中的 Redux reducer 替换为 icestore model。 + +参考:[CodeSandbox](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/migration-redux-2?module=/src/index.js) + +#### 声明层 + +##### Redux 中 reducer 的声明 + +```js +const INCREMENT = 'sharks/increment'; + +export const incrementSharks = (payload) => ({ + type: INCREMENT, + payload, +}); + +export default (state = 0, action) => { + switch(action.type) { + case INCREMENT: + return state + action.payload; + default: + return state; + } +}; +``` + +##### icestore 中 model 的声明 + +```js +export default { + state: 0, + reducers: { + increment: (state, payload) => state + payload + } +} +``` + +#### 消费层 + +##### Redux 中 mapDispatch 的返回值 + +```js +import { connect } from 'react-redux'; +import { incrementSharks } from './reducers/sharks'; +import { incrementDolphins } from './reducers/dolphins'; + +const mapDispatch = dispatch => ({ + incrementSharks: () => dispatch(incrementSharks(1)), + incrementDolphins: () => dispatch(incrementDolphins(1)) +}); + +export default connect(undefined, mapDispatch)(ReactComponent); +``` + +##### icestore 中 mapDispatch 的返回值 + +```js +import { connect } from 'react-redux'; +import { incrementDolphins } from './reducers/dolphins'; + +const mapDispatch = dispatch => ({ + // 注意这一行的区别! + incrementSharks: () => dispatch.sharks.increment(1), + incrementDolphins: () => dispatch(incrementDolphins(1)), +}); + +export default connect(undefined, mapDispatch)(ReactComponent); +``` + +### 第三步:替换 Provider + +参考:[CodeSandbox](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/migration-redux-3?module=/src/index.js) + +#### 将 react-redux Provider 替换为 icestore Provider + +##### react-redux + +```js +import { Provider } from 'react-redux'; +import App from './App'; +import store from './store'; + +const Root = () => ( + + + +); +``` + +##### icestore + +```js +import App from './App'; +import store from './store'; + +const Root = () => ( + + + +); +``` + +#### 兼容 react-redux Hooks + +##### 原 react-redux Hooks 用法 + +```js +import { useSelector, useDispatch } from 'react-redux'; + +export default function() { + const sharks = useSelector(state => state.sharks); + const dispatch = useDispatch(); + dispatch.sharks.increment(); +} +``` + +##### 兼容做法 + +```js +import { createSelectorHook, createDispatchHook } from 'react-redux'; +import store from './store'; + +// 使用 store 提供的 context 创建 Redux Hooks +const useSelector = createSelectorHook(store.context); +const useDispatch = createDispatchHook(store.context); + +export default function() { + const sharks = useSelector(state => state.sharks); + const dispatch = useDispatch(); + dispatch.sharks.increment(); +} +``` + +#### 兼容 react-redux connect + +##### 原 react-redux connect 用法 + +```js +import { connect } from 'react-redux'; + +export default connect( + mapState, + mapDispatch +)(ReactComponent); +``` + +##### 兼容做法 + +```js +import { connect } from 'react-redux'; +import store from './store'; + +export default connect( + mapState, + mapDispatch, + mergeProps, + + // 传递 store 提供的 context 给 connect 函数 + { context: store.context } +)(ReactComponent); +``` + +### 第四步:将 react-redux 替换为 icestore + +您可以局部渐进式地将项目中的 react-redux API 替换为 icestore API。 + +参考:[CodeSandbox](https://codesandbox.io/s/github/ice-lab/icestore/tree/master/examples/migration-redux-4?module=/src/index.js) + +#### 替换 react-redux Hooks + +```js +import { useSelector, useDispatch } from './reudx'; +import store from './store'; + +function Component (){ + // const sharks = useSelector(state => state.sharks); + const sharks = store.useModelState('sharks'); + // const dispatch = useDispatch(); + // dispatch.sharks.increment(); + const dispatchers = store.useModelDispathers('sharks'); + dispatchers.increment(); +} +``` + +#### 替换 react-redux connect + +##### 原 react-redux connect 用法 + +```js +import { connect } from 'react-reudx'; + +function Count(props) { + console.log(props.dolphins); + props.incrementDolphins(); +} + +const mapState = state => ({ + dolphins: state.dolphins +}); + +const mapDispatch = dispatch => ({ + incrementDolphins: dispatch.dolphins.increment +}); + +export default connect( + mapState, + mapDispatch, + undefined, + { context: store.context } +)(Count); +``` + +##### 使用 icestore API + +```js +import store from './store'; +const { withModel } = store; + +function Count(props) { + const [dolphins, { increment }] = props.dolphins; + console.log(dolphins); + increment(); +} + +withModel('dolphins')(Count); +``` diff --git a/docs/recipes.md b/docs/recipes.md index c49bce68..36506a0d 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -134,7 +134,7 @@ import store from '@/store'; const user = { effects: dispatch => ({ - async addByAsync(payload, state) { + async asyncAdd(payload, state) { dispatch.todos.addTodo(payload); // Call methods of other models to update their state const todos = store.getModelState('todos'); // Get the latest state of the updated model } diff --git a/docs/recipes.zh-CN.md b/docs/recipes.zh-CN.md index 670bd6df..83041df2 100644 --- a/docs/recipes.zh-CN.md +++ b/docs/recipes.zh-CN.md @@ -132,7 +132,7 @@ import store from '@/store'; const user = { effects: dispatch => ({ - async addByAsync(payload, state) { + async asyncAdd(payload, state) { dispatch.todos.addTodo(payload); // 调用其他模型的方法更新其状态 const todos = store.getModelState('todos'); // 获取更新后的模型最新状态 } diff --git a/docs/upgrade-guidelines.md b/docs/upgrade-guidelines.md index 6cc5778a..a60bf03c 100644 --- a/docs/upgrade-guidelines.md +++ b/docs/upgrade-guidelines.md @@ -5,7 +5,7 @@ title: Upgrade Guidelines English | [简体中文](./upgrade-guidelines.zh-CN.md) -## 1.2.0 to 1.3.0 +## Upgrade from 1.2.0 to 1.3.0 From 1.2.0 to 1.3.0 is fully compatible, but we recommend that you use the new API in incremental code. We will remove the deprecated API in future versions. @@ -123,7 +123,7 @@ export default withModelDispatchers('todos')(TodoList); - ModelEffectsState => ExtractIModelEffectsStateFromModelConfig - UseModelValue => ExtractIModelFromModelConfig -## 1.0.0 to 1.3.0 +## Upgrade from 1.0.0 to 1.3.0 From 1.0.0 to 1.3.0 is fully compatible, but we recommend that you use the new API in incremental code. We will remove the deprecated API in future versions. @@ -139,13 +139,13 @@ const counter = { }, actions: { increment:(state) => ({ value: state.value + 1 }), - async incrementAsync(state, payload, actions, globalActions) { + async asyncIncrement(state, payload, actions, globalActions) { console.log(state); // 0 await delay(1000); actions.increment(); globalActions.todo.refresh(); }, - async decrementAsync(state) { + async asyncDecrement(state) { await delay(1000); return { value: state.value - 1 }; }, @@ -164,13 +164,13 @@ const counter = { increment:(prevState) => ({ value: prevState.value + 1 }), }, effects: (dispatch) => ({ - async incrementAsync(payload, rootState) { + async asyncIncrement(payload, rootState) { console.log(rootState.counter); // 0 await delay(1000); this.increment(); dispatch.todo.refresh(); }, - async decrementAsync(payload, rootState) { + async asyncDecrement(payload, rootState) { await delay(1000); this.setState({ value: rootState.counter.value - 1 }); // setState is a built-in reducer }, @@ -238,7 +238,7 @@ class TodoList extends Component { export default withModelEffectsState('todos')(TodoList); ``` -## 0.x.x to 1.x.x +## Upgrade from 0.x.x to 1.x.x ### Define Model diff --git a/docs/upgrade-guidelines.zh-CN.md b/docs/upgrade-guidelines.zh-CN.md index 7f8d6241..089ccc8c 100644 --- a/docs/upgrade-guidelines.zh-CN.md +++ b/docs/upgrade-guidelines.zh-CN.md @@ -5,7 +5,7 @@ title: Upgrade Guidelines [English](./upgrade-guidelines.md) | 简体中文 -## 1.2.0 to 1.3.0 +## 从 1.2.0 升级到 1.3.0 1.3.0 是完全向下兼容的,但是我们推荐您在新增代码中使用最新的 API。 @@ -126,7 +126,7 @@ export default withModelDispatchers('todos')(TodoList); - ModelEffectsState => ExtractIModelEffectsStateFromModelConfig - UseModelValue => ExtractIModelFromModelConfig -## 1.0.0 to 1.3.0 +## 从 1.0.0 升级到 1.3.0 1.3.0 是完全向下兼容的,但是我们推荐您在新增代码中使用最新的 API。 @@ -143,13 +143,13 @@ const counter = { }, actions: { increment:(state) => ({ value: state.value + 1 }), - async incrementAsync(state, payload, actions, globalActions) { + async asyncIncrement(state, payload, actions, globalActions) { console.log(state); // 0 await delay(1000); actions.increment(); globalActions.todo.refresh(); }, - async decrementAsync(state) { + async asyncDecrement(state) { await delay(1000); return { value: state.value - 1 }; }, @@ -168,13 +168,13 @@ const counter = { increment:(prevState) => ({ value: prevState.value + 1 }), }, effects: (dispatch) => ({ - async incrementAsync(payload, rootState) { + async asyncIncrement(payload, rootState) { console.log(rootState.counter); // 0 await delay(1000); this.increment(); dispatch.todo.refresh(); }, - async decrementAsync(payload, rootState) { + async asyncDecrement(payload, rootState) { await delay(1000); this.setState({ value: rootState.counter.value - 1 }); // setState 是一个内置的 reducer }, @@ -242,7 +242,7 @@ class TodoList extends Component { export default withModelEffectsState('todos')(TodoList); ``` -## 0.x.x to 1.x.x +## 从 0.x.x 升级到 1.x.x 从 0.x.x 到 1.x.x 是不兼容的。您可以选择性地进行升级。 diff --git a/examples/classComponent/.gitgnore b/examples/classComponent/.gitgnore new file mode 100644 index 00000000..378eac25 --- /dev/null +++ b/examples/classComponent/.gitgnore @@ -0,0 +1 @@ +build diff --git a/examples/classComponent/package.json b/examples/classComponent/package.json new file mode 100644 index 00000000..256cd72f --- /dev/null +++ b/examples/classComponent/package.json @@ -0,0 +1,35 @@ +{ + "name": "counter", + "version": "1.0.0", + "private": true, + "dependencies": { + "@ice/store": "^1.4.0", + "react": "^16.8.6", + "react-dom": "^16.8.6" + }, + "devDependencies": { + "@types/jest": "^24.0.0", + "@types/node": "^12.0.0", + "@types/react": "^16.9.0", + "@types/react-dom": "^16.9.0", + "react-scripts": "3.4.0", + "typescript": "^3.7.5", + "utility-types": "^3.10.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/classComponent/public/index.html b/examples/classComponent/public/index.html new file mode 100644 index 00000000..492089d1 --- /dev/null +++ b/examples/classComponent/public/index.html @@ -0,0 +1,15 @@ + + + + + + + + Counter App + + + +
+ + + diff --git a/examples/classComponent/src/components/Product/Class.tsx b/examples/classComponent/src/components/Product/Class.tsx new file mode 100644 index 00000000..f6e3ccdc --- /dev/null +++ b/examples/classComponent/src/components/Product/Class.tsx @@ -0,0 +1,42 @@ +/* eslint-disable react/prefer-stateless-function */ +import React from 'react'; +import { Assign } from 'utility-types'; +import { withModel, ExtractIModelAPIsFromModelConfig, ExtractIModelFromModelConfig } from '@ice/store'; +import Product from './Product'; + +import model from './model'; + +interface CustomProp { + title: string; +} + +interface MapModelToComponentProp { + model: ExtractIModelFromModelConfig; +} + +type ComponentProps = Assign; + +class Component extends React.Component{ + render() { + const { model, title } = this.props; + const [ state ] = model; + return ( + + ); + } +} + +interface MapModelToProp { + model: ExtractIModelAPIsFromModelConfig; +} + +type Props = Assign; + +export default withModel(model)(function ({ model, ...otherProps }) { + const ComponentWithModel = model.withValue()(Component); + return ; +}); diff --git a/examples/classComponent/src/components/Product/Function.tsx b/examples/classComponent/src/components/Product/Function.tsx new file mode 100644 index 00000000..4dd81746 --- /dev/null +++ b/examples/classComponent/src/components/Product/Function.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Assign } from 'utility-types'; +import { withModel, ExtractIModelAPIsFromModelConfig } from '@ice/store'; +import Product from './Product'; +import model from './model'; + +interface MapModelToProp { + model: ExtractIModelAPIsFromModelConfig; +} + +interface CustomProp { + title: string; +} + +type Props = Assign; + +function Component({ model, title }: Props) { + const [product] = model.useValue(); + return ( + + ); +} + +export default withModel(model)(Component); diff --git a/examples/classComponent/src/components/Product/Product.tsx b/examples/classComponent/src/components/Product/Product.tsx new file mode 100644 index 00000000..aec37ab5 --- /dev/null +++ b/examples/classComponent/src/components/Product/Product.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export default function({ productTitle, title, type }) { + return ( +
+
+
+ Component Type is: {type} +
+

{title}: {productTitle}

+
+ ); +} diff --git a/examples/classComponent/src/components/Product/index.ts b/examples/classComponent/src/components/Product/index.ts new file mode 100644 index 00000000..d7079a13 --- /dev/null +++ b/examples/classComponent/src/components/Product/index.ts @@ -0,0 +1,4 @@ +// import Component from './Function'; +import Component from './Class'; + +export default Component; diff --git a/examples/classComponent/src/components/Product/model.ts b/examples/classComponent/src/components/Product/model.ts new file mode 100644 index 00000000..d1946a88 --- /dev/null +++ b/examples/classComponent/src/components/Product/model.ts @@ -0,0 +1,5 @@ +export default { + state: { + title: 'foo', + }, +}; diff --git a/examples/classComponent/src/components/User/Class.tsx b/examples/classComponent/src/components/User/Class.tsx new file mode 100644 index 00000000..625bb065 --- /dev/null +++ b/examples/classComponent/src/components/User/Class.tsx @@ -0,0 +1,36 @@ +/* eslint-disable react/prefer-stateless-function */ +import React from 'react'; +import { Assign } from 'utility-types'; +import { ExtractIModelFromModelConfig } from '@ice/store'; +import store from '../../store'; +import User from './User'; +import userModel from '../../models/user'; + +const { withModel } = store; + +interface PropsWithModel { + user: ExtractIModelFromModelConfig; +} + +interface CustomProp { + title: string; +} + +type Props = Assign; + +class Component extends React.Component { + render() { + const { title, user } = this.props; + const [ state ] = user; + const { name } = state; + return User({ + name, + title, + type: 'Class', + }); + } +} + +export default withModel('user')( + Component, +); diff --git a/examples/classComponent/src/components/User/Function.tsx b/examples/classComponent/src/components/User/Function.tsx new file mode 100644 index 00000000..ed9a6e04 --- /dev/null +++ b/examples/classComponent/src/components/User/Function.tsx @@ -0,0 +1,15 @@ +import store from '../../store'; +import User from './User'; + +const { useModel } = store; + +export default function({ title }) { + const [ state ] = useModel('user'); + return User( + { + type: 'Function', + name: state.name, + title, + }, + ); +} diff --git a/examples/classComponent/src/components/User/User.tsx b/examples/classComponent/src/components/User/User.tsx new file mode 100644 index 00000000..67cc4fc4 --- /dev/null +++ b/examples/classComponent/src/components/User/User.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export default function({ name, title, type }) { + return ( +
+
+
+ Component Type is: {type} +
+

{title}: {name}

+
+ ); +} diff --git a/examples/classComponent/src/components/User/index.ts b/examples/classComponent/src/components/User/index.ts new file mode 100644 index 00000000..d7079a13 --- /dev/null +++ b/examples/classComponent/src/components/User/index.ts @@ -0,0 +1,4 @@ +// import Component from './Function'; +import Component from './Class'; + +export default Component; diff --git a/examples/classComponent/src/index.tsx b/examples/classComponent/src/index.tsx new file mode 100644 index 00000000..c3d18560 --- /dev/null +++ b/examples/classComponent/src/index.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import store from './store'; +import User from './components/User'; +import Product from './components/Product'; + +const { Provider } = store; + +function App() { + return ( + + + + + ); +} + +const rootElement = document.getElementById('root'); +ReactDOM.render(, rootElement); diff --git a/examples/classComponent/src/models/index.ts b/examples/classComponent/src/models/index.ts new file mode 100644 index 00000000..6aba21ac --- /dev/null +++ b/examples/classComponent/src/models/index.ts @@ -0,0 +1,11 @@ +import { Models } from '@ice/store'; +import user from './user'; + +const rootModels: RootModels = { user }; + +// add interface to avoid recursive type checking +export interface RootModels extends Models { + user: typeof user; +} + +export default rootModels; diff --git a/examples/classComponent/src/models/user.ts b/examples/classComponent/src/models/user.ts new file mode 100644 index 00000000..c74fcf9a --- /dev/null +++ b/examples/classComponent/src/models/user.ts @@ -0,0 +1,5 @@ +export default { + state: { + name: 'Icestore', + }, +}; diff --git a/examples/classComponent/src/react-app-env.d.ts b/examples/classComponent/src/react-app-env.d.ts new file mode 100644 index 00000000..30da8962 --- /dev/null +++ b/examples/classComponent/src/react-app-env.d.ts @@ -0,0 +1 @@ +// / diff --git a/examples/classComponent/src/store.ts b/examples/classComponent/src/store.ts new file mode 100644 index 00000000..d8d5d9ac --- /dev/null +++ b/examples/classComponent/src/store.ts @@ -0,0 +1,11 @@ + +import { createStore, IcestoreRootState, IcestoreDispatch } from '@ice/store'; +import models from './models'; + +const store = createStore(models); + +export default store; +export type Models = typeof models; +export type Store = typeof store; +export type RootDispatch = IcestoreDispatch; +export type RootState = IcestoreRootState; diff --git a/examples/classComponent/tsconfig.json b/examples/classComponent/tsconfig.json new file mode 100644 index 00000000..171592f7 --- /dev/null +++ b/examples/classComponent/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": [ + "src" + ] +} diff --git a/examples/counter/src/index.tsx b/examples/counter/src/index.tsx index 63830f49..de0a0689 100644 --- a/examples/counter/src/index.tsx +++ b/examples/counter/src/index.tsx @@ -12,7 +12,7 @@ const counter = { decrement:(prevState) => prevState - 1, }, effects: () => ({ - async decrementAsync() { + async asyncDecrement() { await delay(1000); this.decrement(); }, @@ -30,12 +30,12 @@ const store = createStore(models); const { useModel } = store; function Counter() { const [ count, dispatchers ] = useModel('counter'); - const { increment, decrementAsync } = dispatchers; + const { increment, asyncDecrement } = dispatchers; return (
{count} - +
); } diff --git a/examples/migration-redux-1/package.json b/examples/migration-redux-1/package.json new file mode 100644 index 00000000..97d07a18 --- /dev/null +++ b/examples/migration-redux-1/package.json @@ -0,0 +1,31 @@ +{ + "name": "icestore-migration-part-1", + "version": "1.0.0", + "description": "Using icestore with Redux only.", + "private": true, + "dependencies": { + "@ice/store": "^1.4.0", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "react-redux": "^7.2.0" + }, + "devDependencies": { + "react-scripts": "3.4.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/migration-redux-1/public/index.html b/examples/migration-redux-1/public/index.html new file mode 100644 index 00000000..a6140def --- /dev/null +++ b/examples/migration-redux-1/public/index.html @@ -0,0 +1,15 @@ + + + + + + + + redux Migration: Part 1 + + + +
+ + + diff --git a/examples/migration-redux-1/src/App.js b/examples/migration-redux-1/src/App.js new file mode 100644 index 00000000..f5edda44 --- /dev/null +++ b/examples/migration-redux-1/src/App.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { incrementSharks } from './reducers/sharks'; +import { incrementDolphins } from './reducers/dolphins'; + +const Count = props => ( +
+
+
+

Sharks

+

{props.sharks}

+ +
+
+

Dolphins

+

{props.dolphins}

+ +
+
+

Using Redux

+
+); + +const mapState = state => ({ + sharks: state.sharks, + dolphins: state.dolphins, +}); + +const mapDispatch = dispatch => ({ + incrementSharks: () => dispatch(incrementSharks(1)), + incrementDolphins: () => dispatch(incrementDolphins(1)), +}); + +export default connect( + mapState, + mapDispatch, +)(Count); diff --git a/examples/migration-redux-1/src/index.js b/examples/migration-redux-1/src/index.js new file mode 100644 index 00000000..1c8da969 --- /dev/null +++ b/examples/migration-redux-1/src/index.js @@ -0,0 +1,28 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { createStore } from '@ice/store'; +import { Provider } from 'react-redux'; + +import sharks from './reducers/sharks'; +import dolphins from './reducers/dolphins'; +import App from './App'; + +const store = createStore( + {}, + { + redux: { + reducers: { + sharks, + dolphins, + }, + }, + }, +); + +const Root = () => ( + + + +); + +ReactDOM.render(, document.querySelector('#root')); diff --git a/examples/migration-redux-1/src/reducers/dolphins.js b/examples/migration-redux-1/src/reducers/dolphins.js new file mode 100644 index 00000000..8f9ddccd --- /dev/null +++ b/examples/migration-redux-1/src/reducers/dolphins.js @@ -0,0 +1,15 @@ +const INCREMENT = 'dolphins/increment'; + +export const incrementDolphins = (payload) => ({ + type: INCREMENT, + payload, +}); + +export default (state = 0, action) => { + switch (action.type) { + case INCREMENT: + return state + action.payload; + default: + return state; + } +}; diff --git a/examples/migration-redux-1/src/reducers/sharks.js b/examples/migration-redux-1/src/reducers/sharks.js new file mode 100644 index 00000000..0b0c957a --- /dev/null +++ b/examples/migration-redux-1/src/reducers/sharks.js @@ -0,0 +1,15 @@ +const INCREMENT = 'sharks/increment'; + +export const incrementSharks = (payload) => ({ + type: INCREMENT, + payload, +}); + +export default (state = 0, action) => { + switch (action.type) { + case INCREMENT: + return state + action.payload; + default: + return state; + } +}; diff --git a/examples/migration-redux-2/package.json b/examples/migration-redux-2/package.json new file mode 100644 index 00000000..819a421f --- /dev/null +++ b/examples/migration-redux-2/package.json @@ -0,0 +1,31 @@ +{ + "name": "icestore-migration-part-2", + "version": "1.0.0", + "description": "Using icestore with Redux & icestore.", + "private": true, + "dependencies": { + "@ice/store": "^1.4.0", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "react-redux": "^7.2.0" + }, + "devDependencies": { + "react-scripts": "3.4.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/migration-redux-2/public/index.html b/examples/migration-redux-2/public/index.html new file mode 100644 index 00000000..a6140def --- /dev/null +++ b/examples/migration-redux-2/public/index.html @@ -0,0 +1,15 @@ + + + + + + + + redux Migration: Part 1 + + + +
+ + + diff --git a/examples/migration-redux-2/src/App.js b/examples/migration-redux-2/src/App.js new file mode 100644 index 00000000..10978d8a --- /dev/null +++ b/examples/migration-redux-2/src/App.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { incrementDolphins } from './reducers/dolphins'; + +const Count = props => ( +
+
+
+

Sharks

+

{props.sharks}

+ +
+
+

Dolphins

+

{props.dolphins}

+ +
+
+

Mixing Redux & icestore

+
+); + +const mapState = state => ({ + sharks: state.sharks, + dolphins: state.dolphins, +}); + +const mapDispatch = dispatch => ({ + incrementSharks: () => dispatch.sharks.increment(1), + incrementDolphins: () => dispatch(incrementDolphins(1)), +}); + +export default connect( + mapState, + mapDispatch, +)(Count); diff --git a/examples/migration-redux-2/src/index.js b/examples/migration-redux-2/src/index.js new file mode 100644 index 00000000..c37fe3fc --- /dev/null +++ b/examples/migration-redux-2/src/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { createStore } from '@ice/store'; +import { Provider } from 'react-redux'; + +import sharks from './models/sharks'; +import dolphins from './reducers/dolphins'; +import App from './App'; + +const store = createStore( + { sharks }, + { + redux: { + reducers: { + dolphins, + }, + }, + }, +); + +const Root = () => ( + + + +); + +ReactDOM.render(, document.querySelector('#root')); diff --git a/examples/migration-redux-2/src/models/sharks.js b/examples/migration-redux-2/src/models/sharks.js new file mode 100644 index 00000000..e54a0a57 --- /dev/null +++ b/examples/migration-redux-2/src/models/sharks.js @@ -0,0 +1,6 @@ +export default { + state: 0, + reducers: { + increment: (state, payload) => state + payload, + }, +}; diff --git a/examples/migration-redux-2/src/reducers/dolphins.js b/examples/migration-redux-2/src/reducers/dolphins.js new file mode 100644 index 00000000..8f9ddccd --- /dev/null +++ b/examples/migration-redux-2/src/reducers/dolphins.js @@ -0,0 +1,15 @@ +const INCREMENT = 'dolphins/increment'; + +export const incrementDolphins = (payload) => ({ + type: INCREMENT, + payload, +}); + +export default (state = 0, action) => { + switch (action.type) { + case INCREMENT: + return state + action.payload; + default: + return state; + } +}; diff --git a/examples/migration-redux-3/package.json b/examples/migration-redux-3/package.json new file mode 100644 index 00000000..41414807 --- /dev/null +++ b/examples/migration-redux-3/package.json @@ -0,0 +1,31 @@ +{ + "name": "icestore-migration-part-3", + "version": "1.0.0", + "description": "Using icestore with react-redux only.", + "private": true, + "dependencies": { + "@ice/store": "^1.4.0", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "react-redux": "^7.2.0" + }, + "devDependencies": { + "react-scripts": "3.4.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/migration-redux-3/public/index.html b/examples/migration-redux-3/public/index.html new file mode 100644 index 00000000..a6140def --- /dev/null +++ b/examples/migration-redux-3/public/index.html @@ -0,0 +1,15 @@ + + + + + + + + redux Migration: Part 1 + + + +
+ + + diff --git a/examples/migration-redux-3/src/App.js b/examples/migration-redux-3/src/App.js new file mode 100644 index 00000000..1a4eb26d --- /dev/null +++ b/examples/migration-redux-3/src/App.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { connect, useSelector, useDispatch } from './redux'; +import store from './store'; + +const Count = props => { + const sharks = useSelector(state => state.sharks); + const dispatch = useDispatch(); + + return ( +
+
+
+

Sharks

+

{sharks}

+ +
+
+

Dolphins

+

{props.dolphins}

+ +
+
+

Using react-redux

+
+ ); +}; + +const mapState = state => ({ + dolphins: state.dolphins, +}); + +const mapDispatch = dispatch => ({ + incrementDolphins: () => dispatch.dolphins.increment(1), +}); + +export default connect( + mapState, + mapDispatch, + undefined, + { context: store.context }, +)(Count); diff --git a/examples/migration-redux-3/src/index.js b/examples/migration-redux-3/src/index.js new file mode 100644 index 00000000..3873c9e2 --- /dev/null +++ b/examples/migration-redux-3/src/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import store from './store'; + +import App from './App'; + +const Root = () => ( + + + +); + +ReactDOM.render(, document.querySelector('#root')); diff --git a/examples/migration-redux-3/src/models/dolphins.js b/examples/migration-redux-3/src/models/dolphins.js new file mode 100644 index 00000000..e54a0a57 --- /dev/null +++ b/examples/migration-redux-3/src/models/dolphins.js @@ -0,0 +1,6 @@ +export default { + state: 0, + reducers: { + increment: (state, payload) => state + payload, + }, +}; diff --git a/examples/migration-redux-3/src/models/sharks.js b/examples/migration-redux-3/src/models/sharks.js new file mode 100644 index 00000000..e54a0a57 --- /dev/null +++ b/examples/migration-redux-3/src/models/sharks.js @@ -0,0 +1,6 @@ +export default { + state: 0, + reducers: { + increment: (state, payload) => state + payload, + }, +}; diff --git a/examples/migration-redux-3/src/redux.js b/examples/migration-redux-3/src/redux.js new file mode 100644 index 00000000..008d0f2b --- /dev/null +++ b/examples/migration-redux-3/src/redux.js @@ -0,0 +1,6 @@ +import { connect as reduxConnect, createSelectorHook, createDispatchHook } from 'react-redux'; +import store from './store'; + +export const useSelector = createSelectorHook(store.context); +export const useDispatch = createDispatchHook(store.context); +export const connect = reduxConnect; diff --git a/examples/migration-redux-3/src/store.js b/examples/migration-redux-3/src/store.js new file mode 100644 index 00000000..fce35b97 --- /dev/null +++ b/examples/migration-redux-3/src/store.js @@ -0,0 +1,10 @@ +import { createStore } from '@ice/store'; + +import sharks from './models/sharks'; +import dolphins from './models/dolphins'; + +const store = createStore( + { sharks, dolphins }, +); + +export default store; diff --git a/examples/migration-redux-4/package.json b/examples/migration-redux-4/package.json new file mode 100644 index 00000000..c7dd7dd5 --- /dev/null +++ b/examples/migration-redux-4/package.json @@ -0,0 +1,31 @@ +{ + "name": "icestore-migration-part-4", + "version": "1.0.0", + "description": "Using icestore with react-redux & icestore.", + "private": true, + "dependencies": { + "@ice/store": "^1.4.0", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "react-redux": "^7.2.0" + }, + "devDependencies": { + "react-scripts": "3.4.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/migration-redux-4/public/index.html b/examples/migration-redux-4/public/index.html new file mode 100644 index 00000000..a6140def --- /dev/null +++ b/examples/migration-redux-4/public/index.html @@ -0,0 +1,15 @@ + + + + + + + + redux Migration: Part 1 + + + +
+ + + diff --git a/examples/migration-redux-4/src/App.js b/examples/migration-redux-4/src/App.js new file mode 100644 index 00000000..ee723807 --- /dev/null +++ b/examples/migration-redux-4/src/App.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { connect, useSelector /* useDispatch */ } from './redux'; +import store from './store'; + +const Count = props => { + const sharks = useSelector(state => state.sharks); + // const dispatch = useDispatch(); + // const sharks = store.useModelState('sharks'); + const dispatchers = store.useModelDispatchers('sharks'); + + return ( +
+
+
+

Sharks

+

{sharks}

+ +
+
+

Dolphins

+

{props.dolphins}

+ +
+
+

Using react-redux & icestore

+
+ ); +}; + +const mapState = state => ({ + dolphins: state.dolphins, +}); + +// const mapDispatch = dispatch => ({ +// incrementDolphins: () => dispatch.dolphins.increment(1) +// }); + +const WrapperedCount = connect( + mapState, + undefined, + undefined, + { context: store.context }, +)(Count); + +export default store.withModelDispatchers('dolphins')(WrapperedCount); diff --git a/examples/migration-redux-4/src/index.js b/examples/migration-redux-4/src/index.js new file mode 100644 index 00000000..3873c9e2 --- /dev/null +++ b/examples/migration-redux-4/src/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import store from './store'; + +import App from './App'; + +const Root = () => ( + + + +); + +ReactDOM.render(, document.querySelector('#root')); diff --git a/examples/migration-redux-4/src/models/dolphins.js b/examples/migration-redux-4/src/models/dolphins.js new file mode 100644 index 00000000..71c8a377 --- /dev/null +++ b/examples/migration-redux-4/src/models/dolphins.js @@ -0,0 +1,6 @@ +export default { + state: 0, + reducers: { + increment: (state) => state + 1, + }, +}; diff --git a/examples/migration-redux-4/src/models/sharks.js b/examples/migration-redux-4/src/models/sharks.js new file mode 100644 index 00000000..e54a0a57 --- /dev/null +++ b/examples/migration-redux-4/src/models/sharks.js @@ -0,0 +1,6 @@ +export default { + state: 0, + reducers: { + increment: (state, payload) => state + payload, + }, +}; diff --git a/examples/migration-redux-4/src/redux.js b/examples/migration-redux-4/src/redux.js new file mode 100644 index 00000000..008d0f2b --- /dev/null +++ b/examples/migration-redux-4/src/redux.js @@ -0,0 +1,6 @@ +import { connect as reduxConnect, createSelectorHook, createDispatchHook } from 'react-redux'; +import store from './store'; + +export const useSelector = createSelectorHook(store.context); +export const useDispatch = createDispatchHook(store.context); +export const connect = reduxConnect; diff --git a/examples/migration-redux-4/src/store.js b/examples/migration-redux-4/src/store.js new file mode 100644 index 00000000..fce35b97 --- /dev/null +++ b/examples/migration-redux-4/src/store.js @@ -0,0 +1,10 @@ +import { createStore } from '@ice/store'; + +import sharks from './models/sharks'; +import dolphins from './models/dolphins'; + +const store = createStore( + { sharks, dolphins }, +); + +export default store; diff --git a/examples/todos/package.json b/examples/todos/package.json index 8e1c5fd9..dcd3a83a 100644 --- a/examples/todos/package.json +++ b/examples/todos/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "@ice/store": "^1.3.4", + "@ice/store": "^1.4.0", "lodash": "^4.17.15", "react": "^16.8.6", "react-dom": "^16.8.6" @@ -33,4 +33,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/examples/todos/src/components/AddTodo.tsx b/examples/todos/src/components/AddTodo.tsx new file mode 100644 index 00000000..ebb8b057 --- /dev/null +++ b/examples/todos/src/components/AddTodo.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import store from '../store'; + +const { useModelDispatchers } = store; + +export default function() { + const { add } = useModelDispatchers('todos'); + let input; + + return ( +
+
{ + e.preventDefault(); + if (!input.value.trim()) { + return; + } + add({ text: input.value }); + input.value = ''; + }}> + input = node} /> + + +
+ ); +} diff --git a/examples/todos/src/components/Car.tsx b/examples/todos/src/components/Car.tsx deleted file mode 100644 index 522ad161..00000000 --- a/examples/todos/src/components/Car.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import store from '../store'; - -const { useModel } = store; - -export default function UserApp() { - const [ state ] = useModel('car'); - const { logo } = state; - - console.debug('Car rending...'); - return ( -
- {logo} -
- ); -} diff --git a/examples/todos/src/components/Footer.tsx b/examples/todos/src/components/Footer.tsx new file mode 100644 index 00000000..089d1722 --- /dev/null +++ b/examples/todos/src/components/Footer.tsx @@ -0,0 +1,33 @@ + +import React from 'react'; +import { VisibilityFilters } from '../models/visibilityFilter'; +import store from '../store'; + +const Link = ({ active, children, onClick }) => ( + +); + +export default function Footer() { + const [state, dispatchers] = store.useModel('visibilityFilter'); + return ( +
+ Show: + { + Object.keys(VisibilityFilters).map((key) => { + return ( dispatchers.setState(key)}> + {key.toLowerCase()} + ); + }) + } +
+ ); +} diff --git a/examples/todos/src/components/Todo.tsx b/examples/todos/src/components/Todo.tsx new file mode 100644 index 00000000..e45f3de9 --- /dev/null +++ b/examples/todos/src/components/Todo.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +export default function({ completed, text, onAsyncRemove, onRemove, onToggle, isLoading }) { + return ( +
  • + + + +
  • + ); +} diff --git a/examples/todos/src/components/TodoAdd.tsx b/examples/todos/src/components/TodoAdd.tsx deleted file mode 100644 index 5606eb5f..00000000 --- a/examples/todos/src/components/TodoAdd.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import store from '../store'; - -const { useModelDispatchers } = store; - -export default function TodoAdd() { - const { add } = useModelDispatchers('todos'); - - console.debug('TodoAdd rending...'); - return ( - { - if (event.keyCode === 13) { - add({ - name: event.currentTarget.value, - }); - event.currentTarget.value = ''; - } - }} - placeholder="Press Enter" - /> - ); -} diff --git a/examples/todos/src/components/TodoList.tsx b/examples/todos/src/components/TodoList.tsx index cd6ee532..cc7b8062 100644 --- a/examples/todos/src/components/TodoList.tsx +++ b/examples/todos/src/components/TodoList.tsx @@ -1,49 +1,53 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import store from '../store'; +import Todo from './Todo'; +import { VisibilityFilters } from '../models/visibilityFilter'; const { useModel, useModelEffectsLoading } = store; -export function TodoList({ state, dispatchers, effectsLoading }) { - const { title, subTitle, dataSource } = state; - const { toggle, remove } = dispatchers; +const getVisibleTodos = (todos, filter) => { + switch (filter) { + case VisibilityFilters.ALL: + return todos; + case VisibilityFilters.COMPLETED: + return todos.filter(t => t.completed); + case VisibilityFilters.ACTIVE: + return todos.filter(t => !t.completed); + default: + throw new Error(`Unknown filter: ${ filter}`); + } +}; - return ( -
    -

    {title}

    -

    - Now is using {subTitle}. -

    -
      - {dataSource.map(({ name, done = false }, index) => ( -
    • - - { - effectsLoading.remove ? - '...deleting...' : - - } -
    • - ))} -
    -
    - ); -} - -export default function({ title }) { - const [ state, dispatchers ] = useModel('todos'); +export default function TodoList() { + const [ todos, dispatchers ] = useModel('todos'); + const [ visibilityFilter ] = useModel('visibilityFilter'); const effectsLoading = useModelEffectsLoading('todos'); - return TodoList( - { - state: { ...state, title, subTitle: 'Function Component' }, - dispatchers, - effectsLoading, - }, - ); + + const { refresh, asyncRemove, remove, toggle } = dispatchers; + const visableTodos = getVisibleTodos(todos, visibilityFilter); + + useEffect(() => { + refresh(); + // eslint-disable-next-line + }, []); + + const noTaskView =
    No task
    ; + const loadingView =
    Loading...
    ; + const taskView = visableTodos.length ? ( +
      + {visableTodos.map(({ text, completed }, index) => ( + asyncRemove(index)} + onRemove={() => remove(index)} + onToggle={() => toggle(index)} + isLoading={effectsLoading.asyncRemove} + /> + ))} +
    + ) : noTaskView; + + return effectsLoading.refresh ? loadingView : taskView; } diff --git a/examples/todos/src/components/TodoListClass.tsx b/examples/todos/src/components/TodoListClass.tsx deleted file mode 100644 index 9e764e1f..00000000 --- a/examples/todos/src/components/TodoListClass.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Component } from 'react'; -import { Assign } from 'utility-types'; -import { ExtractIModelFromModelConfig, ExtractIModelEffectsLoadingFromModelConfig } from '@ice/store'; -// import compose from 'lodash/fp/compose'; -import store from '../store'; -import { TodoList as TodoListFn } from './TodoList'; -import todosModel from '../models/todos'; - -const { withModel, withModelEffectsLoading } = store; - -interface MapModelToProp { - todos: ExtractIModelFromModelConfig; -} - -interface MapModelEffectsStateToProp { - todosEffectsLoading: ExtractIModelEffectsLoadingFromModelConfig; -} - -interface CustomProp { - title: string; -} - -type PropsWithModel = Assign; -type Props = Assign; - -class TodoList extends Component { - onRemove = (index) => { - const [, dispatchers] = this.props.todos; - dispatchers.remove(index); - } - - onToggle = (index) => { - const [, dispatchers] = this.props.todos; - dispatchers.toggle(index); - } - - render() { - const { title, todos, todosEffectsLoading } = this.props; - const [ state ] = todos; - const { dataSource } = state; - return TodoListFn({ - state: { title, dataSource, subTitle: 'Class Component' }, - dispatchers: { toggle: this.onToggle, remove: this.onRemove }, - effectsLoading: todosEffectsLoading, - }); - } -} - -export default withModelEffectsLoading('todos')( - withModel('todos')(TodoList), -); - -// functional flavor: -// export default compose(withModelEffectsLoading('todos'), withModel('todos'))(TodoList); diff --git a/examples/todos/src/components/Todos.tsx b/examples/todos/src/components/Todos.tsx deleted file mode 100644 index 56cdbd16..00000000 --- a/examples/todos/src/components/Todos.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { useEffect } from 'react'; -import store from '../store'; -// import TodoList from './TodoListClass'; -import TodoList from './TodoList'; - -const { useModel, useModelEffectsLoading } = store; - -export default function Todos() { - const todos = useModel('todos'); - const [ state, dispatchers ] = todos; - const effectsLoading = useModelEffectsLoading('todos'); - - const { dataSource } = state; - const { refresh } = dispatchers; - - useEffect(() => { - refresh(); - - // eslint-disable-next-line - }, []); - - const noTaskView =
    no task
    ; - const loadingView =
    loading...
    ; - const taskView = dataSource.length ? : noTaskView; - - console.debug('Todos rending... '); - return effectsLoading.refresh ? loadingView : taskView; -} diff --git a/examples/todos/src/components/User.tsx b/examples/todos/src/components/User.tsx deleted file mode 100644 index 57ea7ca9..00000000 --- a/examples/todos/src/components/User.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { useEffect } from 'react'; -import store from '../store'; - -const { useModel } = store; - -export default function UserApp() { - const [ state, dispatchers ] = useModel('user'); - const { dataSource, auth, todos } = state; - const { login } = dispatchers; - const { name } = dataSource; - - useEffect(() => { - login(); - - // eslint-disable-next-line - }, []); - - console.debug('UserApp rending...'); - return auth ? - (
    -

    - User Information -

    -
      -
    • Name:{name}
    • -
    • Todos:{todos}
    • -
    -
    ) : - (
    - Not logged in -
    ); -} diff --git a/examples/todos/src/index.tsx b/examples/todos/src/index.tsx index c5787702..d88b2d52 100644 --- a/examples/todos/src/index.tsx +++ b/examples/todos/src/index.tsx @@ -1,30 +1,18 @@ import React from 'react'; import ReactDOM from 'react-dom'; import store from './store'; -import Todos from './components/Todos'; -import TodoAdd from './components/TodoAdd'; -import User from './components/User'; -import Car from './components/Car'; - -const initialStates = { - user: { - dataSource: { - name: 'Tom', - }, - auth: true, - todos: 5, - }, -}; +import TodoList from './components/TodoList'; +import AddTodo from './components/AddTodo'; +import Footer from './components/Footer'; const { Provider } = store; function App() { return ( - - - - - + + + +
    ); } diff --git a/examples/todos/src/models/car.ts b/examples/todos/src/models/car.ts deleted file mode 100644 index 05547d38..00000000 --- a/examples/todos/src/models/car.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * This model only defines state, but not effects and reducers - */ -const model = { - state: { - logo: 'car', - }, -}; - -export default model; diff --git a/examples/todos/src/models/index.ts b/examples/todos/src/models/index.ts index 086ca814..56a5c8cf 100644 --- a/examples/todos/src/models/index.ts +++ b/examples/todos/src/models/index.ts @@ -1,15 +1,13 @@ import { Models } from '@ice/store'; import todos from './todos'; -import user from './user'; -import car from './car'; +import visibilityFilter from './visibilityFilter'; -const rootModels: RootModels = { todos, user, car }; +const rootModels: RootModels = { todos, visibilityFilter }; // add interface to avoid recursive type checking export interface RootModels extends Models { todos: typeof todos; - user: typeof user; - car: typeof car; + visibilityFilter: typeof visibilityFilter; } export default rootModels; diff --git a/examples/todos/src/models/todos.ts b/examples/todos/src/models/todos.ts index 7120a439..1f1924c9 100644 --- a/examples/todos/src/models/todos.ts +++ b/examples/todos/src/models/todos.ts @@ -1,67 +1,48 @@ import { delay } from '../utils'; -import store, { RootDispatch, RootState } from '../store'; export interface Todo { - name: string; - done?: boolean; + text: string; + completed?: boolean; } -export interface TodosState { - dataSource: Todo[]; -} +export type TodosState = Todo[]; const model = { - state: { - dataSource: [ - { - name: 'Init', - done: false, - }, - ], - }, + state: [ + { + text: 'Init', + completed: false, + }, + ], reducers: { toggle(state: TodosState, index: number) { - state.dataSource[index].done = !state.dataSource[index].done; + state[index].completed = !state[index].completed; }, add(state: TodosState, todo: Todo) { - state.dataSource.push(todo); + state.push(todo); }, remove(state: TodosState, index: number) { - state.dataSource.splice(index, 1); + state.splice(index, 1); }, }, - effects: (dispatch: RootDispatch) => ({ - // this will run after "add" reducer finished - add(todo: Todo, rootState: RootState) { - console.log(rootState.todos); - dispatch.user.setTodos(store.getModelState('todos').dataSource.length); - }, + effects: () => ({ async refresh() { await delay(2000); // wait for data to load - const dataSource: Todo[] = [ - { - name: 'react', - }, + + // pass the result to a local reducer + this.setState([ { - name: 'vue', - done: true, + text: 'react', }, { - name: 'angular', + text: 'vue', + completed: true, }, - ]; - - // pass the result to a local reducer - // setState is a built-in reducer - this.setState({ - dataSource, - }); - - dispatch.user.setTodos(dataSource.length); + ]); }, - async remove(index: number) { + async asyncRemove(index: number) { await delay(1000); - dispatch.user.setTodos(store.getModelState('todos').dataSource.length); + this.remove(index); }, }), }; diff --git a/examples/todos/src/models/user.ts b/examples/todos/src/models/user.ts deleted file mode 100644 index c85d662c..00000000 --- a/examples/todos/src/models/user.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { delay } from '../utils'; - -export interface UserState { - dataSource: { - name: string; - }; - todos: number; - auth: boolean; -} - -const model = { - state: { - dataSource: { - name: '', - }, - todos: 0, - auth: false, - }, - reducers: { - setTodos(state: UserState, todos: number) { - state.todos = todos; - }, - }, - effects: () => ({ - async login() { - await delay(1000); - - this.setState({ - dataSource: { - name: 'Alvin', - }, - auth: true, - }); - }, - }), -}; - -export default model; diff --git a/examples/todos/src/models/visibilityFilter.ts b/examples/todos/src/models/visibilityFilter.ts new file mode 100644 index 00000000..7dd9dda2 --- /dev/null +++ b/examples/todos/src/models/visibilityFilter.ts @@ -0,0 +1,16 @@ +export const VisibilityFilters = { + ALL: 'ALL', + COMPLETED: 'COMPLETED', + ACTIVE: 'ACTIVE', +}; + +const visibilityFilter = { + state: VisibilityFilters.ALL, + reducers: { + setState(prevState, nextState) { + return nextState; + }, + }, +}; + +export default visibilityFilter; diff --git a/examples/withModel/.gitgnore b/examples/withModel/.gitgnore new file mode 100644 index 00000000..378eac25 --- /dev/null +++ b/examples/withModel/.gitgnore @@ -0,0 +1 @@ +build diff --git a/examples/withModel/package.json b/examples/withModel/package.json new file mode 100644 index 00000000..db3b554a --- /dev/null +++ b/examples/withModel/package.json @@ -0,0 +1,34 @@ +{ + "name": "with-model", + "version": "1.0.0", + "private": true, + "dependencies": { + "@ice/store": "^1.4.0", + "react": "^16.8.6", + "react-dom": "^16.8.6" + }, + "devDependencies": { + "@types/jest": "^24.0.0", + "@types/node": "^12.0.0", + "@types/react": "^16.9.0", + "@types/react-dom": "^16.9.0", + "react-scripts": "3.4.0", + "typescript": "^3.7.5" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/withModel/public/index.html b/examples/withModel/public/index.html new file mode 100644 index 00000000..492089d1 --- /dev/null +++ b/examples/withModel/public/index.html @@ -0,0 +1,15 @@ + + + + + + + + Counter App + + + +
    + + + diff --git a/examples/withModel/src/index.tsx b/examples/withModel/src/index.tsx new file mode 100644 index 00000000..8aa675fd --- /dev/null +++ b/examples/withModel/src/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { withModel } from '@ice/store'; + +const delay = (time) => new Promise((resolve) => setTimeout(() => resolve(), time)); + +const counter = { + state: 0, + reducers: { + increment:(prevState) => prevState + 1, + decrement:(prevState) => prevState - 1, + }, + effects: () => ({ + async decrementAsync() { + await delay(1000); + this.decrement(); + }, + }), +}; + +function Counter({ model }) { + const [ count, dispatchers ] = model.useValue('counter'); + const { increment, decrementAsync } = dispatchers; + return ( +
    + {count} + + +
    + ); +} + +const CounterWithModel = withModel(counter)(Counter); + +const rootElement = document.getElementById('root'); +ReactDOM.render(, rootElement); diff --git a/examples/withModel/src/react-app-env.d.ts b/examples/withModel/src/react-app-env.d.ts new file mode 100644 index 00000000..30da8962 --- /dev/null +++ b/examples/withModel/src/react-app-env.d.ts @@ -0,0 +1 @@ +// / diff --git a/examples/withModel/tsconfig.json b/examples/withModel/tsconfig.json new file mode 100644 index 00000000..171592f7 --- /dev/null +++ b/examples/withModel/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": [ + "src" + ] +} diff --git a/package.json b/package.json index 6c1cd288..8f9dc7e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ice/store", - "version": "1.3.5", + "version": "1.4.0", "description": "Simple and friendly state for React", "main": "lib/index.js", "files": [ @@ -29,8 +29,9 @@ "watch": "tsc -w", "prepublishOnly": "npm run lint:nofix && npm run test && npm run build", "lint": "npm run lint:nofix -- --fix", - "lint:nofix": "eslint --cache --ext .ts,.tsx ./", - "test": "NODE_ENV=unittest jest", + "lint:nofix": "eslint --cache --ext .ts,.tsx,.js ./", + "test": "cross-env NODE_ENV=unittest jest", + "test:w": "jest --watch", "coverage": "codecov" }, "devDependencies": { @@ -38,15 +39,18 @@ "@commitlint/config-conventional": "^8.2.0", "@ice/spec": "^0.1.9", "@testing-library/react": "^9.0.0", - "@types/jest": "^24.0.12", + "@testing-library/react-hooks": "^3.2.1", + "@types/jest": "^25.2.1", "@types/node": "^12.0.0", "codecov": "^3.3.0", + "cross-env": "^7.0.2", "eslint": "^6.7.2", "husky": "^3.0.9", - "jest": "^24.7.1", + "jest": "^25.2.1", "react": "^16.8.0", "react-dom": "^16.8.0", - "ts-jest": "^24.0.2", + "react-test-renderer": "^16.13.0", + "ts-jest": "^25.2.1", "typescript": "^3.7.4" }, "peerDependencies": { @@ -55,6 +59,10 @@ "jest": { "coverageDirectory": "./coverage/", "collectCoverage": true, + "coveragePathIgnorePatterns": [ + "/tests/helpers/", + "/node_modules/" + ], "preset": "ts-jest" }, "dependencies": { diff --git a/src/index.ts b/src/index.tsx similarity index 71% rename from src/index.ts rename to src/index.tsx index 1e3f5808..2a979d02 100644 --- a/src/index.ts +++ b/src/index.tsx @@ -6,7 +6,6 @@ import mergeConfig from './utils/mergeConfig'; import createProviderPlugin from './plugins/provider'; import createReduxHooksPlugin from './plugins/reduxHooks'; import createModelApisPlugin from './plugins/modelApis'; -import createEffectsStateApisPlugin from './plugins/effectsStateApis'; import createImmerPlugin from './plugins/immer'; import createLoadingPlugin from './plugins/loading'; import createErrorPlugin from './plugins/error'; @@ -68,9 +67,6 @@ export const createStore = if (!disableError) { plugins.push(error); } - if (!disableLoading || !disableError) { - plugins.push(createEffectsStateApisPlugin()); - } // compatibility handling const wrappedModels = appendReducers( @@ -91,5 +87,34 @@ export const createStore = return store as T.PresetIcestore; }; +interface MapModelToProps { + (model: T.ExtractIModelAPIsFromModelConfig): Record; +} + +export const withModel = < + M extends T.ModelConfig, + F extends MapModelToProps, + C extends T.CreateStoreConfig<{ model: M }> +>(model: M, mapModelToProps?: F, initConfig?: C) => { + const modelName = 'model'; + mapModelToProps = (mapModelToProps || ((modelApis) => ({ [modelName]: modelApis }))) as F; + const store = createStore({ [modelName]: model }, initConfig); + const { Provider, getModelAPIs } = store; + const modelApis = getModelAPIs(modelName); + const withProps = mapModelToProps(modelApis); + return , 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"