The most common use of Ajax in Drupal is through the Form API, where we can create dynamic interactions between the server and client with ease. To demonstrate how this works, we will go through an example. This will be a rework of the Importer configuration entity form we created in Chapter 7, Your Own Custom Entity and Plugin Types.
If you remember, we said that tying certain configuration values to the generic entity does not make sense, as importer plugins might be different. The first Importer we wrote loads a JSON file from a remote URL. So, it stands to reason that the configuration value for the URL is tied to the plugin and not the configuration entity (even if the latter actually stores it). Because if we want to create a CSV importer, for example, we don't need the URL. So, let's refactor our work to make this happen.
Here is an outline of the steps we need to take for this refactoring:
- Importer plugins need to provide their own configuration form elements.
- The Importer configuration form needs to read these elements depending on which plugin is selected (this is where the Ajax API comes into play).
- We need to alter the storage and configuration schema of the values that are specific to plugins.
Let's start by giving the ImporterInterface plugin type a new method:
/**
* Returns the form array for configuring this plugin.
*
* @param \Drupal\products\Entity\ImporterInterface $importer
*
* @return array
*/
public function getConfigurationForm(\Drupal\products\Entity\ImporterInterface $importer);
This is responsible for getting the form elements needed for this plugin. As an argument, it receives the Importer configuration entity, which can be inspected for default values.
Next, on the ImporterInterface of the configuration entity, we need to remove the getUrl() method (since that is specific to the JsonImporter plugin) and replace it with a generic method for retrieving all the configuration values pertaining to the plugin selected for the entity:
/** * Returns the configuration specific to the chosen plugin. * * @return array */ public function getPluginConfiguration();
And of course, in the importer entity class, we reflect this change as well (by replacing the $url property):
/** * The configuration specific to the plugin. * * @var array */ protected $plugin_configuration;
And the actual getter method, in line with the interface:
/** * {@inheritdoc} */ public function getPluginConfiguration() { return $this->plugin_configuration; }
So far so good, nothing complicated going on. We are replacing the plugin-specific configuration values with a generic one in which values specific to the selected plugin will be stored. However, since our entity type no longer has the $url field but a $plugin_configuration one instead, we need to also adjust the config_export key in the annotation to reflect this change:
* config_export = { * "id", * "label", * "plugin", * "update_existing", * "source", * "bundle", * "plugin_configuration" * }
Now, let's turn to the ImporterForm and make all the adjustments there. But before we do that, let's move the form element for the url field into the JsonImporter, where we have to implement the new getConfigurationForm() method:
/** * {@inheritdoc} */ public function getConfigurationForm(\Drupal\products\Entity\ImporterInterface $importer) { $form = []; $config = $importer->getPluginConfiguration(); $form['url'] = [ '#type' => 'url', '#default_value' => isset($config['url']) ? $config['url'] : '', '#title' => $this->t('Url'), '#description' => $this->t('The URL to the import resource'), '#required' => TRUE, ]; return $form; }
You'll notice some differences in getting the default value. Instead of calling the now-removed getUrl() method on the configuration entity, we use the new getPluginConfiguration() method and check inside the resulting array. Also, since we use the $this->t() method to ensure the translation of the strings, we should use the StringTranslationTrait as well (which can go inside the parent base class as it is a trait):
use StringTranslationTrait;
Let's not forget that we are actually using the URL in the import, so we need to make some adjustments to the getData() method as well:
/**
* Loads the product data from the remote URL.
*
* @return \stdClass
*/
private function getData() {
/** @var ImporterInterface $importer_config */
$importer_config = $this->configuration['config'];
$config = $importer_config->getPluginConfiguration();
$url = isset($config['url']) ? $config['url'] : NULL;
if (!$url) {
return NULL;
}
$request = $this->httpClient->get($url);
$string = $request->getBody();
return json_decode($string);
}
With this in place, we can go ahead and adjust our ImporterForm (where we no longer have the form element for the URL field).
There are two main things we need to do:
- Expose the plugin selection element to Ajax, that is, trigger an Ajax request when the user makes a selection
- Add the extra elements to the form depending on the chosen plugin
This is what the new plugin element looks like:
$form['plugin'] = [ '#type' => 'select', '#title' => $this->t('Plugin'), '#default_value' => $importer->getPluginId(), '#options' => $options, '#description' => $this->t('The plugin to be used with this importer.'), '#required' => TRUE, '#empty_option' => $this->t('Please select a plugin'), '#ajax' => array( 'callback' => [$this, 'pluginConfigAjaxCallback'], 'wrapper' => 'plugin-configuration-wrapper' ), ];
There are two noticeable changes: we've added an #empty_option key (to be used as the option shown if the user has not made a choice) and the #ajax key (which we will discuss in a bit more detail).
What we did is pretty simple. We declared a callback method to be triggered when a user makes a change to this form element, and we declared the HTML ID of the element that should be replaced with the result of the Ajax callback. And in the latter (which is a simple method on the same class), all we have to do is this:
/** * Ajax callback for the plugin configuration form elements. * * @param $form * @param \Drupal\Core\Form\FormStateInterface $form_state * * @return array */ public function pluginConfigAjaxCallback($form, FormStateInterface $form_state) { return $form['plugin_configuration']; }
We return a form element (which we still have to define). An important lesson here is that Ajax responses in forms can return content as well (in the form of render arrays or even strings), which will be used to replace the HTML found by the ID specified in the wrapper key of the Ajax declaration. Alternatively, an AjaxResponse full of commands can also be returned to do more complex things, as we saw in the previous section.
Before we look at this new plugin_configuration form element, let's look at some of the other options that can be used inside the #ajax array:
- method: This indicates the jQuery method to use when interacting with the wrapper element (if specified). The default is replaceWith(), but you can also use append(), html(), and others.
- event: This shows which event should be used to trigger the Ajax call. By default, the form element in question decides that. For example, when selecting an option in a select element or when typing something into a textfield.
- progress: This defines the indicator to be used while the Ajax request is taking place.
- url: A URL to trigger the Ajax request in case the callback was not specified. Typically, using the latter is more powerful as the entire $form and $form_state are passed as parameters and can be used in processing.
I recommend you check out the documentation page (https://api.drupal.org/api/drupal/core%21core.api.php/group/ajax/8.7.x) for the Ajax API for more information about these options and the other ones that are available.
With that out of the way, we can go back to our form definition and add our missing parts, right after the plugin element:
$form['plugin_configuration'] = [ '#type' => 'hidden', '#prefix' => '<div id="plugin-configuration-wrapper">', '#suffix' => '</div>', ]; $plugin_id = NULL; if ($importer->getPluginId()) { $plugin_id = $importer->getPluginId(); } if ($form_state->getValue('plugin') && $plugin_id !== $form_state->getValue('plugin')) { $plugin_id = $form_state->getValue('plugin'); } if ($plugin_id) { /** @var \Drupal\products\Plugin\ImporterInterface $plugin */ $plugin = $this->importerManager->createInstance($plugin_id, ['config' => $importer]); $form['plugin_configuration']['#type'] = 'details'; $form['plugin_configuration']['#tree'] = TRUE; $form['plugin_configuration']['#open'] = TRUE; $form['plugin_configuration']['#title'] = $this->t('Plugin configuration for <em>@plugin</em>', ['@plugin' => $plugin->getPluginDefinition()['label']]); $form['plugin_configuration']['plugin'] = $plugin->getConfigurationForm($importer); }
First, we define the plugin_configuration form element as a hidden type. This means it will not be visible to users when the page loads for the first time. However, we do use the #prefix and #suffix options (common practice with the Drupal Form API) to wrap this element with a div that has the ID we indicated as the wrapper of our Ajax declaration. So, the goal is to have this element replaced each time an Ajax request is made, that is, each time a plugin is selected.
Next, we try to get the ID of the chosen plugin. First, we load it from the configuration entity in case we are looking at an edit form. However, we also check in the form state to see if one has been selected (and is different from the one in the entity). And if you are wondering how we can have the plugin in the form state, the answer is that after an Ajax call is made (triggered by the user selecting a plugin), the form gets rebuilt. Now, we can see what's in the form state and retrieve the plugin ID that was chosen.
Even more than that, if we get our hands on a plugin ID, we can completely change the plugin_configuration element, which in turn then gets returned by the Ajax callback to be used to replace our wrapper. So to sum up:
- The page loads for the first time (on a new form). The element is hidden.
- The user selects a plugin and an Ajax request is triggered, which rebuilds the form.
- As the form is rebuilt, we check for the selected plugin and alter the plugin_configuration element to reflect the selected plugin.
- The Ajax response replaces the old element with the new, potentially changed, one.
The new plugin_configuration element becomes a details one (a collapsible container for multiple elements), open by default, and which has one key, called plugin, onto which we add all the elements coming from the plugin. Moreover, we use the #tree property to indicate that when the form is submitted, the values of the elements are sent and stored in a tree that reflects the form element (a multidimensional array, basically). Otherwise, the form state values that are submitted get flattened and we lose their connection to the plugin_configuration element (which is also the Importer configuration entity field name we want to store the data under).
We are almost there. We can already go and create an importer entity, and when we select the JSON Importer, the new fieldset containing the URL field should show up below. But we still have one problem. If we save the form, the URL value will be stored inside an array keyed by plugin, inside the plugin_configuration field. So we need to clean things up a bit and we can do so inside the save() method.
Right before saving the entity, we can do this:
$importer->set('plugin_configuration', $importer->getPluginConfiguration()['plugin']);
So, we basically move the values one array up, removing the superfluous plugin level in the array (which was only needed to neatly organize the form tree).
With this, we are done. Well, not really, as we still need to handle the configuration schema aspect. Yes, remember those from Chapter 6, Data Modeling and Storage, and Chapter 7, Your Own Custom Entity and Plugin Types? We are now going to see how we can work with our own dynamic configuration schema, similar to how we did with the ones needed for the field plugins in Chapter 9, Custom Fields. But why do we need a dynamic configuration schema?
Before this refactoring, we knew the exact fields of the importer configuration entity and we could declare the schema for each easily (as we did). However, now plugins can come with their own individual fields, so we need to make sure they can provide their own schema definitions for the respective data. So how can we do this?
First, inside our importer.schema.yml file, we need to remove the url field schema definition as it no longer exists. We replace it, however, with one for the new field we created, namely the plugin_configuration array of values that came from the plugin:
plugin_configuration: type: products.importer.plugin.[%parent.plugin]
Here is where things become interesting. We don't know what fields there will be inside, so we instead reference another type (our own). Moreover, the name of the type is dynamic. We have a prefix (products.importer.plugin.) followed by a variable name given by the value of the plugin field of the parent (the main configuration entity). So basically, if a given configuration entity uses the json plugin, the type of schema definition will be products.importer.plugin.json. So now, it's the responsibility of whoever creates new plugins to also provide their own schema definitions for their own fields (like we did in Chapter 9, Custom Fields, when we defined field plugins).
But before that can happen, we need to define this new type we created:
products.importer.plugin.*: type: mapping label: 'Plugin configuration'
So essentially our new type extends from mapping and has a simple label. Of course, it applies to all that start with that name (hence the wildcard we encountered before).
Now, we can add the schema definition for our single json Importer plugin:
products.importer.plugin.json: type: mapping label: Plugin configuration for the Json importer plugin mapping: url: type: uri label: Uri
As you can see, we now have our first instance of the products.importer.plugin type, which contains the url field and which is inside the plugin_configuration field of the configuration entity—reflecting a simple array hierarchy.
But the point of this dynamic declaration is that other modules that define new plugins can now also define their own instances of the products.importer.plugin.* schema definitions to map their own fields. It is no longer the responsibility of the configuration entity (schema) to "guess" what field types are being used on each plugin.
With this, our refactoring is complete. Drupal is well aware of the type of data the configuration entity is saving, even if it is in part relating to external input (the selected plugin). So that means we can create (if we want) another importer plugin that uses a CSV file for the product data. But we'll see how to do that in a later chapter when we talk about file handling.