Hotwire Discussion

Search-as-you-type with Turbo?

Hi!

I’m currently researching Hotwire to decide if it would be a good idea to replace most of our fairly convoluted React code with Turbo + Stimulus. I’ve chosen a fairly simple view for my initial take on such a rewrite.

The view consists of

  • a table of data
  • table headers that can be clicked to sort on that column
  • pagination links
  • pagination info (e.g. “showing row 1-10 of 180”)
  • a select element for row count selection (i.e. display 10/20/50 rows)
  • an input field for filtering/searching the table

The pagination and sorting links were easy enough to get working using Turbo Drive. It also worked pretty well for selecting a row count, for which I went with a Stimulus controller which submitted a form when the value changed and let Turbo handle the submission. I did however get stuck on implementing the search field.

My specification

  • The search field should automatically refresh the results as the user types their query (though with a slight debounce)
  • The field must not lose focus while new search results are loaded!
  • The current page should be reset to 1, to avoid ending up at a non-existent page as the number of search results decreases
  • The updated page parameter should be pushed to the URL
  • I don’t have a strong opinion on whether the search query itself should be pushed to (or replace) the browser location though.
  • My ideal solution would probably only use Turbo Drive and Turbo Frames and as little Stimulus as possible, simply to keep the complexity to a minimum, but perhaps that’s unreasonable

I’ve been struggling a lot with this seemingly simple task. The main problem I’ve had using Turbo is to ensure focus is kept in the search field even as the form is submitted and new search results are rendered, and that the page parameter is pushed to the URL so that a page reload does not change the page number that is displayed. A few of my failed work-arounds:

  • add data-turbo-permanent to persist the search field, but it seems the element is cloned and then added, which means input focus is lost
  • wrap the table and pagination in a Turbo Frame and let the search bar render only that, however that means the reset page number is not pushed to the URL
  • do the above, but also add a Stimulus controller to the turbo-frame element that observes changes to the src attribute, but it seems it’s not possible to use a MutationObserver on the custom element
  • find a way to hook into the fetch performed by Turbo to dynamically add the search query to the URL before it’s sent, but it seems the URL cannot be changed in e.g. the pre-fetch event
  • use a custom renderer (i.e. morphdom) to ensure the search field is kept as is, but no luck (though there’s a PR that could potentially make this viable: Pausable rendering by domchristie · Pull Request #28 · hotwired/turbo · GitHub)

Now, I do have another implementation which completely skips Turbo and basically implements a similar approach using only Stimulus in about 80 lines spread over three controllers, but it feels (not very scientific, I know) like Turbo could provide a cleaner solution for this scenario.

Does anyone have some pointers or even solutions for this? I hope I’ve managed to explain the problem, but feel free to ask follow-up questions if something is not clear!

Best regards,
Erik

That’s what I’d do too. I assume it looks something like this?

<form data-turbo-frame="data">
  <input name="query">
  <button>Search</button>
</form>

<turbo-frame id="data">
  <table>
    <!-- ... -->
  </table>
</turbo-frame>

The URL-changing part here is tricky. One idea off the top of my head: make a Stimulus controller that uses history.pushState to replace the URL. I assume you want the UI to change too? The Stimulus controller could also update the frame URL if so. Something like:

export default class extends Controller {
  static targets = ["frame"]
  static values = { resetUrl: String }

  searchStart() {
    history.pushState(this.resetUrlValue)
    this.frameTarget.src = this.resetUrlValue
  }
}

No idea if that’ll do the job, but I think it’s probably worth exploring something along those lines.

Thanks dan! You are correct that that would be pretty much how the structure of the HTML is.

Letting Turbo render the updated data in the data-frame would already take care of updating the frame’s src attribute, but what I could do is e.g. create a Stimulus controller, attach it the the form, and let that controller attach a MutationObserver to the specified frame. When the frame’s src attribute changes, it can then be pushed to the browser history.

That could definitely work, and is a variant of my third failed attempt above (where I attempted to attach a Stimulus controller to the turbo-frame)! My main concern is that the controller would actually update the browser history no matter where the src-change originated from. In this particular case it doesn’t really matter though, since this is the only part of the page that will update only the frame. My concern is more from a perspective of making it easy to understand for future developers.

Unless any other ideas pop up here I might just go with this!

Ah, got you. What I had in mind was updating the URL and UI immediately when you click into the search field. My mistake.

I’m not loving the MutationObserver idea tbh. Gonna give it some thought.

Here’s another thought. If the response to the form submission gives you the right URL, have you tried hooking into turbo:submit-end? Because this would, if it works (I haven’t tried it), address your concern that any change to the frame src can change the URL. It’s also simpler, since you don’t need to mess around with MutationObserver.

// view.html
<form data-turbo-frame="data" data-action="turbo:submit-end->search#updateURL">
</form>

<turbo-frame id="data">
</turbo-frame>

// search_controller.js
export default class extends Controller {
  updateURL() {
    history.pushState(this.frame.src)
  }

  get frame() {
    return document.getElementById(this.element.getAttribute("data-turbo-frame"))
  }
}

See suggestion posted here about using GET Triggering Turbo Frame with JS - #37 by tleish

1 Like

Just remembered this PR: Dispatch lifecycle events within turbo-frames by seanpdoyle · Pull Request #59 · hotwired/turbo · GitHub

@dan Interesting! I looked into it, and there are two issues. One is not really related to this though, but it seems that the visit or submit events are not emitted when the submission is triggered using JS. (I’m using the requestSubmit method)

Ignoring that for a bit though, even if I would get those events, there is (as far as I can tell) no way to tell if the event was triggered by this specific form. This means it would trigger for any change on the page, which would break the encapsulation of the logic.

It’s a tough nut to crack, this one! Or perhaps I’m too concerned with code cleanliness :smiley:

@tleish Thanks! I think that works better when there are not a bunch of other links/forms that could also update other parameters, though. In this case it would probably be better to use update the URL from the frame or browser location.

I just opened a PR that enables updating URLs on frame navigation (links and form submissions) and Turbo Stream responses: Optionally update URLs on Frame navigation and stream responses by bfitch · Pull Request #167 · hotwired/turbo · GitHub. This will hopefully provide a “first class” way to update history/URL with Turbo.

1 Like

@bfitch That’s an interesting idea! I’m gonna follow the PR :+1:

Yeah, I ended up using a simple stimulus controller for this. It was more work than I wanted to do but at the same time, it’s pretty straightforward and works well. Still leverages <turbo-stream>s! Just debounces a fetch request on input and updates the URL manually.

I ended up going with a Stimulus controller on the form which observes changes on the turbo-frame’s src attribute. When a new mutation happens, the history is updated using replaceState for any parameters in the new src with the same name as an input/select/textarea element within the form.

Not super elegant, but easy enough to understand for future developers (including myself) and it gets the job done.

3 Likes

Hi @erikbrannstrom,

Can you share some code, please? :pray:

Have a good day :+1:

Yes, of course! Your mileage may vary, obviously, and I should note that this is not in production yet.

location_sync_controller.js

import { Controller } from "stimulus";

export default class extends Controller {
  initialize() {
    this.observer = new MutationObserver(this.handleMutation.bind(this));
  }

  connect() {
    this.observer.observe(this.getTurboFrame(), {
      attributes: true,
      attributeFilter: ["src"],
    });
  }

  disconnect() {
    this.observer.disconnect();
  }

  getTurboFrame() {
    return document.querySelector(
      "turbo-frame#" + this.element.dataset.turboFrame
    );
  }

  handleMutation() {
    const frameURL = new URL(this.getTurboFrame().src);
    const documentURL = new URL(document.location.href);
    this.getFormFieldNames().forEach((fieldName) => {
      documentURL.searchParams.set(
        fieldName,
        frameURL.searchParams.get(fieldName)
      );
    });

    history.replaceState({}, null, documentURL.href);
  }

  getFormFieldNames() {
    let fieldNames = [];

    this.element
      .querySelectorAll("input[name], select[name], textarea[name]")
      .forEach((element) => fieldNames.push(element.name));

    return fieldNames;
  }
}

This is then connected to a form as follows:

<form method="get" data-controller="location-sync" data-turbo-frame="my-frame">
  ...
</form>
3 Likes