├── examples └── simple-BI │ ├── src │ ├── types │ │ └── index.d.ts │ ├── react-app-env.d.ts │ ├── common │ │ ├── component │ │ │ ├── DatasetIcon │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ ├── FieldIcon │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ └── LoadingIcon │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ └── mock │ │ │ └── index.ts │ ├── page │ │ ├── reportView │ │ │ ├── ui │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ └── workbook │ │ │ ├── ui │ │ │ ├── index.css │ │ │ └── index.tsx │ │ │ └── index.ts │ ├── setupTests.ts │ ├── api │ │ ├── workbook.ts │ │ ├── dataset.ts │ │ ├── chart.ts │ │ └── report.ts │ ├── index.css │ ├── router.tsx │ ├── container │ │ ├── workbook │ │ │ ├── datasetField │ │ │ │ ├── ui │ │ │ │ │ ├── components │ │ │ │ │ │ └── fieldItem │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ ├── reportList │ │ │ │ ├── ui │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ ├── datasetList │ │ │ │ ├── ui │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ └── chartAttribute │ │ │ │ ├── ui │ │ │ │ ├── index.tsx │ │ │ │ ├── index.css │ │ │ │ └── components │ │ │ │ │ └── attrItem │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ └── common │ │ │ └── reportCanvas │ │ │ ├── ui │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ └── components │ │ │ │ └── chart │ │ │ │ └── index.tsx │ │ │ └── index.ts │ ├── domain │ │ ├── report.ts │ │ ├── workbook.ts │ │ ├── dataset.ts │ │ └── chart.ts │ ├── service │ │ └── loadWorkbook.ts │ └── index.tsx │ ├── public │ ├── robots.txt │ ├── area.png │ ├── bar.png │ ├── line.png │ ├── pie.png │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html │ ├── README.md │ ├── .gitignore │ ├── tsconfig.json │ └── package.json ├── .prettierrc.json ├── commitlint.config.js ├── .eslintignore ├── src ├── middlewares │ ├── index.ts │ └── batchedUpdate.ts ├── types │ ├── util.ts │ ├── globalModule.ts │ ├── classicsModule.ts │ ├── containerModule.ts │ ├── helper.ts │ ├── pageModule.ts │ ├── createApp.ts │ ├── domainModule.ts │ └── common.ts ├── module │ ├── index.ts │ ├── module.ts │ ├── options │ │ ├── watchers.ts │ │ ├── action.ts │ │ ├── selector.ts │ │ └── effects.ts │ ├── globalModule.ts │ ├── containerModule.ts │ ├── domainModule.ts │ ├── pageModule.ts │ └── util │ │ └── util.ts ├── index.ts ├── const.ts ├── helpers │ ├── autoGetSet.ts │ └── createDomainModel.ts ├── utils.ts ├── package.json └── createApp.tsx ├── kop.common.js ├── .gitignore ├── .babelrc ├── LEGAL.md ├── .editorconfig ├── __tests__ ├── autoGetSet_spec.ts ├── setup.ts ├── middleware_spec.ts ├── module_internal_spec.ts ├── addRouter_spec.tsx ├── utils_spec.ts ├── module_global_spec.ts ├── module_container_spec.ts ├── module_domain_spec.ts └── module_page_spec.ts ├── LICENSE ├── README.md ├── .github └── workflows │ └── ci.yml ├── rollup.config.js ├── .eslintrc ├── tsconfig.json └── package.json /examples/simple-BI/src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'immutability-helper-x' 2 | -------------------------------------------------------------------------------- /examples/simple-BI/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | static/* 3 | bin/* 4 | public/* 5 | tests/* 6 | webpack.config*.js 7 | -------------------------------------------------------------------------------- /examples/simple-BI/src/common/component/DatasetIcon/index.css: -------------------------------------------------------------------------------- 1 | .icon-dataset path { 2 | fill: #666; 3 | } -------------------------------------------------------------------------------- /examples/simple-BI/src/common/component/FieldIcon/index.css: -------------------------------------------------------------------------------- 1 | .icon-field path { 2 | fill: #666; 3 | } 4 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | import batchedUpdate from './batchedUpdate' 2 | 3 | export default [batchedUpdate] 4 | -------------------------------------------------------------------------------- /examples/simple-BI/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/simple-BI/public/area.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtoTeam/redux-with-domain/HEAD/examples/simple-BI/public/area.png -------------------------------------------------------------------------------- /examples/simple-BI/public/bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtoTeam/redux-with-domain/HEAD/examples/simple-BI/public/bar.png -------------------------------------------------------------------------------- /examples/simple-BI/public/line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtoTeam/redux-with-domain/HEAD/examples/simple-BI/public/line.png -------------------------------------------------------------------------------- /examples/simple-BI/public/pie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtoTeam/redux-with-domain/HEAD/examples/simple-BI/public/pie.png -------------------------------------------------------------------------------- /examples/simple-BI/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtoTeam/redux-with-domain/HEAD/examples/simple-BI/public/favicon.ico -------------------------------------------------------------------------------- /examples/simple-BI/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtoTeam/redux-with-domain/HEAD/examples/simple-BI/public/logo192.png -------------------------------------------------------------------------------- /examples/simple-BI/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtoTeam/redux-with-domain/HEAD/examples/simple-BI/public/logo512.png -------------------------------------------------------------------------------- /src/types/util.ts: -------------------------------------------------------------------------------- 1 | import { connect, useDispatch, useSelector, useStore } from 'react-redux' 2 | 3 | export { connect, useDispatch, useSelector, useStore } 4 | -------------------------------------------------------------------------------- /examples/simple-BI/src/page/reportView/ui/index.css: -------------------------------------------------------------------------------- 1 | .main-content { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | position: relative; 7 | } 8 | -------------------------------------------------------------------------------- /examples/simple-BI/README.md: -------------------------------------------------------------------------------- 1 | ## Simple-BI 2 | 3 | 基于 BI 为原型开发的 redux-with-domain 例子。演示了如何使用 redux-with-domain 解决复杂前端项目的架构问题。 4 | 5 | ### 启动 6 | 7 | ```bash 8 | npm i 9 | npm run start 10 | // 自动打开 localhost:3000 11 | ``` 12 | -------------------------------------------------------------------------------- /kop.common.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | if (process.env.NODE_ENV === 'production') { 3 | module.exports = require('./dist/kop.prod.common') 4 | } else { 5 | module.exports = require('./dist/kop.dev.common') 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | tmp/ 4 | temp/ 5 | .idea/ 6 | *.log 7 | *.iml 8 | .DS_Store 9 | test/coverage/ 10 | coverage/ 11 | lib/ 12 | dist/ 13 | .cache/ 14 | .vscode/ 15 | 16 | # lock 17 | package-lock.json 18 | -------------------------------------------------------------------------------- /examples/simple-BI/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect' 6 | -------------------------------------------------------------------------------- /examples/simple-BI/src/api/workbook.ts: -------------------------------------------------------------------------------- 1 | import { WORK_BOOK } from '../common/mock' 2 | 3 | export async function queryWorkbook(wbId: string) { 4 | const workbook = WORK_BOOK 5 | // 模拟等待3秒钟 6 | await new Promise(resolve => { 7 | setTimeout(resolve, 1000) 8 | }) 9 | return workbook 10 | } 11 | -------------------------------------------------------------------------------- /src/types/globalModule.ts: -------------------------------------------------------------------------------- 1 | import { KopActions, KopSelectors } from './common' 2 | 3 | export type KopGlobalModule = { 4 | namespace: string 5 | selectors: KopSelectors 6 | actions: KopActions 7 | event: (evt: string) => string 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "targets": { 8 | "ie": "10" 9 | }, 10 | "corejs": 2 11 | } 12 | ], 13 | "@babel/react" 14 | ], 15 | "plugins": ["@babel/proposal-class-properties"] 16 | } 17 | -------------------------------------------------------------------------------- /src/types/classicsModule.ts: -------------------------------------------------------------------------------- 1 | import { EffectsOf } from './common' 2 | 3 | export interface ModuleOption< 4 | State = {}, 5 | Selectors = {}, 6 | Reducers = {}, 7 | Effects = {} 8 | > { 9 | initialState: State 10 | selectors?: Selectors 11 | reducers?: Reducers 12 | effects?: EffectsOf 13 | } 14 | -------------------------------------------------------------------------------- /examples/simple-BI/src/api/dataset.ts: -------------------------------------------------------------------------------- 1 | import { DATASET_LIST } from '../common/mock' 2 | 3 | export async function quertyDatasetList(idList: string[]) { 4 | const list = DATASET_LIST.filter(dataset => idList.includes(dataset.id)) 5 | // 模拟等待1秒钟 6 | await new Promise(resolve => { 7 | setTimeout(resolve, 1000) 8 | }) 9 | return list 10 | } 11 | -------------------------------------------------------------------------------- /src/module/index.ts: -------------------------------------------------------------------------------- 1 | import createPageModule from './pageModule' 2 | import createContainerModule from './containerModule' 3 | import createDomainModule from './domainModule' 4 | import createGlobalModule from './globalModule' 5 | 6 | export { 7 | createPageModule, 8 | createContainerModule, 9 | createDomainModule, 10 | createGlobalModule 11 | } 12 | -------------------------------------------------------------------------------- /examples/simple-BI/src/common/component/LoadingIcon/index.css: -------------------------------------------------------------------------------- 1 | @keyframes loadingCircle { 2 | 100% { 3 | transform: rotate(360deg); 4 | } 5 | } 6 | 7 | .icon-loading { 8 | fill: #1890ff; 9 | } 10 | 11 | .icon-loading svg { 12 | display: inline-block; 13 | animation: loadingCircle 1s infinite linear; 14 | width: 26px; 15 | height: 26px; 16 | } 17 | -------------------------------------------------------------------------------- /src/middlewares/batchedUpdate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file redux中store变更后, 强制批量更新react 3 | */ 4 | 5 | // eslint-disable-next-line @typescript-eslint/camelcase 6 | import { unstable_batchedUpdates } from 'react-dom' 7 | 8 | export default function batchedUpdateMiddleware() { 9 | return (next: Function) => (action: object) => 10 | unstable_batchedUpdates(() => next(action)) 11 | } 12 | -------------------------------------------------------------------------------- /src/types/containerModule.ts: -------------------------------------------------------------------------------- 1 | import { KopActions, KopSelectors } from './common' 2 | 3 | export type KopContainerModule = { 4 | namespace: string 5 | selectors: KopSelectors 6 | actions: KopActions 7 | event: (evt: string) => string 8 | } 9 | 10 | export interface GlobalState { 11 | [index: string]: any 12 | } 13 | -------------------------------------------------------------------------------- /LEGAL.md: -------------------------------------------------------------------------------- 1 | Legal Disclaimer 2 | 3 | Within this source code, the comments in Chinese shall be the original, governing version. Any comment in other languages are for reference only. In the event of any conflict between the Chinese language version comments and other language version comments, the Chinese language version shall prevail. 4 | 5 | 法律免责声明 6 | 7 | 关于代码注释部分,中文注释为官方版本,其它语言注释仅做参考。中文注释可能与其它语言注释存在不一致,当中文注释与其它语言注释存在不一致时,请以中文注释为准。 -------------------------------------------------------------------------------- /examples/simple-BI/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [Makefile] 16 | indent_style = tab 17 | indent_size = 1 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /src/types/helper.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, Action } from 'redux' 2 | import { KopSelectors, SagaEffects, SagaEnhancer } from './common' 3 | 4 | export interface EffectsParams { 5 | actions: Actions 6 | selectors: KopSelectors 7 | sagaEffects: SagaEffects 8 | enhancer: SagaEnhancer 9 | } 10 | 11 | export interface ActionCreatorsParams { 12 | actions: Actions 13 | selectors: KopSelectors 14 | dispatch: Dispatch 15 | createAction: (type: string) => Action 16 | } 17 | -------------------------------------------------------------------------------- /examples/simple-BI/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | height: 100%; 9 | overflow: hidden; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 14 | monospace; 15 | } 16 | 17 | html, 18 | #root, 19 | .main { 20 | height: 100%; 21 | } 22 | -------------------------------------------------------------------------------- /examples/simple-BI/src/api/chart.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { MOCK_DATA } from '../common/mock' 3 | 4 | export const queryData = (datasetId: string, fieldIds: string[]) => { 5 | return new Promise((resolve, reject) => { 6 | setTimeout(() => { 7 | const mockData = MOCK_DATA[datasetId] 8 | if (!mockData) { 9 | reject(new Error('data not found')) 10 | } 11 | resolve( 12 | MOCK_DATA[datasetId].map((data: any) => { 13 | return _.pick(data, fieldIds) 14 | }) 15 | ) 16 | }, 0) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as saga from 'redux-saga' 2 | import { effects } from 'redux-saga' 3 | import createApp from './createApp' 4 | 5 | export default createApp 6 | 7 | // Type helper for domain 8 | export * from './types/domainModule' 9 | export * from './types/helper' 10 | 11 | export { 12 | createPageModule, 13 | createDomainModule, 14 | createContainerModule, 15 | createGlobalModule 16 | } from './module/index' 17 | 18 | export { connect, useDispatch, useSelector, useStore } from 'react-redux' 19 | export { autoGetSet } from './helpers/autoGetSet' 20 | 21 | export { effects } 22 | export { saga } 23 | -------------------------------------------------------------------------------- /examples/simple-BI/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react", 16 | "strict": true, 17 | "typeRoots": ["./types", "./node_modules/@types"] 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/simple-BI/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/simple-BI/src/common/component/FieldIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import './index.css' 3 | 4 | interface Props {} 5 | 6 | const FieldIcon: FC = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | export default FieldIcon 17 | -------------------------------------------------------------------------------- /__tests__/autoGetSet_spec.ts: -------------------------------------------------------------------------------- 1 | import createApp, { createPageModule, autoGetSet } from '../src' 2 | 3 | describe('autoGetSet', () => { 4 | const moduleOpt = { 5 | initialState: { 6 | count: 1 7 | } 8 | } 9 | 10 | test('auto get & set', () => { 11 | const app = createApp() 12 | const module2 = createPageModule('module2', autoGetSet(moduleOpt)) 13 | 14 | app.addPage(module2) 15 | 16 | app.start() 17 | 18 | expect(module2.selectors.getCount(app._store.getState())).toBe(1) 19 | app._store.dispatch(module2.actions.setCount(2)) 20 | expect(module2.selectors.getCount(app._store.getState())).toBe(2) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /examples/simple-BI/src/api/report.ts: -------------------------------------------------------------------------------- 1 | import { REPORT_LIST, REPORT_CHART_INFO } from '../common/mock' 2 | 3 | // 查询报表列表 4 | export async function quertyReportList(idList: string[]) { 5 | const list = REPORT_LIST.filter(report => idList.includes(report.id)) 6 | // 模拟等待1秒钟 7 | await new Promise(resolve => { 8 | setTimeout(resolve, 1000) 9 | }) 10 | return list 11 | } 12 | 13 | // 查询报表详情 14 | export async function quertyReportChartInfo(reportId: string) { 15 | const chartInfo = REPORT_CHART_INFO as { [key: string]: object[] } 16 | // 模拟等待1秒钟 17 | await new Promise(resolve => { 18 | setTimeout(resolve, 1000) 19 | }) 20 | return chartInfo[reportId] || {} 21 | } 22 | -------------------------------------------------------------------------------- /examples/simple-BI/src/router.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | BrowserRouter as Router, 4 | Switch, 5 | Route, 6 | Redirect 7 | } from 'react-router-dom' 8 | 9 | import Workbook from './page/workbook/ui' 10 | import Report from './page/reportView/ui' 11 | 12 | const AppRouter = () => { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default 27 | -------------------------------------------------------------------------------- /examples/simple-BI/src/page/reportView/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react' 2 | import _ from 'lodash' 3 | import { useDispatch } from 'redux-with-domain' 4 | import ReportCanvas from '../../../container/common/reportCanvas/ui' 5 | import module from '../index' 6 | import './index.css' 7 | 8 | interface Props {} 9 | 10 | const Report: FC = props => { 11 | const id: string = _.get(props, 'match.params.id') 12 | const dispatch = useDispatch() 13 | useEffect(() => { 14 | dispatch(module.actions.init(id)) 15 | }, [dispatch, id]) 16 | 17 | return ( 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | export default Report 25 | -------------------------------------------------------------------------------- /__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import jsdom from 'jsdom'; 2 | import Enzyme from 'enzyme'; 3 | import ReactSixteenAdapter from 'enzyme-adapter-react-16'; 4 | 5 | /** 6 | * unit test 7 | * setup 8 | */ 9 | 10 | Enzyme.configure({ adapter: new ReactSixteenAdapter() }); 11 | 12 | interface Global { 13 | document: Document; 14 | window: Window; 15 | } 16 | 17 | declare let global: Global; 18 | 19 | const { JSDOM } = jsdom; 20 | const dom = new JSDOM( 21 | `kop test page`, 22 | { url: 'http://www.alipay.com' } 23 | ); 24 | 25 | global.document = dom.window.document; 26 | global.document.querySelector = (selector: any) => 27 | dom.window.document.querySelector(selector); 28 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/datasetField/ui/components/fieldItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { useDrag } from 'react-dnd' 3 | import FieldIcon from '../../../../../../common/component/FieldIcon' 4 | 5 | // import "./index.css"; 6 | 7 | interface Props { 8 | field: { 9 | id: string 10 | name: string 11 | } 12 | } 13 | 14 | const FieldItem: FC = props => { 15 | const { field } = props 16 | 17 | const [_, dragRef] = useDrag({ 18 | item: { type: 'FIELD', id: field.id } 19 | }) 20 | 21 | return ( 22 | 23 | 24 | {field.name} 25 | 26 | ) 27 | } 28 | 29 | export default FieldItem 30 | -------------------------------------------------------------------------------- /examples/simple-BI/src/common/component/LoadingIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import './index.css' 3 | 4 | interface Props {} 5 | 6 | const LoadingIcon: FC = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | export default LoadingIcon 17 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/reportList/ui/index.css: -------------------------------------------------------------------------------- 1 | .reports { 2 | display: flex; 3 | flex-shrink: 0; 4 | background-color: #fff; 5 | box-shadow: 3px 1px 6px 0 rgba(184, 198, 211, 0.4); 6 | z-index: 2; 7 | height: 41px; 8 | } 9 | 10 | .reports .item { 11 | width: 140px; 12 | display: flex; 13 | align-items: center; 14 | padding-left: 10px; 15 | font-size: 14px; 16 | cursor: pointer; 17 | border-left: 1px solid #e9e9e9; 18 | color: rgba(0, 0, 0, 0.43); 19 | background-color: #f7f9fb; 20 | cursor: pointer; 21 | } 22 | .reports .item:first-child { 23 | border: none; 24 | } 25 | 26 | .reports .item:last-child { 27 | border-right: 1px solid #e9e9e9; 28 | } 29 | 30 | .reports .item.selected { 31 | background-color: #fff; 32 | color: #1890ff; 33 | } -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/datasetList/ui/index.css: -------------------------------------------------------------------------------- 1 | .dataset-list { 2 | } 3 | 4 | .dataset-list .title { 5 | font-size: 12px; 6 | padding-left: 8px; 7 | padding-top: 10px; 8 | color: rgba(0, 0, 0, 0.45); 9 | } 10 | 11 | .dataset-list .list { 12 | padding: 8px; 13 | } 14 | 15 | .dataset-list .item { 16 | display: flex; 17 | align-items: center; 18 | background-color: #e6e9ed; 19 | cursor: pointer; 20 | margin: 0 0 2px 0; 21 | border-radius: 4px; 22 | color: rgba(0, 0, 0, 0.65); 23 | font-size: 12px; 24 | height: 32px; 25 | padding: 0 8px; 26 | } 27 | 28 | .dataset-list .item.selected { 29 | color: #1890ff; 30 | } 31 | 32 | .dataset-list .item .icon-dataset { 33 | margin-right: 8px; 34 | line-height: 14px; 35 | height: 14px; 36 | } 37 | 38 | .dataset-list .item.selected .icon-dataset path{ 39 | fill: #1890ff; 40 | } 41 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/chartAttribute/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { useSelector } from 'redux-with-domain' 3 | import AttriItem from './components/attrItem' 4 | import module from '../index' 5 | import './index.css' 6 | 7 | interface Props {} 8 | 9 | const ChartAttribute: FC = props => { 10 | const attrs = 11 | useSelector((state: any) => module.selectors.getAttribute(state)) || [] 12 | 13 | const attrItems = attrs.map((attr, index) => { 14 | const { group } = attr 15 | 16 | return 17 | }) 18 | 19 | return ( 20 | 21 | {attrItems.length ? ( 22 | attrItems 23 | ) : ( 24 | 图表数据配置区 25 | )} 26 | 27 | ) 28 | } 29 | 30 | export default ChartAttribute 31 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/datasetField/ui/index.css: -------------------------------------------------------------------------------- 1 | .dataset-field { 2 | } 3 | 4 | .dataset-field .title { 5 | font-size: 12px; 6 | padding-left: 8px; 7 | padding-top: 10px; 8 | color: rgba(0, 0, 0, 0.45); 9 | } 10 | 11 | .dataset-field .list { 12 | padding: 8px; 13 | display: flex; 14 | flex-direction: column; 15 | } 16 | 17 | .dataset-field .search { 18 | margin-bottom: 4px; 19 | } 20 | 21 | .dataset-field .item { 22 | cursor: pointer; 23 | display: flex; 24 | align-items: center; 25 | background: #e6e9ed; 26 | border-radius: 2px; 27 | padding-left: 8px; 28 | margin: 0 0 2px 0; 29 | border-radius: 4px; 30 | color: rgba(0, 0, 0, 0.65); 31 | font-size: 12px; 32 | height: 32px; 33 | padding: 0 8px; 34 | } 35 | 36 | .dataset-field .item .icon-field { 37 | margin-right: 8px; 38 | line-height: 14px; 39 | height: 14px; 40 | } 41 | -------------------------------------------------------------------------------- /examples/simple-BI/src/domain/report.ts: -------------------------------------------------------------------------------- 1 | import { createDomainModule } from 'redux-with-domain' 2 | import { quertyReportList } from '../api/report' 3 | 4 | export interface ReportItem { 5 | id: string // 报表id 6 | name: string // 报表名称 7 | workbookId: string // 所属工作簿 8 | } 9 | 10 | interface Entities { 11 | reportList: ReportItem 12 | } 13 | 14 | interface Effects { 15 | fetchReports: (data: { payload: string[] }) => any 16 | } 17 | 18 | export default createDomainModule('domain/report', { 19 | entities: { 20 | reportList: 'id' 21 | }, 22 | selectors: {}, // 占位 23 | services: ({ services, entities, sagaEffects: { put, call } }) => ({ 24 | *fetchReports({ payload }: { payload: string[] }) { 25 | // 请求报表列表 26 | const reports = yield call(quertyReportList, payload) 27 | // 保存值 28 | entities.reportList.insert(reports) 29 | } 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /examples/simple-BI/src/service/loadWorkbook.ts: -------------------------------------------------------------------------------- 1 | import { effects } from 'redux-with-domain' 2 | import { queryWorkbook } from '../api/workbook' 3 | import { quertyDatasetList } from '../api/dataset' 4 | import { quertyReportList } from '../api/report' 5 | import workbookDomain from '../domain/workbook' 6 | import datasetDomain from '../domain/dataset' 7 | import reportDomain from '../domain/report' 8 | 9 | export default function* loadWorkbook(id: string) { 10 | // 查询工作簿 11 | const workbook = yield effects.call(queryWorkbook, id) 12 | workbookDomain.entities.workbookList.insert(workbook) 13 | // 查询引用的数据集 14 | const datasetList = yield effects.call( 15 | quertyDatasetList, 16 | workbook.datasetIdList 17 | ) 18 | datasetDomain.entities.datasetList.insert(datasetList) 19 | // 查询管理的报表 20 | const reportList = yield effects.call(quertyReportList, workbook.reportIdList) 21 | reportDomain.entities.reportList.insert(reportList) 22 | } 23 | -------------------------------------------------------------------------------- /examples/simple-BI/src/page/reportView/index.ts: -------------------------------------------------------------------------------- 1 | import { createPageModule } from 'redux-with-domain' 2 | import reportDomain from '../../domain/report' 3 | import ReportCanvasModule from '../../container/common/reportCanvas' 4 | 5 | export default createPageModule('report', { 6 | initialState: { 7 | reportId: '' 8 | }, 9 | reducers: { 10 | saveReportId: (state, { payload }: { payload: string }) => { 11 | return { 12 | ...state, 13 | reportId: payload 14 | } 15 | } 16 | }, 17 | effects: ({ 18 | actions, 19 | sagaEffects: { call, put }, 20 | enhancer: { syncPut } 21 | }) => ({ 22 | *init({ payload: id }: { payload: string }) { 23 | // 保存当前工作簿id 24 | yield syncPut(actions.saveReportId(id)) 25 | // 查询报表 26 | yield syncPut(reportDomain.services.fetchReports([id])) 27 | // 初使化图表视图 28 | yield syncPut(ReportCanvasModule.actions.reloadChart(id)) 29 | } 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /examples/simple-BI/src/domain/workbook.ts: -------------------------------------------------------------------------------- 1 | import { createDomainModule, DomainEntities } from 'redux-with-domain' 2 | 3 | export interface Workbook { 4 | id: string // 工作簿id 5 | name: string // 工作簿名称 6 | datasetIdList: string[] // 引用的数据集id列表 7 | reportIdList: string[] // 创建的报表id列表 8 | } 9 | 10 | interface Entities { 11 | workbookList: Workbook 12 | } 13 | 14 | const selectors = { 15 | getWorkbook: ({ entities }: { entities: DomainEntities }) => { 16 | return entities.workbookList.select() 17 | } 18 | } 19 | 20 | export default createDomainModule( 21 | 'domain/workbook', 22 | { 23 | entities: { 24 | workbookList: 'id' 25 | }, 26 | selectors: { 27 | getWorkbook: ({ entities }) => { 28 | return entities.workbookList.select() as Workbook[] 29 | } 30 | }, 31 | services: ({ entities, sagaEffects: { call, put } }) => ({ 32 | // dosomething 33 | }) 34 | } 35 | ) 36 | -------------------------------------------------------------------------------- /src/module/module.ts: -------------------------------------------------------------------------------- 1 | import { noop } from 'lodash' 2 | 3 | import { 4 | initActions, 5 | initSelectors, 6 | initModule, 7 | initInitialState 8 | } from './util/util' 9 | import { DEFAULT_MODULE } from '../const' 10 | 11 | import { ModuleOption } from '../types/classicsModule' 12 | 13 | // internal API for create KOP module 14 | export default function createModule( 15 | namespace: string, 16 | pkg: ModuleOption, 17 | type: symbol 18 | ) { 19 | const module = initModule(pkg, namespace, type) as any 20 | 21 | initInitialState(module, pkg) 22 | 23 | initSelectors(module, pkg, namespace, DEFAULT_MODULE) 24 | 25 | const { actions, effects, _reducers, event } = initActions( 26 | module, 27 | pkg, 28 | namespace, 29 | DEFAULT_MODULE, 30 | noop 31 | ) 32 | 33 | module.actions = actions 34 | 35 | if (effects) { 36 | module.effects = effects 37 | } 38 | 39 | module._reducers = _reducers 40 | module.event = event 41 | 42 | return module 43 | } 44 | -------------------------------------------------------------------------------- /examples/simple-BI/src/domain/dataset.ts: -------------------------------------------------------------------------------- 1 | import { createDomainModule } from 'redux-with-domain' 2 | import { quertyDatasetList } from '../api/dataset' 3 | 4 | export interface Field { 5 | id: string // 字段id 6 | name: string // 字段名称 7 | } 8 | 9 | export interface DatasetItem { 10 | id: string // 数据集id 11 | name: string // 数据集名称 12 | type: string // 数据集的类型 13 | fields: Field[] // 数据集的字段 14 | } 15 | 16 | interface Entities { 17 | datasetList: DatasetItem 18 | } 19 | 20 | interface Effects { 21 | fetchDatasets: (data: { payload: string[] }) => any 22 | } 23 | 24 | export default createDomainModule('domain/dataset', { 25 | entities: { 26 | datasetList: 'id' 27 | }, 28 | selectors: {}, // 占位 29 | services: ({ entities, sagaEffects: { put, call } }) => ({ 30 | *fetchDatasets({ payload }: { payload: string[] }) { 31 | // 请求图表详情 32 | const datasets = yield call(quertyDatasetList, payload) 33 | // 保存值 34 | entities.datasetList.insert(datasets) 35 | } 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /__tests__/middleware_spec.ts: -------------------------------------------------------------------------------- 1 | import createApp, { createPageModule } from '../src' 2 | 3 | describe('add_middleware', () => { 4 | test('multi modules', () => { 5 | const app = createApp() 6 | 7 | const module = createPageModule('page', { 8 | initialState: { 9 | count: 0 10 | }, 11 | selectors: { 12 | getCount: state => state.count 13 | }, 14 | reducers: { 15 | addCount: (state, { payload }) => ({ 16 | ...state, 17 | count: state.count + payload 18 | }) 19 | } 20 | }) 21 | 22 | const resetPayloadMiddleware = () => next => action => { 23 | const result = next({ 24 | ...action, 25 | payload: 0 26 | }) 27 | return { 28 | ...result, 29 | count: 0 30 | } 31 | } 32 | app.addMiddleWare(resetPayloadMiddleware) 33 | app.addPage(module) 34 | app.start() 35 | 36 | app._store.dispatch(module.actions.addCount(100)) 37 | const data2 = module.selectors.getCount(app._store.getState()) 38 | expect(data2).toBe(0) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/chartAttribute/ui/index.css: -------------------------------------------------------------------------------- 1 | .chart-attr { 2 | width: 100%; 3 | } 4 | 5 | .chart-attr .attr-empty { 6 | height: 20px; 7 | line-height: 20px; 8 | font-size: 12px; 9 | text-align: center; 10 | color: rgba(0, 0, 0, 0.45); 11 | } 12 | 13 | .chart-attr .attr .attr-field { 14 | color: rgba(0, 0, 0, 0.65); 15 | font-size: 12px; 16 | height: 24px; 17 | line-height: 24px; 18 | padding-left: 4px; 19 | margin-bottom: 2px; 20 | } 21 | 22 | .chart-attr .attr .attr-field:last-child { 23 | margin-bottom: 0; 24 | } 25 | 26 | .chart-attr .attr:first-child .attr-field { 27 | background-color: #b7dcf7; 28 | } 29 | 30 | .chart-attr .attr .attr-field { 31 | background-color: #cfefdf; 32 | } 33 | 34 | .chart-attr .attr .attr-group { 35 | font-size: 12px; 36 | color: rgba(0, 0, 0, 0.45); 37 | margin-bottom: 4px; 38 | } 39 | 40 | .chart-attr .attr .attr-field-list { 41 | margin-bottom: 16px; 42 | font-size: 12px; 43 | padding: 4px; 44 | border-radius: 4px; 45 | color: #999; 46 | border: 1px dashed #bfbfbf; 47 | background: #edf1f5; 48 | } 49 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/datasetField/index.ts: -------------------------------------------------------------------------------- 1 | import { createContainerModule } from 'redux-with-domain' 2 | import datasetDomain, { DatasetItem, Field } from '../../../domain/dataset' 3 | 4 | /** 5 | * {page-module} 6 | */ 7 | export default createContainerModule('workbook/datasetFields', { 8 | initialState: { 9 | filter: '' 10 | }, 11 | selectors: { 12 | getDatasetFields: (state, { pageSelectors } = {}): Field[] => { 13 | const selectedDatasetId: string = pageSelectors.getSelectedDatasetId() 14 | const dataset: DatasetItem = datasetDomain.entities.datasetList.get( 15 | selectedDatasetId 16 | ) 17 | const fields = dataset ? dataset.fields : [] 18 | // 过滤逻辑 19 | if (state.filter.length > 0) { 20 | return fields.filter(field => field.name.includes(state.filter)) 21 | } 22 | return fields 23 | } 24 | }, 25 | reducers: { 26 | updateFilter: (state, { payload }) => { 27 | return { 28 | ...state, 29 | filter: payload || '' 30 | } 31 | } 32 | }, 33 | effects: () => {} 34 | }) 35 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | // Object type 2 | export const MODULE = 'MODULE' 3 | export const ACTION = Symbol('ACTION') 4 | export const PRESENTER = Symbol('PRESENTER') 5 | 6 | // Module type 7 | export const DEFAULT_MODULE = Symbol('DEFAULT_MODULE') 8 | export const DI_MODULE = Symbol('DI_MODULE') 9 | export const CONTAINER_MODULE = Symbol('CONTAINER_MODULE') 10 | export const PAGE_MODULE = Symbol('PAGE_MODULE') 11 | export const DOMAIN_MODULE = Symbol('DOMAIN_MODULE') 12 | export const ENTITY_MODULE = Symbol('ENTITY_MODULE') 13 | export const GLOBAL_MODULE = Symbol('GLOBAL_MODULE') 14 | 15 | // Action type 16 | export const ACTION_KIND = { 17 | REDUCER: Symbol('REDUCER'), 18 | EFFECT: Symbol('EFFECT'), 19 | CREATOR: Symbol('CREATOR') 20 | } 21 | 22 | export const CHANGE_LOADING_REDUCER = '@@loading-update' 23 | export const GET_LOADING_SELECTOR = '__getLoadings' 24 | 25 | // For test 26 | export const SET_MODULE_STATE_ACTION = 'SET_MODULE_STATE_ACTION' 27 | 28 | export const KOP_GLOBAL_STORE_REF = Symbol('KOP_GLOBAL_STORE_REF') 29 | export const KOP_GLOBAL_SELECTOR_LOOP_CHECK = Symbol( 30 | 'KOP_GLOBAL_SELECTOR_LOOP_CHECK' 31 | ) 32 | -------------------------------------------------------------------------------- /src/helpers/autoGetSet.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import update from 'immutability-helper-x' 3 | import { SelectorsOf, ReducerOf } from '../types/common' 4 | 5 | interface Options { 6 | initialState: { 7 | [key: string]: any 8 | } 9 | selectors?: SelectorsOf 10 | reducers?: ReducerOf 11 | } 12 | 13 | const firstUpperCase = ([first, ...rest]: string[]): string => 14 | first.toUpperCase() + rest.join('') 15 | 16 | export function autoGetSet(options: Options): Options { 17 | const { initialState, selectors, reducers } = options 18 | const autoSelectors: object = {} 19 | const autoReducers: object = {} 20 | 21 | _.forEach(initialState, (_value, key: string) => { 22 | autoSelectors[`get${firstUpperCase(key as any)}`] = state => state[key] 23 | 24 | autoReducers[`set${firstUpperCase(key as any)}`] = (state, { payload }) => 25 | update.$set(state, key, payload) 26 | }) 27 | 28 | options.selectors = { 29 | ...autoSelectors, 30 | ...selectors 31 | } 32 | 33 | options.reducers = { 34 | ...autoReducers, 35 | ...reducers 36 | } 37 | 38 | return options 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Proto Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/simple-BI/src/common/component/DatasetIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import './index.css' 3 | 4 | interface Props {} 5 | 6 | const DatasetIcon: FC = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | export default DatasetIcon 17 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/chartAttribute/ui/components/attrItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { useDrop } from 'react-dnd' 3 | import { useDispatch, useSelector } from 'redux-with-domain' 4 | import module, { Attrbute } from '../../../index' 5 | 6 | interface Props { 7 | attr: Attrbute 8 | index: number 9 | } 10 | 11 | const AttriItem: FC = ({ attr: { group, fields }, index }) => { 12 | const dispatch = useDispatch() 13 | const chartId = useSelector(module.selectors.getChartId) 14 | const fieldItems = fields.map(field => { 15 | return ( 16 | 17 | {field.name} 18 | 19 | ) 20 | }) 21 | 22 | const [_, drop] = useDrop({ 23 | accept: 'FIELD', 24 | drop: (item: any, monitor) => { 25 | dispatch( 26 | module.actions.addField({ group, fieldId: item.id, index, chartId }) 27 | ) 28 | } 29 | }) 30 | 31 | return ( 32 | 33 | {group} 34 | 35 | {fieldItems} 36 | 37 | 38 | ) 39 | } 40 | 41 | export default AttriItem 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-with-domain 2 | 3 | 4 | 5 |  6 | [](https://coveralls.io/github/ProtoTeam/redux-with-domain?branch=master) 7 | [](https://www.npmjs.com/package/redux-with-domain) 8 | 9 |  10 |  11 | 12 | 13 | 14 | -------------------- 15 | 16 | ## 背景 17 | 18 | 一个从蚂蚁数据平台孵化出的前端 SPA 框架,她专为开发复杂 web 应用而生。 19 | 在蚂蚁内部,我们喜欢叫她 KOP(King Of Prediction),而在外部,我们将其命名为 redux-with-domain,这样更能见名知意一些。 20 | 21 | ## 设计理念 22 | 23 | 1. 领域驱动设计 [链接](https://github.com/ProtoTeam/redux-with-domain/wiki#%E9%A2%86%E5%9F%9F%E9%A9%B1%E5%8A%A8%E8%AE%BE%E8%AE%A1) 24 | 25 | 2. 状态分层 [链接](https://github.com/ProtoTeam/redux-with-domain/wiki#%E7%8A%B6%E6%80%81%E5%88%86%E5%B1%82) 26 | 27 | ## demos 28 | 基于 BI 为原型开发的 redux-with-domain 例子。演示了如何使用 redux-with-domain 解决复杂前端项目的架构问题。 29 | [simple-BI](./examples/simple-BI) 30 | 31 | ## License 32 | [MIT](https://tldrlegal.com/license/mit-license) 33 | -------------------------------------------------------------------------------- /src/types/pageModule.ts: -------------------------------------------------------------------------------- 1 | import { 2 | KopActions, 3 | Func, 4 | BaseModule, 5 | SagaEffects, 6 | SagaEnhancer 7 | } from './common' 8 | 9 | type KopSelectors = { 10 | [K in keyof Selectors]: (...args: any[]) => any 11 | } 12 | 13 | export interface PageSelectorsOf { 14 | [key: string]: (state: State, { payload: any }) => any 15 | } 16 | 17 | export interface PageEffectsOf { 18 | < 19 | Actions extends KopActions & { [key: string]: Func }, 20 | S extends KopSelectors 21 | >(params: { 22 | actions: Actions 23 | selectors: S 24 | sagaEffects: SagaEffects 25 | enhancer: SagaEnhancer 26 | }): Effects 27 | } 28 | 29 | export interface PageModule extends BaseModule { 30 | injectModules?: string[] 31 | watchers?: Function[] 32 | } 33 | 34 | export interface KopPageModule { 35 | namespace: string 36 | type: symbol // module 分层模块类型 37 | parent: {} 38 | presenter: { 39 | loaded: boolean 40 | } 41 | selectors: KopSelectors 42 | actions: KopActions 43 | injectModules: string[] 44 | watchers: Func[] 45 | event: (evt: string) => string 46 | } 47 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/common/reportCanvas/ui/index.css: -------------------------------------------------------------------------------- 1 | .report-canvas { 2 | flex: 1; 3 | overflow: auto; 4 | display: flex; 5 | flex-direction: column; 6 | padding: 16px 48px; 7 | background: #f0f3f5; 8 | } 9 | 10 | .report-canvas .list { 11 | border: 1px dashed #a4b1bd; 12 | } 13 | 14 | .report-canvas .chart { 15 | margin: 4px 4px 8px 4px; 16 | padding: 20px; 17 | height: 280px; 18 | flex-direction: column; 19 | overflow: hidden; 20 | background-color: #fff; 21 | cursor: pointer; 22 | border: 2px solid transparent; 23 | display: flex; 24 | } 25 | 26 | .report-canvas .chart:last-child { 27 | margin-bottom: 4px; 28 | } 29 | 30 | .report-canvas .chart .title { 31 | color: rgba(0, 0, 0, 0.85); 32 | font-size: 18px; 33 | margin-bottom: 16px; 34 | font-family: PingFangSC-Medium; 35 | } 36 | 37 | .report-canvas .chart.selected { 38 | border: 2px solid #69c0ff; 39 | } 40 | 41 | .report-canvas .chart img { 42 | height: 90%; 43 | width: auto; 44 | } 45 | 46 | .report-canvas .chart .chart-container { 47 | display: flex; 48 | flex: 1; 49 | } 50 | 51 | .report-canvas .icon-loading { 52 | position: absolute; 53 | z-index: 2; 54 | top: 50%; 55 | left: 50%; 56 | transform: translate(-13px, -13px); 57 | } 58 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/datasetField/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback, ChangeEvent } from 'react' 2 | import { useSelector, useDispatch } from 'redux-with-domain' 3 | import { Input } from 'antd' 4 | import FieldItem from './components/fieldItem' 5 | import module from '../index' 6 | import './index.css' 7 | 8 | interface Props {} 9 | 10 | const DatasetField: FC = props => { 11 | const fields = useSelector((state: any) => 12 | module.selectors.getDatasetFields(state) 13 | ) 14 | 15 | const dispatch = useDispatch() 16 | const onSearch = useCallback( 17 | (event: ChangeEvent) => { 18 | dispatch(module.actions.updateFilter(event.target.value)) 19 | }, 20 | [module, dispatch] 21 | ) 22 | 23 | return ( 24 | 25 | 数据集字段 26 | 27 | 33 | {fields.map((field, index: number) => { 34 | return 35 | })} 36 | 37 | 38 | ) 39 | } 40 | 41 | export default DatasetField 42 | -------------------------------------------------------------------------------- /examples/simple-BI/src/domain/chart.ts: -------------------------------------------------------------------------------- 1 | import { createDomainModule } from 'redux-with-domain' 2 | import { quertyReportChartInfo } from '../api/report' 3 | 4 | export interface Attr { 5 | max?: number 6 | group: string 7 | fields: string[] 8 | fieldsName?: string[] 9 | } 10 | 11 | export interface Chart { 12 | id: string 13 | reportId: string 14 | datasetId: string 15 | name: string 16 | attribute: Attr[] 17 | type: string 18 | } 19 | 20 | interface Entities { 21 | chart: Chart 22 | } 23 | 24 | const selectors = {} // 占位 25 | 26 | type Effects = { 27 | fetchCharts: (data: { payload: string }) => any 28 | } 29 | 30 | export default createDomainModule( 31 | 'domain/chart', 32 | { 33 | entities: { 34 | chart: 'id' 35 | }, 36 | selectors, 37 | services: ({ services, entities, sagaEffects: { call } }) => ({ 38 | *fetchCharts({ payload }) { 39 | // 如果之前已经请求过,不用重复请求 40 | const charts = entities.chart.get(payload) 41 | if (charts) { 42 | return 43 | } 44 | // 请求图表详情 45 | const reportInfo: Chart = yield call(quertyReportChartInfo, payload) 46 | // 保存值 47 | entities.chart.insert(reportInfo) 48 | } 49 | }) 50 | } 51 | ) 52 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/reportList/index.ts: -------------------------------------------------------------------------------- 1 | import { createContainerModule } from 'redux-with-domain' 2 | import reportDomain, { ReportItem } from '../../../domain/report' 3 | 4 | // 向页面抛出选中报表id改变的事件名 5 | export const kSelectedReportIdChangeEmitName = 'setSelectedReportId' 6 | 7 | export default createContainerModule('workbook/reportList', { 8 | initialState: {}, 9 | selectors: { 10 | getReports: state => { 11 | const list: ReportItem[] = reportDomain.entities.reportList.select() || [] 12 | return list 13 | }, 14 | getSelectedReportId: (state, { pageSelectors } = {}) => { 15 | return pageSelectors.getSelectedReportId() 16 | } 17 | }, 18 | effects: ({ 19 | actions, 20 | selectors, 21 | sagaEffects: { call, put, select }, 22 | enhancer: { emit, syncPut } 23 | }) => ({ 24 | *init() { 25 | // 选中第一条报表 26 | const reportList: ReportItem[] = yield select(selectors.getReports) 27 | if (reportList && reportList.length > 0) { 28 | yield syncPut(actions.setSelectedReportId(reportList[0].id)) 29 | } 30 | }, 31 | *setSelectedReportId({ payload }: { payload: string }) { 32 | // 告知页面报表选中已经发生变化 33 | yield emit({ name: kSelectedReportIdChangeEmitName, payload: payload }) 34 | } 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/reportList/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback } from 'react' 2 | import { useSelector, useDispatch } from 'redux-with-domain' 3 | import module from '../index' 4 | import './index.css' 5 | 6 | interface Props {} 7 | const ReportList: FC = props => { 8 | // 报表列表 9 | const reports = useSelector(state => { 10 | return module.selectors.getReports(state) 11 | }) 12 | // 选中的报表 13 | const selectedReportId = useSelector(state => { 14 | return module.selectors.getSelectedReportId(state) 15 | }) 16 | // 选中变更 17 | const dispatch = useDispatch() 18 | const changeSelectedReport = useCallback( 19 | (id: string) => { 20 | dispatch(module.actions.setSelectedReportId(id)) 21 | }, 22 | [dispatch] 23 | ) 24 | 25 | return ( 26 | 27 | {reports.map((report, index: number) => { 28 | return ( 29 | { 35 | changeSelectedReport(report.id) 36 | }} 37 | > 38 | {report.name} 39 | 40 | ) 41 | })} 42 | 43 | ) 44 | } 45 | 46 | export default ReportList 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: macOS-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Use Node.js 12 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: 12.x 14 | - name: npm install 15 | run: | 16 | npm install 17 | - name: lint 18 | run: | 19 | npm run lint 20 | build: 21 | runs-on: macOS-latest 22 | steps: 23 | - uses: actions/checkout@v1 24 | - name: Use Node.js 12 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: 12.x 28 | - name: npm install 29 | run: | 30 | npm install 31 | - name: build 32 | run: | 33 | npm run build 34 | test: 35 | runs-on: macOS-latest 36 | steps: 37 | - uses: actions/checkout@v1 38 | - name: Use Node.js 12 39 | uses: actions/setup-node@v1 40 | with: 41 | node-version: 12.x 42 | - name: npm install 43 | run: | 44 | npm install 45 | - name: test 46 | run: | 47 | npm run coverage 48 | env: 49 | CI: true 50 | - name: Coveralls 51 | uses: coverallsapp/github-action@master 52 | with: 53 | github-token: ${{ secrets.GITHUB_TOKEN }} 54 | -------------------------------------------------------------------------------- /examples/simple-BI/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom' 2 | import createApp from 'redux-with-domain' 3 | import './index.css' 4 | import AppRouter from './router' 5 | 6 | import workbookDomain from './domain/workbook' 7 | import datasetDomain from './domain/dataset' 8 | import reportDomain from './domain/report' 9 | import chartDomain from './domain/chart' 10 | import WorkbookModule from './page/workbook' 11 | import AttributesModule from './container/workbook/chartAttribute' 12 | import DatasetFieldsModule from './container/workbook/datasetField' 13 | import DatasetListModule from './container/workbook/datasetList' 14 | import ReportCanvasModule from './container/common/reportCanvas' 15 | import ReportListModule from './container/workbook/reportList' 16 | 17 | import ReportPageModule from './page/reportView' 18 | 19 | const app = createApp() 20 | 21 | app.addPage(WorkbookModule, { 22 | containers: [ 23 | AttributesModule, 24 | DatasetFieldsModule, 25 | DatasetListModule, 26 | ReportCanvasModule, 27 | ReportListModule 28 | ] 29 | }) 30 | 31 | app.addPage(ReportPageModule, { 32 | container: [ReportCanvasModule] 33 | }) 34 | 35 | app.addDomain(workbookDomain) 36 | app.addDomain(datasetDomain) 37 | app.addDomain(reportDomain) 38 | app.addDomain(chartDomain) 39 | 40 | app.addRouter(AppRouter) 41 | 42 | const node = app.start() 43 | 44 | ReactDOM.render(node as any, document.getElementById('root')) 45 | -------------------------------------------------------------------------------- /src/module/options/watchers.ts: -------------------------------------------------------------------------------- 1 | import { forEach, isFunction, isArray } from 'lodash' 2 | import { effects } from 'redux-saga' 3 | import { generateSagaEnhancer } from '../../utils' 4 | import { wrapEffectsFunc } from '../util/util' 5 | 6 | export function createPageWatchers( 7 | actions, 8 | selectors, 9 | watchersOpt, 10 | checkAuth, 11 | namespace 12 | ) { 13 | let sagaEffects = effects 14 | let enhancer = generateSagaEnhancer({}, namespace) 15 | 16 | if (process.env.NODE_ENV === 'development') { 17 | sagaEffects = wrapEffectsFunc(checkAuth, namespace) 18 | enhancer = generateSagaEnhancer({}, namespace, checkAuth) 19 | } 20 | 21 | const watchers = watchersOpt({ 22 | actions, 23 | selectors, 24 | enhancer, 25 | sagaEffects 26 | }) 27 | const watcherFns: Function[] = [] 28 | 29 | function takeWatch(value, key) { 30 | if (isArray(value)) { 31 | watcherFns.push(function*() { 32 | yield effects[value[1]](key, value[0]) 33 | }) 34 | } 35 | 36 | if (isFunction(value)) { 37 | watcherFns.push(function*() { 38 | yield effects.takeEvery(key, value) 39 | }) 40 | } 41 | } 42 | 43 | forEach(watchers, (value, key) => { 44 | const keyArr = key.split(',') 45 | if (keyArr.length > 1) { 46 | keyArr.forEach(k => { 47 | takeWatch(value, k) 48 | }) 49 | } else { 50 | takeWatch(value, key) 51 | } 52 | }) 53 | return watcherFns 54 | } 55 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/common/reportCanvas/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback, useEffect } from 'react' 2 | import { useSelector, useDispatch } from 'redux-with-domain' 3 | import Chart from './components/chart' 4 | import { Chart as ChartType } from '../../../../domain/chart' 5 | import module from '../index' 6 | import LoadingIcon from '../../../../common/component/LoadingIcon' 7 | import './index.css' 8 | 9 | interface Props { 10 | reportId: string 11 | } 12 | 13 | const ReportCanvas: FC = props => { 14 | const dispatch = useDispatch() 15 | const charts: ChartType[] = useSelector(state => { 16 | return module.selectors.getCharts(state, props.reportId) 17 | }) 18 | const selectedChartId = useSelector(module.selectors.getSelectedChartId) 19 | const loading = useSelector(module.selectors.getLoading) 20 | 21 | const changeSelectedChart = useCallback((id: string) => { 22 | dispatch(module.actions.setSelectedChart(id)) 23 | }, []) 24 | 25 | const chartList = charts.map((chart: ChartType, index: number) => { 26 | return ( 27 | 33 | ) 34 | }) 35 | 36 | return ( 37 | 38 | {loading ? ( 39 | 40 | ) : chartList.length ? ( 41 | {chartList} 42 | ) : null} 43 | 44 | ) 45 | } 46 | 47 | export default ReportCanvas 48 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/datasetList/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { useSelector, useDispatch } from 'redux-with-domain' 3 | import { useParams } from 'react-router' 4 | import DatasetIcon from '../../../../common/component/DatasetIcon' 5 | import module from '../index' 6 | import './index.css' 7 | 8 | interface Props {} 9 | 10 | interface DatasetListItem { 11 | id: string 12 | name: string 13 | } 14 | 15 | const DatasetList: FC = props => { 16 | const dispatch = useDispatch() 17 | const params = useParams<{ id: string }>() 18 | const datasetList = useSelector((state: any) => 19 | module.selectors.getDatasetList(state, params.id) 20 | ) 21 | 22 | const selectedDatasetId = useSelector(module.selectors.getSelectedDatasetId) 23 | 24 | const changeSelected = (id: string) => { 25 | dispatch(module.actions.setSelectedDataset(id)) 26 | } 27 | 28 | return ( 29 | 30 | 数据集 31 | 32 | {datasetList.map((dataset: DatasetListItem, index: number) => { 33 | return ( 34 | { 40 | changeSelected(dataset.id) 41 | }} 42 | > 43 | 44 | {dataset.name} 45 | 46 | ) 47 | })} 48 | 49 | 50 | ) 51 | } 52 | 53 | export default DatasetList 54 | -------------------------------------------------------------------------------- /src/module/globalModule.ts: -------------------------------------------------------------------------------- 1 | import { extend } from 'lodash' 2 | import { GLOBAL_MODULE } from '../const' 3 | import { 4 | initInitialState, 5 | initModule, 6 | initSelectors, 7 | initActions 8 | } from './util/util' 9 | import { getAuthCheck } from './options/effects' 10 | import { SelectorsOf, ReducerOf, EffectsOf, ActionsOf } from '../types/common' 11 | import { KopGlobalModule } from '../types/globalModule' 12 | 13 | export default function createGlobalModule< 14 | State = {}, 15 | Selectors extends SelectorsOf = SelectorsOf, 16 | Reducers extends ReducerOf = ReducerOf, 17 | Effects = {}, 18 | ActionCreators = {} 19 | >( 20 | namespace: string, 21 | pkg: { 22 | initialState?: State 23 | selectors?: Selectors 24 | reducers?: Reducers 25 | effects?: EffectsOf 26 | effectLoading?: boolean 27 | actionCreators?: ActionsOf 28 | } 29 | ): KopGlobalModule { 30 | const type = GLOBAL_MODULE 31 | const module = initModule(pkg, namespace, type) as any 32 | 33 | initInitialState(module, pkg) 34 | initSelectors(module, pkg, namespace, type) 35 | 36 | module.actions = {} 37 | 38 | const { actions, effects, _reducers, event, actionCreators } = initActions( 39 | module, 40 | pkg, 41 | namespace, 42 | type, 43 | getAuthCheck[type] 44 | ) 45 | 46 | extend(module.actions, actionCreators, actions) 47 | 48 | module._reducers = _reducers 49 | module.event = event 50 | 51 | if (effects) { 52 | module.effects = effects 53 | } 54 | 55 | return module 56 | } 57 | -------------------------------------------------------------------------------- /__tests__/module_internal_spec.ts: -------------------------------------------------------------------------------- 1 | import createApp from '../src' 2 | import createModule from '../src/module/module' 3 | import { DEFAULT_MODULE } from '../src/const' 4 | 5 | // unit test internal `createModule` API 6 | describe('createModule', () => { 7 | test('selectors & reducers & effects', () => { 8 | const app = createApp() 9 | const initCount = 1 10 | const moduleOption = { 11 | initialState: { 12 | count: initCount 13 | }, 14 | selectors: { 15 | getCount: state => state.count 16 | }, 17 | reducers: { 18 | setCount: (state, { payload: count }) => ({ 19 | ...state, 20 | count 21 | }) 22 | }, 23 | // Note: here `effects` function parameters are different from those of container or page module. 24 | effects: (actions, selectors, { put }) => ({ 25 | fetchCount: [ 26 | function*({ payload }) { 27 | yield put(actions.setCount(payload)) 28 | }, 29 | 'takeLatest' 30 | ], 31 | *fetchCount2({ payload }) { 32 | yield put(actions.setCount(payload)) 33 | } 34 | }) 35 | } 36 | 37 | const module1 = createModule('module1', moduleOption, DEFAULT_MODULE) 38 | app.addModule(module1) 39 | app.start() 40 | 41 | expect(module1.selectors.getCount()).toBe(initCount) 42 | 43 | let newCount = 2 44 | app._store.dispatch(module1.actions.fetchCount(newCount)) 45 | expect(module1.selectors.getCount()).toBe(newCount) 46 | 47 | newCount = 3 48 | app._store.dispatch(module1.actions.fetchCount2(newCount)) 49 | expect(module1.selectors.getCount()).toBe(newCount) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /examples/simple-BI/src/page/workbook/ui/index.css: -------------------------------------------------------------------------------- 1 | .workbook { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | .header { 7 | display: flex; 8 | flex-shrink: 0; 9 | justify-content: space-between; 10 | height: 48px; 11 | color: #fff; 12 | background: #2a3038; 13 | align-items: center; 14 | padding-left: 10px; 15 | } 16 | 17 | .header button { 18 | margin-right: 20px; 19 | background-color: transparent; 20 | color: white; 21 | } 22 | 23 | .content { 24 | overflow: hidden; 25 | flex: 1; 26 | display: flex; 27 | } 28 | 29 | .sidebar { 30 | width: 400px; 31 | display: flex; 32 | flex-direction: column; 33 | flex-shrink: 0; 34 | box-shadow: 0 2px 4px 0 rgba(184, 198, 211, 0.4); 35 | position: relative; 36 | z-index: 2; 37 | } 38 | 39 | .sidebar-header { 40 | height: 40px; 41 | background-color: #ebeef3; 42 | justify-content: center; 43 | align-items: center; 44 | display: flex; 45 | border-right: 1px solid #dce3e8; 46 | border-bottom: 1px solid #dce3e8; 47 | font-size: 14px; 48 | color: #259df7; 49 | font-weight: 500; 50 | } 51 | 52 | .sidebar-main { 53 | display: flex; 54 | flex: 1; 55 | } 56 | 57 | .sidebar-main .left, 58 | .sidebar-main .right { 59 | display: flex; 60 | flex: 1; 61 | background-color: rgb(247, 249, 251); 62 | border-right: 1px solid #dce3e8; 63 | } 64 | .sidebar-main .left { 65 | flex-direction: column; 66 | border-right: 1px solid rgb(220, 227, 232); 67 | } 68 | 69 | .sidebar-main .right { 70 | padding: 10px 8px; 71 | } 72 | 73 | .main-container { 74 | flex: 1; 75 | display: flex; 76 | background-color: #f0f3f5; 77 | flex-direction: column; 78 | position: relative; 79 | } 80 | -------------------------------------------------------------------------------- /__tests__/addRouter_spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, HashRouter } from 'react-router-dom' 3 | import { mount } from 'enzyme' 4 | import _ from 'lodash' 5 | import createApp, { createPageModule } from '../src' 6 | 7 | describe('Router', () => { 8 | const moduleNamespace = 'module1' 9 | 10 | function startApp(...args: any[]) { 11 | const app = createApp() 12 | 13 | const Container = (props: any) => { 14 | const { children } = props 15 | return {children} 16 | } 17 | const router = ( 18 | 19 | 20 | 21 | ) 22 | app.addRouter(router) 23 | const module = createPageModule(moduleNamespace, { 24 | initialState: {} 25 | }) 26 | app.addPage(module) 27 | 28 | const routerNode = app.start(...args) 29 | return { 30 | app, 31 | wrapper: 32 | _.isString(args[0]) || _.isElement(args[0]) ? mount(routerNode) : null 33 | } 34 | } 35 | 36 | test('add router', () => { 37 | const { wrapper } = startApp('#container') 38 | expect(wrapper.find('#root').length).toBe(1) 39 | }) 40 | 41 | test('app.start with dom element', () => { 42 | const { wrapper } = startApp(document.querySelector('#container'), _.noop) 43 | expect(wrapper.find('#root').length).toBe(1) 44 | }) 45 | 46 | test('app.start with callback', () => { 47 | const mockCallback = jest.fn(_.noop) 48 | startApp(mockCallback) 49 | expect(mockCallback.mock.calls.length).toBe(1) 50 | }) 51 | 52 | test('app.start with false', () => { 53 | const { app } = startApp(false) 54 | expect(app._modules[moduleNamespace]).toBeDefined() 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /examples/simple-BI/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "./src/index.d.ts", 6 | "dependencies": { 7 | "redux-with-domain": "^1.0.0", 8 | "@antv/g2plot": "^1.0.2", 9 | "@testing-library/jest-dom": "^4.2.4", 10 | "@testing-library/react": "^9.3.2", 11 | "@testing-library/user-event": "^7.1.2", 12 | "@types/jest": "^24.0.0", 13 | "@types/node": "^12.0.0", 14 | "@types/react": "^16.9.0", 15 | "@types/react-dom": "^16.9.0", 16 | "antd": "^4.1.3", 17 | "immutability-helper-x": "^1.0.5", 18 | "lodash": "^4.17.15", 19 | "react": "^16.13.0", 20 | "react-dnd": "^10.0.2", 21 | "react-dnd-html5-backend": "^10.0.2", 22 | "react-dom": "^16.13.0", 23 | "react-redux": "^7.2.0", 24 | "react-router": "^5.1.2", 25 | "react-router-dom": "^5.1.2", 26 | "react-scripts": "3.4.0", 27 | "redux": "^4.0.0", 28 | "redux-saga": "^0.16.0", 29 | "reselect": "^3.0.0", 30 | "typescript": "~3.7.2" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "build": "react-scripts build", 35 | "test": "react-scripts test", 36 | "eject": "react-scripts eject" 37 | }, 38 | "eslintConfig": { 39 | "extends": "react-app" 40 | }, 41 | "browserslist": { 42 | "production": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ], 47 | "development": [ 48 | "last 1 chrome version", 49 | "last 1 firefox version", 50 | "last 1 safari version" 51 | ] 52 | }, 53 | "devDependencies": { 54 | "@types/lodash": "^4.14.149", 55 | "@types/react-redux": "^7.0.0", 56 | "@types/react-router-dom": "^5.1.3", 57 | "eslint-plugin-flowtype": "^5.2.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/module/containerModule.ts: -------------------------------------------------------------------------------- 1 | import { extend } from 'lodash' 2 | import { CONTAINER_MODULE } from '../const' 3 | import { 4 | initInitialState, 5 | initModule, 6 | initSelectors, 7 | initActions 8 | } from './util/util' 9 | import { getAuthCheck } from './options/effects' 10 | import { 11 | SelectorsOf, 12 | ReducerOf, 13 | EffectsOf, 14 | ActionsOf, 15 | DefaultReducer 16 | } from '../types/common' 17 | import { KopContainerModule } from '../types/containerModule' 18 | 19 | export default function createContainerModule< 20 | State = {}, 21 | Selectors extends SelectorsOf = SelectorsOf, 22 | Reducers extends ReducerOf = ReducerOf, 23 | Effects = {}, 24 | ActionCreators = {} 25 | >( 26 | namespace: string, 27 | pkg: { 28 | initialState?: State 29 | selectors?: Selectors 30 | reducers?: Reducers 31 | defaultReducer?: DefaultReducer 32 | effects?: EffectsOf 33 | effectLoading?: boolean 34 | actionCreators?: ActionsOf 35 | } 36 | ): KopContainerModule { 37 | const module = initModule(pkg, namespace, CONTAINER_MODULE) as any 38 | 39 | initInitialState(module, pkg) 40 | 41 | initSelectors(module, pkg, namespace, CONTAINER_MODULE) 42 | 43 | module.actions = {} 44 | 45 | const { actions, effects, _reducers, event, actionCreators } = initActions( 46 | module, 47 | pkg, 48 | namespace, 49 | CONTAINER_MODULE, 50 | getAuthCheck[CONTAINER_MODULE] 51 | ) 52 | 53 | extend(module.actions, actionCreators, actions) 54 | 55 | module._reducers = _reducers 56 | module._defaultReducer = pkg.defaultReducer 57 | module.event = event 58 | 59 | if (effects) { 60 | module.effects = effects 61 | } 62 | 63 | return module 64 | } 65 | -------------------------------------------------------------------------------- /examples/simple-BI/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | You need to enable JavaScript to run this app. 31 | 32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable quote-props */ 2 | import typescript from 'rollup-plugin-typescript' 3 | import commonjs from 'rollup-plugin-commonjs' 4 | import resolve from 'rollup-plugin-node-resolve' 5 | import babel from 'rollup-plugin-babel' 6 | import * as react from 'react' 7 | import * as reactDom from 'react-dom' 8 | import * as reactIs from 'react-is' 9 | import * as lodash from 'lodash' 10 | 11 | const env = process.env.NODE_ENV 12 | const isDevelopment = env === 'development' 13 | 14 | const extensions = ['.js', '.jsx', '.ts', '.tsx'] 15 | 16 | export default { 17 | input: './src/index.ts', 18 | 19 | external: id => { 20 | const list = [ 21 | 'react', 22 | 'react-dom', 23 | 'lodash', 24 | 'redux', 25 | 'react-redux', 26 | 'redux-saga', 27 | 'reselect', 28 | 'immutability-helper-x', 29 | 'invariant', 30 | 'react-router-dom', 31 | 'regenerator-runtime/runtime' 32 | ] 33 | 34 | if (list.includes(id) || /core-js/.test(id)) { 35 | return true 36 | } 37 | return false 38 | }, 39 | 40 | plugins: [ 41 | typescript(), 42 | 43 | // Allows node_modules resolution 44 | resolve({ extensions }), 45 | 46 | // Allow bundling cjs modules. Rollup doesn't understand cjs 47 | commonjs({ 48 | namedExports: { 49 | react: Object.keys(react), 50 | 'react-dom': Object.keys(reactDom), 51 | 'react-is': Object.keys(reactIs), 52 | lodash: Object.keys(lodash) 53 | } 54 | }), 55 | 56 | // Compile TypeScript/JavaScript files 57 | babel({ extensions, include: ['src/**/*'] }) 58 | ], 59 | 60 | output: [ 61 | { 62 | file: isDevelopment 63 | ? 'dist/kop.dev.common.js' 64 | : 'dist/kop.prod.common.js', 65 | format: 'cjs' 66 | }, 67 | { 68 | file: 'dist/kop.esm.js', 69 | format: 'es' 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/datasetList/index.ts: -------------------------------------------------------------------------------- 1 | import { createContainerModule } from 'redux-with-domain' 2 | import update from 'immutability-helper-x' 3 | import _ from 'lodash' 4 | import datasetDomain, { DatasetItem } from '../../../domain/dataset' 5 | import chartDomain from '../../../domain/chart' 6 | 7 | // 向页面数据集改变的事件名 8 | export const kSelectedDatasetIdChangeEmitName = 'setSelectedDatasetId' 9 | 10 | /** 11 | * {page-module} 12 | */ 13 | export default createContainerModule('workbook/datasetList', { 14 | initialState: { 15 | selectedDatasetId: '' 16 | }, 17 | selectors: { 18 | getDatasetList: (state: any, { payload: id }: { payload: string }) => { 19 | return datasetDomain.entities.datasetList.select() 20 | }, 21 | getSelectedDatasetId: state => state.selectedDatasetId, 22 | getSelectedChartId: (state, { pageSelectors }) => { 23 | const id = pageSelectors.getSelectedChartId() 24 | return id 25 | } 26 | }, 27 | reducers: { 28 | saveSelectedDataset: (state, { payload }: { payload: string }) => { 29 | return update.$set(state, 'selectedDatasetId', payload) 30 | } 31 | }, 32 | effects: ({ 33 | actions, 34 | selectors, 35 | sagaEffects: { put, select, call }, 36 | enhancer: { emit, syncPut } 37 | }) => ({ 38 | *init() { 39 | // 选中第一个数据集 40 | const datasetList: DatasetItem[] = yield select( 41 | selectors.getDatasetList as any 42 | ) 43 | if (datasetList && datasetList.length > 0) { 44 | yield syncPut(actions.saveSelectedDataset(datasetList[0].id)) 45 | } 46 | }, 47 | *setSelectedDataset({ payload }: { payload: string }) { 48 | yield put(actions.saveSelectedDataset(payload)) 49 | const chartId: string = yield select(selectors.getSelectedChartId as any) // pageSelectors 的类型问题 50 | if (chartId) { 51 | // 判断是否对应,不对应的话,清理图表选中 52 | const chart = chartDomain.entities.chart.get(chartId) 53 | if (chart.datasetId !== payload) { 54 | yield emit({ name: kSelectedDatasetIdChangeEmitName, payload }) 55 | } 56 | } 57 | } 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/types/createApp.ts: -------------------------------------------------------------------------------- 1 | import { Store as ReduxStore, Dispatch, Reducer, Middleware } from 'redux' 2 | import { DefaultReducer } from './common' 3 | 4 | export interface Store extends ReduxStore { 5 | dispatch: Dispatch 6 | replaceReducer: (nextReducer: Reducer) => void 7 | } 8 | 9 | export interface Presenter { 10 | actions?: { [key: string]: Function } 11 | injectModules?: string[] 12 | selectors?: { [key: string]: Function } 13 | } 14 | 15 | export interface Module { 16 | effectLoading?: boolean 17 | actions?: { [key: string]: Function } 18 | entities?: { 19 | [key: string]: Module 20 | } 21 | effects?: Function[] 22 | watchers?: Function[] 23 | event?: (evt: string) => string 24 | injectModules: string[] 25 | namespace: string 26 | parent?: Record 27 | presenter?: { 28 | loaded: boolean 29 | selectors: { [key: string]: Function } 30 | actions: { [key: string]: Function } 31 | } 32 | reset: Function 33 | selectors?: { [key: string]: Function } 34 | services?: { [key: string]: Function } 35 | setup: Function 36 | type: symbol 37 | _store: Store 38 | } 39 | 40 | export interface Modules { 41 | [key: string]: Module 42 | } 43 | 44 | export interface App { 45 | _store?: Store 46 | _modules?: { 47 | [key: string]: Module 48 | } 49 | _init: Function 50 | _run: Function 51 | _middleWares?: Middleware<{}>[] 52 | _router?: Record 53 | addModule: Function 54 | addPage: Function 55 | addDomain: Function 56 | addGlobal: Function 57 | addRouter: Function 58 | addMiddleWare: Function 59 | removeModule: Function 60 | start: Function 61 | getStore: Function 62 | } 63 | 64 | export interface CreateOpt { 65 | initialReducer?: { 66 | [key: string]: Function 67 | } 68 | onError?: Function 69 | } 70 | 71 | export interface PageOption { 72 | containers?: Module[] 73 | } 74 | 75 | export interface ReducerHandler { 76 | [propName: string]: Function 77 | } 78 | 79 | export interface ParentNode { 80 | _initialState: object 81 | _reducers: { 82 | [index: string]: Function 83 | } 84 | _defaultReducer?: DefaultReducer 85 | } 86 | -------------------------------------------------------------------------------- /examples/simple-BI/src/page/workbook/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useCallback } from 'react' 2 | import { useSelector, useDispatch } from 'redux-with-domain' 3 | import { DndProvider } from 'react-dnd' 4 | import Backend from 'react-dnd-html5-backend' 5 | import _ from 'lodash' 6 | import PageModule from '../index' 7 | import './index.css' 8 | 9 | import Attribute from '../../../container/workbook/chartAttribute/ui' 10 | import DatasetFields from '../../../container/workbook/datasetField/ui' 11 | import DatasetList from '../../../container/workbook/datasetList/ui' 12 | import ReportCanvas from '../../../container/common/reportCanvas/ui' 13 | import ReportList from '../../../container/workbook/reportList/ui' 14 | 15 | interface Props {} 16 | 17 | const Workbook: FC = props => { 18 | const dispatch = useDispatch() 19 | const id: string = _.get(props, 'match.params.id') 20 | // 触发初使化 21 | useEffect(() => { 22 | dispatch(PageModule.actions.init(id)) 23 | }, [id]) 24 | 25 | // 获取工作簿名称 26 | const name = useSelector((state: object) => { 27 | return PageModule.selectors.getWorkbookName(state) 28 | }) 29 | 30 | // 当前选中的报表 31 | const reportId = useSelector(state => { 32 | return PageModule.selectors.getSelectedReportId(state) 33 | }) 34 | 35 | // 打开预览 36 | const onPreview = useCallback(() => { 37 | window.open(`${window.location.origin}/report/${reportId}`) 38 | }, [reportId]) 39 | 40 | return ( 41 | 42 | 43 | 44 | {`KOP Demo:${name}`} 45 | 预览 46 | 47 | 48 | 49 | 数据 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ) 68 | } 69 | 70 | export default Workbook 71 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint-config-airbnb", 5 | "prettier", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier/@typescript-eslint", 8 | "prettier/react", 9 | "plugin:react/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "plugins": ["react", "@typescript-eslint", "prettier"], 13 | "parserOptions": { 14 | "ecmaVersion": 2018, 15 | "sourceType": "module", 16 | "ecmaFeatures": { 17 | "jsx": true 18 | } 19 | }, 20 | "env": { 21 | "jest": true 22 | }, 23 | "globals": { 24 | "window": "writable", 25 | "document": "writable" 26 | }, 27 | "rules": { 28 | "no-param-reassign": [ 29 | "error", 30 | { 31 | "props": false 32 | } 33 | ], 34 | "no-underscore-dangle": 0, 35 | "quote-props": [2, "consistent-as-needed"], 36 | "no-unused-vars": 1, 37 | "arrow-body-style": [1, "as-needed"], 38 | "arrow-parens": [1, "as-needed"], 39 | "class-methods-use-this": 1, 40 | "no-console": [ 41 | 1, 42 | { 43 | "allow": ["error"] 44 | } 45 | ], 46 | "func-names": [2, "never"], 47 | "no-plusplus": 0, 48 | "no-useless-constructor": 1, 49 | "max-len": [ 50 | 2, 51 | 120, 52 | 2, 53 | { 54 | "ignoreUrls": true, 55 | "ignoreComments": false, 56 | "ignoreRegExpLiterals": true, 57 | "ignoreStrings": true, 58 | "ignoreTemplateLiterals": true 59 | } 60 | ], 61 | "react/jsx-no-bind": 1, 62 | "react/no-unused-prop-types": [ 63 | 1, 64 | { 65 | "skipShapeProps": true 66 | } 67 | ], 68 | "react/sort-comp": 0, 69 | "react/jsx-filename-extension": 0, 70 | "react/prefer-stateless-function": 0, 71 | "jsx-a11y/no-static-element-interactions": 0, 72 | "import/no-extraneous-dependencies": 0, 73 | "import/no-unresolved": 0, 74 | "import/extensions": 0, 75 | "new-cap": [ 76 | 2, 77 | { 78 | "capIsNewExceptions": ["DragSource", "DropTarget", "DragDropContext"] 79 | } 80 | ], 81 | "generator-star-spacing": 0, 82 | "no-use-before-define": [0], 83 | "space-before-function-paren": [0], 84 | "@typescript-eslint/camelcase": 1, 85 | "@typescript-eslint/no-use-before-define": 1, 86 | "@typescript-eslint/interface-name-prefix": 0, 87 | "@typescript-eslint/explicit-function-return-type": 0, 88 | "prefer-destructuring": 0, 89 | "import/prefer-default-export": 0, 90 | "@typescript-eslint/no-explicit-any": 0 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/types/domainModule.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EffectToAction, 3 | KopSelectors, 4 | Func, 5 | BaseEffects, 6 | BaseReducer, 7 | BaseModule, 8 | BaseModuleSelectors, 9 | SagaEffects, 10 | SagaEnhancer 11 | } from './common' 12 | 13 | type KopActions = { [K in keyof Effects]: EffectToAction } 14 | 15 | export interface KopDomainModule { 16 | entities: DomainEntities 17 | selectors: KopSelectors 18 | services: KopActions 19 | } 20 | 21 | /** turn original entites type to entites with crud method */ 22 | export type DomainEntities = { 23 | [key in keyof Entities]: { 24 | get: ( 25 | id: T 26 | ) => T extends string 27 | ? Entities[key] 28 | : T extends string[] 29 | ? Entities[key][] 30 | : never 31 | select: (predicate?: object) => Entities[key][] 32 | insert: (data: Entities[key]) => {} 33 | delete: (id: string | string[]) => {} 34 | update: (data: Entities[key]) => {} 35 | clear: () => {} 36 | } 37 | } 38 | 39 | export interface DomainSelectorsOf { 40 | entities: DomainEntities 41 | payload: any 42 | } 43 | 44 | export interface DomainSelectors { 45 | [key: string]: ({ entities, payload }: DomainSelectorsOf) => any 46 | } 47 | 48 | export type EntitiesIdMap = { 49 | [key in keyof Entities]: string 50 | } 51 | 52 | export type EffectsToActions = { 53 | [key in keyof S]: S[key] extends ({ 54 | payload 55 | }: { 56 | payload: infer P 57 | }) => Generator 58 | ? (arg: P) => {} 59 | : S[key] 60 | } 61 | 62 | export type DomainService = { 63 | (param: { 64 | services: EffectsToActions 65 | selectors: Selectors 66 | entities: DomainEntities 67 | sagaEffects: SagaEffects 68 | enhancer: SagaEnhancer 69 | }): Effects 70 | } 71 | 72 | export type DomainServices = BaseEffects 73 | 74 | export interface DomainModuleOption { 75 | entities: EntitiesIdMap 76 | selectors?: Selectors 77 | services?: DomainService 78 | } 79 | 80 | export interface DomainModuleSelectors extends BaseModuleSelectors { 81 | [index: string]: (...param: any) => any 82 | } 83 | 84 | /** internal domain module instance type */ 85 | export interface DomainModule extends BaseModule { 86 | entities?: { 87 | [key: string]: any 88 | } 89 | selectors?: DomainModuleSelectors 90 | _reducers?: BaseReducer 91 | services?: object 92 | effects?: Function[] 93 | } 94 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/workbook/chartAttribute/index.ts: -------------------------------------------------------------------------------- 1 | import { createContainerModule } from 'redux-with-domain' 2 | import _ from 'lodash' 3 | import chartDomain, { Attr } from '../../../domain/chart' 4 | import datasetDomain, { DatasetItem, Field } from '../../../domain/dataset' 5 | 6 | export interface Attrbute { 7 | max?: number 8 | group: string 9 | fields: Field[] 10 | } 11 | 12 | /** 13 | * {page-module} 14 | */ 15 | export default createContainerModule('workbook/attributes', { 16 | initialState: {}, 17 | selectors: { 18 | getChartId: (state, { pageSelectors } = {}): string => { 19 | return pageSelectors.getSelectedChartId() 20 | }, 21 | getAttribute: (state, { pageSelectors } = {}): Attrbute[] => { 22 | const chartId: string = pageSelectors.getSelectedChartId() 23 | const chart = chartDomain.entities.chart.get(chartId) 24 | let attributes: Attr[] | undefined = _.get(chart, 'attribute') 25 | if (attributes && attributes.length > 0) { 26 | // 找到对应的数据集 27 | const dataset: DatasetItem = datasetDomain.entities.datasetList.get( 28 | chart.datasetId 29 | ) 30 | return attributes.map(item => { 31 | const chartFields = item.fields as string[] 32 | const fields = !dataset 33 | ? [] 34 | : dataset.fields.filter(field => chartFields.includes(field.id)) 35 | return { 36 | ...item, 37 | fields 38 | } 39 | }) 40 | } 41 | return [] 42 | } 43 | }, 44 | reducers: {}, 45 | effects: ({ 46 | actions, 47 | selectors, 48 | sagaEffects: { put, select, call }, 49 | enhancer: { emit, syncPut } 50 | }) => ({ 51 | // eslint-disable-next-line require-yield 52 | *addField({ 53 | payload 54 | }: { 55 | payload: { 56 | group: string 57 | fieldId: string 58 | index: number 59 | chartId: string 60 | } 61 | }) { 62 | const { chartId, index, fieldId } = payload 63 | console.log(payload) 64 | const chart = chartDomain.entities.chart.get(chartId) 65 | let newAttr: Attr[] = chart.attribute 66 | newAttr = newAttr.map((item, idx) => { 67 | if (index === idx) { 68 | let arr = _.uniq([...item.fields, fieldId]) 69 | if (item.max && arr.length > item.max) { 70 | arr = arr.slice(arr.length - 1 - item.max + 1, arr.length - 1 + 1) 71 | } 72 | console.log(arr) 73 | return { 74 | ...item, 75 | fields: arr 76 | } 77 | } 78 | return item 79 | }) 80 | 81 | chartDomain.entities.chart.update({ 82 | ...chart, 83 | attribute: newAttr 84 | }) 85 | console.log({ 86 | ...chart, 87 | attribute: newAttr 88 | }) 89 | } 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /__tests__/utils_spec.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import * as saga from 'redux-saga' 3 | import { 4 | toStorePath, 5 | decorateSagaEffects, 6 | hasDuplicatedKeys, 7 | getStateByNamespace 8 | } from '../src/utils' 9 | import { ACTION_KIND } from '../src/const' 10 | 11 | describe('utils', () => { 12 | test('toStorePath', () => { 13 | expect(toStorePath('a')).toBe('a') 14 | expect(toStorePath('a/b/c')).toBe('a.b.c') 15 | }) 16 | 17 | describe('getStateByNamespace', () => { 18 | test('', () => { 19 | const state = { 20 | module1: { 21 | base: { 22 | count: 2 23 | } 24 | } 25 | } 26 | 27 | let errorMsg = '' 28 | try { 29 | getStateByNamespace(state, 'module2', {}) 30 | } catch (e) { 31 | errorMsg = e.message 32 | } 33 | expect( 34 | _.startsWith(errorMsg, 'Please check if you forget to add module') 35 | ).toBe(true) 36 | expect(getStateByNamespace(state, 'module1', {})).toEqual({ count: 2 }) 37 | expect(getStateByNamespace(state, 'module1', { count: 1 })).toEqual({ 38 | count: 2 39 | }) 40 | 41 | const state2 = { 42 | module1: { 43 | count: 2 44 | } 45 | } 46 | expect(getStateByNamespace(state2, 'module1', { count: 1 })).toEqual({ 47 | count: 2 48 | }) 49 | }) 50 | }) 51 | 52 | describe('decorateSagaEffects', () => { 53 | test('put', () => { 54 | const referrer = 'a' 55 | const wrappedEffects = decorateSagaEffects(saga.effects, referrer) 56 | const action = { type: 'update' } 57 | expect(wrappedEffects.put(action)).toEqual( 58 | saga.effects.put({ ...action, referrer }) 59 | ) 60 | }) 61 | 62 | test('call.normal', () => { 63 | const wrappedEffects = decorateSagaEffects(saga.effects) 64 | const fn = () => { 65 | // do nothing 66 | } 67 | expect(wrappedEffects.call(fn)).toEqual(saga.effects.call(fn)) 68 | }) 69 | 70 | test('call.reducer', () => { 71 | const wrappedEffects = decorateSagaEffects(saga.effects) 72 | const fn = () => ({ type: 'update' }) 73 | fn.__actionKind = ACTION_KIND.REDUCER 74 | expect(wrappedEffects.call(fn)).toEqual(saga.effects.put(fn())) 75 | }) 76 | 77 | test('call.effect', () => { 78 | const wrappedEffects = decorateSagaEffects(saga.effects) 79 | const fn = () => { 80 | // do nothing 81 | } 82 | fn.__actionKind = ACTION_KIND.EFFECT 83 | fn.__kopFunction = () => { 84 | // do nothing 85 | } 86 | expect(wrappedEffects.call(fn)).toEqual( 87 | saga.effects.call(fn.__kopFunction, { payload: undefined }) 88 | ) 89 | }) 90 | }) 91 | 92 | test('hasDuplicatedKeys', () => { 93 | expect(hasDuplicatedKeys({ a: 1 }, { b: 2 }, 'a')).toBe(true) 94 | expect(hasDuplicatedKeys({ a: 1 }, { a: 2, b: 3 }, 'c')).toBe(true) 95 | expect(hasDuplicatedKeys({ a: 1, b: 2 }, { b: 3, c: 4 }, 'd')).toBe(true) 96 | expect(hasDuplicatedKeys({ a: 1 }, { b: 2, c: 3 }, 'd')).toBe(false) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/module/domainModule.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { DOMAIN_MODULE } from '../const' 3 | import createDomainModel from '../helpers/createDomainModel' 4 | import { getModuleState, initModule, getGlobalState } from './util/util' 5 | import { createDomainSelectors } from './options/selector' 6 | import { createDomainEffects } from './options/effects' 7 | import { 8 | DomainSelectors, 9 | DomainModule, 10 | DomainModuleOption, 11 | KopDomainModule 12 | } from '../types/domainModule' 13 | 14 | /** 15 | * 创建container module 16 | */ 17 | export default function createDomainModule< 18 | Entities, 19 | Selectors extends DomainSelectors = DomainSelectors, 20 | Effects = {} 21 | >( 22 | namespace: string, 23 | pkg: DomainModuleOption 24 | ): KopDomainModule { 25 | const module = initModule(pkg, namespace, DOMAIN_MODULE) as any 26 | 27 | // 初始化 entities 为 DomainModel 28 | initEntity(module, pkg, namespace) 29 | 30 | // 向 selectors 中注入实际生成的 entities 实例 31 | initDomainSelectors(module, pkg) 32 | 33 | // 注入运行时的实际参数 34 | initDomainActions( 35 | module, 36 | pkg as any, 37 | namespace, 38 | DOMAIN_MODULE 39 | ) 40 | 41 | return module 42 | } 43 | 44 | /* 45 | * 初始化 domain actions 46 | * todo:修改了入参 47 | */ 48 | export function initDomainActions( 49 | module: DomainModule, 50 | pkg: DomainModuleOption, 51 | namespace: string, 52 | moduleType: symbol 53 | ) { 54 | const { services } = pkg 55 | 56 | module._reducers = _.noop 57 | 58 | module.services = {} 59 | 60 | if (_.isFunction(services)) { 61 | const { effects, actions } = createDomainEffects( 62 | namespace, 63 | module.selectors, 64 | services, 65 | module.entities, 66 | moduleType, 67 | module 68 | ) 69 | 70 | module.effects = effects 71 | _.extend(module.services, actions) 72 | } else { 73 | // module.services = services 74 | _.extend(module.services, services) 75 | } 76 | } 77 | 78 | /* 79 | * 初始化container selectors 80 | */ 81 | function initDomainSelectors( 82 | module: DomainModule, 83 | pkg: DomainModuleOption 84 | ) { 85 | const { selectors = {} } = pkg 86 | 87 | module.selectors = createDomainSelectors( 88 | { ...selectors, getModuleState, getGlobalState }, 89 | module.entities 90 | ) 91 | } 92 | 93 | /** 94 | * 注入领域模型的实体部分 95 | * @param module 模块 96 | * @param pkg 模块的配置 97 | * @param namespace 模块的命名空间 98 | */ 99 | function initEntity( 100 | module: DomainModule, 101 | pkg: DomainModuleOption, 102 | namespace: string 103 | ) { 104 | const entities: { [index: string]: any } = {} 105 | const { entities: entityOps } = pkg 106 | 107 | _.forEach(entityOps, (e, key) => { 108 | entities[key] = createDomainModel(`${namespace}/@@entity-${key}`, e) 109 | }) 110 | 111 | module.entities = entities 112 | } 113 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { effects } from 'redux-saga' 2 | import { get, some, replace, isString, keys, has, isEmpty } from 'lodash' 3 | import { MODULE, PRESENTER, ACTION_KIND } from './const' 4 | 5 | export function isModule(value) { 6 | return value && value[MODULE] === MODULE 7 | } 8 | 9 | export function isPresenter(value) { 10 | return value && value[PRESENTER] === PRESENTER 11 | } 12 | 13 | export function toStorePath(path) { 14 | return replace(path, /\//g, '.') 15 | } 16 | 17 | // get state by path 18 | // parent module's data aside in .base 19 | export function getStateByNamespace(state, namespace, initialState) { 20 | const path = toStorePath(namespace) 21 | const initialStateKeys = keys(initialState) 22 | const findState = get(state, path) 23 | if (findState === undefined) { 24 | throw Error(`Please check if you forget to add module ${path} `) 25 | } 26 | 27 | if (isEmpty(initialState)) { 28 | if (findState['@@loading']) { 29 | return findState 30 | } 31 | return get(state, `${path}.base`) // not in base 32 | } 33 | let isModuleState = true 34 | initialStateKeys.forEach(key => { 35 | if (!has(findState, key)) { 36 | isModuleState = false 37 | } 38 | }) 39 | if (isModuleState) return findState 40 | return get(state, `${path}.base`) 41 | } 42 | 43 | // decorate put and call for tracing 44 | export function decorateSagaEffects(sagaEffects, referrer = '') { 45 | function put(action) { 46 | return sagaEffects.put({ ...action, referrer }) 47 | } 48 | 49 | function call(fn, ...params) { 50 | if (fn.__actionKind) { 51 | if (fn.__actionKind === ACTION_KIND.EFFECT) { 52 | return sagaEffects.call(fn.__kopFunction, { 53 | payload: params.length > 1 ? params : params[0] 54 | }) 55 | } 56 | return sagaEffects.put(fn(params.length > 1 ? params : params[0])) 57 | } 58 | return sagaEffects.call(fn, ...params) 59 | } 60 | 61 | return { ...sagaEffects, put, call } 62 | } 63 | 64 | export function generateSagaEnhancer( 65 | events = {}, 66 | namespace: string, 67 | authCheck?: Function 68 | ) { 69 | function syncPut(action) { 70 | if (authCheck) authCheck(action, namespace) 71 | if (action.__actionKind === ACTION_KIND.EFFECT) { 72 | const res = function*() { 73 | if (action.__changeLoading) { 74 | yield effects.put( 75 | action.__changeLoading({ 76 | key: action.__type, 77 | value: true 78 | }) 79 | ) 80 | } 81 | const result = yield effects.call(action.__kopFunction, { 82 | payload: action.payload 83 | }) 84 | if (action.__changeLoading) { 85 | yield effects.put( 86 | action.__changeLoading({ 87 | key: action.__type, 88 | value: false 89 | }) 90 | ) 91 | } 92 | return result 93 | } 94 | return res() 95 | } 96 | return effects.put(action) 97 | } 98 | return { 99 | syncPut, 100 | ...events 101 | } 102 | } 103 | 104 | export function hasDuplicatedKeys(obj, ...others) { 105 | return some(obj, (val, key) => 106 | some(others, compareItem => { 107 | if (isString(compareItem)) { 108 | return compareItem === key 109 | } 110 | return key in compareItem 111 | }) 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /src/module/pageModule.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, extend } from 'lodash' 2 | import { 3 | initInitialState, 4 | initModule, 5 | initActions, 6 | initSelectors 7 | } from './util/util' 8 | import { createPageWatchers } from './options/watchers' 9 | import { getAuthCheck } from './options/effects' 10 | import { PAGE_MODULE } from '../const' 11 | import { 12 | SelectorsOf, 13 | ReducerOf, 14 | ActionsOf, 15 | DefaultReducer 16 | } from '../types/common' 17 | import { PageEffectsOf, PageModule, KopPageModule } from '../types/pageModule' 18 | import { KopContainerModule } from '../types/containerModule' 19 | 20 | interface PageOptions< 21 | State, 22 | Selectors, 23 | Reducers, 24 | Effects, 25 | Watchers, 26 | ActionCreators 27 | > { 28 | initialState?: State 29 | selectors?: Selectors 30 | reducers?: Reducers 31 | defaultReducer?: DefaultReducer 32 | injectModules?: KopContainerModule<{}, {}, {}, {}>[] 33 | effects?: PageEffectsOf 34 | watchers?: PageEffectsOf 35 | effectLoading?: boolean 36 | actionCreators?: ActionsOf 37 | } 38 | 39 | function initInjectModules< 40 | State, 41 | Selectors, 42 | Reducers, 43 | Effects, 44 | Watchers, 45 | ActionCreators 46 | >( 47 | module: PageModule, 48 | pkg: PageOptions< 49 | State, 50 | Selectors, 51 | Reducers, 52 | Effects, 53 | Watchers, 54 | ActionCreators 55 | > 56 | ) { 57 | const { injectModules = [] } = pkg 58 | 59 | module.injectModules = injectModules.map(m => m.namespace) 60 | } 61 | 62 | function initPageWatchers< 63 | State, 64 | Selectors, 65 | Reducers, 66 | Effects, 67 | Watchers, 68 | ActionCreators 69 | >( 70 | module: PageModule, 71 | pkg: PageOptions< 72 | State, 73 | Selectors, 74 | Reducers, 75 | Effects, 76 | Watchers, 77 | ActionCreators 78 | > 79 | ) { 80 | const { namespace } = module 81 | 82 | if (isFunction(pkg.watchers)) { 83 | module.watchers = createPageWatchers( 84 | module.actions, 85 | module.selectors, 86 | pkg.watchers, 87 | getAuthCheck[PAGE_MODULE], 88 | namespace 89 | ) 90 | } 91 | } 92 | 93 | export default function createPageModule< 94 | State, 95 | Selectors extends SelectorsOf = SelectorsOf, 96 | Reducers extends ReducerOf = ReducerOf, 97 | Effects = {}, 98 | Watchers = {}, 99 | ActionCreators = {} 100 | >( 101 | namespace: string, 102 | pkg: PageOptions< 103 | State, 104 | Selectors, 105 | Reducers, 106 | Effects, 107 | Watchers, 108 | ActionCreators 109 | > 110 | ): KopPageModule { 111 | const module = initModule(pkg, namespace, PAGE_MODULE) as any 112 | 113 | initInitialState(module, pkg) 114 | 115 | initSelectors(module, pkg, namespace, PAGE_MODULE) 116 | 117 | initInjectModules(module, pkg) 118 | 119 | module.actions = {} 120 | 121 | const { actions, effects, _reducers, event, actionCreators } = initActions( 122 | module, 123 | pkg, 124 | namespace, 125 | PAGE_MODULE, 126 | getAuthCheck[PAGE_MODULE] 127 | ) 128 | 129 | extend(module.actions, actionCreators, actions) 130 | 131 | if (effects) { 132 | module.effects = effects 133 | } 134 | 135 | module._reducers = _reducers 136 | module._defaultReducer = pkg.defaultReducer 137 | module.event = event 138 | 139 | initPageWatchers(module, pkg) 140 | 141 | return module 142 | } 143 | -------------------------------------------------------------------------------- /src/module/options/action.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * create action 3 | */ 4 | import { forEach } from 'lodash' 5 | import invariant from 'invariant' 6 | import { Func } from 'types/common' 7 | import { ACTION_KIND, KOP_GLOBAL_STORE_REF } from '../../const' 8 | 9 | interface ActionOption { 10 | namespace: string 11 | actionType: string 12 | actionKind: symbol 13 | kopFunction?: Function 14 | changeLoading?: Function 15 | moduleType?: symbol 16 | } 17 | 18 | export function getActionTypeWithNamespace( 19 | namespace: string, 20 | type: string 21 | ): string { 22 | return `${namespace}/${type}` 23 | } 24 | 25 | export function createActionCreators({ 26 | namespace, 27 | module, 28 | actionCreators, 29 | moduleType, 30 | checkAuth 31 | }) { 32 | const actions = {} 33 | 34 | const createActionWarpper = (type: string) => { 35 | return createAction({ 36 | namespace, 37 | actionType: type, 38 | actionKind: ACTION_KIND.CREATOR, 39 | moduleType 40 | }) 41 | } 42 | 43 | let dispatch 44 | 45 | // wrap dispatch for layered call auth check 46 | if (process.env.NODE_ENV === 'development') { 47 | dispatch = (action: any) => { 48 | checkAuth(action, namespace) 49 | return window[KOP_GLOBAL_STORE_REF].dispatch(action) 50 | } 51 | } else { 52 | dispatch = window[KOP_GLOBAL_STORE_REF].dispatch 53 | } 54 | 55 | const moduleActionCreators = actionCreators({ 56 | actions: module.actions, 57 | selectors: module.selectors, 58 | createAction: createActionWarpper, // helper for create plain action creator 59 | dispatch 60 | }) 61 | 62 | forEach({ ...moduleActionCreators }, (creator, type) => { 63 | invariant( 64 | !module.actions[type], 65 | `Module ${namespace} action ${type} duplicated` 66 | ) 67 | 68 | // wrap actionCreator to add meta data for the returned action 69 | if (process.env.NODE_ENV === 'development') { 70 | actions[type] = actionCreatorWithMetaTag( 71 | creator, 72 | namespace, 73 | moduleType, 74 | type 75 | ) 76 | } else { 77 | actions[type] = creator // directly pass in user defined actionCreator on module.actions 78 | } 79 | }) 80 | 81 | return { 82 | actionCreators: actions 83 | } 84 | } 85 | 86 | const actionCreatorWithMetaTag = ( 87 | creator: Func, 88 | namespace: string, 89 | moduleType: symbol, 90 | type: string 91 | ) => { 92 | return (...args) => { 93 | const action = creator(...args) 94 | action.__namespace = namespace 95 | action.__moduleType = moduleType 96 | action.toString = () => getActionTypeWithNamespace(namespace, type) 97 | return action 98 | } 99 | } 100 | 101 | export default function createAction({ 102 | namespace, 103 | actionType, 104 | actionKind, 105 | kopFunction, 106 | changeLoading, 107 | moduleType 108 | }: ActionOption) { 109 | const fixedType = getActionTypeWithNamespace(namespace, actionType) 110 | 111 | const action = ( 112 | payload: object, 113 | meta: object, 114 | referrer: any, 115 | error: any 116 | ) => ({ 117 | payload, 118 | meta, 119 | referrer, 120 | error, 121 | type: fixedType, 122 | __namespace: namespace, // meta tag for auth check 123 | __actionKind: actionKind, 124 | __type: actionType, // for syncPut auto loading 125 | __kopFunction: kopFunction, // for syncPut 126 | __changeLoading: changeLoading, // for syncPut auto loading 127 | __moduleType: moduleType // meta tag for auth check 128 | }) 129 | 130 | action.type = fixedType 131 | action.toString = (): string => fixedType // override toString to get namespace and type 132 | action.__actionKind = actionKind 133 | action.__kopFunction = kopFunction 134 | 135 | return action 136 | } 137 | -------------------------------------------------------------------------------- /__tests__/module_global_spec.ts: -------------------------------------------------------------------------------- 1 | import createApp, { 2 | createContainerModule, 3 | createPageModule, 4 | createGlobalModule 5 | } from '../src' 6 | 7 | describe('global_module_test', () => { 8 | test('selectors & reducers & effects & actionCreators', () => { 9 | let authError = null 10 | 11 | const containerOpt = { 12 | initialState: {}, 13 | effects: () => ({ 14 | *query() { 15 | // nothing to do 16 | } 17 | }) 18 | } 19 | const containerModule = createContainerModule('container1', containerOpt) 20 | 21 | const globalNamespace = 'global1' 22 | const globalOpt = { 23 | effectLoading: true, 24 | initialState: { 25 | count: 0 26 | }, 27 | selectors: { 28 | getCount: state => state.count 29 | }, 30 | reducers: { 31 | setCount: (state, { payload }: { payload: number }) => ({ 32 | ...state, 33 | count: payload 34 | }), 35 | add: (state, { payload }: { payload: number }) => ({ 36 | ...state, 37 | count: state.count + payload 38 | }) 39 | }, 40 | effects: ({ actions, sagaEffects: { put } }) => ({ 41 | *fetchCount({ payload }: { payload: number }) { 42 | yield put(actions.setCount(payload)) 43 | }, 44 | *query() { 45 | try { 46 | // global module can only dispatch its own actions, so it should throw error 47 | yield put(containerModule.actions.query()) 48 | } catch (e) { 49 | authError = e 50 | } 51 | } 52 | }), 53 | actionCreators: ({ createAction }) => ({ 54 | incrementCount: createAction('increment'), 55 | decrementCount: (payload: number) => ({ 56 | type: `${globalNamespace}/decrement`, 57 | payload 58 | }) 59 | }) 60 | } 61 | const globalModule = createGlobalModule(globalNamespace, globalOpt) 62 | 63 | const pageOpt = { 64 | initialState: { 65 | name: '' 66 | }, 67 | selectors: { 68 | getName: state => state.name 69 | }, 70 | reducers: { 71 | setName: (state, { payload }: { payload: string }) => ({ 72 | ...state, 73 | name: payload 74 | }) 75 | }, 76 | effects: ({ sagaEffects: { put } }) => ({ 77 | *fetchCount({ payload }) { 78 | yield put(globalModule.actions.fetchCount(payload)) 79 | } 80 | }), 81 | watchers: ({ sagaEffects: { put } }) => ({ 82 | // watch `incrementCount` action creator 83 | *[`${globalNamespace}/increment`]({ payload }: { payload: number }) { 84 | yield put(globalModule.actions.add(payload)) 85 | }, 86 | // watch `decrementCount` action creator 87 | *[`${globalNamespace}/decrement`]({ payload }: { payload: number }) { 88 | yield put(globalModule.actions.add(-payload)) 89 | } 90 | }) 91 | } 92 | const pageModule = createPageModule('page1', pageOpt) 93 | 94 | const app = createApp() 95 | app.addPage(pageModule) 96 | app.addModule(containerModule) 97 | app.addGlobal(globalModule) 98 | app.start() 99 | expect(globalModule.selectors.getCount()).toBe(0) 100 | app._store.dispatch(pageModule.actions.fetchCount(1)) 101 | expect(globalModule.selectors.getCount()).toBe(1) 102 | 103 | expect(authError).toBeNull() 104 | app._store.dispatch(globalModule.actions.query()) 105 | expect(authError).not.toBeNull() 106 | 107 | // plus one 108 | app._store.dispatch(globalModule.actions.incrementCount(1)) 109 | expect(globalModule.selectors.getCount()).toBe(2) 110 | 111 | // minus one 112 | app._store.dispatch(globalModule.actions.decrementCount(1)) 113 | expect(globalModule.selectors.getCount()).toBe(1) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /examples/simple-BI/src/page/workbook/index.ts: -------------------------------------------------------------------------------- 1 | import { createPageModule } from 'redux-with-domain' 2 | import _ from 'lodash' 3 | import AttributesModule from '../../container/workbook/chartAttribute' 4 | import DatasetFieldsModule from '../../container/workbook/datasetField' 5 | import DatasetListModule, { 6 | kSelectedDatasetIdChangeEmitName 7 | } from '../../container/workbook/datasetList' 8 | import ReportCanvasModule, { 9 | kDatasetIdChangeEmitName 10 | } from '../../container/common/reportCanvas' 11 | import ReportListModule, { 12 | kSelectedReportIdChangeEmitName 13 | } from '../../container/workbook/reportList' 14 | import workbookDomain from '../../domain/workbook' 15 | import reportDomain, { ReportItem } from '../../domain/report' 16 | import loadWorkbook from '../../service/loadWorkbook' 17 | 18 | // 工作簿页面 module 19 | export default createPageModule('workbook', { 20 | initialState: { 21 | workbookId: '', 22 | selectedReportId: '' 23 | }, 24 | selectors: { 25 | getWorkbookName: state => { 26 | const workbook = workbookDomain.entities.workbookList.get( 27 | state.workbookId 28 | ) 29 | return workbook ? workbook.name : '' 30 | }, 31 | getSelectedDatasetId: state => { 32 | return DatasetListModule.selectors.getSelectedDatasetId(state) 33 | }, 34 | getSelectedReportId: state => { 35 | return state.selectedReportId 36 | }, 37 | getSelectedChartId: state => { 38 | return ReportCanvasModule.selectors.getSelectedChartId(state) 39 | } 40 | }, 41 | reducers: { 42 | saveWorkbookId: (state, { payload }: { payload: string }) => { 43 | return { 44 | ...state, 45 | workbookId: payload 46 | } 47 | }, 48 | saveSelectedReportId: (state, { payload }: { payload: string }) => { 49 | return { 50 | ...state, 51 | selectedReportId: payload 52 | } 53 | } 54 | }, 55 | injectModules: [ 56 | AttributesModule, 57 | DatasetFieldsModule, 58 | DatasetListModule, 59 | ReportCanvasModule, 60 | ReportListModule 61 | ], 62 | effects: ({ 63 | actions, 64 | selectors, 65 | sagaEffects: { call, put }, 66 | enhancer: { syncPut } 67 | }) => ({ 68 | *init({ payload: workbookId }: { payload: string }) { 69 | // 保存当前工作簿id 70 | yield syncPut(actions.saveWorkbookId(workbookId)) 71 | // 通过service完成数据初使化 72 | yield call(loadWorkbook, workbookId) 73 | // 初始化列表显示 74 | yield syncPut(ReportListModule.actions.init()) 75 | // 初始化数据集 76 | yield syncPut(DatasetListModule.actions.init()) 77 | }, 78 | *setSelectedReport({ payload: reportId }: { payload: string }) { 79 | // 保存当前选中报表id 80 | yield syncPut(actions.saveSelectedReportId(reportId)) 81 | // 清空选中的图表 82 | yield syncPut(ReportCanvasModule.actions.saveSelectedChartId('')) 83 | // 刷新图表视图 84 | yield put(ReportCanvasModule.actions.reloadChart(reportId)) 85 | } 86 | }), 87 | watchers: ({ actions, sagaEffects: { put }, enhancer: { syncPut } }) => { 88 | return { 89 | // 选中数据集id改变 90 | *[DatasetListModule.event(kSelectedDatasetIdChangeEmitName)]({}) { 91 | // 清空图表选中 92 | yield put(ReportCanvasModule.actions.saveSelectedChartId('')) 93 | }, 94 | // 图表使用的数据集改变 95 | *[ReportCanvasModule.event(kDatasetIdChangeEmitName)]({ 96 | payload 97 | }: { 98 | payload: string 99 | }) { 100 | // 选中数据集 101 | yield put(DatasetListModule.actions.saveSelectedDataset(payload)) 102 | }, 103 | // 选中报表id改变 104 | *[ReportListModule.event(kSelectedReportIdChangeEmitName)]({ 105 | payload 106 | }: { 107 | payload: string 108 | }) { 109 | // 设置报表id 110 | yield syncPut(actions.setSelectedReport(payload)) 111 | } 112 | } 113 | } 114 | }) 115 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/common/reportCanvas/index.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { createContainerModule, EffectsParams } from 'redux-with-domain' 3 | import chartDomain, { Attr, Chart } from '../../../domain/chart' 4 | import datasetDomain, { DatasetItem } from '../../../domain/dataset' 5 | import { Action } from 'redux' 6 | 7 | // 向页面抛出选中报表的数据集改变的事件名 8 | export const kDatasetIdChangeEmitName = 'setDatasetId' 9 | 10 | export interface State { 11 | selectedChartId: string 12 | loading: boolean 13 | } 14 | 15 | interface InjectActions { 16 | setLoading: (flag: boolean) => Action 17 | queryChart: (id: string) => Action 18 | saveSelectedChartId: (id: string) => Action 19 | } 20 | 21 | type Selector = typeof selectors 22 | type Effects = ReturnType 23 | 24 | const initialState = { 25 | selectedChartId: '', 26 | loading: false 27 | } 28 | 29 | const selectors = { 30 | getLoading: (state: State) => { 31 | return state.loading 32 | }, 33 | getCharts: (state: State, { payload: reportId }: { payload: string }) => { 34 | const chart = chartDomain.entities.chart.select({ reportId }) 35 | 36 | return chart.map((chart: Chart) => { 37 | let attributes: Attr[] | undefined = _.get(chart, 'attribute') 38 | 39 | if (attributes && attributes.length > 0) { 40 | attributes = attributes.map(item => { 41 | const fields = item.fields || [] 42 | const dataset: DatasetItem = datasetDomain.entities.datasetList.get( 43 | chart.datasetId 44 | ) 45 | const fieldsName = !dataset 46 | ? [] 47 | : dataset.fields 48 | .filter(field => fields.includes(field.id)) 49 | .map(field => field.name) 50 | 51 | return { 52 | ...item, 53 | fieldsName: fieldsName 54 | } 55 | }) 56 | } 57 | return { 58 | ...chart, 59 | attribute: attributes 60 | } 61 | }) 62 | }, 63 | getSelectedChartId: (state: State) => { 64 | return state.selectedChartId 65 | } 66 | } 67 | 68 | const reducers = { 69 | setLoading: (state: State, { payload }: { payload: boolean }) => { 70 | return { 71 | ...state, 72 | loading: payload 73 | } 74 | }, 75 | saveSelectedChartId: (state: State, { payload }: { payload: string }) => { 76 | return { 77 | ...state, 78 | selectedChartId: payload 79 | } 80 | } 81 | } 82 | 83 | const effects = ({ 84 | actions, 85 | selectors, 86 | sagaEffects: { call, put, select }, 87 | enhancer: { emit, syncPut } 88 | }: EffectsParams) => ({ 89 | *reloadChart({ payload: reportId }: { payload: string }) { 90 | // 转圈圈 91 | yield syncPut(actions.setLoading(true)) 92 | // 拉取图表信息 93 | const charts: Chart[] = 94 | chartDomain.entities.chart.select({ reportId }) || [] 95 | if (charts.length === 0) { 96 | yield syncPut(actions.queryChart(reportId)) 97 | } 98 | // 停止转圈圈 99 | yield syncPut(actions.setLoading(false)) 100 | }, 101 | *queryChart({ payload }: { payload: string }) { 102 | // 查询图表信息 103 | yield syncPut(chartDomain.services.fetchCharts(payload)) 104 | // 查询图表依赖的数据集 105 | const charts: Chart[] = chartDomain.entities.chart.select() 106 | const datasetIdList: string[] = [] 107 | charts.forEach(element => { 108 | const datasetId = element.datasetId 109 | if (!datasetIdList.includes(datasetId)) { 110 | datasetIdList.push(element.datasetId) 111 | } 112 | }) 113 | yield syncPut(datasetDomain.services.fetchDatasets(datasetIdList)) 114 | }, 115 | *setSelectedChart({ payload }: { payload: string }) { 116 | yield syncPut(actions.saveSelectedChartId(payload)) 117 | const chart = chartDomain.entities.chart.get(payload) 118 | const { datasetId } = chart 119 | // 向页面抛出图表依赖的数据集变更的事件 120 | yield emit({ name: kDatasetIdChangeEmitName, payload: datasetId }) 121 | } 122 | }) 123 | 124 | export default createContainerModule( 125 | 'workbook/reportCanvas', 126 | { 127 | initialState, 128 | selectors, 129 | effects, 130 | reducers 131 | } 132 | ) 133 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, 5 | "module": "ES2015" /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */, 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "lib": [ 10 | "es2018", 11 | "dom" 12 | ] /* Specify library files to be included in the compilation: */, 13 | "allowJs": false /* Allow javascript files to be compiled. */, 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 16 | "sourceMap": true /* Generates corresponding '.map' file. */, 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | "outDir": "dist" /* Redirect output structure to the directory. */, 19 | "rootDir": "src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | "importHelpers": true /* Import emit helpers from 'tslib'. */, 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, 29 | "strictNullChecks": true /* Enable strict null checks. */, 30 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 31 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 32 | 33 | /* Additional Checks */ 34 | "noUnusedLocals": false /* Report errors on unused locals. */, 35 | "noUnusedParameters": false /* Report errors on unused parameters. */, 36 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 37 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 38 | 39 | /* Module Resolution Options */ 40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | "baseUrl": "src" /* Base directory to resolve non-absolute module names. */, 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 47 | 48 | /* Source Map Options */ 49 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 50 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 51 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 52 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 53 | 54 | /* Experimental Options */ 55 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 56 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 57 | "skipLibCheck": true 58 | }, 59 | "exclude": [ 60 | "node_modules", 61 | "libs", 62 | "dist", 63 | "bin", 64 | "build", 65 | "doc", 66 | "examples", 67 | "__tests__", 68 | "lib", 69 | "coverage" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /__tests__/module_container_spec.ts: -------------------------------------------------------------------------------- 1 | import createApp, { createContainerModule, createPageModule } from '../src' 2 | 3 | describe('container_module_test', () => { 4 | const pageOpt = { 5 | initialState: {} 6 | } 7 | const containerOpt = { 8 | effectLoading: true, 9 | initialState: { 10 | count: 1 11 | }, 12 | selectors: { 13 | getCount: state => state.count 14 | }, 15 | reducers: { 16 | initCount(state, { payload }) { 17 | return { 18 | ...state, 19 | count: payload 20 | } 21 | } 22 | }, 23 | effects: ({ 24 | actions, 25 | sagaEffects: { put, call }, 26 | enhancer: { syncPut } 27 | }) => ({ 28 | *fetchCount({ payload }) { 29 | yield put(actions.initCount(payload)) 30 | }, 31 | *sleep({ payload }) { 32 | const sleepFn = (duration: number) => 33 | new Promise(resove => { 34 | setTimeout(() => { 35 | resove() 36 | }, duration) 37 | }) 38 | yield call(sleepFn, payload) 39 | }, 40 | *saveCount({ 41 | payload 42 | }: { 43 | payload: { callback: Function; count: number } 44 | }) { 45 | const { callback, count } = payload 46 | yield syncPut(actions.sleep(200)) 47 | // We can use `put` here, but use `syncPut` to unit test `syncPut` reducer actions. 48 | yield syncPut(actions.initCount(count)) 49 | callback() 50 | } 51 | }) 52 | } 53 | 54 | const pageModule = createPageModule('pageModule1', pageOpt) 55 | 56 | test('single module & syncPut', done => { 57 | const app = createApp() 58 | const containerModule = createContainerModule('container1', containerOpt) 59 | app.addPage(pageModule, { 60 | containers: [containerModule] 61 | }) 62 | app.start() 63 | 64 | expect(app._modules.container1).toBe(containerModule) 65 | const savedCount = 2 66 | const callback = () => { 67 | try { 68 | expect(containerModule.selectors.getCount()).toBe(savedCount) 69 | done() 70 | } catch (e) { 71 | done(e) 72 | } 73 | } 74 | app._store.dispatch( 75 | containerModule.actions.saveCount({ count: savedCount, callback }) 76 | ) 77 | }) 78 | 79 | test('multi modules', () => { 80 | const app = createApp() 81 | const containerModule1 = createContainerModule('container1', containerOpt) 82 | const containerModule2 = createContainerModule('container2', containerOpt) 83 | app.addPage(pageModule, { 84 | containers: [containerModule1, containerModule2] 85 | }) 86 | app.start() 87 | 88 | expect(app._modules.container1).toBe(containerModule1) 89 | expect(app._modules.container2).toBe(containerModule2) 90 | }) 91 | 92 | test('effect & reducer & selector', () => { 93 | const app = createApp() 94 | const containerModule = createContainerModule('container1', containerOpt) 95 | app.addPage(pageModule, { 96 | containers: [containerModule] 97 | }) 98 | app.start() 99 | 100 | const initCount = 102 101 | app._store.dispatch(containerModule.actions.fetchCount(initCount)) 102 | const data = containerModule.selectors.getCount(app._store.getState()) 103 | expect(data).toBe(initCount) 104 | }) 105 | 106 | test('__getLoadings', async () => { 107 | const app = createApp() 108 | const containerModule = createContainerModule('container1', containerOpt) 109 | app.addPage(pageModule, { 110 | containers: [containerModule] 111 | }) 112 | app.start() 113 | 114 | const duration = 1000 115 | app._store.dispatch(containerModule.actions.sleep(duration)) 116 | let loadings = containerModule.selectors.__getLoadings() 117 | expect(loadings.sleep).toBe(true) 118 | 119 | loadings = await new Promise(resolve => { 120 | setTimeout(() => { 121 | resolve(containerModule.selectors.__getLoadings()) 122 | }, duration * 2) 123 | }) 124 | 125 | expect(loadings.sleep).toBe(false) 126 | }) 127 | 128 | test('auth check', () => { 129 | let error = null 130 | const app = createApp() 131 | const containerModule1 = createContainerModule('container1', { 132 | effects: () => ({ 133 | *fetchCount() { 134 | // do nothing 135 | } 136 | }) 137 | }) 138 | const containerModule2 = createContainerModule('container2', { 139 | effects: ({ sagaEffects: { put } }) => ({ 140 | *fetchCount() { 141 | try { 142 | // here call actions of another container module , so it should throw error 143 | // in development environment. 144 | yield put(containerModule1.actions.fetchCount()) 145 | } catch (e) { 146 | error = e 147 | } 148 | } 149 | }) 150 | }) 151 | app.addPage(pageModule, { 152 | containers: [containerModule1, containerModule2] 153 | }) 154 | app.start() 155 | 156 | app._store.dispatch(containerModule2.actions.fetchCount()) 157 | expect(error).not.toBeNull() 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /src/module/options/selector.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import { mapValues, isArray } from 'lodash' 3 | import { Store } from 'redux' 4 | 5 | import { KOP_GLOBAL_SELECTOR_LOOP_CHECK } from '../../const' 6 | import { getStateByNamespace } from '../../utils' 7 | 8 | declare global { 9 | interface Window { 10 | KOP_GLOBAL_SELECTOR_LOOP_CHECK: any 11 | } 12 | } 13 | 14 | window[KOP_GLOBAL_SELECTOR_LOOP_CHECK] = 15 | window[KOP_GLOBAL_SELECTOR_LOOP_CHECK] || {} 16 | 17 | let currentState = null 18 | 19 | // check if selector has circular dependency 20 | const selectorLoopChecker = { 21 | start: (namespace: string, key: string) => { 22 | if (!window[KOP_GLOBAL_SELECTOR_LOOP_CHECK]) { 23 | // eslint-disable-next-line @typescript-eslint/camelcase 24 | window[KOP_GLOBAL_SELECTOR_LOOP_CHECK] = {} 25 | } 26 | if (window[KOP_GLOBAL_SELECTOR_LOOP_CHECK][namespace + key] === 1) { 27 | throw Error( 28 | `module ${namespace}'s selector ${key} has circular dependency` 29 | ) 30 | } else { 31 | window[KOP_GLOBAL_SELECTOR_LOOP_CHECK][namespace + key] = 1 32 | } 33 | }, 34 | end: (namespace: string, key: string) => { 35 | delete window[KOP_GLOBAL_SELECTOR_LOOP_CHECK][namespace + key] 36 | } 37 | } 38 | 39 | function _getStateValue( 40 | key: string, 41 | currState: object | null, 42 | namespace: string, 43 | initialState: object 44 | ) { 45 | let stateValue 46 | 47 | if (key === 'getGlobalState') { 48 | stateValue = currState 49 | invariant(stateValue, 'Please check the kop store') 50 | } else { 51 | stateValue = getStateByNamespace(currState, namespace, initialState) 52 | 53 | invariant( 54 | stateValue, 55 | `Please check the kop-module ${namespace} is added, or you have return wrong state in last reducer` 56 | ) 57 | } 58 | 59 | return stateValue 60 | } 61 | 62 | export default function initSelectorHelper(store: Store) { 63 | currentState = store.getState() 64 | 65 | store.subscribe(() => { 66 | currentState = store.getState() 67 | }) 68 | } 69 | 70 | export function createSelectors(namespace, selectors, presenter, initialState) { 71 | const globalizeSelector = (selector, key) => (...params) => { 72 | const stateValue = _getStateValue( 73 | key, 74 | currentState, 75 | namespace, 76 | initialState 77 | ) 78 | 79 | selectorLoopChecker.start(namespace, key) 80 | 81 | if (params[0] === currentState) { 82 | params.shift() 83 | } 84 | 85 | const res = presenter.loaded 86 | ? selector(stateValue, presenter.selectors, ...params) 87 | : selector(stateValue, null, ...params) 88 | 89 | selectorLoopChecker.end(namespace, key) 90 | 91 | return res 92 | } 93 | 94 | return mapValues(selectors, globalizeSelector) 95 | } 96 | 97 | function _transformSelectors(namespace, selectors, initialState) { 98 | const globalizeSelector = (selector, key) => (...params) => { 99 | if (isArray(params) && params.length > 1 && params[0] === currentState) { 100 | params.shift() 101 | } 102 | const stateValue = _getStateValue( 103 | key, 104 | currentState, 105 | namespace, 106 | initialState 107 | ) 108 | 109 | const res = selector(stateValue, { 110 | payload: params.length === 1 ? params[0] : params 111 | }) 112 | 113 | return res 114 | } 115 | 116 | return mapValues(selectors, globalizeSelector) 117 | } 118 | 119 | export const createPageSelectors = _transformSelectors 120 | export const createGlobalSelectors = _transformSelectors 121 | 122 | export function createDomainSelectors(selectors, entities) { 123 | const globalizeSelector = selector => (...params) => { 124 | if (isArray(params) && params.length > 1 && params[0] === currentState) { 125 | params.shift() 126 | } 127 | 128 | const res = selector({ 129 | entities, 130 | payload: params.length === 1 ? params[0] : params 131 | }) 132 | 133 | return res 134 | } 135 | 136 | return mapValues(selectors, globalizeSelector) 137 | } 138 | 139 | export function createContainerSelectors( 140 | namespace: string, 141 | selectors: object, 142 | presenter: { loaded: boolean; selectors: object }, 143 | initialState: object 144 | ) { 145 | const globalizeSelector = (selector: Function, key: string) => ( 146 | ...params 147 | ) => { 148 | if (isArray(params) && params.length > 1 && params[0] === currentState) { 149 | params.shift() 150 | } 151 | 152 | const stateValue = _getStateValue( 153 | key, 154 | currentState, 155 | namespace, 156 | initialState 157 | ) 158 | 159 | let res 160 | 161 | if (presenter.loaded) { 162 | res = selector(stateValue, { 163 | pageSelectors: presenter.selectors, 164 | payload: params.length === 1 ? params[0] : params 165 | }) 166 | } else { 167 | res = selector(stateValue, { 168 | payload: params.length === 1 ? params[0] : params 169 | }) 170 | } 171 | 172 | return res 173 | } 174 | 175 | return mapValues(selectors, globalizeSelector) 176 | } 177 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alipay/kop", 3 | "version": "1.0.0", 4 | "description": "kop", 5 | "main": "kop.common.js", 6 | "module": "dist/kop.esm.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist", 10 | "README.md", 11 | "HISTORY.md" 12 | ], 13 | "keywords": [ 14 | "kop" 15 | ], 16 | "authors": [ 17 | "mark.chenfl@alibaba-inc.com", 18 | "jiawei.njw@antfin.com", 19 | "xinhui.zxh@antgroup.com", 20 | "changzhe.zb@antgroup.com" 21 | ], 22 | "scripts": { 23 | "type-check": "tsc --noEmit", 24 | "build:types": "tsc --emitDeclarationOnly", 25 | "build": "rimraf ./dist/ & npm run build:prod & npm run build:types", 26 | "build:dev": "cross-env NODE_ENV=development rollup -c", 27 | "build:prod": "cross-env NODE_ENV=production rollup -c", 28 | "dev": "npm run watch:rollup & npm run watch:ts", 29 | "watch:rollup": "cross-env NODE_ENV=development rollup -c --watch", 30 | "watch:ts": "tsc --emitDeclarationOnly --watch", 31 | "build:old": "tnpm run lint && tnpm run clean && babel src --out-dir lib --extensions \".ts,.tsx\"", 32 | "clean": "rimraf lib", 33 | "lint": "eslint --ext .ts,.tsx src", 34 | "test": "cross-env NODE_ENV=development npm run jest", 35 | "jest": "jest", 36 | "ci": "tnpm run lint && tnpm test && tnpm run clean && tnpm run build" 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "lint-staged", 41 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 42 | } 43 | }, 44 | "lint-staged": { 45 | "*.{js,jsx,ts,tsx}": [ 46 | "prettier --write", 47 | "eslint --ext .js,.jsx,.ts,.tsx", 48 | "git add" 49 | ] 50 | }, 51 | "repository": { 52 | "type": "git", 53 | "url": "git@gitlab.alipay-inc.com:alisis/kop.git" 54 | }, 55 | "dependencies": { 56 | "core-js": "^2.6.11", 57 | "immutability-helper-x": "^1.0.5", 58 | "invariant": "^2.2.2", 59 | "lodash": "^4.17.11", 60 | "react-redux": "^7.2.0", 61 | "redux": "^4.0.0", 62 | "redux-saga": "^0.16.0", 63 | "regenerator-runtime": "^0.10.5", 64 | "reselect": "^3.0.0" 65 | }, 66 | "peerDependencies": { 67 | "react": "~16.8.0", 68 | "react-dom": "~16.8.0", 69 | "react-router-dom": "~4.4.0 || ~3.2.1" 70 | }, 71 | "devDependencies": { 72 | "@ali/ci": "^4.8.0", 73 | "@babel/cli": "^7.8.3", 74 | "@babel/core": "^7.8.3", 75 | "@babel/plugin-proposal-class-properties": "^7.8.3", 76 | "@babel/preset-env": "^7.8.3", 77 | "@babel/preset-typescript": "^7.8.3", 78 | "@commitlint/config-conventional": "^8.2.0", 79 | "@types/enzyme": "^3.1.15", 80 | "@types/enzyme-adapter-react-16": "^1.0.3", 81 | "@types/invariant": "^2.2.29", 82 | "@types/jest": "^23.3.10", 83 | "@types/jsdom": "^12.2.0", 84 | "@types/lodash": "^4.14.129", 85 | "@types/react": "^16.9.0", 86 | "@types/react-dom": "^16.9.0", 87 | "@types/react-redux": "^7.0.0", 88 | "@types/react-router-dom": "~4.3.1", 89 | "@typescript-eslint/eslint-plugin": "^2.8.0", 90 | "@typescript-eslint/parser": "^2.8.0", 91 | "babel-jest": "^24.9.0", 92 | "commitlint": "^8.2.0", 93 | "cross-env": "^7.0.2", 94 | "enzyme": "^3.7.0", 95 | "enzyme-adapter-react-16": "^1.7.0", 96 | "eslint": "^6.6.0", 97 | "eslint-config-airbnb": "^17.1.0", 98 | "eslint-config-prettier": "^6.7.0", 99 | "eslint-plugin-import": "^2.18.2", 100 | "eslint-plugin-jsx-a11y": "^6.2.3", 101 | "eslint-plugin-prettier": "^3.1.1", 102 | "eslint-plugin-react": "^7.16.0", 103 | "eslint-plugin-typescript": "^0.14.0", 104 | "father": "^2.29.2", 105 | "history": "~4.9.0", 106 | "husky": "^3.1.0", 107 | "immutability-helper": "^2.2.0", 108 | "jest": "^24.9.0", 109 | "jest-cli": "^23.6.0", 110 | "jest-enzyme": "^7.0.0", 111 | "jsdom": "^10.1.0", 112 | "lint-staged": "^9.2.5", 113 | "prettier": "^1.19.1", 114 | "react-addons-test-utils": "^15.6.2", 115 | "react-router-dom": "^5.2.0", 116 | "rimraf": "^2.7.1", 117 | "rollup": "^2.3.1", 118 | "rollup-plugin-babel": "^4.4.0", 119 | "rollup-plugin-commonjs": "^10.1.0", 120 | "rollup-plugin-node-resolve": "^5.2.0", 121 | "ts-jest": "^23.10.5", 122 | "tslint-config-prettier": "^1.3.0", 123 | "typescript": "^3.7.0", 124 | "typescript-eslint-parser": "22.0.0" 125 | }, 126 | "publishConfig": { 127 | "registry": "http://registry.npm.alibaba-inc.com" 128 | }, 129 | "jest": { 130 | "globals": { 131 | "ts-jest": { 132 | "diagnostics": false 133 | } 134 | }, 135 | "setupTestFrameworkScriptFile": "jest-enzyme", 136 | "testEnvironment": "enzyme", 137 | "setupFiles": [ 138 | "./__tests__/setup.ts" 139 | ], 140 | "transform": { 141 | "^.+\\.tsx?$": "ts-jest" 142 | }, 143 | "testRegex": "(/__tests__/.*_(test|spec))\\.(ts|tsx|js)$", 144 | "moduleNameMapper": { 145 | "^.+\\.(css|less)$": "/.jestignore.js" 146 | }, 147 | "collectCoverage": true, 148 | "collectCoverageFrom": [ 149 | "src/**/*.{js,jsx,ts,tsx}", 150 | "!src/types/**/*.ts" 151 | ], 152 | "moduleFileExtensions": [ 153 | "ts", 154 | "tsx", 155 | "js", 156 | "json" 157 | ], 158 | "moduleDirectories": [ 159 | "node_modules", 160 | "src" 161 | ] 162 | }, 163 | "ci": { 164 | "type": "gitlab", 165 | "version": "8, 10" 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-with-domain", 3 | "version": "1.0.0", 4 | "description": "redux based, domain driven design framework", 5 | "main": "kop.common.js", 6 | "module": "dist/kop.esm.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist", 10 | "README.md" 11 | ], 12 | "keywords": [ 13 | "kop", 14 | "redux-with-domain", 15 | "react", 16 | "redux", 17 | "redux-saga", 18 | "framework", 19 | "frontend" 20 | ], 21 | "authors": [ 22 | "https://github.com/Nanchenk", 23 | "https://github.com/zxc0328", 24 | "https://github.com/cnfi" 25 | ], 26 | "scripts": { 27 | "type-check": "tsc --noEmit", 28 | "build:types": "tsc --emitDeclarationOnly", 29 | "build": "rimraf ./dist/ & npm run build:prod & npm run build:types", 30 | "build:dev": "cross-env NODE_ENV=development rollup -c", 31 | "build:prod": "cross-env NODE_ENV=production rollup -c", 32 | "coverage": "cross-env NODE_ENV=development jest --coverage", 33 | "dev": "npm run watch:rollup & npm run watch:ts", 34 | "watch:rollup": "cross-env NODE_ENV=development rollup -c --watch", 35 | "watch:ts": "tsc --emitDeclarationOnly --watch", 36 | "lint": "eslint --ext .ts,.tsx src", 37 | "test": "cross-env NODE_ENV=development npm run jest", 38 | "jest": "jest", 39 | "ci": "tnpm run lint && tnpm test && tnpm run clean && tnpm run build" 40 | }, 41 | "husky": { 42 | "hooks": { 43 | "pre-commit": "lint-staged", 44 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 45 | } 46 | }, 47 | "lint-staged": { 48 | "*.{js,jsx,ts,tsx}": [ 49 | "prettier --write", 50 | "eslint --ext .js,.jsx,.ts,.tsx", 51 | "git add" 52 | ] 53 | }, 54 | "dependencies": { 55 | "core-js": "^2.6.11", 56 | "immutability-helper-x": "^1.0.5", 57 | "invariant": "^2.2.2", 58 | "lodash": "^4.17.11", 59 | "react-redux": "^7.2.0", 60 | "redux": "^4.0.0", 61 | "redux-saga": "^0.16.0", 62 | "regenerator-runtime": "^0.10.5", 63 | "reselect": "^3.0.0" 64 | }, 65 | "peerDependencies": { 66 | "react": "~16.8.0", 67 | "react-dom": "~16.8.0", 68 | "react-router-dom": "^4.3.1" 69 | }, 70 | "devDependencies": { 71 | "@babel/cli": "^7.8.3", 72 | "@babel/core": "^7.8.3", 73 | "@babel/plugin-proposal-class-properties": "^7.8.3", 74 | "@babel/preset-env": "^7.8.3", 75 | "@babel/preset-typescript": "^7.8.3", 76 | "@commitlint/config-conventional": "^8.2.0", 77 | "@types/enzyme": "^3.1.15", 78 | "@types/enzyme-adapter-react-16": "^1.0.3", 79 | "@types/invariant": "^2.2.29", 80 | "@types/jest": "^23.3.10", 81 | "@types/jsdom": "^12.2.0", 82 | "@types/lodash": "^4.14.129", 83 | "@types/react": "^16.9.0", 84 | "@types/react-dom": "^16.9.0", 85 | "@types/react-redux": "^7.0.0", 86 | "@types/react-router-dom": "~4.3.1", 87 | "@typescript-eslint/eslint-plugin": "^2.8.0", 88 | "@typescript-eslint/parser": "^2.8.0", 89 | "babel-jest": "^24.9.0", 90 | "commitlint": "^8.2.0", 91 | "cross-env": "^7.0.2", 92 | "enzyme": "^3.7.0", 93 | "enzyme-adapter-react-16": "^1.7.0", 94 | "eslint": "^6.6.0", 95 | "eslint-config-airbnb": "^17.1.0", 96 | "eslint-config-prettier": "^6.7.0", 97 | "eslint-plugin-flowtype": "^5.2.0", 98 | "eslint-plugin-import": "^2.18.2", 99 | "eslint-plugin-jsx-a11y": "^6.2.3", 100 | "eslint-plugin-prettier": "^3.1.1", 101 | "eslint-plugin-react": "^7.16.0", 102 | "eslint-plugin-react-hooks": "^4.0.8", 103 | "eslint-plugin-typescript": "^0.14.0", 104 | "history": "~4.9.0", 105 | "husky": "^3.1.0", 106 | "immutability-helper": "^2.2.0", 107 | "jest": "^24.9.0", 108 | "jest-cli": "^23.6.0", 109 | "jest-enzyme": "^7.0.0", 110 | "jsdom": "^10.1.0", 111 | "lint-staged": "^9.2.5", 112 | "prettier": "^1.19.1", 113 | "react": "~16.8.0", 114 | "react-addons-test-utils": "^15.6.2", 115 | "react-dom": "~16.8.0", 116 | "react-router-dom": "^4.3.1", 117 | "rimraf": "^2.7.1", 118 | "rollup": "^2.3.1", 119 | "rollup-plugin-babel": "^4.4.0", 120 | "rollup-plugin-commonjs": "^10.1.0", 121 | "rollup-plugin-node-resolve": "^5.2.0", 122 | "rollup-plugin-typescript": "^1.0.1", 123 | "ts-jest": "^23.10.5", 124 | "tslint-config-prettier": "^1.3.0", 125 | "typescript": "^3.7.0", 126 | "typescript-eslint-parser": "22.0.0" 127 | }, 128 | "repository": { 129 | "type": "git", 130 | "url": "https://github.com/ProtoTeam/redux-with-domain" 131 | }, 132 | "jest": { 133 | "globals": { 134 | "ts-jest": { 135 | "diagnostics": false 136 | } 137 | }, 138 | "setupTestFrameworkScriptFile": "jest-enzyme", 139 | "testEnvironment": "enzyme", 140 | "setupFiles": [ 141 | "./__tests__/setup.ts" 142 | ], 143 | "transform": { 144 | "^.+\\.tsx?$": "ts-jest" 145 | }, 146 | "testRegex": "(/__tests__/.*_(test|spec))\\.(ts|tsx|js)$", 147 | "moduleNameMapper": { 148 | "^.+\\.(css|less)$": "/.jestignore.js" 149 | }, 150 | "collectCoverage": true, 151 | "collectCoverageFrom": [ 152 | "src/**/*.{js,jsx,ts,tsx}", 153 | "!src/types/**/*.ts" 154 | ], 155 | "moduleFileExtensions": [ 156 | "ts", 157 | "tsx", 158 | "js", 159 | "json" 160 | ], 161 | "moduleDirectories": [ 162 | "node_modules", 163 | "src" 164 | ] 165 | }, 166 | "license": "MIT" 167 | } 168 | -------------------------------------------------------------------------------- /src/types/common.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux' 2 | import { effects, Effect } from 'redux-saga' 3 | import { GlobalState } from './containerModule' 4 | 5 | export interface DefaultReducer { 6 | (state: State, action: Action): State 7 | } 8 | 9 | export interface SagaEnhancer { 10 | emit: Func 11 | take: Func 12 | syncPut: Func 13 | } 14 | 15 | export interface SagaEffects { 16 | take: typeof effects.take 17 | put: typeof effects.put 18 | all: typeof effects.all 19 | race: typeof effects.race 20 | call: typeof effects.call 21 | apply: typeof effects.apply 22 | cps: typeof effects.cps 23 | fork: typeof effects.fork 24 | spawn: typeof effects.spawn 25 | join: typeof effects.join 26 | cancel: typeof effects.cancel 27 | select: typeof effects.select 28 | actionChannel: typeof effects.actionChannel 29 | flush: typeof effects.flush 30 | getContext: typeof effects.getContext 31 | setContext: typeof effects.setContext 32 | takeEvery: typeof effects.takeEvery 33 | takeLatest: typeof effects.takeLatest 34 | throttle: typeof effects.throttle 35 | } 36 | 37 | export interface Func { 38 | (...args: any[]): any 39 | } 40 | 41 | // object 42 | export interface SelectorsOf { 43 | [key: string]: (state: State, ...args: any[]) => any 44 | } 45 | 46 | // object 47 | export interface ReducerOf { 48 | [key: string]: (state: State, action: { type: string; payload: any }) => State 49 | } 50 | 51 | export interface ActionsOf { 52 | >(param: { 53 | actions: any 54 | selectors: S 55 | dispatch: Func 56 | createAction: (type: string) => Action 57 | }): ActionCreators 58 | } 59 | 60 | // function 61 | export interface EffectsOf { 62 | >(param: { 63 | actions: any 64 | selectors: S 65 | sagaEffects: SagaEffects 66 | enhancer: SagaEnhancer 67 | }): Effects 68 | } 69 | // end create module /////////////////////////////////////////////////////////////////// 70 | 71 | // Generate a typed Kop Module from its selectors, reducers, effects. 72 | // Pick payload in a reducer/effect. 73 | export type ReducerToAction = Reducer extends ( 74 | state: any, 75 | action: infer A 76 | ) => any 77 | ? A extends { payload: infer P } 78 | ? (payload: P) => any 79 | : () => any 80 | : never 81 | 82 | export type EffectToAction = Effect extends () => any 83 | ? () => any 84 | : Effect extends (payload: { payload: infer P }) => any 85 | ? (payload: P) => any 86 | : never 87 | 88 | // kop module 限制 89 | // Generate actions from reducers/effects. 90 | export type KopActions = { 91 | [K in keyof Reducers]: ReducerToAction 92 | } & 93 | { [K in keyof Effects]: EffectToAction } & 94 | ActionCreators 95 | 96 | // Generate kop selectors from module selectors 97 | export interface CommonSelectors { 98 | getModuleState: Func 99 | getGlobalState: Func 100 | } 101 | 102 | export type KopSelectors = { 103 | [P in keyof T]: T[P] extends (state: any) => infer A 104 | ? (state: GlobalState) => A 105 | : T[P] extends (state: any, payload: { payload: infer B }) => infer A 106 | ? (state: GlobalState, payload: B) => A 107 | : T[P] extends (state: any, payload: any) => infer A 108 | ? (state: GlobalState, payload: any) => A 109 | : (state: GlobalState) => any 110 | } & 111 | CommonSelectors 112 | 113 | export interface KopModule { 114 | selectors?: KopSelectors 115 | actions?: KopActions 116 | event?: (evt: string) => string 117 | setup?: Func 118 | reset?: Func 119 | } 120 | // end kop module /////////////////////////////////////////////////////////////////// 121 | 122 | // /////////////////////////////////////////////// 123 | export interface WrappedAction { 124 | type: string 125 | __namespace: string 126 | __actionKind: symbol 127 | __type: string // actionType 128 | __kopFunction: Function 129 | __changeLoading: Function 130 | __moduleType: symbol 131 | toString: Function 132 | } 133 | 134 | export interface BaseAction { 135 | type: string 136 | payload: any 137 | } 138 | 139 | export interface BaseSelectors { 140 | [key: string]: (state: State, ...args: any[]) => any 141 | } 142 | 143 | export interface BaseReducers { 144 | [key: string]: (state: State, action: BaseAction) => State 145 | } 146 | 147 | export interface Enhancer { 148 | syncPut: Effect 149 | emit: Function 150 | } 151 | 152 | export interface BaseEffects { 153 | (actions, selectors: Selector, sagaEffects, enhancer: Enhancer): { 154 | [key: string]: Effect 155 | } 156 | } 157 | 158 | export interface BaseModuleOption { 159 | initialState: State 160 | selectors: BaseSelectors 161 | reducers: BaseReducers 162 | effects: BaseEffects> 163 | } 164 | 165 | // todo 166 | export interface BaseModuleActions { 167 | [index: string]: (...param: any) => any 168 | } 169 | 170 | export interface BaseModuleSelectors { 171 | [index: string]: (...param: any) => any 172 | } 173 | 174 | export interface BaseReducer { 175 | (state: object): any 176 | } 177 | 178 | export interface BaseModule { 179 | // initModule 阶段写入 180 | namespace: string 181 | type: symbol // module 分层模块类型 182 | parent: object 183 | presenter: { 184 | loaded: boolean 185 | } 186 | // initInitialState 的阶段写入 187 | _initialState?: { 188 | '@@loading': boolean 189 | [key: string]: any 190 | } 191 | // initSelectors 的阶段写入 192 | selectors?: BaseModuleSelectors 193 | // initActions 的阶段写入 194 | _reducers?: BaseReducer 195 | event?: any 196 | actions?: BaseModuleActions 197 | // initLifeCycle 的阶段写入 198 | setup?: Func // 已废弃 199 | reset?: Func // 已废弃 200 | lifecycle?: Func 201 | } 202 | -------------------------------------------------------------------------------- /src/helpers/createDomainModel.ts: -------------------------------------------------------------------------------- 1 | import update from 'immutability-helper-x' 2 | import { createSelectorCreator, defaultMemoize } from 'reselect' 3 | import _ from 'lodash' 4 | import createModule from '../module/module' 5 | import { ENTITY_MODULE } from '../const' 6 | 7 | const arrize = input => { 8 | if (_.isArray(input)) { 9 | return input 10 | } 11 | return [input] 12 | } 13 | 14 | // use _.isEqual instead of === for argument change determine 15 | const createDeepEqualSelector = createSelectorCreator( 16 | defaultMemoize, // default memoize cache size is 1 17 | _.isEqual 18 | ) 19 | 20 | // 实体合并函数,默认是简单的浅合并 21 | const mergeEntity = (target, source) => _.assign({}, target, source) 22 | 23 | // 主函数 createDomainModal 24 | export default function createDomainModel(namespace, idKey) { 25 | const mapIds = (ids, byId) => _.map(ids, id => byId[id]) 26 | const mapIdsMemoized = _.memoize(mapIds, ids => ids.join()) 27 | const getSelector = createDeepEqualSelector( 28 | [(state, id) => state, (state, id) => id], 29 | (state, id) => { 30 | if (_.isArray(id)) { 31 | // return _.map(id, key => state.byId[key]); 32 | return mapIdsMemoized(id, state.byId) 33 | } 34 | return state.byId[id] 35 | } 36 | ) 37 | const selectSelector = createDeepEqualSelector( 38 | [(state, predicate) => state, (state, predicate) => predicate], 39 | (state, predicate) => { 40 | const arr = _.map(state.ids, id => state.byId[id]) 41 | if (predicate) { 42 | return _.filter(arr, predicate) 43 | } 44 | return arr 45 | } 46 | ) 47 | const module = { 48 | /** 49 | * Kop initialState 50 | */ 51 | initialState: { 52 | byId: {}, 53 | ids: [] 54 | }, 55 | 56 | /** 57 | * Kop selectors 58 | */ 59 | selectors: { 60 | /** 61 | * 通过 id 直接拿数据,找不到则为 undefined 62 | * 例如: 63 | * BaseModel.actions.get('xxx'); 64 | * 批量: 65 | * BaseModel.actions.get(['xxx', 'xxxsss']) 66 | * 67 | * @param state 68 | * @param id 69 | * 70 | * @return {Object| Array | undefined} 71 | */ 72 | get: (state, selectors, id) => 73 | // ignore selectors and use reselect 74 | getSelector(state, id), 75 | /** 76 | * 基于lodash的filter实现,你可以从model中选出所有符合条件的实体 77 | * 78 | * @param state 79 | * @param predicate 如果不传predicate,则返回全部数据 80 | * 例如: 81 | * BaseModel.actions.select({id: 'xxxx'}); 82 | * 获取全部 83 | * BaseModel.actions.select(); 84 | * 85 | * @return {Array|*} 86 | */ 87 | select: (state, selectors, predicate) => 88 | // ignore selectors and use reselect 89 | selectSelector(state, predicate) 90 | }, 91 | 92 | /** 93 | * Kop reducers 94 | */ 95 | // tslint:disable-next-line:object-literal-sort-keys 96 | reducers: { 97 | /** 98 | * 特别说明:每个实体都必须是object,不支持array格式,例如: 99 | * 支持 { a: 1, b: 2 }, 100 | * 不支持 [1, 2] 101 | * 102 | * 插入或者更新实体,支持批量插入或更新 103 | * 例如: 104 | * BaseModel.actions.insert({ id: 'xxxx', a: 1, b: 2 }); 105 | * 批量: 106 | * BaseModel.actions.insert([ 107 | * { id: 'xxxx', a: 1, b: 2 }, 108 | * { id: 'xxxxsss', a: 1, b: 3 }, 109 | * ]) 110 | */ 111 | insert: (state, { payload: data }) => { 112 | mapIdsMemoized.cache.clear!() 113 | const arr = arrize(data) 114 | const ids = _.map(arr, idKey) 115 | const byId = _.keyBy(arr, idKey) 116 | return { 117 | byId: _.chain(state.byId) 118 | .clone() 119 | .assignWith(byId, (objValue, srcValue) => { 120 | if (objValue === undefined) { 121 | return srcValue 122 | } 123 | return mergeEntity(objValue, srcValue) 124 | }) 125 | .value(), 126 | ids: _.union(state.ids, ids) 127 | } 128 | }, 129 | 130 | /** 131 | * 根据id删除实体,支持批量 132 | * 例如: 133 | * BaseModel.actions.delete('xxxx'); 134 | * 批量 135 | * BaseModel.actions.delete(['xxxx', 'xxxxsss']); 136 | */ 137 | // tslint:disable-next-line:object-literal-sort-keys 138 | delete: (state, { payload: data }) => { 139 | mapIdsMemoized.cache.clear!() 140 | const arr = arrize(data) 141 | return update(state, { 142 | byId: { $set: _.omit(state.byId, ...arr) }, 143 | ids: { $set: _.without(state.ids, ...arr) } 144 | }) 145 | }, 146 | 147 | /** 148 | * 更新实体,支持批量,如果id不存在,则不会插入 149 | * 例如: 150 | * BaseModel.actions.update({ [idKey]: 'xxxx', b: 10 }); 151 | * 批量 152 | * BaseModel.actions.update([ 153 | * { [idKey]: 'xxxx', b: 10 }, 154 | * { [idKey]: 'xxxxsss', a: 'lingyi' }, 155 | * ]) 156 | */ 157 | update: (state, { payload: data }) => { 158 | mapIdsMemoized.cache.clear!() 159 | const arr = arrize(data) 160 | const byId = _.keyBy(arr, idKey) 161 | const re = update.$set( 162 | state, 163 | 'byId', 164 | _.mapValues(state.byId, (entity, key) => { 165 | if (byId[key]) { 166 | return mergeEntity(entity, byId[key]) 167 | } 168 | return entity 169 | }) 170 | ) 171 | return re 172 | }, 173 | 174 | /** 175 | * 清空数据库 176 | * 例如: 177 | * BaseModel.actions.clear(state); 178 | */ 179 | clear: () => { 180 | mapIdsMemoized.cache.clear!() 181 | return { ids: [], byId: {} } 182 | } 183 | } 184 | } 185 | 186 | const res = createModule(namespace, module, ENTITY_MODULE) 187 | const { actions, selectors } = res as any 188 | const newActions = {} 189 | 190 | // 转化一层,不用put了 191 | _.forEach(actions, (action, key) => { 192 | newActions[key] = (...params) => { 193 | if (returnModule._store) { 194 | returnModule._store.dispatch(action(...params)) 195 | } 196 | } 197 | }) 198 | 199 | const returnModule = { 200 | ...res, 201 | ...newActions, 202 | ...selectors 203 | } 204 | 205 | return returnModule 206 | } 207 | -------------------------------------------------------------------------------- /examples/simple-BI/src/container/common/reportCanvas/ui/components/chart/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, useEffect, useRef } from 'react' 2 | import _ from 'lodash' 3 | import { Pie, Line, Bar, Column } from '@antv/g2plot' 4 | 5 | import { queryData as query } from '../../../../../../api/chart' 6 | import { Chart as ChartType } from '../../../../../../domain/chart' 7 | 8 | interface Props { 9 | chart: ChartType 10 | selectedChartId?: string 11 | changeSelectedChart?: Function 12 | } 13 | 14 | const aggregate = (data: any, key: string, value: string) => { 15 | // 对 value 进行聚合,默认 count 16 | const countData: any = {} 17 | data.forEach((item: any) => { 18 | if (countData[item[key]]) { 19 | countData[item[key]] = countData[item[key]] + item[value] 20 | } else { 21 | countData[item[key]] = item[value] 22 | } 23 | }) 24 | return Object.keys(countData).map((k: string) => { 25 | return { 26 | [key]: k, 27 | [value]: countData[k] 28 | } 29 | }) 30 | } 31 | 32 | const renderer: Record> = { 33 | pie: { 34 | render: (id: string, data: any, chart: any) => { 35 | const key = chart.attribute[0].fields[0] 36 | const value = chart.attribute[1].fields[0] 37 | const keyName = chart.attribute[0].fieldsName[0] 38 | const valName = chart.attribute[1].fieldsName[0] 39 | 40 | const piePlot = new Pie(document.getElementById(id)!, { 41 | forceFit: true, 42 | radius: 0.8, 43 | data, 44 | angleField: value, 45 | colorField: key, 46 | meta: { 47 | [key]: { 48 | alias: keyName 49 | }, 50 | [value]: { 51 | alias: valName 52 | } 53 | }, 54 | label: { 55 | visible: true, 56 | type: 'inner' 57 | } 58 | }) 59 | piePlot.render() 60 | return piePlot 61 | }, 62 | update: (instance: any, data: any, chart: any) => { 63 | instance.updateConfig({ 64 | data, 65 | angleField: chart.attribute[1].fields[0], 66 | colorField: chart.attribute[0].fields[0] 67 | }) 68 | instance.render() 69 | } 70 | }, 71 | line: { 72 | render: (id: string, data: any, chart: any) => { 73 | const key = chart.attribute[0].fields[0] 74 | const value = chart.attribute[1].fields[0] 75 | const keyName = chart.attribute[0].fieldsName[0] 76 | const valName = chart.attribute[1].fieldsName[0] 77 | 78 | const linePlot = new Line(document.getElementById(id)!, { 79 | data: aggregate(data, key, value), 80 | xField: chart.attribute[0].fields[0], 81 | yField: chart.attribute[1].fields[0], 82 | meta: { 83 | [key]: { 84 | alias: keyName 85 | }, 86 | [value]: { 87 | alias: valName 88 | } 89 | } 90 | }) 91 | linePlot.render() 92 | return linePlot 93 | }, 94 | update: (instance: any, data: any, chart: any) => { 95 | const key = chart.attribute[0].fields[0] 96 | const value = chart.attribute[1].fields[0] 97 | 98 | instance.updateConfig({ 99 | data: aggregate(data, key, value), 100 | xField: key, 101 | yField: value 102 | }) 103 | instance.render() 104 | } 105 | }, 106 | bar: { 107 | render: (id: string, data: any, chart: any) => { 108 | const key = chart.attribute[0].fields[0] 109 | const value = chart.attribute[1].fields[0] 110 | const keyName = chart.attribute[0].fieldsName[0] 111 | const valName = chart.attribute[1].fieldsName[0] 112 | 113 | const barPlot = new Column(document.getElementById(id)!, { 114 | forceFit: true, 115 | data: aggregate(data, key, value), 116 | xField: key, 117 | yField: value, 118 | padding: 'auto', 119 | meta: { 120 | [key]: { 121 | alias: keyName 122 | }, 123 | [value]: { 124 | alias: valName 125 | } 126 | }, 127 | label: { 128 | visible: true 129 | } 130 | }) 131 | barPlot.render() 132 | return barPlot 133 | }, 134 | update: (instance: any, data: any, chart: any) => { 135 | const key = chart.attribute[0].fields[0] 136 | const value = chart.attribute[1].fields[0] 137 | 138 | instance.updateConfig({ 139 | data: aggregate(data, key, value), 140 | xField: key, 141 | yField: value 142 | }) 143 | instance.render() 144 | } 145 | } 146 | } 147 | 148 | const validateField: Record = { 149 | pie: (chart: ChartType) => { 150 | const attribute = chart.attribute 151 | if (attribute.length === 2) { 152 | if (attribute[0].fields.length > 0 && attribute[1].fields.length > 0) { 153 | return true 154 | } 155 | } 156 | return false 157 | }, 158 | line: (chart: ChartType) => { 159 | const attribute = chart.attribute 160 | if (attribute.length === 2) { 161 | if (attribute[0].fields.length > 0 && attribute[1].fields.length > 0) { 162 | return true 163 | } 164 | } 165 | return false 166 | }, 167 | bar: (chart: ChartType) => { 168 | const attribute = chart.attribute 169 | if (attribute.length === 2) { 170 | if (attribute[0].fields.length > 0 && attribute[1].fields.length > 0) { 171 | return true 172 | } 173 | } 174 | return false 175 | } 176 | } 177 | 178 | const Chart: FC = ({ chart, changeSelectedChart, selectedChartId }) => { 179 | const [type, setType] = useState('') 180 | const g2plot = useRef(null) 181 | 182 | const id = `chart-container-${chart.id}` 183 | 184 | const queryData = () => { 185 | if (validateField[chart.type](chart)) { 186 | query( 187 | chart.datasetId, 188 | _.flatten( 189 | Object.values(chart.attribute).map(item => { 190 | return item.fields 191 | }) 192 | ) 193 | ).then(res => { 194 | if (!g2plot.current) { 195 | g2plot.current = renderer[chart.type].render(id, res, chart) 196 | setType(chart.type) 197 | } else { 198 | renderer[chart.type].update(g2plot.current, res, chart) 199 | } 200 | }) 201 | } 202 | } 203 | 204 | useEffect(() => { 205 | // shouldQueryData 206 | queryData() 207 | }, [chart]) 208 | 209 | return ( 210 | { 215 | changeSelectedChart && changeSelectedChart(chart.id) 216 | }} 217 | > 218 | {chart.name} 219 | 220 | 221 | ) 222 | } 223 | 224 | export default Chart 225 | -------------------------------------------------------------------------------- /src/module/util/util.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import { forEach, cloneDeep, isFunction } from 'lodash' 3 | import { effects } from 'redux-saga' 4 | import update from 'immutability-helper-x' 5 | 6 | import { 7 | createSelectors, 8 | createPageSelectors, 9 | createContainerSelectors, 10 | createGlobalSelectors 11 | } from '../options/selector' 12 | import createEffectsAndActions, { 13 | createPageEffects, 14 | createContainerEffects, 15 | createGlobalEffects, 16 | getAuthCheck 17 | } from '../options/effects' 18 | import { generateSagaEnhancer } from '../../utils' 19 | import { 20 | MODULE, 21 | ACTION_KIND, 22 | PAGE_MODULE, 23 | CONTAINER_MODULE, 24 | CHANGE_LOADING_REDUCER, 25 | GET_LOADING_SELECTOR, 26 | GLOBAL_MODULE 27 | } from '../../const' 28 | import createAction, { 29 | getActionTypeWithNamespace, 30 | createActionCreators 31 | } from '../options/action' 32 | 33 | type EffectsType = typeof effects 34 | 35 | export function parseActionsAndReducers( 36 | namespace: string, 37 | reducerMap, 38 | moduleType 39 | ) { 40 | const actions = {} 41 | const reducers = {} 42 | forEach(reducerMap, (reducer, actionType) => { 43 | const actionTypeWithNamespace = getActionTypeWithNamespace( 44 | namespace, 45 | actionType 46 | ) 47 | reducers[actionTypeWithNamespace] = reducer 48 | actions[actionType] = createAction({ 49 | namespace, 50 | actionType, 51 | moduleType, 52 | actionKind: ACTION_KIND.REDUCER 53 | }) 54 | }) 55 | 56 | return { 57 | actions, 58 | reducers 59 | } 60 | } 61 | 62 | export function getModuleState(state) { 63 | return state 64 | } 65 | 66 | export function getGlobalState(state) { 67 | return state 68 | } 69 | 70 | export function initInitialState(module, pkg) { 71 | const { initialState = {} } = pkg 72 | module._initialState = cloneDeep(initialState) 73 | module._initialState['@@loading'] = {} 74 | } 75 | 76 | export const initModule = (pkg, namespace: string, type: symbol) => { 77 | invariant(namespace.indexOf(',') === -1, 'Module name can not contain " , "') 78 | 79 | return { 80 | namespace, 81 | type, 82 | [MODULE]: MODULE, 83 | parent: {}, 84 | presenter: { 85 | loaded: false 86 | } 87 | } 88 | } 89 | 90 | // wrap saga effects with check auth 91 | export function wrapEffectsFunc(authCheck, namespace) { 92 | const wrapEffects = {} 93 | forEach(effects, (value, key) => { 94 | if (key === 'put') { 95 | wrapEffects[key] = (...args) => { 96 | const action = args && args[0] 97 | authCheck(action, namespace) 98 | return (value as Function)(...args) 99 | } 100 | } else { 101 | wrapEffects[key] = value 102 | } 103 | }) 104 | return wrapEffects as EffectsType 105 | } 106 | 107 | export function initActions( 108 | module, 109 | pkg, 110 | namespace, 111 | moduleType, 112 | authCheck?: Function 113 | ) { 114 | const { initialState = {}, reducers, effectLoading = false } = pkg 115 | 116 | // internal reducer for reset state 117 | const __reset = () => ({ 118 | ...initialState, 119 | '@@loading': {} 120 | }) 121 | 122 | const changeLoading = (state, { payload }) => { 123 | if (state['@@loading']) { 124 | return update(state, { 125 | '@@loading': { 126 | [payload.key]: { 127 | $set: payload.value 128 | } 129 | } 130 | }) 131 | } 132 | const loading = { 133 | [payload.key]: payload.value 134 | } 135 | return update(state, { 136 | '@@loading': { 137 | $set: loading 138 | } 139 | }) 140 | } 141 | const reducersWithDefault = { 142 | ...reducers, 143 | __reset, 144 | [CHANGE_LOADING_REDUCER]: changeLoading 145 | } 146 | const getEventCompleteType = name => `${namespace}/_EVENT_${name}_` 147 | const emit = ({ name, payload }) => 148 | effects.put({ 149 | payload, 150 | type: getEventCompleteType(name) 151 | }) 152 | const take = name => effects.take(getEventCompleteType(name)) 153 | 154 | const actionsAndReducers = parseActionsAndReducers( 155 | namespace, 156 | reducersWithDefault, 157 | moduleType 158 | ) 159 | 160 | const normalActions = actionsAndReducers.actions 161 | 162 | let actionCreators = {} 163 | 164 | if (pkg.actionCreators) { 165 | actionCreators = initActionCreators( 166 | module, 167 | pkg, 168 | namespace, 169 | moduleType, 170 | authCheck 171 | ) 172 | } 173 | 174 | // extract actions from effects 175 | if (isFunction(pkg.effects)) { 176 | let data: any = {} 177 | 178 | switch (moduleType) { 179 | case PAGE_MODULE: 180 | data = createPageEffects( 181 | namespace, 182 | normalActions, 183 | module.selectors, 184 | { emit, take }, 185 | pkg.effects, 186 | moduleType, 187 | effectLoading 188 | ) 189 | break 190 | case CONTAINER_MODULE: 191 | data = createContainerEffects( 192 | namespace, 193 | normalActions, 194 | module.selectors, 195 | { emit, take }, 196 | pkg.effects, 197 | moduleType, 198 | effectLoading 199 | ) 200 | break 201 | case GLOBAL_MODULE: 202 | data = createGlobalEffects( 203 | namespace, 204 | normalActions, 205 | module.selectors, 206 | { emit, take }, 207 | pkg.effects, 208 | moduleType, 209 | effectLoading 210 | ) 211 | break 212 | default: 213 | data = createEffectsAndActions( 214 | namespace, 215 | normalActions, 216 | module.selectors, 217 | { emit, take }, 218 | pkg.effects 219 | ) 220 | } 221 | 222 | return { 223 | actions: data.allActions, // actions = reducers actions + effects actions 224 | effects: data.effects, 225 | _reducers: actionsAndReducers.reducers, 226 | event: getEventCompleteType, 227 | actionCreators 228 | } 229 | } 230 | return { 231 | _reducers: actionsAndReducers.reducers, 232 | actions: normalActions, 233 | event: getEventCompleteType, 234 | actionCreators 235 | } 236 | } 237 | 238 | export function initSelectors(module, pkg, namespace, moduleType) { 239 | const { selectors = {}, initialState = {} } = pkg 240 | 241 | selectors[GET_LOADING_SELECTOR] = state => state['@@loading'] || {} 242 | 243 | let moduleSelectors 244 | 245 | switch (moduleType) { 246 | case PAGE_MODULE: 247 | moduleSelectors = createPageSelectors( 248 | namespace, 249 | { ...selectors, getModuleState, getGlobalState }, 250 | initialState 251 | ) 252 | break 253 | case CONTAINER_MODULE: 254 | moduleSelectors = createContainerSelectors( 255 | namespace, 256 | { ...selectors, getModuleState, getGlobalState }, 257 | module.presenter, 258 | initialState 259 | ) 260 | break 261 | case GLOBAL_MODULE: 262 | moduleSelectors = createGlobalSelectors( 263 | namespace, 264 | { ...selectors, getModuleState, getGlobalState }, 265 | initialState 266 | ) 267 | break 268 | default: 269 | moduleSelectors = createSelectors( 270 | namespace, 271 | { ...selectors, getModuleState, getGlobalState }, 272 | module.presenter, 273 | initialState 274 | ) 275 | } 276 | 277 | module.selectors = moduleSelectors 278 | } 279 | 280 | export function initActionCreators( 281 | module, 282 | pkg, 283 | namespace, 284 | moduleType, 285 | checkAuth 286 | ) { 287 | const { actionCreators } = createActionCreators({ 288 | namespace, 289 | module, 290 | actionCreators: pkg.actionCreators, 291 | moduleType, 292 | checkAuth 293 | }) 294 | 295 | return actionCreators 296 | } 297 | 298 | export function getWrappedEffectOptions( 299 | moduleType: symbol, 300 | namespace: string, 301 | events: any 302 | ) { 303 | const authCheck = getAuthCheck[moduleType] 304 | let enhancer = generateSagaEnhancer(events, namespace) 305 | let effectsFunc = effects 306 | 307 | if (process.env.NODE_ENV === 'development') { 308 | effectsFunc = wrapEffectsFunc(authCheck, namespace) 309 | enhancer = generateSagaEnhancer(events, namespace, authCheck) 310 | } 311 | return { 312 | sagaEffects: effectsFunc, 313 | enhancer 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /examples/simple-BI/src/common/mock/index.ts: -------------------------------------------------------------------------------- 1 | // 数据表的mock数据 2 | export const MOCK_DATA: any = { 3 | dataset_1: [ 4 | { 5 | '1-1': '家具类', 6 | '1-2': '沙发', 7 | '1-3': '2020-01-01', 8 | '1-4': '张三', 9 | '1-5': '20000', 10 | '1-6': '2', 11 | '1-7': '浙江省', 12 | '1-8': '10000' 13 | }, 14 | { 15 | '1-1': '生鲜类', 16 | '1-2': '火龙果', 17 | '1-3': '2020-01-02', 18 | '1-4': '李四', 19 | '1-5': '1000', 20 | '1-6': '20', 21 | '1-7': '黑龙江省', 22 | '1-8': '50' 23 | }, 24 | { 25 | '1-1': '数码类', 26 | '1-2': '手机', 27 | '1-3': '2020-01-03', 28 | '1-4': '王五', 29 | '1-5': '6999', 30 | '1-6': '1', 31 | '1-7': '上海市', 32 | '1-8': '6999' 33 | }, 34 | { 35 | '1-1': '军火类', 36 | '1-2': '航空母舰', 37 | '1-3': '2020-01-05', 38 | '1-4': '赵四', 39 | '1-5': '998', 40 | '1-6': '1', 41 | '1-7': '浙江省', 42 | '1-8': '998' 43 | } 44 | ], 45 | dataset_2: [ 46 | { 47 | '2-1': '1', 48 | '2-2': '淘淘网', 49 | '2-3': '2020-01-01', 50 | '2-5': 30000, 51 | '2-4': '主页' 52 | }, 53 | { 54 | '2-1': '1', 55 | '2-2': '淘淘网', 56 | '2-3': '2020-01-01', 57 | '2-5': 60000, 58 | '2-4': '详情页' 59 | }, 60 | { 61 | '2-1': '1', 62 | '2-2': '淘淘网', 63 | '2-3': '2020-01-02', 64 | '2-5': 40000, 65 | '2-4': '主页' 66 | }, 67 | { 68 | '2-1': '1', 69 | '2-2': '淘淘网', 70 | '2-3': '2020-01-02', 71 | '2-5': 70000, 72 | '2-4': '详情页' 73 | }, 74 | { 75 | '2-1': '1', 76 | '2-2': '淘淘网', 77 | '2-3': '2020-01-03', 78 | '2-5': 4500, 79 | '2-4': '主页' 80 | }, 81 | { 82 | '2-1': '1', 83 | '2-2': '淘淘网', 84 | '2-3': '2020-01-03', 85 | '2-5': 60000, 86 | '2-4': '详情页' 87 | }, 88 | { 89 | '2-1': '1', 90 | '2-2': '淘淘网', 91 | '2-3': '2020-01-04', 92 | '2-5': 50000, 93 | '2-4': '主页' 94 | }, 95 | { 96 | '2-1': '1', 97 | '2-2': '淘淘网', 98 | '2-3': '2020-01-04', 99 | '2-5': 90000, 100 | '2-4': '详情页' 101 | } 102 | ], 103 | dataset_3: [ 104 | { 105 | '3-1': 110.0, 106 | '3-2': '123234', 107 | '3-3': '2020-01-01', 108 | '3-4': '有好货超市', 109 | '3-5': '浙江省', 110 | '3-6': '2', 111 | '3-7': '杭州市', 112 | '3-8': '黄龙商圈' 113 | }, 114 | { 115 | '3-1': 210.0, 116 | '3-2': '423424', 117 | '3-3': '2020-01-02', 118 | '3-4': '盒马鲜生', 119 | '3-5': '浙江省', 120 | '3-6': '2', 121 | '3-7': '杭州市', 122 | '3-8': '湖滨商圈' 123 | }, 124 | { 125 | '3-1': 233.0, 126 | '3-2': '423424', 127 | '3-3': '2020-01-03', 128 | '3-4': '银泰', 129 | '3-5': '浙江省', 130 | '3-6': '2', 131 | '3-7': '杭州市', 132 | '3-8': '武林商圈' 133 | }, 134 | { 135 | '3-1': 310.0, 136 | '3-2': '4234324', 137 | '3-3': '2020-01-04', 138 | '3-4': '自动售货机', 139 | '3-5': '浙江省', 140 | '3-6': '2', 141 | '3-7': '杭州市', 142 | '3-8': '下沙商圈' 143 | } 144 | ] 145 | } 146 | 147 | // Mock一个工作簿信息 148 | export const WORK_BOOK = { 149 | id: '1', 150 | name: 'Simple BI 工作簿', 151 | datasetIdList: ['dataset_1', 'dataset_2', 'dataset_3'], 152 | reportIdList: ['report_1', 'report_2', 'report_3'] 153 | } 154 | 155 | // 可用数据集的列表 156 | export const DATASET_LIST = [ 157 | { 158 | id: 'dataset_1', 159 | name: '电商销售', 160 | type: 'sql', 161 | fields: [ 162 | { 163 | id: '1-1', 164 | name: '商品类别' 165 | }, 166 | { 167 | id: '1-2', 168 | name: '商品名称' 169 | }, 170 | { 171 | id: '1-5', 172 | name: '销售额' 173 | }, 174 | { 175 | id: '1-3', 176 | name: '日期' 177 | }, 178 | { 179 | id: '1-4', 180 | name: '客户' 181 | }, 182 | { 183 | id: '1-6', 184 | name: '销售数量' 185 | }, 186 | { 187 | id: '1-7', 188 | name: '客户省份' 189 | }, 190 | { 191 | id: '1-8', 192 | name: '单价' 193 | } 194 | ] 195 | }, 196 | { 197 | id: 'dataset_2', 198 | name: '访问记录', 199 | type: 'sql', 200 | fields: [ 201 | { 202 | id: '2-1', 203 | name: '产品id' 204 | }, 205 | { 206 | id: '2-2', 207 | name: '产品名称' 208 | }, 209 | { 210 | id: '2-3', 211 | name: '日期' 212 | }, 213 | { 214 | id: '2-4', 215 | name: '页面' 216 | }, 217 | { 218 | id: '2-5', 219 | name: '访问计数' 220 | } 221 | ] 222 | }, 223 | { 224 | id: 'dataset_3', 225 | name: '支付记录', 226 | type: 'sql', 227 | fields: [ 228 | { 229 | id: '3-1', 230 | name: '支付金额' 231 | }, 232 | { 233 | id: '3-2', 234 | name: '账号id' 235 | }, 236 | { 237 | id: '3-3', 238 | name: '时间' 239 | }, 240 | { 241 | id: '3-4', 242 | name: '商户id' 243 | }, 244 | { 245 | id: '3-5', 246 | name: '商户省份' 247 | }, 248 | { 249 | id: '3-7', 250 | name: '商户城市' 251 | }, 252 | { 253 | id: '3-8', 254 | name: '商户商区' 255 | }, 256 | { 257 | id: '3-6', 258 | name: '账号等级' 259 | } 260 | ] 261 | } 262 | ] 263 | 264 | // 报表列表 265 | export const REPORT_LIST = [ 266 | { 267 | id: 'report_1', 268 | name: '电商销售分析报表', 269 | workbookId: '1' 270 | }, 271 | { 272 | id: 'report_2', 273 | name: '网站访问分析报表', 274 | workbookId: '1' 275 | }, 276 | { 277 | id: 'report_3', 278 | name: '支付数据分析报表', 279 | workbookId: '1' 280 | } 281 | ] 282 | 283 | // 报表中的详情信息 284 | export const REPORT_CHART_INFO = { 285 | report_1: [ 286 | { 287 | id: 'chart_1', 288 | reportId: 'report_1', 289 | datasetId: 'dataset_1', 290 | name: '商品分类销售占比', 291 | attribute: [ 292 | { 293 | group: '切片', 294 | max: 1, 295 | fields: ['1-1'] 296 | }, 297 | { 298 | group: '值', 299 | max: 1, 300 | fields: ['1-5'] 301 | } 302 | ], 303 | type: 'pie' 304 | } 305 | ], 306 | report_2: [ 307 | { 308 | id: 'chart_2', 309 | reportId: 'report_2', 310 | datasetId: 'dataset_2', 311 | name: '图表2', 312 | attribute: [ 313 | { 314 | group: 'X轴', 315 | max: 1, 316 | fields: ['2-4'] 317 | }, 318 | { 319 | group: 'Y轴', 320 | max: 1, 321 | fields: ['2-5'] 322 | } 323 | ], 324 | type: 'bar' 325 | }, 326 | { 327 | id: 'chart_3', 328 | reportId: 'report_2', 329 | datasetId: 'dataset_2', 330 | name: '网站 UV 趋势', 331 | attribute: [ 332 | { 333 | group: 'X轴', 334 | max: 1, 335 | fields: ['2-3'] 336 | }, 337 | { 338 | group: 'Y轴', 339 | max: 1, 340 | fields: ['2-5'] 341 | } 342 | ], 343 | type: 'line' 344 | } 345 | ], 346 | report_3: [ 347 | { 348 | id: 'chart_4', 349 | reportId: 'report_3', 350 | datasetId: 'dataset_3', 351 | name: '支付笔数趋势', 352 | attribute: [ 353 | { 354 | group: 'X轴', 355 | max: 1, 356 | fields: ['3-3'] 357 | }, 358 | { 359 | group: 'Y轴', 360 | max: 1, 361 | fields: ['3-1'] 362 | } 363 | ], 364 | type: 'line' 365 | }, 366 | { 367 | id: 'chart_5', 368 | reportId: 'report_3', 369 | datasetId: 'dataset_3', 370 | name: '商圈支付占比', 371 | attribute: [ 372 | { 373 | group: '切片', 374 | max: 1, 375 | fields: ['3-8'] 376 | }, 377 | { 378 | group: '值', 379 | fields: ['3-1'] 380 | } 381 | ], 382 | type: 'pie' 383 | }, 384 | { 385 | id: 'chart_6', 386 | reportId: 'report_3', 387 | datasetId: 'dataset_3', 388 | name: '商家成交 Top 4', 389 | attribute: [ 390 | { 391 | group: 'X轴', 392 | max: 1, 393 | fields: ['3-4'] 394 | }, 395 | { 396 | group: 'Y轴', 397 | max: 1, 398 | fields: ['3-1'] 399 | } 400 | ], 401 | type: 'bar' 402 | } 403 | ] 404 | } 405 | -------------------------------------------------------------------------------- /src/module/options/effects.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | import { extend, forEach, isArray, isUndefined } from 'lodash' 3 | import { effects } from 'redux-saga' 4 | 5 | import { WrappedAction } from 'types/common' 6 | import { 7 | ACTION_KIND, 8 | DOMAIN_MODULE, 9 | CHANGE_LOADING_REDUCER, 10 | PAGE_MODULE, 11 | CONTAINER_MODULE, 12 | GLOBAL_MODULE 13 | } from '../../const' 14 | import createAction, { getActionTypeWithNamespace } from './action' 15 | import { decorateSagaEffects } from '../../utils' 16 | import { getWrappedEffectOptions } from '../util/util' 17 | 18 | function transformEffects({ 19 | moduleEffects, 20 | normalActions, 21 | namespace, 22 | moduleType, 23 | effectLoading 24 | }) { 25 | const sagaFns: Function[] = [] 26 | const effectActions = {} // saga action 27 | 28 | forEach({ ...moduleEffects }, (value, type) => { 29 | invariant( 30 | !normalActions[type], 31 | `Module ${namespace} action ${type} duplicated` 32 | ) 33 | 34 | const actionType = getActionTypeWithNamespace(namespace, type) 35 | let generator 36 | let helper = effects.takeEvery 37 | let args: Array = [] // extra args for saga 38 | 39 | if (isArray(value)) { 40 | generator = value[0] 41 | const helperName = value[1] 42 | helper = effects[helperName] 43 | invariant( 44 | helper, 45 | `Module ${namespace} effect ${type} use invalid helper ${helperName}` 46 | ) 47 | if (!isUndefined(value[2])) { 48 | args = value.slice(2) 49 | } 50 | } else { 51 | generator = value 52 | } 53 | 54 | sagaFns.push(function*() { 55 | // fix redux-saga's bug 56 | // https://github.com/redux-saga/redux-saga/issues/1482 57 | yield (helper as any)( 58 | actionType, 59 | function*(...params) { 60 | try { 61 | if (effectLoading) { 62 | if (DOMAIN_MODULE !== moduleType) { 63 | yield effects.put( 64 | normalActions[CHANGE_LOADING_REDUCER]({ 65 | key: type, 66 | value: true 67 | }) 68 | ) 69 | } 70 | } 71 | yield generator(...params) 72 | if (effectLoading) { 73 | if (DOMAIN_MODULE !== moduleType) { 74 | yield effects.put( 75 | normalActions[CHANGE_LOADING_REDUCER]({ 76 | key: type, 77 | value: false 78 | }) 79 | ) 80 | } 81 | } 82 | } catch (e) { 83 | if (effectLoading) { 84 | if (DOMAIN_MODULE !== moduleType) { 85 | yield effects.put( 86 | normalActions[CHANGE_LOADING_REDUCER]({ 87 | key: type, 88 | value: false 89 | }) 90 | ) 91 | } 92 | } 93 | 94 | console.error(e) 95 | } 96 | }, 97 | ...args 98 | ) 99 | }) 100 | 101 | effectActions[type] = createAction({ 102 | namespace, 103 | moduleType, 104 | actionType: type, 105 | actionKind: ACTION_KIND.EFFECT, 106 | kopFunction: generator, 107 | changeLoading: normalActions[CHANGE_LOADING_REDUCER] 108 | }) 109 | }) 110 | 111 | return { sagaFns, effectActions } 112 | } 113 | 114 | export default function createEffectsAndActions( 115 | namespace: string, 116 | normalActions, 117 | selectors, 118 | events, 119 | effectsOpt 120 | ) { 121 | const allActions = {} 122 | const effectActions = {} 123 | 124 | const sagaEffects = decorateSagaEffects(effects) 125 | 126 | const moduleEffects = effectsOpt(allActions, selectors, sagaEffects, events) 127 | const sagas: Function[] = [] 128 | 129 | forEach({ ...moduleEffects }, (value, type) => { 130 | invariant( 131 | !normalActions[type], 132 | `Module ${namespace} action ${type} duplicated` 133 | ) 134 | 135 | const actionType = getActionTypeWithNamespace(namespace, type) 136 | let generator 137 | let helper = effects.takeEvery // default helper is takeEvery 138 | let args: Array = [] 139 | 140 | if (isArray(value)) { 141 | const helperName = value[1] // effect function 142 | generator = value[0] // saga function 143 | helper = effects[helperName] 144 | 145 | invariant( 146 | helper, 147 | `Module ${namespace} effect ${type} use invalid helper ${helperName}` 148 | ) 149 | 150 | if (!isUndefined(value[2])) { 151 | args = value.slice(2) 152 | } 153 | } else { 154 | generator = value 155 | } 156 | 157 | sagas.push(function*() { 158 | // fix redux-saga's bug 159 | // https://github.com/redux-saga/redux-saga/issues/1482 160 | yield (helper as any)(actionType, generator, ...args) 161 | }) 162 | 163 | effectActions[type] = createAction({ 164 | namespace, 165 | actionType: type, 166 | actionKind: ACTION_KIND.EFFECT, 167 | kopFunction: generator 168 | }) 169 | }) 170 | 171 | extend(allActions, normalActions, effectActions) // merge actions 172 | 173 | return { 174 | effectActions, 175 | allActions, 176 | effects: sagas 177 | } 178 | } 179 | 180 | // auth check for effects 181 | export const getAuthCheck = { 182 | // RULE: cannot call other page's action 183 | [PAGE_MODULE]: (action: WrappedAction, currentNamespace: string) => { 184 | const actionModuleType = action.__moduleType 185 | const actionNamespace = action.__namespace 186 | const notOwnPageModuleAction = 187 | PAGE_MODULE === actionModuleType && currentNamespace !== actionNamespace 188 | if (notOwnPageModuleAction) { 189 | throw new Error( 190 | 'Page module can dispatch own actions, container module actions and domain module actions~' 191 | ) 192 | } 193 | 194 | return true 195 | }, 196 | // RULE: cannot call other container and page's action 197 | [CONTAINER_MODULE]: (action: WrappedAction, currentNamespace: string) => { 198 | const isPageModuleAction = PAGE_MODULE === action.__moduleType 199 | const notOwnContainerActions = 200 | CONTAINER_MODULE === action.__moduleType && 201 | currentNamespace !== action.__namespace 202 | if (isPageModuleAction || notOwnContainerActions) { 203 | throw new Error( 204 | 'Container module can only dispatch own actions or domain module actions' 205 | ) 206 | } 207 | 208 | return true 209 | }, 210 | // RULE: cannot call container, page and other domain's action 211 | [DOMAIN_MODULE]: (action: WrappedAction, currentNamespace: string) => { 212 | const isPageOrContainerModuleAction = 213 | PAGE_MODULE === action.__moduleType || 214 | CONTAINER_MODULE === action.__moduleType 215 | const notOwnDomainActions = 216 | DOMAIN_MODULE === action.__moduleType && 217 | currentNamespace !== action.__namespace 218 | if (isPageOrContainerModuleAction || notOwnDomainActions) { 219 | throw new Error('Domain module can only dispatch own actions') 220 | } 221 | }, 222 | // RULE: can only dipatch its own actions 223 | [GLOBAL_MODULE]: (action: WrappedAction, currentNamespace: string) => { 224 | const valid = 225 | GLOBAL_MODULE === action.__moduleType && 226 | currentNamespace === action.__namespace 227 | if (!valid) { 228 | throw new Error('Global module can only dispatch its own actions') 229 | } 230 | return true 231 | } 232 | } 233 | 234 | // internal method for get effects and actions 235 | function _getEffectsAndActions( 236 | namespace, 237 | normalActions, 238 | selectors, 239 | events, 240 | effectsOpt, 241 | moduleType, 242 | effectLoading 243 | ) { 244 | const allActions = {} 245 | 246 | const { enhancer, sagaEffects } = getWrappedEffectOptions( 247 | moduleType, 248 | namespace, 249 | events 250 | ) 251 | 252 | const moduleEffects = effectsOpt({ 253 | enhancer, 254 | selectors, 255 | actions: allActions, 256 | sagaEffects 257 | }) 258 | 259 | const { sagaFns, effectActions } = transformEffects({ 260 | moduleEffects, 261 | normalActions, 262 | namespace, 263 | moduleType, 264 | effectLoading 265 | }) 266 | 267 | extend(allActions, normalActions, effectActions) // merge actions 268 | 269 | return { 270 | effectActions, 271 | allActions, 272 | effects: sagaFns 273 | } 274 | } 275 | 276 | export const createPageEffects = _getEffectsAndActions 277 | export const createContainerEffects = _getEffectsAndActions 278 | export const createGlobalEffects = _getEffectsAndActions 279 | 280 | export function createDomainEffects( 281 | namespace, 282 | selectors, 283 | servicesOpt, 284 | entities, 285 | moduleType, 286 | module 287 | ) { 288 | const allActions = {} 289 | 290 | const { enhancer, sagaEffects } = getWrappedEffectOptions( 291 | moduleType, 292 | namespace, 293 | {} 294 | ) 295 | 296 | const moduleEffects = servicesOpt({ 297 | selectors, 298 | enhancer, 299 | entities, 300 | services: module.services, 301 | sagaEffects 302 | }) 303 | 304 | moduleEffects.__reset = () => { 305 | forEach(entities, v => { 306 | v.__reset() 307 | }) 308 | } 309 | 310 | const { sagaFns, effectActions } = transformEffects({ 311 | moduleEffects, 312 | normalActions: {}, 313 | namespace, 314 | moduleType, 315 | effectLoading: false 316 | }) 317 | 318 | return { 319 | actions: effectActions, 320 | effects: sagaFns 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /__tests__/module_domain_spec.ts: -------------------------------------------------------------------------------- 1 | import createApp, { createDomainModule } from '../src' 2 | 3 | describe('domain_module_test', () => { 4 | const moduleOpt = { 5 | entities: { 6 | chart: 'id' 7 | }, 8 | selectors: { 9 | getChart: ({ payload, entities }) => { 10 | const data = entities.chart.get(payload) 11 | return data 12 | } 13 | }, 14 | services: ({ entities }) => ({ 15 | // eslint-disable-next-line require-yield 16 | *add({ payload }) { 17 | entities.chart.insert(payload) 18 | } 19 | }) 20 | } 21 | 22 | test('add single module', () => { 23 | const app = createApp() 24 | const module1 = createDomainModule('module1', moduleOpt) 25 | app.addDomain(module1) 26 | app.start() 27 | expect(app._modules.module1).toBe(module1) 28 | }) 29 | 30 | test('add multi modules', () => { 31 | const app = createApp() 32 | const module1 = createDomainModule('module1', moduleOpt) 33 | const module2 = createDomainModule('module2', moduleOpt) 34 | const modules = [module1, module2] 35 | app.addDomain(modules) 36 | app.start() 37 | expect(app._modules.module1).toBe(module1) 38 | expect(app._modules.module2).toBe(module2) 39 | }) 40 | 41 | test('entity insert single', () => { 42 | const app = createApp() 43 | const module1 = createDomainModule('module1', moduleOpt) 44 | app.addDomain(module1) 45 | app.start() 46 | 47 | const chartData = { id: 'id_1', name: 'name_1' } 48 | module1.entities.chart.insert(chartData) 49 | const data = module1.entities.chart.select() 50 | expect(data).toEqual([chartData]) 51 | }) 52 | 53 | test('entity insert multi & select all & reset', () => { 54 | const app = createApp() 55 | const module1 = createDomainModule('module1', moduleOpt) 56 | app.addDomain(module1) 57 | app.start() 58 | 59 | const chartDatas = [ 60 | { id: 'id_1', name: 'name_1' }, 61 | { id: 'id_2', name: 'name_2' }, 62 | { id: 'id_3', name: 'name_3' }, 63 | { id: 'id_4', name: 'name_4' }, 64 | { id: 'id_5', name: 'name_5' } 65 | ] 66 | module1.entities.chart.insert(chartDatas) 67 | const data = module1.entities.chart.select() 68 | expect(data).toEqual(chartDatas) 69 | 70 | app._store.dispatch(module1.services.__reset()) 71 | const data2 = module1.entities.chart.select() 72 | expect(data2).toEqual([]) 73 | }) 74 | 75 | test('entity insert && update', () => { 76 | const app = createApp() 77 | const module1 = createDomainModule('module1', moduleOpt) 78 | app.addDomain(module1) 79 | app.start() 80 | 81 | const chartData1 = [ 82 | { id: 'id_1', name: 'name_1' }, 83 | { id: 'id_2', name: 'name_2' } 84 | ] 85 | const chartData2 = [ 86 | { id: 'id_1', name: 'update_1' }, 87 | { id: 'id_2', name: 'update_2' } 88 | ] 89 | module1.entities.chart.insert(chartData1) 90 | module1.entities.chart.insert(chartData2) 91 | const data = module1.entities.chart.select() 92 | 93 | expect(data).toEqual(chartData2) 94 | }) 95 | 96 | test('entity get single', () => { 97 | const app = createApp() 98 | const module1 = createDomainModule('module1', moduleOpt) 99 | app.addDomain(module1) 100 | app.start() 101 | 102 | const chartData1 = { id: 'id_1', name: 'name_1' } 103 | const chartData2 = { id: 'id_2', name: 'name_2' } 104 | module1.entities.chart.insert([chartData1, chartData2]) 105 | const data1 = module1.entities.chart.get('id_1') 106 | const data2 = module1.entities.chart.get('id_2') 107 | 108 | expect(data1).toEqual(chartData1) 109 | expect(data2).toEqual(chartData2) 110 | }) 111 | 112 | test('entity get multi', () => { 113 | const app = createApp() 114 | const module1 = createDomainModule('module1', moduleOpt) 115 | app.addDomain(module1) 116 | app.start() 117 | 118 | const chartData1 = { id: 'id_1', name: 'name_1' } 119 | const chartData2 = { id: 'id_2', name: 'name_2' } 120 | const chartDatas = [chartData1, chartData2] 121 | module1.entities.chart.insert(chartDatas) 122 | const data = module1.entities.chart.get(['id_1', 'id_2']) 123 | 124 | expect(data).toEqual(chartDatas) 125 | }) 126 | 127 | test('entity select with rule', () => { 128 | const app = createApp() 129 | const module1 = createDomainModule('module1', moduleOpt) 130 | app.addDomain(module1) 131 | app.start() 132 | 133 | const chartData1 = { id: 'id_1', name: 'name_1' } 134 | const chartData2 = { id: 'id_2', name: 'name_2' } 135 | const chartDatas = [chartData1, chartData2] 136 | module1.entities.chart.insert(chartDatas) 137 | const data = module1.entities.chart.select({ 138 | id: 'id_1' 139 | }) 140 | expect(data).toEqual([chartData1]) 141 | }) 142 | 143 | test('entity delete single', () => { 144 | const app = createApp() 145 | const module1 = createDomainModule('module1', moduleOpt) 146 | app.addDomain(module1) 147 | app.start() 148 | 149 | const chartData1 = { id: 'id_1', name: 'name_1' } 150 | const chartData2 = { id: 'id_2', name: 'name_2' } 151 | const chartDatas = [chartData1, chartData2] 152 | module1.entities.chart.insert(chartDatas) 153 | module1.entities.chart.delete('id_1') 154 | const data1 = module1.entities.chart.get('id_1') 155 | 156 | expect(data1).toBeUndefined() 157 | }) 158 | 159 | test('entity delete multi', () => { 160 | const app = createApp() 161 | const module1 = createDomainModule('module1', moduleOpt) 162 | app.addDomain(module1) 163 | app.start() 164 | 165 | const chartData1 = { id: 'id_1', name: 'name_1' } 166 | const chartData2 = { id: 'id_2', name: 'name_2' } 167 | const chartDatas = [chartData1, chartData2] 168 | module1.entities.chart.insert(chartDatas) 169 | module1.entities.chart.delete(['id_1', 'id_2']) 170 | const data = module1.entities.chart.select() 171 | 172 | expect(data).toEqual([]) 173 | }) 174 | 175 | test('entity update single', () => { 176 | const app = createApp() 177 | const module1 = createDomainModule('module1', moduleOpt) 178 | app.addDomain(module1) 179 | app.start() 180 | 181 | const chartData1 = { id: 'id_1', name: 'name_1' } 182 | const chartData2 = { id: 'id_1', name: 'update_1' } 183 | module1.entities.chart.insert(chartData1) 184 | module1.entities.chart.update(chartData2) 185 | const data = module1.entities.chart.get('id_1') 186 | 187 | expect(data).toEqual(chartData2) 188 | }) 189 | 190 | test('entity update multi', () => { 191 | const app = createApp() 192 | const module1 = createDomainModule('module1', moduleOpt) 193 | app.addDomain(module1) 194 | app.start() 195 | 196 | const chartDatas1 = [ 197 | { id: 'id_1', name: 'name_1' }, 198 | { id: 'id_2', name: 'name_2' } 199 | ] 200 | const chartDatas2 = [ 201 | { id: 'id_1', name: 'update_1' }, 202 | { id: 'id_2', name: 'update_2' } 203 | ] 204 | module1.entities.chart.insert(chartDatas1) 205 | module1.entities.chart.update(chartDatas2) 206 | const data = module1.entities.chart.select() 207 | 208 | expect(data).toEqual(chartDatas2) 209 | }) 210 | 211 | test('entity update non-existent', () => { 212 | const app = createApp() 213 | const module1 = createDomainModule('module1', moduleOpt) 214 | app.addDomain(module1) 215 | app.start() 216 | 217 | const chartData1 = { id: 'id_1', name: 'name_1' } 218 | module1.entities.chart.update(chartData1) 219 | const data = module1.entities.chart.get('id_1') 220 | 221 | expect(data).toBeUndefined() 222 | }) 223 | 224 | test('entity clear', () => { 225 | const app = createApp() 226 | const module1 = createDomainModule('module1', moduleOpt) 227 | app.addDomain(module1) 228 | app.start() 229 | 230 | const chartDatas = [ 231 | { id: 'id_1', name: 'name_1' }, 232 | { id: 'id_2', name: 'name_2' }, 233 | { id: 'id_3', name: 'name_3' }, 234 | { id: 'id_4', name: 'name_4' }, 235 | { id: 'id_5', name: 'name_5' } 236 | ] 237 | module1.entities.chart.insert(chartDatas) 238 | module1.entities.chart.clear() 239 | const data = module1.entities.chart.select() 240 | 241 | expect(data).toEqual([]) 242 | }) 243 | 244 | test('selector', () => { 245 | const app = createApp() 246 | const module1 = createDomainModule('module1', moduleOpt) 247 | app.addDomain(module1) 248 | app.start() 249 | 250 | const chartData1 = { id: 'id_1', name: 'name_1' } 251 | module1.entities.chart.insert(chartData1) 252 | 253 | const data = module1.selectors.getChart('id_1' as any) 254 | expect(data).toEqual(chartData1) 255 | }) 256 | 257 | test('service', async () => { 258 | const app = createApp() 259 | const module1 = createDomainModule('module1', moduleOpt) 260 | app.addDomain(module1) 261 | app.start() 262 | 263 | const chartData1 = { id: 'id_1', name: 'name_1' } 264 | app._store.dispatch(module1.services.add(chartData1)) 265 | const data = module1.entities.chart.get('id_1') 266 | 267 | expect(data).toEqual(chartData1) 268 | }) 269 | 270 | test('auth check', () => { 271 | let error = null 272 | const app = createApp() 273 | const module1 = createDomainModule('module1', { 274 | entities: { 275 | chart: 'id' 276 | }, 277 | services: ({ entities }) => ({ 278 | // eslint-disable-next-line require-yield 279 | *add({ payload }) { 280 | entities.chart.insert(payload) 281 | } 282 | }) 283 | }) 284 | const module2 = createDomainModule('module2', { 285 | entities: { 286 | chart: 'id' 287 | }, 288 | services: ({ sagaEffects: { put } }) => ({ 289 | *add({ payload }) { 290 | try { 291 | // here call actions of another domain module, so it should throw error 292 | // in development environment. 293 | yield put(module1.services.add(payload)) 294 | } catch (e) { 295 | error = e 296 | } 297 | } 298 | }) 299 | }) 300 | const modules = [module1, module2] 301 | app.addDomain(modules) 302 | app.start() 303 | 304 | app._store.dispatch(module2.services.add({ id: 1, name: 'a' })) 305 | expect(error).not.toBeNull() 306 | }) 307 | }) 308 | -------------------------------------------------------------------------------- /__tests__/module_page_spec.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import createApp, { createPageModule, createContainerModule } from '../src' 3 | import { GET_LOADING_SELECTOR } from '../src/const' 4 | 5 | describe('page_module_test', () => { 6 | const pageOpt = { 7 | effectLoading: true, 8 | initialState: { 9 | count: 1 10 | }, 11 | selectors: { 12 | getCount: state => state.count, 13 | getIncreasedCount: (state, { payload }) => state.count + payload 14 | }, 15 | reducers: { 16 | initCount(state, { payload }) { 17 | return { 18 | ...state, 19 | count: payload 20 | } 21 | } 22 | }, 23 | effects: ({ actions, sagaEffects: { put, call } }) => ({ 24 | *fetchCount({ payload }) { 25 | yield put(actions.initCount(payload)) 26 | }, 27 | *sleep({ payload }) { 28 | const sleepFn = (duration: number) => 29 | new Promise(resove => { 30 | setTimeout(() => { 31 | resove() 32 | }, duration) 33 | }) 34 | yield call(sleepFn, payload) 35 | } 36 | }) 37 | } 38 | 39 | test('single module & getStore', () => { 40 | const module1 = createPageModule('module1', pageOpt) 41 | 42 | const app = createApp() 43 | app.addPage(module1) 44 | app.start() 45 | 46 | expect(app._modules.module1).toBe(module1) 47 | expect(typeof app.getStore().dispatch).toBe('function') 48 | }) 49 | 50 | test('multi modules', () => { 51 | const module1 = createPageModule('module1', pageOpt) 52 | const module2 = createPageModule('module2', pageOpt) 53 | 54 | const app = createApp() 55 | app.addPage([module1, module2]) 56 | app.start() 57 | 58 | expect(app._modules.module1).toBe(module1) 59 | expect(app._modules.module2).toBe(module2) 60 | }) 61 | 62 | test('selector', () => { 63 | const module1 = createPageModule('module1', pageOpt) 64 | const app = createApp() 65 | app.addPage(module1) 66 | app.start() 67 | 68 | const increateCount = 100 69 | // selector 70 | const data1 = module1.selectors.getCount() 71 | // selector with arg 72 | const data2 = module1.selectors.getIncreasedCount(increateCount) 73 | expect(data2 - data1).toBe(increateCount) 74 | }) 75 | 76 | test('reducer & selector', () => { 77 | const module1 = createPageModule('module1', pageOpt) 78 | const app = createApp() 79 | app.addPage(module1) 80 | app.start() 81 | 82 | const initCount = 101 83 | app._store.dispatch(module1.actions.initCount(initCount)) 84 | const data = module1.selectors.getCount() 85 | expect(data).toBe(initCount) 86 | }) 87 | 88 | test('effect & reducer & selector', () => { 89 | const module1 = createPageModule('module1', pageOpt) 90 | const app = createApp() 91 | app.addPage(module1) 92 | app.start() 93 | 94 | const initCount = 102 95 | app._store.dispatch(module1.actions.fetchCount(initCount)) 96 | const data = module1.selectors.getCount(app._store.getState()) 97 | expect(data).toBe(initCount) 98 | }) 99 | 100 | test('inject & getGlobalState & getModuleState & watchers & removeModule', () => { 101 | const initCount = 103 102 | const eventName = 'test_event' 103 | const containerModule = createContainerModule('container1', { 104 | selectors: { 105 | getCount: (state, { pageSelectors }) => pageSelectors.getCount() 106 | }, 107 | effects: ({ enhancer: { emit } }) => ({ 108 | *addCountEvent({ payload }) { 109 | yield emit({ name: eventName, payload }) 110 | }, 111 | *fetchCount() { 112 | // do nothing 113 | }, 114 | *fetchCount2() { 115 | // do nothing 116 | } 117 | }) 118 | }) 119 | 120 | const pageNamespace = 'page1' 121 | const pageModule = createPageModule(pageNamespace, { 122 | initialState: { 123 | count: initCount 124 | }, 125 | selectors: { 126 | getCount: state => state.count 127 | }, 128 | reducers: { 129 | addCount: (state, { payload }) => ({ 130 | ...state, 131 | count: state.count + payload 132 | }), 133 | setCount: (state, { payload }) => ({ 134 | ...state, 135 | count: payload 136 | }) 137 | }, 138 | injectModules: [containerModule], 139 | 140 | effects: ({ actions, sagaEffects: { put } }) => ({ 141 | *fetchCount({ payload }) { 142 | yield put(actions.setCount(payload)) 143 | } 144 | }), 145 | watchers: ({ actions, sagaEffects: { put } }) => ({ 146 | *[containerModule.event(eventName)]({ payload }) { 147 | yield put(actions.addCount(payload)) 148 | }, 149 | // watch both actions of container module 150 | [`${containerModule.actions.fetchCount},${containerModule.actions.fetchCount2}`]: [ 151 | function*({ payload }) { 152 | yield put(actions.fetchCount(payload)) 153 | }, 154 | 'takeLatest' 155 | ] 156 | }) 157 | }) 158 | const app = createApp() 159 | app.addPage(pageModule, { 160 | containers: [containerModule] 161 | }) 162 | app.start() 163 | 164 | const data1 = containerModule.selectors.getCount( 165 | app._store.getState(), 166 | null 167 | ) 168 | expect(data1).toBe(initCount) 169 | 170 | // Note: `getGlobalState` is a builtin selector in KOP which returns 171 | // global state instead of module(local) state. 172 | const globalState = pageModule.selectors.getGlobalState() 173 | expect(globalState[pageNamespace].count).toEqual(initCount) 174 | 175 | // Note: `getModuleState` is also a builtin selector in KOP which returns 176 | // module(local) state. 177 | const moduleState = pageModule.selectors.getModuleState() 178 | expect(moduleState.count).toEqual(initCount) 179 | 180 | app._store.dispatch(containerModule.actions.addCountEvent(1)) 181 | const data2 = pageModule.selectors.getCount() 182 | expect(data2).toBe(initCount + 1) 183 | 184 | app._store.dispatch(containerModule.actions.fetchCount(initCount + 2)) 185 | const data3 = pageModule.selectors.getCount() 186 | expect(data3).toBe(initCount + 2) 187 | 188 | app._store.dispatch(containerModule.actions.fetchCount2(initCount + 3)) 189 | const data4 = pageModule.selectors.getCount() 190 | expect(data4).toBe(initCount + 3) 191 | 192 | expect(app._modules[pageModule.namespace]).toBe(pageModule) 193 | app.removeModule(pageModule) 194 | expect(app._modules[pageModule.namespace]).toBeUndefined() 195 | }) 196 | 197 | test('createApp with onError', async () => { 198 | const initCount = 103 199 | const eventName = 'test_event' 200 | let caughtError: Error | null = null 201 | const watcherErrorMsg = 'watcher error' 202 | const containerModule = createContainerModule('container1', { 203 | selectors: { 204 | getCount: (state, { pageSelectors }) => pageSelectors.getCount() 205 | }, 206 | effects: ({ enhancer: { emit } }) => ({ 207 | *addCountEvent({ payload }) { 208 | yield emit({ name: eventName, payload }) 209 | } 210 | }) 211 | }) 212 | const pageModule = createPageModule('page1', { 213 | initialState: { 214 | count: initCount 215 | }, 216 | selectors: { 217 | getCount: state => state.count 218 | }, 219 | reducers: { 220 | addCount: (state, { payload }) => ({ 221 | ...state, 222 | count: state.count + payload 223 | }) 224 | }, 225 | injectModules: [containerModule], 226 | effects: () => ({}), 227 | watchers: () => ({ 228 | // eslint-disable-next-line require-yield 229 | *[containerModule.event(eventName)]() { 230 | throw new Error(watcherErrorMsg) 231 | } 232 | }) 233 | }) 234 | 235 | const app = createApp({ 236 | onError: (e: Error) => { 237 | caughtError = e 238 | } 239 | }) 240 | app.addPage(pageModule, { 241 | containers: [containerModule] 242 | }) 243 | app.start() 244 | 245 | expect(caughtError).toBeNull() 246 | app._store.dispatch(containerModule.actions.addCountEvent(1)) 247 | expect(caughtError.message).toBe(watcherErrorMsg) 248 | }) 249 | 250 | test('getLoading selector', async () => { 251 | const module1 = createPageModule('module1', pageOpt) 252 | const app = createApp() 253 | app.addPage(module1) 254 | app.start() 255 | 256 | const duration = 1000 257 | app._store.dispatch(module1.actions.sleep(duration)) 258 | let loadings = module1.selectors[GET_LOADING_SELECTOR]() 259 | expect(loadings.sleep).toBe(true) 260 | 261 | loadings = await new Promise(resolve => { 262 | setTimeout(() => { 263 | resolve(module1.selectors[GET_LOADING_SELECTOR]()) 264 | }, duration * 2) 265 | }) 266 | 267 | expect(loadings.sleep).toBe(false) 268 | }) 269 | 270 | test('auth check', () => { 271 | let error = null 272 | const module1 = createPageModule('module1', { 273 | effects: () => ({ 274 | *fetchCount() { 275 | // do nothing 276 | } 277 | }) 278 | }) 279 | const module2 = createPageModule('module2', { 280 | effects: ({ sagaEffects: { put } }) => ({ 281 | *fetchCount() { 282 | try { 283 | // here call actions of another page module, so it should throw error 284 | // in development environment. 285 | yield put(module1.actions.fetchCount()) 286 | } catch (e) { 287 | error = e 288 | } 289 | } 290 | }) 291 | }) 292 | 293 | const app = createApp() 294 | app.addPage([module1, module2]) 295 | app.start() 296 | 297 | app._store.dispatch(module2.actions.fetchCount(1)) 298 | expect(error).not.toBeNull() 299 | }) 300 | 301 | test('effect throw error', () => { 302 | const module1 = createPageModule('module1', { 303 | effectLoading: true, 304 | effects: () => ({ 305 | // eslint-disable-next-line require-yield 306 | *fetchCount() { 307 | throw new Error('effect error') 308 | } 309 | }) 310 | }) 311 | const app = createApp() 312 | app.addPage(module1) 313 | app.start() 314 | 315 | const originConsoleError = console.error 316 | // Because fetchCount effect throw an error, the error is logged by 317 | // `console.error` and we do not want to see this error in the output, 318 | // so change `console.error` and restore it later. 319 | console.error = _.noop 320 | app._store.dispatch(module1.actions.fetchCount()) 321 | console.error = originConsoleError 322 | const loadings = module1.selectors[GET_LOADING_SELECTOR]() 323 | expect(loadings.fetchCount).toBe(false) 324 | }) 325 | }) 326 | -------------------------------------------------------------------------------- /src/createApp.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | 5 | import createSagaMiddleware, { SagaMiddleware, effects } from 'redux-saga' 6 | 7 | import { 8 | applyMiddleware, 9 | createStore, 10 | compose, 11 | combineReducers, 12 | Middleware, 13 | Action, 14 | Reducer, 15 | Store, 16 | AnyAction 17 | } from 'redux' 18 | import { 19 | isString, 20 | isFunction, 21 | isPlainObject, 22 | isArray, 23 | isElement, 24 | reduce, 25 | set, 26 | mapValues, 27 | noop, 28 | forEach, 29 | pickBy, 30 | isEmpty, 31 | extend, 32 | get, 33 | cloneDeepWith 34 | } from 'lodash' 35 | import invariant from 'invariant' 36 | import { DOMAIN_MODULE, ENTITY_MODULE, KOP_GLOBAL_STORE_REF } from './const' 37 | import defaultMiddleWares from './middlewares' 38 | import { isModule, isPresenter, toStorePath, hasDuplicatedKeys } from './utils' 39 | import initSelectorHelper from './module/options/selector' 40 | import { 41 | Presenter, 42 | Module, 43 | Modules, 44 | App, 45 | ReducerHandler, 46 | CreateOpt, 47 | PageOption, 48 | ParentNode 49 | } from './types/createApp' 50 | import { DefaultReducer } from './types/common' 51 | 52 | // global store ref 53 | let _store: Store 54 | 55 | export default function createApp(createOpt: CreateOpt = {}) { 56 | let app: App 57 | const { initialReducer, onError } = createOpt 58 | 59 | // internal map for modules 60 | const _modules: Modules = {} 61 | 62 | let _router = 63 | 64 | const _middleWares: Middleware<{}>[] = [...defaultMiddleWares] 65 | const sagaMiddleware: SagaMiddleware<{}> = createSagaMiddleware() 66 | 67 | _middleWares.push(sagaMiddleware) 68 | 69 | function hasSubModule(module: Module) { 70 | let flag = false 71 | forEach(module, value => { 72 | if (isModule(value)) { 73 | flag = true 74 | } 75 | }) 76 | return flag 77 | } 78 | 79 | function _addModule(m: Module) { 80 | invariant(!_modules[m.namespace], `kop nodule ${m.namespace} exists`) 81 | 82 | if (!isEmpty(m.entities)) { 83 | forEach(m.entities, e => { 84 | _addModule(e) 85 | }) 86 | } 87 | _modules[m.namespace] = m 88 | } 89 | 90 | // create reducer 91 | // http://redux.js.org/docs/recipes/reducers/RefactoringReducersExample.html 92 | function createReducer( 93 | initialState = {}, 94 | handlers: ReducerHandler, 95 | defaultReducer?: DefaultReducer 96 | ) { 97 | return (state = initialState, action: Action) => { 98 | if ( 99 | Object.prototype.hasOwnProperty.call(handlers, action.type) && 100 | isFunction(handlers[action.type]) 101 | ) { 102 | const handler = handlers[action.type] 103 | return handler(state, action) 104 | } 105 | if (defaultReducer && isFunction(defaultReducer)) { 106 | return defaultReducer(state, action) 107 | } 108 | return state 109 | } 110 | } 111 | 112 | function loopCombineReducer( 113 | tree: any, 114 | combineRootreducer = true, 115 | parentNode?: string | ParentNode 116 | ) { 117 | const childReducer: any = mapValues(tree, node => { 118 | if (!isModule(node)) { 119 | return loopCombineReducer(node) 120 | } 121 | 122 | if (hasSubModule(node)) { 123 | const subModuleMap = pickBy(node, value => isModule(value)) 124 | return loopCombineReducer(subModuleMap, true, node) 125 | } 126 | 127 | return createReducer( 128 | node._initialState, 129 | node._reducers, 130 | node._defaultReducer 131 | ) 132 | }) 133 | 134 | let result 135 | 136 | if (isEmpty(parentNode)) { 137 | result = { 138 | ...childReducer 139 | } 140 | } else if (parentNode === 'root') { 141 | invariant( 142 | !initialReducer || isPlainObject(initialReducer), 143 | 'initialReducer should be object' 144 | ) 145 | const noDuplicatedKeys = !hasDuplicatedKeys( 146 | initialReducer, 147 | childReducer, 148 | 'router' 149 | ) 150 | invariant( 151 | noDuplicatedKeys, 152 | 'initialReducer has reduplicate keys with other reducers' 153 | ) 154 | 155 | result = { 156 | ...initialReducer, 157 | ...childReducer 158 | } 159 | } else { 160 | result = { 161 | base: createReducer( 162 | (parentNode as ParentNode)._initialState, 163 | (parentNode as ParentNode)._reducers, 164 | (parentNode as ParentNode)._defaultReducer 165 | ), 166 | ...childReducer 167 | } 168 | } 169 | 170 | if (parentNode === 'root' && !combineRootreducer) return result 171 | 172 | return combineReducers(result) 173 | } 174 | 175 | function addModule(module: Module | Module[]) { 176 | if (isArray(module)) { 177 | module.forEach(m => { 178 | _addModule(m) 179 | }) 180 | } else { 181 | _addModule(module) 182 | } 183 | } 184 | 185 | function initInjectModules(presenter: Presenter) { 186 | forEach(presenter.injectModules, (name: string) => { 187 | invariant(_modules[name], `please check the kop-module ${name} is added`) 188 | extend(_modules[name].presenter, { 189 | loaded: true, // 标记已被装载,在module中会注入 presentor 的 seletor 190 | selectors: presenter.selectors, 191 | actions: presenter.actions 192 | }) 193 | }) 194 | } 195 | 196 | function createRootReducer(combineRootreducer = true) { 197 | const moduleData = cloneDeepWith(_modules) 198 | const moduleTree = reduce( 199 | moduleData, 200 | (result, value, key) => { 201 | const module = get(result, toStorePath(key)) 202 | 203 | if (isModule(value)) { 204 | if (module) { 205 | return set(result, `${toStorePath(key)}.base`, value) 206 | } 207 | return set(result, toStorePath(key), value) 208 | } 209 | 210 | return result 211 | }, 212 | {} 213 | ) 214 | 215 | return loopCombineReducer(moduleTree, combineRootreducer, 'root') 216 | } 217 | 218 | function addPage(pageModule: Module, opt: PageOption = {}) { 219 | const { containers } = opt 220 | 221 | if (containers && containers.length > 0) { 222 | addModule(containers) 223 | } 224 | 225 | if (pageModule.injectModules && pageModule.injectModules.length > 0) { 226 | initInjectModules(pageModule) 227 | } 228 | 229 | addModule(pageModule) 230 | } 231 | 232 | function addDomain(module: Module) { 233 | addModule(module) 234 | } 235 | 236 | const addGlobal = _addModule 237 | 238 | function removeModule(module: Module | Module[]) { 239 | const _remove = (m: Module) => { 240 | invariant( 241 | _modules[m.namespace], 242 | `error: the kop-module - ${m.namespace} is not existed` 243 | ) 244 | 245 | // hack redux-devtools's bug 246 | if ( 247 | m && 248 | m.actions && 249 | !isPresenter(m) && 250 | m.type !== DOMAIN_MODULE && 251 | (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 252 | ) { 253 | _store.dispatch(m.actions.__reset()) 254 | } 255 | 256 | delete _modules[m.namespace] 257 | _store.dispatch({ type: `${m.namespace}/@@KOP_CANCEL_EFFECTS` }) 258 | } 259 | 260 | if (isArray(module)) { 261 | module.forEach(m => { 262 | _remove(m) 263 | }) 264 | _store.replaceReducer(createRootReducer() as Reducer) 265 | } else if (module) { 266 | _remove(module) 267 | _store.replaceReducer(createRootReducer() as Reducer) 268 | } 269 | } 270 | 271 | function addRouter(r: JSX.Element) { 272 | _router = r 273 | } 274 | 275 | function addMiddleWare(middleWare: Middleware) { 276 | const add = (m: Middleware) => { 277 | _middleWares.push(m) 278 | } 279 | 280 | add(middleWare) 281 | } 282 | 283 | function getStore() { 284 | return _store 285 | } 286 | 287 | function createAppStore() { 288 | // inject chrome redux devtools 289 | let composeEnhancers 290 | 291 | if ( 292 | process.env.NODE_ENV !== 'production' && 293 | (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 294 | ) { 295 | composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 296 | } else { 297 | composeEnhancers = compose 298 | } 299 | 300 | const enhancer = composeEnhancers(applyMiddleware(..._middleWares)) 301 | const rootReducer = createRootReducer() 302 | return createStore(rootReducer as Reducer, enhancer) 303 | } 304 | 305 | function getArguments(...args: any[]) { 306 | let domSelector: string | null = null 307 | let callback = noop 308 | 309 | if (args.length === 1) { 310 | // app.start(false) means jump render phase and return early 311 | if (args[0] === false) { 312 | return { 313 | domSelector: '', 314 | callback: noop, 315 | shouldRender: false 316 | } 317 | } 318 | if (isString(args[0])) { 319 | domSelector = args[0] 320 | } 321 | if (isFunction(args[0])) { 322 | callback = args[0] 323 | } 324 | } 325 | 326 | if (args.length === 2) { 327 | domSelector = args[0] 328 | callback = args[1] 329 | } 330 | 331 | return { 332 | domSelector, 333 | callback, 334 | shouldRender: true 335 | } 336 | } 337 | 338 | function renderAppElement( 339 | domSelector: string | null, 340 | callback: Function, 341 | shouldRender: boolean 342 | ) { 343 | const $elem = {_router} 344 | 345 | // skip render when shouldRender is false 346 | if (shouldRender) { 347 | if (isString(domSelector)) { 348 | render($elem, document.querySelector(domSelector), () => { 349 | callback() 350 | }) 351 | } else if (isElement(domSelector)) { 352 | render($elem, domSelector, () => { 353 | callback() 354 | }) 355 | } else { 356 | callback() 357 | } 358 | } 359 | 360 | return $elem 361 | } 362 | 363 | function _onError(e: Error) { 364 | if (!onError) { 365 | console.error(e) 366 | } else { 367 | onError(e) 368 | } 369 | } 370 | 371 | // create root saga 372 | function createSaga(module: Module) { 373 | return function*() { 374 | if (isArray(module.effects)) { 375 | for (let i = 0, k = module.effects.length; i < k; i++) { 376 | const task = yield effects.fork(function*(): any { 377 | try { 378 | if (module.effects) { 379 | yield module.effects[i]() 380 | } 381 | } catch (e) { 382 | _onError(e) 383 | } 384 | }) 385 | 386 | yield effects.fork(function*() { 387 | yield effects.take(`${module.namespace}/@@KOP_CANCEL_EFFECTS`) 388 | yield effects.cancel(task) 389 | }) 390 | } 391 | } 392 | 393 | if (isArray(module.watchers)) { 394 | for (let i = 0, k = module.watchers.length; i < k; i++) { 395 | const task = yield effects.fork(function*(): any { 396 | try { 397 | if (module.watchers) { 398 | yield module.watchers[i]() 399 | } 400 | } catch (e) { 401 | _onError(e) 402 | } 403 | }) 404 | yield effects.fork(function*() { 405 | yield effects.take(`${module.namespace}/@@KOP_CANCEL_EFFECTS`) 406 | yield effects.cancel(task) 407 | }) 408 | } 409 | } 410 | } 411 | } 412 | 413 | function start(...args: any[]) { 414 | const { domSelector, callback, shouldRender } = getArguments(...args) 415 | 416 | _store = createAppStore() 417 | 418 | initSelectorHelper(_store) 419 | 420 | app._store = _store 421 | 422 | window[KOP_GLOBAL_STORE_REF] = _store 423 | 424 | app._modules = _modules 425 | 426 | forEach(app._modules, (m: Module) => { 427 | if (m.type === ENTITY_MODULE) { 428 | m._store = _store // 提供domainModel的遍历action 429 | } 430 | }) 431 | 432 | app._middleWares = _middleWares 433 | 434 | app._router = _router 435 | 436 | forEach(_modules, module => { 437 | sagaMiddleware.run(createSaga(module)) 438 | }) 439 | 440 | return renderAppElement(domSelector, callback, shouldRender) 441 | } 442 | 443 | // 集成模式初始化,返回 saga,reducer 等等 444 | function _init() { 445 | const rootReducer = createRootReducer(false) 446 | return { 447 | rootReducer, 448 | sagaMiddleware 449 | } 450 | } 451 | 452 | // 集成模式启动,由外部 Redux 控制 App 渲染流程 453 | function _run(store: Store) { 454 | initSelectorHelper(store) 455 | app._store = store 456 | window[KOP_GLOBAL_STORE_REF] = store 457 | app._modules = _modules 458 | forEach(app._modules, (m: Module) => { 459 | if (m.type === ENTITY_MODULE) { 460 | m._store = store 461 | } 462 | }) 463 | app._router = _router 464 | forEach(_modules, module => { 465 | sagaMiddleware.run(createSaga(module)) 466 | }) 467 | } 468 | 469 | app = { 470 | addModule, // register container module 471 | addPage, // register page module 472 | addDomain, // register domain module 473 | addGlobal, // register global module 474 | addRouter, 475 | addMiddleWare, 476 | start, 477 | getStore, 478 | removeModule, 479 | _init, 480 | _run 481 | } 482 | 483 | return app 484 | } 485 | --------------------------------------------------------------------------------