Write a Wizard Command
A Wizard is a multistep Command. A common example would be a first step with a resource selection, and a second step with a message box, pre-filled with the previous selection.
In Sharp, Wizard are similar to Commands in many ways: they can be scoped to an instance or to an entity, and can be attached to an Entity List, a Show Page or a Dashboard.
A Wizard Command can not be configured as bulk (meaning: with instance selection).
Write the Wizard Command class
The class must extend either:
Code16\Sharp\EntityList\Commands\Wizards\EntityWizardCommand
: for an Entity command, on an Entity ListCode16\Sharp\EntityList\Commands\Wizards\InstanceWizardCommand
: for an Instance command, on an Entity List or a Show PageCode16\Sharp\Dashboard\Commands\DashboardWizardCommand
: for a Dashboard Command
Like any Command, you must extend label(): string
function, and can extend buildCommandConfig(): void
(see Commands documentation).
Implement the first step of the Wizard
Instead of execute()
, you must implement executeFirstStep(array $data): array
, or executeFirstStep(mixed $instanceId, array $data): array
in an instance case. This method, as expected, must contain the execution code of your first step:
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function executeFirstStep(array $data): array
{
// Do something
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function executeFirstStep(array $data): array
{
// Do something
}
}
You must also implement protected function buildFormFieldsForFirstStep(FieldsContainer $formFields): void
, to build the first step's form:
Add a form for the first step
Wizard Commands needs forms, one for each step. To build the forms, we use the same API as usual (see Commands documentation), the only difference is where to put the code. For the first step, you already have the answer, it's in buildFormFieldsForFirstStep
:
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function buildFormFieldsForFirstStep(FieldsContainer $formFields): void
{
$formFields->addField(
SharpFormSelectField::make('posts', Post::pluck('name', 'id')->toArray())
->setMultiple()
->setLabel('Posts to add to the message')
);
}
protected function buildFormLayoutForFirstStep(FormLayoutColumn &$column): void
{
$column->withSingleField('posts');
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function buildFormFieldsForFirstStep(FieldsContainer $formFields): void
{
$formFields->addField(
SharpFormSelectField::make('posts', Post::pluck('name', 'id')->toArray())
->setMultiple()
->setLabel('Posts to add to the message')
);
}
protected function buildFormLayoutForFirstStep(FormLayoutColumn &$column): void
{
$column->withSingleField('posts');
}
}
TIP
The layout is optional: if you don't define one, fields will appear in the order of declaration. In the above case, the method can be entirely removed without any impact.
And finally, if you need to set initial data for the form, you should implement:
protected function initialDataForFirstStep(): array
{
return ['name' => 'Bob'];
}
protected function initialDataForFirstStep(): array
{
return ['name' => 'Bob'];
}
Link a step to another one
To tell Sharp to go to the next step, Wizard commands expose a new toStep(string $step)
action, which expects a string key representing you step:
public function executeFirstStep(array $data): array
{
// Do something
return $this->toStep('compose-message');
}
public function executeFirstStep(array $data): array
{
// Do something
return $this->toStep('compose-message');
}
This string key must be "sluggable": only chars, carets and underscores.
Implement further steps
Add a form to each step
There are two options:
First option: one method for all
If your Wizard is small, this could be the right way to proceed. Simply extend the buildFormFieldsForStep(string $step, FieldsContainer $formFields): void
method, with a test on $step
:
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
protected function buildFormFieldsForStep(string $step, FieldsContainer $formFields): void
{
if ($step === 'compose-message') {
$formFields->addField(
SharpFormTextareaField::make('message')
->setLabel('Message text')
->setRowCount(8),
);
} elseif ($step === 'my-other-step') {
// ...
}
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
protected function buildFormFieldsForStep(string $step, FieldsContainer $formFields): void
{
if ($step === 'compose-message') {
$formFields->addField(
SharpFormTextareaField::make('message')
->setLabel('Message text')
->setRowCount(8),
);
} elseif ($step === 'my-other-step') {
// ...
}
}
}
Second option: one method per step
This should be a better option in many cases, to clarify things in the Wizard class; you can define a buildFormFieldsForStepXXX(FieldsContainer $formFields): void
, where XXX
is the camel cased name of you step. So in our example:
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function buildFormFieldsForStepComposeMessage(FieldsContainer $formFields): void
{
$formFields
->addField(
SharpFormTextareaField::make('message')
->setLabel('Message text')
->setRowCount(8),
);
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function buildFormFieldsForStepComposeMessage(FieldsContainer $formFields): void
{
$formFields
->addField(
SharpFormTextareaField::make('message')
->setLabel('Message text')
->setRowCount(8),
);
}
}
Define form layouts (if needed)
By default, fields will appear in the order of declaration, like for a regular Command. In case you need more control, you might want to define a layout; once again, you can use one global method:
protected function buildFormLayoutForStep(string $step, FormLayoutColumn &$column): void
{
// ...
}
protected function buildFormLayoutForStep(string $step, FormLayoutColumn &$column): void
{
// ...
}
... or define one per step:
protected function buildFormLayoutForStepComposeMessage(FormLayoutColumn &$column): void
{
// ...
}
protected function buildFormLayoutForStepComposeMessage(FormLayoutColumn &$column): void
{
// ...
}
Initialize form data
You will start to notice a pattern; one method for all:
protected function initialDataForStep(string $step): array
{
// ...
}
protected function initialDataForStep(string $step): array
{
// ...
}
or one method per step:
protected function initialDataForStepComposeMessage(): array
{
// ...
}
protected function initialDataForStepComposeMessage(): array
{
// ...
}
In the Instance case, methods have an $instanceId
param: initialDataForFirstStep(mixed $instanceId): array
and initialDataForStep(string $step, mixed $instanceId): array
.
Write the execution code of each step
Very much like first step, you must define the execution code of each step. And like form declaration, this could be done either in one method, or in one by step:
One method for all
Entity and Dashboard case:
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function executeStep(string $step, array $data = []): array
{
if ($step === 'compose-message') {
return $this->toStep('checkout');
} else {
// ...
}
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function executeStep(string $step, array $data = []): array
{
if ($step === 'compose-message') {
return $this->toStep('checkout');
} else {
// ...
}
}
}
Instance case:
public function executeStep(string $step, mixed $instanceId, array $data = []): array
{
// ...
}
public function executeStep(string $step, mixed $instanceId, array $data = []): array
{
// ...
}
One method per step
Similarly to forms and layouts; for Entity and Dashboard cases:
public function executeStepComposeMessage(array $data = []): array
{
// ...
}
public function executeStepComposeMessage(array $data = []): array
{
// ...
}
Instance case:
public function executeStepComposeMessage(mixed $instanceId, array $data = []): array
{
// ...
}
public function executeStepComposeMessage(mixed $instanceId, array $data = []): array
{
// ...
}
Validate posted data
Validation works the same as for regular Commands, with $this->validate()
:
public function executeStepComposeMessage(array $data = []): array
{
$this->validate($data, ['message' => 'required']);
// ...
}
public function executeStepComposeMessage(array $data = []): array
{
$this->validate($data, ['message' => 'required']);
// ...
}
Link steps, terminate the Wizard
As seen before, Wizard commands provide a new toStep(string $step)
action that can be returned in execution methods.
An any point, if a step returns another action (view
, download
, info
...), this will lead to terminate the Wizard. This means that steps are dynamically linked: you can finish after the first step if some data was entered, or link to another one in other cases.
If an exception is thrown (an in particular, a SharpApplicativeException
), the Wizard is also stopped.
Keep context between steps
This is a key part of Wizard commands: each step may need data from the previous one. To achieve this, you may use you regular storage (database) to store some state, but often it's better not to persist anything before the end of the Wizard.
For this purpose, you have access to a shared context, maintained between each step, via $this->getWizardContext()
. You can:
- store a value:
$this->getWizardContext()->put('name', 'value')
(typically, in theexecute()
method) - retrieve a value:
$this->getWizardContext()->get('name')
(in theinitialData()
method) - validate a stored value:
$this->getWizardContext()->validate('name', $rules)
(in theinitialData()
method)
Consider the following example; first we build and execute the first step; in the process, we save the select post ids in the context:
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function buildFormFieldsForFirstStep(FieldsContainer $formFields): void
{
$formFields->addField(
SharpFormSelectField::make('posts', Post::pluck('name', 'id')->toArray())
->setMultiple()
->setLabel('Posts to add to the message')
);
}
public function executeFirstStep(array $data = []): array
{
$this->validate($data, ['posts' => 'required']);
$this->getWizardContext()->put('posts', $data['posts']);
return $this->toStep('compose_message');
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function buildFormFieldsForFirstStep(FieldsContainer $formFields): void
{
$formFields->addField(
SharpFormSelectField::make('posts', Post::pluck('name', 'id')->toArray())
->setMultiple()
->setLabel('Posts to add to the message')
);
}
public function executeFirstStep(array $data = []): array
{
$this->validate($data, ['posts' => 'required']);
$this->getWizardContext()->put('posts', $data['posts']);
return $this->toStep('compose_message');
}
}
For the compose_message
step, we initialize data based on what is in the context, after validating that te context has post ids (to ensure we are coming from step 1):
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
protected function initialDataForStepComposeMessage(): array
{
$this->getWizardContext()->validate(['posts' => 'required']);
return [
'message' => collect(
['Here’s a list of posts I think you may like:'])
->merge(
Post::whereIn('id', $this->getWizardContext()->get('posts'))
->get()
->pluck('title')
)
->implode("\n"),
];
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
protected function initialDataForStepComposeMessage(): array
{
$this->getWizardContext()->validate(['posts' => 'required']);
return [
'message' => collect(
['Here’s a list of posts I think you may like:'])
->merge(
Post::whereIn('id', $this->getWizardContext()->get('posts'))
->get()
->pluck('title')
)
->implode("\n"),
];
}
}
We build the form, and store a result useful for the next step in the context (and so on, until the end):
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function buildFormFieldsForStepComposeMessage(FieldsContainer $formFields): void
{
$formFields->addField(
SharpFormTextareaField::make('message')->setLabel('Message text')
);
}
public function executeStepComposeMessage(array $data = []): array
{
$this->validate($data, ['message' => 'required']);
$this->getWizardContext()->put('message', $data['message']);
return $this->toStep('choose_recipients');
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
// [...]
public function buildFormFieldsForStepComposeMessage(FieldsContainer $formFields): void
{
$formFields->addField(
SharpFormTextareaField::make('message')->setLabel('Message text')
);
}
public function executeStepComposeMessage(array $data = []): array
{
$this->validate($data, ['message' => 'required']);
$this->getWizardContext()->put('message', $data['message']);
return $this->toStep('choose_recipients');
}
}
Tip: compose the code
Sharp forces you to define the whole Wizard, with all its steps, in a single Command class. This is wanted, since the Command still has only one label, config, authorization... and is seen as a single process, as it should be. But it can lead to a big file with many rows; for this, there is a simple solution provided by PHP: using traits, one per step.
Maybe something like this, where each trait contains step related methods (initialData
, buildFormField
, buildFormLayout
, execute
):
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
use SendEmailStepChoosePosts,
SendEmailStepComposeMessage,
SendEmailStepSelectRecipients;
public function label(): ?string
{
return 'Compose an email with chosen posts...';
}
public function authorize(): bool
{
return auth()->user()->hasRole('admin');
}
}
class SendEmailWithPostsWizardCommand extends EntityWizardCommand
{
use SendEmailStepChoosePosts,
SendEmailStepComposeMessage,
SendEmailStepSelectRecipients;
public function label(): ?string
{
return 'Compose an email with chosen posts...';
}
public function authorize(): bool
{
return auth()->user()->hasRole('admin');
}
}