├── .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 | 
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 |
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 |
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