Toasts notifications in Thymeleaf with Shoelace and htmx - part 2

Posted at — Feb 21, 2023
Modern frontends with htmx cover
Interested in learning more about htmx? Check out my book Modern frontends with htmx. The book shows how to use htmx in combination with Spring Boot and Thymeleaf.

Yesterday, I shared how to combine Thymeleaf, Shoelace and htmx to show toast notifications in your web application. This worked pretty well and only needed a little JavaScript, but we can even further reduce the JavaScript used further and make them even more useful as the same time!

The "problem" with the previous solution

The notification works just fine in the previous solution, but there are a few drawbacks:

  • We need some custom JavaScript to generate the notification HTML itself. It would be nice if we could avoid this and use a Thymeleaf template to generate the HTML.

  • The text to show was hardcoded in the JavaScript part. It would be better if we could determine the exact text on the server. That way, we can have translated texts and texts with dynamic values in it.

  • We need some JavaScript to link the htmx event with showing the toast notification. We will see how we can use Alpine to simplify this.

Controller

The controller is a bit more complicated now, but only because it packs a lot more functionality as well.

import io.github.wimdeblauwe.hsbt.mvc.HxRequest;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.Locale;
import java.util.random.RandomGenerator;
import java.util.random.RandomGeneratorFactory;

@Controller
@RequestMapping("/")
public class HomeController {

    private static final RandomGenerator RANDOM_GENERATOR = RandomGeneratorFactory.getDefault().create();

    private final MessageSource messageSource;

    public HomeController(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    @GetMapping
    public String index() {
        return "index";
    }

    @PostMapping("/purchase")
    @HxRequest (1)
    public String purchase(Model model, (2)
                           Locale locale) { (3)

        model.addAttribute("itemName", messageSource.getMessage("item.black.tee", null, locale)); (4)

        int randomInt = RANDOM_GENERATOR.nextInt(3);
        return switch (randomInt) {
            case 0 -> {
                // simulate a succesful purchase
                yield "fragments/toasts :: itemAdded"; (5)
            }
            case 1 -> {
                // simulate a succesful purchase where we see that the user is
                // close to enough purchases to avoid the shipping fee
                model.addAttribute("amountToSpend", "$" + RANDOM_GENERATOR.nextInt(10, 20)); (6)
                yield "fragments/toasts :: itemAddedNearShipping";
            }
            default -> {
                // simulate an error happened. In a real application this would probably in a catch block.
                yield "fragments/toasts :: error"; (7)
            }
        };
    }
}
1 Use HxRequest to indicate we want this method to only react to a POST request coming from htmx.
2 Inject the Model instance so we can set some Thymeleaf variables for use in our fragments.
3 Inject the Locale of the user so we can translate something.
4 When the random generator generates a 0, we return the itemAdded fragment so Thymeleaf can use that to send back some HTML to the browser to be swapped by htmx.
5 Generate a random number that is a simulation of how much many the current user needs to spend extra to avoid the shipping costs on its order.
6 Use the error fragment as the response when there was something wrong.

For the translations to work, we update application.properties with:

spring.messages.basename=i18n/messages

And create 2 files:

  • src/main/resources/i18n/messages.properties

  • src/main/resources/i18n/messages_nl.properties

The English version contains some translations like this:

application.title=Shoelace - Thymeleaf - Alpine Demo
item.added.title=Item Added
item.added.message=Your item {0} has been added to your cart.
item.added-near-shipping.message=Your item {0} has been added to your cart. Spend another {1} to avoid shipping costs!
item.black.tee=Black Tee
application.error=There was a problem communicating with the server.

Fragments

The notification HTML itself is now created as Thymeleaf fragments:

src/main/resources/templates/fragments/toasts.html
<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<body>
<sl-alert th:fragment="itemAdded" variant="success" duration="3000" closable>
    <sl-icon slot="icon" name="check2-circle"></sl-icon>
    <strong th:text="#{item.added.title}">Success title</strong><br/>
    [[#{item.added.message(${itemName})}]]
</sl-alert>

<sl-alert th:fragment="itemAddedNearShipping" variant="success" duration="3000" closable>
    <sl-icon slot="icon" name="check2-circle"></sl-icon>
    <strong th:text="#{item.added.title}">Success title</strong><br/>
    [[#{item.added-near-shipping.message(${itemName}, ${amountToSpend})}]]
</sl-alert>

<sl-alert th:fragment="error" id="htmx-error-toast" variant="danger" duration="3000" closable>
    <sl-icon slot="icon" name="exclamation-octagon"></sl-icon>
    <strong>Error</strong><br/>
    There was a problem communicating with the server.
</sl-alert>
</body>
</html>

We can see the 3 fragments itemAdded, itemAddedNearShipping and error. We use the #{…​} construct to read from the translation files.

The [[#{item.added.message(${itemName})}]] construct might need some explanation:

  • [[..]]: allows to inline some Thymeleaf code

  • item.added.message is the translation key. The value of that has a variable (Using {0} notation).

  • We pass in the value of ${itemName} (which is set in the Model in our controller) to the translation.

Read more about all the string concatenation options at String concatenation with Thymeleaf.

Putting it all together

In the index.html, we will add htmx attributes to the <form> that powers the button:

<form method="post"
      hx:post="@{/purchase}"
      hx-swap="beforeend" (1)
      hx-target="#toast-stack">(2)
    ...
    <button type="submit"
            class="...">
        Add to cart
    </button>
</form>
1 Swap the HTML that gets returned from the /purchase endpoint just before the end of whatever hx-target points at.
2 Point at toast-stack to append the notification HTML that returns.

Somewhere on the page, put the toast-stack. It does not matter really where as this will not be visible anyway. I have put it at end of the main fragment:

<div layout:fragment="content" x-data> (1)
    ...
    <div id="toast-stack"
         x-on:htmx:after-swap.camel="$event.detail.elt.lastChild.toast()">

    </div>
</div>
1 Ensure Alpine is enabled for the whole div.

The real magic now comes from that single line of Alpine code x-on:htmx:after-swap.camel="$event.detail.elt.lastChild.toast()":

  • x-on allows to listen to an event that is sent in the browser

  • htmx sends the htmx:afterSwap event whenever it has swapped something.

  • .camel allows Alpine to listen for an event that is in camelcase by using kebab-case.

  • $event is an Alpine magic variable to get a reference to the event.

  • $event.detail.elt gives us access to the HTML element that sent out the event. In our case, this is the toast-stack itself.

  • Since we use beforeend in the hx-swap, the HTML that comes back from the server will the the last child element of the toast stack. lastChild gives us easy access to that element.

  • toast() is the function from Shoelace to display the notification as a toast message.

Demo

If we now start everything, we get our random notifications:

toast notifications alpine en

We get the notification with the random amount in the message itself:

toast notifications alpine shipping cost en

If we set our browser to the nl-NL locale (You can do this in the 'Sensors' menu item of Chrome Developer Tools), we see the messages are properly translated:

toast notifications alpine nl
toast notifications alpine shipping cost nl

Bonus: translate the message in JavaScript

With this setup, we don’t need any custom JavaScript anymore to show the notifications coming back from the controller. However, it is still possible that the browser sends out the request and no response comes back, or there was an exception that we did not handle in our controller.

To ensure we also show something to the user, we still need a bit of JavaScript like in part 1.

It does not mean we have to give up on a proper translation. We can re-use the translation we have on the server like this:

<script layout:fragment="js-content" th:inline="javascript"> (1)

    document.addEventListener('htmx:responseError', () => {
        notifyError(/*[[#{application.error}]]*/); (2)
    });

    ...
</script>
1 Indicate to Thymeleaf that this is a JavaScript fragment where you want to use Thymeleaf expressions.
2 Use a string literal to get the translation of the application.error translation key.

When Thymeleaf rends the HTML page, the result will be:

<script layout:fragment="js-content" th:inline="javascript">

    document.addEventListener('htmx:responseError', () => {
        notifyError('There was a problem communicating with the server.');
    });

    ...
</script>

If the browser language is set to Dutch, it will render as:

<script layout:fragment="js-content" th:inline="javascript">

    document.addEventListener('htmx:responseError', () => {
        notifyError('Er was een communicatie probleem met de server.');
    });

    ...
</script>

Conclusion

Shoelace, htmx and Alpine are a very powerful combination, given very nice results with minimal code.

See shoelace-thymeleaf-alpine on GitHub for the full sources of this example.

If you have any questions or remarks, feel free to post a comment at GitHub discussions.

If you want to be notified in the future about new articles, as well as other interesting things I'm working on, join my mailing list!
I send emails quite infrequently, and will never share your email address with anyone else.