25. September 2024

Skills Turbo

We want to demonstrate the practical usage of Hotwire in a Ruby on Rails application, with PuzzleSkills as example. PuzzleSkills allows users to track their so-called skills, which include their qualifications, certifications, and their experience and interest in a particular technology.

Software Development & Architecture
Digital Transformation
Skills Turbo Screenshot
 

We aim to showcase how Hotwire can be effectively utilized within a Ruby on Rails application, using PuzzleSkills. We’re going to focus on the filter form in the skill search tab, which enables users to filter people by their skills and skill level. Let’s have a look on the UI:

Puzzle Skillz

The PuzzleSkills tech stack

The filter form already makes use of a wide range of technologies.

Hotwire

Hotwire alone is not really a singular technology, but rather «an alternative approach to building modern web applications without using much JavaScript by sending HTML instead of JSON over the wire.» To enable «HTML Over The Wire», the following technologies are essential:

Stimulus

Stimulus is a JavaScript framework that allows us to bring JavaScript functionality into static or server-side rendered webpages. This is done by connecting JavaScript objects to page elements.

Aditionally, to get started, create a Stimulus controller by executing ./bin/rails generate stimulus controllerName in our Rails application. This generates a file that looks like the following and also registers the controller in the file javascript/controllers/index.js.

// /javascript/controllers/alert_user_controller.js
import { Controller } from "@hotwired/stimulus"  

export default class extends Controller {  
  connect() {
  }
  alertUser() {
   alert("Hello user!");
  }
}

The connect method is automatically generated, but could be omitted. This method is called whenever the page has finished loading and the controller is newly connected to an element on the page.

The controller can then be connected to an element on the page by using the data-controller attribute to the element. Every element nested inside an element with that attribute can then use the functions in the controller by using the data-action attribute. This could look like this:

// /views/alerts/index.html.haml
%div{"data-controller": "alert-user"}
%button{"data-action": "click->alert-user#alertUser"} Alert me!

Notice how the value of data-controller is alert-user even though the name of the controller is alert_user. This is because the controller name is also defined in the stimulus index file and by default they are hyphenated there.

Also good to know: "data-action": "click->alert-user#alertUser" can actually be shortened to"data-action": "alert-user#alertUser" because click is the default action for buttons. You can find the default actions for other elements here.

Turbo

Turbo provides us with several methods of dynamic page updates and navigation. The one we use here are Turbo Frames, which are explained in the next section.

Used frequently in our application (but not in the filter form) are Turbo Streams. Turbo Streams allow dynamic delivery of page updates directly from the controller (for example, after a form submission), ensuring a response to async actions. Turbo Streams have eight methods of altering a page, including update, delete, or morph.

For page navigation, Turbo gives us the power of Turbo Drive. Turbo Drive enhances page navigation and form submissions in non-single-page applications by turning full-page navigations or submissions into async fetch requests, and then rendering the HTML from the answer. Actions like updating browser history are also handled in the background.

Turbo Frames and async requests

One of the main technologies used in our filter form are Turbo Frames. Turbo Frames bring dynamic behaviour to static, backend-rendered Rails applications. To get started with Turbo Frames, declare one like this:

// /views/messages/index.html.haml
%turbo-frame{id: "message"}
    %h3 I am a message on the index page
    %link_to "Change me!", new_message_path

Here we have declared a Turbo Frame with the id message. Nested inside it, we find a link that leads to the edit page of the messages view. Let’s take a look at it.

// /views/messages/new.html.haml
%turbo-frame{id: "message"}
    %h3 I am a message on the new page
    %link_to "Go back!", messages_path

As we can see, the new page also contains a Turbo Frame with the id message. This is important because Turbo relies on the id to replace the Turbo Frame.

When you click on the link, Turbo prevents the default behavior, which means that the browser doesn’t follow the link. Instead, an async request the new action of the messages controller, to which Rails responds with HTML content. Turbo takes a look at this content and if found, extracts the corresponding Turbo Frame by comparing IDs. The original Turbo Frame on the view is then replaced with the fetched content, which leads to a partial page update.

Now let’s take a look at our original example, the PuzzleSkills filter form. Essentially, the form is split up in two parts. The search filter part (red) and the search result part (green).

Puzzle Skillz colored

Both are Turbo Frames. When the filter is updated, for example by adding a new filter row, Turbo makes a request and replaces the search filter Turbo Frame. When this happens, the form’s settings are appended to the query params. This allows us to extract the settings like the number of rows on render, which eventually leads to a visual update of the filter for the user.

Similarly, on every filter update Turbo also replaces the search result Turbo Frame with the fetched Turbo Frame, containing the search results, to show the results to the user.

Skillz Turbo Flow
Async requests

We’ve talked about async requests a few times now – but what are they exactly? When you normally visit a webpage, an HTTP request is sent to the server to initially load the page. This call is synchronous, which means you have to wait for the answer for the page to be loaded. But in our example, Turbo makes requests in the background to replace a Turbo Frame on the page without a full page reload. This is where async requests come into play. By using the global fetch() function, we are able to make a request from JavaScript to the server and then work with the response. This is an asynchronous request, because the execution is passed to a background worker, so the request runs independently and other code isn’t interrupted. Such requests are known as XHR requests, which stands for XMLHttpRequest.

Where and why JavaScript is still used

Sometimes, a functionality is needed where a Turbo Stream isn’t suitable, for example when there’s no interaction with a controller, or because additional actions are required. In these cases we normally use JavaScript by implementing a Stimulus controller.

In our example, we do this for the remove button that removes a filter row from the form.

Skills Turbo Screenshot

The corresponding Stimulus controller looks like this:

// /javascript/controllers/people_skills_filter_controller
import { Controller } from "@hotwired/stimulus"  
export default class extends Controller {  
    static targets = ["filter", "form"]  
    remove({params}) {  
        this.filterTargets[params.id].remove();  
        this.formTarget.submit();  
    }  
}

Notice the targets array. This is a functionality of Stimulus that allows us to get elements from the view by adding an attribute to an HTML tag with the following naming schema:

"data-<controller-name>-target": "<target-name>".

When the delete button is clicked, the remove method is executed, which first removes the correct filter from the DOM and then submits the form, leading to an update. We know which filter is the one to delete from the params. You can pass params to a Stimulus action by adding the following attribute to the element that calls the action:
data-[identifier]-[param-name]-param.

Identifier is the Stimulus controller name. You can then either get the parameters from the event like this: e.params, or through destructured assignment as shown in our example.

In our case we have a loop in the template, so we just pass the index of the rendered filter row, which we then use as an index on the filterTargets array.

 <target-name>Targets is automatically instantiated for every target in the targets array by Stimulus.

To find out more about how to integrate Stimulus controllers in your application, you could follow this short tutorial from the official Hotwire Stimulus documentation.