Wizards
A multi-stepped data gathering process is often called a wizard. When people hear the term 'wizard' many naturally think of dialogs in traditional GUI software with next, back and cancel buttons. In fact, many web based systems also fit the description of a wizard even if the term 'wizard' would not occur to the users. For example, a checkout of an E-commerce site generally has a details step, a shipping step, a billing step a final review step and an acknowledgement step. Similarly, onboarding forms for new accounts are often stepped to guide the user through the journey and perhaps to conceal the full size of the form from the user.
The wizard leaf module provides a pattern of classes to enable you to quickly build multi-step systems and provides solutions to common issues such as moving between steps, retaining captured data, and validating which steps are permissible.
It can be helpful to remember that ultimately this pattern boils down to a parent 'host' leaf that contains multiple 'sub' leaves (the steps), but only one of which it shows at one time.
Creating the wizard steps
For each step you need to make:
- a new Leaf that extends
Step
- a new View that extends
StepView
- a new Model that extends
StepModel
The StepView
A StepView is a canvas on which your present your steps UI and it behaves
just like a normal Leaf. You should create your sub leaves in the normal way
by extending createSubLeaves
.
However, most 'steps' in a wizard have a familiar surround and so while you
could extend the printViewContent
function as normal, a StepView
has a pattern of three methods you can override instead:
printTop()
- Common area above the content
printTail()
- Common area below the content
printStepBody()
- The step content itself
It's expected that you might make a base View for your wizard that implements
printTop
or printTail
in order to supply common elements like a step
indicator, and leave the printStepBody
to be overridden by the
actual step views.
The StepView also provides two important helper methods:
printNavigationButton($text, $step)
- Prints a button that attempts to navigate to the step named by
$step
with$text
as the button text. printNavigationSubmitLink($text, $step)
- As above but as a standard <a> tag.
Whether or not the navigation succeeds depends upon how the steps have been wired together.
The Wizard
To bring your steps together you must create your Wizard Leaf by extending
the Wizard
class. Wizard
is abstract and has one method that must
be defined:
class Checkout extends Wizard
{
protected function createSteps(): array
{
return [
'details' => new PersonalDetailsStep(),
'address' => new AddressStep(),
'payment' => new PaymentStep().
'confirm' => new ConfirmationStep()
];
}
}
Note that there is no particular rhyme or reason in the keys used in the array, however, they do become important for navigation. Think of these keys as aliases for referring to the steps later. As such its best practice to define the keys as constants instead so that navigation logic can be sure to get the right alias names:
class Checkout extends Wizard
{
const STEP_PERSONAL = 'personal';
const STEP_ADDRESS = 'address';
const STEP_PAYMENT = 'payment';
const STEP_CONFIRM = 'confirm';
protected function createSteps(): array
{
return [
self::STEP_PERSONAL => new PersonalDetailsStep(),
self::STEP_ADDRESS => new AddressStep(),
self::STEP_PAYMENT => new PaymentStep().
self::STEP_CONFIRM => new ConfirmationStep()
];
}
}
You can, of course, create steps conditionally by reviewing state however it is strongly discouraged. Ideally, this function should return the same array under any circumstances. The actual journey your user takes may not touch all of these steps depending on how the navigation buttons bring them through the wizard. That is fine - there is no problem having the additional steps created. Also if you need to control the 'entry' or default step you can do this in a more considered way than changing the order of the steps in this array.
Controlling the entry step
By default the first step in the array returned by createSteps
becomes
the default step.
To change this either because that is not the case, or because after
analysing the state a different step would be best simply override the
getDefaultStep()
method and return the alias name of the step
to use as the entry step.
Bear in mind the original implementation of this function first looks to see if a step alias name is supplied in the Url and uses that in preference to the first step. If you want to keep this behaviour don't forget to call the parent implementation.
Validating navigation permissions
While individual StepView
objects should try to only show navigation links
to steps that make sense given the current state it is important that
the Wizard is stopped from navigating to invalid steps if either
- A bug in StepView shows a navigation button or link to a step that should not be allowed or
- The user has appended the step name to the URL
To validate a step change you need to override the canNavigateToStep
method:
class Checkout extends Wizard
{
protected function canNavigateToStep(string $stepName): bool
{
switch($stepName){
case: self::STEP_ADDRESS:
if ($this->wizardData[self::STEP_PERSONAL]['FirstName'] == ''){
// Only once the user has entered a name should we be allowed
// to navigate to the address step.
return false;
}
break;
}
return true;
}
}
While it's most common to see a switch statement here don't forget you can apply more broad checks as well:
class Checkout extends Wizard
{
protected function canNavigateToStep(string $stepName): bool
{
if ($stepName != self::STEP_PERSONAL &&
$this->wizardData[self::STEP_PERSONAL]['FirstName'] == '' ){
// If the user hasn't entered a name then only the personal
// details step is permissible.
return false;
}
return true;
}
}
It's also common to expect some journeys in one direction only in which
case you can look at the $this->model->currentStep
field:
class Checkout extends Wizard
{
protected function canNavigateToStep(string $stepName): bool
{
switch($stepName){
case self::STEP_PAYMENT:
if ($this->model->currentStep != self::STEP_CONFIRM &&
$this->model->currentStep != self::STEP_ADDRESS){
// Only allow access to the payment step from either the
// address step (user moving forwards from left to right)
// or the confirm step (user changing their mind)
return false;
}
break;
}
return true;
}
}
A common requirement is to stop navigation if the current step is incomplete
or has a processing error. While this could be handled in canNavigateToStep
it would become a very unwieldy function. Ideally canNavigateToStep
would
really describe the mechanics of how users are expected to negotiate through
the steps of your wizard.
When a navigation event is raised the wizard will call the onLeaving
method on the current step's Step class. If this function throws an
AbortChangeStepException then the navigation will fail.
Wizard Lifecycle methods
There are two main lifecycle methods you may find useful. In each case simply extend the appropriate method to implement:
onLeavingStep($fromStep, $toStep)
- Called when the user is trying to navigate away from the current step. If you throw an AbortChangeStepException then the navigation will be cancelled.
onLeftStep[]($fromStep, $toStep)
- Called after the step change has completed.
Note that these lifecycle events are only fired if the user is navigating to a step other than the one they're currently on.
Step Lifecycle methods
Similarly the wizard lifecycle methods are repeated on the steps themselves:
onLeaving($targetStepName)
- Called when the step is the current step but the user is trying to navigate away. If you throw an AbortChangeStepException then the navigation will be cancelled.
onLeft($targetStepName)
- Called after the step change has completed.
Note that these lifecycle events are only fired if the user is navigating to a step other than the one they're currently on.
Data Binding
The Wizard class creates a central array to gather all entered data from
all of the steps. It's important that your Step classes use a model class
that derives from StepModel
as it understands how to interact with this
array and will bind the data from your step's controls to the central
array store.
Data gathered by steps is retained across post backs by propagating the data in a hidden input on the page. You should be careful not to put into the wizard data array anything that should be kept private. You can define custom model properties outside of wizard data as normal for that purpose.
Processing Data
When you need to process user events you can do this either in the step or the wizard. Event processing in the step must be confined to actions that can be completed with only the data on that step. If your processing action requires data gathered on other steps you must bubble the event up to your wizard leaf class.
Simply define an event in your step class and then attach a handler in the
createSteps()
method:
class Checkout extends Wizard
{
const STEP_PERSONAL = 'personal';
const STEP_ADDRESS = 'address';
const STEP_PAYMENT = 'payment';
const STEP_CONFIRM = 'confirm';
protected function createSteps(): array
{
$confirmStep = new ConfirmationStep();
$confirmStep->placeOrderEvent->attachHandler(function(){
// Do something to place the order...
});
return [
self::STEP_PERSONAL => new PersonalDetailsStep(),
self::STEP_ADDRESS => new AddressStep(),
self::STEP_PAYMENT => new PaymentStep().
self::STEP_CONFIRM => $confirmStep
];
}
}
Processing data in response to navigation
Sometimes you won't be defining your own buttons for the step, you want to rely instead upon actions being committed when the user performs the navigation to another step.
The easiest mechanism for this is to override either the onLeft
method of
the Step class, or the onLeftStep
method of the Wizard
(depending on
if the saving action needs know about more than the single step or not).
Advanced Techniques
Persisting state
Some stepped systems are populating business models that could be stored 'as you go'. Others may want to initialise the wizard with data from the business models, for example, to load up the logged in customers details.
There are three main patterns for state persistence:
-
When capturing new data generally you let the wizard gather the data from the steps and on the final step a button raises an event and your wizard invokes the state storage.
-
For wizards that edit or amend existing data, you can override
loadDataFromPersistantState()
and return an array of wizard data, keyed by the step alias name with values that are associate arrays of the initialised data. -
In some cases, you may abandon the central storage of wizard data provided by the Wizard class and intentionally make your Step classes use a Leaf model that does not extend from StepModel. You can then populate your own model data and commit it for storage just as you would if this was a normal page leaf. This means the wizard is just providing the mechanics around step switching and validation but it can be appropriate. Perhaps your steps are all self-contained and by design should update the data store as the user leaves each step.
Step reuse
Sometimes you need to allow the user to arrive at the same step from different places, however, the user interface needs to change subtly based on where the user came from. Most often the main change is that where the 'next' button takes them would be completely different.
Rather than invent extremely complex validation logic and add lots of conditions in the UI for detecting which 'mode' the step is running in it is much more straightforward to add the same step class multiple times when defining your steps with different aliases.
You can use the same step any number of times and if you pass arguments to its constructor you can control it's 'mode' in a more explicit fashion.
For example, consider the case where an address step needs to show as the user moves through a checkout. But from the confirm step the user might be invited to make changes to the address before confirming the order. When the user navigates to change the address, the buttons should be different. The user should not be offered a 'back to details' button and the 'forward to payment' button should be replaced with a 'continue' button that returns them to the confirm step. By providing for these modes and adding two steps to the step list it is much simpler by design. You can also achieve the 'mode' variation by extending the step class although this requires also extending the view in most cases.
class Checkout extends Wizard
{
const STEP_PERSONAL = 'personal';
const STEP_ADDRESS = 'address';
const STEP_PAYMENT = 'payment';
const STEP_CONFIRM = 'confirm';
const STEP_CHANGE_ADDRESS = 'change-address';
protected function createSteps(): array
{
return [
self::STEP_PERSONAL => new PersonalDetailsStep(),
self::STEP_ADDRESS => new AddressStep(false), // Not in 'change' mode
self::STEP_PAYMENT => new PaymentStep().
self::STEP_CONFIRM => new ConfirmationStep(),
self::STEP_CHANGE_ADDRESS => new AddressStep(true) // In 'change' mode
];
}
}
Step data sharing
There are occasions where it may be appropriate for two different steps to share and update the same set of data. In the E-commerce example, it may be the case that there is a payment and delivery details step, then on a confirm order step, the customer may decide that they want to change their delivery information. In this case you have to redirect them to a 'change delivery details' step rather than the original payment and delivery details step.
Steps can share data by changing their StepDataBindingKey
which can be done by overriding the
getStepDataBindingKey
function in your Step
class.
By default, this function returns null
which tells the wizard that this step should store
its own data. Making the function return the name of another step will tell the wizard
that when accessing that step's data, it should actually be the data from the other step.
class ChangeDeliveryStep extends Step
{
// Overridden function
protected function getStepDataBindingKey()
{
return Checkout::STEP_PAYMENT_DELIVERY;
}
}
class Checkout extends Wizard
{
const STEP_PAYMENT_DELIVERY = 'payment-and-delivery';
const STEP_CONFIRM = 'confirm';
const STEP_CHANGE_DELIVERY = 'change-delivery';
protected function createSteps(): array
{
return [
self::STEP_PAYMENT_DELIVERY => new PaymentDeliveryStep(),
self::STEP_CONFIRM => new ConfirmationStep(),
self::STEP_CHANGE_DELIVERY => new ChangeDeliveryStep() // This steps data points to the first step
];
}
}
In this example, accessing the step data of change-delivery
will actually point to
the step data of payment-and-delivery
. Therefore, it could be said that stepData['payment-and-delivery'] === stepData['change-delivery']
Be careful not to point two steps' data to each other. This may result in unwanted behaviour.