Tutorial

Bootstrap 5 and Phoenix LiveView

This post was updated 01 Oct - 2021

bootstrapformsmodalliveviewesbuildphoenix-1.6

This tutorial is updated for Phoenix 1.6 with Esbuild

Even though a large part of the Phoenix community seem to embrace Tailwind, there are still a lot that prefer Bootstrap CSS framework. And with Boostrap 5 just around the corner, 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 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 toolchain to customize and configure it to your needs. However, with the recent changes to Phoenix 1.6 with Esbuild, it doesnt come with webpack anymore and you could setup Weback, there are now an easier way. That is to install and use the sass binaries in another way than using Node and NPM. So for this tutorial I use this library: https://github.com/CargoSense/dart_sass

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

The current version is 1.42.1 You can find the latest vesrion on the releases page in the dart sass github page.

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

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

This should work if you are not on an Apple M1.

1.1 Apple M1 issue

As of today, there is no native binary for the Apple M1 Arm platform. That means that the command above could result in

** (RuntimeError) could not download dart_sass for architecture: arm-apple-darwin20.3.0

If you get this error, you can download the binaries from the releases page. Make sure its the same version as specified above.

And open the folder and then copy 3 files to _build folder.

_build/dart
_build/sass
_build/sass.snapshot

NOTE This is the solution for development and needs to be modified when you deploy to production.

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.

Phoenix Bolerplate

Generate a Phoenix Boilerplate and save hours on your next project.

6288 downloads
Try now
OR

SAAS Starter Kit

Get started and save time and resources by using the SAAS Starter Kit built with Phoenix and LiveView.

Subscribe for $39/mo to geat ahead!

Learn More

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

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..