Headless CMS fun with Phoenix LiveView and Airtable (pt. 3)

In the previous part, we generated the base application, and the Airtable API HTTP client to request both contents and blog articles. We also defined the Article and Content domain models, and implemented the repository pattern with two different adapters, one returning fake data for testing purposes, and the other using the Airtable HTTP client to request and convert the returned data into our domain. It's time for some front-end fun, so let's get cracking.

Rendering content using LiveView

One thing before continuing, though. I'm using Bulma, which is very good looking and easy to use CSS framework for the UI styles. To use it, you need to add this line in the root.html.leex template, and here you can find the CSS file with the custom styles.

What is Phoenix.LiveView? The short definition would be: a library which provides rich, real-time user experiences with server-rendered HTML, without having to write almost any JS whatsoever, only using plain Elixir. But in reality, it is a bit more complicated.

LiveView initially renders static HTML, which is fast and optimal for search and indexing engines. After the first rendering, it upgrades to a persistent connection, with its state, and is capable of listening to messages from both other processes and the browser, and update its state. Once the state is updated, it re-renders the parts of the HTML corresponding to these changes.

LiveView is currently so well integrated into Phoenix, that we can use them anywhere, including the router file as if they were controllers. Since we created the project with the --live option, we already have everything we need to start using it, so let's go ahead and edit the route file to add the three different live view that we need:

# lib/phoenix_cms_web/router.ex

defmodule PhoenixCmsWeb.Router do
  use PhoenixCmsWeb, :router

  # ...


  scope "/", PhoenixCmsWeb do
    pipe_through :browser

    live "/", PageLive
    live "/blog", ArticlesLive
    live "/blog/:id/:slug", ShowArticleLive
  end

  # ...
end

We have three different routes in our application:

  • /: which renders the home page using the PageLive live view.
  • /blog: which renders all the articles using the ArticlesLive live view.
  • /blog/:id/:slug: which renders a given article using the ShowArticleLive live view.

Live navigation

LiveView provides support for live navigation using the browser's pushState API, making it possible to navigate between pages without full page reloads. Let's use this feature by adding links to both the home and the blog page in the main navigation bar:

# lib/phoenix_cms_web/templates/layout/root.html.leex

<!DOCTYPE html>
<html lang="en">
  # ...

    <nav class="navbar has-shadow" role="navigation" aria-label="main navigation">
      <div class="container">
        <div class="navbar-brand">
          <%= live_patch "PhoenixCMS", to: Routes.live_path(@conn, PhoenixCmsWeb.PageLive), class: "navbar-item has-text-weight-bold has-text-link" %>
        </div>
        <div class="navbar-end">
          <%= live_patch "Blog", to: Routes.live_path(@conn, PhoenixCmsWeb.ArticlesLive), class: "navbar-item" %>
        </div>
      </div>
    </nav>

    # ...
</html>

live_patch renders a link which patches the current LiveView with the one specified in the to option, without reloading the whole page and adding a new entry in the browser's history. Now that we can navigate through our views let's implement the home page.

The PageLive live view

Let's start with the main home page:

# lib/phoenix_cms_web/live/page_live.ex

defmodule PhoenixCmsWeb.PageLive do
  use PhoenixCmsWeb, :live_view

  alias PhoenixCmsWeb.LiveEncoder

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign_socket(socket)}
  end

  # Missing assign_socket function...
end

The mount/3 function receives params, the current session, and the socket, returning it with the assigned contents. Let's implement the assign_socket/1 private function:

# lib/phoenix_cms_web/live/page_live.ex

defmodule PhoenixCmsWeb.PageLive do
  use PhoenixCmsWeb, :live_view

  # ...

  defp assign_socket(socket) do
    case fetch_contents() do
      {:ok, contents} ->
        socket
        |> assign(:page_title, "Home")
        |> assign(:contents, contents)
        |> put_flash(:error, nil)

      _ ->
        socket
        |> assign(:page_title, "Home")
        |> assign(:contents, nil)
        |> put_flash(:error, "Error fetching data")
    end
  end

  # Missing fetch_contents function...
end

Depending on the result of the fetch_contents/0 function, it assigns :contents or a flash :error. The fetch_contents/0 looks like this:

# lib/phoenix_cms_web/live/page_live.ex

defmodule PhoenixCmsWeb.PageLive do
  use PhoenixCmsWeb, :live_view

  # ...

  defp fetch_contents do
    with {:ok, contents} <- PhoenixCms.contents() do
      contents =
        contents
        |> Enum.sort_by(& &1.position)
        |> LiveEncoder.contents()

      {:ok, contents}
    end
  end
end

This function calls PhoenixCms.contents/0, which we haven't implemented yet, sorts contents by position and calls LiveEncoder.contents/1, which converts these Content structs into the payload which the live view template is expecting. When working with Pheonix apps, I like to delegate any business logic functions that need the *Web namespace from the main module, in our case PhoenixCms, acting as the public API between business logic and presentation. Let's go ahead and expose the functions that we need:

# lib/phoenix_cms.ex

defmodule PhoenixCms do
  defdelegate articles, to: PhoenixCms.Repo

  defdelegate contents, to: PhoenixCms.Repo

  defdelegate get_article(id), to: PhoenixCms.Repo
end

Now we need to implement the PhoenixCmsWeb.LiveEncoder module and convert the list of PhoenixCms.Content into the payload that the live template needs to render:

# lib/phoenix_cms_web/live/encoder.ex

defmodule PhoenixCmsWeb.LiveEncoder do
  alias PhoenixCms.Content

  def contents(items) when is_list(items) do
    {features, rest} =
      items
      |> Enum.map(&encode/1)
      |> Enum.split_with(&(&1.type == "feature"))

    rest
    |> Enum.concat([%{features: features}])
    |> List.flatten()
  end

  def encode(%Content{} = content) do
    Map.take(content, [:id, :type, :title, :content, :image, :styles])
  end
end

We want to render every content in its HTML section node, except for content with type feature, which we want to group them in the same section. Therefore, we split the contents into two different lists, extracting the ones with type feature and appending it as a map with a features key.

To render HTML in LiveView, you can either implement the render/1 callback function or create a your_view_template.html.leex template in your live view folder. Let's take the second choice:

# lib/phoenix_cms_web/live/page_live.html.leex

<%= if @contents do %>
  <%= for content <- @contents, do: render_section(content) %>
<% end %>

Iterating over the assigned contents, it calls the render_section/1 function, which we need to add to the PageLive module:

# lib/phoenix_cms_web/live/page_live.ex

defmodule PhoenixCmsWeb.PageLive do
  use PhoenixCmsWeb, :live_view

  # ...

  def render_section(%{type: "hero"} = content) do
    Phoenix.View.render(PhoenixCmsWeb.PageView, "hero.html", content: content)
  end

  def render_section(%{type: "text_and_image"} = content) do
    Phoenix.View.render(PhoenixCmsWeb.PageView, "text_and_image.html", content: content)
  end

  def render_section(%{features: content}) do
    Phoenix.View.render(PhoenixCmsWeb.PageView, "features.html", content: content)
  end
end

As we have three different content types (hero, text_and_image, and feature), we want to give them their layout and style, so we render them using different templates:

# lib/phoenix_cms_web/templates/page/hero.html.eex

<section class="hero is-link is-medium">
  <div class="hero-body">
    <div class="container">
      <header class="hero__header">
        <h1 class="title is-1 mb-6"><%= @content.title %></h1>
        <p class="subtitle is-3 mb-6"><%= @content.content %></p>
      </header>
      <figure class="image">
        <img class="" src="<%= @content.image %>" alt="Placeholder image">
      </figure>
    </div>
  </div>
</section>
# lib/phoenix_cms_web/templates/page/text_and_image.html.eex

<div class="container text-and-image">
  <div class="columns is-variable is-mobile is-8">
    <div class="column is-half">
      <header class="mb-4"><h2 class="title"><%= @content.title %></h2></header>
      <p class="subtitle"><%= @content.content %></p>
    </div>
    <div class="column is-half image-container">
      <figure class="image">
        <img src="<%= @content.image %>" alt="Placeholder image">
      </figure>
    </div>
  </div>
</div>
# lib/phoenix_cms_web/templates/page/features.html.eex

<section class="section">
  <div class="container mb-6 features">
    <header class="mb-6">
      <h2 class="title is-2">Features</h2>
    </header>
    <div class="columns is-multiline is-mobile is-8">
      <%= for item <- @content do %>
        <div class="column is-one-third feature">
          <figure class="image feature__image">
            <img src="<%= item.image %>" alt="Placeholder image">
          </figure>
          <header class="mb-4"><h4 class="title is-4"><%= item.title %></h4></header>
          <p class="subtitle"><%= item.content %></p>
        </div>
      <% end %>
    </div>
  </div>
</section>

The ArticlesLive live view

To render the articles list corresponding to the /blog route, let's implement the ArticlesLive module:

# lib/phoenix_cms_web/live/articles_live.ex

defmodule PhoenixCmsWeb.ArticlesLive do
  use PhoenixCmsWeb, :live_view

  alias PhoenixCmsWeb.LiveEncoder

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign_socket(socket)}
  end

  defp assign_socket(socket) do
    case fetch_articles() do
      {:ok, articles} ->
        socket
        |> assign(:page_title, "Blog")
        |> assign(:articles, articles)
        |> put_flash(:error, nil)

      _ ->
        socket
        |> assign(:page_title, "Blog")
        |> assign(:articles, nil)
        |> put_flash(:error, "Error fetching data")
    end
  end

  defp fetch_articles do
    with {:ok, articles} <- PhoenixCms.articles() do
      articles
      |> Enum.sort_by(& &1.published_at)
      |> LiveEncoder.articles()

      {:ok, articles}
    end
  end
end

Just like in the PageLive module, it fetches the articles using PhoenixCms.articles/0, which delegates its call to the PhoenixCms.Repo module. If everything goes fine, it encodes the items and assigns them to the socket. This step is important because since the socket process stores the assigned elements in memory, we only want to store the necessary values:

# lib/phoenix_cms_web/live/encoder.ex

defmodule PhoenixCmsWeb.LiveEncoder do
  alias PhoenixCms.{Article, Content }

  # ...

  def articles(articles) do
    Enum.map(articles, &encode/1)
  end

  def encode(%Article{} = article) do
    Map.take(article, [:id, :slug, :title, :description, :image, :author, :published_at])
  end
end

Note that we are not taking the full article content for this page, because we don't want to render it. Now let's write its template:

# lib/phoenix_cms_web/live/articles_live.html.leex

<%= if @articles  do %>
  <section class="section">
    <div class="container">
      <header class="mb-6"><h2 class="title">Blog</h2></header>
      <div class="columns is-variable is-multiline is-mobile is-8">
        <%= for article <- @articles, do: render_article(@socket, article) %>
      </div>
    </div>
  </section>
<% end %>

As we did with the contents list, we have to add the render_article/2 to the view:

# lib/phoenix_cms_web/live/articles_live.ex

defmodule PhoenixCmsWeb.ArticlesLive do
  use PhoenixCmsWeb, :live_view

  # ...


  def render_article(socket, %{id: _id, slug: _slug} = article) do
    Phoenix.View.render(PhoenixCmsWeb.PageView, "article.html", socket: socket, article: article)
  end
end

And we can't forget about its article item template:

# lib/phoenix_cms_web/templates/page/article.html.eex

<%= live_patch to: Routes.live_path(@socket, PhoenixCmsWeb.ShowArticleLive, @article.id, @article.slug), class: "column is-half article-list__article" do %>
  <img class="article__image" src="<%= @article.image %>">
  <header>
    <h3 class="title is-4"><%= @article.title %></h3>
    <h4 class="subtitle is-5"><%= @article.description %></h4>
    <div class="media">
      <div class="media-left">
        <figure class="image is-48x48">
          <img class="is-rounded avatar" src="<%= "https://avatars.dicebear.com/api/avataaars/#{@article.author}.svg" %>" alt="Placeholder image">
        </figure>
      </div>
      <div class="media-content">
        <p class="title is-6"><%= @article.author %></p>
        <p class="subtitle is-6"><%= @article.published_at %></p>
      </div>
    </div>
  </header>
<% end %>

Using the same live_patch function as in the main navigation section, we create a link around the article summary to navigate to the article detail page, in which we can read the full version of the article.

The ShowArticleLive live view

Last but not least, this LiveView renders the full version of an article:

# lib/phoenix_cms_web/live/show_article_live.ex

defmodule PhoenixCmsWeb.ShowArticleLive do
  use PhoenixCmsWeb, :live_view

  @impl true
  def mount(%{"id" => id}, _session, socket) do
    {:ok, assign_socket(socket, id)}
  end

  defp assign_socket(socket, id) do
    case PhoenixCms.get_article(id) do
      {:ok, article} ->
        socket
        |> assign(:page_title, article.title)
        |> assign(:article, article)
        |> put_flash(:error, nil)

      {:error, _} ->
        socket
        |> assign(:page_title, "Blog")
        |> assign(:article, nil)
        |> put_flash(:error, "Error fetching data")
    end
  end
end

Following the same pattern as in the previous views, it calls PhoenixCms.get_article/1 passing the article id received in its mount parameters, and assigning the result to the socket. The corresponding template looks like this:

# lib/phoenix_cms_web/live/show_article_live.html.leex

<%= if @article  do %>
  <article class="article">
    <div class="container mt-6">
      <header class="article__header">
        <h1 class="title"><%= @article.title %></h1>
        <div class="media">
          <div class="media-left">
            <figure class="image is-48x48">
              <img class="is-rounded avatar" src="<%= "https://avatars.dicebear.com/api/avataaars/#{@article.author}.svg" %>" alt="Placeholder image">
            </figure>
          </div>
          <div class="media-content">
            <p class="title is-6"><%= @article.author %></p>
            <p class="subtitle is-7"><%= @article.published_at %></p>
          </div>
        </div>
      </header>
      <figure class="image main-image">
        <img src="<%= @article.image %>">
      </figure>
      <p class="subtitle is-italic"><%= @article.description %></p>
      <section class="article__content">
        <%= raw(@article.content) %>
      </section>
    </div>
  </article>
<% end %>

Almost there

Now that we have everything ready, let's start the application and navigate through its pages, checking out the logs in the console:

iex(2)> [info] GET https://api.airtable.com/v0/appXTw8FgG3h55fk6/articles -> 200 (653.723 ms)
[info] GET /
[info] Sent 200 in 20ms
[info] GET https://api.airtable.com/v0/appXTw8FgG3h55fk6/contents -> 200 (153.722 ms)
[info] GET /blog
[info] Sent 200 in 426µs
[info] GET https://api.airtable.com/v0/appXTw8FgG3h55fk6/articles -> 200 (218.254 ms)
[info] GET /blog/rec1osLptzsXfWg5g/lorem-ipsum
[info] Sent 200 in 384µs
[info] GET https://api.airtable.com/v0/appXTw8FgG3h55fk6/articles/rec1osLptzsXfWg5g -> 200 (193.594 ms)
[info] GET /blog
[info] Sent 200 in 581µs
[info] GET https://api.airtable.com/v0/appXTw8FgG3h55fk6/articles -> 200 (211.392 ms)
[info] GET /
[info] Sent 200 in 519µs
[info] GET https://api.airtable.com/v0/appXTw8FgG3h55fk6/contents -> 200 (129.278 ms)
[info] GET /blog
[info] Sent 200 in 427µs
[info] GET https://api.airtable.com/v0/appXTw8FgG3h55fk6/articles -> 200 (224.131 ms)
[info] GET /blog/rec1osLptzsXfWg5g/lorem-ipsum
[info] Sent 200 in 381µs
[info] GET https://api.airtable.com/v0/appXTw8FgG3h55fk6/articles/rec1osLptzsXfWg5g -> 200 (118.158 ms)

As we can see, every time we visit a page, the view makes the corresponding HTTP request to get its necessary contents. Although working fine for a single user, if we had many users visiting our site, it could easily overcome Airtable's rate limit of five requests per second. Not to mention the overhead that adds making an HTTP request on every page and what would happen if Airtable is down for whatever reason. In the next and last part of the series, we will look for a solution to all these problems, by implementing an automated cache mechanism using ETS. In the meantime, you can check the end result here, or have a look at the source code.

Happy coding!

comments powered by Disqus