5

I want to turn a node form into a multi-step form using ajax. I've added ajax-enabled submit buttons and some logic in a hook_form_alter (see below) to handle inc- and decrementing steps, and showing and hiding form items based on the current step. The stepping back and forth works great, in terms of rendering the correct elements.

The problem is, if I enter some data in one step, go forward or backward, and then return to the original step, the data I entered is gone from the form. If I check the value of the item for which I entered data using FormStateInterface::getValue(), the data is there when my alter hook runs when leaving the original step, but gone on subsequent next/back transitions. This problem also does not apply to data that is already in the form (e.g. when editing an existing node).

Clearly, I need to store the incoming values somewhere, but where? And then, how do I restore them to the form elements on the appropriate step?

My code (minimal example):

<?php
/**
 * Implements hook_form_FORM_ID_alter().
 */
function my_module_form_node_ajax_test_form_alter(&$form, $form_state) {
  // Define steps. Each child array is a step, and each string in the
  // a child array is a field that should be present on that step.
  $steps = [
    ['title'],
    ['body'],
  ];
  // Add wrapper.
  $form['#prefix'] = '<div id="ajax-form-wrapper">';
  $form['#suffix'] = '</div>';
  // If the step is not set, this is step 0.
  if (!$form_state->has('step')) {
    $form_state->set('step', 0);
  }
  // Get the current step.
  $step = $form_state->get('step');
  // If we have a triggering element, increment or decrement step as
  // appropriate.
  if ($trigger = $form_state->getTriggeringElement()) {
    switch ($trigger['#name']) {
      case 'next':
        $form_state->set('step', ++$step);
        break;

      case 'back':
        $form_state->set('step', --$step);
        break;
    }
  }
  // Show elements in the current step, hide others. Always show the
  // `actions` element, and all `form_*` elements.      
  foreach (Element::children($form) as $element) {
    $form[$element]['#access'] = (
      in_array($element, $steps[$step])
      || $element == 'actions'
      || strpos($element, 'form') === 0
    );
  }
  // Show the back button if this is not the first step.
  $form['actions']['back'] = $step > 0 ? ajax_button('back') : NULL;
  // Show the next button if this is not the last step.
  $is_last_step = $step == count($steps) - 1;
  $form['actions']['next'] = !$is_last_step ? ajax_button('next') : NULL;
  // Show the submit button if this is the last step.
  $form['actions']['submit']['#access'] = $is_last_step;
}

/**
 * Return a render array for an ajax step button.
 */
function ajax_button($name) {
  return [
    '#type' => 'button',
    '#value' => $name,
    '#name' => $name,
    '#ajax' => [
      'wrapper' => 'ajax-form-wrapper',
    ],
  ];
}
3

This code is quite impressive. I did not think it was possible to build a multi-step entity form with a few lines in a form alter hook. To keep it this simple you can try to use the entity object to store the values, because the form object is serialized and cached between the steps and a method to build such an entity from the submitted values is part of the entity form class:

  if ($trigger = $form_state->getTriggeringElement()) {
    $form_object = $form_state->getFormObject();
    $new_entity = $form_object->buildEntity($form, $form_state);
    $form_object->setEntity($new_entity);
  • Thanks for the compliment! I posted my own answer with the full code, but this is exactly the solution I discovered after many hours of research. – Xaq May 15 '18 at 14:55
1

Well, I figured it out. (This was before @4k4's answer, which is very close to what I ended up with).

I needed to build the entity using the submitted form values and then set the form to rebuild (which puts the values from the entity into the form) in a submit handler. Here's how I did it:

<?php
/**
 * Return a render array for an ajax step button.
 */
function ajax_button($name) {
  return [
    // Changed from 'button', buttons don't invoke submit handlers, 
    // submits do.
    '#type' => 'submit',
    '#value' => $name,
    '#name' => $name,
    // Set the submit handler.
    '#submit' = ['my_module_ajax_form_submit'],
    '#ajax' => [
      'wrapper' => 'ajax-form-wrapper',
    ],
  ];
}

/**
 * Build entity using submitted form data and rebuild form.
 */
function my_module_ajax_form_submit($form, $form_state) {
  // Get the NodeForm object.
  $form_object = $form_state->getFormObject();
  // NodeForm::buildEntity() maps form values into the entity object
  // and returns it.
  $entity = $form_object->buildEntity($form, $form_state);
  // NodeForm::setEntity() stores the entity in the form object which 
  // is used to populate form fields when the form rebuilds.
  $form_object->setEntity($entity);
  // Set the form to rebuild.
  $form_state->setRebuild()
}

I got here by following very closely the various implementations of ::buildEntity() and ::buildForm() in NodeForm and its ancestors ContentEntityForm and EntityForm.

0

I think you need to use the symfony sessions

Set session variable

$session = \Drupal::request()->getSession();
$session->set('mymodule_variable', 'value');

Later on you can get this variable like this

$session = \Drupal::request()->getSession();
$value = $session->get('mymodule_variable', 'default_value');

Symfony sessions are equivalent to php $_SESSION

0

It seems that the correct way todo this is with PrivateTempStore, if you go to see the CommentAdminOverview in Core you will see this:

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
      ...
      $info = [];
      /** @var \Drupal\comment\CommentInterface $comment */
      foreach ($comments as $comment) {
        $langcode = $comment->language()->getId();
        $info[$comment->id()][$langcode] = $langcode;
      }
      $this->tempStoreFactory
        ->get('comment_multiple_delete_confirm')
        ->set($this->currentUser()->id(), $info);
      $form_state->setRedirect('comment.multiple_delete_confirm');
    }
  }

In this page you collect the comment id that you want to delete and made a redirect, and the comment deletion happens in comment.multiple_delete_confirm (ConfirmDeleteMultiple) that have the following code:

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $this->commentInfo = $this->tempStoreFactory->get('comment_multiple_delete_confirm')->get($this->currentUser()->id());
    if (empty($this->commentInfo)) {
      return $this->redirect('comment.admin');
    }
    /** @var \Drupal\comment\CommentInterface[] $comments */
    $comments = $this->commentStorage->loadMultiple(array_keys($this->commentInfo));
  ...
  }

Views also works with PrivateTempStorage, this is why when you return to a view that is not saved you can see again the changes.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.