├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── _config.yml ├── example.config.js ├── example ├── index.html └── src │ ├── ProfileForm │ ├── ProfileController.ts │ ├── ProfileForm.tsx │ ├── UserModel.ts │ ├── actions.ts │ ├── common.ts │ └── index.ts │ ├── app.tsx │ └── utils.ts ├── jest.config.js ├── mvc.jpg ├── package-lock.json ├── package.json ├── src ├── Collection.ts ├── Controller.ts ├── DataModel.ts ├── Model.ts ├── ReactReduxMvc.ts ├── helpers.ts ├── index.ts └── withController.ts ├── tests ├── Collection.spec.ts ├── Controller.spec.ts ├── Model.spec.ts └── withController.spec.tsx ├── tsconfig.json ├── tslint.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib/ 3 | npm-debug.log 4 | /.idea/ 5 | /example/example.js.map 6 | /example/example.js 7 | /_tscache/**/* 8 | /coverage/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Vitalii Vorobioff 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-redux-mvc 2 | Implementation of MVC pattrern based on React-Redux bunch keeping one direction data flow (flux pattern) 3 | 4 | 5 | ![alt tag](https://github.com/welljs/react-redux-mvc/blob/master/mvc.jpg) 6 | 7 | index.ts (entry point of application) 8 | 9 | ```javascript 10 | import React from 'react'; 11 | import ReactDOM from 'react-dom'; 12 | import { createStore as reduxCreateStore, applyMiddleware } from 'redux'; 13 | import { Provider } from 'react-redux'; 14 | import {combine as combineReducers, middleware as requestMiddleware} from 'easy-redux'; 15 | import {ProfileForm} from './ProfileForm'; 16 | import {ReactReduxMvc} from 'react-redux-mvc'; 17 | 18 | /* 19 | * in place of this helper can be superagent.js for example, or other asynс function. It is used by middleware to 20 | * support async actions functionality 21 | * */ 22 | function promiseHelper (data) { 23 | return new Promise((resolve, reject) => { 24 | setTimeout(() => { 25 | resolve(data) 26 | }, 3000); 27 | }); 28 | } 29 | 30 | 31 | const appStore = function () { 32 | return applyMiddleware(requestMiddleware(promiseHelper))(reduxCreateStore)(combineReducers({}), {}); 33 | }(); 34 | 35 | ReactDOM.render( 36 | 37 | 38 | 39 | 40 | , document.getElementById('content') 41 | ); 42 | 43 | ``` 44 | 45 | 46 | ProfileForm.js (View) 47 | ```javascript 48 | import React, { Component, PropTypes } from 'react'; 49 | import {withController} from 'react-redux-mvc'; 50 | import ProfileController from './ProfileController'; 51 | import {STORE_KEY} from './common'; 52 | 53 | @withController(ProfileController) 54 | export default class ProfileForm extends Component { 55 | render () { 56 | const { 57 | [STORE_KEY]: { 58 | userData, 59 | userData: { 60 | firstName, 61 | lastName, 62 | age, 63 | department, 64 | phone, 65 | email 66 | }, 67 | isSaved 68 | } 69 | } = this.props; 70 | const isSubmitWaiting = this.controller.isSubmitWaiting(); 71 | return ( 72 |
73 | {isSaved &&

Data saved!

} 74 |
this.controller.onSubmit(userData, e)}> 75 | this.controller.updateUserData('firstName', e.target.value)} placeholder="First name"/>
76 | this.controller.updateUserData('lastName', e.target.value)} placeholder="Last name"/>
77 | this.controller.updateUserData('age', e.target.value)} placeholder="Age"/>
78 | this.controller.updateUserData('email', e.target.value)} placeholder="Email"/>
79 | this.controller.updateUserData('phone', e.target.value)} placeholder="Phone"/>
80 | this.controller.updateUserData('department', e.target.value)} placeholder="Department"/>
81 | 82 |
83 |
84 | ); 85 | } 86 | } 87 | ``` 88 | 89 | ProfileController.js 90 | ```javascript 91 | import {PropTypes} from 'react'; 92 | import {Controller} from 'react-redux-mvc'; 93 | import {STORE_KEY, ACTION_UPDATE_PROFILE, ASYNC_ACTION_SUBMIT_PROFILE} from './common'; 94 | import actions from './actions'; 95 | import UserModel from './UserModel'; 96 | 97 | export default class ProfileController extends Controller { 98 | static storeKey = STORE_KEY; 99 | static actions = actions; 100 | static propTypes = UserModel.shape; 101 | static connectedState = [STORE_KEY]; 102 | 103 | constructor () { 104 | super(UserModel); 105 | } 106 | 107 | updateUserData = (prop, value) => { 108 | this.updateProp('userData', {[prop]: value}); 109 | }; 110 | 111 | updateProp = (prop, value) => { 112 | this.action(ACTION_UPDATE_PROFILE, {[prop]: value}); 113 | }; 114 | 115 | submit = (userData, e) => { 116 | e.preventDefault(); 117 | this.action(ASYNC_ACTION_SUBMIT_PROFILE, userData); 118 | }; 119 | 120 | isSubmitWaiting = () => { 121 | this.isWaiting(ASYNC_ACTION_SUBMIT_PROFILE) 122 | }; 123 | } 124 | ``` 125 | 126 | UserModel.js 127 | 128 | ```javascript 129 | import {PropTypes} from 'react'; 130 | import {Model} from 'react-redux-mvc'; 131 | import {ASYNC_ACTION_SUBMIT_PROFILE} from './common'; 132 | 133 | const {number, string, bool} = PropTypes; 134 | 135 | export default class UserModel extends Model { 136 | static shape = { 137 | userData: PropTypes.shape({ 138 | firstName: string, 139 | lastName: string, 140 | age: number, 141 | phone: string, 142 | email: string.isRequired, 143 | department: string 144 | }), 145 | errorMsg: string, 146 | isSaved: bool 147 | }; 148 | static defaults = { 149 | userData: { 150 | firstName: '', 151 | lastName: '', 152 | age: null, 153 | department: '', 154 | email: '', 155 | phone: '' 156 | }, 157 | errorMsg: null, 158 | isSaved: false 159 | }; 160 | constructor(state){ 161 | super(state); 162 | } 163 | 164 | onUpdate (updates) { 165 | return this.update(updates).getState(); 166 | } 167 | 168 | onSubmitWaiting () { 169 | return this 170 | .update({isSaved: false}) 171 | .setWaiting(ASYNC_ACTION_SUBMIT_PROFILE) 172 | .resetFailed(ASYNC_ACTION_SUBMIT_PROFILE) 173 | .getState(); 174 | } 175 | 176 | onSubmitFailed (errorMsg) { 177 | return this 178 | .update({errorMsg}) 179 | .setWaiting(ASYNC_ACTION_SUBMIT_PROFILE) 180 | .resetFailed(ASYNC_ACTION_SUBMIT_PROFILE) 181 | .getState(); 182 | } 183 | 184 | onSubmitComplete (updates) { 185 | return this 186 | .update({userData: updates, isSaved: true }) 187 | .resetWaiting(ASYNC_ACTION_SUBMIT_PROFILE) 188 | .getState(); 189 | } 190 | } 191 | ``` 192 | 193 | actions.js 194 | ```javascript 195 | import {createAction} from 'easy-redux'; 196 | import {STORE_KEY, ACTION_UPDATE_PROFILE, ASYNC_ACTION_SUBMIT_PROFILE} from './common'; 197 | import UserModel from './UserModel'; 198 | 199 | const initialState = Object.assign({}, UserModel.defaults); 200 | export default { 201 | [ASYNC_ACTION_SUBMIT_PROFILE]: createAction(ASYNC_ACTION_SUBMIT_PROFILE, { 202 | async: true, 203 | storeKey: STORE_KEY, 204 | initialState, 205 | action: (userData) => ({ 206 | promise: asyncExec => asyncExec(userData) 207 | }), 208 | handlers: { 209 | onWait: state => new UserModel(state).onSubmitWaiting(), 210 | onFail: (state, {error}) => new UserModel(state).onSubmitFailed(error), 211 | onSuccess: (state, {result}) => new UserModel(state).onSubmitComplete(result) 212 | } 213 | }), 214 | [ACTION_UPDATE_PROFILE]: createAction(ACTION_UPDATE_PROFILE, { 215 | storeKey: STORE_KEY, 216 | initialState, 217 | action: (updates) => ({updates}), 218 | handler: (state, {updates}) => new UserModel(state).onUpdate(updates) 219 | }) 220 | }; 221 | ``` 222 | 223 | Installation 224 | ------------ 225 | 226 | `npm i react-redux-mvc -S` 227 | 228 | 229 | Example 230 | ------ 231 | 232 | Для того, чтобы посмотреть рабочий пример, нужно собрать его командой `npm run example` из корневой директории проекта, затем открыть файл `./example/index.html` Пример работает локально, без веб-сервера 233 | 234 | 235 | Documentation wiki 236 | ------- 237 | 238 | [Overview](https://github.com/welljs/react-redux-mvc/wiki/Overview) 239 | 240 | [Model](https://github.com/welljs/react-redux-mvc/wiki/Model) 241 | 242 | [View](https://github.com/welljs/react-redux-mvc/wiki/View) 243 | 244 | [Controller](https://github.com/welljs/react-redux-mvc/wiki/Controller) 245 | 246 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-architect -------------------------------------------------------------------------------- /example.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | //Execute npm run build, and set this flag to true to use source version 5 | const useSources = false; 6 | 7 | const resolve = { 8 | alias: {}, 9 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 10 | }; 11 | const include = []; 12 | 13 | if (useSources) { 14 | resolve.alias['react-redux-mvc'] = path.join(__dirname, './src/index.ts'); 15 | include.push(path.resolve(__dirname, `./example/src`)); 16 | include.push(path.resolve(__dirname, `./src`)); 17 | console.log(`\n> compile using sources...\n`); 18 | } 19 | else { 20 | resolve.alias['react-redux-mvc'] = path.join(__dirname, './lib/index.ts'); 21 | include.push(path.resolve(__dirname, `./example/src`)); 22 | include.push(path.resolve(__dirname, `./lib`)); 23 | console.log('\n> compile using production lib...\n'); 24 | } 25 | 26 | module.exports = { 27 | context: path.join(__dirname, './'), 28 | devtool: 'source-map', 29 | entry: './example/src/app.tsx', 30 | output: { 31 | path: path.resolve(__dirname, `./example`), 32 | filename: 'example.js' 33 | }, 34 | resolve, 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.(ts|tsx|js)$/, 39 | loader: 'awesome-typescript-loader', 40 | exclude: /(node_modules)/, 41 | }, 42 | { 43 | enforce: 'pre', 44 | test: /\.js$/, 45 | loader: 'source-map-loader' 46 | }, 47 | ] 48 | }, 49 | devServer: { 50 | contentBase: path.resolve(__dirname, `./example`), 51 | hot: true, 52 | inline: true 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /example/src/ProfileForm/ProfileController.ts: -------------------------------------------------------------------------------- 1 | import * as MVC from '../../../src/'; 2 | import {UserModel} from './UserModel'; 3 | import {update, storeKey, submit} from './actions'; 4 | import {SUBMIT_ACTION_NAME} from './common'; 5 | 6 | export default class ProfileController extends MVC.Controller { 7 | public static storeKey = storeKey; 8 | public static actions = {}; 9 | public static connectedState = [storeKey]; 10 | public static Model = UserModel; 11 | 12 | public constructor(props, context) { 13 | super(UserModel, props, context); 14 | } 15 | 16 | public updateUserData = (prop) => (e): void => { 17 | this.updateProp('userData', {[prop]: e.target.value})(); 18 | } 19 | 20 | public updateProp = (prop, value) => (): void => { 21 | this.action(update, {[prop]: value}); 22 | } 23 | 24 | public submit = (userData) => { 25 | this.action(submit, userData); 26 | } 27 | 28 | public isSubmitWaiting = () => this.isWaiting(SUBMIT_ACTION_NAME); 29 | } 30 | -------------------------------------------------------------------------------- /example/src/ProfileForm/ProfileForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as MVC from '../../../src'; 3 | import ProfileController from './ProfileController'; 4 | import {IUserModelState, UserModel} from './UserModel'; 5 | import {storeKey} from './actions'; 6 | 7 | interface IProfileFormProps { 8 | [storeKey]: IUserModelState; 9 | } 10 | 11 | @MVC.withController(ProfileController) 12 | export class ProfileForm extends React.Component { 13 | public controller: ProfileController; 14 | 15 | public onSubmit = (e) => { 16 | e.preventDefault(); 17 | this.controller.submit(this.props[storeKey].userData); 18 | } 19 | 20 | public render() { 21 | const {[storeKey]: {userData: {firstName, lastName, age, department, phone, email}, isSaved}} = this.props; 22 | const isSubmitWaiting = this.controller.isSubmitWaiting(); 23 | return ( 24 |
25 | { 26 | isSaved && 27 | (
28 |

Data saved

29 |
    30 |
  • {firstName}
  • 31 |
  • {lastName}
  • 32 |
  • {age}
  • 33 |
  • {phone}
  • 34 |
  • {email}
  • 35 |
  • {department}
  • 36 |
37 | 38 |
39 | ) 40 | 41 | } 42 | { 43 | !isSaved && 44 |
45 | 51 |
52 | 58 |
59 | 65 |
66 | 72 |
73 | 79 |
80 | 86 |
87 | 88 |
89 | } 90 |
91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /example/src/ProfileForm/UserModel.ts: -------------------------------------------------------------------------------- 1 | import * as MVC from '../../../src'; 2 | import {SUBMIT_ACTION_NAME} from './common'; 3 | 4 | export interface IUserModelState { 5 | userData: { 6 | email: string; 7 | firstName: string; 8 | lastName: string; 9 | age: number | null; 10 | phone: string; 11 | department: string; 12 | }, 13 | errorMsg: string | null; 14 | isSaved: boolean; 15 | } 16 | 17 | export class UserModel extends MVC.Model { 18 | public static defaults: IUserModelState = { 19 | userData: { 20 | firstName: '', 21 | lastName: '', 22 | age: null, 23 | department: '', 24 | email: '', 25 | phone: '' 26 | }, 27 | errorMsg: null, 28 | isSaved: false 29 | }; 30 | 31 | constructor(state) { 32 | super(state); 33 | } 34 | 35 | public onUpdate(updates): IUserModelState { 36 | return this.update(updates).getState(); 37 | } 38 | 39 | public onSubmitWaiting(): IUserModelState { 40 | return this 41 | .update({isSaved: false}) 42 | .setWaiting(SUBMIT_ACTION_NAME) 43 | .resetFailed(SUBMIT_ACTION_NAME) 44 | .getState(); 45 | } 46 | 47 | public onSubmitFailed(errorMsg): IUserModelState { 48 | return this 49 | .update({errorMsg}) 50 | .setWaiting(SUBMIT_ACTION_NAME) 51 | .resetFailed(SUBMIT_ACTION_NAME) 52 | .getState(); 53 | } 54 | 55 | public onSubmitComplete(updates): IUserModelState { 56 | return this 57 | .update({userData: updates, isSaved: true}) 58 | .resetWaiting(SUBMIT_ACTION_NAME) 59 | .getState(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /example/src/ProfileForm/actions.ts: -------------------------------------------------------------------------------- 1 | import {createActions} from 'easy-redux'; 2 | import {UserModel} from './UserModel'; 3 | import {SUBMIT_ACTION_NAME, UPDATE_ACTION_NAME} from './common'; 4 | 5 | export const storeKey = 'user'; 6 | export const SUBMIT_ACTION = SUBMIT_ACTION_NAME; 7 | const UPDATE_ACTION = UPDATE_ACTION_NAME; 8 | 9 | const initialState = Object.assign({}, UserModel.defaults); 10 | 11 | const actions = createActions({ 12 | storeKey, 13 | initialState, 14 | actions: { 15 | [SUBMIT_ACTION]: { 16 | action: (userData) => ({ 17 | promise: asyncExec => asyncExec(userData) 18 | }), 19 | handlers: { 20 | onWait: state => new UserModel(state).onSubmitWaiting(), 21 | onFail: (state, {error}) => new UserModel(state).onSubmitFailed(error), 22 | onSuccess: (state, {result}) => new UserModel(state).onSubmitComplete(result) 23 | } 24 | }, 25 | [UPDATE_ACTION]: { 26 | action: (updates) => ({updates}), 27 | handler: (state, {updates}) => new UserModel(state).onUpdate(updates) 28 | } 29 | } 30 | }); 31 | 32 | export const submit = actions[SUBMIT_ACTION]; 33 | export const update = actions[UPDATE_ACTION]; 34 | -------------------------------------------------------------------------------- /example/src/ProfileForm/common.ts: -------------------------------------------------------------------------------- 1 | export const SUBMIT_ACTION_NAME = 'user@@SUBMIT'; 2 | export const UPDATE_ACTION_NAME = 'user@@UPDATE'; 3 | -------------------------------------------------------------------------------- /example/src/ProfileForm/index.ts: -------------------------------------------------------------------------------- 1 | export {ProfileForm} from './ProfileForm'; 2 | -------------------------------------------------------------------------------- /example/src/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import {Provider} from 'react-redux'; 4 | import {ProfileForm} from './ProfileForm'; 5 | import * as MVC from '../../src'; 6 | import {UserModel} from './ProfileForm/UserModel'; 7 | import {createStore} from './utils'; 8 | 9 | if (!(window as any).__initialData) { 10 | (window as any).__initialData = {}; 11 | } 12 | 13 | const appStore = createStore({data: (window as any).__initialData.store || {}}); 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | 20 | , document.getElementById('content') 21 | ); 22 | -------------------------------------------------------------------------------- /example/src/utils.ts: -------------------------------------------------------------------------------- 1 | import {applyMiddleware, compose, createStore as reduxCreateStore} from 'redux'; 2 | import {combine as combineReducers, middleware as requestMiddleware} from 'easy-redux'; 3 | 4 | const initialState = {}; 5 | 6 | const some = (state = initialState) => { 7 | return state; 8 | }; 9 | 10 | export function createStore({data}) { 11 | const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 12 | const middleWareList = [ 13 | requestMiddleware(promiseHelper) 14 | ]; 15 | const reducer = combineReducers({some}); 16 | const finalCreateStore = composeEnhancers(applyMiddleware(...middleWareList))(reduxCreateStore); 17 | return finalCreateStore(reducer, data); 18 | } 19 | 20 | /* 21 | * in place of this helper can be superagent.js for example, or other asynс function. It is used by middleware to 22 | * support async actions functionality 23 | * */ 24 | function promiseHelper(data) { 25 | return new Promise((resolve, reject) => { 26 | setTimeout(() => { 27 | resolve(data); 28 | }, 3000); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = { 4 | transform: { 5 | '.(ts|tsx)': 'ts-jest', 6 | }, 7 | collectCoverage: true, 8 | testRegex: '(/__tests__/.*!(e2e)|(\\.|/)(test|spec))\\.(tsx?)$', 9 | moduleDirectories: ['node_modules'], 10 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 11 | moduleNameMapper: { 12 | '\\.(css|scss|sass)$': 'identity-obj-proxy' 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /mvc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/welljs/react-redux-mvc/081540914462835e346b22c5403d1ff12ef2beed/mvc.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-mvc", 3 | "version": "2.2.1", 4 | "description": "Implementation of MVC pattern based on React-Redux bunch", 5 | "main": "lib/index.js", 6 | "types": "lib/types/index.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "example": "better-npm-run example", 10 | "build": "tslint --project tsconfig.json && rm -rf lib && tsc", 11 | "lint": "tslint --project tsconfig.json" 12 | }, 13 | "betterScripts": { 14 | "example": { 15 | "command": "webpack-dev-server --mode development --hot --progress --color --port 3000 --open --config example.config.js ", 16 | "env": { 17 | "NODE_ENV": "dev" 18 | } 19 | } 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/welljs/react-redux-mvc.git" 24 | }, 25 | "keywords": [ 26 | "React", 27 | "redux", 28 | "MVC" 29 | ], 30 | "author": "Vitalii Vorobioff ", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/welljs/react-redux-mvc/issues" 34 | }, 35 | "homepage": "https://github.com/welljs/react-redux-mvc#readme", 36 | "devDependencies": { 37 | "@types/jest": "23.3.3", 38 | "@types/lodash": "4.14.116", 39 | "@types/react": "16.4.14", 40 | "@types/react-dom": "16.0.7", 41 | "@types/react-redux": "6.0.9", 42 | "awesome-typescript-loader": "5.2.1", 43 | "better-npm-run": "0.1.1", 44 | "enzyme": "3.7.0", 45 | "enzyme-adapter-react-16": "1.6.0", 46 | "html-webpack-plugin": "3.2.0", 47 | "jest": "^24.1.0", 48 | "resolve-url-loader": "3.0.0", 49 | "source-map-loader": "0.2.4", 50 | "ts-jest": "23.10.3", 51 | "tslint": "5.11.0", 52 | "tslint-loader": "3.6.0", 53 | "tslint-react": "3.6.0", 54 | "typescript": "3.1.5", 55 | "webpack": "4.20.2", 56 | "webpack-cli": "3.1.2", 57 | "webpack-dev-server": "^3.1.14" 58 | }, 59 | "dependencies": { 60 | "@types/enzyme": "3.1.15", 61 | "@types/redux-actions": "2.3.0", 62 | "easy-redux": "^2.0.1", 63 | "hoist-non-react-statics": "3.0.1", 64 | "lodash": "4.17.11", 65 | "react": "16.5.2", 66 | "react-dom": "16.5.2", 67 | "react-redux": "5.0.7", 68 | "redux": "4.0.0" 69 | }, 70 | "peerDependencies": { 71 | "easy-redux": "2.0.1" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Collection.ts: -------------------------------------------------------------------------------- 1 | import {Model} from './Model'; 2 | import {generateGuid} from './helpers'; 3 | import {TState} from './DataModel'; 4 | 5 | // Model interface 6 | interface IModelData { 7 | [name: string]: any; 8 | } 9 | 10 | interface Constructable { 11 | new (...args: any[]): Proto; 12 | } 13 | 14 | // Collection is class to work with Models with IModelData interface 15 | export class Collection { 16 | public models: Array> = []; 17 | public options?: O; 18 | 19 | public constructor(items: T[] = [], options?: O) { 20 | this.options = options; 21 | this._prepare(items); 22 | this.onInit(); 23 | return this; 24 | } 25 | 26 | /** 27 | * Return array with clean models data 28 | * @returns {Array>} 29 | */ 30 | public getState(): Array> { 31 | return this.models.map((model) => model.getState()); 32 | } 33 | 34 | /** 35 | * Return last model of collection 36 | * @returns {Model} 37 | */ 38 | public last(): Model { 39 | return this.models[this.size() - 1]; 40 | } 41 | 42 | /** 43 | * Return first model of collection 44 | * @returns {Model} 45 | */ 46 | public first(): Model { 47 | return this.models[0]; 48 | } 49 | 50 | /** 51 | * Return model which property prop is value 52 | * @param {string} prop 53 | * @param value 54 | * @returns {Model | undefined} 55 | */ 56 | public find(prop: string, value: any): Model | undefined { 57 | return this.models.find(model => model.equals(prop, value)); 58 | } 59 | 60 | /** 61 | * Return all models which property prop is value 62 | * @param {string} prop 63 | * @param value 64 | * @returns {Array>} 65 | */ 66 | public filter(prop: string, value: any): Array> { 67 | return this.models.filter(model => model.equals(prop, value)); 68 | } 69 | 70 | /** 71 | * Return first model which property prop includes value 72 | * @param {string} prop 73 | * @param {string} value 74 | * @returns {Model | undefined} 75 | */ 76 | public findIncludes(prop: string, value: string): Model | undefined { 77 | return this.models.find(model => model.includes(prop, value)); 78 | } 79 | 80 | /** 81 | * Return all models which property prop includes value 82 | * @param {string} prop 83 | * @param value 84 | * @returns {Array>} 85 | */ 86 | public filterIncludes(prop: string, value: any): Array> { 87 | return this.models.filter(model => model.includes(prop, value)); 88 | } 89 | 90 | /** 91 | * Return index of the model with prop equals value 92 | * @param {string} prop 93 | * @param value 94 | * @returns {number} 95 | */ 96 | public findIndex(prop: string, value: any): number { 97 | return this.models.findIndex(model => model.equals(prop, value)); 98 | } 99 | 100 | /** 101 | * Return model by index in the collection 102 | * @param {number} index 103 | * @returns {Model | undefined} 104 | */ 105 | public findByIndex(index: number): Model | undefined { 106 | return this.models[index]; 107 | } 108 | 109 | /** 110 | * Remove model from the collection 111 | * @param {Model} model 112 | * @returns {this} 113 | */ 114 | public remove(model: Model): this { 115 | const index = this.findIndex('_id', model.getState('_id')); 116 | this.models.splice(index, 1); 117 | return this; 118 | } 119 | 120 | /** 121 | * Reverse models in the collection 122 | * @returns {Array>} 123 | */ 124 | public reverse(): Array> { 125 | return this.models.reverse(); 126 | } 127 | 128 | /** 129 | * Check if collection is empty 130 | * @returns {boolean} 131 | */ 132 | public isEmpty(): boolean { 133 | return !this.size(); 134 | } 135 | 136 | /** 137 | * Return models count 138 | * @returns {number} 139 | */ 140 | public size(): number { 141 | return this.models.length; 142 | } 143 | 144 | /** 145 | * Insert model in collection 146 | * @param {Model | T} data - If data not is instance of Model, then adding new instance of Model 147 | * @param {number} index - Position to insert 148 | * @returns {Model} 149 | */ 150 | public insert(data: Model | T, index?: number): Model { 151 | let newModel: Model; 152 | if (data instanceof Model) { 153 | newModel = data; 154 | } 155 | else { 156 | newModel = new Model(data); 157 | } 158 | if (index !== undefined) { 159 | this.models.splice(index, 0, newModel); 160 | } 161 | else { 162 | this.models.push(newModel); 163 | } 164 | return newModel; 165 | } 166 | 167 | /** 168 | * This method is necessary for initializing 169 | * @returns {this} 170 | */ 171 | protected onInit(): this { 172 | return this; 173 | } 174 | 175 | /** 176 | * This method is recommended for overriding model instantiating 177 | * @param ModelProto 178 | * @param data 179 | */ 180 | protected createModel(ModelProto: Constructable>, data: T): Model { 181 | return new ModelProto(data); 182 | } 183 | 184 | /** 185 | * Creates array of models 186 | * @param {T[]} items - Instance of Model 187 | * @private 188 | */ 189 | private _prepare(items: T[]): void { 190 | items.forEach(item => { 191 | item._id = item._id || generateGuid(); 192 | const modelInstance = this.createModel(Model, item); 193 | this.models.push(modelInstance); 194 | }); 195 | } 196 | 197 | // todo sort 198 | } 199 | -------------------------------------------------------------------------------- /src/Controller.ts: -------------------------------------------------------------------------------- 1 | import {get as _get} from 'lodash'; 2 | import {AnyAction} from 'redux'; 3 | import {Model} from './Model'; 4 | 5 | interface Constructable { 6 | new(state: object): T; 7 | } 8 | 9 | type TAsyncAction = Promise; 10 | type TAction = TAsyncAction | Action; 11 | 12 | interface IActions { 13 | [key: string]: TAction; 14 | } 15 | 16 | // Basic controller 17 | export class Controller> { 18 | // propsType bind to connected component 19 | public static propsTypes = {}; 20 | // List of fields to get from global store 21 | // To get nested properties, it is necessary to specify them through a dot: routing.location 22 | public static connectedState: string[] = []; 23 | // actions that need to be wrapped by dispatcher 24 | public static actions: IActions = {}; 25 | public static storeKey: string = ''; 26 | public Model: Constructable>; 27 | public name: string = 'BasicController'; 28 | public readonly storeKey: string = ''; 29 | private readonly actions: IActions = {}; 30 | 31 | public constructor(Model, props, context?) { 32 | this.Model = Model; 33 | this.storeKey = (this.constructor as typeof Controller).storeKey; 34 | this.actions = (this.constructor as typeof Controller).actions; 35 | } 36 | 37 | public componentWillReceiveProps(currentProps, nextProps): void {} 38 | 39 | public getGlobalState() {} 40 | 41 | // withController must pass here real dispatcher 42 | public dispatch(action: Action): TAction { 43 | return action; 44 | } 45 | 46 | /** 47 | * Use to connect to global store 48 | * @param {object} state - store properties that need to be connected 49 | * @returns {object} 50 | */ 51 | public mappedProps(state: object): object { 52 | return (this.constructor as typeof Controller).connectedState.reduce((result, prop: string) => { 53 | let key: string = prop; 54 | if (prop.includes(':')) { 55 | const parts: string[] = prop.split(':'); 56 | prop = parts[0]; 57 | key = parts[1]; 58 | } 59 | return (result[key] = _get(state, prop), result); 60 | }, {}); 61 | } 62 | 63 | /** 64 | * dispatches actions 65 | * @param name 66 | * @param args 67 | * @returns TDispatchReturn 68 | */ 69 | public action(name, ...args: Args): TAction { 70 | if (typeof name === 'function') { 71 | return this.dispatch(name.apply(undefined, args)); 72 | } 73 | const action = this.actions[name]; 74 | if (typeof action !== 'function') { 75 | throw Error('Action must be a function'); 76 | } 77 | return this.dispatch((action as AnyAction).apply(undefined, args)); 78 | } 79 | 80 | /** 81 | * Starts with initialization for initial loading 82 | * ! Until it is done, the first render does not start 83 | * @returns {Promise} 84 | */ 85 | public onInit = (): Promise => Promise.resolve(); 86 | 87 | /** 88 | * Return connected state. Can be nested 89 | * @param {string} prop 90 | * @returns {any} 91 | */ 92 | public getState = (prop?: string): any => { 93 | if (this.storeKey) { 94 | return prop ? _get(this.getGlobalState()[this.storeKey], prop) : this.getGlobalState()[this.storeKey]; 95 | } 96 | } 97 | 98 | /** 99 | * Return waiting 100 | * @returns {object} 101 | */ 102 | public getWaiting(): object { 103 | if (this.Model) { 104 | return new this.Model(this.getState()).getWaiting(); 105 | } 106 | else { 107 | noModelWarning(this.name); 108 | return {}; 109 | } 110 | } 111 | 112 | /** 113 | * Return is this prop in waiting 114 | * @param prop 115 | * @returns {boolean} 116 | */ 117 | public isWaiting(prop): boolean { 118 | if (this.Model) { 119 | return new this.Model(this.getState()).isWaiting(prop); 120 | } 121 | else { 122 | noModelWarning(this.name); 123 | return false; 124 | } 125 | } 126 | 127 | /** 128 | * Return is this prop is failed 129 | * @param prop 130 | * @returns {boolean} 131 | */ 132 | public isFailed(prop): boolean { 133 | if (this.Model) { 134 | return new this.Model(this.getState()).isFailed(prop); 135 | } 136 | else { 137 | noModelWarning(this.name); 138 | return false; 139 | } 140 | } 141 | 142 | /** 143 | * Return failed 144 | * @returns {object} 145 | */ 146 | public getFailed(): object { 147 | if (this.Model) { 148 | return new this.Model(this.getState()).getFailed(); 149 | } 150 | else { 151 | noModelWarning(this.name); 152 | return {}; 153 | } 154 | } 155 | } 156 | 157 | function noModelWarning(controllerName: string): void { 158 | throw new Error(`There is Model provided to ${controllerName}`); 159 | } 160 | -------------------------------------------------------------------------------- /src/DataModel.ts: -------------------------------------------------------------------------------- 1 | import {set as _set, isPlainObject, cloneDeep, get as _get} from 'lodash'; 2 | import {merge} from './helpers'; 3 | 4 | export interface IDefaultState { 5 | _id?: string; 6 | } 7 | 8 | export type TState = T & IDefaultState; 9 | 10 | // Basic data model 11 | export class DataModel { 12 | public state: TState; 13 | public options?: O; 14 | 15 | public constructor(props: T, options?: O) { 16 | this.options = options; 17 | this.prepare(props); 18 | return this; 19 | } 20 | 21 | /** 22 | * Set prop value to value and return updated Model 23 | * @param {string | object} prop 24 | * @param value 25 | * @returns {this} 26 | */ 27 | public set(prop: string | object, value?: any): this { 28 | if (!prop) { 29 | throw Error('Property must be set'); 30 | } 31 | // sets value to whole object 32 | if (isPlainObject(prop)) { 33 | const key = Object.keys(prop)[0]; 34 | const piece = this.getState(key); 35 | // for nested properties 36 | if (key && !!~key.indexOf('.') && piece && !!prop[key]) { 37 | _set(this.state, key, merge(cloneDeep(piece), prop[key])); 38 | } 39 | else { 40 | this.state = merge(cloneDeep(this.state), prop); 41 | } 42 | } 43 | else if (typeof prop === 'string' && value !== undefined) { 44 | // Allows to set values for nested keys. Example: set('user.name', 'Benedict') 45 | _set(this.state, prop, value); 46 | } 47 | return this; 48 | } 49 | 50 | /** 51 | * Return updated Model 52 | * @param {Partial | object} updates 53 | * @returns {this} 54 | */ 55 | public update(updates: Partial | object): this { 56 | this.set(updates); 57 | return this; 58 | } 59 | 60 | /** 61 | * Return Model state 62 | * @param {string} prop 63 | * @returns {TState} 64 | */ 65 | public getState(prop?: string): TState { 66 | return prop ? _get(this.state, prop) : this.state; 67 | } 68 | 69 | /** 70 | * Reset Model to newState and return it 71 | * @param {TState} newState 72 | * @returns {this} 73 | */ 74 | public reset(newState: TState): this { 75 | this.state = cloneDeep(newState); 76 | return this; 77 | } 78 | 79 | /** 80 | * Return is Model state contain equal prop: value 81 | * @param {string} prop 82 | * @param value 83 | * @param {boolean} exact 84 | * @returns {boolean} 85 | */ 86 | public equals(prop: string, value: any, exact?: boolean): boolean { 87 | return exact ? this.getState(prop) === value : this.getState(prop) == value; 88 | } 89 | 90 | /** 91 | * Return is prop in Model state includes value 92 | * @param {string} prop 93 | * @param {string} value 94 | * @param {boolean} caseSensitive 95 | * @returns {boolean} 96 | */ 97 | public includes(prop: string, value: string, caseSensitive?: boolean): boolean { 98 | const currentValue = this.getState(prop); 99 | if (typeof currentValue !== 'string' && !(currentValue instanceof String) ) { 100 | return false; 101 | } 102 | if (caseSensitive) { 103 | return !!~currentValue.indexOf(value); 104 | } 105 | else { 106 | return !!~currentValue.toLocaleLowerCase().indexOf(value.toLocaleLowerCase()); 107 | } 108 | } 109 | 110 | /** 111 | * Creating Model 112 | * @param {T} data 113 | * @returns {this} 114 | */ 115 | private prepare(data: T): this { 116 | return this.reset(Object.assign({}, data)); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Model.ts: -------------------------------------------------------------------------------- 1 | import {DataModel} from './DataModel'; 2 | 3 | export interface IDefaultModelState { 4 | __waiting: object; 5 | __failed: object; 6 | } 7 | 8 | const stateDefaults = (): IDefaultModelState => ({ 9 | __waiting: {}, 10 | __failed: {}, 11 | }); 12 | 13 | // Basic Model 14 | export class Model extends DataModel{ 15 | public constructor(props: T, options?: O) { 16 | super(Object.assign({}, stateDefaults(), props), options); 17 | this.onInit(); 18 | return this; 19 | } 20 | 21 | /** 22 | * This method is necessary for initializing 23 | * @returns {this} 24 | */ 25 | public onInit(): this { 26 | return this; 27 | } 28 | 29 | /** 30 | * Adding prop to waiting 31 | * @param {string | any} prop 32 | * @returns {this} 33 | */ 34 | public setWaiting(prop: string | any): this { 35 | return this.set('__waiting.' + prop, true); 36 | } 37 | 38 | /** 39 | * Reset waiting of prop 40 | * @param {string | any} prop 41 | * @returns {this} 42 | */ 43 | public resetWaiting(prop: string | any): this { 44 | return this.set('__waiting.' + prop, false); 45 | } 46 | 47 | /** 48 | * Adding prop to failed 49 | * @param {string | any} prop 50 | * @returns {this} 51 | */ 52 | public setFailed(prop: string | any): this { 53 | return this.set('__failed.' + prop, true); 54 | } 55 | 56 | /** 57 | * Reset failed of prop 58 | * @param {string | any} prop 59 | * @returns {this} 60 | */ 61 | public resetFailed(prop: string | any): this { 62 | return this.set('__failed.' + prop, false); 63 | } 64 | 65 | /** 66 | * Return is this props in waiting 67 | * @param {string} key 68 | * @returns {boolean} 69 | */ 70 | public isWaiting(key: string): boolean { 71 | return !!this.getState('__waiting.' + key); 72 | } 73 | 74 | /** 75 | * Return is this props in failed 76 | * @param key 77 | * @returns {boolean} 78 | */ 79 | public isFailed(key: any): boolean { 80 | return !!this.getState('__failed.' + key); 81 | } 82 | 83 | /** 84 | * Return waiting 85 | * @returns {object} 86 | */ 87 | public getWaiting(): object { 88 | return this.getState('__waiting'); 89 | } 90 | 91 | /** 92 | * Return failed 93 | * @returns {object} 94 | */ 95 | public getFailed(): object { 96 | return this.getState('__failed'); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/ReactReduxMvc.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { object, element } from 'prop-types'; 3 | 4 | export interface IReactReduxMVCProps { 5 | children: JSX.Element; 6 | store: object; 7 | } 8 | 9 | export interface IChildContextProps { 10 | store: object; 11 | } 12 | 13 | export class ReactReduxMvc extends React.Component { 14 | public static childContextTypes: IChildContextProps = { 15 | store: () => null 16 | }; 17 | 18 | public static propTypes = { 19 | children: element.isRequired, 20 | store: object.isRequired 21 | }; 22 | 23 | private store: object; 24 | 25 | public constructor(props: IReactReduxMVCProps, context) { 26 | super(props, context); 27 | this.store = props.store; 28 | } 29 | 30 | public getChildContext(): IChildContextProps { 31 | return { 32 | store: this.store 33 | }; 34 | } 35 | 36 | public render() { 37 | return React.Children.only(this.props.children); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import {mergeWith, isArray} from 'lodash'; 2 | 3 | export function merge(dst, src) { 4 | // To prevent merging arrays, return initial 5 | if (isArray(dst) && isArray(src)) { 6 | return src; 7 | } 8 | else { 9 | return { 10 | ...mergeWith(dst, src, (objValue, srcValue) => { 11 | // To prevent merging arrays, return initial 12 | if (isArray(objValue)) { 13 | return srcValue; 14 | } 15 | }) 16 | }; 17 | } 18 | } 19 | 20 | export function generateGuid(): string { 21 | const S4 = (): string => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); 22 | return (S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4()); 23 | } 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ReactReduxMvc'; 2 | export * from './withController'; 3 | export * from './Controller'; 4 | export * from './Collection'; 5 | export * from './Model'; 6 | -------------------------------------------------------------------------------- /src/withController.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import * as hoistStatics from 'hoist-non-react-statics'; 4 | import {isFunction} from 'lodash'; 5 | import {Controller as BasicController} from './Controller'; 6 | import {Model} from './Model'; 7 | 8 | export interface IWrapperProps extends Model { 9 | store?: object; 10 | dispatch?: any; 11 | } 12 | 13 | export interface IWrapperState { 14 | canRender: boolean; 15 | } 16 | function mapStateToProps(Controller) { 17 | return function (state) { 18 | return Controller.prototype.mappedProps(state); 19 | }; 20 | } 21 | 22 | export function withController(Controller = BasicController): any { 23 | return Component => { 24 | class Wrapper extends React.Component { 25 | public static contextTypes = { 26 | store: () => null 27 | }; 28 | 29 | public state = { 30 | canRender: false 31 | }; 32 | 33 | private store; 34 | 35 | constructor (props, context) { 36 | super(props, context); 37 | this.store = props.store || context.store; 38 | Controller.prototype.name = Component.prototype.constructor.name + 'Controller'; 39 | Controller.prototype.dispatch = this.store.dispatch; 40 | 41 | Controller.prototype.getGlobalState = function (prop) { 42 | return prop ? this.store.getState()[prop] : this.store.getState(); 43 | }.bind(this); 44 | 45 | const controller = new Controller(props, context); 46 | Component.prototype.controller = controller; 47 | controller.onInit().then(() => this.setState({canRender: true})); 48 | } 49 | 50 | public render() { 51 | const {canRender} = this.state; 52 | return canRender ? React.createElement(Component, {...this.props}) : null; 53 | } 54 | } 55 | 56 | const connectedWrapper = connect(mapStateToProps(Controller))(Wrapper); 57 | const fn = Component.prototype.componentWillReceiveProps; 58 | Component.prototype.componentWillReceiveProps = function (nextPops) { 59 | Controller.prototype.componentWillReceiveProps.call(Component.prototype.controller, this.props, nextPops); 60 | if (isFunction(fn)) { 61 | fn.call(this, nextPops); 62 | } 63 | }; 64 | return hoistStatics(connectedWrapper, Component); 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /tests/Collection.spec.ts: -------------------------------------------------------------------------------- 1 | import {Collection, Model} from '../src'; 2 | import {generateGuid} from '../src/helpers'; 3 | 4 | describe('collection', () => { 5 | const testCollectionData = [ 6 | { 7 | test: true 8 | }, 9 | { 10 | second: 'test' 11 | } 12 | ]; 13 | const testCollection = new Collection(testCollectionData); 14 | const firstKeyToTest = Object.keys(testCollectionData[0])[0]; 15 | const lastKeyToTest = Object.keys(testCollectionData[testCollectionData.length - 1])[0]; 16 | it('should return data includes testCollectionData', () => { 17 | expect(testCollection.getState()[0][firstKeyToTest]).toEqual(testCollectionData[0][firstKeyToTest]); 18 | }); 19 | it('should return last model with correct value', () => { 20 | expect(testCollection.last().getState(lastKeyToTest)).toEqual(testCollectionData[testCollectionData.length - 1][lastKeyToTest]); 21 | }); 22 | it('should return first model with correct value', () => { 23 | expect(testCollection.first().getState(firstKeyToTest)).toEqual(testCollectionData[0][firstKeyToTest]); 24 | }); 25 | it('should find model', () => { 26 | const foundModel = testCollection.find(firstKeyToTest, testCollectionData[0][firstKeyToTest]); 27 | expect(foundModel).not.toBe(undefined); 28 | }); 29 | it('should not find model', () => { 30 | const foundModel = testCollection.find(generateGuid(), generateGuid()); 31 | expect(foundModel).toBe(undefined); 32 | }); 33 | it('should return correctly length', () => { 34 | const filteredModels = testCollection.filter(firstKeyToTest, testCollectionData[0][firstKeyToTest]); 35 | expect(filteredModels.length).toEqual(1); 36 | }); 37 | it('should return index of model', () => { 38 | const foundIndex = testCollection.findIndex(firstKeyToTest, testCollectionData[0][firstKeyToTest]); 39 | expect(foundIndex).toEqual(0); 40 | }); 41 | it('should return length', () => { 42 | expect(testCollection.size()).toEqual(testCollectionData.length); 43 | }); 44 | it('should find model', () => { 45 | const foundModel = testCollection.findIncludes(lastKeyToTest, testCollectionData[testCollectionData.length - 1][lastKeyToTest]); 46 | expect(foundModel).not.toBe(undefined); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/Controller.spec.ts: -------------------------------------------------------------------------------- 1 | import {Controller, Model} from '../src'; 2 | 3 | describe('Controller', () => { 4 | // TS hack to have access to private methods and props 5 | let controller; 6 | controller = new Controller(Model, {}); 7 | const dispatch = jest.fn(); 8 | const getGlobalState = () => { 9 | return ({[controller.storeKey]: 'test'}); 10 | }; 11 | Controller.storeKey = 'testStoreKey'; 12 | controller.storeKey = 'testStoreKey'; 13 | controller.dispatch = dispatch; 14 | controller.getGlobalState = getGlobalState; 15 | Controller.connectedState = ['testState']; 16 | it('should call dispatch', () => { 17 | expect(dispatch).not.toBeCalled(); 18 | controller.action(() => {}); 19 | expect(dispatch).toBeCalled(); 20 | }); 21 | it('should call getGlobalState function', () => { 22 | expect(controller.getState()).toEqual('test'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/Model.spec.ts: -------------------------------------------------------------------------------- 1 | import {Model} from '../src'; 2 | 3 | describe('Model', () => { 4 | const testKey = 'testKey'; 5 | const testModelData = { 6 | [testKey]: true, 7 | }; 8 | const testModel = new Model(testModelData); 9 | it('should set waiting', () => { 10 | testModel.setWaiting(testKey); 11 | expect(testModel.isWaiting(testKey)).toEqual(true); 12 | }); 13 | it('should reset waiting', () => { 14 | testModel.resetWaiting(testKey); 15 | expect(testModel.isWaiting(testKey)).toEqual(false); 16 | }); 17 | it('should set failed', () => { 18 | testModel.setFailed(testKey); 19 | expect(testModel.isFailed(testKey)).toEqual(true); 20 | }); 21 | it('should reset failed', () => { 22 | testModel.resetFailed(testKey); 23 | expect(testModel.isFailed(testKey)).toEqual(false); 24 | }); 25 | it('should return model state', () => { 26 | expect(testModel.getState(testKey)).toEqual(testModelData[testKey]); 27 | }); 28 | it('should have different initial state', () => { 29 | const secondModel = new Model({}); 30 | const testWaitingKey = 'testWaitingKey'; 31 | testModel.setWaiting(testWaitingKey); 32 | expect(testModel.isWaiting(testWaitingKey)).toBeTruthy(); 33 | expect(secondModel.isWaiting(testWaitingKey)).toBeFalsy(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/withController.spec.tsx: -------------------------------------------------------------------------------- 1 | import {Controller, withController} from '../src'; 2 | import * as React from 'react'; 3 | import {createStore} from '../example/src/utils'; 4 | import {mount, ShallowWrapper} from 'enzyme'; 5 | import * as Adapter from 'enzyme-adapter-react-16'; 6 | import * as Enzyme from 'enzyme'; 7 | 8 | Enzyme.configure({adapter: new Adapter()}); 9 | 10 | describe('withController', () => { 11 | class TestView extends React.Component { 12 | public controller; 13 | 14 | public render() { 15 | return
123
; 16 | } 17 | } 18 | 19 | const Component = withController(Controller)(TestView); 20 | const appStore = createStore({data: {some: true}}); 21 | const mountedComponent = mount(); 22 | const instancePromise = new Promise((resolve) => { 23 | setTimeout(() => { 24 | mountedComponent.update(); 25 | resolve(mountedComponent.find('Wrapper')); 26 | }, 1); 27 | }); 28 | it('it should render view', (done) => { 29 | instancePromise.then((instance) => { 30 | expect((instance as ShallowWrapper).find('TestView').length).toEqual(1); 31 | done(); 32 | }); 33 | }); 34 | it('should have dispatcher from redux', () => { 35 | expect(Controller.prototype.dispatch).toEqual(appStore.dispatch); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "module": "commonjs", 5 | "target": "es5", 6 | "lib":["dom", "es6", "dom.iterable", "scripthost", "es2016.array.include", "es2015.promise", "es2017"], 7 | "sourceMap": true, 8 | "strictNullChecks": true, 9 | "jsx": "react", 10 | "moduleResolution": "node", 11 | "experimentalDecorators": true, 12 | "removeComments": true, 13 | "declarationDir": "./lib/types", 14 | "declaration": true, 15 | "outDir": "./lib/" 16 | }, 17 | "include": [ 18 | "./src" 19 | ], 20 | "exclude": [ 21 | "./node_modules", 22 | "**/*.spec.ts", 23 | "**/*.spec.tsx" 24 | ] 25 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-react" 5 | ], 6 | "rules": { 7 | "indent": [ 8 | true, 9 | "spaces", 10 | 2 11 | ], 12 | "object-literal-sort-keys": false, 13 | "space-before-function-paren": false, 14 | "no-bitwise": false, 15 | "no-angle-bracket-type-assertion": false, 16 | "triple-equals": false, 17 | "only-arrow-functions": [ 18 | false 19 | ], 20 | "ordered-imports": [ 21 | false 22 | ], 23 | "no-namespace": false, 24 | "no-string-literal": false, 25 | "no-shadowed-variable": false, 26 | "jsx-no-multiline-js": false, 27 | "max-classes-per-file": [ 28 | true, 29 | 2 30 | ], 31 | "max-line-length": [ 32 | 120 33 | ], 34 | "one-line": [ 35 | true 36 | ], 37 | "quotemark": [ 38 | true, 39 | "single", 40 | "jsx-double" 41 | ], 42 | "interface-name": [ 43 | false 44 | ], 45 | // due to official Coding guidelines https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines 46 | "no-console": [ 47 | true, 48 | "warn", 49 | "debug", 50 | "table" 51 | ], 52 | "trailing-comma": [ 53 | false 54 | ], 55 | "arrow-parens": false, 56 | "whitespace": [ 57 | true, 58 | "check-branch", 59 | "check-decl", 60 | "check-operator", 61 | "check-separator", 62 | "check-type", 63 | "check-typecast" 64 | ], 65 | "no-empty": false, 66 | "no-empty-interface": false 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | context: path.join(__dirname, './src'), 6 | entry: './index.ts', 7 | output: { 8 | path: path.resolve(__dirname, 'lib'), 9 | filename: 'index.js' 10 | }, 11 | resolve: { 12 | extensions: ['.js', '.json', '.ts', '.tsx'], 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.(ts|tsx)$/, 18 | loader: 'awesome-typescript-loader', 19 | exclude: /(node_modules)/, 20 | }, 21 | { 22 | enforce: 'pre', 23 | test: /\.js$/, 24 | loader: 'source-map-loader' 25 | }, 26 | ] 27 | } 28 | }; --------------------------------------------------------------------------------