Building forms with the Forms module: how a form is put together and placed on a page, validating and branching, presenting it, where submissions are stored, and what happens after one arrives, from jobs and records to CRMs and payments.
L1–L2
Unit 01 · Building a form
Multi-step forms
Building a formPage 3 of 20Updated
Steps, and what happens between them in each of the two submission models: what is saved and when, how each model moves between steps and keeps answers, the markup the platform's JS form needs, a progress indicator, and how many steps is too many.
Who this is for. The theme developer building a form with more than one screen, and the site builder deciding whether it should have more than one. Afterwards you can choose a submission model, build its layout, show progress, and judge how many steps a form can bear.
A form gets a second step when it has a second screen. Each step in the admin is one screen for the visitor, and is either normal, collecting answers, or result, shown after a successful submit. A form whose OnSubmit action shows the result step must end with exactly one result step; a form that redirects needs none (What happens on submit). How the steps behave depends on which of the two submission models the form's layout uses.
Page per step: saved as it goes
In the page-per-step model, each step is an ordinary POST back to the page. The platform handles it while building the page:
The first submit creates a form submission with a random token. It stores a step submission for the step and an element submission for each field, each marked valid or invalid. The layout puts the token in a hidden _token field, and that field is the only thing linking the next step to this one.
The page then shows the form's current step: the submitted step if any answer on it is invalid, and otherwise the step after it.
The submission is marked completed only when every input on every step has a valid answer. The form then shows its result step or redirects.
Consequences worth designing for:
Abandoned forms leave incomplete submissions behind. A visitor who stops after step 1 has still stored step 1's answers, and they appear among the form's submissions without a completed date. That shows where people give up, and it is personal data too. A job that acts on a submission should start on Completed, not on creation.
Losing the token starts again. A visitor who reloads the page with a plain GET, or opens the form in a new tab, gets a new, empty submission. The old one stays incomplete.
Steps move forward only. The server always shows the next step that still needs answers. If visitors need to revisit an earlier step to correct an answer, use the JS model.
No step is skipped. The server moves to the next step by position, and a submission completes only when every step has been submitted, so a Step Visibility rule has no effect in this model.
A result step is rendered by the same layout, as the current step once the submission is complete: check getType() and leave the hidden fields and the button off it, as the minimal layout does.
All steps in one page: saved at the end
The JS model renders every step at once and lets the platform's script move between them. Load its three files on the pages that carry such a form:
Give both scripts the page's nonce. The platform's default script policy would run them without it, but a site whose policy adds 'strict-dynamic' runs only scripts that carry the nonce.
The script looks for this markup and nothing else, so keep the class names exactly as shown:
A container with the class coredna-js-form, holding a _formId field.
Inside it, one .form-step per step, each with data-step-name set to the step's name, so that conditional logic can find it. Every step except the visible one carries js-hidden.
Each normal step wraps its fields in its own <form>. The browser checks required and the other attributes step by step, because each step is submitted on its own. A submit on any step but the last only moves to the next step.
The last <form> is the real submit. It sends every step's answers as one JSON POST to /cdna-api/forms/submission/<form id>, and on success shows the last .form-step: the result step.
.navigate-previous goes back one step, and .go-to-step with data-step="2" jumps to a step. Hidden steps are skipped in either direction.
Five optional callbacks, each naming a global function on the container: data-start-callback (first keystroke, for analytics), data-navigate-callback (return false to stay on the step), data-submit-modifier-callback (change the data before it is sent), data-submit-callback (return false to cancel, or a promise), and data-submit-result-callback (see the handler on the validation page, which every JS form needs).
{% set current = form.getCurrentStep() %}
<div class="coredna-js-form" data-submit-result-callback="formResult">
{% for step in form.getSteps() %}
<div class="form-step{% if step.getPosition() != current.getPosition() %} js-hidden{% endif %}" data-step-name="{{ step.getName() }}">
{% if step.getType() == 'normal' %}
<form method="post">
<input type="hidden" name="_formId" value="{{ form.getId() }}">
{% for element in step.getElements().filtered('parent', null) %}
{{ element.getHtml() }}
{% endfor %}
{% if step.getPosition() > 1 %}<a href="#" class="navigate-previous">Back</a>{% endif %}
<button type="submit">Continue</button>
<p class="submit-error-message js-hidden" role="alert"></p>
</form>
{% else %}
{% for element in step.getElements() %}{{ element.getHtml() }}{% endfor %}
{% endif %}
</div>
{% endfor %}
</div>
The rules for conditional logic also need the form's conditionals on the page. That snippet is on Conditional logic. The trade-offs of the JS model:
Nothing is stored between steps. The answers live in the page until the final submit, so a reload, a closed tab or a lost connection loses them all. A long form that people fill in over several sittings needs the page-per-step model, or its own save-as-you-go script.
Every step is validated by the server at once, at the end. An error on step 1 is reported after step 4. The error has to be shown on step 1, and the visitor taken back there to see it.
A failed final submit may still have stored something. The API answers 207 when the submission was saved but some answers are invalid. A second attempt creates a new submission, leaving the first one incomplete.
Showing progress
The platform draws no progress indicator; the layout does. List the normal steps and mark the current one. The example below fits the page-per-step model. In the JS model, put the same list inside each .form-step with that step as the current one, as above.
{% set current = form.getCurrentStep() %}
{% if current.getType() == 'normal' %}
<ol class="form-progress">
{% for step in form.getSteps() %}
{% if step.getType() == 'normal' %}
<li{% if step.getPosition() == current.getPosition() %} aria-current="step"{% elseif step.getPosition() < current.getPosition() %} class="done"{% endif %}>
{{ step.getName() ?: 'Step ' ~ step.getPosition() }}
</li>
{% endif %}
{% endfor %}
</ol>
{% endif %}
Name every step in the admin, even if the design shows only numbers. A step's name is the label of its progress item, and the JS model uses it to find the step. A Step Visibility rule also points at the step by its name, so after renaming a step, point its Step Visibility rule at the new name.
If conditional logic can hide a step, a fixed list of step names overstates what is left. Either leave hidden steps out of the count, or show the names without "step 3 of 5".
How many steps
Every step is a click, a wait and a moment for the visitor to give up. A step earns its place when it groups questions the visitor thinks of as one subject ("about you", "about your event") or when its answers decide what is asked next. Guidance that holds up in practice:
Up to about seven short fields: one step. Splitting a short contact form in two adds a click and a wait, and buys nothing.
Longer forms: three to five steps, each a coherent subject, with the easiest questions first, so the visitor is invested before the hard ones.
Never a step with one field, unless its answer decides which steps follow.
Ask for contact details early on a long enquiry. With page per step, the partial submission then gives the client someone to call back when the form is abandoned, if the privacy policy allows it.
Past about five steps, measure. The incomplete submissions show where people stop (in the page-per-step model), and data-navigate-callback can send a step event to analytics (in the JS model).