Ontdek hoe het moet: Drupal 8 - Multi step forms

Drupal tip
Drupal tip

Ontdek hoe het moet: Drupal 8 - Multi step forms

Een multi step form is een veel gevraagde functionaliteit binnen de web-ontwikkelings wereld. Drupal 7 voorziet hiervoor verschillende out-of-the-box oplossingen:

Jammer genoeg zijn deze modules nog niet geported naar Drupal 8. In deze korte tutorial leggen we uit hoe je een basic multi step form framework kan maken. Wat deze tutorial niet bevat is de uitleg over basic drupal 8 routing/forms/best practices. We gaan er dus van uit dat je al wat kennis op zak hebt.

Een volledig werkend voorbeeld kan je vinden op http://www.entityone.be/multi_step_form.zip. Na het enabelen van de module kan je het multi step form raadplegen op "/multi-step-form"
Alle codevoorbeelden hieronder maken gebruik van interfaces, deze kan je terugvinden in de voorbeeld module.

1. Maak de nodige base classes aan

1.1 De class "BaseButton"

Deze class voorziet de basis functionaliteit van een submit button. Elke submit button die we op ons formulier willen plaatsen moet deze class extenden. De functie "getSubmitHandler" laat toe om na de default submit handler extra functionaliteit te voorzien. Dit kan bv handig zijn als er op de laatse stap een button "Finish" is voorzien en er waardes naar de databank moeten geschreven worden.


abstract class BaseButton implements ButtonInterface{

  /**
   * @inheritDoc.
   */
  public function ajaxify() {
    return TRUE;
  }

  /**
   * @inheritDoc.
   */
  public function getSubmitHandler() {
   return FALSE;
  }
}

1.2 De class "BaseValidator"

De "BaseValidator" class voorziet de basis functionaliteit voor elke validator die we later zullen toevoegen.


abstract class BaseValidator implements ValidatorInterface {

  protected $errorMessage;

  /**
   * BaseValidator constructor.
   * @param $error_message
   */
  public function __construct($error_message) {
    $this->errorMessage = $error_message;
  }

  /**
   * @inheritDoc
   */
  public function getErrorMessage() {
    return $this->errorMessage;
  }

}

1.3 De class "BaseStep"

Deze class voorziet de basis functionaliteit van de stappen in ons formulier. Elke stap moet deze class extenden. Verder heeft deze class 1 abstract function die de child classes verplicht om te definiëren om welke stap het gaat.


abstract class BaseStep implements StepInterface{

  protected $step;
  protected $values;

  /**
   * BaseStep constructor.
   */
  public function __construct() {
    $this->step = $this->setStep();
  }

  /**
   * @inheritdoc
   */
  public function getStep() {
    return $this->step;
  }

  /**
   * @inheritdoc
   */
  public function isLastStep() {
    return FALSE;
  }

  /**
   * @inheritdoc
   */
  public function setValues($values) {
    $this->values = $values;
  }

  /**
   * @inheritdoc
   */
  public function getValues() {
   return $this->values;
  }

  /**
   * @inheritDoc.
   */
  public function getFieldNames() {
    return array();
  }

  /**
   * @inheritDoc.
   */
  public function getFieldsValidators(){
    return array();
  }

  /**
   * Sets current step.
   */
  protected abstract function setStep();
}

2 Definieer classes die de base classes extenden

2.1 Voorbeeld van een "Button" class

Dit voorbeeld maakt de "volgende" button aan op de eerste stap aan. De button definieert een key en het form element zelf. Let op dat we op het form element een property "#goto_step" definiëren om duidelijk te maken naar welke stap deze button moet navigeren. Dit maakt het mogelijk om op bepaalde buttons onmiddellijk naar stap 3,4,... te navigeren.


class StepOneNextButton extends BaseButton {

  /**
   * @inheritDoc.
   */
  public function getKey() {
   return 'next';
  }

  /**
   * @inheritDoc.
   */
  public function build(){
    return array(
      '#type' => 'submit',
      '#value' => t('Next'),
      '#goto_step' => 2,
    );
  }

}

2.2 Voorbeeld van een "Validator" class

Een validator class valideert als de waarde van het veld voldoet aan bepaalde regels.


class ValidatorRegex extends BaseValidator {

  protected $pattern;

  /**
   * ValidatorRegex constructor.
   * @param $error_message
   * @param $pattern
   */
  public function __construct($error_message, $pattern) {
    parent::__construct($error_message);
    $this->pattern = $pattern;
  }

  /**
   * @inheritDoc
   */
  public function validates($value) {
    return preg_match($this->pattern,  $value);
  }

}

2.3 Voorbeeld van een "Step" class

Deze class definieert verschillende belangrijke elementen:

  • De huidige stap waarop we zitten (setStep)
  • De buttons die op de stap moeten afgebeeld worden (getButtons)
  • De form elementen die de stap moet bevatten (buildStepFormElements)
  • De veldnamen van de aanwezige velden (getFieldNames)
  • De validators voor elk veld (getFieldValidators)

class StepOne extends BaseStep {

  /**
   * @inheritDoc.
   */
  protected function setStep() {
    return 1;
  }

  /**
   * @inheritDoc.
   */
  public function getButtons() {
    return array(
      new StepOneNextButton(),
    );
  }

  /**
   * @inheritDoc.
   */
  public function buildStepFormElements() {
    $form['name'] = array(
      '#type' => 'textfield',
      '#title' => t("What's your name?"),
      '#required' => FALSE,
      '#default_value' => isset($this->getValues()['name']) ? $this->getValues()['name'] : NULL,
    );

    return $form;
  }

  /**
   * @inheritDoc.
   */
  public function getFieldNames() {
    return array(
      'name',
    );
  }

  /**
   * @inheritDoc.
   */
  public function getFieldsValidators() {
    return array(
      'name' => array(
        new ValidatorRegex('Your name has to contain a number', '/\d/'),
      ),
    );
  }
}

3 Definieer een manager class om bewerkingen uit te voeren op de steps

Bewerkingen uitvoeren op steps zullen we in een aparte class plaatsen. Zo is alle functionaliteit mooi gescheiden van elkaar. Dit verhoogt de testability en plugability van de code.

Deze class definieert verschillende belangrijke functies:

  • addStep: Voeg een instantie van de class "step" met de ingevulde waarden toe aan de manager.
  • getStep: Haalt een bepaalde step op. Indien die nog niet bestaat, wordt een instantie van de step aangemaakt.
  • getAllSteps: Haalt alle bestaande steps op.

In de functie "addStep" wordt aan de hand van de step id automatisch een object van de correcte step geïnitialiseerd (StapsEnum::map). Dit kan ook gewoon in een "switch" statement geprogrammeerd worden:


switch($step_id){
  case 1 : $step = new StepOne();
    ...
}


class StepManager {

  protected $steps;

  /**
   * StepManager constructor.
   */
  public function __construct() {
    
  }

  /**
   * Add a step to the steps property.
   */
  public function addStep(StepInterface $step) {
    $this->steps[$step->getStep()] = $step;
  }

  /**
   * Fetches a step from the steps property.
   * If it doesn't exist, create step object.
   */
  public function getStep($step_id) {
    if (isset($this->steps[$step_id])) {
      // If step was already initialized, use that step.
      // Chance is there are values stored on that step.
      $step = $this->steps[$step_id];
    }
    else {
      // Get class.
      // Example: this function will return "StepOne" if you pass 1 to the function.
      $class = StepsEnum::map($step_id);
      // Init step.
      $step = new $class();
    }

    return $step;
  }

  /**
   * @returns all steps.
   */
  public function getAllSteps(){
    return $this->steps;
  }

4 Puttin' it all together

Nu we alle nodige classes hebben aangemaakt, wordt het dringend tijd om alles "aan elkaar te lijmen". Dit gebeurt in een "form" class die de drupal "FormBase" class extend.

4.1 Constructor van de form class

Hier definiëren we de initiële stap van ons formulier en de step manager.


public function __construct() {
   $this->step_id = 1;
   $this->stepManager = new StepManager();
}

4.2 "buildForm" functie

Dit is de functie die alle heavy lifting voor zich neemt. Hier wordt het formulier opgebouwd.


public function buildForm(array $form, FormStateInterface $form_state) {
  $form['wrapper-messages'] = array(
    '#type' => 'container',
    '#attributes' => array(
      'id' => 'messages-wrapper',
    ),
  );

  $form['wrapper'] = array(
    '#type' => 'container',
    '#attributes' => array(
      'id' => 'form-wrapper',
    ),
  );

  // Get step from step manager.
  $this->step = $this->stepManager->getStep($this->step_id);

  // Attach step form elements.
  $form['wrapper'] += $this->step->buildStepFormElements();

  // Attach buttons.
  $form['wrapper']['actions']['#type'] = 'actions';
  $buttons = $this->step->getButtons();
  foreach ($buttons as $button) {
    $form['wrapper']['actions'][$button->getKey()] = $button->build();

    if ($button->ajaxify()) {
      // Add ajax to button.
      $form['wrapper']['actions'][$button->getKey()]['#ajax'] = array(
        'callback' => array($this, 'loadStep'),
        'wrapper' => 'form-wrapper',
        'effect' => 'fade',
      );
    }

    $callable = array($this, $button->getSubmitHandler());
    if ($button->getSubmitHandler() && is_callable($callable)) {
      // attach submit handler to button, so we can execute it later on.
      $form['wrapper']['actions'][$button->getKey()]['#submit_handler'] = $button->getSubmitHandler();
    }
  }

  return $form;
}

4.3 "loadStep" functie

Deze functie laat toe om de volgende stap via ajax te laden via een mooi "fade" effect. Dit wordt enkel toegepast als de functie "ajaxify()" op de geklikte button "TRUE" returnt.


public function loadStep(array &$form, FormStateInterface $form_state) {
  $response = new AjaxResponse();

  $messages = drupal_get_messages();
  if (!empty($messages)) {
    // Form did not validate, get messages and render them.
    $messages = [
      '#theme' => 'status_messages',
      '#message_list' => $messages,
      '#status_headings' => [
        'status' => t('Status message'),
        'error' => t('Error message'),
        'warning' => t('Warning message'),
      ],
    ];
    $response->addCommand(new HtmlCommand('#messages-wrapper', $messages));
  }
  else {
    // Remove messages.
    $response->addCommand(new HtmlCommand('#messages-wrapper', ''));
  }

  // Update Form.
  $response->addCommand(new HtmlCommand('#form-wrapper', $form['wrapper']));

  return $response;
}

4.4 "validateForm" functie

Deze functie zorgt voor de form validate. De validatie gebeurt op de eerder aangemaakte "Validators" gedefinieerd in de class van de huidige step.


public function validateForm(array &$form, FormStateInterface $form_state) {
  $triggering_element = $form_state->getTriggeringElement();
  // Only validate if validation doesn't have to be skipped.
  // For example on "previous" button.
  if (empty($triggering_element['#skip_validation']) && $fields_validators = $this->step->getFieldsValidators()) {
    // Validate fields.
    foreach ($fields_validators as $field => $validators) {
      // Validate all validators for field.
      $field_value = $form_state->getValue($field);
      foreach ($validators as $validator) {
        if (!$validator->validates($field_value)) {
          $form_state->setErrorByName($field, $validator->getErrorMessage());
        }
      }
    }
  }
}

4.5 "submitForm" functie.

De laatste belangrijke functie is de submit functie. Deze neemt voor zijn rekening:

  • Opslaan van de ingevulde waardes
  • Opslaan van de step in de step manager
  • Laden van de volgende stap
  • Eventueel een extra submit handler oproepen.

public function submitForm(array &$form, FormStateInterface $form_state) {
  // Save filled out values to step. So we can use them as default_value later on.
  $values = array();
  foreach ($this->step->getFieldNames() as $name) {
    $values[$name] = $form_state->getValue($name);
  }
  $this->step->setValues($values);
  // Add step to manager.
  $this->stepManager->addStep($this->step);
  // Set step to navigate to.
  $triggering_element = $form_state->getTriggeringElement();
  $this->step_id = $triggering_element['#goto_step'];

  // If an extra submit handler is set, execute it.
  // We already tested if it is callable before.
  if(isset($triggering_element['#submit_handler'])){
    $this->{$triggering_element['#submit_handler']}($form, $form_state);
  }

  $form_state->setRebuild(TRUE);
}

5 Conclusie

Er is behoorlijk wat werk en technische kennis nodig om een multi step form te realiseren in Drupal 8. Maar éénmaal dit "framework" is opgezet, is het zeer simpel om stappen, buttons en form elementen toe te voegen of te verwijderen. 

Deze oplossing is maar één van de vele mogelijkheden om dit probleem te tackelen. 

 

Blijf op de hoogte via onze nieuwsbrief