In this article, we are going to look at building a multistep
form in Drupal 8. For brevity, the form will have only two steps in the
shape of two completely separate forms. To persist values across these
steps, we will use functionality provided by Drupal’s core for storing
temporary and private data across multiple requests.
In Drupal 7, a similar approach can be achieved using the cTools object cache. Alternatively, there is the option of persisting data through the
The code we write in this article can be found in this repository alongside much of the Drupal 8 work we’ve been doing so far. We will be dealing with forms quite a lot so I do recommend checking out one of the previous articles on Drupal 8 in which we talk about forms.
Technically, both of these forms will inherit common functionality from an abstract form class we will call
We will group all the form classes together and place them inside a new folder called
MultistepFormBase.php:
The
Now it’s time for the actual forms that will represent steps in the process. We start with the first class inside a file called
In the
In the
Since we’ve touched upon the issue of routes, let’s update the route file in our
demo.routing.yml:
Finally, we can create our second form (inside a file called
Again, we are extending from our base class like we did with the first form. This time, however, we have different form elements and we are adding a new action link next to the submit button. This will allow users to navigate back to the first step of the form process.
Inside the
And that is pretty much it. We should now have a working multistep form that uses the
In Drupal 7, a similar approach can be achieved using the cTools object cache. Alternatively, there is the option of persisting data through the
$form_state
array as illustrated in this tutorial. The code we write in this article can be found in this repository alongside much of the Drupal 8 work we’ve been doing so far. We will be dealing with forms quite a lot so I do recommend checking out one of the previous articles on Drupal 8 in which we talk about forms.
The plan
As I mentioned above, our multistep form will consist of two independent forms with two simple elements each. Users will be able to fill in the first one and move to the second form where they can either go back to the previous step or fill it in and press submit. While navigating between the different steps, the previously submitted values are stored and used to pre-populate the form fields. If the last form is submitted, however, the data gets processed (not covered in this article) and cleared from the temporary storage.Technically, both of these forms will inherit common functionality from an abstract form class we will call
MultistepFormBase
.
This class will be in charge of injecting the necessary dependencies,
scaffolding the form, processing the end result and anything else that
is needed and is common to both. We will group all the form classes together and place them inside a new folder called
Multistep
located within the Form
plugin directory of our demo
module (next to the old DemoForm
).
This is purely for having a clean structure and being able to quickly
tell which forms are part of our multistep form process. The code
We will start with the form base class. I will explain what is going on here after we see the code.MultistepFormBase.php:
/**
* @file
* Contains \Drupal\demo\Form\Multistep\MultistepFormBase.
*/
namespace Drupal\demo\Form\Multistep;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\SessionManagerInterface;
use Drupal\user\PrivateTempStoreFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
abstract class MultistepFormBase extends FormBase {
/**
* @var \Drupal\user\PrivateTempStoreFactory
*/
protected $tempStoreFactory;
/**
* @var \Drupal\Core\Session\SessionManagerInterface
*/
private $sessionManager;
/**
* @var \Drupal\Core\Session\AccountInterface
*/
private $currentUser;
/**
* @var \Drupal\user\PrivateTempStore
*/
protected $store;
/**
* Constructs a \Drupal\demo\Form\Multistep\MultistepFormBase.
*
* @param \Drupal\user\PrivateTempStoreFactory $temp_store_factory
* @param \Drupal\Core\Session\SessionManagerInterface $session_manager
* @param \Drupal\Core\Session\AccountInterface $current_user
*/
public function __construct(PrivateTempStoreFactory $temp_store_factory, SessionManagerInterface $session_manager, AccountInterface $current_user) {
$this->tempStoreFactory = $temp_store_factory;
$this->sessionManager = $session_manager;
$this->currentUser = $current_user;
$this->store = $this->tempStoreFactory->get('multistep_data');
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('user.private_tempstore'),
$container->get('session_manager'),
$container->get('current_user')
);
}
/**
* {@inheritdoc}.
*/
public function buildForm(array $form, FormStateInterface $form_state) {
// Start a manual session for anonymous users.
if ($this->currentUser->isAnonymous() && !isset($_SESSION['multistep_form_holds_session'])) {
$_SESSION['multistep_form_holds_session'] = true;
$this->sessionManager->start();
}
$form = array();
$form['actions']['#type'] = 'actions';
$form['actions']['submit'] = array(
'#type' => 'submit',
'#value' => $this->t('Submit'),
'#button_type' => 'primary',
'#weight' => 10,
);
return $form;
}
/**
* Saves the data from the multistep form.
*/
protected function saveData() {
// Logic for saving data goes here...
$this->deleteStore();
drupal_set_message($this->t('The form has been saved.'));
}
/**
* Helper method that removes all the keys from the store collection used for
* the multistep form.
*/
protected function deleteStore() {
$keys = ['name', 'email', 'age', 'location'];
foreach ($keys as $key) {
$this->store->delete($key);
}
}
}
Our abstract form class extends from the default Drupal FormBase
class so that we can use some of the functionality made available by it and the traits it uses. We are using dependency injection to inject some of the needed services:PrivateTempStoreFactory
gives us a temporary store that is private to the current user (PrivateTempStore
). We will keep all the submitted data from the form steps in this store. In the constructor, we are also immediately saving thestore
attribute which contains a reference to themultistep_data
key/value collection we will use for this process. Theget()
method on the factory either creates the store if it doesn’t exist or retrieves it from the storage.- The
SessionManager
allows us to start a session for anonymous users. - The
CurrentUser
allows us to check if the current user is anonymous.
buildForm()
method we do two main things. First, we start a session for anonymous
users if one does’t already exist. This is because without a session we
cannot pass around temporary data across multiple requests. We use the
session manager for this. Second, we create a base submit action button
that will be present on all the implementing forms.The
saveData()
method is
going to be called from one or more of the implementing forms and is
responsible with persisting the data from the temporary storage once the
multistep process is completed. We won’t be going into the details of
this implementation because it depends entirely on your use case (e.g.
you can create a configuration entity from each submission). We do,
however, handle the removal of all the items in the store once the data
has been persisted. Keep in mind though that these types of logic checks
should not be performed in the base class. You should defer to a
dedicated service class as usual, or use a similar approach.Now it’s time for the actual forms that will represent steps in the process. We start with the first class inside a file called
MultistepOneForm.php
:/**
* @file
* Contains \Drupal\demo\Form\Multistep\MultistepOneForm.
*/
namespace Drupal\demo\Form\Multistep;
use Drupal\Core\Form\FormStateInterface;
class MultistepOneForm extends MultistepFormBase {
/**
* {@inheritdoc}.
*/
public function getFormId() {
return 'multistep_form_one';
}
/**
* {@inheritdoc}.
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
$form['name'] = array(
'#type' => 'textfield',
'#title' => $this->t('Your name'),
'#default_value' => $this->store->get('name') ? $this->store->get('name') : '',
);
$form['email'] = array(
'#type' => 'email',
'#title' => $this->t('Your email address'),
'#default_value' => $this->store->get('email') ? $this->store->get('email') : '',
);
$form['actions']['submit']['#value'] = $this->t('Next');
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->store->set('email', $form_state->getValue('email'));
$this->store->set('name', $form_state->getValue('name'));
$form_state->setRedirect('demo.multistep_two');
}
}
This form will look something like this:In the
buildForm()
method we
are defining our two dummy form elements. Do notice that we are
retrieving the existing form definition from the parent class first. The
default values for these fields are set as the values found in the
store for those keys (so that users can see the values they filled in at
this step if they come back to it). Finally, we are changing the value
of the action button to Next
(to indicate that this form is not the final one). In the
submitForm()
method we save the submitted values to the store and then redirect to the second form (which can be found at the route demo.multistep_two
).
Keep in mind that we are not doing any sort of validation here to keep
the code light. But most use cases will call for some input validation.Since we’ve touched upon the issue of routes, let’s update the route file in our
demo
module and create two new routes for our forms:demo.routing.yml:
demo.multistep_one:
path: '/demo/multistep-one'
defaults:
_form: '\Drupal\demo\Form\Multistep\MultistepOneForm'
_title: 'First form'
requirements:
_permission: 'access content'
demo.multistep_two:
path: '/demo/multistep-two'
defaults:
_form: '\Drupal\demo\Form\Multistep\MultistepTwoForm'
_title: 'Second form'
requirements:
_permission: 'access content'
For more information about what is going on in this file you can read one of the previous Drupal 8 articles which explain routes as well. Finally, we can create our second form (inside a file called
MultistepTwoForm
):/**
* @file
* Contains \Drupal\demo\Form\Multistep\MultistepTwoForm.
*/
namespace Drupal\demo\Form\Multistep;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
class MultistepTwoForm extends MultistepFormBase {
/**
* {@inheritdoc}.
*/
public function getFormId() {
return 'multistep_form_two';
}
/**
* {@inheritdoc}.
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
$form['age'] = array(
'#type' => 'textfield',
'#title' => $this->t('Your age'),
'#default_value' => $this->store->get('age') ? $this->store->get('age') : '',
);
$form['location'] = array(
'#type' => 'textfield',
'#title' => $this->t('Your location'),
'#default_value' => $this->store->get('location') ? $this->store->get('location') : '',
);
$form['actions']['previous'] = array(
'#type' => 'link',
'#title' => $this->t('Previous'),
'#attributes' => array(
'class' => array('button'),
),
'#weight' => 0,
'#url' => Url::fromRoute('demo.multistep_one'),
);
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->store->set('age', $form_state->getValue('age'));
$this->store->set('location', $form_state->getValue('location'));
// Save the data
parent::saveData();
$form_state->setRedirect('some_route');
}
}
This one will look like this, again very simple:Again, we are extending from our base class like we did with the first form. This time, however, we have different form elements and we are adding a new action link next to the submit button. This will allow users to navigate back to the first step of the form process.
Inside the
submitForm()
method we again save the values to the store and defer to the parent
class to persist this data in any way it sees fit. We then redirect to
whatever page we want (the route we use here is a dummy one). And that is pretty much it. We should now have a working multistep form that uses the
PrivateTempStore
to keep data available across multiple requests. If we need more steps,
all we have to do is create some more forms, add them in between the
existing ones and make a couple of adjustments. Of course you can make
this much more flexible by not hardcoding the route names in the links
and redirects, but I leave that part up to you.Conclusion
In this article, we looked at a simple way to create a multistep form in Drupal 8. You can, of course, build on this approach and create highly complex and dynamic processes that involve not just forms but also other kinds of steps that leverage cross-request data. Thus, the purpose of this article has been as much about multistep forms as it has been about illustrating the power of thePrivateTempStore
.
And if you, like me, think that the cTools object cache is very
powerful in Drupal 7, you’ll be very invested in its Drupal 8
counterpart.
No comments:
Post a Comment
Thanks for your comment.