2

I have ajaxified form where I add or remove plugins. If plugin is present it provides its configuration form.

I am using the SubFormState, as one should, but I have noticed that if plugin sets data into the form state's storage(ie. it also uses ajax), they will leak into the parent form state.

Beside key collision(high probabilty with multiple plugins) the main issue I am facing is that once I remove a plugin but add it again, the data from previous configuration is still present in the form state storage so I end up with already configured plugin form, which is highly undesired.

This is happening because the plugin has no way of knowing it has been removed from the parent form so there is no way to unset the data from storage and the parent form has no way of knowing the plugin(s) put something into the storage as well, so there is no garbage collecting possible.

I am wondering if I should Implement a plugin interface that would have "flushState" method that I would have to invoke when I would remove the plugin from the form or there's a better way to do this?

1 Answer 1

1

So far, I have solved it by using #array_parents for getting/setting values into form state storage in plugin forms since there cannot be a key collision in parent form this way.

Small issue with this is that I have to provide the whole parents in there. So if the plugin form is not flat, I have to add all those additional parents in there since they will appear in the ...

... additionally to this in ajax plugin form submit handlers and ajax callbacks I have to get the the "root" element(ie. the form element for the plugin) by detecting #array_parents on the trigger and extracting the proper form element by #array_parents and extracting the #array_parents from it to get to the data in the storage.

Overall it is a massiv DX fail. I have a ***load of plugins and to bulletproof them this way will take a lot of unnecessary time now.

I was thinking about extending the SubformState as KeyedSubformState where the state would "hide" the plugin's storage under provided key so the parent form would control this and be responsible for avoiding key collision and the plugin form doesn't have to be altered in any way(since values are already taken care of) but I have decided not to do it this way since it feels like adding additional layer is not a good idea in the long run...also it won' work for ajax submit handlers since they receive the full form and state and not the subform element and state.

If anyone has a better solution, I'm all ears. But for now it seems plugins are treated like homeles people :D


Also, for the love of god, do not use #parents in plugin form. Or you'll be leaking values into the parent form. Overall, just make the plugin form as dumb as possible. Flat and no ajax, just to be safe.


This is, currently working, plugin with ajax.

/**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    $ajax = [
      'wrapper' => Html::getId($this->getPluginId() . '-countries-ajax-wrapper'),
      'callback' => [get_called_class(), 'ajaxRebuildCountries'],
      'progress' => [
        'type' => 'throbber'
      ]
    ];

    $form['allowed_countries'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('Allowed countries'),
      '#prefix' => '<div id="' . $ajax['wrapper'] . '">',
      '#suffix' => '</div>'
    ];

    $country_list = $this->countryManager->getList();

    // @see https://drupal.stackexchange.com/questions/243812
    // @see https://www.drupal.org/node/2902121
    $state = $form['#array_parents'];
    $state[] = 'allowed_countries';
    $state[] = 'countries';

    if ($form_state->has($state) === FALSE) {
      $form_state->set($state, $this->getSetting('countries'));
    }
    $countries = $form_state->get($state);

    $form['allowed_countries']['countries'] = [
      '#type' => 'table',
      '#default_value' => [],
      '#empty' => $this->t('No countries have been added yet.')
    ];

    foreach ($countries AS $iso => $level_1) {
      $row = [];

      $row['iso'] = [
        '#markup' => $iso
      ];

      $row['label'] = [
        '#markup' => $country_list[$iso]
      ];

      $row['level_1'] = [
        '#type' => 'value',
        '#value' => []
      ];

      if ($level_1_list = Address::hasLevel1($iso)) {
        $row['level_1'] = [
          '#type' => 'select',
          '#title' => Address::getLevel1Label($iso),
          '#options' => $level_1_list,
          '#default_value' => $level_1,
          '#multiple' => TRUE
        ];
      }

      $row['delete'] = [
        '#type' => 'submit',
        '#value' => $this->t('Delete'),
        '#submit' => [[get_called_class(), 'deleteCountrySubmit']],
        '#name' => 'delete_country_' . $iso,
        '#ajax' => $ajax
      ];

      $form['allowed_countries']['countries'][$iso] = $row;
    }

    $form['allowed_countries']['add_country'] = [
      'country' => [
        '#type' => 'select',
        '#title' => $this->t('Add country'),
        '#options' => array_diff_key($country_list, $countries),
        '#ajax' => $ajax + ['trigger_as' => ['name' => 'add_country_submit']],
        '#empty_value' => '',
        '#empty_option' => t('- Select -')
      ],
      'submit' => [
        '#type' => 'submit',
        '#value' => $this->t('Add country'),
        '#submit' => [[get_called_class(), 'addCountrySubmit']],
        '#name' => 'add_country_submit',
        '#ajax' => $ajax,
        '#attributes' => [
          'class' => ['js-hide']
        ]
      ]
    ];

    return $form;
  }

  /**
   * Form submit handler for attaching a new allowed country.
   *
   * @param array $form
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   */
  public static function addCountrySubmit(array $form, FormStateInterface $form_state) {
    $element = static::getFormElement($form, $form_state);

    // @see https://drupal.stackexchange.com/questions/243812
    // @see https://www.drupal.org/node/2902121
    $get_country = array_merge($element['#parents'], ['add_country', 'country']);
    $get_countries = array_merge($element['#parents'], ['countries']);
    $set_state = array_merge($element['#array_parents'], ['countries']);

    $country = $form_state->getValue($get_country);
    $countries = $form_state->getValue($get_countries);
    $countries[$country] = [];

    ksort($countries);

    $form_state->set($set_state, $countries);

    $form_state->setRebuild();
  }

  /**
   * Form submit handler for removing allowed country.
   *
   * @param array $form
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   */
  public static function deleteCountrySubmit(array $form, FormStateInterface $form_state) {
    $element = static::getFormElement($form, $form_state);

    $country = substr($form_state->getTriggeringElement()['#name'], strlen('delete_country_'));

    // @see https://drupal.stackexchange.com/questions/243812
    // @see https://www.drupal.org/node/2902121
    $get_countries = array_merge($element['#parents'], ['countries']);
    $set_state = array_merge($element['#array_parents'], ['countries']);

    $countries = $form_state->getValue($get_countries);
    unset($countries[$country]);

    ksort($countries);

    $form_state->set($set_state, $countries);

    $form_state->setRebuild();
  }

  /**
   * Ajax callback for rebuilding the allowed countries form elements.
   *
   * @param array $form
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *
   * @return mixed
   */
  public static function ajaxRebuildCountries(array $form, FormStateInterface $form_state) {
    return static::getFormElement($form, $form_state);
  }

  /**
   * Helper method to retrieve the form element containing the plugin
   * configuration "form" from the parent form for ajax calls since
   * there is no SubformState involved and we are getting the whole
   * parent form.
   *
   * @param array $form
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *
   * @return mixed
   */
  public static function getFormElement(array $form, FormStateInterface $form_state) {
    $trigger_parents = $form_state->getTriggeringElement()['#array_parents'];
    $key = array_search('allowed_countries', $trigger_parents);
    return NestedArray::getValue($form, array_slice($trigger_parents, 0, $key + 1));
  }

  /**
   * {@inheritdoc}
   */
  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValue(['allowed_countries', 'countries']);

    // Fix Level 1 administrative area.
    $values = array_map(function($value) {
      if (is_array($value) && isset($value['level_1'])) {
        return $value['level_1'];
      }
      return $value;
    }, $values);

    $form_state->setValues(['countries' => $values]);
    parent::submitConfigurationForm($form, $form_state);
  }

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.