My appreciation for form API in Drupal is on the same level as my attempt to avoid it when it comes to user facing forms. Both are pretty high. The reasons I love it are because it’s extendable and security is built in. I’ve worked with a few other frameworks in different languages, and my impression is that Drupal’s form API is significantly more advanced than any other solution I’ve seen.
The reason I try to avoid it, on the other hand, is mainly because it’s hard to create forms that satisfy the end users, and achieve their expectations. Basically, forms are bulky, and going with a mix of JS/Ajaxy solutions is often a pain. Having a JS form (i.e. some JS widget that builds and controls the entire form), that POSTs to a RESTful endpoint takes more code, but often times provides a more streamlined user experience.
Not sure why and how, but over the years we’ve been tasked quite a few times with creating form wizards. It’s frequently used for more complex registrations, like for students, or alumnus applying for different programs. In the early Drupal 7 days we went with CTools’ wizard, and then switched to Elm (i.e. a JS form) along with RESTful endpoints. Drupal 8 however has one major feature that makes it very appealing to work once more with form API - that is “Form modes.”
This post has an example repo, that you should be able to reliably download and run locally thanks to DDEV. I will not try to cover every single line of code - but rather share the concepts behind our implementation, with some references to the code. The audience is intermediate-level Drupal developers, that can expect to have a good sense of how to use Form modes to build wizards after reading this post and going over the code.
Before diving in, it’s important to recognize that “wizards” come in many flavors. I personally hold the opinion that a generic module cannot be (easily) built to accommodate all cases. Instead, I look at Drupal 8 with its core and a couple of contrib modules as the “generic” solution to build complex - sprinkled with lots of custom business logic - wizards.
In our case, and for demonstration purposes, we’ve built a simplified “Visa application” wizard – the same you may find when visiting other countries. As an aside, I do wish some of the countries I’ve visited lately would have implemented a similar solution. The experience of their wizards did bring me to tear some of the remaining hair I’ve still got.
Our objectives are:
- A user can have only a single Visa application.
- Users should have an easy overview of the state of their application.
- Sections (i.e. the wizard pages) must be completed to be able to submit it to final processing; however, it doesn’t have to happen in a single go. That is, a user can save a partial draft and return to it later.
- After a user “signed” the last section, the application is locked. The user can still see what they have submitted, but cannot edit it.
- A site admin should have easy access to view and edit existing applications.
With the above worthy goals, we were set for the implementation. Here are the two main concepts we’ve used.
Sections as Form Modes
In Drupal 7 we could define View modes for a node (well, for any entity, but let’s be node centric for now). For example, a “Teaser” view mode, would show only the title and trimmed body field; And a “Full” view mode would show the entire content. The same concept was applied to the node forms. That is, the node add/edit form we know, is in fact a “Default” form mode. With Drupal 8, we can now define multiple form modes.
That’s pretty big. Because it means we can have multiple forms, showing different fields - but they are all accumulated under a single node.
For each wizard “page” we’ve added a section: section_1
, section_2
,
etc. If you have the example repo running locally, you can see it in:
https://form-wizard-example.ddev.site:8243/admin/structure/display-modes/form
Next we have to enable those form modes for our Visa application
content
type.
Then we can see those form modes enabled, allowing us to setup fields and disable others under each section.
It’s almost enough. However, Drupal still doesn’t really know about those sections, and so we have to explicitly register them.
The next ingredient to our recipe, is having our wizard page controller recognize which section needs to be rendered. Drupal 8 has made it quite elegant, and it requires only a few lines to get it. So now, based on the URL, Drupal serves us the node form - with the correct form mode.
Faux Required is Not Required
One of our goals, as often requested by clients, was to allow saving the application in a draft state. We can easily do it by not checking the “required” option on the field settings, but from a UX perspective - how could a user know the field would eventually be required? So we’ve decided to mark the “required” fields with the usual red asterisk, along with a message indicating they are able to save a draft.
As for the config of the field, there is a question: should the fields be marked as required, and on the sections be un-required? Or should it be the other way around? We have decided to make it optional, as it has the advantage that a site admin can edit a node – via the usual node edit form – in case of some troubleshooting, and won’t be required to fill in all the fields (don’t worry – we’ll cover the fact that only admins can access directly the node view/edit/delete in the “Access” section).
So to reconcile the opposing needs, we came up with a “faux-required” setting. I have entertained the idea of calling it a “non-required required” just to see how that goes, but yeah… Faux-required is a 3rd party setting, in field config lingo.
By itself it doesn’t do much. It’s just a way for us to build our custom code around it. In fact, we have a whole manager class that helps us manage the logic, and have a proper API to determine, for example, the status of a section – is a section empty, partially filled, or completed. To do that we basically ask Drupal to give us a list of all the faux-required fields that appear under a given Form mode.
Access & Application Status
We need to make sure only site-admins have direct access to the node, and we don’t want applicants to be able to edit the node directly. So we have a Route subscriber that redirects to our own access callback, which in turn, relies on our implementation of hook_node_access.
As for knowing in which status the application is, we have a single required field (really required, not faux-required) - a field called “Status” with New, Locked, Accepted and Rejected options. Those are probably the most basic ones, and I can easily imagine how they could be extended.
With this status, we can control when the form is editable by the user or
disabled and without submit
buttons.
Having to write $form['#disabled'] = TRUE;
and knowing it will disable any
element on the form, is one of the prettiest parts form API.
Theming that Freaking Required Symbol
The subtitle says it all. Theming, like always and forever, is one of the hardest parts of the task. That is, unlike writing some API functions and having the feeling - “that’s the cleanest way possible,” with theme I often have the feeling of “it works, but I wonder if that’s the best way”. I guess the reason for this is that theming is indeed objectively hard to get “right.”
Anyway, with a bunch of form alters, preprocess and process functions callback we were able to tame the beast.
Field Groups
Many fields in different sections can make the job of site admins (or the ones responsible for processing or supporting the applications) quite hard. Field group is a nice way to mimic the structure of the sections both to the default node form, as well as the node view.
https://form-wizard-example.ddev.site:8243/node/1/edit
Summary
Form modes in Drupal 8 are a very useful addition, and a cornerstone in our implementation of wizards. I encourage you to jump into the code of the example repository, and tinker with it. It has lots of comments, and if you ignore some of the ugly parts, you might find some pretty ones hidden there: Extending the complex inline entity form widget with custom logic to be more user friendly, use Form API states for the “other” option and getting that to work with faux-required, implementing Theme negotiator, our DDEV config and more…
And finally, if you spot something you have a better idea for, I’d love to get PRs!