├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build └── karma.js ├── docs └── roadmap.md ├── example ├── containers │ └── Root.js ├── index.html ├── index.jsx ├── reducers │ └── index.js ├── steps │ ├── EditUserFriendsStep.jsx │ └── EditUserInfoStep.jsx ├── stores │ └── configure.js └── wizards │ └── EditUserWizard.jsx ├── karma.conf.js ├── karma.entry.js ├── package.json ├── src ├── actions │ ├── _tests │ │ └── wizard.spec.js │ └── wizard.js ├── components │ ├── HalcyonBreadcrumbs.jsx │ ├── HalcyonDirectionalNavigation.jsx │ ├── HalcyonStepSelector.jsx │ ├── HalcyonViewportFooter.jsx │ ├── HalcyonWizard.jsx │ ├── HalcyonWizardSidebar.jsx │ └── _tests │ │ ├── HalcyonBreadCrumbs.spec.js │ │ ├── HalcyonDirectionalNavigation.spec.js │ │ ├── HalcyonStepSelector.spec.js │ │ ├── HalcyonViewportFooter.spec.js │ │ ├── HalcyonWizard.spec.js │ │ ├── HalcyonWizardSidebar.spec.js │ │ └── _mock-wizard.js ├── constants │ ├── _create.js │ └── wizard.js ├── decorators │ ├── HalcyonStep.js │ └── _tests │ │ ├── HalcyonStep.spec.js │ │ └── MockComponent.js ├── index.js ├── index.spec.js ├── lib │ ├── component.js │ └── debug.js └── reducers │ ├── _tests │ ├── _mock-reducer.js │ └── halcyon.spec.js │ ├── halcyon.js │ └── index.js ├── test └── unit │ └── index.js └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | # A special property that should be specified at the top of the file outside of 4 | # any sections. Set to true to stop .editor config file search on current file 5 | root = true 6 | 7 | # Indentation style 8 | # Possible values - tab, space 9 | indent_style = space 10 | 11 | # Indentation size in single-spaced characters 12 | # Possible values - an integer, tab 13 | indent_size = 2 14 | 15 | # Line ending file format 16 | # Possible values - lf, crlf, cr 17 | end_of_line = lf 18 | 19 | # File character encoding 20 | # Possible values - latin1, utf-8, utf-16be, utf-16le 21 | charset = utf-8 22 | 23 | # Denotes whether to trim whitespace at the end of lines 24 | # Possible values - true, false 25 | trim_trailing_whitespace = true 26 | 27 | # Denotes whether file should end with a newline 28 | # Possible values - true, false 29 | insert_final_newline = true 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | *.log 3 | node_modules 4 | 5 | dist 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "4.0" 5 | - "iojs-v2" 6 | 7 | install: 8 | - npm install 9 | 10 | script: 11 | - NODE_ENV=production npm run test 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 David Zukowski 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Halcyon 2 | ======= 3 | [![Build Status](https://travis-ci.org/davezuko/halcyon.svg?branch=master)](https://travis-ci.org/davezuko/halcyon) 4 | 5 | A lightweight Wizard platform for React. 6 | 7 | Table of Contents 8 | ----------------- 9 | 1. [Features](#features) 10 | 1. [Requirements](#requirements) 11 | 1. [Installation](#installation) 12 | 1. [Components](#components) 13 | * [HalcyonWizard](#halcyon-wizard) 14 | * [Attributes](#attributes) 15 | * [HalcyonStep](#halcyon-step) 16 | * [Injected Properties](#injected-properties) 17 | * [Hooks](#hooks) 18 | 1. [Lifecycle](#lifecycle) 19 | 1. [FAQ](#faq) 20 | 21 | Features 22 | -------- 23 | 24 | * Automatic model management 25 | * Emphasizes immutability 26 | * Wizard model remains pure at all times 27 | * All changes to the core model produce a new reference 28 | * Steps provide "sessions" 29 | * Higher order step components handle the exchange between the immutable 30 | model received from the wizard and what's injected into your step. 31 | * Model changes are performed by a clean API injected through props 32 | * Free "undo changes" ability to revert all changes made in a step session 33 | * Intuitive lifecycle hooks for step components 34 | * Support for nested wizards 35 | * Generates core UI components 36 | * Automatic breadcrumb generation 37 | * Step selector sidebar panel 38 | * Action buttons (previous, next, cancel, submit) 39 | * Accepts callback hooks for core wizard events 40 | * Built with reusability in mind 41 | * Steps can be dropped into any wizard and rearranged at will 42 | * Core wizard functionality is decoupled from submission and cancellation events 43 | * Easily integrated into a larger Redux application 44 | 45 | 46 | Requirements 47 | ------------ 48 | 49 | **Node**: Node.js `^4.0.0` 50 | 51 | **Build**: Build system capable of performing ES6 to ES5 transpilation. If you wish to use the decorator syntax (e.g. with `@halcyonStep`) you must enable support for it in your transpiler (Babel, Traceur). 52 | 53 | **Redux**: The wizard currently relies on Redux for state management. See [Installation](#installation) for more details on how to integrate it. 54 | 55 | Installation 56 | ------------ 57 | 58 | Components 59 | ---------- 60 | 61 | ### HalcyonWizard 62 | 63 | #### Example 64 | 65 | ```js 66 | import React from 'react'; 67 | import { HalcyonWizard } from 'halcyon'; 68 | import FirstStep from './YourFirstStep'; 69 | import SecondStep from './YourSecondStep'; 70 | 71 | class YourWizard extends React.Component { 72 | constructor () { 73 | super(); 74 | } 75 | 76 | // This will be called if your wizard is cancelled for any reason. 77 | onCancel () {} 78 | 79 | // This will be called when the wizard is able to submit. The new model 80 | // will be passed to the callback. 81 | onSubmit (model) {} 82 | 83 | // Let's just assume that you have an object (POJO) on your state that 84 | // can be used as the model for the wizard. 85 | render () { 86 | return ( 87 | 90 | 91 | 92 | 93 | ); 94 | } 95 | } 96 | ``` 97 | 98 | #### Attributes 99 | 100 | ##### `[Object] model` 101 | A plain JavaScript object. Halcyon makes no distinction between loading/loaded states for your model; instead, you should only render the wizard once your model is ready. If you pass a POJO, halcyon will instantiate it as an Immutable object internally. This way, the model you provide is _never_ mutated. 102 | 103 | ##### `[Function] onSubmit` 104 | Handler invoked when the wizard submits. This will provide the wizard model as its only argument. 105 | 106 | ##### `[Function] onCancel` 107 | Handler invoked when the wizard is cancelled for any reason. 108 | 109 | ##### `[Int] stepIndex` 110 | Optional step override. Halcyon will only notice this property the first time it is received, allowing you to modify the initial step or override the current one, but the wizard will be allowed to proceed as normal afterward. 111 | 112 | ### HalcyonStep 113 | 114 | #### Example 115 | ```js 116 | import React from 'react'; 117 | import { halcyonStep } from 'halcyon'; 118 | 119 | @halcyonStep('Default Title') 120 | class YourStep extends React.Component { 121 | constructor () { 122 | super(); 123 | } 124 | 125 | // the halcyonStep higher order component injects the model and 126 | // convenience functions into your component. The great thing is, by updating 127 | // your model via this API the internal model remains immutable the whole 128 | // time, but you don't have to worry about it! Just _react_ to its changes. 129 | render () { 130 | const { model } = this.props; 131 | 132 | return ( 133 |
134 | 135 |
136 | ); 137 | } 138 | } 139 | ``` 140 | 141 | #### Injected Properties 142 | 143 | **NOTE**: I'm documenting `Rerender` as a return type for the function signatures. This isn't technically correct, but the functions produce the controlled side effect of updating the internal step model, which then triggers a rerender. They technically return `undefined`, since you are not expected to manually receive the updated model. 144 | 145 | ##### `[Object] model` 146 | POJO version of the current model. 147 | 148 | ##### `[Function] setProperty : (String|Array) -> * -> Rerender` 149 | Function that takes two parameters, the first is either an array of nested properties (single level is OK) or a string whose properties are dot-separated. This will produce a new representation of your model and subsequently trigger a re-render. 150 | 151 | **NOTE**: This method will trigger a re-render for your step. 152 | **NOTE**: This method is simply a wrapper around ImmutableJS's "setIn" method in order to support string-based paths. 153 | 154 | Example: 155 | ```js 156 | // this.props.model = { firstName : 'Michael' }; 157 | 158 | // update the property first Name 159 | this.props.setValue('firstName', 'Dwight'); 160 | 161 | // update a nested property (objects will be created if necessary) 162 | this.props.setValue('address.zipcode', '49024'); 163 | 164 | /** 165 | updated model 166 | { 167 | firstName : 'Dwight', 168 | address : { 169 | zipcode : '49024' 170 | } 171 | } 172 | */ 173 | ``` 174 | 175 | ##### `bindTo : (String|Array) -> ({ onChange : (Event -> Rerender), value : * })` 176 | Returns an object to be applied to as attributes to an input. Contains the current value of the target property (as "value") and an event handler that will apply the event's target value to the model with onChange. Uses `setProperty` internally. 177 | 178 | **NOTE**: This method will trigger a re-render for your step. 179 | 180 | ##### `[Function] setModel : Object -> Rerender` 181 | Sets the model to the provided object 182 | 183 | **NOTE**: This method will trigger a re-render for your step. 184 | 185 | Lifecycle 186 | --------- 187 | 188 | FAQ 189 | --- 190 | -------------------------------------------------------------------------------- /build/karma.js: -------------------------------------------------------------------------------- 1 | import webpackConfig from '../webpack.config'; 2 | 3 | const KARMA_ENTRY_FILE = 'karma.entry.js'; 4 | 5 | function makeDefaultConfig () { 6 | const preprocessors = {}; 7 | 8 | preprocessors[KARMA_ENTRY_FILE] = ['webpack']; 9 | preprocessors['src/**/*.js'] = ['webpack']; 10 | preprocessors['src/**/*.jsx'] = ['webpack']; 11 | 12 | return { 13 | files : [ 14 | './node_modules/phantomjs-polyfill/bind-polyfill.js', 15 | './' + KARMA_ENTRY_FILE 16 | ], 17 | singleRun : process.env.NODE_ENV === 'production', 18 | frameworks : ['mocha', 'sinon-chai'], 19 | preprocessors : preprocessors, 20 | reporters : ['spec'], 21 | browsers : ['PhantomJS'], 22 | webpack : { 23 | devtool : 'inline-source-map', 24 | resolve : webpackConfig.resolve, 25 | plugins : webpackConfig.plugins, 26 | module : { 27 | loaders : webpackConfig.module.loaders 28 | } 29 | }, 30 | webpackMiddleware : { 31 | noInfo : true 32 | }, 33 | plugins : [ 34 | require('karma-webpack'), 35 | require('karma-mocha'), 36 | require('karma-sinon-chai'), 37 | require('karma-phantomjs-launcher'), 38 | require('karma-spec-reporter') 39 | ] 40 | }; 41 | } 42 | 43 | export default (karmaConfig) => karmaConfig.set(makeDefaultConfig()); 44 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | Roadmap 2 | ======= 3 | 4 | v0.2.0 5 | ------ 6 | * [ ] Step session model should be managed through a reducer 7 | * [ ] Wizards should _not_ really on a component instance 8 | 9 | Unsorted 10 | -------- 11 | * [ ] Improve README documentation 12 | * [ ] Rename `setProperty` to `set` 13 | * [ ] Refactor tests 14 | * [ ] Improve support for disabled steps 15 | * [ ] should apply a "disabled" class to the step selector 16 | * [ ] should skip over disabled steps when next/previous is clicked 17 | * [ ] Prohibit skipping steps until all steps have been visited. 18 | -------------------------------------------------------------------------------- /example/containers/Root.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import reducers from '../reducers'; 3 | import { Provider } from 'react-redux'; 4 | import EditUserWizard from '../wizards/EditUserWizard'; 5 | import configureStore from '../stores/configure'; 6 | import { combineReducers, createStore } from 'redux'; 7 | import { DevTools, LogMonitor, DebugPanel } from 'redux-devtools/lib/react'; 8 | 9 | const store = configureStore(); 10 | 11 | export default class Root extends React.Component { 12 | constructor () { 13 | super(); 14 | } 15 | 16 | renderDevTools () { 17 | return ( 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | render () { 25 | return ( 26 |
27 | {this.renderDevTools()} 28 | 29 | 30 | 31 |
32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Halcyon Demo 5 | 6 | 7 | 8 | 9 | 36 | 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /example/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Root from './containers/root'; 4 | 5 | const model = { 6 | firstName : 'Michael', 7 | lastName : 'Scott', 8 | friends : [] 9 | }; 10 | 11 | ReactDOM.render(, document.getElementById('root')); 12 | -------------------------------------------------------------------------------- /example/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { reducers as HalcyonReducers } from '../../src'; 3 | 4 | const reducers = { ...HalcyonReducers }; 5 | export default combineReducers(reducers); 6 | -------------------------------------------------------------------------------- /example/steps/EditUserFriendsStep.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import EditUserInfoStep from './EditUserInfoStep'; 3 | import { halcyonStep, HalcyonWizard } from '../../src'; 4 | 5 | @halcyonStep('Edit User Friends') 6 | export default class EditUserFriendsStep extends React.Component { 7 | static propTypes = { 8 | model : React.PropTypes.object.isRequired, 9 | setProperty : React.PropTypes.func.isRequired 10 | } 11 | 12 | constructor () { 13 | super(); 14 | this.state = { 15 | wizardModel : null, 16 | wizardSubmit : null 17 | }; 18 | } 19 | 20 | _addFriend () { 21 | this.setState({ 22 | wizardModel : {}, 23 | wizardSubmit : (model) => { 24 | this.props.setProperty('friends', [...this.props.model.friends, model]); 25 | } 26 | }); 27 | } 28 | 29 | _closeSubwizard () { 30 | this.setState({ 31 | wizardModel : null, 32 | wizardSubmit : null 33 | }); 34 | } 35 | 36 | renderFriendWizard () { 37 | return ( 38 | { 42 | this.state.wizardSubmit(model); 43 | this._closeSubwizard(); 44 | }}> 45 | 46 | 47 | ); 48 | } 49 | 50 | render () { 51 | const { model } = this.props; 52 | 53 | if (this.state.wizardModel) { 54 | return this.renderFriendWizard(); 55 | } 56 | 57 | return ( 58 |
59 |

Friends of {model.firstName} {model.lastName}

60 |
61 | {model.friends.map((friend, idx) => ( 62 | 65 | ))} 66 |
67 | 70 |
71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /example/steps/EditUserInfoStep.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { halcyonStep } from '../../src'; 3 | import * as debug from '../../src/lib/debug'; 4 | 5 | @halcyonStep('Edit User Info') 6 | export default class EditUserInfoStep extends React.Component { 7 | static propTypes = { 8 | bindTo : React.PropTypes.func.isRequired, 9 | model : React.PropTypes.object.isRequired 10 | } 11 | 12 | constructor () { 13 | super(); 14 | } 15 | 16 | isStepValid () { 17 | const { model } = this.props; 18 | 19 | if (model.age < 18) { 20 | debug.error('Age must be >= 18, silly!'); 21 | return false; 22 | } 23 | return true; 24 | } 25 | 26 | render () { 27 | const { model } = this.props; 28 | 29 | return ( 30 |
31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 |
39 |
40 | 41 | 44 |
45 |
46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /example/stores/configure.js: -------------------------------------------------------------------------------- 1 | import rootReducer from '../reducers'; 2 | import { devTools } from 'redux-devtools'; 3 | import { compose, createStore } from 'redux'; 4 | 5 | const createStoreWithMiddleware = compose(devTools())(createStore); 6 | 7 | export default function configureStore (initialState) { 8 | const store = createStoreWithMiddleware(rootReducer, initialState); 9 | 10 | if (module.hot) { 11 | module.hot.accept('../reducers', () => { 12 | const nextRootReducer = require('../reducers/index'); 13 | 14 | store.replaceReducer(nextRootReducer); 15 | }); 16 | } 17 | return store; 18 | } 19 | -------------------------------------------------------------------------------- /example/wizards/EditUserWizard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import EditUserInfoStep from '../steps/EditUserInfoStep'; 3 | import EditUserFriendsStep from '../steps/EditUserFriendsStep'; 4 | import { HalcyonWizard } from '../../src'; 5 | 6 | export default class EditUserWizard extends React.Component { 7 | static propTypes = { 8 | title : React.PropTypes.string, 9 | model : React.PropTypes.object.isRequired 10 | } 11 | 12 | static defaultProps = { 13 | title : 'Edit User Wizard' 14 | } 15 | 16 | constructor () { 17 | super(); 18 | this.state = { 19 | stepOverride : null 20 | }; 21 | } 22 | 23 | _onModelChange (model) { 24 | console.log('model change received', model); 25 | } 26 | 27 | // do some mock async validation 28 | _onSubmit (model) { 29 | console.log('OLD MODEL'); 30 | console.log(this.props.model); 31 | 32 | console.log('NEW MODEL'); 33 | console.log(model); 34 | } 35 | 36 | _onCancel () {} 37 | 38 | render () { 39 | return ( 40 | 46 | 47 | 48 | 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | require('babel/register'); 2 | module.exports = require('./build/karma'); 3 | -------------------------------------------------------------------------------- /karma.entry.js: -------------------------------------------------------------------------------- 1 | // Require all ".spec.js" files in ~/src. 2 | var context = require.context('./src', true, /.+\.spec\.js$/); 3 | context.keys().forEach(context); 4 | module.exports = context; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "halcyon", 3 | "version": "1.0.0", 4 | "description": "Lightweight Flux-based wizard system for React", 5 | "main": "./src/index.js", 6 | "scripts": { 7 | "dev": "webpack-dev-server --port 3000 --hot --inline --colors --history-api-fallback", 8 | "dev:quiet": "webpack-dev-server --port 3000 --hot --inline --colors --no-info --history-api-fallback --quiet", 9 | "test": "node ./node_modules/karma/bin/karma start" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/davezuko/halcyon.git" 14 | }, 15 | "keywords": [ 16 | "React", 17 | "Wizard", 18 | "Framework" 19 | ], 20 | "author": "David Zukowski (http://zuko.me)", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/davezuko/halcyon/issues" 24 | }, 25 | "homepage": "https://github.com/davezuko/halcyon#readme", 26 | "dependencies": { 27 | "classnames": "^2.1.3", 28 | "immutable": "^3.7.4", 29 | "invariant": "^2.1.0", 30 | "react": "0.14.0-rc1", 31 | "react-dom": "0.14.0-rc1", 32 | "react-redux": "^2.1.2", 33 | "redux": "^3.0.0" 34 | }, 35 | "devDependencies": { 36 | "babel": "^5.8.23", 37 | "babel-core": "^5.6.15", 38 | "babel-loader": "^5.1.4", 39 | "babel-runtime": "^5.8.24", 40 | "html-webpack-plugin": "^1.5.2", 41 | "karma": "^0.13.8", 42 | "karma-chai": "^0.1.0", 43 | "karma-coverage": "^0.5.2", 44 | "karma-mocha": "^0.2.0", 45 | "karma-phantomjs-launcher": "^0.2.1", 46 | "karma-sinon-chai": "^1.0.0", 47 | "karma-spec-reporter": "0.0.20", 48 | "karma-webpack": "^1.7.0", 49 | "phantomjs": "^1.9.17", 50 | "phantomjs-polyfill": "0.0.1", 51 | "react-addons-test-utils": "^0.14.0-rc1", 52 | "react-hot-loader": "^1.2.7", 53 | "redux-devtools": "^2.1.2", 54 | "webpack": "^1.9.11", 55 | "webpack-dev-server": "^1.9.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/actions/_tests/wizard.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | createWizard, 3 | destroyWizard, 4 | setWizardModel, 5 | changeWizardStep 6 | } from '../wizard'; 7 | 8 | describe('(Actions) HalcyonWizard', function () { 9 | 10 | describe('createWizard', function () { 11 | it('Should be a function.', function () { 12 | expect(createWizard).to.be.a('function'); 13 | }); 14 | 15 | it('Should return an action object.', function () { 16 | const action = createWizard(); 17 | 18 | expect(action).to.be.an('object'); 19 | expect(action.type).to.be.a('string'); 20 | expect(action.payload).to.be.an('object'); 21 | }); 22 | 23 | it('Should specify an action type of "HALCYON_WIZARD_CREATE".', function () { 24 | const action = createWizard(); 25 | 26 | expect(action.type).to.equal('HALCYON_WIZARD_CREATE'); 27 | }); 28 | 29 | it('Should set the first argument as the payload\'s "instance" property.', function () { 30 | const instance = {}; 31 | const action = createWizard(instance); 32 | 33 | expect(action.payload.instance).to.equal(instance); 34 | }); 35 | 36 | it('Should set the second argument as the payload\'s "model" property.', function () { 37 | const model = {}; 38 | const action = createWizard(null, model); 39 | 40 | expect(action.payload.model).to.equal(model); 41 | }); 42 | }); 43 | 44 | describe('destroyWizard', function () { 45 | it('Should be a function.', function () { 46 | expect(destroyWizard).to.be.a('function'); 47 | }); 48 | 49 | it('Should return an action object.', function () { 50 | const action = destroyWizard(); 51 | 52 | expect(action).to.be.an('object'); 53 | expect(action.type).to.be.a('string'); 54 | expect(action.payload).to.be.an('object'); 55 | }); 56 | 57 | it('Should specify an action type of "HALCYON_WIZARD_DESTROY".', function () { 58 | expect(destroyWizard().type).to.equal('HALCYON_WIZARD_DESTROY'); 59 | }); 60 | 61 | it('Should set the first argument as the payload\'s "instance" property.', function () { 62 | const instance = {}; 63 | const action = destroyWizard(instance); 64 | 65 | expect(action.payload.instance).to.equal(instance); 66 | }); 67 | }); 68 | 69 | describe('setWizardModel', function () { 70 | it('Should be a function.', function () { 71 | expect(setWizardModel).to.be.a('function'); 72 | }); 73 | 74 | it('Should return an action object.', function () { 75 | const action = setWizardModel(); 76 | 77 | expect(action).to.be.an('object'); 78 | expect(action.type).to.be.a('string'); 79 | expect(action.payload).to.be.an('object'); 80 | }); 81 | 82 | it('Should specify an action type of "HALCYON_WIZARD_SET_MODEL".', function () { 83 | expect(setWizardModel().type).to.equal('HALCYON_WIZARD_SET_MODEL'); 84 | }); 85 | 86 | it('Should set the first argument as the payload\'s "instance" property.', function () { 87 | const instance = {}; 88 | const action = setWizardModel(instance); 89 | 90 | expect(action.payload.instance).to.equal(instance); 91 | }); 92 | 93 | it('Should set the second argument as the payload\'s "model" property.', function () { 94 | const model = {}; 95 | 96 | expect(setWizardModel(null, model).payload.model).to.equal(model); 97 | }); 98 | }); 99 | 100 | describe('changeWizardStep', function () { 101 | it('Should be a function.', function () { 102 | expect(changeWizardStep).to.be.a('function'); 103 | }); 104 | 105 | it('Should return an action object.', function () { 106 | const action = changeWizardStep(); 107 | 108 | expect(action).to.be.an('object'); 109 | expect(action.type).to.be.a('string'); 110 | expect(action.payload).to.be.an('object'); 111 | }); 112 | 113 | it('Should specify an action type of "HALCYON_WIZARD_STEP_CHANGE".', function () { 114 | expect(changeWizardStep().type).to.equal('HALCYON_WIZARD_STEP_CHANGE'); 115 | }); 116 | 117 | it('Should set the first argument as the payload\'s "instance" property.', function () { 118 | const instance = {}; 119 | const action = changeWizardStep(instance); 120 | 121 | expect(action.payload.instance).to.equal(instance); 122 | }); 123 | 124 | it('Should set the second argument as the payload\'s "index" property.', function () { 125 | expect(changeWizardStep(null, 0).payload.index).to.equal(0); 126 | expect(changeWizardStep(null, 2).payload.index).to.equal(2); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/actions/wizard.js: -------------------------------------------------------------------------------- 1 | import { 2 | HALCYON_WIZARD_CREATE, 3 | HALCYON_WIZARD_DESTROY, 4 | HALCYON_WIZARD_SET_MODEL, 5 | HALCYON_WIZARD_STEP_CHANGE 6 | } from '../constants/wizard'; 7 | 8 | // createWizard :: Wizard -> Object -> Object 9 | export function createWizard (instance, model) { 10 | return { 11 | type : HALCYON_WIZARD_CREATE, 12 | payload : { 13 | instance, model 14 | } 15 | }; 16 | } 17 | 18 | // destroyWizard :: Wizard -> Object 19 | export function destroyWizard (instance) { 20 | return { 21 | type : HALCYON_WIZARD_DESTROY, 22 | payload : { 23 | instance 24 | } 25 | }; 26 | } 27 | 28 | // setWizardModel :: Wizard -> Object -> Object 29 | export function setWizardModel (instance, model) { 30 | return { 31 | type : HALCYON_WIZARD_SET_MODEL, 32 | payload : { 33 | instance, model 34 | } 35 | }; 36 | } 37 | 38 | // setWizardModel :: Wizard -> Int -> Object 39 | export function changeWizardStep (instance, index) { 40 | return { 41 | type : HALCYON_WIZARD_STEP_CHANGE, 42 | payload : { 43 | instance, index 44 | } 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/HalcyonBreadcrumbs.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { getComponentTitle } from '../lib/component'; 4 | import * as debug from '../lib/debug'; 5 | 6 | const mapDispatchToProps = (state) => ({ 7 | wizards : state.halcyon 8 | }); 9 | export class HalcyonBreadcrumbs extends React.Component { 10 | static propTypes = { 11 | wizards : React.PropTypes.object.isRequired 12 | } 13 | 14 | constructor () { 15 | super(); 16 | } 17 | 18 | // TODO: this should be handled a bit more cleanly with the wizard 19 | // lifecycle hooks when time allows. Best idea would be for close/submit 20 | // events to be generated from the redux store, and have wizards simply 21 | // listen to them, that way these could cascade naturally without having 22 | // to manually hook into the instances. 23 | onClick (link) { 24 | 25 | // if target instance is the currently active instance, ignore. 26 | if (link.instance === this.props.wizards.last().get('instance')) { 27 | return null; 28 | } 29 | 30 | // Otherwise, find all the wizards that need to close in order to navigate 31 | // to the target instance. 32 | const wizardsThatNeedToClose = this.props.wizards 33 | .reverse() 34 | .takeUntil(w => w.get('instance') === link.instance); 35 | 36 | const wizardsThatCantClose = wizardsThatNeedToClose 37 | .filter(w => !w.get('instance').isCurrentStepExitable()); 38 | 39 | if (wizardsThatCantClose.size) { 40 | debug.warn( 41 | `Cannot navigate to the target wizard because %s wizards ahead ` + 42 | `of the target are unable to close.`, 43 | wizardsThatCantClose.size 44 | ); 45 | } else { 46 | wizardsThatNeedToClose.forEach(w => w.get('instance')._onCancel()); 47 | } 48 | } 49 | 50 | /** 51 | * @param {Immutable.List} wizards - global wizard state 52 | * 53 | * @returns {Array} list of objects with data needed to render breadcrumbs, 54 | * includes "title" for the friendly name of the breadcrumb (references 55 | * either the step or the wizard) and "instance" which is a ref to the 56 | * target wizard component. 57 | */ 58 | getBreadcrumbsForWizards (wizards) { 59 | return wizards 60 | .flatMap(wizard => { 61 | const instance = wizard.get('instance'), 62 | activeStep = instance.getCurrentStep(); 63 | 64 | return [instance, activeStep] 65 | .map(component => ({ 66 | title : getComponentTitle(component), 67 | instance : instance 68 | })); 69 | }).toJS(); 70 | } 71 | 72 | render () { 73 | const breadcrumbs = this.getBreadcrumbsForWizards(this.props.wizards); 74 | 75 | return ( 76 |
    77 | {breadcrumbs.map((link, idx) => ( 78 |
  1. 79 | 80 | {link.title} 81 | 82 |
  2. 83 | ))} 84 |
85 | ); 86 | } 87 | } 88 | 89 | export default connect(mapDispatchToProps)(HalcyonBreadcrumbs); 90 | -------------------------------------------------------------------------------- /src/components/HalcyonDirectionalNavigation.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class HalcyonDirectionalNavigation extends React.Component { 4 | static propTypes = { 5 | onNext : React.PropTypes.func.isRequired, 6 | onPrevious : React.PropTypes.func.isRequired, 7 | disabled : React.PropTypes.bool, 8 | disableNext : React.PropTypes.bool, 9 | disablePrevious : React.PropTypes.bool 10 | } 11 | 12 | static defaultProps = { 13 | disabled : false, 14 | disableNext : false, 15 | disablePrevious : false 16 | } 17 | 18 | constructor () { 19 | super(); 20 | } 21 | 22 | render () { 23 | return ( 24 |
25 |
26 | 31 |
32 |
33 | 38 |
39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/HalcyonStepSelector.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import { getComponentTitle } from '../lib/component'; 4 | 5 | export default class HalcyonStepSelector extends React.Component { 6 | static propTypes = { 7 | steps : React.PropTypes.array.isRequired, 8 | onSelect : React.PropTypes.func.isRequired 9 | } 10 | 11 | constructor () { 12 | super(); 13 | } 14 | 15 | render () { 16 | return ( 17 |
    18 | {this.props.steps.map((step, idx) => { 19 | const isDisabled = !!step.props.disabled; 20 | const cn = classnames( 21 | 'panel', 22 | 'panel-default', 23 | 'halcyon-step-selector__card', 24 | { 'halcyon-step-selector__card--disabled' : isDisabled } 25 | ); 26 | const onClick = this.props.onSelect.bind(this, idx); 27 | 28 | return ( 29 |
    30 |
    31 |

    {getComponentTitle(step)}

    32 |
    33 |
    34 | ); 35 | })} 36 |
37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/HalcyonViewportFooter.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HalcyonNavigation from './HalcyonDirectionalNavigation'; 3 | 4 | export default class HalcyonViewportFooter extends React.Component { 5 | static propTypes = { 6 | onNext : React.PropTypes.func.isRequired, 7 | onPrevious : React.PropTypes.func.isRequired, 8 | onCancel : React.PropTypes.func.isRequired, 9 | onSubmit : React.PropTypes.func.isRequired, 10 | disabled : React.PropTypes.bool, 11 | disableNext : React.PropTypes.bool, 12 | disablePrevious : React.PropTypes.bool 13 | } 14 | 15 | static defaultProps = { 16 | disabled : false, 17 | disableNext : false, 18 | disablePrevious : false 19 | } 20 | 21 | constructor () { 22 | super(); 23 | } 24 | 25 | render () { 26 | return ( 27 |
28 | 32 |
33 | 37 | 42 |
43 |
44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/HalcyonWizard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Immutable from 'immutable'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import invariant from 'invariant'; 6 | import classnames from 'classnames'; 7 | import * as WizardActions from '../actions/wizard'; 8 | import HalcyonViewportFooter from './HalcyonViewportFooter'; 9 | import HalcyonWizardSidebar from './HalcyonWizardSidebar'; 10 | import HalcyonBreadcrumbs from './HalcyonBreadcrumbs'; 11 | import * as debug from '../lib/debug'; 12 | 13 | /** 14 | * Decorates an instance method to only be called when its caller is the active 15 | * wizard instance. This helps to standardize and eliminate redundant 16 | * "isActive()" checks at the top of render helper methods. 17 | * 18 | * @returns {function} lifts the target function so that it will only be called 19 | * if its caller is the active wizard instance. 20 | */ 21 | export function activeWizardOnly (target, key, descriptor) { 22 | const fn = descriptor.value; 23 | 24 | descriptor.value = function calledIfWizardIsActive () { 25 | return this.isActive() ? fn.call(this) : null; 26 | }; 27 | 28 | return descriptor; 29 | } 30 | 31 | const mapDispatchToProps = (state) => ({ 32 | wizards : state.halcyon 33 | }); 34 | export class HalcyonWizard extends React.Component { 35 | static propTypes = { 36 | children : React.PropTypes.oneOfType([ 37 | React.PropTypes.element, 38 | React.PropTypes.arrayOf(React.PropTypes.element) 39 | ]).isRequired, 40 | wizards : React.PropTypes.object.isRequired, 41 | model : React.PropTypes.object.isRequired, 42 | dispatch : React.PropTypes.func.isRequired, 43 | onCancel : React.PropTypes.func.isRequired, 44 | onSubmit : React.PropTypes.func.isRequired, 45 | onModelChange : React.PropTypes.func, 46 | stepIndex : React.PropTypes.number 47 | } 48 | 49 | constructor () { 50 | super(); 51 | } 52 | 53 | /* eslint-disable */ 54 | /** 55 | * Takes a map of action creators and binds them to the Redux dispatcher and 56 | * then directly to the wizard class instance. This faciliates dispatching 57 | * Halcyon-specific actions because callers no longer need to specify `this` 58 | * as the first argument to specify the target instance. 59 | * @example 60 | * const actions = { 61 | * foo : function () { ... } 62 | * }; 63 | * 64 | * // instead of doing: 65 | * this.props.dispatch(actions.foo(this, ...)); 66 | * 67 | * // you can do: 68 | * this.bindActionCreatorsToSelf(actions); 69 | * this._actions.foo(...); 70 | * @param {Object} actions - object that defines a map of functions where 71 | * the key is the function name and the value is its definition. 72 | */ 73 | bindActionCreatorsToSelf (actions) { 74 | const boundActions = bindActionCreators(actions, this.props.dispatch); 75 | 76 | this._actions = this._actions || {}; 77 | for (const key in boundActions) { 78 | if (!this[key]) { 79 | this._actions[key] = boundActions[key].bind(null, this); 80 | } else { 81 | debug.warn( 82 | `Cannot apply action ${key} to HalcyonWizard instance because the ` + 83 | `property already exists.` 84 | ); 85 | } 86 | } 87 | } 88 | /* eslint-enable */ 89 | 90 | setModel (model) { 91 | this._actions.setWizardModel(Immutable.fromJS(model)); 92 | } 93 | 94 | // ---------------------------------- 95 | // Native Life Cycle Hooks 96 | // ---------------------------------- 97 | componentWillMount () { 98 | this.bindActionCreatorsToSelf(WizardActions); 99 | this._actions.createWizard(Immutable.fromJS(this.props.model)); 100 | 101 | invariant( 102 | this.props.children, 103 | 'A HalcyonWizard must include at least one step.' 104 | ); 105 | } 106 | 107 | componentWillUpdate (nextProps) { 108 | this._state = nextProps.wizards.find(w => w.get('instance') === this); 109 | 110 | invariant( 111 | this._state, 112 | 'Wizard component was updated but no reference was found for it in ' + 113 | 'the Redux store. Make sure your component instance did not change.' 114 | ); 115 | 116 | // If Wizard component receives a new stepIndex override, then update 117 | // the internal step index. 118 | if ( 119 | typeof nextProps.stepIndex === 'number' && 120 | this.props.stepIndex !== nextProps.stepIndex 121 | ) { 122 | this._actions.changeWizardStep(nextProps.stepIndex); 123 | } 124 | } 125 | 126 | componentDidUpdate (prevProps) { 127 | const prevState = prevProps.wizards.find(w => w.get('instance') === this); 128 | 129 | // If the model has changed, invoke the onModelChange hook. 130 | if ( 131 | prevState && 132 | typeof this.props.onModelChange === 'function' && 133 | this._state.get('model') !== prevState.get('model') 134 | ) { 135 | this.props.onModelChange(this._state.get('model').toJS()); 136 | } 137 | } 138 | 139 | componentWillUnmount () { 140 | this._actions.destroyWizard(); 141 | } 142 | 143 | // ---------------------------------- 144 | // Halcyon Life Cycle Definition 145 | // ---------------------------------- 146 | /** 147 | * Determines whether or not the current step component can be safely 148 | * exited by invoking its "shouldStepExit" method. 149 | * @returns {Boolean} whether or not the current step is valid. 150 | */ 151 | isCurrentStepExitable () { 152 | if (typeof this.refs.step.shouldStepExit === 'function') { 153 | return this.refs.step.shouldStepExit(); 154 | } else { 155 | debug.warn( 156 | 'Current step did not provide a "shouldStepExit" method, ' + 157 | 'wizard will assume step is exitable and continue.' 158 | ); 159 | return true; 160 | } 161 | } 162 | 163 | /** 164 | * Determines whether or not the current step component is valid by 165 | * invoking its "isStepValid" method. 166 | * @returns {Boolean} whether or not the current step is valid. 167 | */ 168 | isCurrentStepValid () { 169 | if (typeof this.refs.step.isStepValid === 'function') { 170 | return this.refs.step.isStepValid(); 171 | } else { 172 | debug.warn( 173 | 'Current step did not provide an "isStepValid" method, ' + 174 | 'wizard will assume model is valid and continue.' 175 | ); 176 | return true; 177 | } 178 | } 179 | 180 | /** 181 | * Determines whether or not a navigation attempt should be allowed to 182 | * continue. By default will always return true; to prevent a navigation 183 | * attempt return false. 184 | * @returns {Boolean} Whether or not to stop a navigation attempt. 185 | */ 186 | shouldWizardNavigate () { 187 | return this.isCurrentStepExitable() && this.isCurrentStepValid(); 188 | } 189 | 190 | // ---------------------------------- 191 | // Internal Halcyon Methods 192 | // ---------------------------------- 193 | /** 194 | * Routes all internal navigation attempts. Helps hook into lifecycle methods 195 | * to determine if the wizard should proceed with the navigation. 196 | * @param {Int} idx - Index of the target step. 197 | */ 198 | attemptToNavigateToIndex (idx) { 199 | if (!this.getSteps()[idx].props.disabled && this.shouldWizardNavigate()) { 200 | this.navigateToIndex(idx); 201 | } 202 | } 203 | 204 | /** 205 | * Updates the active step of the wizard to the target index. 206 | * @param {Int} idx - Index of the target step. 207 | */ 208 | navigateToIndex (idx) { 209 | this._actions.setWizardModel(this.refs.step.state.model); 210 | this._actions.changeWizardStep(idx); 211 | } 212 | 213 | // ---------------------------------- 214 | // State Convenience Methods 215 | // ---------------------------------- 216 | /** 217 | * @returns {Array} Collection of direct child steps. 218 | */ 219 | getSteps () { 220 | const steps = this.props.children; 221 | 222 | return Array.isArray(steps) ? steps : [steps]; 223 | } 224 | 225 | /** 226 | * @returns {React.Component} The active step component. 227 | */ 228 | getCurrentStep () { 229 | return this.getSteps()[this.getCurrentStepIndex()]; 230 | } 231 | 232 | /** 233 | * @returns {Int} The index of the active step. 234 | */ 235 | getCurrentStepIndex () { 236 | return this._state.get('stepIndex'); 237 | } 238 | 239 | /** 240 | * Determines whether or not the wizard is able to navigate backwards by 241 | * checking how many non-disabled steps exist behind the current step. 242 | * @returns {Boolean} whether or not the wizard can navigate backward. 243 | */ 244 | canNavigateBackward () { 245 | const stepIdx = this.getCurrentStepIndex(); 246 | 247 | // If wizard is on the first step it's not possible to go backward. 248 | if (stepIdx === 0) return false; 249 | 250 | // Find all navigable steps behind the current step (i.e. steps that 251 | // aren't disabled). If there is at least one available previous step, 252 | // then allow backward navigation. 253 | const availablePreviousSteps = this.getSteps() 254 | .filter((step, idx) => idx < stepIdx && !step.props.disabled); 255 | 256 | return availablePreviousSteps.length > 0; 257 | } 258 | 259 | /** 260 | * Determines whether or not the wizard is able to navigate forward by 261 | * checking how many non-disabled steps exist ahead of the current step. 262 | * @returns {Boolean} whether or not the wizard can navigate forward. 263 | */ 264 | canNavigateForward () { 265 | const stepIdx = this.getCurrentStepIndex(); 266 | const steps = this.getSteps(); 267 | 268 | // If wizard is on the last step it's not possible to go forward. 269 | if (stepIdx === steps.length) return false; 270 | 271 | // Find all navigable steps ahead of the current step (i.e. steps that 272 | // aren't disabled). If there is at least one available future step, 273 | // then allow forward navigation. 274 | const availableFutureSteps = steps 275 | .filter((step, idx) => idx > stepIdx && !step.props.disabled); 276 | 277 | return availableFutureSteps.length > 0; 278 | } 279 | 280 | /** 281 | * @returns {Boolean} Whether or not the current wizard instance is the 282 | * active wizard within the set of all instantiated wizards. This is based 283 | * on its position on the wizard stack, where the active wizard is at The 284 | * bottom of the stack (newest). 285 | */ 286 | isActive () { 287 | const selfDepth = this._state.get('depth'); 288 | 289 | return (selfDepth + 1) === this.props.wizards.size; 290 | } 291 | 292 | // ---------------------------------- 293 | // Event Handlers 294 | // ---------------------------------- 295 | /** 296 | * Handler invoked when the user cancels a wizard (this can be called 297 | * through indirect means such as breadcrumb selection). Checks to see 298 | * whether the current step can be safely exited (just as in step-to-step 299 | * navigation), but does _not_ care if the current step is valid or not, 300 | * since the model will be thrown out. 301 | */ 302 | _onCancel () { 303 | if (this.isCurrentStepExitable()) { 304 | invariant( 305 | typeof this.props.onCancel === 'function', 306 | 'No onCancel method was provided to the wizard. Wizards will not ' + 307 | 'unmount themselves.' 308 | ); 309 | 310 | this.props.onCancel(); 311 | } 312 | } 313 | 314 | /** 315 | * Handler invoked when the user submits the wizard. Checks to see whether 316 | * the current step can be safely exited (similar to checks performed during 317 | * navigation attempts), and if all checks pass updates the internal model 318 | * and invoking the onSubmit handler provided through props. 319 | */ 320 | _onSubmit () { 321 | const finalModel = this.refs.step.state.model; 322 | 323 | // Verify that the current step is exitable 324 | if (!this.isCurrentStepExitable()) { 325 | return debug.warn( 326 | `Could not submit wizard because the current step is not exitable.` 327 | ); 328 | } 329 | 330 | // Verify that the current step is valid 331 | if (!this.isCurrentStepValid()) { 332 | return debug.warn( 333 | `Could not submit wizard because the current step is not valid.` 334 | ); 335 | } 336 | 337 | this._actions.setWizardModel(finalModel); 338 | this.props.onSubmit(finalModel.toJS()); 339 | } 340 | 341 | // ---------------------------------- 342 | // Rendering Logic 343 | // ---------------------------------- 344 | /** 345 | * Renders the active step component based on the "stepIndex" provided by 346 | * the global state for this wizard instance. Applies additional properties 347 | * to the target component for convenience, including: 348 | * ------------------------- 349 | * {ref} ref - React ref for internal use. 350 | * {Immutable.Map} model - current state of the tracked internal model, 351 | * originally supplied through props.model. 352 | * ------------------------- 353 | * @returns {React.Component} Active step component 354 | */ 355 | renderStepComponent (component) { 356 | invariant(component, 357 | 'No component found in this.props.children for the active step index.' 358 | ); 359 | 360 | return React.cloneElement(component, { 361 | ref : 'step', 362 | model : this._state.get('model') 363 | }); 364 | } 365 | 366 | /** 367 | * Renders the HalcyonBreadcrumbs component iff the current wizard instance 368 | * is the active wizard. 369 | * @returns {React.Component} HalcyonViewportFooter 370 | */ 371 | @activeWizardOnly 372 | renderBreadcrumbs () { 373 | return ; 374 | } 375 | 376 | /** 377 | * Renders the sidebar component iff the current wizard instance 378 | * is the active wizard. 379 | * @returns {DOM} Regular DOM element wrapping the HalcyonStepSelector. 380 | */ 381 | @activeWizardOnly 382 | renderSidebar () { 383 | return ( 384 | 386 | ); 387 | } 388 | 389 | /** 390 | * Renders the HalcyonViewportFooter component iff the current wizard instance 391 | * is the active wizard. 392 | * @returns {React.Component} HalcyonViewportFooter 393 | */ 394 | @activeWizardOnly 395 | renderViewportFooter () { 396 | const navigate = this.attemptToNavigateToIndex; 397 | const currIdx = this._state.get('stepIndex'); 398 | 399 | return ( 400 | 406 | ); 407 | } 408 | 409 | /** 410 | * Wizard now has a valid state attached to it, so we can render its current 411 | * step. However, due to UI requirements, we don't want to display redundant 412 | * step selectors/breadcrumbs/etc. if this is not the currently active wizard, 413 | * which is why the rendering logic for those components is separated into 414 | * smaller methods. 415 | * 416 | * NOTE: this differentation _cannot_ be separated in another render method 417 | * with different markup, because then subwizards would be re-rendered and 418 | * consequently lose their state (since state is tied to the component 419 | * instance). This should be investigated more thoroughly in the future. 420 | */ 421 | renderWizardReadyState () { 422 | const classes = classnames('halcyon-wizard', { 423 | 'halcyon-wizard--active' : this.isActive() 424 | }); 425 | 426 | return ( 427 |
428 | {this.renderSidebar()} 429 |
430 | {this.renderBreadcrumbs()} 431 |
432 | {this.renderStepComponent(this.getCurrentStep())} 433 |
434 | {this.renderViewportFooter()} 435 |
436 |
437 | ); 438 | } 439 | 440 | /** 441 | * Render a base container with the wizard state only once the state exists. 442 | * This is important because since the component is responsible for creating 443 | * its own state in the Halcyon wizards reducer, the first render cycle won't 444 | * have a valid state. 445 | */ 446 | render () { 447 | return ( 448 |
449 | {this._state && this.renderWizardReadyState()} 450 |
451 | ); 452 | } 453 | } 454 | 455 | export default connect(mapDispatchToProps)(HalcyonWizard); 456 | -------------------------------------------------------------------------------- /src/components/HalcyonWizardSidebar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HalcyonStepSelector from './HalcyonStepSelector'; 3 | 4 | export default class HalcyonWizardSidebar extends React.Component { 5 | static propTypes = { 6 | steps : React.PropTypes.oneOfType([ 7 | React.PropTypes.element, 8 | React.PropTypes.arrayOf(React.PropTypes.element) 9 | ]).isRequired, 10 | onSelect : React.PropTypes.func.isRequired 11 | } 12 | 13 | constructor () { 14 | super(); 15 | } 16 | 17 | render () { 18 | return ( 19 |
20 | 22 |
23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/_tests/HalcyonBreadCrumbs.spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import React from 'react'; 3 | import TestUtils from 'react-addons-test-utils'; 4 | import { HalcyonWizard } from '../../index'; 5 | import { createMockWizard, getStoreState } from './_mock-wizard'; 6 | import { 7 | HalcyonBreadcrumbs, 8 | default as ConnectedHalcyonBreadcrumbs 9 | } from '../HalcyonBreadcrumbs'; 10 | 11 | function renderComponent (component) { 12 | return TestUtils.renderIntoDocument(component) 13 | } 14 | 15 | function shallowRenderComponent (component, props = {}) { 16 | const shallowRenderer = TestUtils.createRenderer(); 17 | 18 | shallowRenderer.render(component); 19 | return shallowRenderer.getRenderOutput(); 20 | } 21 | 22 | // ------------------------------------ 23 | // Base HalcyonBreadcrumbs Component Tests 24 | // ------------------------------------ 25 | describe('(Component) HalcyonBreadcrumbs', function () { 26 | let component, rendered, defaultProps, wizards, MockWizard, 27 | _mockWizardCloseSpy, _mockWizardSubmitSpy; 28 | 29 | const shallowRenderWith = (props = {}) => { 30 | const mergedProps = {...defaultProps, ...props}; 31 | 32 | return shallowRenderComponent(); 33 | }; 34 | 35 | const renderWith = (props) => { 36 | const mergedProps = {...defaultProps, ...props}; 37 | 38 | return renderComponent(); 39 | }; 40 | 41 | beforeEach(function () { 42 | MockWizard = createMockWizard(); 43 | _mockWizardCloseSpy = sinon.spy(); 44 | _mockWizardSubmitSpy = sinon.spy(); 45 | 46 | const mockWizardProps = { 47 | model : { firstName : 'Michael' }, 48 | onClose : _mockWizardCloseSpy, 49 | onSubmit : _mockWizardSubmitSpy 50 | }; 51 | 52 | renderComponent(); 53 | wizards = getStoreState().halcyon; 54 | 55 | defaultProps = { wizards }; 56 | component = shallowRenderWith(); 57 | rendered = renderWith(); 58 | }); 59 | 60 | it('(meta) Halcyon reducer should have 1 instantiated wizard.', function () { 61 | expect(wizards.size).to.equal(1); 62 | }); 63 | 64 | describe('getBreadcrumbsForWizards()', function () { 65 | var breadcrumbs; 66 | 67 | beforeEach(function () { 68 | breadcrumbs = rendered.getBreadcrumbsForWizards(wizards); 69 | }); 70 | 71 | it('Should return a regular JavaScript array.', function () { 72 | expect(breadcrumbs).to.be.an('array'); 73 | }); 74 | 75 | it('Should return two breadcrumbs for each wizard.', function () { 76 | expect(breadcrumbs).to.have.length(wizards.size * 2); 77 | expect(breadcrumbs).to.have.length.gt(0); 78 | }); 79 | 80 | describe('Breadcrumb Data', function () { 81 | it('Should be an object.', function () { 82 | breadcrumbs.forEach(x => { 83 | expect(x).to.be.an('object'); 84 | }); 85 | }); 86 | 87 | it('Should have a property "instance"', function () { 88 | breadcrumbs.forEach(x => { 89 | expect(x).to.have.property('instance'); 90 | }); 91 | }); 92 | 93 | it('Should define "instance" as a CompositeComponent.', function () { 94 | breadcrumbs.forEach(x => { 95 | expect(TestUtils.isCompositeComponent(x.instance)).to.be.true; 96 | }); 97 | }); 98 | 99 | it('Should have a property "title"', function () { 100 | breadcrumbs.forEach(x => { 101 | expect(x).to.have.property('title'); 102 | }); 103 | }); 104 | 105 | it('Should define "title" as a string', function () { 106 | breadcrumbs.forEach(x => { 107 | expect(x.title).to.be.a('string'); 108 | }); 109 | }); 110 | }); 111 | 112 | describe('Breadcrumb Events', function () { 113 | it('Should not close the wizard of the clicked breadcrumb.', function () { 114 | _mockWizardCloseSpy.should.not.have.been.called; 115 | 116 | TestUtils.Simulate.click(breadcrumbs[0]); 117 | _mockWizardCloseSpy.should.not.have.been.called; 118 | expect(getStoreState().halcyon.size).to.equal(1); 119 | 120 | 121 | TestUtils.Simulate.click(breadcrumbs[1]); 122 | _mockWizardCloseSpy.should.not.have.been.called; 123 | expect(getStoreState().halcyon.size).to.equal(1); 124 | }); 125 | }); 126 | }); 127 | 128 | describe('(Lifecycle) Render', function () { 129 | 130 | it('Should render as an
    .', function () { 131 | expect(component.type).to.equal('ol'); 132 | }); 133 | }); 134 | }); 135 | 136 | // ------------------------------------ 137 | // Connected HalcyonBreadcrumbs Component Tests 138 | // ------------------------------------ 139 | describe('(Connected Component) HalcyonBreadcrumbs', function () { 140 | it('Should have a test.', function () { 141 | expect(true).to.be.true; 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /src/components/_tests/HalcyonDirectionalNavigation.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestUtils from 'react-addons-test-utils'; 3 | import HalcyonDirectionalNavigation from '../HalcyonDirectionalNavigation'; 4 | 5 | const defaultProps = { 6 | onNext : () => {}, 7 | onPrevious : () => {} 8 | }; 9 | 10 | function renderComponent (propOverrides) { 11 | const props = {...defaultProps, ...propOverrides}; 12 | 13 | return TestUtils.renderIntoDocument(); 14 | } 15 | 16 | function shallowRenderComponent (propOverrides) { 17 | const props = {...defaultProps, ...propOverrides}; 18 | 19 | const renderer = TestUtils.createRenderer(); 20 | renderer.render(); 21 | return renderer.getRenderOutput(); 22 | } 23 | 24 | describe('(Component) HalcyonDirectionalNavigation', function () { 25 | let component, rendered; 26 | 27 | beforeEach(function () { 28 | component = shallowRenderComponent({}); 29 | rendered = renderComponent({}); 30 | }); 31 | 32 | describe('(Lifecycle) Render', function () { 33 | it('Should render as a div.', function () { 34 | expect(component.type).to.equal('div'); 35 | }); 36 | 37 | it('Should have a classname with "halcyon__directional-navigation".', function () { 38 | expect(component.props.className).to.match(/halcyon__directional-navigation/); 39 | }); 40 | 41 | describe('Previous Button', function () { 42 | const findPreviousButton = (tree) => { 43 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(tree, 'button'); 44 | 45 | return buttons.filter(b => b.textContent.match(/Previous/))[0]; 46 | }; 47 | 48 | it('Should be a DOM component.', function () { 49 | const previous = findPreviousButton(rendered); 50 | 51 | expect(TestUtils.isDOMComponent(previous)).to.be.true; 52 | }); 53 | 54 | it('Should be enabled by default.', function () { 55 | const previous = findPreviousButton(rendered); 56 | 57 | expect(previous.attributes.disabled).to.be.undefined; 58 | }); 59 | 60 | it('Should be disabled if this.props.disablePrevious is true.', function () { 61 | const previous = findPreviousButton(renderComponent({ 62 | disablePrevious : true 63 | })); 64 | 65 | expect(previous.attributes.disabled).to.exist; 66 | }); 67 | 68 | it('Should be disabled if this.props.disabled is true.', function () { 69 | const previous = findPreviousButton(renderComponent({ 70 | disabled : true 71 | })); 72 | 73 | expect(previous.attributes.disabled).to.exist; 74 | }); 75 | 76 | it('Should call props.onPrevious on click.', function () { 77 | var clicked = false; 78 | const previous = findPreviousButton(renderComponent({ 79 | onPrevious : () => { clicked = true } 80 | })); 81 | 82 | expect(clicked).to.be.false; 83 | TestUtils.Simulate.click(previous); 84 | expect(clicked).to.be.true; 85 | }); 86 | 87 | it('Should not be clickable if disabled.', function () { 88 | var clicked = false; 89 | const previous = findPreviousButton(renderComponent({ 90 | disabled : true, 91 | onPrevious : () => { clicked = true } 92 | })); 93 | 94 | expect(clicked).to.be.false; 95 | TestUtils.Simulate.click(previous); 96 | expect(clicked).to.be.false; 97 | }); 98 | }); 99 | 100 | describe('Next Button', function () { 101 | const findNextButton = (tree) => { 102 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(tree, 'button'); 103 | 104 | return buttons.filter(b => b.textContent.match(/Next/))[0]; 105 | }; 106 | 107 | it('Should be a DOM component.', function () { 108 | const next = findNextButton(rendered); 109 | 110 | expect(TestUtils.isDOMComponent(next)).to.be.true; 111 | }); 112 | 113 | it('Should be enabled by default.', function () { 114 | const next = findNextButton(rendered); 115 | 116 | expect(next.attributes.disabled).to.be.undefined; 117 | }); 118 | 119 | it('Should be disabled if this.props.disableNext is true.', function () { 120 | const next = findNextButton(renderComponent({ 121 | disableNext : true 122 | })); 123 | 124 | expect(next.attributes.disabled).to.exist; 125 | }); 126 | 127 | it('Should be disabled if this.props.disabled is true.', function () { 128 | const next = findNextButton(renderComponent({ 129 | disabled : true 130 | })); 131 | 132 | expect(next.attributes.disabled).to.exist; 133 | }); 134 | 135 | it('Should call props.onNext on click.', function () { 136 | var clicked = false; 137 | const next = findNextButton(renderComponent({ 138 | onNext : () => { clicked = true; } 139 | })); 140 | 141 | expect(clicked).to.be.false; 142 | TestUtils.Simulate.click(next); 143 | expect(clicked).to.be.true; 144 | }); 145 | 146 | it('Should not be clickable if disabled.', function () { 147 | var clicked = false; 148 | const next = findNextButton(renderComponent({ 149 | disabled : true, 150 | onNext : () => { clicked = true; } 151 | })); 152 | 153 | expect(clicked).to.be.false; 154 | TestUtils.Simulate.click(next); 155 | expect(clicked).to.be.false; 156 | }); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/components/_tests/HalcyonStepSelector.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestUtils from 'react-addons-test-utils'; 3 | import HalcyonStepSelector from '../HalcyonStepSelector'; 4 | 5 | const onSelect = () => {}; 6 | const defaultProps = { 7 | steps : [ 8 | { props : { title : 'A' }}, 9 | { props : { title : 'B' }} 10 | ], 11 | onSelect : onSelect 12 | }; 13 | 14 | function renderComponent (propOverrides) { 15 | const props = {...defaultProps, ...propOverrides}; 16 | 17 | return TestUtils.renderIntoDocument(); 18 | } 19 | 20 | function shallowRenderComponent (propOverrides) { 21 | const props = {...defaultProps, ...propOverrides}; 22 | 23 | const renderer = TestUtils.createRenderer(); 24 | renderer.render(); 25 | return renderer.getRenderOutput(); 26 | } 27 | 28 | describe('(Component) HalcyonStepSelector', function () { 29 | let component, rendered; 30 | 31 | beforeEach(function () { 32 | component = shallowRenderComponent({}); 33 | rendered = renderComponent({}); 34 | }); 35 | 36 | describe('(Lifecycle) Render', function () { 37 | it('Should render to the DOM.', function () { 38 | expect(rendered).to.exist; 39 | }); 40 | 41 | it('Should render as an
      .', function () { 42 | expect(component.type).to.equal('ol'); 43 | }); 44 | 45 | it('Should render a direct child component for each step.', function () { 46 | expect(component.props.children).to.have.length(2); 47 | }); 48 | 49 | describe('Step Title Card', function () { 50 | let stepComponents, renderedSteps; 51 | 52 | beforeEach(function () { 53 | stepComponents = component.props.children; 54 | renderedSteps = TestUtils.scryRenderedDOMComponentsWithClass(rendered, 'halcyon-step-selector__card'); 55 | }); 56 | 57 | it('Should render as a
      .', function () { 58 | stepComponents.forEach(node => { 59 | expect(node.type).to.equal('div'); 60 | }); 61 | }); 62 | 63 | it('Should have a numerical attribute "key" matching the step index.', function () { 64 | stepComponents.forEach((node, idx) => { 65 | expect(+node.key).to.equal(idx); 66 | }); 67 | }); 68 | 69 | it('Should have a bound onClick handler.', function () { 70 | stepComponents.forEach(node => { 71 | expect(node.props.onClick).to.be.a('function'); 72 | }); 73 | }); 74 | 75 | it('Should call props.onSelect when clicked.', function () { 76 | let clicked = 0; 77 | 78 | const customRender = renderComponent({ 79 | onSelect : () => clicked++ 80 | }); 81 | const customSteps = TestUtils.scryRenderedDOMComponentsWithClass( 82 | customRender, 'halcyon-step-selector__card' 83 | ); 84 | 85 | customSteps.forEach((node, idx) => { 86 | TestUtils.Simulate.click(node); 87 | expect(clicked).to.equal(idx + 1); 88 | }); 89 | 90 | expect(clicked).to.be.gt(0); 91 | expect(clicked).to.equal(stepComponents.length); 92 | }); 93 | 94 | it('Should render each step\'s "title" property in an h3.', function () { 95 | const titleNodes = TestUtils.scryRenderedDOMComponentsWithTag(rendered, 'h3'); 96 | 97 | expect(titleNodes).to.exist; 98 | expect(titleNodes).to.not.be.empty; 99 | 100 | titleNodes.forEach((node, idx) => { 101 | expect(node.textContent).to.equal(defaultProps.steps[idx].props.title); 102 | }); 103 | }); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/components/_tests/HalcyonViewportFooter.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestUtils from 'react-addons-test-utils'; 3 | import HalcyonViewportFooter from '../HalcyonViewportFooter'; 4 | import HalcyonDirectionalNavigation from '../HalcyonDirectionalNavigation'; 5 | 6 | function renderComponent (props) { 7 | return TestUtils.renderIntoDocument(); 8 | } 9 | 10 | function shallowRenderComponent (props) { 11 | const renderer = TestUtils.createRenderer(); 12 | 13 | renderer.render(); 14 | return renderer.getRenderOutput(); 15 | } 16 | 17 | describe('(Component) HalcyonViewportFooter', function () { 18 | let clickedOnNext, clickedOnPrevious, clickedOnCancel, clickedOnSubmit, 19 | component, rendered; 20 | 21 | beforeEach(function () { 22 | clickedOnNext = clickedOnPrevious = clickedOnCancel = clickedOnSubmit = 0; 23 | 24 | const defaultProps = { 25 | onNext : () => clickedOnNext++, 26 | onPrevious : () => clickedOnPrevious++, 27 | onCancel : () => clickedOnCancel++, 28 | onSubmit : () => clickedOnSubmit++ 29 | }; 30 | 31 | component = shallowRenderComponent(defaultProps); 32 | rendered = renderComponent(defaultProps); 33 | }); 34 | 35 | describe('(Lifecycle) Render', function () { 36 | it('Should render to the DOM.', function () { 37 | expect(rendered).to.exist; 38 | }); 39 | 40 | it('Should render as a
      .', function () { 41 | expect(component.type).to.equal('div'); 42 | }); 43 | 44 | it('Should render a HalcyonDirectionalNavigation component.', function () { 45 | const nav = TestUtils.findRenderedComponentWithType( 46 | rendered, HalcyonDirectionalNavigation 47 | ); 48 | 49 | expect(nav).to.exist; 50 | }); 51 | 52 | describe('Cancel Button', function () { 53 | let cancel; 54 | 55 | beforeEach(function () { 56 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag( 57 | rendered, 'button' 58 | ); 59 | 60 | cancel = buttons.filter(b => b.textContent.match(/Cancel/))[0]; 61 | }); 62 | 63 | it('Should render a cancel button in the DOM.', function () { 64 | expect(cancel).to.exist; 65 | expect(TestUtils.isDOMComponent(cancel)).to.be.true; 66 | }); 67 | 68 | it('Should call props.onCancel when clicked.', function () { 69 | expect(clickedOnCancel).to.equal(0); 70 | TestUtils.Simulate.click(cancel); 71 | expect(clickedOnCancel).to.equal(1); 72 | }); 73 | }); 74 | 75 | describe('Submit Button', function () { 76 | let submit; 77 | 78 | beforeEach(function () { 79 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag( 80 | rendered, 'button' 81 | ); 82 | 83 | submit = buttons.filter(b => b.textContent.match(/Submit/))[0]; 84 | }); 85 | 86 | it('Should render a submit button in the DOM.', function () { 87 | expect(submit).to.exist; 88 | expect(TestUtils.isDOMComponent(submit)).to.be.true; 89 | }); 90 | 91 | it('Should be disabled by default.', function () { 92 | expect(submit.attributes.disabled).to.exist; 93 | }); 94 | 95 | it('Should not call props.onSubmit when disabled and clicked.', function () { 96 | expect(clickedOnSubmit).to.equal(0); 97 | TestUtils.Simulate.click(submit); 98 | expect(clickedOnSubmit).to.equal(0); 99 | }); 100 | 101 | it('Should be enabled when props.disableNext is true.', function () { 102 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag( 103 | renderComponent({ 104 | onNext : () => {}, 105 | onPrevious : () => {}, 106 | onCancel : () => {}, 107 | onSubmit : () => {}, 108 | disableNext : true 109 | }), 110 | 'button' 111 | ); 112 | 113 | submit = buttons.filter(b => b.textContent.match(/Submit/))[0]; 114 | expect(submit.attributes.disabled).to.not.exist; 115 | }); 116 | 117 | it('Should called props.onSubmit when enabled and clicked.', function () { 118 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag( 119 | renderComponent({ 120 | onNext : () => {}, 121 | onPrevious : () => {}, 122 | onCancel : () => {}, 123 | onSubmit : () => clickedOnSubmit++, 124 | disableNext : true 125 | }), 126 | 'button' 127 | ); 128 | 129 | submit = buttons.filter(b => b.textContent.match(/Submit/))[0]; 130 | expect(clickedOnSubmit).to.equal(0); 131 | TestUtils.Simulate.click(submit); 132 | expect(clickedOnSubmit).to.equal(1); 133 | }); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/components/_tests/HalcyonWizard.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Immutable from 'immutable'; 3 | import TestUtils from 'react-addons-test-utils'; 4 | import * as wizardActions from '../../actions/wizard'; 5 | import { createMockWizard, getStoreState } from './_mock-wizard'; 6 | import { 7 | activeWizardOnly, 8 | HalcyonWizard, 9 | default as ConnectedHalcyonWizard 10 | } from '../HalcyonWizard'; 11 | 12 | // ------------------------------------ 13 | // Base HalcyonWizard Component Tests 14 | // ------------------------------------ 15 | describe('(Component) HalcyonWizard', function () { 16 | let component, rendered, spies; 17 | 18 | 19 | beforeEach(function () { 20 | const shallowRenderer = TestUtils.createRenderer(); 21 | const model = { foo : 'bar' }; 22 | 23 | spies = {}; 24 | props = { 25 | children : ['A', 'B'].map(x =>

      {x}

      ), 26 | dispatch : (spies.dispatch = sinon.spy()), 27 | onSubmit : (spies.onSubmit = sinon.spy()), 28 | onCancel : (spies.onCancel = sinon.spy()), 29 | model : model, 30 | wizards : Immutable.List() 31 | }; 32 | 33 | shallowRenderer.render(); 34 | component = shallowRenderer.getRenderOutput(); 35 | rendered = TestUtils.renderIntoDocument(); 36 | 37 | // force reset spies 38 | for (prop in spies) { spies[prop].reset(); } 39 | }); 40 | 41 | 42 | describe('(Method) bindActionCreatorsToSelf', function () { 43 | let _this, fn1, fn2; 44 | 45 | beforeEach(function () { 46 | spies.dispatch.reset(); 47 | fn1 = sinon.spy(); 48 | fn2 = sinon.spy(); 49 | _this = { props : { dispatch : spies.dispatch } }; 50 | 51 | rendered.bindActionCreatorsToSelf.call(_this, { 52 | fn1, fn2 53 | }); 54 | }); 55 | 56 | it('Should create an object "_actions" on the instance.', function () { 57 | expect(_this._actions).to.be.an('object'); 58 | }); 59 | 60 | it('Should have properties for each function on "_actions".', function () { 61 | expect(_this._actions.fn1).to.be.a('function'); 62 | expect(_this._actions.fn2).to.be.a('function'); 63 | }); 64 | 65 | it('Should call dispatch when an action is invoked.', function () { 66 | spies.dispatch.should.not.have.been.called; 67 | 68 | _this._actions.fn1(); 69 | spies.dispatch.should.have.been.calledOnce; 70 | _this._actions.fn2(); 71 | spies.dispatch.should.have.been.calledTwice; 72 | _this._actions.fn1(); 73 | spies.dispatch.should.have.been.calledThrice; 74 | }); 75 | 76 | it('Should call action with its first argument as "this".', function () { 77 | _this._actions.fn1(); 78 | fn1.should.have.been.calledWith(_this); 79 | }); 80 | 81 | it('Should accept aditional arguments after "this".', function () { 82 | _this._actions.fn1(); 83 | fn1.should.have.been.calledWith(_this); 84 | 85 | _this._actions.fn1(1); 86 | fn1.should.have.been.calledWith(_this, 1); 87 | 88 | _this._actions.fn1(1, 2, 3); 89 | fn1.should.have.been.calledWith(_this, 1, 2, 3); 90 | }); 91 | 92 | describe('Default Actions', function () { 93 | 94 | it('Should have actions automatically bound after mounting.', function () { 95 | expect(rendered._actions).to.be.an('object'); 96 | }); 97 | 98 | it('Should bind an action for "createWizard".', function () { 99 | expect(rendered._actions.createWizard).to.be.a('function'); 100 | }); 101 | 102 | it('Should bind an action for "destroyWizard".', function () { 103 | expect(rendered._actions.destroyWizard).to.be.a('function'); 104 | }); 105 | 106 | it('Should bind an action for "changeWizardStep".', function () { 107 | expect(rendered._actions.changeWizardStep).to.be.a('function'); 108 | }); 109 | 110 | it('Should bind an action for "setWizardModel".', function () { 111 | expect(rendered._actions.setWizardModel).to.be.a('function'); 112 | }); 113 | 114 | it('Should bind all actions from wizardActions.', function () { 115 | for (let prop in wizardActions) { 116 | expect(rendered._actions[prop]).to.be.a('function'); 117 | } 118 | }); 119 | 120 | it('Should dispatch action when action method is invoked.', function () { 121 | for (let prop in rendered._actions) { 122 | spies.dispatch.reset(); 123 | rendered._actions[prop](); 124 | spies.dispatch.should.have.been.calledOnce; 125 | } 126 | }); 127 | }); 128 | }); 129 | 130 | describe('(Deorator) activeWizardOnly', function () { 131 | let _instance; 132 | const fn = sinon.spy(); 133 | 134 | beforeEach(function () { 135 | class Foo { 136 | @activeWizardOnly 137 | test () { fn(); return 'hello'; } 138 | } 139 | 140 | fn.reset(); 141 | _instance = new Foo(); 142 | }); 143 | 144 | it('Should return a function.', function () { 145 | expect(_instance.test).to.be.a('function'); 146 | }); 147 | 148 | it('Should return null if "this.isActive()" returns false.', function () { 149 | _instance.isActive = () => false; 150 | 151 | expect(_instance.test()).to.equal(null); 152 | }); 153 | 154 | it('Should not call the original method if "this.isActive() returns false".', function () { 155 | _instance.isActive = () => false; 156 | 157 | _instance.test(); 158 | fn.should.not.have.been.called; 159 | }); 160 | 161 | it('Should call the original method if "this.isActive() returns true".', function () { 162 | _instance.isActive = () => true; 163 | 164 | _instance.test(); 165 | fn.should.have.been.calledOnce; 166 | }); 167 | 168 | 169 | it('Should return the result of the original method if "this.isActive()" returns true.', function () { 170 | _instance.isActive = () => true; 171 | 172 | expect(_instance.test()).equal('hello'); 173 | }); 174 | }); 175 | 176 | describe('(Method) setModel', function () { 177 | it('Should dispatch an action.', function () { 178 | spies.dispatch.should.not.have.been.called; 179 | 180 | rendered.setModel(); 181 | 182 | const args = spies.dispatch.args[0]; 183 | expect(args[0]).to.be.an('object'); 184 | expect(args[0]).to.have.property('type'); 185 | expect(args[0]).to.have.property('payload'); 186 | spies.dispatch.should.have.been.called; 187 | }); 188 | 189 | it('Should dispatch an action of type "HALCYON_WIZARD_SET_MODEL".', function () { 190 | rendered.setModel(); 191 | 192 | const args = spies.dispatch.args[0]; 193 | expect(args[0]).to.have.property('type', 'HALCYON_WIZARD_SET_MODEL'); 194 | }); 195 | 196 | it('Should convert a POJO into an Immutable object.', function () { 197 | let args; 198 | 199 | // Verify conversion from {} -> Immutable.Map 200 | rendered.setModel({}); 201 | 202 | args = spies.dispatch.args[0]; 203 | expect(Immutable.Map.isMap(args[0].payload.model)).to.be.true; 204 | 205 | // Verify conversion from [] -> Immutable.List 206 | spies.dispatch.reset(); 207 | rendered.setModel([]); 208 | 209 | args = spies.dispatch.args[0]; 210 | expect(Immutable.List.isList(args[0].payload.model)).to.be.true; 211 | }); 212 | }); 213 | 214 | describe('(Lifecycle) renderBreadcrumbs', function () { 215 | 216 | it('Should check the wizard active state.', function () { 217 | let isActive = rendered.isActive = sinon.spy(); 218 | 219 | isActive.should.not.have.been.called; 220 | rendered.renderBreadcrumbs(); 221 | isActive.should.have.been.called; 222 | }); 223 | 224 | it('Should return null if the wizard is inactive.', function () { 225 | rendered.isActive = () => false; 226 | 227 | const result = rendered.renderBreadcrumbs(); 228 | expect(result).to.be.null; 229 | }); 230 | }); 231 | 232 | describe('(Lifecycle) renderSidebar', function () { 233 | 234 | it('Should check the wizard active state.', function () { 235 | let isActive = rendered.isActive = sinon.spy(); 236 | 237 | isActive.should.not.have.been.called; 238 | rendered.renderSidebar(); 239 | isActive.should.have.been.called; 240 | }); 241 | 242 | it('Should return null if the wizard is inactive.', function () { 243 | rendered.isActive = () => false; 244 | 245 | const result = rendered.renderSidebar(); 246 | expect(result).to.be.null; 247 | }); 248 | }); 249 | 250 | describe('(Lifecycle) renderViewportFooter', function () { 251 | 252 | it('Should check the wizard active state.', function () { 253 | let isActive = rendered.isActive = sinon.spy(); 254 | 255 | isActive.should.not.have.been.called; 256 | rendered.renderViewportFooter(); 257 | isActive.should.have.been.called; 258 | }); 259 | 260 | it('Should return null if the wizard is inactive.', function () { 261 | rendered.isActive = () => false; 262 | 263 | const result = rendered.renderViewportFooter(); 264 | expect(result).to.be.null; 265 | }); 266 | }); 267 | 268 | describe('(Lifecycle) Render', function () { 269 | it('Should render as a
      .', function () { 270 | expect(component.type).to.equal('div'); 271 | }); 272 | }); 273 | }); 274 | 275 | // ------------------------------------ 276 | // Connected HalcyonWizard Tests 277 | // ------------------------------------ 278 | describe('(Connected Component) HalcyonWizard', function () { 279 | let _rendered, _component, _props, _spies, _model; 280 | 281 | beforeEach(function () { 282 | const MockWizard = createMockWizard(); 283 | 284 | _spies = {}; 285 | _props = { 286 | dispatch : (_spies.dispatch = sinon.spy()), 287 | onSubmit : (_spies.onSubmit = sinon.spy()), 288 | onCancel : (_spies.onCancel = sinon.spy()), 289 | model : (_model = { foo : 'bar' }) 290 | }; 291 | 292 | const container = TestUtils.renderIntoDocument(); 293 | _rendered = TestUtils.findRenderedComponentWithType(container, HalcyonWizard); 294 | }); 295 | 296 | it('Should create a wizard entry in the global Halcyon state.', function () { 297 | const state = getStoreState().halcyon; 298 | 299 | expect(state.size).to.equal(1); 300 | }); 301 | 302 | it('Should have a local state reference that matches its global state.', function () { 303 | const state = getStoreState().halcyon; 304 | 305 | expect(Immutable.Map.isMap(_rendered._state)).to.be.true; 306 | expect(_rendered._state).to.equal(getStoreState().halcyon.first()); 307 | }); 308 | 309 | it('Should be active.', function () { 310 | expect(_rendered.isActive()).to.be.true; 311 | }); 312 | 313 | describe('(Lifecycle) Render', function () { 314 | it('(meta) should have two steps.', function () { 315 | expect(_rendered.props.children).to.have.length(2); 316 | }); 317 | 318 | it('Should render with an active class.', function () { 319 | const active = TestUtils.findRenderedDOMComponentWithClass(_rendered, 'halcyon-wizard--active'); 320 | 321 | expect(active).to.exist; 322 | }); 323 | 324 | describe('(Viewport) Step', function () { 325 | it('Should exist.', function () { 326 | const vp = TestUtils.findRenderedDOMComponentWithClass( 327 | _rendered, 'halcyon-wizard__viewport__step' 328 | ); 329 | 330 | expect(vp).to.exist; 331 | }); 332 | }); 333 | 334 | describe('(Button) Previous', function () { 335 | let _prev; 336 | 337 | beforeEach(function () { 338 | const btns = TestUtils.scryRenderedDOMComponentsWithTag(_rendered, 'button'); 339 | _prev = btns.filter(btn => /Previous/.test(btn.textContent))[0]; 340 | }); 341 | 342 | it('Should exist.', function () { 343 | expect(_prev).to.exist; 344 | }); 345 | 346 | it('Should be disabled when wizard cannot navigate backward.', function () { 347 | _rendered.canNavigateBackward = () => false; 348 | 349 | _rendered.forceUpdate(); 350 | expect(_prev.attributes.disabled).to.exist; 351 | }); 352 | 353 | it('Should be enabled when wizard can navigate backward.', function () { 354 | _rendered.canNavigateBackward = () => true; 355 | 356 | _rendered.forceUpdate(); 357 | expect(_prev.attributes.disabled).to.not.exist; 358 | }); 359 | }); 360 | 361 | describe('(Button) Next', function () { 362 | let _next; 363 | 364 | beforeEach(function () { 365 | const btns = TestUtils.scryRenderedDOMComponentsWithTag(_rendered, 'button'); 366 | _next = btns.filter(btn => /Next/.test(btn.textContent))[0]; 367 | }); 368 | 369 | it('Should exist.', function () { 370 | expect(_next).to.exist; 371 | }); 372 | 373 | it('Should be disabled when wizard cannot navigate forward.', function () { 374 | _rendered.canNavigateForward = () => false; 375 | 376 | _rendered.forceUpdate(); 377 | expect(_next.attributes.disabled).to.exist; 378 | }); 379 | 380 | it('Should be enabled when wizard can navigate forward.', function () { 381 | _rendered.canNavigateForward = () => true; 382 | 383 | _rendered.forceUpdate(); 384 | expect(_next.attributes.disabled).to.not.exist; 385 | }); 386 | }); 387 | }); 388 | }); 389 | -------------------------------------------------------------------------------- /src/components/_tests/HalcyonWizardSidebar.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestUtils from 'react-addons-test-utils'; 3 | import HalcyonWizardSidebar from '../HalcyonWizardSidebar'; 4 | 5 | describe('(Component) HalcyonWizardSidebar', function () { 6 | let _rendered, _component, _spies; 7 | 8 | beforeEach(function () { 9 | _spies = {}; 10 | const props = { 11 | onSelect : (_spies.onSelect = sinon.spy()), 12 | steps : ['A', 'B'].map(x =>

      {x}

      ) 13 | }; 14 | 15 | 16 | const shallowRenderer = TestUtils.createRenderer(); 17 | shallowRenderer.render(); 18 | _component = shallowRenderer.getRenderOutput(); 19 | 20 | _rendered = TestUtils.renderIntoDocument( 21 | 22 | ); 23 | }); 24 | 25 | it('Should render as a
      .', function () { 26 | expect(_component).to.exist; 27 | expect(_component.type).to.equal('div'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/_tests/_mock-wizard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { combineReducers, createStore } from 'redux'; 4 | import { HalcyonWizard } from '../../index'; 5 | import * as reducers from '../../reducers'; 6 | import EditUserInfoStep from '../../../example/steps/EditUserInfoStep'; 7 | import EditUserFriendsStep from '../../../example/steps/EditUserFriendsStep'; 8 | 9 | var _store; 10 | 11 | export function getStoreState () { 12 | return _store.getState(); 13 | } 14 | 15 | export function createMockWizard () { 16 | _store = createStore(combineReducers(reducers)); 17 | 18 | return class Root extends React.Component { 19 | constructor () { 20 | super(); 21 | } 22 | 23 | render () { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/constants/_create.js: -------------------------------------------------------------------------------- 1 | export default function createConstants (...constants) { 2 | return constants.reduce((acc, constant) => { 3 | acc[constant] = constant; 4 | return acc; 5 | }, {}); 6 | } 7 | -------------------------------------------------------------------------------- /src/constants/wizard.js: -------------------------------------------------------------------------------- 1 | import createConstants from './_create'; 2 | 3 | export default createConstants( 4 | 'HALCYON_WIZARD_CREATE', 5 | 'HALCYON_WIZARD_DESTROY', 6 | 'HALCYON_WIZARD_SET_MODEL', 7 | 'HALCYON_WIZARD_STEP_CHANGE', 8 | 'HALCYON_WIZARD_OPEN_INSTANCE' 9 | ); 10 | -------------------------------------------------------------------------------- /src/decorators/HalcyonStep.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Immutable from 'immutable'; 3 | import invariant from 'invariant'; 4 | 5 | function createStepComponent (name, Component) { 6 | return class HalcyonStep extends React.Component { 7 | static propTypes = { 8 | name : React.PropTypes.string 9 | } 10 | 11 | static defaultProps = { 12 | title : name 13 | } 14 | 15 | constructor () { 16 | super(); 17 | this.state = { 18 | dirty : false 19 | }; 20 | } 21 | 22 | /** 23 | * We don't want to mutate the canonincal (pure) Wizard model, so we 24 | * store it in local state. This provides the added benefit of a free 25 | * "undo changes" since we can reset to the wizard model if the local 26 | * model diverges. One additional benefit of this is that the wizard does 27 | * not have to worry about copying its model before sending it to the step, 28 | * thereby eliminating extra checks from the wizard when a step is cancelled. 29 | */ 30 | componentWillMount () { 31 | const { model } = this.props; 32 | 33 | invariant( 34 | Immutable.Map.isMap(model) || Immutable.List.isList(model), 35 | 'Model provided to step must be an Immutable.Map or Immutable.List' 36 | ); 37 | this.setState({ model }); 38 | } 39 | 40 | /** 41 | * Sets local dirty state based on whether or not the model has changed 42 | * from the previous state. 43 | */ 44 | componentDidUpdate (nextProps, nextState) { 45 | if ( 46 | !nextState.dirty && 47 | this.state.model !== nextState.model 48 | ) { 49 | this.setState({ dirty : true }); 50 | } 51 | } 52 | 53 | /** 54 | * @returns {Boolean} whether or not the current step is valid. If the 55 | * step component does not provide an `isStepValid` method, it will return 56 | * true by default. 57 | */ 58 | isStepValid () { 59 | if (typeof this.refs.step.isStepValid === 'function') { 60 | return this.refs.step.isStepValid(); 61 | } 62 | return true; 63 | } 64 | 65 | /** 66 | * @returns {Boolean} whether or not the current step can be safely exited. 67 | * If the step component does not provide a `shouldStepExit` method, it will 68 | * return true by default. Use this to prevent navigation away from steps 69 | * that are performing asynchronous operations or have some other limiting 70 | * factor that must complete before the step can safely exit. Note that 71 | * this is entirely separate from whether or not the step is valid. 72 | */ 73 | shouldStepExit () { 74 | if (typeof this.refs.step.shouldStepExit === 'function') { 75 | return this.refs.step.shouldStepExit(); 76 | } 77 | return true; 78 | } 79 | 80 | /** 81 | * @param {Immutable.Map|Immutable.List} model - updated model to apply 82 | * to the step. 83 | * 84 | * @returns {undefined} no return value, but produces a re-render. 85 | */ 86 | setModel (model) { 87 | this.setState({ model }); 88 | } 89 | 90 | /** 91 | * @param {String|Array} path - sequence of properties to access on the 92 | * model when setting the target value. 93 | * @param {any} value - value to apply to the target model property. 94 | * 95 | * @returns {undefined} no return value, but produces a re-render by 96 | * updating the internal model. 97 | */ 98 | setProperty (path, value) { 99 | const pathArr = Array.isArray(path) ? path : path.split('.'); 100 | 101 | this.setState({ 102 | model : this.state.model.setIn(pathArr, value) 103 | }); 104 | } 105 | 106 | /** 107 | * @param {String|Array} path - sequence of properties to access on the 108 | * model when getting/setting the target value. 109 | * 110 | * @returns {Object} properties to apply to an input element. 111 | * @returns {Object.value} - current value of the property on the model. 112 | * @returns {Object.onChange} - event handler that receives a synthetic 113 | * onChange event and applies the event's target value to the model. 114 | */ 115 | bindTo (path) { 116 | const pathArr = Array.isArray(path) ? path : path.split('.'); 117 | 118 | return { 119 | value : this.state.model.getIn(pathArr), 120 | onChange : (e) => this.setProperty(pathArr, e.target.value) 121 | }; 122 | } 123 | 124 | /** 125 | * Discards the any model modifications in the current step session by 126 | * re-applying the model received through props from the wizard component. 127 | * 128 | * @returns {undefined} no return value but produces a re-render. 129 | */ 130 | resetModel () { 131 | this.setState({ 132 | dirty : false, 133 | model : this.props.model 134 | }); 135 | } 136 | 137 | /** 138 | * @returns {DOM} renders a "reset model" button that will discard all 139 | * changes applied to the model during this step session. 140 | */ 141 | renderResetModelButton () { 142 | return ( 143 | 147 | ); 148 | } 149 | 150 | render () { 151 | return ( 152 |
      153 | 158 | {this.state.dirty && this.renderResetModelButton()} 159 |
      160 | ); 161 | } 162 | }; 163 | } 164 | 165 | export default function halcyonStepDecorator (name) { 166 | return function createHalcyonStep (Component) { 167 | return createStepComponent(name, Component); 168 | }; 169 | } 170 | -------------------------------------------------------------------------------- /src/decorators/_tests/HalcyonStep.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestUtils from 'react-addons-test-utils'; 3 | import Immutable from 'immutable'; 4 | import halcyonStep from '../HalcyonStep'; 5 | import { 6 | MockComponent, 7 | default as DecoratedMockComponent 8 | } from './MockComponent'; 9 | 10 | const SAMPLE_MODEL = Immutable.Map({ 11 | firstName : 'Michael', 12 | lastName : 'Scott' 13 | }); 14 | 15 | function renderIntoDocument (props) { 16 | return TestUtils.renderIntoDocument( 17 | 18 | ); 19 | } 20 | 21 | describe('(Decorator) halcyonStep', function () { 22 | 23 | describe('First Invocation', function () { 24 | it('Should return a function.', function () { 25 | expect(halcyonStep()).to.be.a.function; 26 | }); 27 | }); 28 | 29 | describe('Second Invocation', function () { 30 | it('Should return a React Component.', function () { 31 | const component = halcyonStep()(); 32 | 33 | expect().to.have.property('type', 'component'); 34 | }); 35 | }); 36 | 37 | describe('Decorated Component', function () { 38 | var rendered; 39 | 40 | beforeEach(function () { 41 | rendered = renderIntoDocument({ model : SAMPLE_MODEL }); 42 | 43 | const setState = rendered.setState.bind(rendered); 44 | var callCount = 0; 45 | 46 | rendered.awaitStateChange = function (cb) { 47 | rendered.setState = function (state) { 48 | setState(state, () => { 49 | callCount++; 50 | 51 | if (typeof cb === 'function') { 52 | cb(callCount); 53 | } else if (typeof cb === 'object') { 54 | cb[callCount] && cb[callCount](); 55 | } 56 | }); 57 | }; 58 | }; 59 | }); 60 | 61 | it('Should have a defaultProp title matching the first decorator\'s first argument.', function () { 62 | expect(DecoratedMockComponent.defaultProps).to.have.property('title', 'Mock Component Title'); 63 | rendered.setModel(SAMPLE_MODEL); 64 | }); 65 | 66 | describe('(Lifecycle) Initial State', function () { 67 | it('Should set "dirty" to false.', function () { 68 | expect(rendered.state.dirty).to.be.false; 69 | }); 70 | 71 | it('Should set "model" to the model received from props.', function () { 72 | expect(rendered.state.model).to.equal(SAMPLE_MODEL); 73 | }); 74 | }); 75 | 76 | describe('(Lifecycle) isStepValid', function () { 77 | it('Should be a function.', function () { 78 | expect(rendered.isStepValid).to.be.a('function'); 79 | }); 80 | 81 | it('Should return true by default.', function () { 82 | expect(rendered.isStepValid()).to.be.true; 83 | }); 84 | }); 85 | 86 | describe('(Lifecycle) shouldStepExit', function () { 87 | it('Should be a function.', function () { 88 | expect(rendered.shouldStepExit).to.be.a('function'); 89 | }); 90 | 91 | it('Should return true by default.', function () { 92 | expect(rendered.shouldStepExit()).to.be.true; 93 | }); 94 | }); 95 | 96 | describe('(Lifecycle) Render', function () { 97 | var findResetModelButton, component; 98 | 99 | beforeEach(function () { 100 | findResetModelButton = () => 101 | TestUtils.findRenderedDOMComponentWithClass(rendered, 'halcyon__step__reset'); 102 | 103 | 104 | component = TestUtils.findRenderedComponentWithType(rendered, MockComponent); 105 | }); 106 | 107 | it('Should be able to be rendered into the DOM.', function () { 108 | expect(rendered).to.exist; 109 | }); 110 | 111 | it('Should render without a reset model button if state.dirty is false.', function () { 112 | expect(findResetModelButton).to.throw; 113 | }); 114 | 115 | it('Should render with a reset model button if state.dirty is true.', function (done) { 116 | rendered.awaitStateChange({ 117 | 2 : () => { 118 | const button = findResetModelButton(); 119 | 120 | expect(TestUtils.isDOMComponent(button)).to.be.true; 121 | done(); 122 | } 123 | }); 124 | 125 | rendered.setModel(Immutable.Map({ fizz : 'buzz' })); 126 | }); 127 | 128 | it('Should render the base component as a child.', function () { 129 | expect(TestUtils.isCompositeComponent(component)).to.be.true; 130 | }); 131 | 132 | describe('Injected Properties', function () { 133 | it('Should inject an object "model" into the component.', function () { 134 | expect(component.props.model).to.be.an('object'); 135 | }); 136 | 137 | it('Should call ".toJS()" before injecting the model.', function () { 138 | expect(Immutable.Map.isMap(rendered.state.model)).to.be.true; 139 | expect(Immutable.Map.isMap(component.props.model)).to.be.false; 140 | }); 141 | 142 | it('Should inject a function "bindTo".', function () { 143 | expect(component.props.bindTo).to.be.a('function'); 144 | }); 145 | 146 | it('Should inject a function "setModel".', function () { 147 | expect(component.props.setModel).to.be.a('function'); 148 | }); 149 | 150 | it('Should inject a function "setProperty".', function () { 151 | expect(component.props.setProperty).to.be.a('function'); 152 | }); 153 | }); 154 | 155 | it('Should apply a ref of "step" to the base component.', function () { 156 | expect(rendered.refs.step).to.exist; 157 | expect(TestUtils.isCompositeComponent(rendered.refs.step)).to.be.true; 158 | expect(rendered.refs.step).to.equal(component); 159 | }); 160 | }); 161 | 162 | describe('(Method) bindTo', function () { 163 | it('Should be a function.', function () { 164 | expect(rendered).to.have.property('bindTo'); 165 | expect(rendered.bindTo).to.be.a('function'); 166 | }); 167 | 168 | it('Should throw if no argument is supplied.', function () { 169 | expect(rendered.bindTo).to.throw; 170 | }); 171 | 172 | it('Should return an object.', function () { 173 | expect(rendered.bindTo([])).to.be.an('object'); 174 | }); 175 | 176 | it('Should have return an object with a property "value".', function () { 177 | expect(rendered.bindTo([])).to.have.property('value'); 178 | }); 179 | 180 | it('Should return an object with a function "onChange".', function () { 181 | expect(rendered.bindTo([]).onChange).to.be.a('function'); 182 | }); 183 | }); 184 | 185 | describe('(Method) setProperty', function () { 186 | it('Should be a function.', function () { 187 | expect(rendered).to.have.property('setProperty'); 188 | expect(rendered.setProperty).to.be.a('function'); 189 | }); 190 | 191 | it('Should apply the new value to the target path on the internal state\'s model.', function (done) { 192 | rendered.awaitStateChange(function () { 193 | expect(rendered.state.model.get('firstName')).to.equal('Michael'); 194 | done(); 195 | }); 196 | 197 | rendered.setProperty('firstName', 'Michael'); 198 | }); 199 | 200 | it('Should accept path as a dot-delimited string.', function (done) { 201 | rendered.awaitStateChange(function () { 202 | const model = rendered.state.model.toJS(); 203 | 204 | expect(model.address.zipcode).to.equal('123456'); 205 | done(); 206 | }); 207 | 208 | rendered.setProperty('address.zipcode', '123456'); 209 | }); 210 | 211 | it('Should accept path as an array of property names.', function (done) { 212 | rendered.awaitStateChange(function () { 213 | const model = rendered.state.model.toJS(); 214 | 215 | expect(model.address.zipcode).to.equal('000123'); 216 | done(); 217 | }); 218 | 219 | rendered.setProperty(['address', 'zipcode'], '000123'); 220 | }); 221 | }); 222 | 223 | describe('(Method) setModel', function () { 224 | 225 | it('Should be a function.', function () { 226 | expect(rendered).to.have.property('setModel'); 227 | expect(rendered.setModel).to.be.a('function'); 228 | }); 229 | 230 | it('Should update "model" to the supplied argument.', function (done) { 231 | rendered.awaitStateChange(function () { 232 | expect(rendered.state.model).to.not.equal(SAMPLE_MODEL); 233 | done(); 234 | }); 235 | 236 | rendered.setModel(Immutable.Map({ foo : 'bar' })); 237 | }); 238 | 239 | it('Should apply a second state update if the model has changed.', function (done) { 240 | rendered.awaitStateChange(function (called) { 241 | if (called === 1) { 242 | expect(rendered.state.model).to.not.equal(SAMPLE_MODEL); 243 | } else if (called === 2) { 244 | expect(rendered.state.dirty).to.be.true; 245 | done(); 246 | } 247 | }); 248 | 249 | rendered.setModel(Immutable.Map({ foo : 'bar' })); 250 | }); 251 | 252 | it('Should set "dirty" state to true in the second state update.', function done() { 253 | rendered.awaitStateChange(function (called) { 254 | if (called === 1) { 255 | expect(rendered.state.model).to.not.equal(SAMPLE_MODEL); 256 | } else if (called === 2) { 257 | expect(rendered.state.dirty).to.be.true; 258 | done(); 259 | } 260 | }); 261 | 262 | rendered.setModel(Immutable.Map({ foo : 'bar' })); 263 | }); 264 | }); 265 | 266 | describe('(Method) resetModel', function () { 267 | it('Should reset this.state.model to match this.props.model.', function (done) { 268 | const newModel = Immutable.Map({ foo : 'bar' }); 269 | 270 | rendered.awaitStateChange({ 271 | 1 : () => { 272 | expect(rendered.state.model).to.not.equal(SAMPLE_MODEL); 273 | rendered.resetModel(); 274 | }, 275 | 2 : () => { 276 | expect(rendered.state.model).to.equal(SAMPLE_MODEL); 277 | done(); 278 | } 279 | }); 280 | 281 | expect(rendered.state.model).to.equal(SAMPLE_MODEL); 282 | rendered.setModel(Immutable.Map({ foo : 'bar' })); 283 | }); 284 | }); 285 | }); 286 | }); 287 | -------------------------------------------------------------------------------- /src/decorators/_tests/MockComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import halcyonStep from '../HalcyonStep'; 3 | 4 | export class MockComponent extends React.Component { 5 | constructor () { 6 | super(); 7 | } 8 | 9 | render () { 10 | return

      hello

      ; 11 | } 12 | } 13 | 14 | export default halcyonStep('Mock Component Title')(MockComponent); 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export { default as HalcyonWizard } from './components/HalcyonWizard'; 3 | export { default as halcyonStep } from './decorators/HalcyonStep'; 4 | export * as reducers from './reducers'; 5 | /* eslint-enable */ 6 | -------------------------------------------------------------------------------- /src/index.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as Halcyon from './index'; 3 | 4 | describe('(API) Halcyon', function () { 5 | it('Should export a function "HalcyonWizard".', function () { 6 | expect(Halcyon.HalcyonWizard).to.be.a('function'); 7 | }); 8 | 9 | it('Should export a function "halcyonStep".', function () { 10 | expect(Halcyon.halcyonStep).to.be.a('function'); 11 | }); 12 | 13 | it('Should export an object "reducers".', function () { 14 | expect(Halcyon.reducers).to.be.an('object'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/lib/component.js: -------------------------------------------------------------------------------- 1 | export function getComponentTitle (component) { 2 | if (component.props && component.props.title) { 3 | return component.props.title; 4 | } else if (component.type && component.type.name) { 5 | return component.type.name.replace(/([a-z](?=[A-Z]))/g, '$1 '); 6 | } else if (component.name) { 7 | return component.name.replace(/([a-z](?=[A-Z]))/g, '$1 '); 8 | } 9 | 10 | return 'UNNAMED COMPONENT'; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/debug.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export const log = console.log.bind(console); 3 | export const warn = console.warn.bind(console); 4 | export const error = console.error.bind(console); 5 | /* eslint-enable */ 6 | -------------------------------------------------------------------------------- /src/reducers/_tests/_mock-reducer.js: -------------------------------------------------------------------------------- 1 | var _state; 2 | 3 | export default function (reducer) { 4 | const runWithState = (state, action) => _state = reducer(state, action); 5 | 6 | return { 7 | run : (action) => runWithState(_state, action), 8 | runWithState : runWithState, 9 | resetState : runWithState.bind(null, undefined, {}), 10 | getState : () => _state 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/reducers/_tests/halcyon.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TestUtils from 'react-addons-test-utils'; 3 | import halcyonReducer from '../halcyon'; 4 | import Immutable from 'immutable'; 5 | import mockReducer from './_mock-reducer'; 6 | import { 7 | createWizard, 8 | destroyWizard, 9 | setWizardModel, 10 | changeWizardStep, 11 | openWizard 12 | } from '../../actions/wizard'; 13 | 14 | const reducer = mockReducer(halcyonReducer); 15 | 16 | describe('(Reducer) Halcyon', function () { 17 | 18 | beforeEach(function () { 19 | reducer.resetState(); 20 | }); 21 | 22 | describe('Initial State', function () { 23 | 24 | it('Should be an instance of Immutable.List.', function () { 25 | expect(Immutable.List.isList(reducer.getState())).to.be.true; 26 | }); 27 | 28 | it('Should be an empty list.', function () { 29 | expect(reducer.getState().size).to.equal(0); 30 | }); 31 | }); 32 | 33 | describe('HALCYON_WIZARD_CREATE', function () { 34 | 35 | it('Should return a new state reference.', function () { 36 | const before = reducer.getState(); 37 | const after = reducer.run(createWizard()); 38 | 39 | expect(before).to.not.equal(after); 40 | }); 41 | 42 | it('Should initialize the new entry as an Immutable.Map.', function () { 43 | const state = reducer.run(createWizard()); 44 | 45 | expect(Immutable.Map.isMap(state.get(0))).to.be.true; 46 | }); 47 | 48 | describe('Initialized Properties', function () { 49 | var _mockInstance, _mockModel, _item; 50 | 51 | beforeEach(function () { 52 | _mockInstance = {}; 53 | _mockModel = {}; 54 | 55 | _item = reducer.run(createWizard(_mockInstance, _mockModel)).first(); 56 | }); 57 | 58 | it('Should define a property "instance".', function () { 59 | expect(_item.get('instance')).to.exist; 60 | }); 61 | 62 | it('Should reference the "instance" property provided in the action payload.', function () { 63 | expect(_item.get('instance')).to.equal(_mockInstance); 64 | }); 65 | 66 | it('Should define a property "stepIndex".', function () { 67 | expect(_item.get('stepIndex')).to.exist; 68 | }); 69 | 70 | it('Should initialize the property "stepIndex" to 0.', function () { 71 | expect(_item.get('stepIndex')).to.be.a.number; 72 | expect(_item.get('stepIndex')).to.equal(0); 73 | }); 74 | 75 | it('Should define a property "depth".', function () { 76 | expect(_item.get('depth')).to.exist; 77 | }); 78 | 79 | it('Should define the property depth as item\'s index in the list of wizards.', function () { 80 | expect(_item.get('depth')).to.equal(0); 81 | 82 | expect(reducer.run(createWizard()).get(1).get('depth')).to.equal(1); 83 | expect(reducer.run(createWizard()).get(2).get('depth')).to.equal(2); 84 | }); 85 | 86 | it('Should define a property "model".', function () { 87 | expect(_item.get('model')).to.exist; 88 | }); 89 | 90 | it('Should reference the "model" property provided in action payload.', function () { 91 | expect(_item.get('model')).to.equal(_mockModel); 92 | }); 93 | }); 94 | 95 | it('Should insert a new item in the current list of wizards.', function () { 96 | const before = reducer.getState(); 97 | const after = reducer.run(createWizard()); 98 | 99 | expect(after.size).to.equal(1); 100 | }); 101 | 102 | it('Should insert the new item to the back of the list.', function () { 103 | const first = reducer.run(createWizard()).first(); 104 | const afterSecondAdd = reducer.run(createWizard()); 105 | 106 | expect(afterSecondAdd.size).to.equal(2); 107 | expect(afterSecondAdd.get(1)).to.exist; 108 | expect(afterSecondAdd.get(0)).to.equal(first); 109 | expect(afterSecondAdd.get(1)).to.not.equal(first); 110 | }); 111 | }); 112 | 113 | describe('HALCYON_WIZARD_DESTROY', function () { 114 | var _a, _b, _c; // mock instances 115 | 116 | beforeEach(function () { 117 | reducer.run(createWizard(_a = {})); 118 | reducer.run(createWizard(_b = {})); 119 | reducer.run(createWizard(_c = {})); 120 | }); 121 | 122 | it('(meta) Should initialize test with 3 mock wizards.', function () { 123 | expect(reducer.getState().size).to.equal(3); 124 | }); 125 | 126 | it('Should return a new state reference.', function () { 127 | const before = reducer.getState(); 128 | 129 | expect(reducer.run(destroyWizard(_b))).to.not.equal(before); 130 | }); 131 | 132 | it('Should return the same state reference if the target instance is not found.', function () { 133 | const before = reducer.getState(); 134 | const after = reducer.run(destroyWizard()); 135 | 136 | expect(after.size).to.equal(3); 137 | expect(after).to.equal(before); 138 | }); 139 | 140 | it('Should delete the state list entry whose property "instance" matches the target instance.', function () { 141 | const before = reducer.getState(); 142 | const deletedA = reducer.run(destroyWizard(_a)); 143 | 144 | // Delete _a 145 | expect(deletedA.size).to.equal(2); 146 | expect(deletedA.find(w => w.get('instance') === _a)).to.be.undefined; 147 | expect(deletedA.find(w => w.get('instance') === _b)).to.exist; 148 | expect(deletedA.find(w => w.get('instance') === _c)).to.exist; 149 | 150 | // Delete _b 151 | const deletedB = reducer.run(destroyWizard(_b)); 152 | expect(deletedB.size).to.equal(1); 153 | expect(deletedB.find(w => w.get('instance') === _a)).to.be.undefined; 154 | expect(deletedB.find(w => w.get('instance') === _b)).to.be.undefined; 155 | expect(deletedB.find(w => w.get('instance') === _c)).to.exist; 156 | 157 | // Attempt to delete _b again 158 | const deleteBAgain = reducer.run(destroyWizard(_b)); 159 | expect(deleteBAgain.size).to.equal(1); 160 | expect(deleteBAgain.find(w => w.get('instance') === _a)).to.be.undefined; 161 | expect(deleteBAgain.find(w => w.get('instance') === _b)).to.be.undefined; 162 | expect(deleteBAgain.find(w => w.get('instance') === _c)).to.exist; 163 | }); 164 | 165 | }); 166 | 167 | describe('HALCYON_WIZARD_SET_MODEL', function () { 168 | var _a, _b, _c; // mock instances 169 | 170 | beforeEach(function () { 171 | reducer.run(createWizard(_a = {})); 172 | reducer.run(createWizard(_b = {})); 173 | reducer.run(createWizard(_c = {})); 174 | }); 175 | 176 | it('(meta) Should initialize test with 3 mock wizards.', function () { 177 | expect(reducer.getState().size).to.equal(3); 178 | }); 179 | 180 | it('Should set the supplied model on the entry that matches the target instance.', function () { 181 | const beforeModel = reducer.getState().first().get('model'); 182 | const newModel = {}; 183 | const afterModel = reducer.run(setWizardModel(_a, newModel)).first().get('model'); 184 | 185 | expect(afterModel).to.not.equal(beforeModel); 186 | expect(afterModel).to.equal(newModel); 187 | }); 188 | 189 | it('Should return a new state reference.', function () { 190 | const before = reducer.getState(); 191 | const after = reducer.run(setWizardModel(_a, {})); 192 | 193 | expect(before).to.not.equal(after); 194 | }); 195 | 196 | it('Should not affect the models of the other entries.', function () { 197 | 198 | // models from the old state 199 | const aModel = reducer.getState().get(0).get('model'); 200 | const bModel = reducer.getState().get(1).get('model'); 201 | const cModel = reducer.getState().get(2).get('model'); 202 | const newModel = {}; 203 | 204 | // models from the new state 205 | const after = reducer.run(setWizardModel(_a, newModel)); 206 | const afterAModel = after.get(0).get('model'); 207 | const afterBModel = after.get(1).get('model'); 208 | const afterCModel = after.get(2).get('model'); 209 | 210 | expect(afterAModel).to.equal(newModel); 211 | expect(afterAModel).to.not.equal(aModel); 212 | 213 | expect(afterBModel).to.equal(bModel); 214 | expect(afterCModel).to.equal(cModel); 215 | }); 216 | }); 217 | 218 | describe('HALCYON_WIZARD_STEP_CHANGE', function () { 219 | var _a, _b, _c; // mock instances 220 | 221 | beforeEach(function () { 222 | reducer.run(createWizard(_a = {})); 223 | reducer.run(createWizard(_b = {})); 224 | reducer.run(createWizard(_c = {})); 225 | }); 226 | 227 | it('(meta) Should initialize test with 3 mock wizards.', function () { 228 | expect(reducer.getState().size).to.equal(3); 229 | }); 230 | 231 | it('Should update the "stepIndex" property on the target instance to match "index" in the action payload.', function () { 232 | expect(reducer.run(changeWizardStep(_a, 1)).first().get('stepIndex')).to.equal(1); 233 | expect(reducer.run(changeWizardStep(_a, 2)).first().get('stepIndex')).to.equal(2); 234 | expect(reducer.run(changeWizardStep(_a, 0)).first().get('stepIndex')).to.equal(0); 235 | }); 236 | 237 | it('Should return a new state reference if the provided index is different from the current value.', function () { 238 | const before = reducer.getState(); 239 | const after = reducer.run(changeWizardStep(_a, 1)); 240 | 241 | expect(before).to.not.equal(after); 242 | }); 243 | 244 | it('Should not return a new state reference if the provided index is the same as the current value.', function () { 245 | const before = reducer.getState(); 246 | const after = reducer.run(changeWizardStep(_a, 0)); 247 | 248 | expect(before).to.equal(after); 249 | }); 250 | 251 | it('Should not affect the "stepIndex" property in the other entries.', function () { 252 | 253 | // indexes from the original state 254 | const beforeAIndex = reducer.getState().get(0).get('stepIndex'); 255 | const beforeBIndex = reducer.getState().get(1).get('stepIndex'); 256 | const beforeCIndex = reducer.getState().get(2).get('stepIndex'); 257 | const newAIndex = 2; 258 | 259 | // stepIndexes from the new state 260 | const after = reducer.run(changeWizardStep(_a, newAIndex)); 261 | const afterAIndex = after.get(0).get('stepIndex'); 262 | const afterBIndex = after.get(1).get('stepIndex'); 263 | const afterCIndex = after.get(2).get('stepIndex'); 264 | 265 | expect(afterAIndex).to.equal(newAIndex); 266 | expect(afterAIndex).to.not.equal(beforeAIndex); 267 | 268 | expect(afterBIndex).to.equal(beforeBIndex); 269 | expect(afterCIndex).to.equal(beforeCIndex); 270 | }); 271 | }); 272 | }); 273 | -------------------------------------------------------------------------------- /src/reducers/halcyon.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { 3 | HALCYON_WIZARD_CREATE, 4 | HALCYON_WIZARD_DESTROY, 5 | HALCYON_WIZARD_SET_MODEL, 6 | HALCYON_WIZARD_STEP_CHANGE, 7 | HALCYON_WIZARD_OPEN_INSTANCE 8 | } from '../constants/wizard'; 9 | 10 | const initialState = Immutable.List(); 11 | 12 | const actions = { 13 | /** 14 | * @param {Immutable.List} state - current reducer state. 15 | * @param {object} action - action descriptor. 16 | * @param {CompositeComponent} action.instance - target component instance. 17 | * @param {object} action.model - initial model for the wizard. 18 | * 19 | * @returns {Immutable.List} updated reducer state with the new wizard entry 20 | * appended to the current list of wizards. 21 | */ 22 | [HALCYON_WIZARD_CREATE] : (state, { instance, model }) => { 23 | const currentDepth = state.size; 24 | 25 | return state.push(Immutable.Map({ 26 | instance : instance, 27 | stepIndex : 0, 28 | depth : currentDepth, 29 | model : model 30 | })); 31 | }, 32 | 33 | /** 34 | * @param {Immutable.List} state - current reducer state. 35 | * @param {object} action - action descriptor. 36 | * @param {CompositeComponent} action.instance - target component instance. 37 | * 38 | * @returns {Immutable.List} updated reducer state without the target wizard. 39 | */ 40 | [HALCYON_WIZARD_DESTROY] : (state, { instance }) => { 41 | const idx = state.findIndex(w => w.get('instance') === instance); 42 | 43 | return idx >= 0 ? state.delete(idx) : state; 44 | }, 45 | 46 | /** 47 | * @param {Immutable.List} state - current reducer state. 48 | * @param {Object} action - action descriptor. 49 | * @param {Object} action.instance - target component instance. 50 | * @param {Immutable.Map} action.model - updated model for the target wizard instance. 51 | * 52 | * @returns {Immutable.List} updated reducer state where the target wizard's 53 | * active step has been updated to the target index. 54 | */ 55 | [HALCYON_WIZARD_SET_MODEL] : (state, { instance, model }) => { 56 | const wizardPos = state.findIndex(x => x.get('instance') === instance); 57 | const wizard = state.get(wizardPos); 58 | 59 | return state.set(wizardPos, wizard.set('model', model)); 60 | }, 61 | 62 | /** 63 | * @param {Immutable.List} state - current reducer state. 64 | * @param {object} action - action descriptor. 65 | * @param {CompositeComponent} action.instance - target component instance. 66 | * @param {number} action.index - new step index for target wizard. 67 | * 68 | * @returns {Immutable.List} updated reducer state where the target wizard's active 69 | * step has been updated to the target index. 70 | */ 71 | [HALCYON_WIZARD_STEP_CHANGE] : (state, { instance, index }) => { 72 | const wizardPos = state.findIndex(x => x.get('instance') === instance); 73 | const wizard = state.get(wizardPos); 74 | 75 | return state.set(wizardPos, wizard.set('stepIndex', index)); 76 | }, 77 | 78 | /** 79 | * @param {Immutable.List} state - current reducer state. 80 | * @param {object} action - action descriptor. 81 | * @param {CompositeComponent} action.instance - target component instance. 82 | * 83 | * @returns {Immutable.List} updated reducer state that only includes all 84 | * wizards up to and including the target wizard instance. 85 | */ 86 | [HALCYON_WIZARD_OPEN_INSTANCE] : (state, { instance }) => { 87 | const idx = state.findIndex(x => x.get('instance') === instance); 88 | 89 | return idx >= 0 ? state.take(idx + 1) : state; 90 | } 91 | }; 92 | 93 | // halcyonReducer : Immutable.List -> Action -> Immutable.List 94 | export default function halcyonReducer (state = initialState, action) { 95 | const handler = actions[action.type]; 96 | 97 | return handler ? handler(state, action.payload) : state; 98 | } 99 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | export { default as halcyon } from './halcyon'; 2 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Default Unit Test Suite 3 | * Will import all files matching *.spec.js within ~/src 4 | * */ 5 | const context = require.context('../../src', true, /.+\.spec\.js?$/); 6 | 7 | context.keys().forEach(context); 8 | 9 | export default context; 10 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | webpack = require('webpack'), 3 | HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'; 6 | 7 | module.exports = { 8 | entry : { 9 | example : [ 10 | path.resolve(__dirname + '/example/index.jsx'), 11 | 'webpack-dev-server/client?http://localhost:3000', 12 | 'webpack/hot/dev-server' 13 | ] 14 | }, 15 | output : { 16 | filename : '[name].js', 17 | path : __dirname + '/dist', 18 | }, 19 | target : 'web', 20 | plugins : [ 21 | new webpack.DefinePlugin({ 22 | 'process.env' : { 23 | 'NODE_ENV' : JSON.stringify(process.env.NODE_ENV) 24 | } 25 | }), 26 | new HtmlWebpackPlugin({ 27 | template : path.resolve(__dirname + '/example/index.html'), 28 | hash : true, 29 | inject : 'body' 30 | }), 31 | new webpack.HotModuleReplacementPlugin(), 32 | new webpack.NoErrorsPlugin() 33 | ], 34 | resolve : { 35 | extensions : ['', '.js', '.jsx'] 36 | }, 37 | module : { 38 | loaders : [ 39 | { 40 | test : [/\.(js|jsx)?$/], 41 | include : [ 42 | path.resolve(__dirname + '/example'), 43 | path.resolve(__dirname + '/src') 44 | ], 45 | exclude : /node_modules/, 46 | loaders : ['react-hot', 'babel?stage=0'] 47 | } 48 | ] 49 | } 50 | }; 51 | --------------------------------------------------------------------------------