Wizard Component 1.2

By jaredhoyt (jaredhoyt)
Automates several aspects of multi-page forms including data persistence, form preparation, wizard resetting (manual and automatic), user navigation, and plot-branching navigation while maintaining flexibility with custom validation and completion callbacks.

Tutorial can be found here: http://bakery.cakephp.org/articles/view/wizard-component-1-2-tutorial
[03/07/08] Update 1.2.3: Fixed bug pertaining to Previous and Cancel buttons not auto-working with submit buttons with type="image".

[11/23/08] Update 1.2.2: Added "lockdown" feature. If $lockdown = true (default false), user will not be able to edit previously completed steps. This is useful for wizards such as tests and surveys in which the user shouldn't be able to edit previous steps.

[11/21/08] Update 1.2.1: Changed visibility of all callbacks to protected (all callbacks must now be preceded with an _). Added auto-validate feature: if $autoValidate = true, then data is automatically validated with the correct model (must be included in controller's uses array) unless a processCallback() is present.

Component Class:

Download code <?php 
/**
 * Wizard component by jaredhoyt.
 *
 * Handles multi-step form navigation, data persistence, validation callbacks, and plot-branching navigation.
 *
 * PHP versions 4 and 5
 *
 * Comments and bug reports welcome at jaredhoyt AT gmail DOT com
 *
 * Licensed under The MIT License
 *
 * @writtenby        jaredhoyt
 * @lastmodified    Date: March 7, 2009
 * @license        http://www.opensource.org/licenses/mit-license.php The MIT License
 */ 
class WizardComponent extends Object {
/**
 * The Component will redirect to the "expected step" after a step has been successfully
 * completed if autoAdvance is true. If false, the Component will redirect to 
 * the next step in the $steps array. (This is helpful for returning a user to 
 * the expected step after editing a previous step w/o them having to navigate through
 * each step in between.)
 *
 * @var boolean
 * @access public
 */
    
var $autoAdvance true;
/**
 * Option to automatically reset if the wizard does not follow "normal"
 * operation. (ie. manual url changing, navigation away and returning, etc.)
 * Set this to false if you want the Wizard to return to the "expected step"
 * after invalid navigation.
 *
 * @var boolean
 * @access public
 */
    
var $autoReset false;
/**
 * If no processCallback() exists for the current step, the component will automatically
 * validate the model data against the models included in the controller's uses array.
 *
 * @var boolean
 * @access public
 */
    
var $autoValidate false;
/**
 * List of steps, in order, that are to be included in the wizard.
 *        basic example: $steps = array('contact', 'payment', 'confirm');
 * 
 * The $steps array can also contain nested steps arrays of the same format but must be wrapped by a branch group.
 *         plot-branched example: $steps = array('job_application', array('degree' => array('college', 'degree_type'), 'nodegree' => 'experience'), 'confirm');
 *
 * The 'branchnames' (ie 'degree', 'nodegree') are arbitrary but used as selectors for the branch() and unbranch() methods. Branches
 * can point to either another steps array or a single step. The first branch in a group that hasn't been skipped (see branch())
 * is included by default (if $defaultBranch = true). 
 *
 * @var array
 * @access public
 */
    
var $steps = array();
/**
 * Controller action that processes your step. 
 *
 * @var string
 * @access public
 */
    
var $wizardAction 'wizard';
/**
 * Url to be redirected to after the wizard has been completed.
 * Controller::afterComplete() is called directly before redirection.
 *
 * @var string
 * @access public
 */
    
var $completeUrl '/';
/**
 * Url to be redirected to after 'Cancel' submit button has been pressed by user.
 *
 * @var string
 * @access public
 */
    
var $cancelUrl '/';
/**
 * If true, the first "non-skipped" branch in a group will be used if a branch has
 * not been included specifically.
 *
 * @var boolean
 * @access public
 */
    
var $defaultBranch true;
/**
 * If true, the user will not be allowed to edit previously completed steps. They will be
 * "locked down" to the current step.
 *
 * @var boolean
 * @access public
 */    
    
var $lockdown false;
/**
 * Internal step tracking.
 *
 * @var string
 * @access protected
 */
    
var $_currentStep null;
/**
 * Holds the session key for data storage.
 *
 * @var string
 * @access protected
 */
    
var $_sessionKey null;
/**
 * Other session keys used.
 *
 * @var string
 * @access protected
 */
    
var $_configKey null;
    var 
$_branchKey null;
/**
 * Other components used.
 *
 * @var array
 * @access public
 */
    
var $components = array('Session');
/**
 * Initializes WizardComponent for use in the controller
 *
 * @param object $controller A reference to the instantiating controller object
 * @access public
 */
    
function initialize(&$controller) {
        
$this->controller =& $controller;
        
        
$this->_sessionKey    $this->Session->check('Wizard.complete') ? 'Wizard.complete' 'Wizard.' $controller->name;
        
$this->_configKey     'Wizard.config';
        
$this->_branchKey    'Wizard.branches.' $controller->name;    
    }
/**
 * Component startup method.
 *
 * @param object $controller A reference to the instantiating controller object
 * @access public
 */    
    
function startup(&$controller) {
        if (!empty(
$this->wizardAction)) {
            
$this->wizardAction .= '/';
        }
        
        
$this->steps $this->_parseSteps($this->steps);
        
        
$this->config('wizardAction'$this->wizardAction);
        
$this->config('steps'$this->steps);
    }
/**
 * Main Component method.
 *
 * @param string $step Name of step associated in $this->steps to be processed.
 * @access public
 */        
    
function process($step) {
        if (isset(
$this->controller->params['form']['Cancel'])) {
            if (
method_exists($this->controller'_beforeCancel')) {
                
$this->controller->_beforeCancel($this->_getExpectedStep());
            }
            
$this->resetWizard();
            
$this->controller->redirect($this->cancelUrl);
        }
        
        if (empty(
$step)) {
            if (
$this->Session->check('Wizard.complete')) { 
                if (
method_exists($this->controller'_afterComplete')) {
                    
$this->controller->_afterComplete();
                }
                
$this->resetWizard();
                
$this->controller->redirect($this->completeUrl);
            }
            
            
$this->autoReset false;
        } elseif (
$step == 'reset') {
            if (!
$this->lockdown) {
                
$this->resetWizard();
            }
        } else {
            if (
$this->_validStep($step)) {
                
$this->_setCurrentStep($step);
                                                
                if (!empty(
$this->controller->data) && !isset($this->controller->params['form']['Previous'])) { 
                    
$proceed false;
                    
                    
$processCallback '_' Inflector::variable('process_' $this->_currentStep);
                    if (
method_exists($this->controller$processCallback)) {
                        
$proceed $this->controller->$processCallback();
                    } elseif (
$this->autoValidate) {
                        
$proceed $this->_validateData();
                    } else {
                        
trigger_error(__('Process Callback not found. Please create Controller::' $processCallbacktrue), E_USER_WARNING);
                    }
                    
                    if (
$proceed) {
                        
$this->save();
                    
                        if (
next($this->steps)) {
                            if (
$this->autoAdvance) {
                                
$this->redirect();
                            }
                            
$this->redirect(current($this->steps));
                        } else {
                            
$this->Session->write('Wizard.complete'$this->read());        
                            
$this->resetWizard();
                            
                            
$this->controller->redirect($this->wizardAction);
                        }
                    }
                } elseif (isset(
$this->controller->params['form']['Previous']) && prev($this->steps)) { 
                    
$this->redirect(current($this->steps));
                } elseif (
$this->Session->check("$this->_sessionKey.$this->_currentStep")) {
                    
$this->controller->data $this->read($this->_currentStep);
                }
            
                
$prepareCallback '_' Inflector::variable('prepare_' $this->_currentStep);
                if (
method_exists($this->controller$prepareCallback)) {
                    
$this->controller->$prepareCallback();
                }
                
                
$this->config('activeStep'$this->_currentStep);    
                return 
$this->controller->render($this->_currentStep);
            } else {
                
trigger_error(__('Step validation: ' $step ' is not a valid step.'true), E_USER_WARNING);
            }
        }
    
        if (
$step != 'reset' && $this->autoReset) {
            
$this->resetWizard();
        }

        
$this->redirect();
    }
/**
 * Selects a branch to be used in the steps array. The first branch in a group is included by default.
 *
 * @param string $name Branch name to be included in steps.
 * @param boolean $skip Branch will be skipped instead of included if true.
 * @access public
 */    
    
function branch($name$skip false) {    
        
$branches = array();
        
        if (
$this->Session->check($this->_branchKey)) {
            
$branches $this->Session->read($this->_branchKey);
        }
        
        if (isset(
$branches[$name])) {
            unset(
$branches[$name]);
        }
        
        
$value $skip 'skip' 'branch';
        
$branches[$name] = $value;
        
        
$this->Session->write($this->_branchKey$branches);
    }
/**
 * Saves configuration details for use in WizardHelper or returns a config value. 
 * This is method usually handled only by the component.
 *
 * @param string $name Name of configuration variable.
 * @param mixed $value Value to be stored.
 * @return mixed 
 * @access public
 */    
    
function config($name$value null) {
        if (
$value == null) {
            return 
$this->Session->read("$this->_configKey.$name");
        }
        
$this->Session->write("$this->_configKey.$name"$value);
    }
/**
 * Get the data from the Session that has been stored by the WizardComponent.
 *
 * @param mixed $name The name of the session variable (or a path as sent to Set.extract)
 * @return mixed The value of the session variable
 * @access public
 */
    
function read($key null) {
        if (
$key == null) {
            return 
$this->Session->read($this->_sessionKey);
        } else {
            
$wizardData $this->Session->read("$this->_sessionKey.$key");
            if (!empty(
$wizardData)) {
                return 
$wizardData;
            } else {
                return 
null;
            }
        }
    }
/**
 * Handles Wizard redirection. A null url will redirect to the "expected" step.
 *
 * @param mixed $url Stepname to be redirected to.
 * @access public
 */
    
function redirect($step null$status null$exit true) {
        if (
$step == null) {
            
$step $this->_getExpectedStep();
        }

        
$url $this->wizardAction $step;
        
        
$this->controller->redirect($url$status$exit);
    }
/**
 * Resets the wizard by deleting the wizard session.
 *
 * @access public
 */    
    
function resetWizard() {
        
$this->Session->del($this->_branchKey);
        
$this->Session->del($this->_sessionKey);
    }
/**
 * Saves the data from the current step into the Session.
 *
 * Please note: This is normally called automatically by the component after 
 * a successful processCallback, but can be called directly for advanced navigation purposes.
 *
 * @access public
 */        
    
function save() {
        
$this->Session->write("$this->_sessionKey.$this->_currentStep"$this->controller->data);
    }
/**
 * Removes a branch from the steps array.
 *
 * @param string $branch Name of branch to be removed from steps array.
 * @access public
 */    
    
function unbranch($branch) {
        
$this->Session->del("$this->_branchKey.$branch");
    }
/**
 * Finds the first incomplete step (i.e. step data not saved in Session).
 *
 * @return string $step or false if complete
 * @access protected
 */    
    
function _getExpectedStep() {
        foreach (
$this->steps as $step) {
            if (!
$this->Session->check("$this->_sessionKey.$step")) {
                
$this->config('expectedStep'$step);    
                return 
$step;
            }
        }
        return 
false;
    }
/**
 * Saves configuration details for use in WizardHelper.
 *
 * @return mixed
 * @access protected
 */        
    
function _branchType($branch) {
        if (
$this->Session->check("$this->_branchKey.$branch")) {
            return 
$this->Session->read("$this->_branchKey.$branch");
        }
        return 
false;
    }
/**
 * Parses the steps array by stripping off nested arrays not included in the branches
 * and returns a simple array with the correct steps. 
 *
 * @param array $steps Array to be parsed for nested arrays and returned as simple array.
 * @return array
 * @access protected
 */    
    
function _parseSteps($steps) {
        
$parsed = array();

        foreach (
$steps as $key => $name) {
            if (
is_array($name)) { 
                foreach (
$name as $branchName => $step) {
                    
$branchType $this->_branchType($branchName);

                    if (
$branchType) {
                        if (
$branchType !== 'skip') {
                            
$branch $branchName;
                        }
                    } elseif (empty(
$branch) && $this->defaultBranch) {
                        
$branch $branchName;
                    }
                }
                
                if (!empty(
$branch)) {
                    if (
is_array($name[$branch])) {
                        
$parsed array_merge($parsed$this->_parseSteps($name[$branch]));
                    } else {
                        
$parsed[] = $name[$branch];
                    }
                }
            } else {
                
$parsed[] = $name;
            }
        }
        return 
$parsed;
    }
/**
 * Moves internal array pointer of $this->steps to $step and sets $this->_currentStep.
 *
 * @param $step Step to point to.
 * @access protected
 */        
    
function _setCurrentStep($step) {
        
$this->_currentStep reset($this->steps);
        
        while(
current($this->steps) != $step) {
            
$this->_currentStep next($this->steps);
        }
    }
/**
 * Validates controller data with the correct model if the model is included in
 * the controller's uses array. This only occurs if $autoValidate = true and there
 * is no processCallback in the controller for the current step.
 *
 * @return boolean
 * @access protected
 */    
    
function _validateData() {
        
$controller =& $this->controller;
        
        foreach (
$controller->data as $model => $data) {
            if (
in_array($model$controller->uses)) {
                
$controller->{$model}->set($data);
                
                if (!
$controller->{$model}->validates()) {
                    return 
false;
                }
            }
        }
        return 
true;
    }
/**
 * Validates the $step in two ways:
 *   1. Validates that the step exists in $this->steps array.
 *   2. Validates that the step is either before or exactly the expected step.
 *
 * @param $step Step to validate.
 * @return mixed
 * @access protected
 */        
    
function _validStep($step) {
        if (
in_array($step$this->steps)) {
            if (
$this->lockdown) {
                return (
array_search($step$this->steps) == array_search($this->_getExpectedStep(), $this->steps));
            }
            return (
array_search($step$this->steps) <= array_search($this->_getExpectedStep(), $this->steps));
        }
        return 
false;
    }
}
?>

 

Comments 773

CakePHP Team Comments Author Comments
 

Comment

1 Nice update, thanks!

Tried the new version today. Nice update. Feels a bit more solid than before.
Posted Oct 27, 2008 by Martin Westin
 

Comment

2 Nice

But How to use this? Can you provide some example?? please.
Posted Oct 28, 2008 by wispoz
 

Comment

3 New Tutorial

But How to use this? Can you provide some example?? please.
wispoz, the tutorial was just submitted. Hopefully it will be up in the next couple days.
Posted Nov 6, 2008 by jaredhoyt
 

Question

4 Will it works with files uploading?

Hi, i've not read the tutorial and haven't tried the component yet, but if it's not difficult to you answer please.
Posted Dec 10, 2008 by Alexander
 

Comment

5 File Uploading

Hi, i've not read the tutorial and haven't tried the component yet, but if it's not difficult to you answer please.
I am currently using the wizard to handle file uploading and it handles files in the same way as a normal form.
Posted Dec 10, 2008 by Penfold
 

Comment

6 admin routing

Nice component jared. Working like a charm.
Any special tweaks to use it with admin routing? I want to make a wizard in the app backend.

I found a solution. I'm using
Router::connect('/admin/addproduct', array('controller' => 'products', 'action' => 'wizard', 'admin' => 1)); in my routes.
Posted Dec 15, 2008 by Alex Ciobanu
 

Comment

7 capturing image inputs

This component has been a great time saver, thanks!

One thing I ran into was that the checks for Cancel and Previous clicks in a form won't work if the input buttons are an image type. I simply added the Previous_x and Cancel_x to the form click checks.
Posted Feb 20, 2009 by Barrett Kendjoria
 

Comment

8 Re: capturing image inputs

This component has been a great time saver, thanks!

One thing I ran into was that the checks for Cancel and Previous clicks in a form won't work if the input buttons are an image type. I simply added the Previous_x and Cancel_x to the form click checks.

Thanks for the bug find! I was evaluating with empty() on params['form']['Cancel'] and ['Previous'] expecting name and value to both be present. I didn't think about image submit buttons. This has been changed to use isset.
Posted Mar 7, 2009 by jaredhoyt
 

Comment

9 Causes My Apache to Crash

I have downloaded the latest Wizard code and advancing to the next step in my application causes apache to crash. Everytime. If the validation fails, everything is fine. But if it succeeds and attempts to load the next step, apache will crash. Any thoughts? I am running cake 1.2 on WAMP server 2.0. Note that it does not crash using previous version of wizard code.
Posted Mar 10, 2009 by Shane
 

Comment

10 Re: Causes My Apache to Crash

Shane,

Have you checked your Apache errors logs?
Posted Mar 25, 2009 by jaredhoyt
 

Comment

11 Branching

Hello, ive pasted some code in the bin,

when i have the follwoing code setup it does not work as expected.

http://bin.cakephp.org/view/670997522
fyi i am using all the default settings.

Basiccaly the way i want it to work is that if im doing a wizard pertaining to a certain product i want it to branch into the payment process , otherwise i just want it to finish up.

Currently its ALWAYS going into that process_payment branch, i have also tried skipping, using

$this->Wizard->branch('process_payments' , true );

and unbranching.

Posted Mar 30, 2009 by Arnold Almeida
 

Comment

12 Re: branching

Basically the way i want it to work is that if im doing a wizard pertaining to a certain product i want it to branch into the payment process, otherwise i just want it to finish up.

Currently its ALWAYS going into that process_payment branch, i have also tried skipping, using

$this->Wizard->branch('process_payments' , true );

and unbranching.

Arnold,

Here are two solutions for you:

1. Use $defaultBranch = false; (it defaults to true) in your beforeFilter() and Wizard->branch('process_payment') if you want to use that branch.

2. Use the branch skip feature: Wizard->branch('process_payment', true);

Explanations:

1. The Wizard by default will always use the first branch in a branch group (unless $defaultBranch = false). Using unbranch() does not do anything for this, unbranch() is only to remove a branch you've previously included with branch(). By setting $defaultBranch = false, you will be able to branch only to those branches you want to use (1 per branch group).

2. I noticed both in your comment and in your bin paste that your branch was named 'process_payment' but all of your branching actions used branch('process_payments').

This may be as simple as a typo, but I figured I'd offer a more detailed explanation of the PBN. I still consider the PBN aspect of the WizardComponent to be "beta" so it's very probable there are bugs & poorly named features. :D

Hope this helps,
jaredhoyt
Posted Mar 31, 2009 by jaredhoyt
 

Comment

13 Feedback

Hi Jared,

Got some feedback for you.

I tried using your suggesting buy i ran into some limitations. I ended up solving my problem by switching b/w the current id to determine the steps i would be using.

/*
*Method 1
*/
Set the dafault branch to false via in the beforeFilter
$this->Wizard->defaultBranch = false

//outcomes
Skips the branch as expected
but when expected to hook into my branch (using 'process_payment' or 'billing_and_delivery' ) it does not.

/*
*Method 2
*/
Skipping the branch goes into a loop when there is no step after the "skipped branch" however if i add a "test" step it works as expected

Is this on github or something similar? as i wouldnt mnind forking it and adding some contributions.

Thanks for your great work.

Arnold

Posted Mar 31, 2009 by Arnold Almeida
 

Comment

14 Two Suggestions for Improvements

Awesome component idea! This is going to come in handy. With that said, I have two suggestions for improvement.

1. Internationalization Improvement


Utilize sprintf in conjunction with %s/%d/etc. placeholders in your string. This way you only have to convert one string in LC_MESSAGES instead of multiple similar strings.

For example, I changed this:

trigger_error(__('Process Callback not found. Please create Controller::' . $processCallback, true), E_USER_WARNING);
...to this:

trigger_error(sprintf(__('Process Callback not found. Please create Controller::%s', true), $processCallback), E_USER_WARNING);
Similarly, I changed this:

trigger_error(__('Step validation: ' . $step . ' is not a valid step.', true), E_USER_WARNING);
...to this:

trigger_error(sprintf(__('Step validation: %s is not a valid step.', true), $step), E_USER_WARNING);

2. View Rendering Improvement


Several of my prepare steps simply reuse existing views via Controller::render(). Typically, this prevents a view from autoRendering in CakePHP. I would recommend the WizardComponent do the same by not autoRendering if a view has already been rendered.

I did this by changing this line of the Component:

return $this->controller->render($this->_currentStep);
...to this:

return $this->controller->autoRender ? $this->controller->render($this->_currentStep) : true;
Posted Apr 12, 2009 by Matt Huggins