Wizard Component 1.2
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
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.
[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::' . $processCallback, true), 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
Comment
1 Nice update, thanks!
Comment
2 Nice
Comment
3 New Tutorial
wispoz, the tutorial was just submitted. Hopefully it will be up in the next couple days.
Question
4 Will it works with files uploading?
Comment
5 File Uploading
I am currently using the wizard to handle file uploading and it handles files in the same way as a normal form.
Comment
6 admin routing
I found a solution. I'm using
Router::connect('/admin/addproduct', array('controller' => 'products', 'action' => 'wizard', 'admin' => 1));in my routes.Comment
7 capturing image inputs
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.
Comment
8 Re: capturing image inputs
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.
Comment
9 Causes My Apache to Crash
Comment
10 Re: Causes My Apache to Crash
Have you checked your Apache errors logs?
Comment
11 Branching
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.
Comment
12 Re: branching
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
Comment
13 Feedback
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
Comment
14 Two Suggestions for Improvements
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;