Tutorial

Phoenix 1.7 and Bootstrap 5

This post was updated 29 Jan - 2023

Phoenix 1.7 modal forms bootstrap esbuild liveview

For a long time, Tailwind CSS has been the go-to solution for Phoenix applications. And with the release of Phoenix 1.7, Tailwind is now the default option. However, Bootstrap 5 is a great alternative and I think its interesting to see how it works with latest Phoenix and Phoenix LiveView. So, in this tutorial, I will show you how to install Bootstrap 5 in a Phoenix LiveView app.

Step 1 - Install and setup Dart Sass processor

Bootstrap requires sass to build and run. Even though you could use Bootstrap from a CDN, you need to have your own build process to customize and user your own theming. Since Phoenix 1.7 comes with Tailwind, I need to remove the Tailwind package and use a similar library that is used for processing sass-files. So for this tutorial I use this library: https://github.com/CargoSense/dart_sass

# mix.exs
defp deps do
  [
    ...
    {:dart_sass, "~> 0.5", runtime: Mix.env() == :dev}
  ]
end
  
defp aliases do
  [
    ...
    "assets.deploy": [
      "esbuild default --minify",
      "sass default --no-source-map --style=compressed",
      "phx.digest"
    ]
  ]
end

In the config.exs, you need to remove the Tailwind configuration and replace it with dart_sass. The current version is 1.54.5 You can find the latest vesrion on the releases page in the dart sass github page.

# config/config.exs
config :dart_sass,
  version: "1.54.5",
  default: [
    args: ~w(css/app.scss ../priv/static/assets/app.css),
    cd: Path.expand("../assets", __DIR__)
  ]

In the dev.exs, you need to do something similar. Remove the reference the Tailwind and inside the watchers list in the Endpoint configuration, add the following.

# config/dev.exs
sass: {
  DartSass,
  :install_and_run,
  [:default, ~w(--embed-source-map --source-map-urls=absolute --watch)]
}

You should now be able to install the needed binaries for your platform, by running this command:

mix sass.install

Step 2 - Install and setup sass processor

I prefer to install it with yarn instead of pointing to a CDN or download js-files from their site. Note that Bootstrap requires popperjs for some of its components so I need to install that as well.

cd assets
yarn add -D @popperjs/core bootstrap

To get started, import the Bootstrap js in the app js file:

// assets/js/app.js
import "bootstrap"

And also, while you are here, comment out the css import:

// import "../css/app.css"

Besides the javascript, I also need to modify the app.css. Rename the app.css to app.scss. Remove the phoenix.css import and add the bootstrap import. So, in the top, add:

// assets/css/app.scss
@import "../node_modules/bootstrap/scss/bootstrap";

Last thing here is to modify the template and use a classic bootstrap layout. Since this is a LiveView app, I need to edit the root.html.leex

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- keep the original header -->
  </head>
  <body>
    <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
      <div class="container-fluid">
        <a class="navbar-brand" href="#">Navbar</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>

        <div class="collapse navbar-collapse" id="navbarsExampleDefault">
          <ul class="navbar-nav me-auto mb-2 mb-md-0">
            <li class="nav-item">
              <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
            </li>
            <li class="nav-item dropdown">
              <a class="nav-link dropdown-toggle" href="#" id="dropdown01" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
              <ul class="dropdown-menu" aria-labelledby="dropdown01">
                <li><a class="dropdown-item" href="#">Action</a></li>
                <li><a class="dropdown-item" href="#">Another action</a></li>
                <li><a class="dropdown-item" href="#">Something else here</a></li>
              </ul>
            </li>
          </ul>
          <form class="d-flex">
            <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
            <button class="btn btn-outline-success" type="submit">Search</button>
          </form>
        </div>
      </div>
    </nav>

    <%= @inner_content %>

  </body>
</html>

When I start the server and visit the page in the browser I see that the bootstrap styleshhet was successfully loaded. Result:

And I can see that the imported javascipt is working by clicking the dropdown:

Great success. Or at least a step forward. Now I am curious on how this works with a Phoenix generated LiveView CRUD.

Step 3 - Add bootstrap to a liveview resource

In the boostrap

mix phx.gen.live Products Product products name description:text

Add the routes to the route file and then run migrations with:

mix ecto.migrate

3.1 Update the form

From the start, the form doesnt look good at all.

I need to add the classes for forms that I can find in the Bootstrap documentation.

<div>
  <h2><%= @title %></h2>

  <.form
    let={f}
    for={@changeset}
    id="product-form"
    phx-target={@myself}
    phx-change="validate"
    phx-submit="save">

    <div class="mb-3">
      <%= label f, :name, class: "form-label" %>
      <%= text_input f, :name, class: "form-control" %>
      <%= error_tag f, :name %>
    </div>

    <div class="mb-3">
      <%= label f, :description, class: "form-label" %>
      <%= textarea f, :description, class: "form-control" %>
      <%= error_tag f, :description %>
    </div>

    <div class="mb-3">
      <%= submit "Save", phx_disable_with: "Saving...", class: "btn btn-primary" %>
    </div>
  </.form>
</div>

With these small changes it looks much better.

3.2 Update the modal

The next step for me is to update the modal itself. My goal is to make the modal look and feel more like the Bootstrap modal. First, I need to clear all the styles that comes with phoenix and affects both the modal and the form.

Keep only these styles in the app.scss

/* assets/css/app.scss */
@import "../node_modules/bootstrap/scss/bootstrap";

body { /* Add this */
  padding-top: 6rem;
}

/* LiveView specific classes for your customizations */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
  display: none;
}

.phx-click-loading {
  opacity: 0.5;
  transition: opacity 1s ease-out;
}

.phx-disconnected{
  cursor: wait;
}

.phx-disconnected *{
  pointer-events: none;
}

.alert:empty {
  display: none;
}

After I generated the live views for the products, I also got the modal_component.ex . Inside the render function. I want to update with the markup for a bootstrap model.

<div id={@id} class="modal fade phx-modal"
  phx-hook="BsModal"
  phx-capture-click="close"
  phx-window-keydown="close"
  phx-key="escape"
  phx-target={@myself}
  phx-page-loading>

  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Modal title</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <%= live_component @socket, @component, @opts %>
      </div>
    </div>
  </div>
</div>

NOTE that I added a phx-hook="BsModal" . The reason is that I ultimately want to open the modal in javascript land. So I need to fire of a hook.

Here is code for the javascript hook. I import the Modal from Bootstrap and create a new modal instance and open that. I am also adding a eventlistener for the modal to push the close event to the LiveView ModalComponent . With that in place, I can close the modal with an animationeffect and still let LiveView know about the state of the modal.

// assets/js/bs_modal.js
import {Modal} from "bootstrap"

export const BsModal = {
  mounted() {
    const modal = new Modal(this.el)
    modal.show()

    this.el.addEventListener('hidden.bs.modal', event => {
      this.pushEventTo(`#${this.el.getAttribute('id')}`,'close', {})
      modal.dispose()
    })
  }
}

However, this doesnt work properly unless I remove the LiveView functionality to close the modal. So I need to remove these three lines from the template.

<!-- lib/bootstrap_web/live/modal_component.ex -->
phx-capture-click="close"
phx-window-keydown="close"
phx-key="escape"

There is still one problem to solve with the modal. That is if I actually submit the modal. Then it gets closed with the bootstrap modal backdrop still in the DOM.

// assets/js/bs_modal.js
export const BsModal = {
  mounted() {
    // ... other code
    // Add this below

    this.el.querySelector('form').addEventListener('submit', event => {
      const backdrop = document.querySelector('.modal-backdrop')
      if (backdrop) backdrop.parentElement.removeChild(backdrop)
    })
  }
}

The drawback here is that there is no animated exit when submitting the form. For the moment, that is a tradeoff I can live with.

3.3 Form validation

With this approach, I want to prevent the form from being submitten while invalid. This works best with the javascript hook I wrote above. And I usually solves this by disabling the submitbutton if the form is not valid. So I need to add this code disabled: !@changeset.valid? to the submit button.

In the form, I add:

<!-- lib/bootstrap_web/live/product_live/form_component.html.heex -->
<!-- .. Other code -->
<%= submit "Save", phx_disable_with: "Saving...", disabled: !@changeset.valid?, class: "btn btn-primary" %>

Next step is that I want to use the Boostrap validation classes to display the entire form field with a red border. And to accomplish that, I will add another validation hook so as soon as the invalid message is shown, I will fire off the hook. In the error_tag I need to add both id and the phx_hook .

# lib/bootstrap_web/views/error_helpers.ex
def error_tag(form, field) do
  Enum.map(Keyword.get_values(form.errors, field), fn error ->
    content_tag(:div, translate_error(error),
      class: "invalid-feedback",
      id: "validation-#{input_id(form, field)}", # Add this line
      phx_hook: "BsFieldValidation",             # And add this line
      phx_feedback_for: input_id(form, field)
    )
  end)
end

Basically, the idea is that I need to find the form field that the error message is referring to and add the is-invalid -class to it.

export const BsFieldValidation = {
  mounted() {
    const message = this.el
    if (message.classList.contains('phx-no-feedback')) return // Not the field in focus

    const field = document.getElementById(message.getAttribute('phx-feedback-for'))
    field.classList.add('is-invalid')
  }
}

Conclusion

Even though I usually grab Tailwind these days, I think that Bootstrap is still a great CSS framwwork and with their new revamped javascript that no longer require Jquery, it seems great for combining with Phoenix LiveView.

Related Tutorials

Published 13 May - 2021

How to create a custom select with Alpine JS and Phoenix LiveView

In this tutorial, I want to go through how to build a custom select field that is used in Tailwind UI. And I will build it with Alpine JS and Phoeni..

Published 12 Feb - 2022

CSV Export with Phoenix and LiveView

A common need in web apps is to export data to different file formats. One common, or even maybe the most common format, is exporting CSV files. CSV..