Interactive Forms with htmx 4

I recently built an interactive form for the htmx contributed module.  I encountered what I experienced as buried treasure in the Drupal Form API.  To share that with you, I will review the purpose of the form, what I found, and how the form works.

Loading htmx 4 extensions

Extensions in htmx 2 were enabled using an htmx attribute in the markup.  In htmx 4, extensions register themselves by calling a method on the htmx object in javascript. This new architecture allows us to leverage hook_library_info_build to offer all the extensions maintained by the htmx core team.  A site administrator can select which extensions are needed.  These extensions are dynamically added as assets to the htmx/extensions asset library.

A collection of items that can be individually toggled on and off immediately suggests a group of checkboxes. This form could easily be interactive if we could implement this workflow:

  1. An extension's checkbox is checked or unchecked.
  2. A POST request is sent submitting the form data
  3. A list of enabled extensions is updated which also confirms the action for the user.

Sending a POST request on click is a core competency of htmx but we need more than a post.  We need to cause a submit action to be processed.

Discoveries in the Drupal Form API

I knew that we were reporting the triggering element from htmx back to Drupal's FormBuilder so I started looking through what FormBuilder does with that data. My first clue was a comment left by Tim Plunkett when the form system was moved to object-oriented code as Drupal 8 was being built in 2013

      // @todo Simplify this logic; considering Ajax and non-HTML front-ends,
      //   along with element-level #submit properties, it makes no sense to
      //   have divergent form execution based on whether the triggering element
      //   has #executes_submit_callback set to TRUE.

Now I knew that there was a render array property that could designate a form element for submission handling. A quick search found:

      // If the triggering element executes submit handlers, then set the form
      // state key that's needed for those handlers to run.
      if (!empty($triggering_element['#executes_submit_callback'])) {
        $form_state->setSubmitted();
      }

I wondered, how long had this render array property been around? Git history on the code block above led to a Drupal 7 issue, AJAX triggered by non-submit element fails if any elements are validated. Here we find a comment from Alex Bronstein that is so in tune with the philosophy of htmx.

Taking a step back, what's really all that different between a clicked button and an ajax triggering element? Why can't a non-button that submits a form specify "button-level" validate and submit handlers, as well as #executes_submit_callback and #limit_validation_errors (to something other than empty array)?

And just above the check for the executes_submit_callback are the decendents of Alex's code:

      $triggering_element = $form_state->getTriggeringElement();
      // If the triggering element specifies "button-level" validation and
      // submit handlers to run instead of the default form-level ones, then add
      // those to the form state.
      if (isset($triggering_element['#validate'])) {
        $form_state->setValidateHandlers($triggering_element['#validate']);
      }
      if (isset($triggering_element['#submit'])) {
        $form_state->setSubmitHandlers($triggering_element['#submit']);
      }

All the tools to allow any element to submit the form are already here in Drupal core!

Assembling the form

Here's a brief visual tour of the form

I've grouped the extensions in fieldsets that align with the extension categories on four.htmx.org. Here's an example:

$form['compatibility-set'] = [
  '#type' => 'fieldset',
  '#title' => $this->t('Compatibility'),
];
$form['compatibility-set']['compatibility'] = [
  '#type' => 'checkboxes',
  '#options' => [
    'htmx-2-compat' => 'htmx-2-compat',
    'hx-alpine-compat' => 'hx-alpine-compat',
  ],
  '#config_target' => 'htmx_ext.settings:compatibility',
];

Once all the checkboxes are defined, each one is enhanced with htmx attributes

// Iterate the categories and enhance for htmx.
$formUrl = Url::fromRoute('htmx_ext.extensions');
// Use the same htmx properties on each "checkboxes" element.
$htmxCheckboxes = new Htmx();
$htmxCheckboxes->post($formUrl)
      ->onlyMainContent();
foreach (ExtensionCategories::cases() as $category) {
    // Set submission properties on each checkbox for htmx.
    $set = "$category->value-set";
    foreach ($form[$set][$category->value]['#options'] as $extension) {
      $form[$set][$category->value][$extension] = [
        '#executes_submit_callback' => TRUE,
         #submit' => [[$this, 'submitForm']],
      ];
    }
  // Add htmx attributes.
  $htmxCheckboxes->applyTo($form[$set][$category->value]);
}

The form uses HtmxRequestInfoTrait so we can alter the submission process to be aware if it's responding to request sent by htmx.  In that case, instead of the status message, the communication to the user will be the update to the enabled list.

public function submitForm(array &$form, FormStateInterface $form_state) {
    parent::submitForm($form, $form_state);
    // Reset the library discover cache to rebuild the dynamic library
    // definition for `htmx_ext/extensions`.
    $this->libraryDiscovery->clear();
    if ($this->isHtmxRequest()) {
      // Remove the status message set in parent::submitForm().
      $messages = $this->messenger()->messagesByType('status');
      if (count($messages) === 1) {
        // Only the message just added is pending.
        $this->messenger()->deleteByType('status');
      }
      elseif (count($messages) > 1) {
        $messages = $this->messenger()->deleteByType('status');
        $reducedMessages = [];
        foreach ($messages as $message) {
          if ($message instanceof TranslatableMarkup && $message->getUntranslatedString() === 'The configuration options have been saved.') {
            // Skip the just added message.
            continue;
          }
          $reducedMessages[] = $message;
        }
        foreach ($reducedMessages as $replacement) {
          $this->messenger()->addStatus($replacement);
        }
      }
      $form_state->setResponse($this->buildHtmxResponse());
    }
 }

The response builds the list of enabled extensions using the same helper function as the form build.  It takes advantage

of the new hx-partial feature in htmx 4 to send a targeted partial update that swaps in an updated set of <li> elements to the unordered list.

  protected function buildHtmxResponse(): HtmlResponse {
    $list = $this->enabledExtensions();
    $content = [
      '#type' => 'inline_template',
      '#template' => "<hx-partial hx-target='ul.enabled-extensions' hx-swap='innerHTML'>{{ items }}</hx-partial>",
      '#context' => [
        'items' => $list,
      ],
    ];
    /** @var \Drupal\Core\Render\HtmlResponse $response */
    $response = $this->renderer->renderResponse($content, $this->getRequest(), $this->getRouteMatch());
    return $response;
}

With the new htmx 4, it's that straightforward to build a dynamic form that updates stored data every time something is changed by the user and also updates the state of the page.