Wizard Component Tutorial
A tutorial on using my Wizard Component which automates several aspects of multi-page forms including data persistence, form preparation, wizard resetting (manual and automatic), and wizard navigation (including jumping between steps) while maintaining flexibility with custom validation and completion callbacks.
This is a tutorial for my Wizard Component found here: http://bakery.cakephp.org/articles/view/wizard-component
Adding even a second page to a form introduces several new considerations, such as: persisting data throughout the steps, preventing users from skipping steps through forged forms or directly requested urls, being able to handle/validate each page's data set differently, page refreshing and double submission of data, and even simple navigation from one page to the next (or previous).
The benefits and features of the Wizard Component:
The first thing I had to decide when creating the component was the method of passing the step variable between the view, controller, and component. I decided against hidden form variables and numerical steps with an auto-incrementing counter. I decided to just post the current step as a string in the url.
The wizard has a single point of entry into your controller the controller action specified in WizardComponent::wizardAction() - (default is 'wizard'). So your wizard urls would by default be '/controller/wizard/step' (thanks Adam Johnson). See the optional stuff to see how to make your actions '/controller/step'.
The step is then passed from the controller's method to the WizardComponent::process() method. Process() determines: what step has been called, if the step if valid, if the data is valid, and where to redirect/render.
There are two steps that are unique: 'reset' and $receiptAction. Reset resets the wizard by deleting the session and starting the wizard over. $receiptAction is the view called after the wizard is complete (defaults to 'receipt'). All other steps are validated.
Validation has two parts: 1. Validate that the step is contained in the $steps array. 2. Validate that the step is before or exactly the "expected step" (the step after the last step stored in the session). This prevents users from skipping steps directly through the url or a forged form. If a step fails validation, then the wizard is reset (if autoReset=true) and is redirected back to the beginning. (If autoReset=false, wizard is redirected to the "expected step".)
If data is present, then it is validated. Otherwise, the step is rendered (with data loaded from session if present). Data is validated through a custom controller callback named after the step: processStepname(). (ie. $step = 'contact', callback = processContact()). The callback must return a boolean of whether the data is valid. If false, the step is rendered with the errors. If true, the data is stored in the session with an array key of $sessionKey (defaults to Controllername.Wizard).
The last step is to redirect/render the step. If the "Previous" button was pushed, data validation is skipped and the user is redirected to the previous step. If any other button was pushed, the wizard is redirected to the next step. If the current step is the last step in the $steps array, then optional afterComplete() controller callback is called and the user is redirected to the $receiptAction.
The problem
Adding even a second page to a form introduces several new considerations, such as: persisting data throughout the steps, preventing users from skipping steps through forged forms or directly requested urls, being able to handle/validate each page's data set differently, page refreshing and double submission of data, and even simple navigation from one page to the next (or previous).
The solution
The benefits and features of the Wizard Component:
- Allows easy step definition and order with $step array.
- Persists data automatically in session. Array is stored in session under customizable $sessionKey.
- Uses redirects rather than renders for navigation (unless data validation fails) thus allowing user browser-refreshing without reposting data.
- Requires minimal code in your controller: process() and resetWizard() are the only public functions.
- Automatically resets the wizard if a step request is invalid. (optional; default = true)
- Allows each step to be prepared with its own optional custom controller callback.
- Allows each step to be validated differently with its own custom controller callback.
- Also allows optional controller callbacks for after the wizard completion (afterComplete()) and before the receipt is rendered (beforeReceipt()).
- Automatically renders a receipt page after the completion of the wizard. (defined by $receiptAction)
- Automatically resets form data after wizard completion to prevent user from going back and resubmitting data.
What it doesn't do:
- Generate html for your views.
- Automatically validate your data - controller validation callbacks must be present for each step.
- Easily allow you to "lock down" the form. (ie. no resetting, no navigation controls for the user.) (yet - hopefully)
- Easily allow "plot-branching" navigation. (ie. first step choose male/female, varying step afterwards) (also yet)
- Provide security (yet) from people directly accessing the receipt page. I should have this fixed very soon.
How it Works
The first thing I had to decide when creating the component was the method of passing the step variable between the view, controller, and component. I decided against hidden form variables and numerical steps with an auto-incrementing counter. I decided to just post the current step as a string in the url.
The wizard has a single point of entry into your controller the controller action specified in WizardComponent::wizardAction() - (default is 'wizard'). So your wizard urls would by default be '/controller/wizard/step' (thanks Adam Johnson). See the optional stuff to see how to make your actions '/controller/step'.
The step is then passed from the controller's method to the WizardComponent::process() method. Process() determines: what step has been called, if the step if valid, if the data is valid, and where to redirect/render.
There are two steps that are unique: 'reset' and $receiptAction. Reset resets the wizard by deleting the session and starting the wizard over. $receiptAction is the view called after the wizard is complete (defaults to 'receipt'). All other steps are validated.
Validation has two parts: 1. Validate that the step is contained in the $steps array. 2. Validate that the step is before or exactly the "expected step" (the step after the last step stored in the session). This prevents users from skipping steps directly through the url or a forged form. If a step fails validation, then the wizard is reset (if autoReset=true) and is redirected back to the beginning. (If autoReset=false, wizard is redirected to the "expected step".)
If data is present, then it is validated. Otherwise, the step is rendered (with data loaded from session if present). Data is validated through a custom controller callback named after the step: processStepname(). (ie. $step = 'contact', callback = processContact()). The callback must return a boolean of whether the data is valid. If false, the step is rendered with the errors. If true, the data is stored in the session with an array key of $sessionKey (defaults to Controllername.Wizard).
The last step is to redirect/render the step. If the "Previous" button was pushed, data validation is skipped and the user is redirected to the previous step. If any other button was pushed, the wizard is redirected to the next step. If the current step is the last step in the $steps array, then optional afterComplete() controller callback is called and the user is redirected to the $receiptAction.
Comments
Comment
1 example
Comment
2 example
I'll try and get a full example up in the next few days. Thanks. You have read the second page though, correct?
Comment
3 example
Sure.
Comment
4 great job
i originally tried to use this in an existing controller, but the routing messes up access to my existing actions. so instead of rerouting all my existing actions, i was going to alter the component to just keep my wizard action name in the url. so i changed all the redirect commands in the controller to include my action name. it worked fine until i got to the receipt page. i'm a novice, so i gave up. but its probably something you can implement easily. just make a component variable that is set in BeforeFilter, and if you don't set it, it defaults to '' to keep the original functionality.
anyway, great component. i couldn't get the FormWizard component from cakeforge to work, but yours worked with ease.
Comment
5 good idea
That's actually a great idea. That was one thing I didn't like about the component is that it required the controller be "dedicated" to the wizard... a "wizard action" setting would be perfect. Thanks! I'll update the component as soon as I've fixed it.
[Update Sept. 29, 2007 12:48PM]
Adam, I've updated the component and the tutorial with your suggestion, thanks. $wizardAction is now an option and the default is 'wizard'. Meaning, your wizard urls should now look like '/controller/wizard/step' or whatever your set $wizardAction to.
I also included routing options on the second page of the tutorial to show how to make wizard action the default action in the controller without messing up your other actions and how to "dedicate" the controller to wizard as it was before.
Thanks for the suggestion - very useful. I know there is quite a few things that need improvement but I think the component has a pretty good foundation right now.
Comment
6 Good work
I have a suggestion for the action calls of each step. Rather than having a "PrepareStepName()" and "ProcessStepName()" why not just have "StepName($callback)" .. where callback is either "process" or "prepare". This way it would reduce the number of functions. If your wizard has 10 steps you would potentially need to create 20 functions.
If you just have StepName($callback) it would also follow cake standards since each step would now have an action and a view with the same name.
Comment
7 About Validation
function processAccount() {
# do some validation stuff here
return true (or false);
}
I'm confused by this as my validation from previous work with a straight, single step form is inside my model. Can I reference that same validation here? Or instead, do I need to validate all the data in the controller instead? What is the syntax?
Comment
8 small extension
So I implemented a small extension and now it is easy to change the steps in the process<stepname> function like this:
function processstep1()
{
if ($this->flag1 > 0) {
$this->Wizard->setsteps($this->steps);
return true;
} else if ($this->flag1 == -1) {
$this->Wizard->setsteps(array('step1', 'stepx'));
return true;
}
// exit();
return false;
}
the setsteps is a new function in the wizard:
function setsteps($steps = null)
{
$this->Session->write($this->sessionKey.".Way", $steps);
$this->steps = $steps;
}
and in the Wizard::process there is also a small new part. Search for the "elseif(!is_null($step))" position and change to:
} elseif(!is_null($step)) {
// ---this is new--------- get the steps
if ($this->Session->check($this->sessionKey.".Way")) {
$this->steps = $this->Session->read($this->sessionKey.".Way");
}
// ---end of this is new---------
Hope this is a little help to someone.
Question
9 full example of wizardcomponent
Where is the full example of your WizardComponent?
Thanks
Comment
10 Full example
Thanks for this component and this post.
A full example might be helpfull ?
Alex Devry 8)
Comment
11 Problem on clicking Continue
That is, instead of going to http://localhost/project/users/wizard/step2, it's trying to go to http://localhost/project/project/users/wizard/step2
Comment
12 multiple controllers
For example, maybe step 1 is /users/add and step 2 is /profiles/add for that user you just added.
Comment
13 redirect
I found a workaround to this problem:
in your BlablaController add the Controllername to the wizardAction like this:
function beforeFilter() {
$this->Wizard->steps = array('1','2','3');
$this->Wizard->wizardAction = "/blabla/wizard";}
Question
14 looking for full example
Comment
15 Another Problem