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;Bug
15 Not working for AJAX forms
I had the wizard working perfectly but than had to change it to AJAX. Then something goes:
When completing step one, it will post via AJAX instead of a regular HTTP Post and then the url for the redirect isn't working as expected (it is misschien the controller). I ried to change it to a nice array still with autodetecting the current controller, but that made an infinite loop.
Can you fix the redirect function so that it uses the full path to the controller?
BTW. What is the easiest way to get all the data in the _afterComplete function as Model data? Now I re-model it myself because of the extra step layers.
Comment
16 Form URL correction
Basically, creating the form using $this->here
causes an incorrect url to be created for the form action if Cake's webroot is not '/'.$form->create('Signup',array('id'=>'SignupForm','url'=>$this->here));
I was able to resolve this by adding a variable to the view with a preformed url for the form. The easiest way is to do this by creating a beforeRender method in wizard.php:
/**
* Adds wizard variable to view
* Usage:
* $form->create(false, array('url'=>$wizard['form_url']));
*
* @access public
*/
function beforeRender() {
$wizard = array();
$wizard['form_url'] = array(
'controller'=>$this->controller->name,
'action'=>$this->wizardAction,
current($this->steps)
);
$this->controller->set('wizard', $wizard);
}
Once this is in place, change the form creation in each view to use the new variable:
$form->create('Signup',array('id'=>'SignupForm','url'=>$wizard['form_url']));
Comment
17 Re: Not working for AJAX forms
My post above is probably what you need. I started out trying to debug the redirects, but that turned out not to be the problem.
Try adding this code to wizard.php. This works, though could probably be improved.
/**
* Get the data from the Session that has been stored by the WizardComponent by model (instead of by step).
*
* @param mixed $name The name of the model
* @return mixed The value of the session variable
* @access public
*/
function readModel($name) {
$result = array();
if (App::import('Model', $name)) {
$model = new $name;
$fields = $model->schema();
foreach (array_keys($fields) as $field) {
$result[$field] = null;
}
}
$data = $this->read();
foreach($data as $k=>$v) {
if (isset($v[$name])) {
$result = array_merge($result, $v[$name]);
}
}
return $result;
}
Bug
18 PBN - different payments
I do get account info, to which a user want to partecipant, review if everything is correct, then go to billing to let the user choose the way he wants to pay and the process the payment specific code.
In the _processBilling, I have some conditions in order to branch differently in case I have selected different PaymentMethod's id:$this->Wizard->steps = array(
'account',
'event',
'review',
'billing',
array('pay'=>array('paypal')),
array('ban'=>array('bank')),
array('cre'=>array('creditcard')),
array('cas'=>array('cash')),
array('pos'=>array('postal'))
);
if ($this->data['PaymentMethod']['id']==1) {
debug('Brancing to bank');
$this->Wizard->branch('ban');
return true;
} else if ($this->data['PaymentMethod']['id']==2) {
debug('Brancing to paypal');
$this->Wizard->branch('pay');
return true;
} else if ($this->data['PaymentMethod']['id']==3){
debug('Brancing to creditcard');
$this->Wizard->branch('cre');
} else if ($this->data['PaymentMethod']['id']==4){
debug('Brancing to postal');
$this->Wizard->branch('pos');
} else if ($this->data['PaymentMethod']['id']==5){
debug('Brancing to cash');
$this->Wizard->branch('cas');
} else {
debug('Hey, I will not branch anywhere!');
return false;
}
return true;
Unfortunately the wizard goes directly to the "completeUrl" and does not sends the user to one of the branches...
Any idea ?
PS: I'm getting an headache on this now :)
Comment
19 Anyone having branch headaches
http://bin.cakephp.org/saved/53100
P.S. Julien, I think you might have an issue, can you post a bin of your full function code for that? Otherwise I think this might be the issue:
if ($this->data['PaymentMethod']['id']==1) {
debug('Brancing to bank');
$this->Wizard->branch('ban');
return true;
} else if ($this->data['PaymentMethod']['id']==2) {
debug('Brancing to paypal');
$this->Wizard->branch('pay');
return true;
} else if ($this->data['PaymentMethod']['id']==3){
debug('Brancing to creditcard');
$this->Wizard->branch('cre');
} else if ($this->data['PaymentMethod']['id']==4){
debug('Brancing to postal');
$this->Wizard->branch('pos');
} else if ($this->data['PaymentMethod']['id']==5){
debug('Brancing to cash');
$this->Wizard->branch('cas');
} else {
debug('Hey, I will not branch anywhere!');
return true; //this allows it to pass to the next step if nothing is chosen.
}
return false; //This shouldn't pass to the next step, therefore return false.
Comment
20 noted issue
I also had issues when trying to access the form data from the _process function, I used the proper format I believe of the stepname.model.field
so not sure why it's not able to pick it up, instead I had to use good ole $_POST
Any ideas why this could be?
$custom = $_POST['data']['Order']['ForkGeometryCustomFrameSize'];
//$this->Wizard->read('Frametype.Order.ForkGeometryCustomFrameSize'); Doesnt Work!?!?!?
$standard = $_POST['data']['Order']['ForkGeometryStandardFrameSize'];
//$this->Wizard->read('Frametype.Order.ForkGeometryStandardFrameSize'); Doesnt Work!?!?!1
Comment
21 Re: noted issue
This is because form data isn't instantly stored in the Wizard Session in a _process callback. You access your data in _process callback as you normally would a form - $this->data. It's only after the callback is complete that the data is moved into the Session. $this->Wizard->read() is used for accessing data from previous steps, not the one that was just submitted.