Support dynamic forms using HTMX

Created on 10 July 2025, about 2 months ago

Problem/Motivation

In general, \Drupal\Core\Form\FormBuilder is aware of ajax api requests and needs to be adjusted so that it is similarly aware of HTMX requests.

A thorny problem with dynamic elements was encountered in πŸ“Œ [POC] Implementing some components of the Ajax system using HTMX Active .
The form used as our discovery and development context for this POC, \Drupal\config\Form\ConfigSingleExportForm, has <select> elements with values that dynamically change based on user input.

With form builder as it is, if the form route is called with user input that is not a submit, the form rebuilds with a new build ID and fails to validate because the options get reset to their base case, and the user input no longer matches the available options.

Steps to reproduce

Once we have dependent issues committed, I'll update ConfigSingleExportForm without changing FormBuilder so others can see the issue and explore solutions.

Proposed resolution

In the POC I solved the problem like this.

if ($this->isHtmxRequest()) {
        // Restore the build id that was sent with the request. It will be used
        // after the rebuild to cache the rebuilt form.
        $form_state->addRebuildInfo('copy', ['#build_id' => TRUE]);
        $input = $form_state->getUserInput();
        $form['#build_id'] = $input['form_build_id'];
      }

Remaining tasks

Refactor FormBuilder
Add appropriate tests

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

πŸ“Œ Task
Status

Active

Version

11.0 πŸ”₯

Component

ajax system

Created by

πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Merge Requests

Comments & Activities

  • Issue created by @fathershawn
  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York
  • First commit to issue fork.
  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    Posting some notes and code snippets from conversations in Slack. I'm not using the issue fork as we aren't really ready for that yet as there are still dependencies that need to be committed.

    I loaded up the 11.x branch locally and spent more time stepping through FormBuilder, FormAjaxResponseBuilder::buildResponse, and FormAjaxSubscriber::onException with xdebug.

    1. The form is definitely cached on an ajax interaction. This happens at FormBuilder.php:436
    2. I reversed the actual effect of FormAjaxSubscriber::onException. The Ajax classes copy the new build ID back to the form.

    So I removed the previous code to maintain build_id from my POC branch and instead added the following immediately after the $form['form_build_id'] part of the form is built in FormBuilder::prepareForm.

        // If a form is building from an HTMX request and the form id has changed
        // add htmx attributes to update the build ID in the client.
        // @see \Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber::onException
        // @see Drupal.AjaxCommands.update_build_id
        if ($this->isHtmxRequest() && $form['#build_id'] !== $input['form_build_id']) {
          $existing_id = Html::getId($input['form_build_id']);
          $htmx_attributes = new HtmxAttribute();
          $htmx_attributes->swapOob('outerHTML:input[data-drupal-selector="' . $existing_id . '"]');
          $form['form_build_id']['#attributes'] = AttributeHelper::mergeCollections($form['form_build_id']['#attributes'], $htmx_attributes);
        }
    

    We don't have all the similar tools available yet, but this would be adding data-hx-swap-oob='outerHTML:input[data-drupal-selector="existing-form-build-id"]' to the build id input element on the response.

    And that works! Validation is working, form_build_id is changing.

  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York
  • First commit to issue fork.
  • Merge request !12918Draft: Resolve #3535173 "Support dynamic forms" β†’ (Open) created by nod_
  • πŸ‡«πŸ‡·France nod_ Lille

    we need πŸ“Œ Return htmx responses as SimplePageVariant Active before this works properly

  • Pipeline finished with Failed
    24 days ago
    Total: 134s
    #565323
  • Pipeline finished with Failed
    23 days ago
    Total: 249s
    #566997
  • Pipeline finished with Failed
    23 days ago
    #567014
  • Pipeline finished with Failed
    22 days ago
    Total: 295s
    #567273
  • Merge request !12942Draft: Resolve #3535173 "Htmx aware forms" β†’ (Open) created by fathershawn
  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    I pushed up a draft MR of how I imagine this implementation. I've commented out a condition that would depend on the upstream issue, but otherwise the single export form works here. My experience is that the form builder out of band swap needs to be deeper in the build process to prevent the validation error.

    Nice edge case catch on the nested case with a full form swap @nod_!

  • Pipeline finished with Failed
    22 days ago
    #567586
  • Pipeline finished with Failed
    22 days ago
    #567590
  • Pipeline finished with Failed
    22 days ago
    #567606
  • πŸ‡«πŸ‡·France nod_ Lille

    very nice. I couldn't make the config export form work on my end. I have no idea why setting the data-hx-swap-oob earlier makes the whole thing work… There is a side effect somewhere and couldn't figure out what it is yet.

    I think the changes to the form are a bit worrying. We can't expect people to make this kind of changes to switch from ajax to htmx. I guess that's a concern for once things actually work.

    Just realized we're going to have troubles with inline form errors, we need to swap the whole wrapping element, not just the select when it's updated.

  • Pipeline finished with Failed
    22 days ago
    Total: 529s
    #568024
  • Pipeline finished with Failed
    22 days ago
    Total: 505s
    #568034
  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    I think the changes to the form are a bit worrying. We can't expect people to make this kind of changes to switch from ajax to htmx. I guess that's a concern for once things actually work.

    Maybe someone will see how to turn the ajax callbacks into process callbacks? I haven't experimented with that at all. Since this form is pure ajax, the form building logic was definitely distributed into the callbacks.

    Just realized we're going to have troubles with inline form errors, we need to swap the whole wrapping element, not just the select when it's updated.

    Switched to the js-form-item classes. Were data attributes not a thing when those were added to our markup?

  • Pipeline finished with Success
    22 days ago
    Total: 597s
    #568045
  • Pipeline finished with Success
    21 days ago
    Total: 1888s
    #568134
  • Pipeline finished with Failed
    16 days ago
    Total: 163s
    #572987
  • Pipeline finished with Canceled
    16 days ago
    Total: 129s
    #572994
  • Pipeline finished with Failed
    16 days ago
    Total: 219s
    #572996
  • Pipeline finished with Failed
    15 days ago
    Total: 165s
    #573055
  • Pipeline finished with Failed
    15 days ago
    Total: 151s
    #573059
  • Pipeline finished with Failed
    15 days ago
    Total: 174s
    #573075
  • Pipeline finished with Failed
    15 days ago
    Total: 142s
    #573081
  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    I looked over @nod_ latest update and our approach in FormBuilder is now very similar:

    • I have helper methods to check the request if it's from HTMX and to check for the HTMX trigger value which also makes our code differ in a few places but we are checking the same things.
    • We approached targeting the build_id element differently. nod_ adds an id - I go with the existing input[name="form_build_id"][value="' . $old_build_id . '"]' selector for the build id element.
    • I extend elementTriggeredScriptedSubmission using the detected trigger from HTMX since any element type can be the trigger.

    Our adaptations to \Drupal\config\Form\ConfigSingleExportForm are very different, so I'll speak for what I am trying to accomplish. I want the code to be explicit, and to show the full htmx paradigm of request, select, target, swap.

    I also have a helper method on FormBase to determine which element triggered the request.

  • Pipeline finished with Failed
    15 days ago
    Total: 250s
    #573377
  • Pipeline finished with Failed
    15 days ago
    Total: 601s
    #573551
  • Pipeline finished with Failed
    15 days ago
    Total: 185s
    #573561
  • Pipeline finished with Failed
    15 days ago
    Total: 176s
    #573564
  • Pipeline finished with Failed
    14 days ago
    Total: 138s
    #573757
  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    Nice changes from @nod_ that take advantage of existing workflows for FormBuilder. Pulled those into my branch and we are now more closely aligned:

    We still approach targeting the build_id element differently. nod_ adds an id - I go with the drupal data selector pattern.

    I also have a helper method on FormBase to determine which element triggered the form build. As I noted in #13 our approaches to refactoring \Drupal\config\Form\ConfigSingleExportForm are different. I use the helper method to detect and respond to which element caused changes. My intention is to be explicit so some future developer (myself included!) sees the dynamic flow. It also allows me to use the HTMX push url attribute to update the url when a config value is actually exported.

  • Pipeline finished with Success
    14 days ago
    #573777
  • Pipeline finished with Failed
    14 days ago
    Total: 112s
    #573796
  • πŸ‡«πŸ‡·France nod_ Lille

    it's not actually postponed.

    Looks like the only big difference is the configexport form, which probably shouldn't be part of this issue so we can discuss the approach on how to htmx-ify the forms without holding up the necessary pieces in this MR

  • The Needs Review Queue Bot β†’ tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

    This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

    Consult the Drupal Contributor Guide β†’ to find step-by-step guides for working with issues.

  • Merge request !13009Resolve #3535173 "Support dynamic forms only" β†’ (Open) created by nod_
  • Pipeline finished with Canceled
    14 days ago
    Total: 245s
    #574015
  • Pipeline finished with Success
    14 days ago
    Total: 490s
    #574017
  • Pipeline finished with Canceled
    14 days ago
    Total: 479s
    #574029
  • πŸ‡«πŸ‡·France nod_ Lille

    Opened a MR with only the changes we converged on, I kept the swap-oob true, I could be convinced to do it like before but I don't want to keep track of css selectors in the backend.

    Added a small condition so that form_build_id doesn't get the htmx attribute when its not an htmx request.

  • Pipeline finished with Success
    14 days ago
    Total: 528s
    #574039
  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    That looks great @nod_

  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    I kept the swap-oob true, I could be convinced to do it like before but I don't want to keep track of css selectors in the backend.

    This is a fine place to use that since the #id is reliable here. I offered the selector as a way to make fewer changes to the class.

  • πŸ‡«πŸ‡·France nod_ Lille

    nod_ β†’ changed the visibility of the branch 3535173-htmx-aware-forms to hidden.

  • πŸ‡«πŸ‡·France nod_ Lille

    nod_ β†’ changed the visibility of the branch 3535173-support-dynamic-forms to hidden.

  • πŸ‡«πŸ‡·France nod_ Lille

    let's call it to have someone take a look :)

  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    +1 from me!

  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    Matching the priority to πŸ“Œ DX object to collect and manage HTMX behaviors Active as the new functionality in that issue will not work in forms without the changes in this issue.

  • πŸ‡¬πŸ‡§United Kingdom catch

    Couple of questions on the MR.

  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    The MR that prompted the move away from RTBC is hidden in the issue. Returning to RTBC after leaving a comment pointing to the final MR.

  • πŸ‡¬πŸ‡§United Kingdom catch

    Argh OK, so the MR looks a lot simpler, but now there's no test coverage or conversion?

  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    @nod_ and I had different ideas about how to convert the ConfigSingleExportForm form I've been using as an example.

    My understanding is that he converted it so that all three form elements were replaced on every response so that any changes that were caused by changing a value were updated in the form. Based on comments above he is demonstrating how easily convert a form.

    I wanted to demonstrate the request/select/target/swap process that is so common in HTMX usage. My solution had more code that was conditional based on which select element triggered the change.

    I think we both see merit in the other's approach but he decided to defer the conversation. If you would like to see the example and test @catch, we should try to reach consensus on the approach.

  • Pipeline finished with Failed
    about 13 hours ago
    #585116
  • Pipeline finished with Failed
    about 12 hours ago
    #585197
  • Pipeline finished with Success
    about 11 hours ago
    #585209
  • πŸ‡ΊπŸ‡ΈUnited States fathershawn New York

    @catch I brought our consensus changes to FormBuilder over to the MR 12942 and improved my test. I'd welcome your guidance on how to proceed with this issue.

  • πŸ‡«πŸ‡·France nod_ Lille

    We should move the changes to the config form in a new issue like we have for πŸ“Œ Ajaxify the user interface translation forms Active so that we can use the new Htmx object so that we can start using the helper.

Production build 0.71.5 2024