├── .DS_Store ├── _logo ├── gear.png ├── logo.gif ├── logo.jpg └── heart.png ├── .travis.yml ├── src ├── middlewares │ ├── index.js │ ├── Logger.js │ └── __tests__ │ │ └── Logger.spec.js ├── react │ ├── index.js │ ├── connect.js │ └── __tests__ │ │ └── connect.spec.js ├── helpers │ ├── generators │ │ └── call.js │ ├── index.js │ ├── isEmptyObject.js │ ├── validateState.js │ ├── handleActionLatest.js │ ├── validateConfig.js │ ├── handleMiddleware.js │ ├── __tests__ │ │ ├── validateState.spec.js │ │ ├── validateConfig.spec.js │ │ ├── toCamelCase.spec.js │ │ ├── call.spec.js │ │ ├── handleGenerator.spec.js │ │ ├── handleActionLatest.spec.js │ │ ├── registerMethods.spec.js │ │ └── connect.spec.js │ ├── toCamelCase.js │ ├── updateState.js │ ├── registerMethods.js │ ├── vendors │ │ ├── SerializeError.js │ │ └── CircularJSON.js │ ├── handleAction.js │ ├── connect.js │ └── handleGenerator.js ├── createMachine.js ├── constants.js ├── index.js └── __tests__ │ ├── action-handler-scheduling.spec.js │ ├── createMachine.spec.js │ └── index.spec.js ├── _images └── Logger.png ├── .npmignore ├── examples └── todo-app │ ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html │ ├── README.md │ ├── .gitignore │ ├── src │ ├── components │ │ ├── icons │ │ │ ├── CloseIcon.js │ │ │ ├── SquareOIcon.js │ │ │ └── SquareOCheckIcon.js │ │ ├── ToDos.js │ │ ├── App.js │ │ ├── AddNewTodo.js │ │ └── ToDo.js │ ├── index.js │ ├── storage.js │ ├── machines │ │ └── ToDos.js │ └── index.css │ └── package.json ├── .istanbul.yml ├── .babelrc ├── lib ├── helpers │ ├── isEmptyObject.js │ ├── uid.js │ ├── generators │ │ └── call.js │ ├── index.js │ ├── validateState.js │ ├── toCamelCase.js │ ├── __tests__ │ │ ├── validateState.spec.js │ │ ├── validateConfig.spec.js │ │ ├── toCamelCase.spec.js │ │ ├── call.spec.js │ │ ├── registerMethods.spec.js │ │ ├── handleActionLatest.spec.js │ │ ├── handleGenerator.spec.js │ │ └── connect.spec.js │ ├── handleActionLatest.js │ ├── handleMiddleware.js │ ├── validateConfig.js │ ├── updateState.js │ ├── registerMethods.js │ ├── vendors │ │ ├── SerializeError.js │ │ └── CircularJSON.js │ ├── handleAction.js │ ├── connect.js │ └── handleGenerator.js ├── middlewares │ ├── index.js │ ├── Logger.js │ └── __tests__ │ │ └── Logger.spec.js ├── react │ ├── index.js │ └── connect.js ├── createMachine.js ├── constants.js ├── __tests__ │ ├── createMachine.spec.js │ ├── action-handler-scheduling.spec.js │ └── index.spec.js └── index.js ├── test └── setup.js ├── docs ├── README.md ├── state-object.md ├── react-integration.md ├── connect-and-disconnect.md ├── middlewares.md ├── examples.md ├── machine.md ├── action-handler.md └── getting-started.md ├── LICENSE ├── .gitignore ├── package.json ├── README.md └── CHANGELOG.md /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krasimir/stent/HEAD/.DS_Store -------------------------------------------------------------------------------- /_logo/gear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krasimir/stent/HEAD/_logo/gear.png -------------------------------------------------------------------------------- /_logo/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krasimir/stent/HEAD/_logo/logo.gif -------------------------------------------------------------------------------- /_logo/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krasimir/stent/HEAD/_logo/logo.jpg -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "7" 5 | - "6" -------------------------------------------------------------------------------- /_logo/heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krasimir/stent/HEAD/_logo/heart.png -------------------------------------------------------------------------------- /src/middlewares/index.js: -------------------------------------------------------------------------------- 1 | import Logger from './Logger'; 2 | 3 | export { Logger }; -------------------------------------------------------------------------------- /_images/Logger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krasimir/stent/HEAD/_images/Logger.png -------------------------------------------------------------------------------- /src/react/index.js: -------------------------------------------------------------------------------- 1 | import connect from './connect'; 2 | 3 | export default { connect }; -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _images 3 | _logo 4 | coverage 5 | reports 6 | test 7 | examples 8 | .babelrc 9 | .vscode -------------------------------------------------------------------------------- /examples/todo-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krasimir/stent/HEAD/examples/todo-app/public/favicon.ico -------------------------------------------------------------------------------- /src/helpers/generators/call.js: -------------------------------------------------------------------------------- 1 | export default function call(func, ...args) { 2 | return { __type: 'call', func, args }; 3 | }; 4 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | import call from './generators/call'; 2 | import connect from './connect'; 3 | 4 | export { call, connect }; -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | instrumentation: 2 | root: . 3 | extensions: 4 | - .js 5 | default-excludes: true 6 | excludes: ['*.spec.js', '**/__tests__/**'] -------------------------------------------------------------------------------- /examples/todo-app/README.md: -------------------------------------------------------------------------------- 1 | # ToDo Application using Stent and React 2 | 3 | Install all the dependencies by running `yarn` and then `yarn start` to see it at `http://localhost:3000/`. -------------------------------------------------------------------------------- /src/helpers/isEmptyObject.js: -------------------------------------------------------------------------------- 1 | export default function isEmptyObject(obj) { 2 | var name; 3 | for (name in obj) { 4 | if (obj.hasOwnProperty(name)) return false; 5 | } 6 | return true; 7 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | [ "es2015", { "loose": true } ], 5 | "stage-3" 6 | ], 7 | "plugins": [ 8 | "babel-plugin-add-module-exports" 9 | ], 10 | "ignore": [ "node_modules/**/*" ] 11 | } -------------------------------------------------------------------------------- /src/helpers/validateState.js: -------------------------------------------------------------------------------- 1 | import { ERROR_WRONG_STATE_FORMAT } from '../constants'; 2 | 3 | export default function validateState(state) { 4 | if (state && typeof state === 'object' && typeof state.name !== 'undefined') return state; 5 | throw new Error(ERROR_WRONG_STATE_FORMAT(state)); 6 | } -------------------------------------------------------------------------------- /src/helpers/handleActionLatest.js: -------------------------------------------------------------------------------- 1 | import handleAction from './handleAction'; 2 | 3 | const actions = {}; 4 | 5 | export default function handleActionLatest(machine, action, ...payload) { 6 | actions[action] && actions[action](); 7 | actions[action] = handleAction(machine, action, ...payload); 8 | }; -------------------------------------------------------------------------------- /lib/helpers/isEmptyObject.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = isEmptyObject; 5 | function isEmptyObject(obj) { 6 | var name; 7 | for (name in obj) { 8 | if (obj.hasOwnProperty(name)) return false; 9 | } 10 | return true; 11 | } 12 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/middlewares/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.Logger = undefined; 5 | 6 | var _Logger = require('./Logger'); 7 | 8 | var _Logger2 = _interopRequireDefault(_Logger); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | exports.Logger = _Logger2.default; -------------------------------------------------------------------------------- /lib/helpers/uid.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.default = uid; 5 | function uid() { 6 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 7 | var r = Math.random() * 16 | 0, 8 | v = c == 'x' ? r : r & 0x3 | 0x8; 9 | return v.toString(16); 10 | }); 11 | } 12 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/react/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _connect = require('./connect'); 6 | 7 | var _connect2 = _interopRequireDefault(_connect); 8 | 9 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 10 | 11 | exports.default = { connect: _connect2.default }; 12 | module.exports = exports['default']; -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import sinonChai from 'sinon-chai'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | import { configure } from 'enzyme'; 6 | 7 | configure({adapter: new Adapter()}); 8 | 9 | chai.config.truncateThreshold = 0; 10 | 11 | global.expect = expect; 12 | global.sinon = sinon; 13 | 14 | chai.use(sinonChai); -------------------------------------------------------------------------------- /examples/todo-app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /lib/helpers/generators/call.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.default = call; 5 | function call(func) { 6 | for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { 7 | args[_key - 1] = arguments[_key]; 8 | } 9 | 10 | return { __type: 'call', func: func, args: args }; 11 | }; 12 | module.exports = exports['default']; -------------------------------------------------------------------------------- /examples/todo-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | * [Getting started](./getting-started.md) 4 | * API 5 | * [``](./state-object.md) 6 | * [`Machine.`](./machine.md) 7 | * [``](./action-handler.md) 8 | * [`connect` and `disconnect`](./connect-and-disconnect.md) 9 | * [Middlewares](./middlewares.md) 10 | * [React integration](./react-integration.md) 11 | * [examples](./examples.md) 12 | -------------------------------------------------------------------------------- /examples/todo-app/src/components/icons/CloseIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const CloseIcon = () => ; 4 | 5 | export default CloseIcon; -------------------------------------------------------------------------------- /examples/todo-app/src/components/icons/SquareOIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SquareOIcon = () => ; 4 | 5 | export default SquareOIcon; -------------------------------------------------------------------------------- /examples/todo-app/src/components/ToDos.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'stent/lib/react'; 3 | import Todo from './ToDo'; 4 | 5 | const ToDos = ({ todos }) => { 6 | const todosItems = todos.map( 7 | (todo, index) => () 8 | ); 9 | 10 | return
    { todosItems }
; 11 | }; 12 | 13 | export default connect(ToDos) 14 | .with('ToDos') 15 | .map(({ state, deleteTodo, changeStatus }) => ({ todos: state.todos })); -------------------------------------------------------------------------------- /src/helpers/validateConfig.js: -------------------------------------------------------------------------------- 1 | import { 2 | ERROR_MISSING_STATE, 3 | ERROR_MISSING_TRANSITIONS 4 | } from '../constants'; 5 | 6 | export default function validateConfig(config) { 7 | if (typeof config !== 'object') throw new Error(ERROR_MISSING_STATE); 8 | 9 | const { state, transitions } = config; 10 | 11 | if (typeof state !== 'object') throw new Error(ERROR_MISSING_STATE); 12 | if (typeof transitions !== 'object') throw new Error(ERROR_MISSING_TRANSITIONS); 13 | return true; 14 | } -------------------------------------------------------------------------------- /examples/todo-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "kuker-emitters": "6.7.0", 7 | "react": "^15.6.1", 8 | "react-dom": "^15.6.1", 9 | "react-scripts": "1.0.13", 10 | "stent": "4.2.0" 11 | }, 12 | "scripts": { 13 | "start": "BROWSER=none react-scripts start", 14 | "build": "react-scripts build", 15 | "test": "react-scripts test --env=jsdom", 16 | "eject": "react-scripts eject" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/helpers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.connect = exports.call = undefined; 5 | 6 | var _call = require('./generators/call'); 7 | 8 | var _call2 = _interopRequireDefault(_call); 9 | 10 | var _connect = require('./connect'); 11 | 12 | var _connect2 = _interopRequireDefault(_connect); 13 | 14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 15 | 16 | exports.call = _call2.default; 17 | exports.connect = _connect2.default; -------------------------------------------------------------------------------- /src/middlewares/Logger.js: -------------------------------------------------------------------------------- 1 | const Logger = { 2 | onActionDispatched(actionName, ...args) { 3 | if (args.length === 0) { 4 | console.log(`${ this.name }: "${ actionName }" dispatched`); 5 | } else { 6 | console.log(`${ this.name }: "${ actionName }" dispatched with payload ${ args }`); 7 | } 8 | }, 9 | onStateChanged() { 10 | console.log(`${ this.name }: state changed to "${ this.state.name }"`); 11 | }, 12 | onGeneratorStep(yielded) { 13 | console.log(`${ this.name }: generator step -> ${ yielded }`); 14 | } 15 | } 16 | 17 | export default Logger; -------------------------------------------------------------------------------- /examples/todo-app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Machine } from 'stent'; 4 | import { StentEmitter, ReactEmitter, HTMLEmitter } from 'kuker-emitters'; 5 | import ToDos from './machines/ToDos.js'; 6 | import App from './components/App'; 7 | import './index.css'; 8 | 9 | // window.addEventListener('message', event => { 10 | // console.log(event.data); 11 | // }); 12 | 13 | Machine.addMiddleware(StentEmitter()); 14 | Machine.create('ToDos', ToDos); 15 | 16 | render(, document.getElementById('root')); 17 | 18 | ReactEmitter(); 19 | HTMLEmitter(); -------------------------------------------------------------------------------- /examples/todo-app/src/storage.js: -------------------------------------------------------------------------------- 1 | const TODOS = 'TODOS'; 2 | 3 | export default { 4 | load() { 5 | const todos = localStorage.getItem(TODOS) || '[]'; 6 | 7 | return new Promise((resolve, reject) => { 8 | try { 9 | resolve(JSON.parse(todos)); 10 | } catch(error) { 11 | reject(error); 12 | } 13 | }); 14 | }, 15 | save(todos) { 16 | return new Promise((resolve, reject) => { 17 | try { 18 | localStorage.setItem(TODOS, JSON.stringify(todos)); 19 | resolve(); 20 | } catch(error) { 21 | reject(error); 22 | } 23 | }); 24 | } 25 | } -------------------------------------------------------------------------------- /src/helpers/handleMiddleware.js: -------------------------------------------------------------------------------- 1 | import { Machine } from '../'; 2 | 3 | export default function handleMiddleware(hook, machine, ...args) { 4 | const middlewares = Machine.middlewares; 5 | 6 | if (middlewares.length === 0) { 7 | return; 8 | } 9 | 10 | const loop = (index, process) => index < middlewares.length - 1 ? process(index + 1) : null; 11 | 12 | (function process(index) { 13 | const middleware = middlewares[index]; 14 | 15 | if (middleware && typeof middleware[hook] !== 'undefined') { 16 | middleware[hook].apply(machine, args); 17 | } 18 | loop(index, process); 19 | })(0); 20 | } -------------------------------------------------------------------------------- /src/helpers/__tests__/validateState.spec.js: -------------------------------------------------------------------------------- 1 | import validateState from '../validateState'; 2 | import { ERROR_WRONG_STATE_FORMAT } from '../../constants'; 3 | 4 | describe('Given the validateState helper', function () { 5 | describe('when using validateState', function () { 6 | it('throw an error if the state has no "name" property inside', function () { 7 | [ 8 | { answer: 42 }, 9 | null, 10 | undefined, 11 | 'a string', 12 | 42 13 | ].forEach(state => { 14 | expect(validateState.bind(null, state)).to.throw(ERROR_WRONG_STATE_FORMAT(state)); 15 | }); 16 | }); 17 | }); 18 | }); -------------------------------------------------------------------------------- /examples/todo-app/src/components/icons/SquareOCheckIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SquareOCheckIcon = () => ; 4 | 5 | export default SquareOCheckIcon; -------------------------------------------------------------------------------- /src/helpers/__tests__/validateConfig.spec.js: -------------------------------------------------------------------------------- 1 | import validateConfig from '../validateConfig'; 2 | import { ERROR_MISSING_STATE, ERROR_MISSING_TRANSITIONS } from '../../constants'; 3 | 4 | describe('Given the validateConfig helper function', function () { 5 | 6 | describe('when validating config', function () { 7 | it('should throw errors if state or transitions are missing', function () { 8 | expect(validateConfig.bind(null, { transitions: {} })).to.throw(ERROR_MISSING_STATE); 9 | expect(validateConfig.bind(null, { state: {} })).to.throw(ERROR_MISSING_TRANSITIONS); 10 | expect(validateConfig({ state: {}, transitions: {} })).to.equal(true); 11 | }); 12 | }); 13 | 14 | }); -------------------------------------------------------------------------------- /src/helpers/toCamelCase.js: -------------------------------------------------------------------------------- 1 | const startRe = /^[\W_]+/; 2 | const re = /[\W_]+/g; 3 | 4 | export default text => { 5 | return ( 6 | text 7 | // Trim the delimiter from the start of the string 8 | // to ensure the starting character in the result is never capitalized 9 | // e.g., `-camel-case` --> 'camelCase' instead of 'CamelCase' 10 | .replace(startRe, "") 11 | .split(re) 12 | .reduce((result, word, idx) => { 13 | if (idx === 0) { 14 | word = word.charAt(0).toLowerCase() + word.substr(1); 15 | } else { 16 | word = word.charAt(0).toUpperCase() + word.substr(1); 17 | } 18 | result += word; 19 | return result; 20 | }, "") 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /lib/helpers/validateState.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 6 | 7 | exports.default = validateState; 8 | 9 | var _constants = require('../constants'); 10 | 11 | function validateState(state) { 12 | if (state && (typeof state === 'undefined' ? 'undefined' : _typeof(state)) === 'object' && typeof state.name !== 'undefined') return state; 13 | throw new Error((0, _constants.ERROR_WRONG_STATE_FORMAT)(state)); 14 | } 15 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/helpers/toCamelCase.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | var startRe = /^[\W_]+/; 5 | var re = /[\W_]+/g; 6 | 7 | exports.default = function (text) { 8 | return text 9 | // Trim the delimiter from the start of the string 10 | // to ensure the starting character in the result is never capitalized 11 | // e.g., `-camel-case` --> 'camelCase' instead of 'CamelCase' 12 | .replace(startRe, "").split(re).reduce(function (result, word, idx) { 13 | if (idx === 0) { 14 | word = word.charAt(0).toLowerCase() + word.substr(1); 15 | } else { 16 | word = word.charAt(0).toUpperCase() + word.substr(1); 17 | } 18 | result += word; 19 | return result; 20 | }, ""); 21 | }; 22 | 23 | module.exports = exports['default']; -------------------------------------------------------------------------------- /docs/state-object.md: -------------------------------------------------------------------------------- 1 | # State in the context of Stent 2 | 3 | [Full documentation](./README.md) 4 | 5 | --- 6 | 7 | The state in the context of Stent is represented by a _state object_. The state object is just a normal object literal. The only one required property is `name` and it is used to indicate the state of the machine: 8 | 9 | ```js 10 | { 11 | name: 'idle', 12 | user: { 13 | firstName: '...', 14 | lastName: '...' 15 | }, 16 | someOtherProperty: '...' 17 | } 18 | ``` 19 | 20 | If you try transitioning to a state which is not defined into the `transitions` section or it has no actions in it Stent will throw an exception. It's because once you get into that new state you are basically stuck. 21 | 22 | --- 23 | 24 | [Full documentation](./README.md) -------------------------------------------------------------------------------- /lib/helpers/__tests__/validateState.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _validateState = require('../validateState'); 4 | 5 | var _validateState2 = _interopRequireDefault(_validateState); 6 | 7 | var _constants = require('../../constants'); 8 | 9 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 10 | 11 | describe('Given the validateState helper', function () { 12 | describe('when using validateState', function () { 13 | it('throw an error if the state has no "name" property inside', function () { 14 | [{ answer: 42 }, null, undefined, 'a string', 42].forEach(function (state) { 15 | expect(_validateState2.default.bind(null, state)).to.throw((0, _constants.ERROR_WRONG_STATE_FORMAT)(state)); 16 | }); 17 | }); 18 | }); 19 | }); -------------------------------------------------------------------------------- /lib/helpers/handleActionLatest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.default = handleActionLatest; 5 | 6 | var _handleAction = require('./handleAction'); 7 | 8 | var _handleAction2 = _interopRequireDefault(_handleAction); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | var actions = {}; 13 | 14 | function handleActionLatest(machine, action) { 15 | actions[action] && actions[action](); 16 | 17 | for (var _len = arguments.length, payload = Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) { 18 | payload[_key - 2] = arguments[_key]; 19 | } 20 | 21 | actions[action] = _handleAction2.default.apply(undefined, [machine, action].concat(payload)); 22 | }; 23 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/helpers/__tests__/validateConfig.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _validateConfig = require('../validateConfig'); 4 | 5 | var _validateConfig2 = _interopRequireDefault(_validateConfig); 6 | 7 | var _constants = require('../../constants'); 8 | 9 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 10 | 11 | describe('Given the validateConfig helper function', function () { 12 | 13 | describe('when validating config', function () { 14 | it('should throw errors if state or transitions are missing', function () { 15 | expect(_validateConfig2.default.bind(null, { transitions: {} })).to.throw(_constants.ERROR_MISSING_STATE); 16 | expect(_validateConfig2.default.bind(null, { state: {} })).to.throw(_constants.ERROR_MISSING_TRANSITIONS); 17 | expect((0, _validateConfig2.default)({ state: {}, transitions: {} })).to.equal(true); 18 | }); 19 | }); 20 | }); -------------------------------------------------------------------------------- /lib/middlewares/Logger.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | var Logger = { 5 | onActionDispatched: function onActionDispatched(actionName) { 6 | for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { 7 | args[_key - 1] = arguments[_key]; 8 | } 9 | 10 | if (args.length === 0) { 11 | console.log(this.name + ": \"" + actionName + "\" dispatched"); 12 | } else { 13 | console.log(this.name + ": \"" + actionName + "\" dispatched with payload " + args); 14 | } 15 | }, 16 | onStateChanged: function onStateChanged() { 17 | console.log(this.name + ": state changed to \"" + this.state.name + "\""); 18 | }, 19 | onGeneratorStep: function onGeneratorStep(yielded) { 20 | console.log(this.name + ": generator step -> " + yielded); 21 | } 22 | }; 23 | 24 | exports.default = Logger; 25 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/helpers/handleMiddleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.default = handleMiddleware; 5 | 6 | var _ = require('../'); 7 | 8 | function handleMiddleware(hook, machine) { 9 | for (var _len = arguments.length, args = Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) { 10 | args[_key - 2] = arguments[_key]; 11 | } 12 | 13 | var middlewares = _.Machine.middlewares; 14 | 15 | if (middlewares.length === 0) { 16 | return; 17 | } 18 | 19 | var loop = function loop(index, process) { 20 | return index < middlewares.length - 1 ? process(index + 1) : null; 21 | }; 22 | 23 | (function process(index) { 24 | var middleware = middlewares[index]; 25 | 26 | if (middleware && typeof middleware[hook] !== 'undefined') { 27 | middleware[hook].apply(machine, args); 28 | } 29 | loop(index, process); 30 | })(0); 31 | } 32 | module.exports = exports['default']; -------------------------------------------------------------------------------- /src/helpers/updateState.js: -------------------------------------------------------------------------------- 1 | import validateState from './validateState'; 2 | import isEmptyObject from './isEmptyObject'; 3 | import handleMiddleware from './handleMiddleware'; 4 | import { 5 | MIDDLEWARE_PROCESS_STATE_CHANGE, 6 | MIDDLEWARE_STATE_WILL_CHANGE, 7 | ERROR_UNCOVERED_STATE 8 | } from '../constants'; 9 | 10 | export default function updateState(machine, state) { 11 | var newState; 12 | 13 | if (typeof state === 'undefined') return; 14 | if (typeof state === 'string' || typeof state === 'number') { 15 | newState = { name: state.toString() }; 16 | } else { 17 | newState = validateState(state); 18 | } 19 | 20 | if ( 21 | typeof machine.transitions[newState.name] === 'undefined' || 22 | isEmptyObject(machine.transitions[newState.name]) 23 | ) { 24 | throw new Error(ERROR_UNCOVERED_STATE(newState.name)); 25 | } 26 | 27 | handleMiddleware(MIDDLEWARE_STATE_WILL_CHANGE, machine); 28 | 29 | machine.state = newState; 30 | 31 | handleMiddleware(MIDDLEWARE_PROCESS_STATE_CHANGE, machine); 32 | 33 | } -------------------------------------------------------------------------------- /lib/helpers/validateConfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 6 | 7 | exports.default = validateConfig; 8 | 9 | var _constants = require('../constants'); 10 | 11 | function validateConfig(config) { 12 | if ((typeof config === 'undefined' ? 'undefined' : _typeof(config)) !== 'object') throw new Error(_constants.ERROR_MISSING_STATE); 13 | 14 | var state = config.state, 15 | transitions = config.transitions; 16 | 17 | 18 | if ((typeof state === 'undefined' ? 'undefined' : _typeof(state)) !== 'object') throw new Error(_constants.ERROR_MISSING_STATE); 19 | if ((typeof transitions === 'undefined' ? 'undefined' : _typeof(transitions)) !== 'object') throw new Error(_constants.ERROR_MISSING_TRANSITIONS); 20 | return true; 21 | } 22 | module.exports = exports['default']; -------------------------------------------------------------------------------- /src/helpers/__tests__/toCamelCase.spec.js: -------------------------------------------------------------------------------- 1 | import toCamelCase from '../toCamelCase'; 2 | 3 | describe('Given the toCamelCase helper', function () { 4 | describe('when using toCamelCase', function () { 5 | [ 6 | ['run', 'run'], 7 | ['a b', 'aB'], 8 | ['a b c', 'aBC'], 9 | ['the answer is 42', 'theAnswerIs42'], 10 | ['Hello World', 'helloWorld'], 11 | ['get-data-from-there', 'getDataFromThere'], 12 | ['another_mixed example-of^% a method', 'anotherMixedExampleOfAMethod'], 13 | ['startProcess', 'startProcess'], 14 | ['start Pro ceSs', 'startProCeSs'], 15 | ['/initializing', 'initializing'], 16 | ['/initializing/', 'initializing'], 17 | ['/initializing/stage-one', 'initializingStageOne'], 18 | ['/initializing/stage_one', 'initializingStageOne'], 19 | ].forEach(testCase => { 20 | it(`should transform "${ testCase[0] }" to "${ testCase[1] }"`, function () { 21 | expect(toCamelCase(testCase[0])).to.equal(testCase[1]); 22 | }); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /lib/helpers/__tests__/toCamelCase.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _toCamelCase = require('../toCamelCase'); 4 | 5 | var _toCamelCase2 = _interopRequireDefault(_toCamelCase); 6 | 7 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 8 | 9 | describe('Given the toCamelCase helper', function () { 10 | describe('when using toCamelCase', function () { 11 | [['run', 'run'], ['a b', 'aB'], ['a b c', 'aBC'], ['the answer is 42', 'theAnswerIs42'], ['Hello World', 'helloWorld'], ['get-data-from-there', 'getDataFromThere'], ['another_mixed example-of^% a method', 'anotherMixedExampleOfAMethod'], ['startProcess', 'startProcess'], ['start Pro ceSs', 'startProCeSs'], ['/initializing', 'initializing'], ['/initializing/', 'initializing'], ['/initializing/stage-one', 'initializingStageOne'], ['/initializing/stage_one', 'initializingStageOne']].forEach(function (testCase) { 12 | it('should transform "' + testCase[0] + '" to "' + testCase[1] + '"', function () { 13 | expect((0, _toCamelCase2.default)(testCase[0])).to.equal(testCase[1]); 14 | }); 15 | }); 16 | }); 17 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Krasimir Tsonev 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | TODO 61 | _logo/logo.psd 62 | 63 | .vscode -------------------------------------------------------------------------------- /src/helpers/registerMethods.js: -------------------------------------------------------------------------------- 1 | import toCamelCase from './toCamelCase'; 2 | import { ERROR_RESERVED_WORD_USED_AS_ACTION } from '../constants'; 3 | 4 | const reserved = ['name', 'transitions', 'state', 'destroy']; 5 | 6 | export default function registerMethods(machine, transitions, dispatch, dispatchLatest) { 7 | for(var state in transitions) { 8 | 9 | (function (state) { 10 | machine[toCamelCase(`is ${ state }`)] = function() { 11 | return machine.state.name === state; 12 | } 13 | })(state); 14 | 15 | for(var action in transitions[state]) { 16 | const normalized = toCamelCase(action); 17 | const normalizedAllowed = toCamelCase(`is ${ action } allowed`); 18 | if (reserved.indexOf(normalized) >= 0) { 19 | throw new Error(ERROR_RESERVED_WORD_USED_AS_ACTION(normalized)); 20 | } 21 | (function(n, na, a) { 22 | machine[n] = (...payload) => dispatch(a, ...payload); 23 | machine[n].latest = (...payload) => dispatchLatest(a, ...payload); 24 | machine[na] = () => !transitions[machine.state.name] || typeof transitions[machine.state.name][a] !== 'undefined'; 25 | })(normalized, normalizedAllowed, action); 26 | } 27 | 28 | } 29 | } -------------------------------------------------------------------------------- /examples/todo-app/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'stent/lib/react'; 3 | import AddNewTodo from './AddNewTodo'; 4 | import ToDos from './ToDos'; 5 | import PropTypes from 'prop-types'; 6 | 7 | class App extends React.Component { 8 | componentDidMount() { 9 | this.props.fetchTodos(); 10 | } 11 | render() { 12 | const { fetchTodos, isFetching, error } = this.props; 13 | 14 | if (isFetching()) { 15 | return

Loading

; 16 | } 17 | if (error) { 18 | return ( 19 |
20 |

Oops, something happened ...

21 |

{ error }

22 | 23 |
24 | ) 25 | } 26 | return ( 27 |
28 | 29 |
30 | 31 |
32 |
33 | ); 34 | } 35 | } 36 | 37 | App.propTypes = { 38 | fetchTodos: PropTypes.func 39 | } 40 | 41 | export default connect(App) 42 | .with('ToDos') 43 | .map(({ fetchTodos, isFetching, state }) => ({ 44 | fetchTodos, 45 | isFetching, 46 | error: state.error 47 | })); -------------------------------------------------------------------------------- /src/createMachine.js: -------------------------------------------------------------------------------- 1 | import handleAction from './helpers/handleAction'; 2 | import handleActionLatest from './helpers/handleActionLatest'; 3 | import validateConfig from './helpers/validateConfig'; 4 | import registerMethods from './helpers/registerMethods'; 5 | 6 | var IDX = 0; 7 | const getMachineID = () => `_@@@${ ++IDX }`; 8 | 9 | export default function createMachine(name, config) { 10 | if (typeof name === 'object') { 11 | if (typeof config === 'undefined') { 12 | config = name; 13 | name = getMachineID(); 14 | } else { 15 | config = { 16 | state: name, 17 | transitions: config 18 | } 19 | name = getMachineID(); 20 | } 21 | } 22 | 23 | const machine = { name }; 24 | 25 | validateConfig(config); 26 | 27 | const { state: initialState, transitions} = config; 28 | const dispatch = (action, ...payload) => handleAction(machine, action, ...payload); 29 | const dispatchLatest = (action, ...payload) => handleActionLatest(machine, action, ...payload); 30 | 31 | machine.state = initialState; 32 | machine.transitions = transitions; 33 | 34 | registerMethods( 35 | machine, 36 | transitions, 37 | dispatch, 38 | dispatchLatest 39 | ); 40 | 41 | return machine; 42 | } 43 | -------------------------------------------------------------------------------- /examples/todo-app/src/components/AddNewTodo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'stent/lib/react'; 3 | 4 | class AddNewTodo extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this._onKeyUp = this._onKeyUp.bind(this); 9 | this._onChange = this._onChange.bind(this); 10 | this.state = { value: '' }; 11 | } 12 | componentDidMount() { 13 | this.textInput.focus(); 14 | } 15 | _onChange(event) { 16 | this.setState({ value: event.target.value }); 17 | } 18 | _onKeyUp(event) { 19 | const value = this.state.value; 20 | 21 | if (event.key === 'Enter') { 22 | this.props.addNewTodo({ 23 | label: value, 24 | done: false 25 | }); 26 | this.setState({ value: '' }); 27 | } 28 | } 29 | render() { 30 | return ( 31 |
32 | this.textInput = input } 34 | onKeyUp={ this._onKeyUp } 35 | onChange={ this._onChange } 36 | value={ this.state.value } 37 | placeholder='Type here ...' 38 | /> 39 |
40 | ); 41 | } 42 | } 43 | 44 | export default connect(AddNewTodo) 45 | .with('ToDos') 46 | .mapOnce(({ addNewTodo }) => ({ addNewTodo })); -------------------------------------------------------------------------------- /src/helpers/vendors/SerializeError.js: -------------------------------------------------------------------------------- 1 | // Credits: https://github.com/sindresorhus/serialize-error 2 | 3 | 'use strict'; 4 | 5 | module.exports = value => { 6 | if (typeof value === 'object') { 7 | return destroyCircular(value, []); 8 | } 9 | 10 | // People sometimes throw things besides Error objects, so… 11 | 12 | if (typeof value === 'function') { 13 | // JSON.stringify discards functions. We do too, unless a function is thrown directly. 14 | return `[Function: ${(value.name || 'anonymous')}]`; 15 | } 16 | 17 | return value; 18 | }; 19 | 20 | // https://www.npmjs.com/package/destroy-circular 21 | function destroyCircular(from, seen) { 22 | const to = Array.isArray(from) ? [] : {}; 23 | 24 | seen.push(from); 25 | 26 | for (const key of Object.keys(from)) { 27 | const value = from[key]; 28 | 29 | if (typeof value === 'function') { 30 | continue; 31 | } 32 | 33 | if (!value || typeof value !== 'object') { 34 | to[key] = value; 35 | continue; 36 | } 37 | 38 | if (seen.indexOf(from[key]) === -1) { 39 | to[key] = destroyCircular(from[key], seen.slice(0)); 40 | continue; 41 | } 42 | 43 | to[key] = '[Circular]'; 44 | } 45 | 46 | if (typeof from.name === 'string') { 47 | to.name = from.name; 48 | } 49 | 50 | if (typeof from.message === 'string') { 51 | to.message = from.message; 52 | } 53 | 54 | if (typeof from.stack === 'string') { 55 | to.stack = from.stack; 56 | } 57 | 58 | return to; 59 | } -------------------------------------------------------------------------------- /lib/helpers/updateState.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.default = updateState; 5 | 6 | var _validateState = require('./validateState'); 7 | 8 | var _validateState2 = _interopRequireDefault(_validateState); 9 | 10 | var _isEmptyObject = require('./isEmptyObject'); 11 | 12 | var _isEmptyObject2 = _interopRequireDefault(_isEmptyObject); 13 | 14 | var _handleMiddleware = require('./handleMiddleware'); 15 | 16 | var _handleMiddleware2 = _interopRequireDefault(_handleMiddleware); 17 | 18 | var _constants = require('../constants'); 19 | 20 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 21 | 22 | function updateState(machine, state) { 23 | var newState; 24 | 25 | if (typeof state === 'undefined') return; 26 | if (typeof state === 'string' || typeof state === 'number') { 27 | newState = { name: state.toString() }; 28 | } else { 29 | newState = (0, _validateState2.default)(state); 30 | } 31 | 32 | if (typeof machine.transitions[newState.name] === 'undefined' || (0, _isEmptyObject2.default)(machine.transitions[newState.name])) { 33 | throw new Error((0, _constants.ERROR_UNCOVERED_STATE)(newState.name)); 34 | } 35 | 36 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_STATE_WILL_CHANGE, machine); 37 | 38 | machine.state = newState; 39 | 40 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_PROCESS_STATE_CHANGE, machine); 41 | } 42 | module.exports = exports['default']; -------------------------------------------------------------------------------- /docs/react-integration.md: -------------------------------------------------------------------------------- 1 | # Integrating with React 2 | 3 | [Full documentation](./README.md) 4 | 5 | --- 6 | 7 | Stent provides a `connect` helper that creates a [HoC](https://github.com/krasimir/react-in-patterns/tree/master/book/chapter-4/README.md#higher-order-component). It gets re-rendered every time when the machine updates its state: 8 | 9 | ```js 10 | import React from 'react'; 11 | import { connect } from 'stent/lib/react'; 12 | 13 | class TodoList extends React.Component { 14 | render() { 15 | const { isIdle, todos } = this.props; 16 | ... 17 | } 18 | } 19 | 20 | // `MachineA` and `MachineB` are machines defined 21 | // using `Machine.create` function 22 | export default connect(TodoList) 23 | .with('MachineA', 'MachineB') 24 | .map((MachineA, MachineB) => ({ 25 | isIdle: MachineA.isIdle, 26 | todos: MachineB.state.todos 27 | })); 28 | ``` 29 | 30 | The result of the mapping function goes as props to our component. Similarly to [Redux's connect `mapStateToProps`](https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options) function. And of course the mapping function is `disconnect`ed when the component is unmounted. 31 | 32 | Sometimes we want just the state changes subscription. In such cases we may skip the mapping function: 33 | 34 | ```js 35 | const ConnectedComponent = connect(TodoList).with('MachineA', 'MachineB').map(); 36 | ``` 37 | 38 | *`mapOnce` and `mapSilent` are also available for this React's helper.* 39 | 40 | --- 41 | 42 | [Full documentation](./README.md) 43 | -------------------------------------------------------------------------------- /examples/todo-app/src/machines/ToDos.js: -------------------------------------------------------------------------------- 1 | import { call } from 'stent/lib/helpers'; 2 | import storage from '../storage'; 3 | 4 | function * saveTodos (todos) { 5 | try { 6 | yield call(storage.save, [ ...todos ]); 7 | return { name: 'idle', todos: [ ...todos ] }; 8 | } catch(error) { 9 | throw new Error(error); 10 | } 11 | } 12 | 13 | export default { 14 | state: { name: 'idle', todos: [] }, 15 | transitions: { 16 | idle: { 17 | 'fetch todos': function * () { 18 | yield 'fetching'; 19 | try { 20 | this.todosLoaded(yield call(storage.load)); 21 | } catch (error) { 22 | this.error('Can not load the ToDos. Reason: ' + error); 23 | } 24 | }, 25 | 'add new todo': function * ({ todos }, todo) { 26 | return yield call(saveTodos, [...todos, todo]); 27 | }, 28 | 'delete todo': function * ({ todos }, index) { 29 | todos.splice(index, 1); 30 | return yield call(saveTodos, todos); 31 | }, 32 | 'edit todo': function * ({ todos }, index, label) { 33 | todos[index].label = label; 34 | return yield call(saveTodos, todos); 35 | }, 36 | 'change status': function * ({ todos }, index, done) { 37 | todos[index].done = done; 38 | return yield call(saveTodos, todos); 39 | } 40 | }, 41 | fetching: { 42 | 'todos loaded': (machine, todos) => ({ name: 'idle', todos }), 43 | 'error': (machine, error) => ({ name: 'error', error }) 44 | }, 45 | error: { 46 | 'fetch todos': function * () { 47 | yield 'idle'; 48 | this.fetchTodos(); 49 | } 50 | } 51 | } 52 | }; -------------------------------------------------------------------------------- /src/react/connect.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import connect from '../helpers/connect'; 3 | 4 | export default function(Component) { 5 | const withFunc = (...names) => { 6 | const mapFunc = (done, once, silent) => { 7 | const mapping = once ? "mapOnce" : silent ? "mapSilent" : "map"; 8 | 9 | return class StentConnect extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | 13 | this.initialStateHasBeenSet = false; 14 | this.state = {}; 15 | 16 | this.disconnect = connect({ 17 | meta: { component: Component.name } 18 | }) 19 | .with(...names) 20 | [mapping]((...deps) => { 21 | const nextState = done ? done(...deps) : {}; 22 | 23 | if ( 24 | this.initialStateHasBeenSet === false && 25 | mapping !== 'mapSilent' 26 | ) { 27 | this.state = nextState; 28 | this.initialStateHasBeenSet = true; 29 | return; 30 | } 31 | 32 | this.setState(function () { 33 | return nextState; 34 | }); 35 | }); 36 | } 37 | 38 | componentWillUnmount() { 39 | if (this.disconnect) { 40 | this.disconnect(); 41 | } 42 | } 43 | 44 | render() { 45 | return ; 46 | } 47 | } 48 | } 49 | 50 | return { 51 | 'map': mapFunc, 52 | 'mapOnce': done => mapFunc(done, true), 53 | 'mapSilent': done => mapFunc(done, false, true), 54 | } 55 | } 56 | 57 | return { 'with': withFunc }; 58 | } 59 | -------------------------------------------------------------------------------- /src/helpers/__tests__/call.spec.js: -------------------------------------------------------------------------------- 1 | import connect from '../connect'; 2 | import call from '../generators/call'; 3 | import { Machine } from '../../'; 4 | 5 | const IDLE = 'IDLE'; 6 | const END = 'END'; 7 | const FAILURE = 'FAILURE'; 8 | 9 | const makeState = function(name = 'INVALID', error = null) { 10 | return { name, error }; 11 | }; 12 | 13 | describe('Given the call helper', function () { 14 | beforeEach(() => { 15 | Machine.flush(); 16 | }); 17 | describe('when calling it with a non-function argument', function () { 18 | it('should catch the error and transition to Failure state with a meaningful error message', function (done) { 19 | const missingFunc = undefined; 20 | const errMessage = "The argument passed to `call` is falsy (undefined)"; 21 | 22 | const machine = Machine.create('A', { 23 | state: makeState(IDLE), 24 | transitions: { 25 | [IDLE]: { 26 | run: function * run () { 27 | try { 28 | // throw new Error(errMessage) 29 | yield call(missingFunc); 30 | yield makeState(END); 31 | } catch (e) { 32 | yield makeState(FAILURE, e); 33 | } 34 | } 35 | }, 36 | [END]: { 37 | reload: makeState(IDLE) 38 | }, 39 | [FAILURE]: { 40 | reload: makeState(IDLE) 41 | } 42 | } 43 | }); 44 | 45 | connect() 46 | .with('A') 47 | .mapSilent(A => { 48 | expect(A.state.name).to.equal(FAILURE); 49 | expect(A.state.error.message).to.equal(errMessage); 50 | done(); 51 | }); 52 | 53 | machine.run(); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /examples/todo-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/helpers/__tests__/handleGenerator.spec.js: -------------------------------------------------------------------------------- 1 | import handleGenerator from '../handleGenerator'; 2 | import { call } from '../'; 3 | 4 | describe('Given the handleGenerator helper', function () { 5 | describe('when we run the same generator again', function () { 6 | describe('and we want to cancel the first one', function () { 7 | it('should cancel the second generator', function (done) { 8 | const testCases = [ 9 | { timeout: 20, answer: 'a'}, 10 | { timeout: 10, answer: 'b'} 11 | ]; 12 | const delay = ({ timeout, answer }) => new Promise(resolve => { 13 | setTimeout(() => resolve(answer), timeout); 14 | }); 15 | const onGeneratorEnds = sinon.spy(); 16 | const generator = function * () { 17 | return yield call(function * () { 18 | return yield call(() => delay(testCases.shift())); 19 | }); 20 | } 21 | 22 | const cancel = handleGenerator({}, generator(), onGeneratorEnds); 23 | handleGenerator({}, generator(), onGeneratorEnds); 24 | cancel(); 25 | 26 | setTimeout(function () { 27 | expect(onGeneratorEnds).to.be.calledOnce.and.to.be.calledWith('b'); 28 | done(); 29 | }, 30); 30 | }); 31 | }); 32 | }); 33 | 34 | it("should catch errors in the function result of the call helper", function () { 35 | const mistake = () => { 36 | throw new Error("oops"); 37 | }; 38 | 39 | const generator = function* () { 40 | try { 41 | yield call(mistake); 42 | } catch (err) { 43 | return yield call(() => err.message); 44 | } 45 | }; 46 | 47 | handleGenerator({}, generator(), (result) => 48 | expect(result).to.be.equal("oops") 49 | ); 50 | }); 51 | }); -------------------------------------------------------------------------------- /examples/todo-app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | font-family: sans-serif; 7 | font-size: 18px; 8 | } 9 | section { 10 | padding: 1em; 11 | } 12 | h1, h2, h3, h4 { 13 | padding-top: 0; 14 | margin-top: 0; 15 | } 16 | button { 17 | font-size: 1em; 18 | padding: 0.6em 1em; 19 | color: #FFF; 20 | background-color: #66A1E8; 21 | border: none; 22 | border-radius: 0.4em; 23 | cursor: pointer; 24 | } 25 | * { 26 | box-sizing: border-box; 27 | } 28 | 29 | section { 30 | padding: 0 1em 0 1em; 31 | } 32 | 33 | .container { 34 | margin-top: 1em; 35 | } 36 | 37 | .addNewToDo input { 38 | font-size: 1em; 39 | font-family: sans-serif; 40 | width: 100%; 41 | padding: 1em; 42 | border: solid 2px #999; 43 | } 44 | .addNewToDo input:focus { 45 | border-color: #2787B0; 46 | } 47 | 48 | .todos ul { 49 | list-style: none; 50 | margin: 0; 51 | padding: 0; 52 | } 53 | .todos ul li { 54 | margin: 0; 55 | padding: 1em; 56 | width: 100%; 57 | border-bottom: solid 2px #999; 58 | background: #E8A8A8; 59 | transition: background-color ease 600ms; 60 | position: relative; 61 | } 62 | .todos ul li .statusIcon { 63 | position: absolute; 64 | left: 1em; 65 | top: 14px; 66 | } 67 | .todos ul li .deleteIcon { 68 | position: absolute; 69 | right: 1em; 70 | top: 14px; 71 | } 72 | .todos ul li.done { 73 | background: #7FC7A1; 74 | } 75 | .todos ul li span { 76 | display: block; 77 | cursor: pointer; 78 | padding: 0 4em 0 4em; 79 | text-align: center; 80 | } 81 | .todos ul li span input { 82 | width: 80%; 83 | padding: 0.5em; 84 | } 85 | .todos ul li a, .todos ul li label { 86 | cursor: pointer; 87 | } 88 | .todos ul li label input { 89 | display: inline-block; 90 | margin-right: 1em; 91 | } -------------------------------------------------------------------------------- /src/helpers/__tests__/handleActionLatest.spec.js: -------------------------------------------------------------------------------- 1 | import handleActionLatest from '../handleActionLatest'; 2 | import { call } from '../'; 3 | import { Machine } from '../../'; 4 | 5 | describe('Given the handleActionLatest helper', function () { 6 | beforeEach(() => { 7 | Machine.flush(); 8 | }); 9 | describe('and we fire same action twice within the same state', function () { 10 | it('should kill the first generator and its processes leaving only the new one working', function (done) { 11 | const handlerSpyA = sinon.spy(); 12 | const handlerSpyB = sinon.spy(); 13 | const timeouts = [20, 10]; 14 | const results = ['foo', 'bar']; 15 | const apiPromise = function() { 16 | return new Promise(resolve => { 17 | const result = results.shift(); 18 | 19 | setTimeout(() => resolve(result), timeouts.shift()); 20 | }); 21 | } 22 | const api = function * () { 23 | handlerSpyA(); 24 | const newState = { name: yield call(apiPromise) }; 25 | handlerSpyB(); 26 | return newState; 27 | } 28 | const handler = function * () { 29 | return yield call(function * () { 30 | return yield call(api, 'stent'); 31 | }); 32 | } 33 | const machine = { 34 | state: { name: 'idle' }, 35 | transitions: { 36 | idle: { run: handler }, 37 | 'foo': 'a', 38 | 'bar': 'a' 39 | } 40 | }; 41 | 42 | handleActionLatest(machine, 'run'); 43 | handleActionLatest(machine, 'run'); 44 | 45 | setTimeout(() => { 46 | expect(handlerSpyA).to.be.calledTwice; 47 | expect(handlerSpyB).to.be.calledOnce; 48 | expect(machine.state.name).to.equal('bar'); 49 | done(); 50 | }, 31); 51 | }) 52 | }); 53 | }); -------------------------------------------------------------------------------- /src/helpers/handleAction.js: -------------------------------------------------------------------------------- 1 | import { 2 | ERROR_UNCOVERED_STATE, 3 | ERROR_NOT_SUPPORTED_HANDLER_TYPE, 4 | TRANSITIONS_STORAGE, 5 | MIDDLEWARE_PROCESS_ACTION, 6 | MIDDLEWARE_ACTION_PROCESSED 7 | } from '../constants'; 8 | import updateState from './updateState'; 9 | import handleMiddleware from './handleMiddleware'; 10 | import handleGenerator from './handleGenerator'; 11 | 12 | export default function handleAction(machine, action, ...payload) { 13 | const { state, transitions } = machine; 14 | 15 | if (!transitions[state.name]) return false; 16 | 17 | const handler = transitions[state.name][action]; 18 | 19 | if (typeof handler === 'undefined') return false; 20 | 21 | handleMiddleware(MIDDLEWARE_PROCESS_ACTION, machine, action, ...payload); 22 | 23 | // string as a handler 24 | if (typeof handler === 'string') { 25 | updateState(machine, { ...state, name: transitions[state.name][action] }); 26 | 27 | // object as a handler 28 | } else if (typeof handler === 'object') { 29 | updateState(machine, handler); 30 | 31 | // function as a handler 32 | } else if (typeof handler === 'function') { 33 | const response = transitions[state.name][action](machine, ...payload); 34 | 35 | // generator 36 | if (response && typeof response.next === 'function') { 37 | const generator = response; 38 | 39 | return handleGenerator(machine, generator, response => { 40 | updateState(machine, response); 41 | handleMiddleware(MIDDLEWARE_ACTION_PROCESSED, machine, action, ...payload); 42 | }); 43 | } else { 44 | updateState(machine, response); 45 | } 46 | 47 | 48 | // wrong type of handler 49 | } else { 50 | throw new Error(ERROR_NOT_SUPPORTED_HANDLER_TYPE); 51 | } 52 | 53 | handleMiddleware(MIDDLEWARE_ACTION_PROCESSED, machine, action, ...payload); 54 | }; 55 | -------------------------------------------------------------------------------- /docs/connect-and-disconnect.md: -------------------------------------------------------------------------------- 1 | # `connect` and `disconnect` 2 | 3 | [Full documentation](./README.md) 4 | 5 | --- 6 | 7 | `connect` is the short way to do `Machine.get` and retrieving one or more created machines. It also provides a mechanism for subscribing for machine's state changes. 8 | 9 | ```js 10 | import { connect } from 'stent/lib/helpers'; 11 | 12 | Machine.create('MachineA', ...); 13 | Machine.create('MachineB', ...); 14 | 15 | connect() 16 | .with('MachineA', 'MachineB') 17 | .map((MachineA, MachineB) => { 18 | // called once by default and then 19 | // multiple times when the state of 20 | // MachineA or MachineB changes 21 | }); 22 | ``` 23 | 24 | The mapping function by default is called once initially and then every time when the state of the connected machines changes. So, if you need only that first call use `mapOnce` instead. 25 | 26 | ```js 27 | connect() 28 | .with('MachineA', 'MachineB') 29 | .mapOnce((MachineA, MachineB) => { 30 | // this gets called only once 31 | }); 32 | ``` 33 | 34 | If you want to use `map` but skip the initial call of your mapping function then use `mapSilent`: 35 | 36 | ```js 37 | connect() 38 | .with('MachineA', 'MachineB') 39 | .mapSilent((MachineA, MachineB) => { 40 | // called multiple times when the state of 41 | // MachineA or MachineB changes 42 | }); 43 | ``` 44 | 45 | You may also need to `disconnect` which makes sense if you use the `map` function. If you are connecting with `mapOnce` your mapping function is getting called only once anyway. 46 | 47 | ```js 48 | const disconnect = connect() 49 | .with('MachineA', 'MachineB') 50 | .map((MachineA, MachineB) => { 51 | // called multiple times 52 | }); 53 | 54 | // at some point later 55 | disconnect(); 56 | ``` 57 | 58 | _If you are looking for connecting to a React component follow [this link](./react-integration.md)._ 59 | 60 | --- 61 | 62 | [Full documentation](./README.md) -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | // errors 2 | export const ERROR_MISSING_MACHINE = name => `There's no machine with name ${ name }`; 3 | export const ERROR_MISSING_STATE = 'Configuration error: missing initial "state"'; 4 | export const ERROR_MISSING_TRANSITIONS = 'Configuration error: missing "transitions"'; 5 | export const ERROR_WRONG_STATE_FORMAT = state => { 6 | const serialized = typeof state === 'object' ? JSON.stringify(state, null, 2) : state; 7 | 8 | return `The state should be an object and it should always have at least "name" property. You passed ${ serialized }`; 9 | } 10 | export const ERROR_UNCOVERED_STATE = state => `You just transitioned the machine to a state (${ state }) which is not defined or it has no actions. This means that the machine is stuck.`; 11 | export const ERROR_NOT_SUPPORTED_HANDLER_TYPE = 'Wrong handler type passed. Please read the docs https://github.com/krasimir/stent'; 12 | export const ERROR_RESERVED_WORD_USED_AS_ACTION = word => `Sorry, you can't use ${ word } as a name for an action. It is reserved.`; 13 | export const ERROR_GENERATOR_FUNC_CALL_FAILED = type => `The argument passed to \`call\` is falsy (${type})`; 14 | 15 | // middlewares 16 | export const MIDDLEWARE_PROCESS_ACTION = 'onActionDispatched'; 17 | export const MIDDLEWARE_ACTION_PROCESSED = 'onActionProcessed'; 18 | export const MIDDLEWARE_STATE_WILL_CHANGE = 'onStateWillChange'; 19 | export const MIDDLEWARE_PROCESS_STATE_CHANGE = 'onStateChanged'; 20 | export const MIDDLEWARE_GENERATOR_STEP = 'onGeneratorStep'; 21 | export const MIDDLEWARE_GENERATOR_END = 'onGeneratorEnd'; 22 | export const MIDDLEWARE_GENERATOR_RESUMED = 'onGeneratorResumed'; 23 | export const MIDDLEWARE_MACHINE_CREATED = 'onMachineCreated'; 24 | export const MIDDLEWARE_MACHINE_CONNECTED = 'onMachineConnected'; 25 | export const MIDDLEWARE_MACHINE_DISCONNECTED = 'onMachineDisconnected'; 26 | export const MIDDLEWARE_REGISTERED = 'onMiddlewareRegister'; 27 | 28 | // misc 29 | export const DEVTOOLS_KEY = '__hello__stent__'; -------------------------------------------------------------------------------- /lib/helpers/registerMethods.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.default = registerMethods; 5 | 6 | var _toCamelCase = require('./toCamelCase'); 7 | 8 | var _toCamelCase2 = _interopRequireDefault(_toCamelCase); 9 | 10 | var _constants = require('../constants'); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | var reserved = ['name', 'transitions', 'state', 'destroy']; 15 | 16 | function registerMethods(machine, transitions, dispatch, dispatchLatest) { 17 | for (var state in transitions) { 18 | 19 | (function (state) { 20 | machine[(0, _toCamelCase2.default)('is ' + state)] = function () { 21 | return machine.state.name === state; 22 | }; 23 | })(state); 24 | 25 | for (var action in transitions[state]) { 26 | var normalized = (0, _toCamelCase2.default)(action); 27 | var normalizedAllowed = (0, _toCamelCase2.default)('is ' + action + ' allowed'); 28 | if (reserved.indexOf(normalized) >= 0) { 29 | throw new Error((0, _constants.ERROR_RESERVED_WORD_USED_AS_ACTION)(normalized)); 30 | } 31 | (function (n, na, a) { 32 | machine[n] = function () { 33 | for (var _len = arguments.length, payload = Array(_len), _key = 0; _key < _len; _key++) { 34 | payload[_key] = arguments[_key]; 35 | } 36 | 37 | return dispatch.apply(undefined, [a].concat(payload)); 38 | }; 39 | machine[n].latest = function () { 40 | for (var _len2 = arguments.length, payload = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { 41 | payload[_key2] = arguments[_key2]; 42 | } 43 | 44 | return dispatchLatest.apply(undefined, [a].concat(payload)); 45 | }; 46 | machine[na] = function () { 47 | return !transitions[machine.state.name] || typeof transitions[machine.state.name][a] !== 'undefined'; 48 | }; 49 | })(normalized, normalizedAllowed, action); 50 | } 51 | } 52 | } 53 | module.exports = exports['default']; -------------------------------------------------------------------------------- /src/helpers/connect.js: -------------------------------------------------------------------------------- 1 | import { Machine } from '../'; 2 | import handleMiddleware from './handleMiddleware'; 3 | import { MIDDLEWARE_MACHINE_CONNECTED, MIDDLEWARE_MACHINE_DISCONNECTED } from '../constants'; 4 | 5 | var idIndex = 0; 6 | var mappings = null; 7 | 8 | const getId = () => ('m' + (++idIndex)); 9 | const setup = () => { 10 | if (mappings !== null) return; 11 | mappings = {}; 12 | Machine.addMiddleware({ 13 | onStateChanged() { 14 | for (var id in mappings) { 15 | const { done, machines } = mappings[id]; 16 | 17 | if (machines.map(m => m.name).indexOf(this.name) >= 0) { 18 | done && done(...machines); 19 | } 20 | } 21 | } 22 | }); 23 | } 24 | 25 | export function flush() { 26 | mappings = null; 27 | } 28 | 29 | export function getMapping() { 30 | return mappings; 31 | } 32 | 33 | export function destroy(machineId) { 34 | for(var mId in mappings) { 35 | mappings[mId].machines = mappings[mId].machines.filter(({ name }) => name !== machineId); 36 | handleMiddleware(MIDDLEWARE_MACHINE_DISCONNECTED, null, mappings[mId].machines); 37 | if (mappings[mId].machines.length === 0) { 38 | delete mappings[mId]; 39 | } 40 | } 41 | } 42 | 43 | export default function connect({ meta } = {}) { 44 | setup(); 45 | const withFunc = (...names) => { 46 | const machines = names.map(name => Machine.get(name)); 47 | const mapFunc = (done, once, silent) => { 48 | const id = getId(); 49 | 50 | !once && (mappings[id] = { done, machines }); 51 | !silent && done && done(...machines); 52 | 53 | return function disconnect() { 54 | handleMiddleware(MIDDLEWARE_MACHINE_DISCONNECTED, null, machines, meta); 55 | if (mappings && mappings[id]) delete mappings[id]; 56 | } 57 | } 58 | 59 | handleMiddleware(MIDDLEWARE_MACHINE_CONNECTED, null, machines, meta); 60 | return { 61 | 'map': mapFunc, 62 | 'mapOnce': done => mapFunc(done, true), 63 | 'mapSilent': done => mapFunc(done, false, true) 64 | } 65 | } 66 | 67 | return { 'with': withFunc }; 68 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createMachine from './createMachine'; 2 | import { 3 | ERROR_MISSING_MACHINE, 4 | DEVTOOLS_KEY, 5 | MIDDLEWARE_MACHINE_CREATED, 6 | MIDDLEWARE_REGISTERED 7 | } from './constants'; 8 | import connect from './helpers/connect'; 9 | import call from './helpers/generators/call'; 10 | import { flush as flushConnectSetup } from './helpers/connect'; 11 | import { destroy as cleanupConnections } from './helpers/connect'; 12 | import handleMiddleware from './helpers/handleMiddleware'; 13 | 14 | class MachineFactory { 15 | constructor() { 16 | this.machines = {}; 17 | this.middlewares = []; 18 | this.connect = connect; 19 | this.call = call; 20 | } 21 | create(name, config) { 22 | const machine = createMachine(name, config, this.middlewares); 23 | 24 | this.machines[machine.name] = machine; 25 | handleMiddleware(MIDDLEWARE_MACHINE_CREATED, machine, machine); 26 | machine.destroy = () => this.destroy(machine); 27 | return machine; 28 | } 29 | get(name) { 30 | if (typeof name === 'object') name = name.name; 31 | if (this.machines[name]) return this.machines[name]; 32 | throw new Error(ERROR_MISSING_MACHINE(name)); 33 | } 34 | flush() { 35 | this.machines = {}; 36 | this.middlewares = []; 37 | flushConnectSetup(); 38 | } 39 | addMiddleware(middleware) { 40 | if (Array.isArray(middleware)) { 41 | this.middlewares = this.middlewares.concat(middleware); 42 | } else { 43 | this.middlewares.push(middleware); 44 | } 45 | if (middleware.__initialize) middleware.__initialize(this); 46 | if (middleware[MIDDLEWARE_REGISTERED]) middleware[MIDDLEWARE_REGISTERED](); 47 | } 48 | destroy(machine) { 49 | var m = machine; 50 | if (typeof machine === 'string') { 51 | m = this.machines[machine]; 52 | if (!m) throw new Error(ERROR_MISSING_MACHINE(machine)); 53 | } 54 | delete this.machines[m.name]; 55 | cleanupConnections(m.name); 56 | } 57 | } 58 | 59 | const factory = new MachineFactory(); 60 | 61 | export { factory as Machine }; 62 | 63 | if (typeof window !== 'undefined') { 64 | window[DEVTOOLS_KEY] = factory; 65 | } -------------------------------------------------------------------------------- /lib/helpers/vendors/SerializeError.js: -------------------------------------------------------------------------------- 1 | // Credits: https://github.com/sindresorhus/serialize-error 2 | 3 | 'use strict'; 4 | 5 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 6 | 7 | module.exports = function (value) { 8 | if ((typeof value === 'undefined' ? 'undefined' : _typeof(value)) === 'object') { 9 | return destroyCircular(value, []); 10 | } 11 | 12 | // People sometimes throw things besides Error objects, so… 13 | 14 | if (typeof value === 'function') { 15 | // JSON.stringify discards functions. We do too, unless a function is thrown directly. 16 | return '[Function: ' + (value.name || 'anonymous') + ']'; 17 | } 18 | 19 | return value; 20 | }; 21 | 22 | // https://www.npmjs.com/package/destroy-circular 23 | function destroyCircular(from, seen) { 24 | var to = Array.isArray(from) ? [] : {}; 25 | 26 | seen.push(from); 27 | 28 | for (var _iterator = Object.keys(from), _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();;) { 29 | var _ref; 30 | 31 | if (_isArray) { 32 | if (_i >= _iterator.length) break; 33 | _ref = _iterator[_i++]; 34 | } else { 35 | _i = _iterator.next(); 36 | if (_i.done) break; 37 | _ref = _i.value; 38 | } 39 | 40 | var key = _ref; 41 | 42 | var value = from[key]; 43 | 44 | if (typeof value === 'function') { 45 | continue; 46 | } 47 | 48 | if (!value || (typeof value === 'undefined' ? 'undefined' : _typeof(value)) !== 'object') { 49 | to[key] = value; 50 | continue; 51 | } 52 | 53 | if (seen.indexOf(from[key]) === -1) { 54 | to[key] = destroyCircular(from[key], seen.slice(0)); 55 | continue; 56 | } 57 | 58 | to[key] = '[Circular]'; 59 | } 60 | 61 | if (typeof from.name === 'string') { 62 | to.name = from.name; 63 | } 64 | 65 | if (typeof from.message === 'string') { 66 | to.message = from.message; 67 | } 68 | 69 | if (typeof from.stack === 'string') { 70 | to.stack = from.stack; 71 | } 72 | 73 | return to; 74 | } -------------------------------------------------------------------------------- /docs/middlewares.md: -------------------------------------------------------------------------------- 1 | # Middlewares 2 | 3 | [Full documentation](./README.md) 4 | 5 | --- 6 | 7 | *By adding a middleware you are hooking to some internal lifecycle processes of Stent. Changing state or firing methods at this point may lead to bugs so avoid doing it.* 8 | 9 | If you want to extend the library with some additional functionalities you may add a middleware. In fact Stent uses middleware internally for implementing the `connect` helper. We have to call `addMiddleware` with a single parameter which is an object with a set of functions that hook to the lifecycle methods of Stent. 10 | 11 | ```js 12 | import { Machine } from 'stent'; 13 | 14 | Machine.addMiddleware({ 15 | onActionDispatched(actionName, ...args) { 16 | // ... 17 | }, 18 | onActionProcessed(actionName, ...args) { 19 | // ... 20 | }, 21 | onStateWillChange() { 22 | // ... 23 | }, 24 | onStateChanged() { 25 | // ... 26 | }, 27 | onGeneratorStep(yielded) { 28 | // You'll probably never need this hook. 29 | // It gets fired when you yield something in a generator 30 | // as an action handler. 31 | }, 32 | onGeneratorEnd(value) { 33 | // You'll probably never need this hook. 34 | // It gets fired when the generator is completed 35 | }, 36 | onGeneratorResumed(value) { 37 | // You'll probably never need this hook. 38 | // It gets fired when the generator is resumed 39 | }, 40 | onMachineCreated(machine) { 41 | // ... 42 | }, 43 | onMachineConnected(machines) { 44 | // ... 45 | }, 46 | onMachineDisconnected(machines) { 47 | // ... 48 | }, 49 | onMiddlewareRegister() { 50 | // Fired when this middleware is added 51 | } 52 | }); 53 | ``` 54 | 55 | Have in mind that these methods are fired with the machine as a context. Which means that we have an access to the current state and methods via a `this.` notation. 56 | 57 | *If you have more then one middleware to add pass an array of objects instead of multiple calls of `addMiddleware`.* 58 | 59 | There is one built-in middleware which is part of the Stent package - `Logger`. 60 | 61 | **`Logger`** 62 | 63 | It prints out some useful stuff in the dev tools console. 64 | 65 | ![Logger](./_images/Logger.png) 66 | 67 | ```js 68 | import { Machine } from 'stent'; 69 | import { Logger } from 'stent/lib/middlewares'; 70 | 71 | Machine.addMiddleware(Logger); 72 | ``` 73 | --- 74 | 75 | [Full documentation](./README.md) 76 | -------------------------------------------------------------------------------- /src/__tests__/action-handler-scheduling.spec.js: -------------------------------------------------------------------------------- 1 | import { Machine } from "../"; 2 | import { connect, call } from "../helpers"; 3 | 4 | const sleep = function sleep(ms = 100) { 5 | return new Promise(function(resolve) { 6 | setTimeout(resolve, ms); 7 | }); 8 | }; 9 | 10 | const noop = function noop(machine) { 11 | return { ...machine.state }; 12 | }; 13 | 14 | describe("Stent machine", function() { 15 | beforeEach(() => { 16 | Machine.flush(); 17 | }); 18 | 19 | describe("when an async action handler is running", function() { 20 | this.timeout(5000); 21 | 22 | it("should accept state changes from other action handlers", function() { 23 | return new Promise(function(resolve, reject) { 24 | const machine = Machine.create("app", { 25 | state: { name: "A" }, 26 | transitions: { 27 | A: { 28 | init: function* init(machine) { 29 | yield call(sleep, 100); 30 | 31 | yield { ...machine.state, name: "B" }; 32 | 33 | yield call(sleep, 100); 34 | 35 | return yield { ...machine.state, name: "C" }; 36 | }, 37 | goToD: function* goToD(machine) { 38 | yield call(sleep, 100); 39 | 40 | return yield { ...machine.state, name: "D" }; 41 | }, 42 | syncGoToE: function syncGoToE(machine) { 43 | return { ...machine.state, name: "E" }; 44 | } 45 | }, 46 | B: { 47 | noop 48 | }, 49 | C: { 50 | noop 51 | }, 52 | D: { 53 | noop 54 | }, 55 | E: { 56 | noop 57 | } 58 | } 59 | }); 60 | 61 | let actual = []; 62 | 63 | const expected = ["A", "E", "B", "D", "C"]; 64 | 65 | connect() 66 | .with(machine.name) 67 | .map(function(m) { 68 | const stateName = m.state.name; 69 | 70 | actual.push(stateName); 71 | 72 | if (stateName === "C") { 73 | try { 74 | expect(actual).to.deep.equal(expected); 75 | resolve(); 76 | } catch (err) { 77 | reject(err); 78 | } 79 | } 80 | }); 81 | 82 | machine.init(); 83 | machine.goToD(); 84 | machine.syncGoToE(); 85 | }); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /examples/todo-app/src/components/ToDo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'stent/lib/react'; 3 | import { connect as connectWithMachineOnly } from 'stent/lib/helpers' 4 | import { Machine } from 'stent'; 5 | import CloseIcon from './icons/CloseIcon'; 6 | import SquareOIcon from './icons/SquareOIcon'; 7 | import SquareOCheckIcon from './icons/SquareOCheckIcon'; 8 | 9 | const ENTER = 13; 10 | 11 | class Todo extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | 15 | this._editInputField = null; 16 | this._onEditFieldKeyUp = this._onEditFieldKeyUp.bind(this); 17 | this._onEditFieldBlur = this._onEditFieldBlur.bind(this); 18 | 19 | this._machine = Machine.create( 20 | { name: 'idle' }, 21 | { 22 | idle: { edit: 'editing' }, 23 | editing: { 24 | save: (state, newLabel) => { 25 | props.editTodo(props.index, newLabel); 26 | return 'idle'; 27 | }, 28 | cancel: 'idle' 29 | } 30 | } 31 | ); 32 | 33 | connectWithMachineOnly().with(this._machine).mapSilent(() => this.forceUpdate()); 34 | } 35 | componentWillUnmount() { 36 | this._machine.destroy(); 37 | } 38 | _onEditFieldKeyUp(event) { 39 | if (event.keyCode === ENTER) { 40 | this._machine.save(event.target.value); 41 | } 42 | } 43 | _onEditFieldBlur() { 44 | this._machine.cancel(); 45 | } 46 | _renderEditField(label) { 47 | return ( 48 | 49 | (input && input.focus()) } /> 54 | 55 | ) 56 | } 57 | render() { 58 | const { deleteTodo, changeStatus, index, done, label } = this.props; 59 | 60 | return ( 61 |
  • 62 | { this._machine.isEditing() ? 63 | this._renderEditField(label) : 64 | { label } 65 | } 66 | changeStatus(index, !done) } title='change status' className='statusIcon'> 67 | { done ? : } 68 | 69 | deleteTodo(index) } title='delete' className='deleteIcon'> 70 | 71 | 72 |
  • 73 | ) 74 | } 75 | } 76 | 77 | export default connect(Todo) 78 | .with('ToDos') 79 | .map(({ deleteTodo, changeStatus, editTodo }) => ({ 80 | deleteTodo, 81 | changeStatus, 82 | editTodo 83 | })); -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | [Full documentation](./README.md) 4 | 5 | --- 6 | 7 | ### Getting from Redux to stent 8 | 9 | [click here](http://krasimirtsonev.com/blog/article/getting-from-redux-to-state-machine-with-stent) 10 | 11 | ### React ToDo applciation using [create-react-app](https://github.com/facebookincubator/create-react-app) 12 | 13 | [Here.](../examples/todo-app) 14 | 15 | ### Small ToDo app 16 | 17 | ```js 18 | import { Machine } from 'stent'; 19 | 20 | const machine = Machine.create('app', { 21 | state: { name: 'idle', todos: [] }, 22 | transitions: { 23 | 'idle': { 24 | 'add new todo': function ({ state }, todo) { 25 | return { name: 'idle', todos: [...state.todos, todo] }; 26 | }, 27 | 'delete todo': function ({ state }, index) { 28 | return { name: 'idle', todos: state.todos.splice(index, 1) }; 29 | }, 30 | 'fetch todos': function * () { 31 | yield 'fetching'; 32 | 33 | try { 34 | const todos = yield call(getTodos, '/api/todos'); 35 | } catch (error) { 36 | return { name: 'fetching failed', error }; 37 | } 38 | 39 | return { name: 'idle', todos }; 40 | } 41 | }, 42 | 'fetching failed': { 43 | 'fetch todos': function * () { 44 | yield { name: 'idle', error: null }; 45 | this.fetchTodos(); 46 | } 47 | } 48 | } 49 | }); 50 | 51 | machine.fetchTodos(); 52 | ``` 53 | 54 | ### Integration with React 55 | 56 | ```js 57 | import React from 'react'; 58 | import { connect } from 'stent/lib/react'; 59 | 60 | class TodoList extends React.Component { 61 | render() { 62 | const { todos, error, isFetching, fetchTodos, deleteTodo, isAuthorized } = this.props; 63 | 64 | if (isFetching()) return

    Loading

    ; 65 | if (error) return ( 66 |
    67 | Error fetching todos: { error }
    68 | 69 |
    70 | ); 71 | 72 | return ( 73 |
      74 | { todos.map(({ text}) =>
    • { text }
    • ) } 75 |
    76 | ); 77 | } 78 | } 79 | 80 | // `todos` and `authorization` are machines defined 81 | // using `Machine.create` function 82 | export default connect(TodoList) 83 | .with('todos', 'authorization') 84 | .map(({ state, isFetching, fetchTodos, deleteTodo }, { isAuthorized }) => { 85 | todos: state.todos, 86 | error: state.error, 87 | isFetching, 88 | fetchTodos, 89 | deleteTodo, 90 | isAuthorized 91 | }); 92 | ``` 93 | 94 | --- 95 | 96 | [Full documentation](./README.md) 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stent", 3 | "version": "8.0.4", 4 | "description": "Stent is combining the ideas of redux with the concept of state machines", 5 | "main": "lib", 6 | "scripts": { 7 | "build": "npm run coverage && npm run test && babel src --out-dir lib && browserify ./lib/index.js -o ./standalone/stent.js --standalone stent && uglifyjs ./standalone/stent.js -o ./standalone/stent.min.js", 8 | "test": "./node_modules/.bin/mocha --require babel-register --require babel-polyfill --require test/setup.js --require jsdom-global/register --reporter spec --slow 100 './src/**/**.spec.{js,jsx}'", 9 | "test-watch": "./node_modules/.bin/mocha --require babel-register --require babel-polyfill --require test/setup.js --require jsdom-global/register --reporter spec --slow 100 --watch --watch-extensions jx,jsx,json './src/**/**.spec.{js,jsx}'", 10 | "t": "mocha ./test/setup.js ./test/specs/**/*.spec.js", 11 | "coverage": "./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --require babel-register --require babel-polyfill --require test/setup.js --require jsdom-global/register --reporter xunit --reporter-options output=reports/test-results.xml './src/**/**.spec.{js,jsx}'", 12 | "release": "npm run build" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/krasimir/stent.git" 17 | }, 18 | "keywords": [ 19 | "state", 20 | "finite", 21 | "state", 22 | "machine", 23 | "stent", 24 | "state machine", 25 | "mealy", 26 | "redux", 27 | "react", 28 | "connect", 29 | "transitions" 30 | ], 31 | "author": "Krasimir Tsonev", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/krasimir/stent/issues" 35 | }, 36 | "homepage": "https://github.com/krasimir/stent#readme", 37 | "devDependencies": { 38 | "babel-cli": "6.24.0", 39 | "babel-plugin-add-module-exports": "0.2.0", 40 | "babel-polyfill": "6.23.0", 41 | "babel-preset-es2015": "6.22.0", 42 | "babel-preset-react": "6.23.0", 43 | "babel-preset-stage-3": "6.22.0", 44 | "babel-register": "6.24.0", 45 | "browserify": "^14.4.0", 46 | "chai": "4.2.0", 47 | "chai-enzyme": "1.0.0-beta.1", 48 | "enzyme": "3.10.0", 49 | "enzyme-adapter-react-16": "1.14.0", 50 | "istanbul": "1.1.0-alpha.1", 51 | "jsdom": "9.8.3", 52 | "jsdom-global": "2.1.1", 53 | "mocha": "3.2.0", 54 | "react": "16.9.0", 55 | "react-addons-test-utils": "15.6.2", 56 | "react-dom": "16.9.0", 57 | "sinon": "2.0.0", 58 | "sinon-chai": "2.9.0", 59 | "uglify-js": "^3.0.28" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docs/machine.md: -------------------------------------------------------------------------------- 1 | # `Machine.` 2 | 3 | [Full documentation](./README.md) 4 | 5 | --- 6 | 7 | The `Machine` object is used for creating/managing and fetching machines. 8 | 9 | ```js 10 | import { Machine } from 'stent'; 11 | 12 | const appMachine = Machine.create( 13 | 'app', // name of the machine 14 | { 15 | state: , // initial state 16 | transitions: { 17 | : { 18 | : , 19 | : , 20 | ... 21 | }, 22 | : { 23 | : , 24 | : , 25 | ... 26 | }, 27 | ... 28 | } 29 | } 30 | ); 31 | 32 | // later in the code 33 | const appMachine = Machine.get('app'); 34 | ``` 35 | 36 | If you don't plan to reference the machine by name with `Machine.get` or with the `connect` helper then you may skip the first argument. In the example above if we skip `'app'` Stent will still create the machine but with a dynamically generated name. You may even reduce the noise and pass the state as a first argument and the transitions as second: 37 | 38 | ```js 39 | const appMachine = Machine.create( 40 | // initial state 41 | , 42 | // transitions 43 | { 44 | : { 45 | : , 46 | : , 47 | ... 48 | }, 49 | : { 50 | : , 51 | : , 52 | ... 53 | }, 54 | ... 55 | } 56 | ); 57 | ``` 58 | 59 | The created machine has the following methods: 60 | 61 | * `machine.destroy` - cleans the machine up 62 | * For every state there is a `is` method so we can check if the machine is in that state. For example, to check if the machine is in a `fetching remote data` state we may call `machine.isFetchingRemoteData()` method. The alternative is `machine.state.name === 'fetching remote data'`. 63 | * For every action there is a method to fire it. Whatever we pass goes to the handler. For example, `add new todo` is available as `machine.addNewTodo()`. 64 | * For every action there is a method to check whether this action is allowed/exist. For example, for `add new todo` we have `machine.isAddNewTodoAllowed()` (returns either `true` or `false`). 65 | 66 | --- 67 | 68 | `Machine.flush()` can be used to delete the currently created machines and [middlewares](./middlewares.md). `Machine.destroy()` can be used for deleting a machine. 69 | 70 | --- 71 | 72 | [Full documentation](./README.md) 73 | -------------------------------------------------------------------------------- /lib/createMachine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 6 | 7 | exports.default = createMachine; 8 | 9 | var _handleAction = require('./helpers/handleAction'); 10 | 11 | var _handleAction2 = _interopRequireDefault(_handleAction); 12 | 13 | var _handleActionLatest = require('./helpers/handleActionLatest'); 14 | 15 | var _handleActionLatest2 = _interopRequireDefault(_handleActionLatest); 16 | 17 | var _validateConfig = require('./helpers/validateConfig'); 18 | 19 | var _validateConfig2 = _interopRequireDefault(_validateConfig); 20 | 21 | var _registerMethods = require('./helpers/registerMethods'); 22 | 23 | var _registerMethods2 = _interopRequireDefault(_registerMethods); 24 | 25 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 26 | 27 | var IDX = 0; 28 | var getMachineID = function getMachineID() { 29 | return '_@@@' + ++IDX; 30 | }; 31 | 32 | function createMachine(name, config) { 33 | if ((typeof name === 'undefined' ? 'undefined' : _typeof(name)) === 'object') { 34 | if (typeof config === 'undefined') { 35 | config = name; 36 | name = getMachineID(); 37 | } else { 38 | config = { 39 | state: name, 40 | transitions: config 41 | }; 42 | name = getMachineID(); 43 | } 44 | } 45 | 46 | var machine = { name: name }; 47 | 48 | (0, _validateConfig2.default)(config); 49 | 50 | var _config = config, 51 | initialState = _config.state, 52 | transitions = _config.transitions; 53 | 54 | var dispatch = function dispatch(action) { 55 | for (var _len = arguments.length, payload = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { 56 | payload[_key - 1] = arguments[_key]; 57 | } 58 | 59 | return _handleAction2.default.apply(undefined, [machine, action].concat(payload)); 60 | }; 61 | var dispatchLatest = function dispatchLatest(action) { 62 | for (var _len2 = arguments.length, payload = Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { 63 | payload[_key2 - 1] = arguments[_key2]; 64 | } 65 | 66 | return _handleActionLatest2.default.apply(undefined, [machine, action].concat(payload)); 67 | }; 68 | 69 | machine.state = initialState; 70 | machine.transitions = transitions; 71 | 72 | (0, _registerMethods2.default)(machine, transitions, dispatch, dispatchLatest); 73 | 74 | return machine; 75 | } 76 | module.exports = exports['default']; -------------------------------------------------------------------------------- /src/middlewares/__tests__/Logger.spec.js: -------------------------------------------------------------------------------- 1 | import { Machine } from '../../'; 2 | import { Logger } from '../'; 3 | import { call } from '../../helpers'; 4 | 5 | describe('Given the Logger middleware', function () { 6 | beforeEach(() => { 7 | Machine.flush(); 8 | sinon.stub(console, 'log'); 9 | Machine.addMiddleware(Logger); 10 | }); 11 | afterEach(() => { 12 | console.log.restore(); 13 | }); 14 | describe('when using Logger with function and string as a handler', function () { 15 | it('should log to the console', function () { 16 | const machine = Machine.create( 17 | { name: 'idle' }, 18 | { 19 | idle: { 20 | run: function () { 21 | return 'running' 22 | } 23 | }, 24 | running: { 25 | stop: 'idle' 26 | } 27 | } 28 | ); 29 | 30 | machine.run({}, 42, 'hello world'); 31 | machine.stop(); 32 | 33 | expect(console.log.callCount).to.be.equal(4); 34 | [ 35 | `${ machine.name }: "run" dispatched with payload [object Object],42,hello world`, 36 | `${ machine.name }: state changed to "running"`, 37 | `${ machine.name }: "stop" dispatched`, 38 | `${ machine.name }: state changed to "idle"` 39 | ].forEach((logStr, i) => { 40 | expect(console.log.getCall(i)).to.be.calledWith(logStr); 41 | }); 42 | 43 | }); 44 | }); 45 | describe('when using the Logger with a generator function', function () { 46 | it('should log every step of the generator', function () { 47 | const myFunc = () => ({ name: 'running' }); 48 | const machine = Machine.create( 49 | { name: 'idle' }, 50 | { 51 | idle: { 52 | run: function * () { 53 | yield 'running'; 54 | yield { name: 'running' }; 55 | yield call(myFunc, 42); 56 | return 'running' 57 | } 58 | }, 59 | running: { 60 | stop: 'idle' 61 | } 62 | } 63 | ); 64 | 65 | machine.run({ foo: 'bar' }, 42, 'hello world'); 66 | machine.stop(); 67 | 68 | expect(console.log.callCount).to.be.equal(9); 69 | [ 70 | `${ machine.name }: "run" dispatched with payload [object Object],42,hello world`, 71 | `${ machine.name }: generator step -> running`, 72 | `${ machine.name }: state changed to "running"`, 73 | `${ machine.name }: generator step -> [object Object]`, 74 | `${ machine.name }: state changed to "running"`, 75 | `${ machine.name }: generator step -> [object Object]`, 76 | `${ machine.name }: state changed to "running"`, 77 | `${ machine.name }: "stop" dispatched`, 78 | `${ machine.name }: state changed to "idle"` 79 | ].forEach((logStr, i) => { 80 | expect(console.log.getCall(i)).to.be.calledWith(logStr); 81 | }); 82 | }); 83 | }) 84 | }); 85 | -------------------------------------------------------------------------------- /lib/helpers/__tests__/call.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _connect = require('../connect'); 4 | 5 | var _connect2 = _interopRequireDefault(_connect); 6 | 7 | var _call = require('../generators/call'); 8 | 9 | var _call2 = _interopRequireDefault(_call); 10 | 11 | var _ = require('../../'); 12 | 13 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 14 | 15 | var IDLE = 'IDLE'; 16 | var END = 'END'; 17 | var FAILURE = 'FAILURE'; 18 | 19 | var makeState = function makeState() { 20 | var name = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'INVALID'; 21 | var error = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; 22 | 23 | return { name: name, error: error }; 24 | }; 25 | 26 | describe('Given the call helper', function () { 27 | beforeEach(function () { 28 | _.Machine.flush(); 29 | }); 30 | describe('when calling it with a non-function argument', function () { 31 | it('should catch the error and transition to Failure state with a meaningful error message', function (done) { 32 | var _transitions; 33 | 34 | var missingFunc = undefined; 35 | var errMessage = "The argument passed to `call` is falsy (undefined)"; 36 | 37 | var machine = _.Machine.create('A', { 38 | state: makeState(IDLE), 39 | transitions: (_transitions = {}, _transitions[IDLE] = { 40 | run: /*#__PURE__*/regeneratorRuntime.mark(function run() { 41 | return regeneratorRuntime.wrap(function run$(_context) { 42 | while (1) { 43 | switch (_context.prev = _context.next) { 44 | case 0: 45 | _context.prev = 0; 46 | _context.next = 3; 47 | return (0, _call2.default)(missingFunc); 48 | 49 | case 3: 50 | _context.next = 5; 51 | return makeState(END); 52 | 53 | case 5: 54 | _context.next = 11; 55 | break; 56 | 57 | case 7: 58 | _context.prev = 7; 59 | _context.t0 = _context['catch'](0); 60 | _context.next = 11; 61 | return makeState(FAILURE, _context.t0); 62 | 63 | case 11: 64 | case 'end': 65 | return _context.stop(); 66 | } 67 | } 68 | }, run, this, [[0, 7]]); 69 | }) 70 | }, _transitions[END] = { 71 | reload: makeState(IDLE) 72 | }, _transitions[FAILURE] = { 73 | reload: makeState(IDLE) 74 | }, _transitions) 75 | }); 76 | 77 | (0, _connect2.default)().with('A').mapSilent(function (A) { 78 | expect(A.state.name).to.equal(FAILURE); 79 | expect(A.state.error.message).to.equal(errMessage); 80 | done(); 81 | }); 82 | 83 | machine.run(); 84 | }); 85 | }); 86 | }); -------------------------------------------------------------------------------- /src/__tests__/createMachine.spec.js: -------------------------------------------------------------------------------- 1 | import createMachine from '../createMachine'; 2 | import { ERROR_MISSING_STATE, ERROR_MISSING_TRANSITIONS } from '../constants'; 3 | 4 | describe('Given the createMachine factory', function () { 5 | 6 | describe('when passing invalid configuration', function () { 7 | it('should throw errors if state or transitions are missing', function () { 8 | expect(createMachine.bind(null)).to.throw(ERROR_MISSING_STATE); 9 | }); 10 | }); 11 | 12 | describe('when we create a machine without a name', function () { 13 | it('it should automatically generate a name and allow the creation', function () { 14 | const machine = createMachine({ 15 | state: { name: 'idle' }, 16 | transitions: { 'idle': { run: 'running' }, 'running': { stop: 'idle' } } 17 | }); 18 | 19 | expect(machine.isIdle()).to.equal(true); 20 | machine.run(); 21 | expect(machine.isRunning()).to.equal(true); 22 | }); 23 | }); 24 | 25 | describe('when we create a machine without a state', function () { 26 | it('it should throw an error', function () { 27 | expect(createMachine.bind(null)).to.throw(ERROR_MISSING_STATE); 28 | }); 29 | }); 30 | 31 | describe('when we create a machine using the shorter syntax', function () { 32 | it('it should create a working machine', function () { 33 | const machine = createMachine( 34 | { name: 'idle' }, 35 | { 36 | 'idle': { 37 | 'run': 'running' 38 | }, 39 | 'running': { 40 | 'stop': 'idle' 41 | } 42 | } 43 | ); 44 | 45 | machine.run(); 46 | expect(machine.state.name).to.equal('running'); 47 | machine.stop(); 48 | expect(machine.state.name).to.equal('idle'); 49 | }); 50 | }); 51 | 52 | describe('when we dispatch an action', function () { 53 | it('it should handle the action', function () { 54 | const machine = createMachine({ 55 | state: { name: 'idle' }, 56 | transitions: { 57 | 'idle': { 58 | 'run baby run': function (machine, a, b) { 59 | return { name: 'running', data: [a, b] }; 60 | } 61 | }, 62 | 'running': { stop: 'idle' } 63 | } 64 | }); 65 | 66 | machine.runBabyRun('a', 'b'); 67 | expect(machine.state.name).to.equal('running'); 68 | expect(machine.state.data).to.deep.equal(['a', 'b']); 69 | }); 70 | it('it should handle the action implemented as arrow function', function () { 71 | const machine = createMachine({ 72 | state: { name: 'idle' }, 73 | transitions: { 74 | 'idle': { 75 | 'run baby run': (machine, a, b) => ({ 76 | name: 'running', 77 | data: [a, b] 78 | }) 79 | }, 80 | 'running': { stop: 'idle' } 81 | } 82 | }); 83 | 84 | machine.runBabyRun('a', 'b'); 85 | expect(machine.state.name).to.equal('running'); 86 | expect(machine.state.data).to.deep.equal(['a', 'b']); 87 | }); 88 | }); 89 | }); -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 6 | 7 | // errors 8 | var ERROR_MISSING_MACHINE = exports.ERROR_MISSING_MACHINE = function ERROR_MISSING_MACHINE(name) { 9 | return 'There\'s no machine with name ' + name; 10 | }; 11 | var ERROR_MISSING_STATE = exports.ERROR_MISSING_STATE = 'Configuration error: missing initial "state"'; 12 | var ERROR_MISSING_TRANSITIONS = exports.ERROR_MISSING_TRANSITIONS = 'Configuration error: missing "transitions"'; 13 | var ERROR_WRONG_STATE_FORMAT = exports.ERROR_WRONG_STATE_FORMAT = function ERROR_WRONG_STATE_FORMAT(state) { 14 | var serialized = (typeof state === 'undefined' ? 'undefined' : _typeof(state)) === 'object' ? JSON.stringify(state, null, 2) : state; 15 | 16 | return 'The state should be an object and it should always have at least "name" property. You passed ' + serialized; 17 | }; 18 | var ERROR_UNCOVERED_STATE = exports.ERROR_UNCOVERED_STATE = function ERROR_UNCOVERED_STATE(state) { 19 | return 'You just transitioned the machine to a state (' + state + ') which is not defined or it has no actions. This means that the machine is stuck.'; 20 | }; 21 | var ERROR_NOT_SUPPORTED_HANDLER_TYPE = exports.ERROR_NOT_SUPPORTED_HANDLER_TYPE = 'Wrong handler type passed. Please read the docs https://github.com/krasimir/stent'; 22 | var ERROR_RESERVED_WORD_USED_AS_ACTION = exports.ERROR_RESERVED_WORD_USED_AS_ACTION = function ERROR_RESERVED_WORD_USED_AS_ACTION(word) { 23 | return 'Sorry, you can\'t use ' + word + ' as a name for an action. It is reserved.'; 24 | }; 25 | var ERROR_GENERATOR_FUNC_CALL_FAILED = exports.ERROR_GENERATOR_FUNC_CALL_FAILED = function ERROR_GENERATOR_FUNC_CALL_FAILED(type) { 26 | return 'The argument passed to `call` is falsy (' + type + ')'; 27 | }; 28 | 29 | // middlewares 30 | var MIDDLEWARE_PROCESS_ACTION = exports.MIDDLEWARE_PROCESS_ACTION = 'onActionDispatched'; 31 | var MIDDLEWARE_ACTION_PROCESSED = exports.MIDDLEWARE_ACTION_PROCESSED = 'onActionProcessed'; 32 | var MIDDLEWARE_STATE_WILL_CHANGE = exports.MIDDLEWARE_STATE_WILL_CHANGE = 'onStateWillChange'; 33 | var MIDDLEWARE_PROCESS_STATE_CHANGE = exports.MIDDLEWARE_PROCESS_STATE_CHANGE = 'onStateChanged'; 34 | var MIDDLEWARE_GENERATOR_STEP = exports.MIDDLEWARE_GENERATOR_STEP = 'onGeneratorStep'; 35 | var MIDDLEWARE_GENERATOR_END = exports.MIDDLEWARE_GENERATOR_END = 'onGeneratorEnd'; 36 | var MIDDLEWARE_GENERATOR_RESUMED = exports.MIDDLEWARE_GENERATOR_RESUMED = 'onGeneratorResumed'; 37 | var MIDDLEWARE_MACHINE_CREATED = exports.MIDDLEWARE_MACHINE_CREATED = 'onMachineCreated'; 38 | var MIDDLEWARE_MACHINE_CONNECTED = exports.MIDDLEWARE_MACHINE_CONNECTED = 'onMachineConnected'; 39 | var MIDDLEWARE_MACHINE_DISCONNECTED = exports.MIDDLEWARE_MACHINE_DISCONNECTED = 'onMachineDisconnected'; 40 | var MIDDLEWARE_REGISTERED = exports.MIDDLEWARE_REGISTERED = 'onMiddlewareRegister'; 41 | 42 | // misc 43 | var DEVTOOLS_KEY = exports.DEVTOOLS_KEY = '__hello__stent__'; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Stent - brings the power of state machines to the web](./_logo/logo.gif) 2 | 3 | Stent is combining the ideas of [Redux](http://redux.js.org/) with the concept of [state machines](https://en.wikipedia.org/wiki/Automata_theory). 4 | 5 | ![Travis](https://travis-ci.org/krasimir/stent.svg?branch=master) 6 | [![npm downloads](https://img.shields.io/npm/dm/stent.svg?style=flat-square)](https://www.npmjs.com/package/stent) 7 | 8 | Chat: https://gitter.im/stentjs 9 | 10 | --- 11 | 12 | ## A few words about state machines 13 | 14 | State machine is a mathematical model of computation. It's an abstract concept where the machine may have different states but at a given time fulfills only one of them. It accepts input and based on that (plus its current state) transitions to another state. Isn't it familiar? Yes, it sounds like a front-end application. That's why this model/concept applies nicely to UI development. 15 | 16 | *Disclaimer: there are different types of state machines. I think the one that makes sense for front-end development is [Mealy state machine](https://en.wikipedia.org/wiki/Mealy_machine).* 17 | 18 | ## Installation 19 | 20 | The library is available as a [npm module](https://www.npmjs.com/package/stent) so `npm install stent` or `yarn add stent` will do the job. There's also a standalone version [here](./standalone) (only core functionalities) which you can directly add to your page. 21 | 22 | ## Documentaion 23 | 24 | * [Getting started](./docs/getting-started.md) 25 | * API 26 | * [``](./docs/state-object.md) 27 | * [`Machine.`](./docs/machine.md) 28 | * [``](./docs/action-handler.md) 29 | * [`connect` and `disconnect`](./docs/connect-and-disconnect.md) 30 | * [Middlewares](./docs/middlewares.md) 31 | * [React integration](./docs/react-integration.md) 32 | * [examples](./docs/examples.md) 33 | 34 | ## Debugging apps made with Stent 35 | 36 | Stent is supported by [Kuker](https://github.com/krasimir/kuker) Chrome extension. Just add the [Stent emitter](https://github.com/krasimir/kuker#integration-with-stent) to your app and open the [Kuker](https://github.com/krasimir/kuker) tab in Chrome's DevTools. 37 | 38 | ![Kuker](https://github.com/krasimir/kuker/raw/master/img/kuker-emitters/screenshot_stent.jpg) 39 | 40 | ## Must-read articles/resources 41 | 42 | * [The Rise Of The State Machines](https://www.smashingmagazine.com/2018/01/rise-state-machines/) 43 | * [You are managing state? Think twice.](http://krasimirtsonev.com/blog/article/managing-state-in-javascript-with-state-machines-stent) - an article explaining why finite state machines are important in the context of the UI development. 44 | * [Getting from Redux to a state machine](http://krasimirtsonev.com/blog/article/getting-from-redux-to-state-machine-with-stent) - a tutorial that transforms a Redux app to an app using Stent 45 | * [Robust React User Interfaces with Finite State Machines](https://css-tricks.com/robust-react-user-interfaces-with-finite-state-machines/) 46 | * [Mealy state machine](https://en.wikipedia.org/wiki/Mealy_machine) 47 | 48 | ## Other libraries dealing with state machines 49 | 50 | * [xstate](https://github.com/davidkpiano/xstate) 51 | * [machina](http://machina-js.org/) 52 | -------------------------------------------------------------------------------- /lib/helpers/handleAction.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 6 | 7 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 8 | 9 | exports.default = handleAction; 10 | 11 | var _constants = require('../constants'); 12 | 13 | var _updateState = require('./updateState'); 14 | 15 | var _updateState2 = _interopRequireDefault(_updateState); 16 | 17 | var _handleMiddleware = require('./handleMiddleware'); 18 | 19 | var _handleMiddleware2 = _interopRequireDefault(_handleMiddleware); 20 | 21 | var _handleGenerator = require('./handleGenerator'); 22 | 23 | var _handleGenerator2 = _interopRequireDefault(_handleGenerator); 24 | 25 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 26 | 27 | function handleAction(machine, action) { 28 | for (var _len = arguments.length, payload = Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) { 29 | payload[_key - 2] = arguments[_key]; 30 | } 31 | 32 | var state = machine.state, 33 | transitions = machine.transitions; 34 | 35 | 36 | if (!transitions[state.name]) return false; 37 | 38 | var handler = transitions[state.name][action]; 39 | 40 | if (typeof handler === 'undefined') return false; 41 | 42 | _handleMiddleware2.default.apply(undefined, [_constants.MIDDLEWARE_PROCESS_ACTION, machine, action].concat(payload)); 43 | 44 | // string as a handler 45 | if (typeof handler === 'string') { 46 | (0, _updateState2.default)(machine, _extends({}, state, { name: transitions[state.name][action] })); 47 | 48 | // object as a handler 49 | } else if ((typeof handler === 'undefined' ? 'undefined' : _typeof(handler)) === 'object') { 50 | (0, _updateState2.default)(machine, handler); 51 | 52 | // function as a handler 53 | } else if (typeof handler === 'function') { 54 | var _transitions$state$na; 55 | 56 | var response = (_transitions$state$na = transitions[state.name])[action].apply(_transitions$state$na, [machine].concat(payload)); 57 | 58 | // generator 59 | if (response && typeof response.next === 'function') { 60 | var generator = response; 61 | 62 | return (0, _handleGenerator2.default)(machine, generator, function (response) { 63 | (0, _updateState2.default)(machine, response); 64 | _handleMiddleware2.default.apply(undefined, [_constants.MIDDLEWARE_ACTION_PROCESSED, machine, action].concat(payload)); 65 | }); 66 | } else { 67 | (0, _updateState2.default)(machine, response); 68 | } 69 | 70 | // wrong type of handler 71 | } else { 72 | throw new Error(_constants.ERROR_NOT_SUPPORTED_HANDLER_TYPE); 73 | } 74 | 75 | _handleMiddleware2.default.apply(undefined, [_constants.MIDDLEWARE_ACTION_PROCESSED, machine, action].concat(payload)); 76 | }; 77 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/helpers/connect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.flush = flush; 5 | exports.getMapping = getMapping; 6 | exports.destroy = destroy; 7 | exports.default = connect; 8 | 9 | var _ = require('../'); 10 | 11 | var _handleMiddleware = require('./handleMiddleware'); 12 | 13 | var _handleMiddleware2 = _interopRequireDefault(_handleMiddleware); 14 | 15 | var _constants = require('../constants'); 16 | 17 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 18 | 19 | var idIndex = 0; 20 | var mappings = null; 21 | 22 | var getId = function getId() { 23 | return 'm' + ++idIndex; 24 | }; 25 | var setup = function setup() { 26 | if (mappings !== null) return; 27 | mappings = {}; 28 | _.Machine.addMiddleware({ 29 | onStateChanged: function onStateChanged() { 30 | for (var id in mappings) { 31 | var _mappings$id = mappings[id], 32 | done = _mappings$id.done, 33 | machines = _mappings$id.machines; 34 | 35 | 36 | if (machines.map(function (m) { 37 | return m.name; 38 | }).indexOf(this.name) >= 0) { 39 | done && done.apply(undefined, machines); 40 | } 41 | } 42 | } 43 | }); 44 | }; 45 | 46 | function flush() { 47 | mappings = null; 48 | } 49 | 50 | function getMapping() { 51 | return mappings; 52 | } 53 | 54 | function destroy(machineId) { 55 | for (var mId in mappings) { 56 | mappings[mId].machines = mappings[mId].machines.filter(function (_ref) { 57 | var name = _ref.name; 58 | return name !== machineId; 59 | }); 60 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_MACHINE_DISCONNECTED, null, mappings[mId].machines); 61 | if (mappings[mId].machines.length === 0) { 62 | delete mappings[mId]; 63 | } 64 | } 65 | } 66 | 67 | function connect() { 68 | var _ref2 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, 69 | meta = _ref2.meta; 70 | 71 | setup(); 72 | var withFunc = function withFunc() { 73 | for (var _len = arguments.length, names = Array(_len), _key = 0; _key < _len; _key++) { 74 | names[_key] = arguments[_key]; 75 | } 76 | 77 | var machines = names.map(function (name) { 78 | return _.Machine.get(name); 79 | }); 80 | var mapFunc = function mapFunc(done, once, silent) { 81 | var id = getId(); 82 | 83 | !once && (mappings[id] = { done: done, machines: machines }); 84 | !silent && done && done.apply(undefined, machines); 85 | 86 | return function disconnect() { 87 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_MACHINE_DISCONNECTED, null, machines, meta); 88 | if (mappings && mappings[id]) delete mappings[id]; 89 | }; 90 | }; 91 | 92 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_MACHINE_CONNECTED, null, machines, meta); 93 | return { 94 | 'map': mapFunc, 95 | 'mapOnce': function mapOnce(done) { 96 | return mapFunc(done, true); 97 | }, 98 | 'mapSilent': function mapSilent(done) { 99 | return mapFunc(done, false, true); 100 | } 101 | }; 102 | }; 103 | 104 | return { 'with': withFunc }; 105 | } -------------------------------------------------------------------------------- /lib/__tests__/createMachine.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _createMachine = require('../createMachine'); 4 | 5 | var _createMachine2 = _interopRequireDefault(_createMachine); 6 | 7 | var _constants = require('../constants'); 8 | 9 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 10 | 11 | describe('Given the createMachine factory', function () { 12 | 13 | describe('when passing invalid configuration', function () { 14 | it('should throw errors if state or transitions are missing', function () { 15 | expect(_createMachine2.default.bind(null)).to.throw(_constants.ERROR_MISSING_STATE); 16 | }); 17 | }); 18 | 19 | describe('when we create a machine without a name', function () { 20 | it('it should automatically generate a name and allow the creation', function () { 21 | var machine = (0, _createMachine2.default)({ 22 | state: { name: 'idle' }, 23 | transitions: { 'idle': { run: 'running' }, 'running': { stop: 'idle' } } 24 | }); 25 | 26 | expect(machine.isIdle()).to.equal(true); 27 | machine.run(); 28 | expect(machine.isRunning()).to.equal(true); 29 | }); 30 | }); 31 | 32 | describe('when we create a machine without a state', function () { 33 | it('it should throw an error', function () { 34 | expect(_createMachine2.default.bind(null)).to.throw(_constants.ERROR_MISSING_STATE); 35 | }); 36 | }); 37 | 38 | describe('when we create a machine using the shorter syntax', function () { 39 | it('it should create a working machine', function () { 40 | var machine = (0, _createMachine2.default)({ name: 'idle' }, { 41 | 'idle': { 42 | 'run': 'running' 43 | }, 44 | 'running': { 45 | 'stop': 'idle' 46 | } 47 | }); 48 | 49 | machine.run(); 50 | expect(machine.state.name).to.equal('running'); 51 | machine.stop(); 52 | expect(machine.state.name).to.equal('idle'); 53 | }); 54 | }); 55 | 56 | describe('when we dispatch an action', function () { 57 | it('it should handle the action', function () { 58 | var machine = (0, _createMachine2.default)({ 59 | state: { name: 'idle' }, 60 | transitions: { 61 | 'idle': { 62 | 'run baby run': function runBabyRun(machine, a, b) { 63 | return { name: 'running', data: [a, b] }; 64 | } 65 | }, 66 | 'running': { stop: 'idle' } 67 | } 68 | }); 69 | 70 | machine.runBabyRun('a', 'b'); 71 | expect(machine.state.name).to.equal('running'); 72 | expect(machine.state.data).to.deep.equal(['a', 'b']); 73 | }); 74 | it('it should handle the action implemented as arrow function', function () { 75 | var machine = (0, _createMachine2.default)({ 76 | state: { name: 'idle' }, 77 | transitions: { 78 | 'idle': { 79 | 'run baby run': function runBabyRun(machine, a, b) { 80 | return { 81 | name: 'running', 82 | data: [a, b] 83 | }; 84 | } 85 | }, 86 | 'running': { stop: 'idle' } 87 | } 88 | }); 89 | 90 | machine.runBabyRun('a', 'b'); 91 | expect(machine.state.name).to.equal('running'); 92 | expect(machine.state.data).to.deep.equal(['a', 'b']); 93 | }); 94 | }); 95 | }); -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | exports.Machine = undefined; 5 | 6 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 7 | 8 | var _createMachine = require('./createMachine'); 9 | 10 | var _createMachine2 = _interopRequireDefault(_createMachine); 11 | 12 | var _constants = require('./constants'); 13 | 14 | var _connect = require('./helpers/connect'); 15 | 16 | var _connect2 = _interopRequireDefault(_connect); 17 | 18 | var _call = require('./helpers/generators/call'); 19 | 20 | var _call2 = _interopRequireDefault(_call); 21 | 22 | var _handleMiddleware = require('./helpers/handleMiddleware'); 23 | 24 | var _handleMiddleware2 = _interopRequireDefault(_handleMiddleware); 25 | 26 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 27 | 28 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 29 | 30 | var MachineFactory = function () { 31 | function MachineFactory() { 32 | _classCallCheck(this, MachineFactory); 33 | 34 | this.machines = {}; 35 | this.middlewares = []; 36 | this.connect = _connect2.default; 37 | this.call = _call2.default; 38 | } 39 | 40 | MachineFactory.prototype.create = function create(name, config) { 41 | var _this = this; 42 | 43 | var machine = (0, _createMachine2.default)(name, config, this.middlewares); 44 | 45 | this.machines[machine.name] = machine; 46 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_MACHINE_CREATED, machine, machine); 47 | machine.destroy = function () { 48 | return _this.destroy(machine); 49 | }; 50 | return machine; 51 | }; 52 | 53 | MachineFactory.prototype.get = function get(name) { 54 | if ((typeof name === 'undefined' ? 'undefined' : _typeof(name)) === 'object') name = name.name; 55 | if (this.machines[name]) return this.machines[name]; 56 | throw new Error((0, _constants.ERROR_MISSING_MACHINE)(name)); 57 | }; 58 | 59 | MachineFactory.prototype.flush = function flush() { 60 | this.machines = {}; 61 | this.middlewares = []; 62 | (0, _connect.flush)(); 63 | }; 64 | 65 | MachineFactory.prototype.addMiddleware = function addMiddleware(middleware) { 66 | if (Array.isArray(middleware)) { 67 | this.middlewares = this.middlewares.concat(middleware); 68 | } else { 69 | this.middlewares.push(middleware); 70 | } 71 | if (middleware.__initialize) middleware.__initialize(this); 72 | if (middleware[_constants.MIDDLEWARE_REGISTERED]) middleware[_constants.MIDDLEWARE_REGISTERED](); 73 | }; 74 | 75 | MachineFactory.prototype.destroy = function destroy(machine) { 76 | var m = machine; 77 | if (typeof machine === 'string') { 78 | m = this.machines[machine]; 79 | if (!m) throw new Error((0, _constants.ERROR_MISSING_MACHINE)(machine)); 80 | } 81 | delete this.machines[m.name]; 82 | (0, _connect.destroy)(m.name); 83 | }; 84 | 85 | return MachineFactory; 86 | }(); 87 | 88 | var factory = new MachineFactory(); 89 | 90 | exports.Machine = factory; 91 | 92 | 93 | if (typeof window !== 'undefined') { 94 | window[_constants.DEVTOOLS_KEY] = factory; 95 | } -------------------------------------------------------------------------------- /lib/middlewares/__tests__/Logger.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('../../'); 4 | 5 | var _2 = require('../'); 6 | 7 | var _helpers = require('../../helpers'); 8 | 9 | describe('Given the Logger middleware', function () { 10 | beforeEach(function () { 11 | _.Machine.flush(); 12 | sinon.stub(console, 'log'); 13 | _.Machine.addMiddleware(_2.Logger); 14 | }); 15 | afterEach(function () { 16 | console.log.restore(); 17 | }); 18 | describe('when using Logger with function and string as a handler', function () { 19 | it('should log to the console', function () { 20 | var machine = _.Machine.create({ name: 'idle' }, { 21 | idle: { 22 | run: function run() { 23 | return 'running'; 24 | } 25 | }, 26 | running: { 27 | stop: 'idle' 28 | } 29 | }); 30 | 31 | machine.run({}, 42, 'hello world'); 32 | machine.stop(); 33 | 34 | expect(console.log.callCount).to.be.equal(4); 35 | [machine.name + ': "run" dispatched with payload [object Object],42,hello world', machine.name + ': state changed to "running"', machine.name + ': "stop" dispatched', machine.name + ': state changed to "idle"'].forEach(function (logStr, i) { 36 | expect(console.log.getCall(i)).to.be.calledWith(logStr); 37 | }); 38 | }); 39 | }); 40 | describe('when using the Logger with a generator function', function () { 41 | it('should log every step of the generator', function () { 42 | var myFunc = function myFunc() { 43 | return { name: 'running' }; 44 | }; 45 | var machine = _.Machine.create({ name: 'idle' }, { 46 | idle: { 47 | run: /*#__PURE__*/regeneratorRuntime.mark(function run() { 48 | return regeneratorRuntime.wrap(function run$(_context) { 49 | while (1) { 50 | switch (_context.prev = _context.next) { 51 | case 0: 52 | _context.next = 2; 53 | return 'running'; 54 | 55 | case 2: 56 | _context.next = 4; 57 | return { name: 'running' }; 58 | 59 | case 4: 60 | _context.next = 6; 61 | return (0, _helpers.call)(myFunc, 42); 62 | 63 | case 6: 64 | return _context.abrupt('return', 'running'); 65 | 66 | case 7: 67 | case 'end': 68 | return _context.stop(); 69 | } 70 | } 71 | }, run, this); 72 | }) 73 | }, 74 | running: { 75 | stop: 'idle' 76 | } 77 | }); 78 | 79 | machine.run({ foo: 'bar' }, 42, 'hello world'); 80 | machine.stop(); 81 | 82 | expect(console.log.callCount).to.be.equal(9); 83 | [machine.name + ': "run" dispatched with payload [object Object],42,hello world', machine.name + ': generator step -> running', machine.name + ': state changed to "running"', machine.name + ': generator step -> [object Object]', machine.name + ': state changed to "running"', machine.name + ': generator step -> [object Object]', machine.name + ': state changed to "running"', machine.name + ': "stop" dispatched', machine.name + ': state changed to "idle"'].forEach(function (logStr, i) { 84 | expect(console.log.getCall(i)).to.be.calledWith(logStr); 85 | }); 86 | }); 87 | }); 88 | }); -------------------------------------------------------------------------------- /src/helpers/handleGenerator.js: -------------------------------------------------------------------------------- 1 | import handleMiddleware from './handleMiddleware'; 2 | import { MIDDLEWARE_GENERATOR_STEP, MIDDLEWARE_GENERATOR_END, MIDDLEWARE_GENERATOR_RESUMED, ERROR_GENERATOR_FUNC_CALL_FAILED } from '../constants'; 3 | import updateState from './updateState'; 4 | 5 | export default function handleGenerator(machine, generator, done, resultOfPreviousOperation) { 6 | const generatorNext = (gen, res) => !canceled && gen.next(res); 7 | const generatorThrow = (gen, error) => !canceled && gen.throw(error); 8 | const cancelGenerator = () => { 9 | cancelInsideGenerator && cancelInsideGenerator(); 10 | canceled = true; 11 | } 12 | var canceled = false; 13 | var cancelInsideGenerator; 14 | 15 | const iterate = function (result) { 16 | if (canceled) return; 17 | 18 | if (!result.done) { 19 | handleMiddleware(MIDDLEWARE_GENERATOR_STEP, machine, result.value); 20 | 21 | // yield call 22 | if (typeof result.value === 'object' && result.value.__type === 'call') { 23 | const { func, args } = result.value; 24 | 25 | if (!func) { 26 | const error = ERROR_GENERATOR_FUNC_CALL_FAILED(typeof func); 27 | handleMiddleware(MIDDLEWARE_GENERATOR_RESUMED, machine, error); 28 | return iterate(generatorThrow(generator, new Error(error))); 29 | } 30 | 31 | try { 32 | const funcResult = func(...args); 33 | 34 | if (!funcResult) { 35 | handleMiddleware(MIDDLEWARE_GENERATOR_RESUMED, machine); 36 | iterate(generatorNext(generator)); 37 | return; 38 | } 39 | 40 | // promise 41 | if (typeof funcResult.then !== 'undefined') { 42 | funcResult.then( 43 | result => { 44 | handleMiddleware(MIDDLEWARE_GENERATOR_RESUMED, machine, result); 45 | return iterate(generatorNext(generator, result)); 46 | }, 47 | error => { 48 | handleMiddleware(MIDDLEWARE_GENERATOR_RESUMED, machine, error); 49 | return iterate(generatorThrow(generator, error)); 50 | } 51 | ); 52 | // generator 53 | } else if (typeof funcResult.next === 'function') { 54 | try { 55 | cancelInsideGenerator = handleGenerator(machine, funcResult, generatorResult => { 56 | handleMiddleware(MIDDLEWARE_GENERATOR_RESUMED, machine, generatorResult); 57 | iterate(generatorNext(generator, generatorResult)); 58 | }); 59 | } catch (error) { 60 | return iterate(generatorThrow(generator, error)); 61 | } 62 | } else { 63 | handleMiddleware(MIDDLEWARE_GENERATOR_RESUMED, machine, funcResult); 64 | iterate(generatorNext(generator, funcResult)); 65 | } 66 | } catch (error) { 67 | return iterate(generatorThrow(generator, error)); 68 | } 69 | 70 | // a return statement of the normal function 71 | } else { 72 | updateState(machine, result.value); 73 | handleMiddleware(MIDDLEWARE_GENERATOR_RESUMED, machine); 74 | iterate(generatorNext(generator)); 75 | } 76 | 77 | // the end of the generator (return statement) 78 | } else { 79 | handleMiddleware(MIDDLEWARE_GENERATOR_END, machine, result.value); 80 | done(result.value); 81 | } 82 | }; 83 | 84 | iterate(generatorNext(generator, resultOfPreviousOperation)); 85 | 86 | return cancelGenerator; 87 | } -------------------------------------------------------------------------------- /src/helpers/__tests__/registerMethods.spec.js: -------------------------------------------------------------------------------- 1 | import registerMethods from '../registerMethods'; 2 | 3 | describe('Given the registerMethods helper', function () { 4 | 5 | describe('when registering methods', function () { 6 | it('should create methods dynamically (based on states and actions)', function () { 7 | const machine = {}; 8 | 9 | registerMethods( 10 | machine, 11 | { 12 | 'idle': { 13 | 'run baby run': 'running' 14 | }, 15 | 'running': { 16 | 'stop': 'idle' 17 | } 18 | }, 19 | sinon.spy(), 20 | sinon.spy() 21 | ); 22 | 23 | expect(typeof machine.isIdle).to.be.equal('function'); 24 | expect(typeof machine.isRunning).to.be.equal('function'); 25 | expect(typeof machine.runBabyRun).to.be.equal('function'); 26 | expect(typeof machine.stop).to.be.equal('function'); 27 | expect(typeof machine.runBabyRun.latest).to.be.equal('function'); 28 | expect(typeof machine.stop.latest).to.be.equal('function'); 29 | expect(typeof machine.isRunBabyRunAllowed).to.be.equal('function'); 30 | expect(typeof machine.isStopAllowed).to.be.equal('function'); 31 | }); 32 | it('should dispatch an action with the given payload', function () { 33 | const dispatch = sinon.spy(); 34 | const dispatchLatest = sinon.spy(); 35 | const machine = {}; 36 | const payload1 = { answer: 42 }; 37 | const payload2 = 'foo' 38 | 39 | registerMethods( 40 | machine, 41 | { 'idle': { run: 'running' }, 'running': { stop: 'idle' } }, 42 | dispatch, 43 | dispatchLatest 44 | ); 45 | 46 | machine.run(payload1, payload2); 47 | machine.run.latest(payload2, payload1); 48 | 49 | expect(dispatch).to.be.calledOnce.and.to.be.calledWith('run', payload1, payload2); 50 | expect(dispatchLatest).to.be.calledOnce.and.to.be.calledWith('run', payload2, payload1); 51 | }); 52 | it('should check if the machine is in a particular state', function () { 53 | const machine = { state: { name: 'running' }}; 54 | 55 | registerMethods( 56 | machine, 57 | { 'idle': { run: 'running' }, 'running': { stop: 'idle' } }, 58 | sinon.spy(), 59 | sinon.spy() 60 | ); 61 | 62 | expect(machine.isIdle()).to.be.false; 63 | expect(machine.isRunning()).to.be.true; 64 | }); 65 | it('should check if particular transition is allowed', function () { 66 | const machine = { state: { name: 'running' }}; 67 | 68 | registerMethods( 69 | machine, 70 | { 71 | 'idle': { 72 | run: 'running' 73 | }, 74 | 'running': { 75 | stop: 'idle' 76 | } 77 | }, 78 | sinon.spy(), 79 | sinon.spy() 80 | ); 81 | 82 | expect(machine.isStopAllowed()).to.be.true; 83 | expect(machine.isRunAllowed()).to.be.false; 84 | 85 | machine.state.name = 'idle'; 86 | 87 | expect(machine.isStopAllowed()).to.be.false; 88 | expect(machine.isRunAllowed()).to.be.true; 89 | }); 90 | describe('when some of the transitions match the word `name`, `transition` or `state`', function () { 91 | const register = state => registerMethods({}, state, sinon.spy(), sinon.spy()); 92 | ['name', 'transitions', 'state', 'destroy'].forEach(word => { 93 | it(`should throw an error if ${ word } is used`, function () { 94 | expect(register.bind(null, { 'idle': { [word]: 'foo' }})).to.throw('It is reserved.'); 95 | }); 96 | }); 97 | }); 98 | }); 99 | 100 | }); -------------------------------------------------------------------------------- /docs/action-handler.md: -------------------------------------------------------------------------------- 1 | # Action handler 2 | 3 | [Full documentation](./README.md) 4 | 5 | --- 6 | 7 | The action handler may be just a string. In the following example `fetching` is the same as `{ name: 'fetching' }` state object. 8 | 9 | ```js 10 | Machine.create('app', { 11 | 'idle': { 12 | 'fetch data': 'fetching' 13 | } 14 | }); 15 | ``` 16 | 17 | Could be also a state object: 18 | 19 | ```js 20 | Machine.create('app', { 21 | 'idle': { 22 | 'fetch data': { name: 'fetching', data: [], pending: false } 23 | } 24 | }); 25 | ``` 26 | 27 | Another variant is to use a function that returns a string. Which again results in `{ name: 'fetching' }`. 28 | 29 | ```js 30 | Machine.create('app', { 31 | 'idle': { 32 | 'fetch data': function (machine, payload) { 33 | return 'fetching'; 34 | } 35 | } 36 | }); 37 | ``` 38 | 39 | Notice that the function receives the whole state machine and some payload passed when the action is fired. 40 | 41 | And of course we may return the actual state object. That's actually a common case because very often we want to keep some data alongside: 42 | 43 | ```js 44 | Machine.create('app', { 45 | 'idle': { 46 | 'fetch data': function (machine, payload) { 47 | return { name: 'fetching', answer: 42 }; 48 | } 49 | } 50 | }); 51 | ``` 52 | 53 | In some cases you don't want to change the state but only handle the action. So feel free to skip the `return` statement. If the handler returns `undefined` the machine keeps its state. 54 | 55 | We may also use a generator if we have more complex operations or/and async tasks. 56 | 57 | ```js 58 | Machine.create('app', { 59 | 'idle': { 60 | 'fetch data': function * (machine, payload) { 61 | yield 'fetching'; // transition to a `fetching` state 62 | yield { name: 'fetching' } // the same but using a state object 63 | } 64 | } 65 | }); 66 | ``` 67 | 68 | What we can `yield` is a state object (or a string that represents a state) or a call to some of the predefined Stent helpers like `call`. This is the place where Stent looks a bit like [redux-saga](https://redux-saga.js.org/) library. The code above is an equivalent of the `take` side effect helper. There is also a `takeLatest` equivalent. It is just the same action method but with `.latest` at the end. For example if you have `machine.fetchData()` for `take`, `machine.fetchData.latest()` stands for `takeLatest`. If you are not familiar with redux-saga just imagine that you have an async logic and you handle it via a generator. The `.latest` helps when you run this logic many times in row but you want to handle only the last call. 69 | 70 | ### Helpers to handle async logic 71 | 72 | *`yield call(, ...args)`* 73 | 74 | `call` is blocking the generator function and calls `` with the given `...args`. `` could be: 75 | 76 | * A synchronous function 77 | * A function that returns a promise 78 | * Another generator function 79 | 80 | ```js 81 | import { call } from 'stent/lib/helpers'; 82 | 83 | Machine.create('app', { 84 | 'idle': { 85 | 'fetch data': function * () { 86 | const data = yield call(requestToBackend, '/api/todos/', 'POST'); 87 | } 88 | } 89 | }); 90 | ``` 91 | 92 | *`requestToBackend` is getting called with `/api/todos/` and `POST` as arguments.* 93 | 94 | Keep in mind that if you are using the `latest` version of your method you are able to cancel the previously fired generator only if you use the `call` helper. That's because in this case you are giving the control to Stent and the library is able to stop/cancel stuff. Otherwise you have to handle such cases on your own. To get a better context for the problem check out this [issue](https://github.com/krasimir/stent/issues/3). 95 | 96 | --- 97 | 98 | [Full documentation](./README.md) 99 | -------------------------------------------------------------------------------- /lib/react/connect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 6 | 7 | exports.default = function (Component) { 8 | var withFunc = function withFunc() { 9 | for (var _len = arguments.length, names = Array(_len), _key = 0; _key < _len; _key++) { 10 | names[_key] = arguments[_key]; 11 | } 12 | 13 | var mapFunc = function mapFunc(done, once, silent) { 14 | var mapping = once ? "mapOnce" : silent ? "mapSilent" : "map"; 15 | 16 | return function (_React$Component) { 17 | _inherits(StentConnect, _React$Component); 18 | 19 | function StentConnect(props) { 20 | var _connect; 21 | 22 | _classCallCheck(this, StentConnect); 23 | 24 | var _this = _possibleConstructorReturn(this, _React$Component.call(this, props)); 25 | 26 | _this.initialStateHasBeenSet = false; 27 | _this.state = {}; 28 | 29 | _this.disconnect = (_connect = (0, _connect3.default)({ 30 | meta: { component: Component.name } 31 | })).with.apply(_connect, names)[mapping](function () { 32 | var nextState = done ? done.apply(undefined, arguments) : {}; 33 | 34 | if (_this.initialStateHasBeenSet === false && mapping !== 'mapSilent') { 35 | _this.state = nextState; 36 | _this.initialStateHasBeenSet = true; 37 | return; 38 | } 39 | 40 | _this.setState(function () { 41 | return nextState; 42 | }); 43 | }); 44 | return _this; 45 | } 46 | 47 | StentConnect.prototype.componentWillUnmount = function componentWillUnmount() { 48 | if (this.disconnect) { 49 | this.disconnect(); 50 | } 51 | }; 52 | 53 | StentConnect.prototype.render = function render() { 54 | return _react2.default.createElement(Component, _extends({}, this.state, this.props)); 55 | }; 56 | 57 | return StentConnect; 58 | }(_react2.default.Component); 59 | }; 60 | 61 | return { 62 | 'map': mapFunc, 63 | 'mapOnce': function mapOnce(done) { 64 | return mapFunc(done, true); 65 | }, 66 | 'mapSilent': function mapSilent(done) { 67 | return mapFunc(done, false, true); 68 | } 69 | }; 70 | }; 71 | 72 | return { 'with': withFunc }; 73 | }; 74 | 75 | var _react = require('react'); 76 | 77 | var _react2 = _interopRequireDefault(_react); 78 | 79 | var _connect2 = require('../helpers/connect'); 80 | 81 | var _connect3 = _interopRequireDefault(_connect2); 82 | 83 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 84 | 85 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 86 | 87 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 88 | 89 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 90 | 91 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/helpers/__tests__/registerMethods.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 4 | 5 | var _registerMethods = require('../registerMethods'); 6 | 7 | var _registerMethods2 = _interopRequireDefault(_registerMethods); 8 | 9 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 10 | 11 | describe('Given the registerMethods helper', function () { 12 | 13 | describe('when registering methods', function () { 14 | it('should create methods dynamically (based on states and actions)', function () { 15 | var machine = {}; 16 | 17 | (0, _registerMethods2.default)(machine, { 18 | 'idle': { 19 | 'run baby run': 'running' 20 | }, 21 | 'running': { 22 | 'stop': 'idle' 23 | } 24 | }, sinon.spy(), sinon.spy()); 25 | 26 | expect(_typeof(machine.isIdle)).to.be.equal('function'); 27 | expect(_typeof(machine.isRunning)).to.be.equal('function'); 28 | expect(_typeof(machine.runBabyRun)).to.be.equal('function'); 29 | expect(_typeof(machine.stop)).to.be.equal('function'); 30 | expect(_typeof(machine.runBabyRun.latest)).to.be.equal('function'); 31 | expect(_typeof(machine.stop.latest)).to.be.equal('function'); 32 | expect(_typeof(machine.isRunBabyRunAllowed)).to.be.equal('function'); 33 | expect(_typeof(machine.isStopAllowed)).to.be.equal('function'); 34 | }); 35 | it('should dispatch an action with the given payload', function () { 36 | var dispatch = sinon.spy(); 37 | var dispatchLatest = sinon.spy(); 38 | var machine = {}; 39 | var payload1 = { answer: 42 }; 40 | var payload2 = 'foo'; 41 | 42 | (0, _registerMethods2.default)(machine, { 'idle': { run: 'running' }, 'running': { stop: 'idle' } }, dispatch, dispatchLatest); 43 | 44 | machine.run(payload1, payload2); 45 | machine.run.latest(payload2, payload1); 46 | 47 | expect(dispatch).to.be.calledOnce.and.to.be.calledWith('run', payload1, payload2); 48 | expect(dispatchLatest).to.be.calledOnce.and.to.be.calledWith('run', payload2, payload1); 49 | }); 50 | it('should check if the machine is in a particular state', function () { 51 | var machine = { state: { name: 'running' } }; 52 | 53 | (0, _registerMethods2.default)(machine, { 'idle': { run: 'running' }, 'running': { stop: 'idle' } }, sinon.spy(), sinon.spy()); 54 | 55 | expect(machine.isIdle()).to.be.false; 56 | expect(machine.isRunning()).to.be.true; 57 | }); 58 | it('should check if particular transition is allowed', function () { 59 | var machine = { state: { name: 'running' } }; 60 | 61 | (0, _registerMethods2.default)(machine, { 62 | 'idle': { 63 | run: 'running' 64 | }, 65 | 'running': { 66 | stop: 'idle' 67 | } 68 | }, sinon.spy(), sinon.spy()); 69 | 70 | expect(machine.isStopAllowed()).to.be.true; 71 | expect(machine.isRunAllowed()).to.be.false; 72 | 73 | machine.state.name = 'idle'; 74 | 75 | expect(machine.isStopAllowed()).to.be.false; 76 | expect(machine.isRunAllowed()).to.be.true; 77 | }); 78 | describe('when some of the transitions match the word `name`, `transition` or `state`', function () { 79 | var register = function register(state) { 80 | return (0, _registerMethods2.default)({}, state, sinon.spy(), sinon.spy()); 81 | }; 82 | ['name', 'transitions', 'state', 'destroy'].forEach(function (word) { 83 | it('should throw an error if ' + word + ' is used', function () { 84 | var _idle; 85 | 86 | expect(register.bind(null, { 'idle': (_idle = {}, _idle[word] = 'foo', _idle) })).to.throw('It is reserved.'); 87 | }); 88 | }); 89 | }); 90 | }); 91 | }); -------------------------------------------------------------------------------- /lib/helpers/__tests__/handleActionLatest.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _handleActionLatest = require('../handleActionLatest'); 4 | 5 | var _handleActionLatest2 = _interopRequireDefault(_handleActionLatest); 6 | 7 | var _ = require('../'); 8 | 9 | var _2 = require('../../'); 10 | 11 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 12 | 13 | describe('Given the handleActionLatest helper', function () { 14 | beforeEach(function () { 15 | _2.Machine.flush(); 16 | }); 17 | describe('and we fire same action twice within the same state', function () { 18 | it('should kill the first generator and its processes leaving only the new one working', function (done) { 19 | var handlerSpyA = sinon.spy(); 20 | var handlerSpyB = sinon.spy(); 21 | var timeouts = [20, 10]; 22 | var results = ['foo', 'bar']; 23 | var apiPromise = function apiPromise() { 24 | return new Promise(function (resolve) { 25 | var result = results.shift(); 26 | 27 | setTimeout(function () { 28 | return resolve(result); 29 | }, timeouts.shift()); 30 | }); 31 | }; 32 | var api = /*#__PURE__*/regeneratorRuntime.mark(function api() { 33 | var newState; 34 | return regeneratorRuntime.wrap(function api$(_context) { 35 | while (1) { 36 | switch (_context.prev = _context.next) { 37 | case 0: 38 | handlerSpyA(); 39 | _context.next = 3; 40 | return (0, _.call)(apiPromise); 41 | 42 | case 3: 43 | _context.t0 = _context.sent; 44 | newState = { 45 | name: _context.t0 46 | }; 47 | 48 | handlerSpyB(); 49 | return _context.abrupt('return', newState); 50 | 51 | case 7: 52 | case 'end': 53 | return _context.stop(); 54 | } 55 | } 56 | }, api, this); 57 | }); 58 | var handler = /*#__PURE__*/regeneratorRuntime.mark(function handler() { 59 | return regeneratorRuntime.wrap(function handler$(_context3) { 60 | while (1) { 61 | switch (_context3.prev = _context3.next) { 62 | case 0: 63 | _context3.next = 2; 64 | return (0, _.call)( /*#__PURE__*/regeneratorRuntime.mark(function _callee() { 65 | return regeneratorRuntime.wrap(function _callee$(_context2) { 66 | while (1) { 67 | switch (_context2.prev = _context2.next) { 68 | case 0: 69 | _context2.next = 2; 70 | return (0, _.call)(api, 'stent'); 71 | 72 | case 2: 73 | return _context2.abrupt('return', _context2.sent); 74 | 75 | case 3: 76 | case 'end': 77 | return _context2.stop(); 78 | } 79 | } 80 | }, _callee, this); 81 | })); 82 | 83 | case 2: 84 | return _context3.abrupt('return', _context3.sent); 85 | 86 | case 3: 87 | case 'end': 88 | return _context3.stop(); 89 | } 90 | } 91 | }, handler, this); 92 | }); 93 | var machine = { 94 | state: { name: 'idle' }, 95 | transitions: { 96 | idle: { run: handler }, 97 | 'foo': 'a', 98 | 'bar': 'a' 99 | } 100 | }; 101 | 102 | (0, _handleActionLatest2.default)(machine, 'run'); 103 | (0, _handleActionLatest2.default)(machine, 'run'); 104 | 105 | setTimeout(function () { 106 | expect(handlerSpyA).to.be.calledTwice; 107 | expect(handlerSpyB).to.be.calledOnce; 108 | expect(machine.state.name).to.equal('bar'); 109 | done(); 110 | }, 31); 111 | }); 112 | }); 113 | }); -------------------------------------------------------------------------------- /lib/helpers/__tests__/handleGenerator.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _handleGenerator = require('../handleGenerator'); 4 | 5 | var _handleGenerator2 = _interopRequireDefault(_handleGenerator); 6 | 7 | var _ = require('../'); 8 | 9 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 10 | 11 | describe('Given the handleGenerator helper', function () { 12 | describe('when we run the same generator again', function () { 13 | describe('and we want to cancel the first one', function () { 14 | it('should cancel the second generator', function (done) { 15 | var testCases = [{ timeout: 20, answer: 'a' }, { timeout: 10, answer: 'b' }]; 16 | var delay = function delay(_ref) { 17 | var timeout = _ref.timeout, 18 | answer = _ref.answer; 19 | return new Promise(function (resolve) { 20 | setTimeout(function () { 21 | return resolve(answer); 22 | }, timeout); 23 | }); 24 | }; 25 | var onGeneratorEnds = sinon.spy(); 26 | var generator = /*#__PURE__*/regeneratorRuntime.mark(function generator() { 27 | return regeneratorRuntime.wrap(function generator$(_context2) { 28 | while (1) { 29 | switch (_context2.prev = _context2.next) { 30 | case 0: 31 | _context2.next = 2; 32 | return (0, _.call)( /*#__PURE__*/regeneratorRuntime.mark(function _callee() { 33 | return regeneratorRuntime.wrap(function _callee$(_context) { 34 | while (1) { 35 | switch (_context.prev = _context.next) { 36 | case 0: 37 | _context.next = 2; 38 | return (0, _.call)(function () { 39 | return delay(testCases.shift()); 40 | }); 41 | 42 | case 2: 43 | return _context.abrupt('return', _context.sent); 44 | 45 | case 3: 46 | case 'end': 47 | return _context.stop(); 48 | } 49 | } 50 | }, _callee, this); 51 | })); 52 | 53 | case 2: 54 | return _context2.abrupt('return', _context2.sent); 55 | 56 | case 3: 57 | case 'end': 58 | return _context2.stop(); 59 | } 60 | } 61 | }, generator, this); 62 | }); 63 | 64 | var cancel = (0, _handleGenerator2.default)({}, generator(), onGeneratorEnds); 65 | (0, _handleGenerator2.default)({}, generator(), onGeneratorEnds); 66 | cancel(); 67 | 68 | setTimeout(function () { 69 | expect(onGeneratorEnds).to.be.calledOnce.and.to.be.calledWith('b'); 70 | done(); 71 | }, 30); 72 | }); 73 | }); 74 | }); 75 | 76 | it("should catch errors in the function result of the call helper", function () { 77 | var mistake = function mistake() { 78 | throw new Error("oops"); 79 | }; 80 | 81 | var generator = /*#__PURE__*/regeneratorRuntime.mark(function generator() { 82 | return regeneratorRuntime.wrap(function generator$(_context3) { 83 | while (1) { 84 | switch (_context3.prev = _context3.next) { 85 | case 0: 86 | _context3.prev = 0; 87 | _context3.next = 3; 88 | return (0, _.call)(mistake); 89 | 90 | case 3: 91 | _context3.next = 10; 92 | break; 93 | 94 | case 5: 95 | _context3.prev = 5; 96 | _context3.t0 = _context3['catch'](0); 97 | _context3.next = 9; 98 | return (0, _.call)(function () { 99 | return _context3.t0.message; 100 | }); 101 | 102 | case 9: 103 | return _context3.abrupt('return', _context3.sent); 104 | 105 | case 10: 106 | case 'end': 107 | return _context3.stop(); 108 | } 109 | } 110 | }, generator, this, [[0, 5]]); 111 | }); 112 | 113 | (0, _handleGenerator2.default)({}, generator(), function (result) { 114 | return expect(result).to.be.equal("oops"); 115 | }); 116 | }); 117 | }); -------------------------------------------------------------------------------- /lib/__tests__/action-handler-scheduling.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 4 | 5 | var _ = require("../"); 6 | 7 | var _helpers = require("../helpers"); 8 | 9 | var sleep = function sleep() { 10 | var ms = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; 11 | 12 | return new Promise(function (resolve) { 13 | setTimeout(resolve, ms); 14 | }); 15 | }; 16 | 17 | var noop = function noop(machine) { 18 | return _extends({}, machine.state); 19 | }; 20 | 21 | describe("Stent machine", function () { 22 | beforeEach(function () { 23 | _.Machine.flush(); 24 | }); 25 | 26 | describe("when an async action handler is running", function () { 27 | this.timeout(5000); 28 | 29 | it("should accept state changes from other action handlers", function () { 30 | return new Promise(function (resolve, reject) { 31 | var machine = _.Machine.create("app", { 32 | state: { name: "A" }, 33 | transitions: { 34 | A: { 35 | init: /*#__PURE__*/regeneratorRuntime.mark(function init(machine) { 36 | return regeneratorRuntime.wrap(function init$(_context) { 37 | while (1) { 38 | switch (_context.prev = _context.next) { 39 | case 0: 40 | _context.next = 2; 41 | return (0, _helpers.call)(sleep, 100); 42 | 43 | case 2: 44 | _context.next = 4; 45 | return _extends({}, machine.state, { name: "B" }); 46 | 47 | case 4: 48 | _context.next = 6; 49 | return (0, _helpers.call)(sleep, 100); 50 | 51 | case 6: 52 | _context.next = 8; 53 | return _extends({}, machine.state, { name: "C" }); 54 | 55 | case 8: 56 | return _context.abrupt("return", _context.sent); 57 | 58 | case 9: 59 | case "end": 60 | return _context.stop(); 61 | } 62 | } 63 | }, init, this); 64 | }), 65 | goToD: /*#__PURE__*/regeneratorRuntime.mark(function goToD(machine) { 66 | return regeneratorRuntime.wrap(function goToD$(_context2) { 67 | while (1) { 68 | switch (_context2.prev = _context2.next) { 69 | case 0: 70 | _context2.next = 2; 71 | return (0, _helpers.call)(sleep, 100); 72 | 73 | case 2: 74 | _context2.next = 4; 75 | return _extends({}, machine.state, { name: "D" }); 76 | 77 | case 4: 78 | return _context2.abrupt("return", _context2.sent); 79 | 80 | case 5: 81 | case "end": 82 | return _context2.stop(); 83 | } 84 | } 85 | }, goToD, this); 86 | }), 87 | syncGoToE: function syncGoToE(machine) { 88 | return _extends({}, machine.state, { name: "E" }); 89 | } 90 | }, 91 | B: { 92 | noop: noop 93 | }, 94 | C: { 95 | noop: noop 96 | }, 97 | D: { 98 | noop: noop 99 | }, 100 | E: { 101 | noop: noop 102 | } 103 | } 104 | }); 105 | 106 | var actual = []; 107 | 108 | var expected = ["A", "E", "B", "D", "C"]; 109 | 110 | (0, _helpers.connect)().with(machine.name).map(function (m) { 111 | var stateName = m.state.name; 112 | 113 | actual.push(stateName); 114 | 115 | if (stateName === "C") { 116 | try { 117 | expect(actual).to.deep.equal(expected); 118 | resolve(); 119 | } catch (err) { 120 | reject(err); 121 | } 122 | } 123 | }); 124 | 125 | machine.init(); 126 | machine.goToD(); 127 | machine.syncGoToE(); 128 | }); 129 | }); 130 | }); 131 | }); -------------------------------------------------------------------------------- /lib/helpers/handleGenerator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 6 | 7 | exports.default = handleGenerator; 8 | 9 | var _handleMiddleware = require('./handleMiddleware'); 10 | 11 | var _handleMiddleware2 = _interopRequireDefault(_handleMiddleware); 12 | 13 | var _constants = require('../constants'); 14 | 15 | var _updateState = require('./updateState'); 16 | 17 | var _updateState2 = _interopRequireDefault(_updateState); 18 | 19 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 20 | 21 | function handleGenerator(machine, generator, done, resultOfPreviousOperation) { 22 | var generatorNext = function generatorNext(gen, res) { 23 | return !canceled && gen.next(res); 24 | }; 25 | var generatorThrow = function generatorThrow(gen, error) { 26 | return !canceled && gen.throw(error); 27 | }; 28 | var cancelGenerator = function cancelGenerator() { 29 | cancelInsideGenerator && cancelInsideGenerator(); 30 | canceled = true; 31 | }; 32 | var canceled = false; 33 | var cancelInsideGenerator; 34 | 35 | var iterate = function iterate(result) { 36 | if (canceled) return; 37 | 38 | if (!result.done) { 39 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_STEP, machine, result.value); 40 | 41 | // yield call 42 | if (_typeof(result.value) === 'object' && result.value.__type === 'call') { 43 | var _result$value = result.value, 44 | func = _result$value.func, 45 | args = _result$value.args; 46 | 47 | 48 | if (!func) { 49 | var error = (0, _constants.ERROR_GENERATOR_FUNC_CALL_FAILED)(typeof func === 'undefined' ? 'undefined' : _typeof(func)); 50 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_RESUMED, machine, error); 51 | return iterate(generatorThrow(generator, new Error(error))); 52 | } 53 | 54 | try { 55 | var funcResult = func.apply(undefined, args); 56 | 57 | if (!funcResult) { 58 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_RESUMED, machine); 59 | iterate(generatorNext(generator)); 60 | return; 61 | } 62 | 63 | // promise 64 | if (typeof funcResult.then !== 'undefined') { 65 | funcResult.then(function (result) { 66 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_RESUMED, machine, result); 67 | return iterate(generatorNext(generator, result)); 68 | }, function (error) { 69 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_RESUMED, machine, error); 70 | return iterate(generatorThrow(generator, error)); 71 | }); 72 | // generator 73 | } else if (typeof funcResult.next === 'function') { 74 | try { 75 | cancelInsideGenerator = handleGenerator(machine, funcResult, function (generatorResult) { 76 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_RESUMED, machine, generatorResult); 77 | iterate(generatorNext(generator, generatorResult)); 78 | }); 79 | } catch (error) { 80 | return iterate(generatorThrow(generator, error)); 81 | } 82 | } else { 83 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_RESUMED, machine, funcResult); 84 | iterate(generatorNext(generator, funcResult)); 85 | } 86 | } catch (error) { 87 | return iterate(generatorThrow(generator, error)); 88 | } 89 | 90 | // a return statement of the normal function 91 | } else { 92 | (0, _updateState2.default)(machine, result.value); 93 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_RESUMED, machine); 94 | iterate(generatorNext(generator)); 95 | } 96 | 97 | // the end of the generator (return statement) 98 | } else { 99 | (0, _handleMiddleware2.default)(_constants.MIDDLEWARE_GENERATOR_END, machine, result.value); 100 | done(result.value); 101 | } 102 | }; 103 | 104 | iterate(generatorNext(generator, resultOfPreviousOperation)); 105 | 106 | return cancelGenerator; 107 | } 108 | module.exports = exports['default']; -------------------------------------------------------------------------------- /src/__tests__/index.spec.js: -------------------------------------------------------------------------------- 1 | import { Machine } from '../'; 2 | import { ERROR_MISSING_MACHINE } from '../constants'; 3 | import { call } from '../helpers'; 4 | 5 | const create = (name = 'app') => Machine.create(name, { 6 | state: { idle: { run: 'running' } }, 7 | transitions: {} 8 | }) 9 | 10 | describe('Given the Stent library', function () { 11 | beforeEach(() => { 12 | Machine.flush(); 13 | }); 14 | describe('when creating a new machine', function () { 15 | it('should have the machine with its name set up', function () { 16 | expect(create('foo').name).to.equal('foo'); 17 | }); 18 | describe('and we have a middleware attached', function () { 19 | it('should trigger the middleware hook', function () { 20 | const spy = sinon.spy(); 21 | 22 | Machine.addMiddleware({ 23 | onMachineCreated: spy 24 | }); 25 | create('xxxa'); 26 | 27 | expect(spy).to.be.calledOnce.and.to.be.calledWith(sinon.match({ name: 'xxxa'})); 28 | }); 29 | it('should call the onMiddlewareRegister hook if available', function () { 30 | const spy = sinon.spy(); 31 | 32 | Machine.addMiddleware({ 33 | onMiddlewareRegister: spy 34 | }); 35 | Machine.addMiddleware({ 36 | onMiddlewareRegister: spy 37 | }); 38 | 39 | expect(spy).to.be.calledTwice; 40 | }); 41 | }); 42 | }); 43 | describe('when `getting a machine', function () { 44 | it('should return the machine if it exists', function () { 45 | create('bar'); 46 | const foo = create('foo'); 47 | 48 | expect(Machine.get('bar').name).to.equal('bar'); 49 | expect(Machine.get(foo).name).to.equal('foo'); 50 | }); 51 | it('should throw an error if the machine does not exist', function () { 52 | create('bar'); 53 | 54 | expect(Machine.get.bind(Machine, 'baz')).to.throw(ERROR_MISSING_MACHINE('baz')); 55 | }); 56 | }); 57 | describe('when creating a machine without a name', function () { 58 | it('should be possible to fetch it by using the machine itself or the its generated name', function () { 59 | const machine = Machine.create({ 60 | state: { name: 'idle' }, 61 | transitions: { idle: { run: 'running' } } 62 | }); 63 | 64 | expect(Machine.get(machine).state.name).to.equal('idle'); 65 | expect(Machine.get(machine.name).state.name).to.equal('idle'); 66 | }); 67 | }); 68 | describe('when we fire two actions one after each other', function () { 69 | describe('and we use the .latest version of the action', function () { 70 | it('should cancel the first action and only work with the second one', 71 | function (done) { 72 | const backend = sinon.stub(); 73 | backend.withArgs('s').returns('salad'); 74 | backend.withArgs('st').returns('stent'); 75 | 76 | const api = function (char) { 77 | return new Promise(resolve => { 78 | setTimeout(() => resolve(backend(char)), 10); 79 | }); 80 | } 81 | 82 | const machine = Machine.create({ 83 | state: { name: 'x' }, 84 | transitions: { 85 | x: { 86 | type: function * (state, letter) { 87 | const match = yield call(api, letter); 88 | 89 | return { name: 'y', match }; 90 | } 91 | }, 92 | y: { 93 | 'noway': 'x' 94 | } 95 | } 96 | }); 97 | 98 | machine.type.latest('s'); 99 | machine.type.latest('st'); 100 | 101 | setTimeout(function () { 102 | expect(machine.state).to.deep.equal({ name: 'y', match: 'stent' }); 103 | done(); 104 | }, 20); 105 | }); 106 | }); 107 | }); 108 | describe('when using the `destroy` method', function () { 109 | it('should delete the machine', function () { 110 | Machine.create('foo', { state: {}, transitions: {} }); 111 | const B = Machine.create('bar', { state: {}, transitions: {} }); 112 | 113 | expect(typeof Machine.machines.foo).to.equal('object'); 114 | Machine.destroy('foo'); 115 | expect(typeof Machine.machines.foo).to.equal('undefined'); 116 | 117 | expect(typeof Machine.machines.bar).to.equal('object'); 118 | Machine.destroy(B); 119 | expect(typeof Machine.machines.bar).to.equal('undefined'); 120 | }); 121 | describe('and the machine does not exist', function () { 122 | it('should throw an error', function () { 123 | expect(Machine.destroy.bind(Machine, 'foo')).to.throw('foo'); 124 | }); 125 | }); 126 | }); 127 | }); 128 | 129 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | [Full documentation](./README.md) 4 | 5 | --- 6 | 7 | To create a new machine we simply import the `Machine` object and call its `create` method. 8 | 9 | ```js 10 | import { Machine } from 'stent'; 11 | 12 | const machine = Machine.create('name-of-the-machine', { 13 | state: { name: 'idle' }, 14 | transitions: { 15 | 'idle': { 16 | 'run': 'running' 17 | }, 18 | 'running': { 19 | 'stop': 'idle' 20 | } 21 | } 22 | }); 23 | ``` 24 | 25 | `{ name: 'idle'}` is the initial state. `transitions` is the place where we define all the possible states of the machine (`idle` and `running`) with their inputs (actions `run` and `stop`) that they accept. Notice that `stop` is not available when we are at `idle` state and `run` when we are at `running` state. That is an essential characteristic of the state machines - our app is doing only what we allow it to do. There is no sense to call `run` if we are already `running`. The state machine is eliminating side effects that lead to bugs from the very beginning. The library enforces declarative approach of programming which means that by defining the possible states and actions we clearly say what's possible in our application. The user and data flows become a lot more predictable simply because we restrict ourselves of dispatching actions at the wrong time/state. 26 | 27 | And because after the definition the machine knows what to expect it automatically creates a couple of things for us so. Based on the `transitions` property Stent generates: 28 | 29 | * Helper methods for checking if the machine is in a particular state. `idle` state produces `isIdle()` method, for `running` we have `isRunning()`. 30 | * Helper methods for dispatching actions - `run()` and `stop()`. If these methods are generators you may want to use also `run.latest()` or `stop.latest()` which still accepts the action but cancels the logic of a previous call. For example if we fire `run` multiple times really quick and we want to handle only the last one we should use `run.latest()` instead. Go to [action-handler](./action-handler.md) section to learn more. 31 | 32 | *We may use spaces and/or dashes in the state or action names but the rule is that Stent transforms the string to a camel case. For example if we have `fetching data` state the machine will have `isFetchingData()` method, `get fresh todos` action will result in `getFetchTodos()` method.* 33 | 34 | So, here's an example of how to use the machine above: 35 | 36 | ```js 37 | if (machine.isIdle()) { 38 | machine.run(); 39 | } 40 | if (machine.isRunning()) { 41 | machine.stop(); 42 | } 43 | console.log(machine.isIdle()); // true 44 | ``` 45 | 46 | The created machine may accept more than a string as a handler of the action. We may pass a function which accepts two arguments. The first one is the state machine and the second one is some meta data traveling with the action (if any). For example: 47 | 48 | ```js 49 | const machine = Machine.create('todo-app', { 50 | state: { name: 'idle', todos: [] }, 51 | transitions: { 52 | 'idle': { 53 | 'add todo': function (machine, todo) { 54 | return { 55 | name: 'idle', 56 | todos: [...machine.state.todos, todo] 57 | }; 58 | } 59 | } 60 | } 61 | }); 62 | 63 | machine.addTodo({ title: 'Fix that damn bug' }) 64 | ``` 65 | 66 | The *state* in the context of Stent is a vanilla JavaScript object literal. The only one reserved property is `name` which represents the state's name. Everything else depends on our business logic. In the example above that's the `todos` array. 67 | 68 | The handler function accepts the machine with the previous state and should return a new state in a immutable fashion. Same as the [Redux's reducer](http://redux.js.org/docs/basics/Reducers.html), whatever we return becomes the new state. 69 | 70 | The actual todo item is passed to the `addTodo` method and it comes as a second argument of the handler. 71 | 72 | Stent also accepts a generator function as a handler. That's inspired by the [redux-saga](https://redux-saga.js.org/) project. The generators have couple of interesting characteristics and this library uses two of them - the ability to generate multiple results (from a single function) and the ability to *pause* the execution. What if we need to fetch data from the server and want to handle that process with multiple states - `idle`, `fetching`, `done` and `error`. Here's how to do it with a generator as a handler: 73 | 74 | ```js 75 | const machine = Machine.create('todo-app', { 76 | state: { name: 'idle', todos: [] }, 77 | transitions: { 78 | 'idle': { 79 | 'fetch todos': function * () { 80 | yield { name: 'fetching' }; 81 | 82 | try { 83 | const todos = yield call(getTodos, '/api/todos'); 84 | } catch (error) { 85 | return { name: 'error', error }; 86 | } 87 | 88 | return { name: 'done', todos }; 89 | } 90 | } 91 | } 92 | }); 93 | ``` 94 | 95 | Assuming that `getTodos` is a function that accepts an endpoint as a string and returns a promise. Inside the generator we are allowed to `yield` two type of things: 96 | 97 | * A state object (which transitions the machine to that new state) 98 | * A call of Stent's helper functions like `call`. (more about those [helpers](./action-handler.md) below) 99 | 100 | Generator as an action handler is suitable for the cases where we do more then one thing and/or have async operations. 101 | 102 | --- 103 | 104 | [Full documentation](./README.md) 105 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 8.0.2 2 | 3 | Just a new unit test. 4 | 5 | ## 8.0.1 6 | 7 | Reconnecting to the machine when the component is re-mounted. Issue #38, PR: #39. 8 | 9 | ## 8.0.0 10 | 11 | Changing the way of how Stent names stuff. Issue: #32 , PR: #33 12 | 13 | ## 6.0.1 14 | 15 | Making sure that we check gets send to the `call` generator helper. 16 | 17 | ## 6.0.0 18 | 19 | Action handlers are pure functions now, i.e. they receive the machine as first argument and do not rely on `this` context. 20 | 21 | Custom machine functions are no longer supported. 22 | 23 | Example for a handler before 6.x: 24 | 25 | ``` 26 | 'add todo': function (state, todo) { 27 | return { 28 | name: 'idle', 29 | todos: [...state.todos, todo] 30 | }; 31 | } 32 | ``` 33 | 34 | Example for a handler function in 6.x: 35 | 36 | ``` 37 | 'add todo': function ({state}, todo) { 38 | return { 39 | name: 'idle', 40 | todos: [...state.todos, todo] 41 | }; 42 | } 43 | ``` 44 | 45 | Example for an arrow function as handler in 6.x: 46 | 47 | ``` 48 | 'add todo': ({state}, todo) => ({ 49 | name: 'idle', 50 | todos: [...state.todos, todo] 51 | }) 52 | ``` 53 | 54 | ## 5.1.0 55 | 56 | Every action now has a dedicated helper to see if it is available in the current machine transition set. For example: 57 | 58 | ``` 59 | { 60 | 'idle': { 61 | run: 'running' 62 | }, 63 | 'running': { 64 | stop: 'idle' 65 | } 66 | } 67 | 68 | // we have 69 | machine.isRunAllowed(); 70 | machine.isStopAllowed(); 71 | ``` 72 | 73 | ## 5.0.2 74 | 75 | Fixing the case where the `call` function has no return statement. 76 | 77 | ## 5.0.1 78 | 79 | The machine is no longer sent as a context of a function used in call helper. With this version also we are fixing the error handling of chained generators. 80 | 81 | ## 4.2.0 82 | 83 | Stent is no longer sending uid to a middleware when initializing. 84 | 85 | ## 4.1.0 86 | 87 | Support of custom methods added to the machine. 88 | 89 | ## 4.0.0 90 | 91 | DevTools middleware moved to [Stent DevTools emitters](https://github.com/krasimir/stent-dev-tools-emitters) project. 92 | 93 | ## 3.6.6 94 | 95 | Updates in the DevTools middleware. 96 | 97 | ## 3.6.5 98 | 99 | Proper serialization of an error. 100 | 101 | ## 3.6.4 102 | 103 | Exposing the `call` helper. 104 | 105 | ## 3.6.3 106 | 107 | Fire generator resume middleware hook in the proper moment. Also covering the case where the generator is resumed with an error. 108 | 109 | ## 3.6.2 110 | 111 | Calling onActionProcessed when the generator finishes. 112 | 113 | ## 3.6.1 114 | 115 | Sending unique ID to the DevTools instance. 116 | 117 | ## 3.6.0 118 | 119 | Adding `onGeneratorEnd` and `onGeneratorResumed` action types to the middleware. 120 | 121 | ## 3.5.3 - 3.5.8 122 | 123 | Changes in DevTools extension. 124 | 125 | ## 3.5.2 126 | 127 | Updating DevTools middleware onMachineConnect action. 128 | 129 | ## 3.5.1 130 | 131 | Send the current machines' state in every DevTools message. 132 | 133 | ## 3.5.0 134 | 135 | Improving DevTools middleware + test coverage 136 | 137 | ## 3.4.0 138 | 139 | Adding `onMiddlewareRegister` middleware hook. 140 | 141 | ## 3.3.1 142 | 143 | Exporting DevTools middleware. 144 | 145 | ## 3.3.0 146 | 147 | Adding DevTools middleware. 148 | 149 | ## 3.2.3 150 | 151 | Passing the name of the React component to the middleware hook. 152 | 153 | ## 3.2.2 154 | 155 | Adding disconnect middleware call. 156 | 157 | ## 3.2.1 158 | 159 | Fixing a critical bug introduced by 3.2.0 (do not use it). 160 | 161 | ## 3.2.0 162 | 163 | Adding machine `destroy` method. 164 | 165 | ## 3.1.2 166 | 167 | Tooling for the devtools. 168 | 169 | ## 3.1.1 170 | 171 | Adding new middleware hooks. (`onMachineCreated`, `onMachineConnected`, `onMachineDisconnected`) 172 | 173 | ## 3.0.1 174 | 175 | Adding devtools window global key access. 176 | 177 | ## 3.0.0 178 | 179 | - Adding `machine..latest` alias so we cover the `takeLatest` saga method 180 | - Changing the way of how the middlewares work. They now don't block the Stent's logic. No `next` method anymore. [Middlewares](./docs/middlewares.md) 181 | - Documentation is restructured 182 | 183 | ## 2.0.0 184 | 185 | Killing the `wait` generator helper. It is an anti-pattern. We shouldn't listen for actions like that. If there is such a need we better create a dedicated state for it. 186 | Also some clean up. 187 | 188 | ## 1.1.5 189 | 190 | Updating README + adding npm ignore file. 191 | 192 | ## 1.1.4 193 | 194 | Making sure that the js context is kept while running generators. 195 | 196 | ## 1.1.3 197 | 198 | Do not create a new error if a promise in a `call` gets rejected. 199 | 200 | ## 1.1.2 201 | 202 | Just a README update. 203 | 204 | ## 1.1.1 205 | 206 | Stop trying to print object in the Logger middleware. 207 | 208 | ## 1.1.0 209 | 210 | Adding `onGeneratorStep` to the middleware's hook. 211 | 212 | ## 1.0.0 213 | 214 | - Adding `Logger` middleware 215 | - When adding a middleware the hook `onStateChange` is now called `onStateChanged` 216 | 217 | ## 0.7.3 218 | 219 | Making sure that the mapping callback is fired only when the connected machine is updated. 220 | 221 | ## 0.7.2 222 | 223 | README changes. 224 | 225 | ## 0.7.1 226 | 227 | Fancy logo. 228 | 229 | ## 0.7.0 230 | 231 | NOT throwing an error if the dispatched action is missing in the current state. 232 | 233 | ## 0.6.0 234 | 235 | Short syntax for creating a machine. 236 | 237 | ## 0.5.0 238 | 239 | Support of no mapping function in the `connect` helper + support of `mapSilent` for the React's version of connect. 240 | 241 | ## 0.4.0 242 | 243 | Support of `wait(actionA, actionB)` on top of `wait([actionA, actionB])` 244 | 245 | ## Before 0.4.0 246 | 247 | Wild Wild West ... 248 | -------------------------------------------------------------------------------- /lib/__tests__/index.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 4 | 5 | var _ = require('../'); 6 | 7 | var _constants = require('../constants'); 8 | 9 | var _helpers = require('../helpers'); 10 | 11 | var create = function create() { 12 | var name = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'app'; 13 | return _.Machine.create(name, { 14 | state: { idle: { run: 'running' } }, 15 | transitions: {} 16 | }); 17 | }; 18 | 19 | describe('Given the Stent library', function () { 20 | beforeEach(function () { 21 | _.Machine.flush(); 22 | }); 23 | describe('when creating a new machine', function () { 24 | it('should have the machine with its name set up', function () { 25 | expect(create('foo').name).to.equal('foo'); 26 | }); 27 | describe('and we have a middleware attached', function () { 28 | it('should trigger the middleware hook', function () { 29 | var spy = sinon.spy(); 30 | 31 | _.Machine.addMiddleware({ 32 | onMachineCreated: spy 33 | }); 34 | create('xxxa'); 35 | 36 | expect(spy).to.be.calledOnce.and.to.be.calledWith(sinon.match({ name: 'xxxa' })); 37 | }); 38 | it('should call the onMiddlewareRegister hook if available', function () { 39 | var spy = sinon.spy(); 40 | 41 | _.Machine.addMiddleware({ 42 | onMiddlewareRegister: spy 43 | }); 44 | _.Machine.addMiddleware({ 45 | onMiddlewareRegister: spy 46 | }); 47 | 48 | expect(spy).to.be.calledTwice; 49 | }); 50 | }); 51 | }); 52 | describe('when `getting a machine', function () { 53 | it('should return the machine if it exists', function () { 54 | create('bar'); 55 | var foo = create('foo'); 56 | 57 | expect(_.Machine.get('bar').name).to.equal('bar'); 58 | expect(_.Machine.get(foo).name).to.equal('foo'); 59 | }); 60 | it('should throw an error if the machine does not exist', function () { 61 | create('bar'); 62 | 63 | expect(_.Machine.get.bind(_.Machine, 'baz')).to.throw((0, _constants.ERROR_MISSING_MACHINE)('baz')); 64 | }); 65 | }); 66 | describe('when creating a machine without a name', function () { 67 | it('should be possible to fetch it by using the machine itself or the its generated name', function () { 68 | var machine = _.Machine.create({ 69 | state: { name: 'idle' }, 70 | transitions: { idle: { run: 'running' } } 71 | }); 72 | 73 | expect(_.Machine.get(machine).state.name).to.equal('idle'); 74 | expect(_.Machine.get(machine.name).state.name).to.equal('idle'); 75 | }); 76 | }); 77 | describe('when we fire two actions one after each other', function () { 78 | describe('and we use the .latest version of the action', function () { 79 | it('should cancel the first action and only work with the second one', function (done) { 80 | var backend = sinon.stub(); 81 | backend.withArgs('s').returns('salad'); 82 | backend.withArgs('st').returns('stent'); 83 | 84 | var api = function api(char) { 85 | return new Promise(function (resolve) { 86 | setTimeout(function () { 87 | return resolve(backend(char)); 88 | }, 10); 89 | }); 90 | }; 91 | 92 | var machine = _.Machine.create({ 93 | state: { name: 'x' }, 94 | transitions: { 95 | x: { 96 | type: /*#__PURE__*/regeneratorRuntime.mark(function type(state, letter) { 97 | var match; 98 | return regeneratorRuntime.wrap(function type$(_context) { 99 | while (1) { 100 | switch (_context.prev = _context.next) { 101 | case 0: 102 | _context.next = 2; 103 | return (0, _helpers.call)(api, letter); 104 | 105 | case 2: 106 | match = _context.sent; 107 | return _context.abrupt('return', { name: 'y', match: match }); 108 | 109 | case 4: 110 | case 'end': 111 | return _context.stop(); 112 | } 113 | } 114 | }, type, this); 115 | }) 116 | }, 117 | y: { 118 | 'noway': 'x' 119 | } 120 | } 121 | }); 122 | 123 | machine.type.latest('s'); 124 | machine.type.latest('st'); 125 | 126 | setTimeout(function () { 127 | expect(machine.state).to.deep.equal({ name: 'y', match: 'stent' }); 128 | done(); 129 | }, 20); 130 | }); 131 | }); 132 | }); 133 | describe('when using the `destroy` method', function () { 134 | it('should delete the machine', function () { 135 | _.Machine.create('foo', { state: {}, transitions: {} }); 136 | var B = _.Machine.create('bar', { state: {}, transitions: {} }); 137 | 138 | expect(_typeof(_.Machine.machines.foo)).to.equal('object'); 139 | _.Machine.destroy('foo'); 140 | expect(_typeof(_.Machine.machines.foo)).to.equal('undefined'); 141 | 142 | expect(_typeof(_.Machine.machines.bar)).to.equal('object'); 143 | _.Machine.destroy(B); 144 | expect(_typeof(_.Machine.machines.bar)).to.equal('undefined'); 145 | }); 146 | describe('and the machine does not exist', function () { 147 | it('should throw an error', function () { 148 | expect(_.Machine.destroy.bind(_.Machine, 'foo')).to.throw('foo'); 149 | }); 150 | }); 151 | }); 152 | }); -------------------------------------------------------------------------------- /src/helpers/vendors/CircularJSON.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Copyright (C) 2013-2017 by Andrea Giammarchi - @WebReflection 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | 22 | */ 23 | var 24 | // should be a not so common char 25 | // possibly one JSON does not encode 26 | // possibly one encodeURIComponent does not encode 27 | // right now this char is '~' but this might change in the future 28 | specialChar = '~', 29 | safeSpecialChar = '\\x' + ( 30 | '0' + specialChar.charCodeAt(0).toString(16) 31 | ).slice(-2), 32 | escapedSafeSpecialChar = '\\' + safeSpecialChar, 33 | specialCharRG = new RegExp(safeSpecialChar, 'g'), 34 | safeSpecialCharRG = new RegExp(escapedSafeSpecialChar, 'g'), 35 | 36 | safeStartWithSpecialCharRG = new RegExp('(?:^|([^\\\\]))' + escapedSafeSpecialChar), 37 | 38 | indexOf = [].indexOf || function(v){ 39 | for(var i=this.length;i--&&this[i]!==v;); 40 | return i; 41 | }, 42 | $String = String // there's no way to drop warnings in JSHint 43 | // about new String ... well, I need that here! 44 | // faked, and happy linter! 45 | ; 46 | 47 | function generateReplacer(value, replacer, resolve) { 48 | var 49 | inspect = !!replacer, 50 | path = [], 51 | all = [value], 52 | seen = [value], 53 | mapp = [resolve ? specialChar : '[Circular]'], 54 | last = value, 55 | lvl = 1, 56 | i, fn 57 | ; 58 | if (inspect) { 59 | fn = typeof replacer === 'object' ? 60 | function (key, value) { 61 | return key !== '' && replacer.indexOf(key) < 0 ? void 0 : value; 62 | } : 63 | replacer; 64 | } 65 | return function(key, value) { 66 | // the replacer has rights to decide 67 | // if a new object should be returned 68 | // or if there's some key to drop 69 | // let's call it here rather than "too late" 70 | if (inspect) value = fn.call(this, key, value); 71 | 72 | // did you know ? Safari passes keys as integers for arrays 73 | // which means if (key) when key === 0 won't pass the check 74 | if (key !== '') { 75 | if (last !== this) { 76 | i = lvl - indexOf.call(all, this) - 1; 77 | lvl -= i; 78 | all.splice(lvl, all.length); 79 | path.splice(lvl - 1, path.length); 80 | last = this; 81 | } 82 | // console.log(lvl, key, path); 83 | if (typeof value === 'object' && value) { 84 | // if object isn't referring to parent object, add to the 85 | // object path stack. Otherwise it is already there. 86 | if (indexOf.call(all, value) < 0) { 87 | all.push(last = value); 88 | } 89 | lvl = all.length; 90 | i = indexOf.call(seen, value); 91 | if (i < 0) { 92 | i = seen.push(value) - 1; 93 | if (resolve) { 94 | // key cannot contain specialChar but could be not a string 95 | path.push(('' + key).replace(specialCharRG, safeSpecialChar)); 96 | mapp[i] = specialChar + path.join(specialChar); 97 | } else { 98 | mapp[i] = mapp[0]; 99 | } 100 | } else { 101 | value = mapp[i]; 102 | } 103 | } else { 104 | if (typeof value === 'string' && resolve) { 105 | // ensure no special char involved on deserialization 106 | // in this case only first char is important 107 | // no need to replace all value (better performance) 108 | value = value .replace(safeSpecialChar, escapedSafeSpecialChar) 109 | .replace(specialChar, safeSpecialChar); 110 | } 111 | } 112 | } 113 | return value; 114 | }; 115 | } 116 | 117 | function retrieveFromPath(current, keys) { 118 | for(var i = 0, length = keys.length; i < length; current = current[ 119 | // keys should be normalized back here 120 | keys[i++].replace(safeSpecialCharRG, specialChar) 121 | ]); 122 | return current; 123 | } 124 | 125 | function generateReviver(reviver) { 126 | return function(key, value) { 127 | var isString = typeof value === 'string'; 128 | if (isString && value.charAt(0) === specialChar) { 129 | return new $String(value.slice(1)); 130 | } 131 | if (key === '') value = regenerate(value, value, {}); 132 | // again, only one needed, do not use the RegExp for this replacement 133 | // only keys need the RegExp 134 | if (isString) value = value .replace(safeStartWithSpecialCharRG, '$1' + specialChar) 135 | .replace(escapedSafeSpecialChar, safeSpecialChar); 136 | return reviver ? reviver.call(this, key, value) : value; 137 | }; 138 | } 139 | 140 | function regenerateArray(root, current, retrieve) { 141 | for (var i = 0, length = current.length; i < length; i++) { 142 | current[i] = regenerate(root, current[i], retrieve); 143 | } 144 | return current; 145 | } 146 | 147 | function regenerateObject(root, current, retrieve) { 148 | for (var key in current) { 149 | if (current.hasOwnProperty(key)) { 150 | current[key] = regenerate(root, current[key], retrieve); 151 | } 152 | } 153 | return current; 154 | } 155 | 156 | function regenerate(root, current, retrieve) { 157 | return current instanceof Array ? 158 | // fast Array reconstruction 159 | regenerateArray(root, current, retrieve) : 160 | ( 161 | current instanceof $String ? 162 | ( 163 | // root is an empty string 164 | current.length ? 165 | ( 166 | retrieve.hasOwnProperty(current) ? 167 | retrieve[current] : 168 | retrieve[current] = retrieveFromPath( 169 | root, current.split(specialChar) 170 | ) 171 | ) : 172 | root 173 | ) : 174 | ( 175 | current instanceof Object ? 176 | // dedicated Object parser 177 | regenerateObject(root, current, retrieve) : 178 | // value as it is 179 | current 180 | ) 181 | ) 182 | ; 183 | } 184 | 185 | function stringifyRecursion(value, replacer, space, doNotResolve) { 186 | return JSON.stringify(value, generateReplacer(value, replacer, !doNotResolve), space); 187 | } 188 | 189 | function parseRecursion(text, reviver) { 190 | return JSON.parse(text, generateReviver(reviver)); 191 | } 192 | 193 | export default { 194 | stringify: stringifyRecursion, 195 | parse: parseRecursion 196 | } -------------------------------------------------------------------------------- /lib/helpers/vendors/CircularJSON.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.__esModule = true; 4 | 5 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 6 | 7 | /*! 8 | Copyright (C) 2013-2017 by Andrea Giammarchi - @WebReflection 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in 18 | all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | THE SOFTWARE. 27 | 28 | */ 29 | var 30 | // should be a not so common char 31 | // possibly one JSON does not encode 32 | // possibly one encodeURIComponent does not encode 33 | // right now this char is '~' but this might change in the future 34 | specialChar = '~', 35 | safeSpecialChar = '\\x' + ('0' + specialChar.charCodeAt(0).toString(16)).slice(-2), 36 | escapedSafeSpecialChar = '\\' + safeSpecialChar, 37 | specialCharRG = new RegExp(safeSpecialChar, 'g'), 38 | safeSpecialCharRG = new RegExp(escapedSafeSpecialChar, 'g'), 39 | safeStartWithSpecialCharRG = new RegExp('(?:^|([^\\\\]))' + escapedSafeSpecialChar), 40 | indexOf = [].indexOf || function (v) { 41 | for (var i = this.length; i-- && this[i] !== v;) {} 42 | return i; 43 | }, 44 | $String = String // there's no way to drop warnings in JSHint 45 | // about new String ... well, I need that here! 46 | // faked, and happy linter! 47 | ; 48 | 49 | function generateReplacer(value, replacer, resolve) { 50 | var inspect = !!replacer, 51 | path = [], 52 | all = [value], 53 | seen = [value], 54 | mapp = [resolve ? specialChar : '[Circular]'], 55 | last = value, 56 | lvl = 1, 57 | i, 58 | fn; 59 | if (inspect) { 60 | fn = (typeof replacer === 'undefined' ? 'undefined' : _typeof(replacer)) === 'object' ? function (key, value) { 61 | return key !== '' && replacer.indexOf(key) < 0 ? void 0 : value; 62 | } : replacer; 63 | } 64 | return function (key, value) { 65 | // the replacer has rights to decide 66 | // if a new object should be returned 67 | // or if there's some key to drop 68 | // let's call it here rather than "too late" 69 | if (inspect) value = fn.call(this, key, value); 70 | 71 | // did you know ? Safari passes keys as integers for arrays 72 | // which means if (key) when key === 0 won't pass the check 73 | if (key !== '') { 74 | if (last !== this) { 75 | i = lvl - indexOf.call(all, this) - 1; 76 | lvl -= i; 77 | all.splice(lvl, all.length); 78 | path.splice(lvl - 1, path.length); 79 | last = this; 80 | } 81 | // console.log(lvl, key, path); 82 | if ((typeof value === 'undefined' ? 'undefined' : _typeof(value)) === 'object' && value) { 83 | // if object isn't referring to parent object, add to the 84 | // object path stack. Otherwise it is already there. 85 | if (indexOf.call(all, value) < 0) { 86 | all.push(last = value); 87 | } 88 | lvl = all.length; 89 | i = indexOf.call(seen, value); 90 | if (i < 0) { 91 | i = seen.push(value) - 1; 92 | if (resolve) { 93 | // key cannot contain specialChar but could be not a string 94 | path.push(('' + key).replace(specialCharRG, safeSpecialChar)); 95 | mapp[i] = specialChar + path.join(specialChar); 96 | } else { 97 | mapp[i] = mapp[0]; 98 | } 99 | } else { 100 | value = mapp[i]; 101 | } 102 | } else { 103 | if (typeof value === 'string' && resolve) { 104 | // ensure no special char involved on deserialization 105 | // in this case only first char is important 106 | // no need to replace all value (better performance) 107 | value = value.replace(safeSpecialChar, escapedSafeSpecialChar).replace(specialChar, safeSpecialChar); 108 | } 109 | } 110 | } 111 | return value; 112 | }; 113 | } 114 | 115 | function retrieveFromPath(current, keys) { 116 | for (var i = 0, length = keys.length; i < length; current = current[ 117 | // keys should be normalized back here 118 | keys[i++].replace(safeSpecialCharRG, specialChar)]) {} 119 | return current; 120 | } 121 | 122 | function generateReviver(reviver) { 123 | return function (key, value) { 124 | var isString = typeof value === 'string'; 125 | if (isString && value.charAt(0) === specialChar) { 126 | return new $String(value.slice(1)); 127 | } 128 | if (key === '') value = regenerate(value, value, {}); 129 | // again, only one needed, do not use the RegExp for this replacement 130 | // only keys need the RegExp 131 | if (isString) value = value.replace(safeStartWithSpecialCharRG, '$1' + specialChar).replace(escapedSafeSpecialChar, safeSpecialChar); 132 | return reviver ? reviver.call(this, key, value) : value; 133 | }; 134 | } 135 | 136 | function regenerateArray(root, current, retrieve) { 137 | for (var i = 0, length = current.length; i < length; i++) { 138 | current[i] = regenerate(root, current[i], retrieve); 139 | } 140 | return current; 141 | } 142 | 143 | function regenerateObject(root, current, retrieve) { 144 | for (var key in current) { 145 | if (current.hasOwnProperty(key)) { 146 | current[key] = regenerate(root, current[key], retrieve); 147 | } 148 | } 149 | return current; 150 | } 151 | 152 | function regenerate(root, current, retrieve) { 153 | return current instanceof Array ? 154 | // fast Array reconstruction 155 | regenerateArray(root, current, retrieve) : current instanceof $String ? 156 | // root is an empty string 157 | current.length ? retrieve.hasOwnProperty(current) ? retrieve[current] : retrieve[current] = retrieveFromPath(root, current.split(specialChar)) : root : current instanceof Object ? 158 | // dedicated Object parser 159 | regenerateObject(root, current, retrieve) : 160 | // value as it is 161 | current; 162 | } 163 | 164 | function stringifyRecursion(value, replacer, space, doNotResolve) { 165 | return JSON.stringify(value, generateReplacer(value, replacer, !doNotResolve), space); 166 | } 167 | 168 | function parseRecursion(text, reviver) { 169 | return JSON.parse(text, generateReviver(reviver)); 170 | } 171 | 172 | exports.default = { 173 | stringify: stringifyRecursion, 174 | parse: parseRecursion 175 | }; 176 | module.exports = exports['default']; -------------------------------------------------------------------------------- /src/helpers/__tests__/connect.spec.js: -------------------------------------------------------------------------------- 1 | import connect from '../connect'; 2 | import { getMapping } from '../connect'; 3 | import { Machine } from '../../'; 4 | 5 | describe('Given the connect helper', function () { 6 | beforeEach(() => { 7 | Machine.flush(); 8 | }); 9 | describe('when using connect', function () { 10 | it('should allow mapping to machines', function (done) { 11 | Machine.create('A', { 12 | state: { name: 'idle' }, 13 | transitions: { 14 | idle: { run: 'running' }, 15 | running: { stop: 'idle' } 16 | } 17 | }); 18 | Machine.create('B', { 19 | state: { name: 'waiting' }, 20 | transitions: { 21 | waiting: { fetch: 'fetching' }, 22 | fetching: { done: 'waiting' } 23 | } 24 | }); 25 | connect() 26 | .with('A', 'B') 27 | .map((A, B) => { 28 | 29 | expect(A.state.name).to.equal('idle'); 30 | expect(B.state.name).to.equal('waiting'); 31 | done(); 32 | }); 33 | }); 34 | it('should add only one middleware', function () { 35 | const mappingA = sinon.spy(); 36 | const mappingB = sinon.spy(); 37 | const machine = Machine.create('A', { 38 | state: { name: 'idle' }, 39 | transitions: { 40 | idle: { run: 'running' }, 41 | running: { stop: 'idle' } 42 | } 43 | }); 44 | 45 | connect().with('A').map(mappingA); 46 | connect().with('A').map(mappingB); 47 | connect().with(machine).map(mappingB); 48 | 49 | expect(Machine.middlewares.length).to.be.equal(1); 50 | }); 51 | describe('and when we update the state of the mapped machine/s', function () { 52 | it('should fire the mapping function', function () { 53 | const mapping = sinon.spy(); 54 | const machine = Machine.create('A', { 55 | state: { name: 'idle' }, 56 | transitions: { 57 | idle: { run: 'running' }, 58 | running: { stop: 'idle' } 59 | } 60 | }); 61 | 62 | connect().with('A').map(A => { 63 | mapping(A.state.name); 64 | }); 65 | 66 | machine.run(); 67 | machine.stop(); 68 | 69 | expect(mapping).to.be.calledThrice; 70 | expect(mapping.firstCall).to.be.calledWith(sinon.match('idle')); 71 | expect(mapping.secondCall).to.be.calledWith(sinon.match('running')); 72 | expect(mapping.thirdCall).to.be.calledWith(sinon.match('idle')); 73 | }); 74 | }); 75 | describe('and we use `mapOnce`', function () { 76 | it('should fire the mapping only once', function () { 77 | const mapping = sinon.spy(); 78 | const machine = Machine.create('A', { 79 | state: { name: 'idle' }, 80 | transitions: { 81 | idle: { run: 'running' }, 82 | running: { stop: 'idle' } 83 | } 84 | }); 85 | 86 | connect().with('A').mapOnce(A => { 87 | mapping(A.state.name); 88 | }); 89 | 90 | machine.run(); 91 | machine.stop(); 92 | 93 | expect(mapping).to.be.calledOnce; 94 | expect(mapping.firstCall).to.be.calledWith(sinon.match('idle')); 95 | }); 96 | }); 97 | describe('and we use `mapSilent`', function () { 98 | it('should fire the mapping only when the machine changes its state', function () { 99 | const mapping = sinon.spy(); 100 | const machine = Machine.create('A', { 101 | state: { name: 'idle' }, 102 | transitions: { 103 | idle: { run: 'running' }, 104 | running: { jump: 'jumping' }, 105 | jumping: { stop: 'idle' } 106 | } 107 | }); 108 | 109 | connect().with('A').mapSilent(A => { 110 | mapping(A.state.name); 111 | }); 112 | 113 | machine.run(); 114 | machine.jump(); 115 | 116 | expect(mapping).to.be.calledTwice; 117 | expect(mapping.firstCall).to.be.calledWith(sinon.match('running')); 118 | expect(mapping.secondCall).to.be.calledWith(sinon.match('jumping')); 119 | }); 120 | }); 121 | describe('and we pass no mapping function', function () { 122 | it('should still do the connecting', function () { 123 | const machine = Machine.create('A', { 124 | state: { name: 'idle' }, 125 | transitions: { 126 | idle: { run: 'running' }, 127 | running: { stop: 'idle' } 128 | } 129 | }); 130 | 131 | connect().with('A').map(); 132 | 133 | machine.run(); 134 | 135 | expect(machine.state).to.deep.equal({ name: 'running' }); 136 | }); 137 | }); 138 | describe('and we have two mappers', function () { 139 | it('should call them only if the machine that they are connected transitions', function () { 140 | const machineA = Machine.create('A', { 141 | state: { name: 'idle' }, 142 | transitions: { 143 | idle: { run: 'running' }, 144 | running: { stop: 'idle' } 145 | } 146 | }); 147 | const machineB = Machine.create('B', { 148 | state: { name: 'idle' }, 149 | transitions: { 150 | idle: { run: 'running' }, 151 | running: { stop: 'idle' } 152 | } 153 | }); 154 | const spyA = sinon.spy(); 155 | const spyB = sinon.spy(); 156 | 157 | connect().with('A').mapSilent(spyA); 158 | connect().with('B').mapSilent(spyB); 159 | 160 | machineA.run(); 161 | 162 | expect(spyA).to.be.calledOnce; 163 | expect(spyB).to.not.be.called; 164 | }); 165 | }); 166 | describe('and we have a middleware attached', function () { 167 | it('should call the proper middleware hook', function () { 168 | const onMachineConnected = sinon.spy(); 169 | 170 | Machine.addMiddleware({ onMachineConnected }); 171 | Machine.create('A', { 172 | state: { name: 'idle' }, 173 | transitions: { 174 | idle: { run: 'running' }, 175 | running: { stop: 'idle' } 176 | } 177 | }); 178 | Machine.create('B', { 179 | state: { name: 'waiting' }, 180 | transitions: { 181 | waiting: { fetch: 'fetching' }, 182 | fetching: { done: 'waiting' } 183 | } 184 | }); 185 | connect() 186 | .with('A', 'B') 187 | .map(() => {}); 188 | 189 | expect(onMachineConnected).to.be.calledOnce.and.to.be.calledWith(sinon.match([ 190 | sinon.match({ name: 'A' }), 191 | sinon.match({ name: 'B' }) 192 | ])); 193 | }); 194 | }); 195 | describe('and we destroy a machine', function () { 196 | it('should remove the machine from any mapping', function () { 197 | const A = Machine.create('A', { 198 | state: { name: 'idle' }, 199 | transitions: { 200 | idle: { run: 'running' }, 201 | running: { stop: 'idle' } 202 | } 203 | }); 204 | const B = Machine.create('B', { 205 | state: { name: 'waiting' }, 206 | transitions: { 207 | waiting: { fetch: 'fetching' }, 208 | fetching: { done: 'waiting' } 209 | } 210 | }); 211 | connect().with('A', 'B').map((A, B) => {}); 212 | connect().with('B').map(B => {}); 213 | connect().with('A').map(A => {}); 214 | 215 | const [ m1, m2, m3 ] = Object.keys(getMapping()); 216 | 217 | expect(getMapping()[m1].machines.map(m => m.name)).to.deep.equal(['A', 'B']); 218 | expect(getMapping()[m2].machines.map(m => m.name)).to.deep.equal(['B']); 219 | expect(getMapping()[m3].machines.map(m => m.name)).to.deep.equal(['A']); 220 | A.destroy(); 221 | expect(getMapping()[m1].machines.map(m => m.name)).to.deep.equal(['B']); 222 | expect(getMapping()[m2].machines.map(m => m.name)).to.deep.equal(['B']); 223 | expect(typeof getMapping()[m3]).to.be.equal('undefined'); 224 | B.destroy(); 225 | expect(getMapping()).to.be.deep.equal({}); 226 | }); 227 | }); 228 | }); 229 | describe('when we use the `disconnect` function', function () { 230 | it('should detach the mapping', function () { 231 | const mapping = sinon.spy(); 232 | const machine = Machine.create('A', { 233 | state: { name: 'idle' }, 234 | transitions: { 235 | idle: { run: 'running' }, 236 | running: { stop: 'idle' } 237 | } 238 | }); 239 | const disconnect = connect().with('A').map(A => { 240 | mapping(A.state.name); 241 | }); 242 | 243 | machine.run(); 244 | disconnect(); 245 | machine.stop(); 246 | 247 | expect(mapping).to.be.calledTwice; 248 | expect(mapping.firstCall).to.be.calledWith(sinon.match('idle')); 249 | expect(mapping.secondCall).to.be.calledWith(sinon.match('running')); 250 | }); 251 | describe('and we have a middleware attached', function () { 252 | it('should call the proper middleware hook', function () { 253 | const onMachineDisconnected = sinon.spy(); 254 | 255 | Machine.addMiddleware({ onMachineDisconnected }); 256 | Machine.create('A', { 257 | state: { name: 'idle' }, 258 | transitions: { 259 | idle: { run: 'running' }, 260 | running: { stop: 'idle' } 261 | } 262 | }); 263 | Machine.create('B', { 264 | state: { name: 'waiting' }, 265 | transitions: { 266 | waiting: { fetch: 'fetching' }, 267 | fetching: { done: 'waiting' } 268 | } 269 | }); 270 | connect() 271 | .with('A', 'B') 272 | .map(() => {})(); 273 | 274 | expect(onMachineDisconnected).to.be.calledOnce.and.to.be.calledWith(sinon.match([ 275 | sinon.match({ name: 'A' }), 276 | sinon.match({ name: 'B' }) 277 | ])); 278 | }); 279 | }); 280 | }); 281 | }); -------------------------------------------------------------------------------- /lib/helpers/__tests__/connect.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 4 | 5 | var _connect = require('../connect'); 6 | 7 | var _connect2 = _interopRequireDefault(_connect); 8 | 9 | var _ = require('../../'); 10 | 11 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 12 | 13 | describe('Given the connect helper', function () { 14 | beforeEach(function () { 15 | _.Machine.flush(); 16 | }); 17 | describe('when using connect', function () { 18 | it('should allow mapping to machines', function (done) { 19 | _.Machine.create('A', { 20 | state: { name: 'idle' }, 21 | transitions: { 22 | idle: { run: 'running' }, 23 | running: { stop: 'idle' } 24 | } 25 | }); 26 | _.Machine.create('B', { 27 | state: { name: 'waiting' }, 28 | transitions: { 29 | waiting: { fetch: 'fetching' }, 30 | fetching: { done: 'waiting' } 31 | } 32 | }); 33 | (0, _connect2.default)().with('A', 'B').map(function (A, B) { 34 | 35 | expect(A.state.name).to.equal('idle'); 36 | expect(B.state.name).to.equal('waiting'); 37 | done(); 38 | }); 39 | }); 40 | it('should add only one middleware', function () { 41 | var mappingA = sinon.spy(); 42 | var mappingB = sinon.spy(); 43 | var machine = _.Machine.create('A', { 44 | state: { name: 'idle' }, 45 | transitions: { 46 | idle: { run: 'running' }, 47 | running: { stop: 'idle' } 48 | } 49 | }); 50 | 51 | (0, _connect2.default)().with('A').map(mappingA); 52 | (0, _connect2.default)().with('A').map(mappingB); 53 | (0, _connect2.default)().with(machine).map(mappingB); 54 | 55 | expect(_.Machine.middlewares.length).to.be.equal(1); 56 | }); 57 | describe('and when we update the state of the mapped machine/s', function () { 58 | it('should fire the mapping function', function () { 59 | var mapping = sinon.spy(); 60 | var machine = _.Machine.create('A', { 61 | state: { name: 'idle' }, 62 | transitions: { 63 | idle: { run: 'running' }, 64 | running: { stop: 'idle' } 65 | } 66 | }); 67 | 68 | (0, _connect2.default)().with('A').map(function (A) { 69 | mapping(A.state.name); 70 | }); 71 | 72 | machine.run(); 73 | machine.stop(); 74 | 75 | expect(mapping).to.be.calledThrice; 76 | expect(mapping.firstCall).to.be.calledWith(sinon.match('idle')); 77 | expect(mapping.secondCall).to.be.calledWith(sinon.match('running')); 78 | expect(mapping.thirdCall).to.be.calledWith(sinon.match('idle')); 79 | }); 80 | }); 81 | describe('and we use `mapOnce`', function () { 82 | it('should fire the mapping only once', function () { 83 | var mapping = sinon.spy(); 84 | var machine = _.Machine.create('A', { 85 | state: { name: 'idle' }, 86 | transitions: { 87 | idle: { run: 'running' }, 88 | running: { stop: 'idle' } 89 | } 90 | }); 91 | 92 | (0, _connect2.default)().with('A').mapOnce(function (A) { 93 | mapping(A.state.name); 94 | }); 95 | 96 | machine.run(); 97 | machine.stop(); 98 | 99 | expect(mapping).to.be.calledOnce; 100 | expect(mapping.firstCall).to.be.calledWith(sinon.match('idle')); 101 | }); 102 | }); 103 | describe('and we use `mapSilent`', function () { 104 | it('should fire the mapping only when the machine changes its state', function () { 105 | var mapping = sinon.spy(); 106 | var machine = _.Machine.create('A', { 107 | state: { name: 'idle' }, 108 | transitions: { 109 | idle: { run: 'running' }, 110 | running: { jump: 'jumping' }, 111 | jumping: { stop: 'idle' } 112 | } 113 | }); 114 | 115 | (0, _connect2.default)().with('A').mapSilent(function (A) { 116 | mapping(A.state.name); 117 | }); 118 | 119 | machine.run(); 120 | machine.jump(); 121 | 122 | expect(mapping).to.be.calledTwice; 123 | expect(mapping.firstCall).to.be.calledWith(sinon.match('running')); 124 | expect(mapping.secondCall).to.be.calledWith(sinon.match('jumping')); 125 | }); 126 | }); 127 | describe('and we pass no mapping function', function () { 128 | it('should still do the connecting', function () { 129 | var machine = _.Machine.create('A', { 130 | state: { name: 'idle' }, 131 | transitions: { 132 | idle: { run: 'running' }, 133 | running: { stop: 'idle' } 134 | } 135 | }); 136 | 137 | (0, _connect2.default)().with('A').map(); 138 | 139 | machine.run(); 140 | 141 | expect(machine.state).to.deep.equal({ name: 'running' }); 142 | }); 143 | }); 144 | describe('and we have two mappers', function () { 145 | it('should call them only if the machine that they are connected transitions', function () { 146 | var machineA = _.Machine.create('A', { 147 | state: { name: 'idle' }, 148 | transitions: { 149 | idle: { run: 'running' }, 150 | running: { stop: 'idle' } 151 | } 152 | }); 153 | var machineB = _.Machine.create('B', { 154 | state: { name: 'idle' }, 155 | transitions: { 156 | idle: { run: 'running' }, 157 | running: { stop: 'idle' } 158 | } 159 | }); 160 | var spyA = sinon.spy(); 161 | var spyB = sinon.spy(); 162 | 163 | (0, _connect2.default)().with('A').mapSilent(spyA); 164 | (0, _connect2.default)().with('B').mapSilent(spyB); 165 | 166 | machineA.run(); 167 | 168 | expect(spyA).to.be.calledOnce; 169 | expect(spyB).to.not.be.called; 170 | }); 171 | }); 172 | describe('and we have a middleware attached', function () { 173 | it('should call the proper middleware hook', function () { 174 | var onMachineConnected = sinon.spy(); 175 | 176 | _.Machine.addMiddleware({ onMachineConnected: onMachineConnected }); 177 | _.Machine.create('A', { 178 | state: { name: 'idle' }, 179 | transitions: { 180 | idle: { run: 'running' }, 181 | running: { stop: 'idle' } 182 | } 183 | }); 184 | _.Machine.create('B', { 185 | state: { name: 'waiting' }, 186 | transitions: { 187 | waiting: { fetch: 'fetching' }, 188 | fetching: { done: 'waiting' } 189 | } 190 | }); 191 | (0, _connect2.default)().with('A', 'B').map(function () {}); 192 | 193 | expect(onMachineConnected).to.be.calledOnce.and.to.be.calledWith(sinon.match([sinon.match({ name: 'A' }), sinon.match({ name: 'B' })])); 194 | }); 195 | }); 196 | describe('and we destroy a machine', function () { 197 | it('should remove the machine from any mapping', function () { 198 | var A = _.Machine.create('A', { 199 | state: { name: 'idle' }, 200 | transitions: { 201 | idle: { run: 'running' }, 202 | running: { stop: 'idle' } 203 | } 204 | }); 205 | var B = _.Machine.create('B', { 206 | state: { name: 'waiting' }, 207 | transitions: { 208 | waiting: { fetch: 'fetching' }, 209 | fetching: { done: 'waiting' } 210 | } 211 | }); 212 | (0, _connect2.default)().with('A', 'B').map(function (A, B) {}); 213 | (0, _connect2.default)().with('B').map(function (B) {}); 214 | (0, _connect2.default)().with('A').map(function (A) {}); 215 | 216 | var _Object$keys = Object.keys((0, _connect.getMapping)()), 217 | m1 = _Object$keys[0], 218 | m2 = _Object$keys[1], 219 | m3 = _Object$keys[2]; 220 | 221 | expect((0, _connect.getMapping)()[m1].machines.map(function (m) { 222 | return m.name; 223 | })).to.deep.equal(['A', 'B']); 224 | expect((0, _connect.getMapping)()[m2].machines.map(function (m) { 225 | return m.name; 226 | })).to.deep.equal(['B']); 227 | expect((0, _connect.getMapping)()[m3].machines.map(function (m) { 228 | return m.name; 229 | })).to.deep.equal(['A']); 230 | A.destroy(); 231 | expect((0, _connect.getMapping)()[m1].machines.map(function (m) { 232 | return m.name; 233 | })).to.deep.equal(['B']); 234 | expect((0, _connect.getMapping)()[m2].machines.map(function (m) { 235 | return m.name; 236 | })).to.deep.equal(['B']); 237 | expect(_typeof((0, _connect.getMapping)()[m3])).to.be.equal('undefined'); 238 | B.destroy(); 239 | expect((0, _connect.getMapping)()).to.be.deep.equal({}); 240 | }); 241 | }); 242 | }); 243 | describe('when we use the `disconnect` function', function () { 244 | it('should detach the mapping', function () { 245 | var mapping = sinon.spy(); 246 | var machine = _.Machine.create('A', { 247 | state: { name: 'idle' }, 248 | transitions: { 249 | idle: { run: 'running' }, 250 | running: { stop: 'idle' } 251 | } 252 | }); 253 | var disconnect = (0, _connect2.default)().with('A').map(function (A) { 254 | mapping(A.state.name); 255 | }); 256 | 257 | machine.run(); 258 | disconnect(); 259 | machine.stop(); 260 | 261 | expect(mapping).to.be.calledTwice; 262 | expect(mapping.firstCall).to.be.calledWith(sinon.match('idle')); 263 | expect(mapping.secondCall).to.be.calledWith(sinon.match('running')); 264 | }); 265 | describe('and we have a middleware attached', function () { 266 | it('should call the proper middleware hook', function () { 267 | var onMachineDisconnected = sinon.spy(); 268 | 269 | _.Machine.addMiddleware({ onMachineDisconnected: onMachineDisconnected }); 270 | _.Machine.create('A', { 271 | state: { name: 'idle' }, 272 | transitions: { 273 | idle: { run: 'running' }, 274 | running: { stop: 'idle' } 275 | } 276 | }); 277 | _.Machine.create('B', { 278 | state: { name: 'waiting' }, 279 | transitions: { 280 | waiting: { fetch: 'fetching' }, 281 | fetching: { done: 'waiting' } 282 | } 283 | }); 284 | (0, _connect2.default)().with('A', 'B').map(function () {})(); 285 | 286 | expect(onMachineDisconnected).to.be.calledOnce.and.to.be.calledWith(sinon.match([sinon.match({ name: 'A' }), sinon.match({ name: 'B' })])); 287 | }); 288 | }); 289 | }); 290 | }); -------------------------------------------------------------------------------- /src/react/__tests__/connect.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import { Machine } from '../../'; 4 | import connect from '../connect'; 5 | import { mount } from 'enzyme'; 6 | 7 | var wrapper; 8 | const mapping = sinon.spy(); 9 | 10 | class Component extends React.Component { 11 | render() { 12 | return ( 13 |
    14 |

    { this.props.message('machine A', this.props.stateA)}

    15 |

    { this.props.message('machine B', this.props.stateB)}

    16 | 17 | 18 |
    19 | ); 20 | } 21 | } 22 | 23 | function getWrapper(once, m) { 24 | const mappingFunc = (A, B) => { 25 | mapping(A, B); 26 | return { 27 | stateA: A.state.name, 28 | stateB: B.state.name, 29 | run: A.run, 30 | fetch: B.fetch 31 | } 32 | }; 33 | const ConnectedComponent = connect(Component) 34 | .with('A', 'B') 35 | [once ? 'mapOnce' : 'map'](typeof m === 'undefined' ? mappingFunc : m); 36 | const message = (machineName, state) => `${ machineName } is in a ${ state } state`; 37 | 38 | return mount(); 39 | } 40 | 41 | describe('Given the connect React helper', function () { 42 | beforeEach(() => { 43 | mapping.reset(); 44 | Machine.flush(); 45 | Machine.create('A', { 46 | state: { name: 'idle' }, 47 | transitions: { 48 | idle: { run: 'running' }, 49 | running: { stop: 'idle' } 50 | } 51 | }); 52 | Machine.create('B', { 53 | state: { name: 'waiting' }, 54 | transitions: { 55 | waiting: { fetch: 'fetching' }, 56 | fetching: { done: 'waiting' } 57 | } 58 | }); 59 | }); 60 | describe('when connecting a component', function () { 61 | it('should call our mapping function', function () { 62 | wrapper = getWrapper(); 63 | expect(mapping).to.be.calledOnce; 64 | }); 65 | it('should map machines state and actions properly', function () { 66 | wrapper = getWrapper(); 67 | expect(wrapper.find('p#A').text()).to.equal('machine A is in a idle state'); 68 | expect(wrapper.find('p#B').text()).to.equal('machine B is in a waiting state'); 69 | }); 70 | it('should get re-rendered if a machine\'s state is changed', function () { 71 | wrapper = getWrapper(); 72 | const runButton = wrapper.find('button#run'); 73 | const fetchButton = wrapper.find('button#fetch'); 74 | 75 | expect(wrapper.find('p#A').text()).to.equal('machine A is in a idle state'); 76 | runButton.simulate('click'); 77 | expect(wrapper.find('p#A').text()).to.equal('machine A is in a running state'); 78 | runButton.simulate('click'); 79 | runButton.simulate('click'); 80 | runButton.simulate('click'); 81 | 82 | fetchButton.simulate('click'); 83 | expect(wrapper.find('p#B').text()).to.equal('machine B is in a fetching state'); 84 | }); 85 | it('should NOT get re-rendered if mapped with `mapOnce`', function () { 86 | wrapper = getWrapper(true); 87 | wrapper.find('button#run').simulate('click'); 88 | wrapper.find('button#fetch').simulate('click'); 89 | expect(wrapper.find('p#A').text()).to.equal('machine A is in a idle state'); 90 | expect(wrapper.find('p#B').text()).to.equal('machine B is in a waiting state'); 91 | }); 92 | 93 | describe('when we update the state of the machine many times in a single frame', function () { 94 | it('should trigger the mapping function many times', function () { 95 | const render = sinon.spy(); 96 | const Comp = function () { 97 | render(); 98 | return

    Hello

    ; 99 | } 100 | const machine = Machine.create('C', { 101 | state: { name: 'idle', counter: 0 }, 102 | transitions: { 103 | idle: { 104 | run: function ({ state }) { 105 | return { name: 'idle', counter: state.counter + 1 }; 106 | } 107 | } 108 | } 109 | }); 110 | const mappingFunction = sinon.stub().returns(() => { 111 | return { counter: machine.state.counter }; 112 | }); 113 | const ConnectedComponent = connect(Comp).with('C').map(mappingFunction); 114 | 115 | mount() 116 | 117 | machine.run(); 118 | machine.run(); 119 | machine.run(); 120 | machine.run(); 121 | machine.run(); 122 | machine.run(); 123 | 124 | expect(mappingFunction) 125 | .to.be.calledWith(sinon.match({ 126 | state: { counter: 6, name: 'idle' } 127 | })) 128 | expect(render.callCount).to.be.equal(7); 129 | }); 130 | }); 131 | }); 132 | describe('when unmounting the component', function () { 133 | it('should detach from the machines', function () { 134 | wrapper = getWrapper(); 135 | Machine.get('A').run(); 136 | expect(wrapper.find('p#A').text()).to.equal('machine A is in a running state'); 137 | Machine.get('A').stop(); 138 | expect(wrapper.find('p#A').text()).to.equal('machine A is in a idle state'); 139 | wrapper.unmount(); 140 | Machine.get('A').run(); 141 | expect(mapping.callCount).to.be.equal(3); 142 | }); 143 | }); 144 | describe("when remounting the component", function() { 145 | it("should display up-to-date machine state", function() { 146 | const Wired = connect(Component) 147 | .with("A", "B") 148 | .map(function(A, B) { 149 | return { 150 | stateA: A.state.name, 151 | stateB: B.state.name 152 | }; 153 | }); 154 | 155 | const msg = (machineName, state) => 156 | `${machineName} is in a ${state} state`; 157 | 158 | const mounted = mount(); 159 | 160 | Machine.get("A").run(); 161 | Machine.get("B").fetch(); 162 | 163 | expect(mounted.find("p#A").text()).to.equal( 164 | "machine A is in a running state" 165 | ); 166 | expect(mounted.find("p#B").text()).to.equal( 167 | "machine B is in a fetching state" 168 | ); 169 | 170 | mounted.unmount(); 171 | 172 | // Change machine states while the component is unmounted 173 | Machine.get("A").stop(); 174 | Machine.get("B").done(); 175 | 176 | const remounted = mount(); 177 | 178 | expect(remounted.find("p#A").text()).to.equal( 179 | "machine A is in a idle state" 180 | ); 181 | 182 | expect(remounted.find("p#B").text()).to.equal( 183 | "machine B is in a waiting state" 184 | ); 185 | }); 186 | }); 187 | 188 | describe('when we connect without mapping', function () { 189 | it('should render correctly', function () { 190 | class Component extends React.Component { 191 | constructor(props) { 192 | super(props); 193 | 194 | this.counter = 0; 195 | } 196 | render() { 197 | this.counter += 1; 198 | return

    Rendered { this.counter } times

    ; 199 | } 200 | } 201 | const Connected = connect(Component).with('A', 'B').map(); 202 | const connectedWrapper = mount(); 203 | Machine.get('A').run(); 204 | Machine.get('A').stop(); 205 | // 1 - initial render 206 | // 2 - because of machine's action run 207 | // 3 - because of machine's action stop 208 | expect(connectedWrapper.find('p').text()).to.equal('Rendered 3 times'); 209 | }); 210 | }); 211 | 212 | describe('when we connect a server-rendered component', function () { 213 | it('should use the machine states in the first render', function () { 214 | function Component(props) { 215 | return ( 216 |
    217 |

    {props.a}

    218 |

    {props.b}

    219 |
    220 | ); 221 | } 222 | 223 | const Connected = connect(Component) 224 | .with('A', 'B') 225 | .map(function (mA, mB) { 226 | return { 227 | a: mA.state.name, 228 | b: mB.state.name 229 | }; 230 | }); 231 | 232 | expect(ReactDOMServer.renderToStaticMarkup()).to.equal( 233 | '

    idle

    waiting

    ' 234 | ); 235 | }); 236 | }); 237 | 238 | describe('when we use mapSilent', function () { 239 | it('should only call the mapping function when the machine changes its state', function () { 240 | class Component extends React.Component { 241 | constructor(props) { 242 | super(props); 243 | 244 | this.counter = 0; 245 | } 246 | render() { 247 | this.counter += 1; 248 | return ( 249 |
    250 |

    { this.props.message('machine A', this.props.stateA)}

    251 |

    Rendered { this.counter } times

    252 |
    253 | ); 254 | } 255 | } 256 | const message = (machineName, state) => `${ machineName } is in a ${ state } state`; 257 | const Connected = connect(Component).with('A', 'B').mapSilent(A => { 258 | return { 259 | stateA: A.state.name 260 | } 261 | }); 262 | const connectedWrapper = mount(); 263 | expect(connectedWrapper.find('p#A').text()).to.equal('machine A is in a undefined state'); 264 | Machine.get('A').run(); 265 | expect(connectedWrapper.find('p#A').text()).to.equal('machine A is in a running state'); 266 | // 1 - initial render 267 | // 2 - because of machine's action run 268 | expect(connectedWrapper.find('p#counter').text()).to.equal('Rendered 2 times'); 269 | }); 270 | }); 271 | describe('and we have a middleware attached', function () { 272 | it('should call the proper hooks', function () { 273 | const onMachineConnected = sinon.spy(); 274 | const onMachineDisconnected = sinon.spy(); 275 | 276 | Machine.addMiddleware({ onMachineConnected, onMachineDisconnected }); 277 | 278 | const machine = Machine.create('foo', { state: {}, transitions: {} }); 279 | const Zoo = () =>

    Hello world

    ; 280 | const Connected = connect(Zoo).with('foo').map(() => ({})); 281 | const connectedWrapper = mount(); 282 | 283 | expect(onMachineConnected).to.be.calledOnce.and.to.be.calledWith( 284 | sinon.match.array, 285 | sinon.match({ component: 'Zoo' }) 286 | ); 287 | expect(onMachineDisconnected).to.not.be.called; 288 | connectedWrapper.unmount(); 289 | expect(onMachineDisconnected).to.be.calledOnce.and.to.be.calledWith( 290 | sinon.match.array, 291 | sinon.match({ component: 'Zoo' }) 292 | ); 293 | }); 294 | describe('and we destroy a machine', function () { 295 | it('should call the proper hooks', function () { 296 | const onMachineConnected = sinon.spy(); 297 | const onMachineDisconnected = sinon.spy(); 298 | 299 | Machine.addMiddleware({ onMachineConnected, onMachineDisconnected }); 300 | 301 | const machine = Machine.create('foo', { state: {}, transitions: {} }); 302 | const Component = () =>

    Hello world

    ; 303 | const Connected = connect(Component).with('foo').map(() => ({})); 304 | const connectedWrapper = mount(); 305 | 306 | expect(onMachineConnected).to.be.calledOnce; 307 | expect(onMachineDisconnected).to.not.be.called; 308 | machine.destroy(); 309 | expect(onMachineDisconnected).to.be.calledOnce; 310 | }); 311 | }); 312 | }); 313 | 314 | describe('and the problem described in issue #13', function () { 315 | it('should map the transition handler properly', function () { 316 | const spy = sinon.spy(); 317 | class Issue13 extends React.Component { 318 | componentDidMount() { 319 | this.props.startProcess(); 320 | } 321 | render() { 322 | return

    Hello world

    ; 323 | } 324 | } 325 | 326 | Machine.create('issue13', { 327 | state: { name: 'init' }, 328 | transitions: { 329 | init: { startProcess: spy, 'ab': () => {} } 330 | } 331 | }); 332 | 333 | const ConnectedIssue13 = connect(Issue13).with('issue13') 334 | .map(({ startProcess }) => ({ startProcess })); 335 | 336 | mount(); 337 | 338 | }); 339 | }); 340 | }); 341 | --------------------------------------------------------------------------------