├── .babelrc ├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── StateDiagram.png ├── examples ├── 1.Normal-Component-State.js └── 2.With-Redux.js ├── img └── logo.svg ├── package.json ├── src ├── ManagedStateMachine.js ├── StateMachine.js └── index.js ├── test ├── configure-tests.js ├── index.test.js └── mock-components.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-object-rest-spread", 5 | "@babel/plugin-proposal-class-properties" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | # specify the version you desire here 6 | - image: circleci/node:7.10 7 | 8 | # Specify service dependencies here if necessary 9 | # CircleCI maintains a library of pre-built images 10 | # documented at https://circleci.com/docs/2.0/circleci-images/ 11 | # - image: circleci/mongo:3.4.4 12 | 13 | working_directory: ~/repo 14 | 15 | steps: 16 | - checkout 17 | 18 | # Download and cache dependencies 19 | - restore_cache: 20 | keys: 21 | - v1-dependencies-{{ checksum "package.json" }} 22 | # fallback to using the latest cache if no exact match is found 23 | - v1-dependencies- 24 | 25 | - run: yarn install 26 | - run: yarn add jest 27 | 28 | - save_cache: 29 | paths: 30 | - node_modules 31 | key: v1-dependencies-{{ checksum "package.json" }} 32 | 33 | # run tests! 34 | - run: yarn test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib 3 | /coverage -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs 2 | src 3 | .babelrc 4 | webpack.config.js -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug CRA Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/react-scripts", 12 | "args": [ 13 | "test", 14 | "--runInBand", 15 | "--no-cache", 16 | "--env=jsdom", 17 | "${relativeFile}" 18 | ], 19 | "cwd": "${workspaceRoot}", 20 | "protocol": "inspector", 21 | "console": "integratedTerminal", 22 | "internalConsoleOptions": "neverOpen" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Francis Stokes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Machinery 2 | 3 | [![npm version](https://badge.fury.io/js/react-machinery.svg)]() 4 | [![license](https://img.shields.io/github/license/mashape/apistatus.svg)]() 5 | [![CircleCI](https://circleci.com/gh/francisrstokes/React-Machinery.png?circle-token=46dc2a2f9571dfc783c03c6550c3119c18c5b8ce&style=shield)]() 6 | 7 | 8 | 9 | 10 | ⚙️ State Machine Modelling for React 11 | 12 | - [Description](#description) 13 | - [Examples](#examples) 14 | - [Installation](#installation) 15 | - [API](#api) 16 | - [StateMachine](#statemachine) 17 | - [getCurrentState](#getcurrentstate) 18 | - [setNewState](#setnewstate) 19 | - [states](#states) 20 | - [data](#data) 21 | 22 | ## Description 23 | 24 | 🔥 `React Machinery` provides a simple to use, component based approach to state machines in react. 25 | 26 | Describe your states, transitions, and which component each renders, and plug it into the `StateMachine` component. It accepts two extra functions; one for getting the current state name and one for setting it. This allows your app to flexibly use and swap out different ways of storing data - be it in component state, redux, mobx, whatever. 27 | 28 | ### Examples 29 | 30 | Examples of how the following state diagram can be implemented in both vanilla react and using redux can be found in the examples folder. 31 | 32 | - [Example using react](examples/1.Normal-Component-State.js) 33 | - [Example using redux](examples/2.With-Redux.js) 34 | 35 | ![State diagram of code below](StateDiagram.png) 36 | 37 | ## Installation 38 | 39 | ```bash 40 | # with yarn 41 | yarn add react-machinery 42 | 43 | # or with npm 44 | npm i react-machinery 45 | ``` 46 | 47 | ## API 48 | 49 | ### StateMachine 50 | 51 | All props for the `StateMachine` component are required. 52 | 53 | #### getCurrentState 54 | 55 | ##### function() 56 | 57 | A function returning the current state name, stored somewhere like a react component state, or in redux. 58 | 59 | #### setNewState 60 | 61 | ##### function(newStateName) 62 | 63 | A function that updates the current state, stored somewhere like a react component state, or in redux. 64 | 65 | #### states 66 | 67 | ##### Array of state definitions 68 | 69 | A state definition is a plain javascript object with the following properties: 70 | 71 | ```javascript 72 | { 73 | // This name corresponds to the one coming from getCurrentState() and 74 | // being set by setNewState() 75 | name: 'State Name', 76 | 77 | // Array of plain objects that describe automatic transitions 78 | // These are evaluated after a transition and when props change 79 | autoTransitions: [ 80 | // A transition object must contain two properties: 81 | // test, which is a function that recieves the StateMachine data, and returns true if a state change should take place 82 | // newState, which is the name of the state to transition to when the test function returns true 83 | { 84 | test: data => data === 'expected for state change', 85 | newState: 'Name of new state' 86 | } 87 | ], 88 | 89 | // This is a list of states that can be transitioned to from the current state using the transitionTo 90 | // prop passed to a state component. Trying to use transitionTo with a state not specified in this list 91 | // will throw an error. This list, together with any 'newState's described in autoTransitions form the 92 | // full set of valid transitions for this state. 93 | validTransitions: [ 94 | 'Name of valid state' 95 | ], 96 | 97 | // beforeRender is an optional function that can run some code before this states component 98 | // is rendered. For anything sufficiently complex however, it's better to use a react class 99 | // component and take advantage of lifecycle hooks 100 | beforeRender: (data) => { 101 | data.startAPIRequest(); 102 | }, 103 | 104 | // One of the following two properties must be implemented: 105 | 106 | // a render prop that recieves the 'props' object supplied to the StateMachine 107 | // the props object will also include a 'transitionTo' function and a 'currentState' string 108 | render: (data) => { 109 | return 110 | }, 111 | 112 | // Or just a regular react component 113 | component: SomeReactComponent 114 | } 115 | ``` 116 | 117 | #### data 118 | 119 | ##### object 120 | 121 | An object contains data that defines all the states in the state machine. This data is supplied to the component rendered by any state, to `test` functions in autoTransitions. If a render prop is used for the state, then the props are passed as the argument, along with a `transitionTo` function and `currentState` name. 122 | 123 | # Logo 124 | 125 | The awesome logo was designed by @ayushs08, who graciously provided under CC BY 3.0. Many thanks! -------------------------------------------------------------------------------- /StateDiagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/francisrstokes/React-Machinery/acca228c2a70808b6f35e99eb977924a1f61755e/StateDiagram.png -------------------------------------------------------------------------------- /examples/1.Normal-Component-State.js: -------------------------------------------------------------------------------- 1 | import {StateMachine} from 'react-machinery'; 2 | 3 | /* 4 | This example uses a regular react component as the data store for our state machine. 5 | Updating the state is as simple as passing a function that calls setState on our component. 6 | 7 | At any time, we can be in one of four states: 8 | - theNumberOne 9 | - theNumberTwo 10 | - theNumberTen 11 | - lifeTheUniverseAndEverything 12 | 13 | We can only transition to some states if we are already in a certain state. For example, 14 | 'theNumberTen' can only be transitioned if we are already in 'theNumberTwo'. 15 | */ 16 | 17 | const One = () =>
State #1
18 | const Two = () =>
State #2
19 | const Ten = () =>
State #10
20 | const HitchHikerComponent = () =>
Theres a frood who really knows where his towel is.
21 | 22 | const states = [ 23 | { 24 | name: 'theNumberOne', 25 | autoTransitions: [ 26 | { 27 | test: ({n}) => n === 2, 28 | newState: 'theNumberTwo' 29 | } 30 | ], 31 | component: One 32 | }, 33 | { 34 | name: 'theNumberTwo', 35 | autoTransitions: [ 36 | { 37 | test: ({n}) => n === 1, 38 | newState: 'theNumberOne' 39 | }, 40 | { 41 | test: ({n}) => n === 10, 42 | newState: 'theNumberTen' 43 | }, 44 | ], 45 | component: Two 46 | }, 47 | { 48 | name: 'theNumberTen', 49 | autoTransitions: [ 50 | { 51 | test: ({n}) => n === 1, 52 | newState: 'theNumberOne' 53 | }, 54 | { 55 | test: ({n}) => n === 42, 56 | newState: 'lifeTheUniverseAndEverything' 57 | } 58 | ], 59 | component: Ten 60 | }, 61 | { 62 | name: 'lifeTheUniverseAndEverything', 63 | component: HitchHikerComponent 64 | }, 65 | ]; 66 | 67 | export class Example extends React.Component { 68 | constructor(props) { 69 | super(props); 70 | this.state = { 71 | n: 1, 72 | stateName: 'theNumberOne' 73 | }; 74 | } 75 | 76 | render() { 77 | const {n} = this.state; 78 | return
79 | this.state.stateName} 81 | setNewState={stateName => this.setState(() => ({ stateName }))} 82 | data={{n}} 83 | states={states} 84 | /> 85 | 86 |
{n}
87 | 88 | 89 | 90 | 91 | 92 |
; 93 | } 94 | } -------------------------------------------------------------------------------- /examples/2.With-Redux.js: -------------------------------------------------------------------------------- 1 | import {StateMachine} from 'react-machinery'; 2 | import {connect} from 'react-redux'; 3 | 4 | /* 5 | This example uses a redux connected component to provide data to the State Machine. 6 | Updating the state is done by dispatching an action so that the 7 | current state is also kept in the store. 8 | 9 | At any time, we can be in one of four states: 10 | - theNumberOne 11 | - theNumberTwo 12 | - theNumberTen 13 | - lifeTheUniverseAndEverything 14 | 15 | We can only transition to some states if we are already in a certain state. For example, 16 | 'theNumberTen' can only be transitioned if we are already in 'theNumberTwo'. 17 | */ 18 | 19 | const One = () =>
State #1
20 | const Two = () =>
State #2
21 | const Ten = () =>
State #10
22 | const HitchHikerComponent = () =>
Theres a frood who really knows where his towel is.
23 | 24 | const states = [ 25 | { 26 | name: 'theNumberOne', 27 | autoTransitions: [ 28 | { 29 | test: ({n}) => n === 2, 30 | newState: 'theNumberTwo' 31 | } 32 | ], 33 | component: One 34 | }, 35 | { 36 | name: 'theNumberTwo', 37 | autoTransitions: [ 38 | { 39 | test: ({n}) => n === 1, 40 | newState: 'theNumberOne' 41 | }, 42 | { 43 | test: ({n}) => n === 10, 44 | newState: 'theNumberTen' 45 | }, 46 | ], 47 | component: Two 48 | }, 49 | { 50 | name: 'theNumberTen', 51 | autoTransitions: [ 52 | { 53 | test: ({n}) => n === 1, 54 | newState: 'theNumberOne' 55 | }, 56 | { 57 | test: ({n}) => n === 42, 58 | newState: 'lifeTheUniverseAndEverything' 59 | } 60 | ], 61 | component: Ten 62 | }, 63 | { 64 | name: 'lifeTheUniverseAndEverything', 65 | component: HitchHikerComponent 66 | }, 67 | ]; 68 | 69 | export const Example = connect( 70 | state => ({ 71 | n: state.n, 72 | currentState: state.currentState 73 | }), 74 | dispatch => ({ 75 | setNewState: (stateName) => dispatch({ 76 | type: 'SET_NEW_STATE', 77 | payload: stateName 78 | }), 79 | updateN: (newValue) => dispatch({ 80 | type: 'UPDATE_N_VALUE', 81 | payload: newValue 82 | }) 83 | }) 84 | )(({n, currentState, setNewState, updateN}) => { 85 | return
86 | currentState} 88 | setNewState={setNewState} 89 | data={{n}} 90 | states={states} 91 | /> 92 | 93 |
{n}
94 | 95 | 96 | 97 | 98 | 99 |
; 100 | }) 101 | -------------------------------------------------------------------------------- /img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | background 5 | 6 | 7 | 8 | Layer 1 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-machinery", 3 | "version": "1.2.0", 4 | "description": "Control React UI trees with a State Machine", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "dev": "npm run lib:watch", 8 | "lib": "babel src -d lib --copy-files", 9 | "lib:watch": "babel src -w -d lib --copy-files", 10 | "test": "jest ./test/index.test.js" 11 | }, 12 | "keywords": [], 13 | "license": "MIT", 14 | "peerDependencies": { 15 | "react": "^16.8.0", 16 | "react-dom": "^16.8.0" 17 | }, 18 | "devDependencies": { 19 | "@babel/cli": "^7.0.0-beta.46", 20 | "@babel/core": "^7.0.0-beta.46", 21 | "@babel/plugin-proposal-class-properties": "^7.0.0-beta.46", 22 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0-beta.46", 23 | "@babel/preset-env": "^7.0.0-beta.46", 24 | "@babel/preset-react": "^7.0.0-beta.46", 25 | "babel-core": "7.0.0-bridge.0", 26 | "babel-jest": "^23.0.1", 27 | "babel-loader": "^8.0.0-beta.0", 28 | "css-loader": "^0.28.11", 29 | "enzyme": "^3.3.0", 30 | "enzyme-adapter-react-16": "^1.1.1", 31 | "html-webpack-plugin": "^3.2.0", 32 | "react": "^16.8.0", 33 | "react-dom": "^16.8.0", 34 | "style-loader": "^0.21.0", 35 | "webpack": "^4.6.0", 36 | "webpack-cli": "^2.0.15", 37 | "webpack-dev-server": "^3.1.3" 38 | }, 39 | "author": "Francis Stokes ", 40 | "homepage": "https://github.com/francisrstokes/React-Machinery", 41 | "repository": { 42 | "type": "git", 43 | "url": "git@github.com:francisrstokes/React-Machinery.git" 44 | }, 45 | "dependencies": { 46 | "prop-types": "^15.6.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ManagedStateMachine.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { StateMachine, statePropTypes } from './StateMachine'; 5 | 6 | export const ManagedStateMachine = ({ 7 | data: initialData, 8 | states, 9 | children 10 | }) => { 11 | const [data, setData] = useState(initialData); 12 | const [state, setState] = useState(states[0]); 13 | 14 | return ( 15 | 16 | state.name} 19 | setNewState={stateName => { 20 | setState(states.find(s => s.name === stateName)); 21 | }} 22 | states={states} 23 | /> 24 | {children(data, setData)} 25 | 26 | ); 27 | }; 28 | 29 | ManagedStateMachine.propTypes = { 30 | data: PropTypes.object.isRequired, 31 | states: statePropTypes 32 | }; 33 | -------------------------------------------------------------------------------- /src/StateMachine.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export class StateMachine extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | this.validateStateNames(props.states.map(state => state.name)); 8 | this.transition('', this.props.getCurrentState()); 9 | this.update(); 10 | } 11 | 12 | validateStateNames(stateNames) { 13 | const names = stateNames.slice().sort(); 14 | 15 | const duplicates = []; 16 | 17 | for (let i = 0; i < names.length - 1; i++) { 18 | if (names[i + 1] === names[i]) { 19 | duplicates.push(`\t${names[i]}`); 20 | } 21 | } 22 | 23 | if (duplicates.length > 0) { 24 | throw new Error( 25 | `State names must be unique. The following state names were duplicated: [${duplicates.join(", ")}]` 26 | ) 27 | } 28 | } 29 | 30 | transition(oldState, newState) { 31 | const nextState = this.props.states.find(state => state.name === newState); 32 | if (!nextState) { 33 | const validStates = this.props.states.map(state => state.name).join(", "); 34 | throw new Error( 35 | `Tried to transition from state '${oldState}' to '${newState}'. Valid states are: [${validStates}]` 36 | ); 37 | } 38 | 39 | this.props.setNewState(newState); 40 | 41 | if (nextState.beforeRender) { 42 | nextState.beforeRender({ 43 | ...this.props.data, 44 | currentState: newState, 45 | transitionTo: this.createTransitionToFn(nextState), 46 | }); 47 | } 48 | } 49 | 50 | update() { 51 | const { getCurrentState, states, data } = this.props; 52 | const currentStateName = getCurrentState(); 53 | const currentState = states.find(state => state.name === currentStateName); 54 | 55 | if (Array.isArray(currentState.autoTransitions)) { 56 | for (const transition of currentState.autoTransitions) { 57 | if (transition.test(data)) { 58 | this.transition(currentStateName, transition.newState); 59 | return; 60 | } 61 | } 62 | } 63 | } 64 | 65 | componentDidUpdate() { 66 | this.update(); 67 | } 68 | 69 | createTransitionToFn = currentState => newState => { 70 | if (!(currentState.validTransitions && currentState.validTransitions.includes(newState))) { 71 | throw new Error( 72 | `'${newState}' is not listed in transitions array for state ${currentState.name}` 73 | ); 74 | } 75 | this.transition(currentState.name, newState); 76 | } 77 | 78 | render() { 79 | const currentStateName = this.props.getCurrentState(); 80 | const currentState = this.props.states.find( 81 | state => state.name === currentStateName 82 | ); 83 | 84 | const additionalProps = { 85 | ...this.props.data, 86 | currentState: currentStateName, 87 | transitionTo: this.createTransitionToFn(currentState), 88 | }; 89 | 90 | if (currentState.render) return currentState.render(additionalProps); 91 | if (currentState.component) return React.createElement(currentState.component, additionalProps); 92 | 93 | throw new Error( 94 | `Neither a valid render or component property was found for state '${currentStateName}'` 95 | ); 96 | } 97 | } 98 | 99 | const autoTransitionsPropTypes = PropTypes.arrayOf( 100 | PropTypes.shape({ 101 | test: PropTypes.func.isRequired, 102 | newState: PropTypes.string.isRequired 103 | }) 104 | ); 105 | 106 | export const statePropTypes = PropTypes.arrayOf( 107 | PropTypes.shape({ 108 | name: PropTypes.string.isRequired, 109 | autoTransitions: autoTransitionsPropTypes, 110 | validTransitions: PropTypes.arrayOf(PropTypes.string), 111 | 112 | beforeRender: PropTypes.func, 113 | component: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), 114 | render: PropTypes.func 115 | }) 116 | ).isRequired; 117 | 118 | StateMachine.propTypes = { 119 | data: PropTypes.object.isRequired, 120 | getCurrentState: PropTypes.func.isRequired, 121 | setNewState: PropTypes.func.isRequired, 122 | states: statePropTypes, 123 | }; 124 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { ManagedStateMachine } from './ManagedStateMachine'; 2 | export { StateMachine } from './StateMachine'; -------------------------------------------------------------------------------- /test/configure-tests.js: -------------------------------------------------------------------------------- 1 | import Adapter from 'enzyme-adapter-react-16'; 2 | import {configure} from 'enzyme'; 3 | 4 | export default () => configure({ adapter: new Adapter() }); -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount} from 'enzyme'; 3 | import {StateMachine} from '../src'; 4 | import { 5 | State1Component, 6 | State2Component, 7 | State3Component, 8 | } from './mock-components'; 9 | import configureTests from './configure-tests'; 10 | 11 | const promisify = (fn, ctx = null) => (...args) => new Promise(resolve => { 12 | fn.apply(ctx, [...args, resolve]); 13 | }); 14 | 15 | configureTests(); 16 | 17 | test('Render a valid state', () => { 18 | const data = {}; 19 | const state = 'state-1'; 20 | 21 | const states = [ 22 | { 23 | name: 'state-1', 24 | component: State1Component 25 | } 26 | ]; 27 | 28 | const stateMachine = mount( 29 | state} 31 | setNewState={() => {}} 32 | data={data} 33 | states={states} 34 | /> 35 | ); 36 | expect(stateMachine.text()).toEqual('State 1'); 37 | }); 38 | 39 | test('Render a valid state (render prop)', () => { 40 | const data = {}; 41 | const state = 'state-1'; 42 | 43 | const states = [ 44 | { 45 | name: 'state-1', 46 | render: ({currentState}) =>
Hello, {currentState}
47 | } 48 | ]; 49 | 50 | const stateMachine = mount( 51 | state} 53 | setNewState={() => {}} 54 | data={data} 55 | states={states} 56 | /> 57 | ); 58 | expect(stateMachine.text()).toEqual('Hello, state-1'); 59 | }); 60 | 61 | test('Should pass additional props to state component if they are present', () => { 62 | const state = 'state-1'; 63 | 64 | const states = [ 65 | { 66 | name: 'state-1', 67 | component: State1Component 68 | } 69 | ]; 70 | 71 | const extraProps = { 72 | extraProp1: 'hello!' 73 | }; 74 | 75 | const stateMachine = mount( 76 | state} 78 | setNewState={() => {}} 79 | data={extraProps} 80 | states={states} 81 | /> 82 | ); 83 | 84 | const propNames = Object.keys(stateMachine.children().props()); 85 | expect(propNames.includes('extraProp1')).toEqual(true); 86 | }); 87 | 88 | test('Pass additional props to state render if they are present', () => { 89 | const state = 'state-1'; 90 | 91 | const states = [ 92 | { 93 | name: 'state-1', 94 | render: (props) => { 95 | return 96 | } 97 | } 98 | ]; 99 | 100 | const extraProps = { 101 | extraProp1: 'hello!' 102 | }; 103 | 104 | const stateMachine = mount( 105 | state} 107 | setNewState={() => {}} 108 | data={extraProps} 109 | states={states} 110 | /> 111 | ); 112 | 113 | const propNames = Object.keys(stateMachine.children().props()); 114 | expect(propNames.includes('extraProp1')).toEqual(true); 115 | }); 116 | 117 | test('Should run a state\'s beforeRender before it\'s rendered if one is present' , () => { 118 | const data = {}; 119 | const state = 'state-1'; 120 | 121 | let beforeRenderCalled; 122 | let beforeRenderCalledFirst; 123 | 124 | const states = [ 125 | { 126 | name: 'state-1', 127 | beforeRender: () => { 128 | beforeRenderCalled = true; 129 | }, 130 | render: () => { 131 | beforeRenderCalledFirst = !!beforeRenderCalled; 132 | return 133 | } 134 | } 135 | ]; 136 | 137 | const stateMachine = mount( 138 | state} 140 | setNewState={() => {}} 141 | data={data} 142 | states={states} 143 | /> 144 | ); 145 | 146 | expect(beforeRenderCalledFirst).toEqual(true); 147 | }); 148 | 149 | test('Throw on an invalid beforeRender', () => { 150 | spyOn(console, 'error'); 151 | const data = {}; 152 | const state = 'state-1'; 153 | 154 | const states = [ 155 | { 156 | name: 'state-1', 157 | beforeRender: /regex/, 158 | component: State1Component 159 | } 160 | ]; 161 | 162 | expect(() => { 163 | mount( 164 | state} 166 | setNewState={() => {}} 167 | data={data} 168 | states={states} 169 | /> 170 | ); 171 | }).toThrow(); 172 | }); 173 | 174 | test('Pass rendered components a transitionTo function and currentState name', () => { 175 | const data = {}; 176 | const state = 'state-1'; 177 | 178 | const states = [ 179 | { 180 | name: 'state-1', 181 | component: State1Component 182 | } 183 | ]; 184 | 185 | const stateMachine = mount( 186 | state} 188 | setNewState={() => {}} 189 | data={data} 190 | states={states} 191 | /> 192 | ); 193 | 194 | const childProps = stateMachine.children().props(); 195 | expect(Boolean(childProps.transitionTo)).toEqual(true); 196 | expect(typeof childProps.transitionTo).toEqual('function'); 197 | expect(Boolean(childProps.currentState)).toEqual(true); 198 | expect(childProps.currentState).toEqual(state); 199 | }); 200 | 201 | test('Throw on an invalid state', () => { 202 | spyOn(console, 'error'); 203 | const data = {}; 204 | const state = 'state-2'; 205 | 206 | const states = [ 207 | { 208 | name: 'state-1', 209 | component: State1Component 210 | } 211 | ]; 212 | 213 | expect(() => { 214 | mount( 215 | state} 217 | setNewState={() => {}} 218 | data={data} 219 | states={states} 220 | /> 221 | ); 222 | }).toThrow(); 223 | }); 224 | 225 | test('Transition from one state to another', async () => { 226 | const data = { a: 1 }; 227 | let state = 'state-1'; 228 | 229 | const states = [ 230 | { 231 | name: 'state-1', 232 | // beforeRender: ({a}) => { 233 | // if 234 | // }, 235 | autoTransitions: [ 236 | { 237 | test: ({a}) => a === 2, 238 | newState: 'state-2' 239 | } 240 | ], 241 | component: State1Component 242 | }, 243 | { 244 | name: 'state-2', 245 | component: State2Component 246 | } 247 | ]; 248 | 249 | const getCurrentState = () => state; 250 | const setNewState = newState => state = newState; 251 | 252 | let stateMachine = mount( 253 | 259 | ); 260 | 261 | expect(stateMachine.text()).toEqual('State 1'); 262 | 263 | const setProps = promisify(stateMachine.setProps, stateMachine); 264 | 265 | // First new props cause a call to setNewState() 266 | await setProps({ 267 | getCurrentState, 268 | setNewState, 269 | data: {...data, a: 2}, 270 | states, 271 | }); 272 | 273 | // Second set props forces another render in tests 274 | await setProps({ 275 | getCurrentState, 276 | setNewState, 277 | data: {a: 2}, 278 | states, 279 | }); 280 | 281 | expect(stateMachine.text()).toEqual('State 2'); 282 | }); 283 | 284 | test('Throw when transitioning from one state to an invalid one', async () => { 285 | spyOn(console, 'error'); 286 | const data = { a: 1 }; 287 | let state = 'state-1'; 288 | 289 | const states = [ 290 | { 291 | name: 'state-1', 292 | autoTransitions: [ 293 | { 294 | test: ({a}) => a === 2, 295 | newState: 'state-3' 296 | } 297 | ], 298 | component: State1Component 299 | }, 300 | { 301 | name: 'state-2', 302 | component: State2Component 303 | } 304 | ]; 305 | 306 | const getCurrentState = () => state; 307 | const setNewState = newState => state = newState; 308 | 309 | let stateMachine = mount( 310 | 316 | ); 317 | 318 | expect(stateMachine.text()).toEqual('State 1'); 319 | 320 | expect(() => { 321 | stateMachine.setProps({ 322 | getCurrentState, 323 | setNewState, 324 | data: {a: 2}, 325 | states, 326 | }) 327 | }).toThrow(); 328 | }); 329 | 330 | test('Transition from one state to another when presented with multiple options', async () => { 331 | const data = { a: 1, b: 1 }; 332 | let state = 'state-1'; 333 | 334 | const states = [ 335 | { 336 | name: 'state-1', 337 | autoTransitions: [ 338 | { 339 | test: ({a}) => a === 2, 340 | newState: 'state-2' 341 | }, 342 | { 343 | test: ({b}) => b === 2, 344 | newState: 'state-3' 345 | } 346 | ], 347 | component: State1Component 348 | }, 349 | { 350 | name: 'state-2', 351 | component: State2Component 352 | }, 353 | { 354 | name: 'state-3', 355 | component: State3Component 356 | } 357 | ]; 358 | 359 | const getCurrentState = () => state; 360 | const setNewState = newState => state = newState; 361 | 362 | let stateMachine = mount( 363 | 369 | ); 370 | 371 | expect(stateMachine.text()).toEqual('State 1'); 372 | 373 | const setProps = promisify(stateMachine.setProps, stateMachine); 374 | 375 | // First new props cause a call to setNewState() 376 | await setProps({ 377 | getCurrentState, 378 | setNewState, 379 | data: {...data, b: 2}, 380 | states, 381 | }); 382 | 383 | // Second set props forces another render in tests 384 | await setProps({ 385 | getCurrentState, 386 | setNewState, 387 | data: {b: 2}, 388 | states, 389 | }); 390 | 391 | expect(stateMachine.text()).toEqual('State 3'); 392 | }); 393 | 394 | test('Transition to a valid state when calling transitionTo', async () => { 395 | const data = { a: 1, b: 1 }; 396 | let state = 'state-1'; 397 | 398 | const states = [ 399 | { 400 | name: 'state-1', 401 | validTransitions: ['state-2'], 402 | component: State1Component 403 | }, 404 | { 405 | name: 'state-2', 406 | component: State2Component 407 | } 408 | ]; 409 | 410 | const getCurrentState = () => state; 411 | const setNewState = newState => state = newState; 412 | 413 | let stateMachine = mount( 414 | 420 | ); 421 | 422 | expect(stateMachine.text()).toEqual('State 1'); 423 | 424 | const transitionToFn = stateMachine.children().props().transitionTo; 425 | transitionToFn('state-2'); 426 | 427 | // Call set props to trigger an update 428 | const setProps = promisify(stateMachine.setProps, stateMachine); 429 | await setProps({ 430 | getCurrentState, 431 | setNewState, 432 | data: data, 433 | states, 434 | }); 435 | 436 | expect(stateMachine.text()).toEqual('State 2'); 437 | }); 438 | 439 | test('Throw an error when calling transitionTo with an invalid new state', async () => { 440 | const data = { a: 1, b: 1 }; 441 | let state = 'state-1'; 442 | 443 | const states = [ 444 | { 445 | name: 'state-1', 446 | validTransitions: ['state-2'], 447 | component: State1Component 448 | }, 449 | { 450 | name: 'state-2', 451 | component: State2Component 452 | }, 453 | { 454 | name: 'state-3', 455 | component: State2Component 456 | } 457 | ]; 458 | 459 | const getCurrentState = () => state; 460 | const setNewState = newState => state = newState; 461 | 462 | let stateMachine = mount( 463 | 469 | ); 470 | 471 | expect(stateMachine.text()).toEqual('State 1'); 472 | 473 | const transitionToFn = stateMachine.children().props().transitionTo; 474 | expect(() => transitionToFn('state-3')).toThrow(); 475 | }); 476 | 477 | test('Throw on an invalid render prop', () => { 478 | spyOn(console, 'error'); 479 | const data = {}; 480 | const state = 'state-2'; 481 | 482 | const states = [ 483 | { 484 | name: 'state-1', 485 | render: /NotAComponent/ 486 | } 487 | ]; 488 | 489 | expect(() => { 490 | mount( 491 | state} 493 | setNewState={() => {}} 494 | data={data} 495 | states={states} 496 | /> 497 | ); 498 | }).toThrow(); 499 | }); 500 | 501 | test('Throw on an invalid component', () => { 502 | spyOn(console, 'error'); 503 | const data = {}; 504 | const state = 'state-2'; 505 | 506 | const states = [ 507 | { 508 | name: 'state-1', 509 | component: /NotAComponent/ 510 | } 511 | ]; 512 | 513 | expect(() => { 514 | mount( 515 | state} 517 | setNewState={() => {}} 518 | data={data} 519 | states={states} 520 | /> 521 | ); 522 | }).toThrow(); 523 | }); 524 | 525 | test('Throw when niether a render nor a component is provided', () => { 526 | spyOn(console, 'error'); 527 | const data = {}; 528 | const state = 'state-1'; 529 | 530 | const states = [ 531 | { 532 | name: 'state-1', 533 | } 534 | ]; 535 | 536 | expect(() => { 537 | mount( 538 | state} 540 | setNewState={() => {}} 541 | data={data} 542 | states={states} 543 | /> 544 | ); 545 | }).toThrow(); 546 | }); -------------------------------------------------------------------------------- /test/mock-components.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const State1Component = () =>
State 1
; 4 | export const State2Component = () =>
State 2
; 5 | export const State3Component = () =>
State 3
; 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: path.join(__dirname, "src/docs"), 6 | output: { 7 | path: path.join(__dirname, "docs"), 8 | filename: "bundle.js" 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.(js|jsx)$/, 14 | use: "babel-loader", 15 | exclude: /node_modules/ 16 | }, 17 | { 18 | test: /\.css$/, 19 | use: ["style-loader", "css-loader"] 20 | } 21 | ] 22 | }, 23 | plugins: [ 24 | new HtmlWebpackPlugin({ 25 | template: path.join(__dirname, "src/docs/index.html") 26 | }) 27 | ], 28 | resolve: { 29 | extensions: [".js", ".jsx"] 30 | }, 31 | devServer: { 32 | contentBase: path.join(__dirname, "docs"), 33 | port: 8000, 34 | stats: "minimal" 35 | } 36 | }; 37 | --------------------------------------------------------------------------------