Phoenix 1.7 with LiveView 0.20 Starter Demo

Article 0 - Exploring and new Phoenix / LiveView features

It’s been a while since I have had time to code in Elixir. In this article I wanted to explore its current usage and features.

We will build toward a flexible Kanban / Task Tracker. We will see how far we get. The main point is to explore features and create a quasi-tutorial (assuming some pre-existing knowledge).

Article Series

Getting started

mix phx.new taskboard
cd taskboard
mix ecto.create
git init
git add .
git commit -m "initial commit"

iex -S mix phx.server

Binary IDs

if you prefer UUID keys to serial IDs then you can easily do that with the following changes. This can be very helpful if you need to sync with external frontend or other external apps

update config/config.ex from:

config :taskboard,
  ecto_repos: [Taskboard.Repo],
  generators: [timestamp_type: :utc_datetime]

to:

config :taskboard,
  ecto_repos: [Taskboard.Repo],
  generators: [
    timestamp_type: :utc_datetime,
    binary_id: true
  ]

using phx.gen.auth

Some resources used:

also:

mix phx.gen.auth Accounts User users --web Auth
mix deps.get
mix ecto.migrate

organize user into core data

I like to organize the code into areas that is associated with usage - so, in this case, I will start by moving all core ‘models’ into a folder / module called ‘core’ by doing:

# create the core lib folder
mkdir lib/taskboard/core/
# create the core test folder
mkdir test/taskboard/core
mkdir test/support/fixtures/core

# move your lib code into the new area
mv lib/taskboard/account* lib/taskboard/core/.
# move the test and support code into 'core'
mv test/taskboard/accounts* test/taskboard/core/.
mv test/support/fixtures/accounts* test/support/fixtures/core/.

replace every Taskboard.Accounts with Taskboard.Core.Accounts

Lets make sure everything works

mix test

iex -S mix phx.server
# make a new user and login and logout

git add .
git commit -m "add user auth"

System Emails

you can find (in Dev) any emails that would have been sent for user confirmation or password reminders, etc. at:

http://localhost:4000/dev/mailbox

Here you can read and test the messages - without them being sent to real people.

add Projects / Projects

(using web space - and lib module)

Resources used:

with a renamed (owner) association for the ‘user’

and within a routing namespace (auth)

mix phx.gen.live Projects Project projects title:string description:text owner_id:references:users --web Admin

Again lets move ‘Projects’ into the core area/

mv lib/projects* lib/core/.

replace: Taskboard.Projects with Taskboard.Core.Projects

now update schemas with the custom association name (owner) - replace: field :owner_id, :id with: belongs_to :owner, User, [foreign_key: :owner_id]

and in the changeset add the owner_id

Thus lib/core/projects/project.ex should now look like:

defmodule Taskboard.Core.Projects.Project do
  use Ecto.Schema
  import Ecto.Changeset
  # add the association alias
  alias Taskboard.Core.Accounts.User

  schema "projects" do
    field :description, :string
    field :title, :string
    # remove this
    # field :owner_id, :id
    # use this for relationship and custom association name
    belongs_to :owner, User, [foreign_key: :owner_id]

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(project, attrs) do
    project
    |> cast(attrs, [:title, :description, :owner_id])
    |> validate_required([:title, :description, :owner_id])
    # |> cast(attrs, [:title, :description])
    # |> validate_required([:title, :description])
  end
end

we also need to update user schema with the has_many association: has_many :projects, Project, foreign_key: :owner_id again given the rename of owner / user id we need to add the foreign key.

now lib/core/accounts/user.ex should now look like:

defmodule Taskboard.Core.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  # add the association alias
  alias Taskboard.Core.Projects.Project

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime

    # add the foreign_key here to ensure the custom association name works
    has_many :projects, Project, foreign_key: :owner_id

    timestamps(type: :utc_datetime)
  end
  ...

end

finally we need to update our new migration to delete projects when user is deleted - on_delete: :delete_all and ensure an association = null: false - replace: add :owner_id, references(:users, on_delete: :nothing)with:add :owner_id, references(:users, on_delete: :delete_all), null: false`

Thus the migration should look like:

defmodule Taskboard.Repo.Migrations.CreateProjects do
  use Ecto.Migration

  def change do
    create table(:projects) do
      add :title, :string
      add :description, :text
      # remove this
      # add :owner_id, references(:users, on_delete: :nothing)
	    # add this to delete projects when user is deleted and require association
      add :owner_id, references(:users, on_delete: :delete_all), null: false

      timestamps(type: :utc_datetime)
    end

    create index(:projects, [:owner_id])
  end
end

update the routes too with:

    scope "/admin", TaskboardWeb.Admin, as: :admin do
      pipe_through :browser

      live "/projects", ProjectLive.Index, :index
      live "/projects/new", ProjectLive.Index, :new
      live "/projects/:id/edit", ProjectLive.Index, :edit

      live "/projects/:id", ProjectLive.Show, :show
      live "/projects/:id/show/edit", ProjectLive.Show, :edit
    end

update routes

    scope "/admin", TaskboardWeb.Admin, as: :admin do
      pipe_through :browser

      live "/projects", ProjectsLive.Index, :index
      live "/projects/new", ProjectsLive.Index, :new
      live "/projects/:id/edit", ProjectsLive.Index, :edit

      live "/projects/:id", ProjectsLive.Show, :show
      live "/projects/:id/show/edit", ProjectsLive.Show, :edit
    end

lets see if it all works

mix ecto.migrate
mix test

iex -S mix phx.server

go to the admin page http://localhost:4000/admin/projects and try to make a new project.

fix the projects form

Resources used:

We can’t make new projects (because we required an owner_id), but there is no way to enter the owner!

To add the user to the form, we first need to add the users to the socket in the update function (within the file: lib/taskboard_web/live/admin/project_live/form_component.ex) thus it should now look like:

  @impl true
  def update(%{domains: domains} = assigns, socket) do
    changeset = WorkDomains.change_domains(domains)

    # add these lines
    owners = ClearSync.Core.Accounts.list_users()
    socket = assign(socket, owners: owners)

    {:ok,
     socket
     |> assign(assigns)
     |> assign_form(changeset)}
  end

now we need to create the list_users: in lib/taskboard/core/accounts.ex

  def list_users, do: Repo.all(User)

now we can add the user selection to the form lib/taskboard_web/live/admin/project_live/form_component.ex page:

        <.input
          field={@form[:owner_id]}
          type="select"
          label="Owner"
          prompt="Select an owner"
          options={Enum.map(@users, &{&1.email, &1.id})}
        />

We will also make the description a textarea by changing: <.input field={@form[:description]} type="text" label="Description" /> to <.input field={@form[:description]} type="textarea" label="Description" />

Thus now lib/taskboard_web/components/core_components.ex should look like:

defmodule TaskboardWeb.Admin.ProjectLive.FormComponent do
  use TaskboardWeb, :live_component

  alias Taskboard.Core.Projects

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.header>
        <%= @title %>
        <:subtitle>Use this form to manage project records in your database.</:subtitle>
      </.header>

      <.simple_form
        for={@form}
        id="project-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.input field={@form[:title]} type="text" label="Title" />
		    <!-- use text area for a longer input in the description -->
        <.input field={@form[:description]} type="textarea" label="Description" />
        <!-- add this to allow a relationship -->
        <.input
          field={@form[:owner_id]}
          type="select"
          label="Owner"
          prompt="Select an owner"
          options={Enum.map(@owners, &{&1.email, &1.id})}
        />
        <:actions>
          <.button phx-disable-with="Saving...">Save Project</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

  @impl true
  def update(%{project: project} = assigns, socket) do
    changeset = Projects.change_project(project)

    # add these two lines (so we have users available in the heex render)
    owners = Taskboard.Core.Accounts.list_users()
    socket = assign(socket, owners: owners)

    {:ok,
     socket
     |> assign(assigns)
     |> assign_form(changeset)}
  end
  ...
end

now be sure we can make a new project.

git add .
git commit -m "add projects to an admin panel

update show & index pages

Now we also notice that both the show and index page also don’t show the ‘owner’ (‘user’). To do this we need to ‘preload’ the owner within the project - Phoenix / Ecto doesn’t do this automatically. (if you forget you get a ’not loaded error’)

Preload Resource:

update lib/taskboard/core/Projects.ex to preload the owner in the index page by changing: def list_projects, do: Repo.all(Project) to def list_projects, do: Repo.all(Project) |> Repo.preload(:owner)

and for the show page change: def get_project!(id), do: Repo.get!(Project, id) to def get_project!(id), do: Repo.get!(Project, id) |> Repo.preload(:owner)

Thus now lib/taskboard/core/Projects.ex should look like:

defmodule Taskboard.Core.Projects do
  import Ecto.Query, warn: false
  alias Taskboard.Repo

  alias Taskboard.Core.Projects.Project

  # def list_projects, do: Repo.all(Project)
  def list_projects, do: Repo.all(Project) |> Repo.preload(:owner)

  # def get_project!(id), do: Repo.get!(Project, id)
  def get_project!(id), do: Repo.get!(Project, id) |> Repo.preload(:owner)

  ...

Now we can add <:col :let={{_id, project}} label="Owner"><%= project.owner.email %></:col> to the index page:

Thus now the index heex page should look like: lib/taskboard_web/live/admin/project_live/index.html.heex:

<.header>
  Listing Projects
  <:actions>
    <.link patch={~p"/admin/projects/new"}>
      <.button>New Project</.button>
    </.link>
  </:actions>
</.header>

<.table
  id="projects"
  rows={@streams.projects}
  row_click={fn {_id, project} -> JS.navigate(~p"/admin/projects/#{project}") end}
>
  <:col :let={{_id, project}} label="Title"><%= project.title %></:col>
  <:col :let={{_id, project}} label="Description"><%= project.description %></:col>
  <!-- add this line -->
  <:col :let={{_id, project}} label="Owner"><%= project.owner.email %></:col>
  <:action :let={{_id, project}}>
    <div class="sr-only">
      <.link navigate={~p"/admin/projects/#{project}"}>Show</.link>
    </div>
    <.link patch={~p"/admin/projects/#{project}/edit"}>Edit</.link>
  </:action>
  <:action :let={{id, project}}>
    <.link
      phx-click={JS.push("delete", value: %{id: project.id}) |> hide("##{id}")}
      data-confirm="Are you sure?"
    >
      Delete
    </.link>
  </:action>
</.table>

<.modal :if={@live_action in [:new, :edit]} id="project-modal" show on_cancel={JS.patch(~p"/admin/projects")}>
  <.live_component
    module={TaskboardWeb.Admin.ProjectLive.FormComponent}
    id={@project.id || :new}
    title={@page_title}
    action={@live_action}
    project={@project}
    patch={~p"/admin/projects"}
  />
</.modal>

now we should see the email for the owner on the index page!

Now we are ready to fix the show page - add: <:item title="Owner"><%= @project.owner.email %></:item> to the <.list> in the show heex page.

Thus lib/taskboard_web/live/admin/project_live/show.html.heex should now look like:

<.header>
  Project <%= @project.id %>
  <:subtitle>This is a project record from your database.</:subtitle>
  <:actions>
    <.link patch={~p"/admin/projects/#{@project}/show/edit"} phx-click={JS.push_focus()}>
      <.button>Edit project</.button>
    </.link>
  </:actions>
</.header>

<.list>
  <:item title="Title"><%= @project.title %></:item>
  <:item title="Description"><%= @project.description %></:item>
  <!-- add this line -->
  <:item title="Owner"><%= @project.owner.email %></:item>
</.list>

<.back navigate={~p"/admin/projects"}>Back to projects</.back>

<.modal :if={@live_action == :edit} id="project-modal" show on_cancel={JS.patch(~p"/admin/projects/#{@project}")}>
  <.live_component
    module={TaskboardWeb.Admin.ProjectLive.FormComponent}
    id={@project.id}
    title={@page_title}
    action={@live_action}
    project={@project}
    patch={~p"/admin/projects/#{@project}"}
  />
</.modal>

Now if we click on a project on the index page we should see the owner on the show page too.

git add .
git commit -m "admin panel for projects"

Empty Page Component

Resources

Let’s say you want a reusable ’empty page’ component with an interesting logo which will be displayed on ANY index page with nothing to show - we will start with our project page.

Lets make a new file at: lib/taskboard_web/components/empty_state.ex with the contents:

defmodule TaskboardWeb.Components.EmptyState do
  use TaskboardWeb, :html

  # this help heex know if the component is used properly
  attr(:text, :string)
  attr(:image, :string)
  def empty_state(assigns) do
    ~H"""
    <div>
      <h2 class="text-2xl font-semibold tracking-tight sm:text-center sm:text-4xl">
        <%= @text %>
      </h2>
      <div class="mt-5 mx-auto w-full max-w-xs">
        <img
          alt={@text}
          src={~p"/images/#{@image}"}
          class="w-full max-w-none rounded-xl ring-1 ring-gray-400/10 md:-ml-4 lg:-ml-0"
        />
      </div>
    </div>
    """
  end
end

These two line:

  attr(:text, :string)
  attr(:image, :string)

will let HEEX enforce that they are included in the call (or provide an error)

usage

Now we can change: `` with the content:


<%= if Enum.count(@streams.projects) == 0 do %>
  <div class="mt-20">
    <.empty_state show text="no Projects yet" image="task.png"/>
    <%!-- <TaskboardWeb.Components.EmptyState.empty_state text="no Projects yet" image="task.png"/> --%>
  </div>
<% else %>
  <.table
    id="projects"
    rows={@streams.projects}
    row_click={fn {_id, project} -> JS.navigate(~p"/admin/projects/#{project}") end}
  >
    <:col :let={{_id, project}} label="Title"><%= project.title %></:col>
    <:col :let={{_id, project}} label="Description"><%= project.description %></:col>
    <:col :let={{_id, project}} label="Owner"><%= project.owner.email %></:col>
    <:action :let={{_id, project}}>
      <div class="sr-only">
        <.link navigate={~p"/admin/projects/#{project}"}>Show</.link>
      </div>
      <.link patch={~p"/admin/projects/#{project}/edit"}>Edit</.link>
    </:action>
    <:action :let={{id, project}}>
      <.link
        phx-click={JS.push("delete", value: %{id: project.id}) |> hide("##{id}")}
        data-confirm="Are you sure?"
      >
        Delete
      </.link>
    </:action>
  </.table>
<% end %>

Now we only show the table when there are items to show.

Simplify Usage

We can actually simplify the line: <TaskboardWeb.Components.EmptyState.empty_state text="no Projects yet" image="task.png"/> to <.empty_state show text="no Projects yet" image="task.png"/> by importing our Component with: import TaskboardWeb.Components.EmptyState, only: [empty_state: 1] into lib/taskboard_web/live/admin/project_live/index.ex

So now the beginning of this file should look like:

defmodule TaskboardWeb.Admin.ProjectLive.Index do
  use TaskboardWeb, :live_view

  alias Taskboard.Core.Programs
  alias Taskboard.Core.Programs.Project
  import TaskboardWeb.Components.EmptyState, only: [empty_state: 1]

  ...
end

Thus now we can use:


<%= if Enum.count(@streams.projects) == 0 do %>
  <div class="mt-20">
    <.empty_state show text="no Projects yet" image="task.png"/>

  </div>
<% else %>
  ...
<% end %>

Create a Reactive navbar

Since Navbar is a lot of messy code lets make a Navbar component.

To make it reactive we will need to inject some ‘JS’ that Phoenix provides (and avoids us needing to install our own JavaScript framework)

Let’s start making an landing page with a menu bar. To do this we will start by updating: lib/taskboard_web/controllers/page_html/root.html.heex

By putting the navbar in root.html.heex we won’t be able to add user notifications into the menubar. If this is desired they you will need to put the dynamic navbar in app.html.heex and the homepage static navbar in home.html.heex (There might be a better way, but I don’t know it yet).

Be sure to use <.link navigate={~p"/some/route/here"}> instead of <.link patch={~p"/some/route/here"} in root.html.heex because the patch navigation option is only available in LiveViews (LiveViews use both root.html.heex and app.html.heex - dead pages only use eoot.html.heex).

Create a new page at lib/taskboard_web/components/navigation_bar.ex and copy the following contents into it:

defmodule TaskboardWeb.Components.NavigationBar do
  use TaskboardWeb, :html

  def navigation_bar(assigns) do
    ~H"""
    <header class="absolute sticky inset-x-0 top-0 z-50 bg-slate-100">
      <nav class="mx-auto flex max-w-7xl items-center justify-between p-6 lg:px-8" aria-label="Global">
        <div class="flex lg:flex-1">
          <.link href={~p"/"} class="-m-1.5 p-1.5">
            <span class="sr-only">TaskBoard</span>
            <!-- Logo -->
            <img
              class="h-8 w-auto"
              src={~p"/images/task.png"}
              alt="logo"
            >
          </.link>
        </div>
        <!-- hamburger menu button -->
        <div class="flex lg:hidden">
          <button phx-click={JS.toggle(to: "#mobile-menu")} class="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700">
            <span class="sr-only">Open main menu</span>
            <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
              <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
            </svg>
          </button>
        </div>
        <div class="hidden lg:flex lg:gap-x-12">
          <a href={~p"/admin/projects"} class="text-sm font-semibold leading-6 text-gray-900 hover:text-blue-700">
            SiteAdmin
          </a>
          <a href="#" class="text-sm font-semibold leading-6 text-gray-900 hover:text-blue-700">
            ProjectOwner
          </a>
          <a href="#" class="text-sm font-semibold leading-6 text-gray-900 hover:text-blue-700">
            Collaborate
          </a>
        </div>
        <!-- right justified links (login, etc) -->
        <div class="flex flex-1 items-center justify-end gap-x-6">
          <%= if @current_user do %>
            <div class="relative">
              <%!-- <button type="button" class="flex items-center gap-x-1 text-sm font-semibold leading-6 text-gray-900" aria-expanded="false"> --%>
              <button phx-click={JS.toggle(to: "#user-dropdown")} class="flex items-center gap-x-1 text-sm font-semibold leading-6 text-gray-900">
                <%= @current_user.email %>
                <svg class="h-5 w-5 flex-none text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                  <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
                </svg>
              </button>
              <div id="user-dropdown" class="hidden absolute -left-8 top-full z-10 mt-3 w-56 rounded-xl bg-white p-2 shadow-lg ring-1 ring-gray-900/5">
                <.link
                  phx-click={JS.hide(to: "#user-dropdown")}
                  href={~p"/auth/users/settings"}
                  class="block rounded-lg px-3 py-2 text-sm font-semibold leading-6 text-gray-900 hover:bg-gray-50"
                >
                  Settings
                </.link>
                <.link
                  phx-click={JS.hide(to: "#user-dropdown")}
                  href={~p"/auth/users/log_out"}
                  method="delete"
                  class="block rounded-lg px-3 py-2 text-sm font-semibold leading-6 text-gray-900 hover:bg-gray-50"
                >
                  Log out
                </.link>
              </div>
            </div>
            <.link
              href={~p"/auth/users/log_out"}
              class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
            >
              Log out
            </.link>
          <% else %>
            <.link
              href={~p"/auth/users/log_in"}
              class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-blue-700"
            >
              Log in
            </.link>
            <.link
              href={~p"/auth/users/register"}
              class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
            >
              Register
            </.link>
          <% end %>
        </div>
      </nav>

      <!-- Mobile menu, show/hide based on menu open state. -->
      <div id="mobile-menu" class="lg:hidden" role="dialog" aria-modal="true">
        <!-- Background backdrop, show/hide based on slide-over state. -->
        <div class="fixed inset-0 z-50"></div>
        <div class="fixed inset-y-0 right-0 z-50 w-full overflow-y-auto bg-white px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10">
          <div class="flex items-center justify-between">
            <.link href={~p"/"} class="-m-1.5 p-1.5" >
              <span class="sr-only">TaskBoard</span>
              <!-- Logo -->
              <img
                class="h-8 w-auto"
                src={~p"/images/task.png"}
                alt="logo"
              >
            </.link>
            <!-- Close button, show/hide based on slide-over state. -->
            <button phx-click={JS.toggle(to: "#mobile-menu")} class="-m-2.5 rounded-md p-2.5 text-gray-700">
              <span class="sr-only">Close menu</span>
              <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
              </svg>
            </button>
          </div>
          <div class="mt-6 flow-root">
            <div class="-my-6 divide-y divide-gray-500/10">
              <div class="space-y-2 py-3">
                <a href={~p"/admin/projects"} class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50 hover:text-blue-700">
                  SiteAdmin
                </a>
                <a href="#" class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50 hover:text-blue-700">
                  ProjectOwner
                </a>
                <a href="#" class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50 hover:text-blue-700">
                  Collaborate
                </a>
              </div>
              <div class="space-y-2 py-3">
                <%= if @current_user do %>
                  <%= @current_user.email %>
                  <.link
                    href={~p"/auth/users/settings"}
                    class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50 hover:text-blue-700"
                  >
                    Settings
                  </.link>
                  <.link
                    href={~p"/auth/users/log_out"}
                    method="delete"
                    class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50 hover:text-blue-700"
                  >
                    Log out
                  </.link>
                <% else %>
                  <.link
                    href={~p"/auth/users/register"}
                    class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50 hover:text-blue-700"
                  >
                    Register
                  </.link>
                  <.link
                    href={~p"/auth/users/log_in"}
                    class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50 hover:text-blue-700"
                  >
                    Log in
                  </.link>
                <% end %>
              </div>
            </div>
          </div>
        </div>
      </div>
    </header>
    """
  end
end

Using the Navbar

now we can add our new component to the root.html.heex

<!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable]">

  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title suffix=" · Phoenix Framework">
      <%= assigns[:page_title] || "Taskboard" %>
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
    </script>
  </head>

  <body class="bg-white antialiased">

    <TaskboardWeb.Components.NavigationBar.navigation_bar current_user={@current_user}/>

    <%= @inner_content %>

  </body>
</html>

(this will make it work everywhere - with the limitation that it can’t do pop-up notifications). For popup notifications we need to put it in: app.html.heex, but that has some complications with @current_user I haven’t yet resolved.

Notes on Reactivity

Since we want our app to work with mobile and desktop we need to create a collapsable menubar. We will do this by adding the following JS toggle JS.toggle(to: "#mobile-menu" snippets to the menu bar – the outline is:

<!-- Hamburger Button -->
<button phx-click={JS.toggle(to: "#mobile-menu")} class="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700">
  <!-- SVG for the icon -->
</button>

<!-- Mobile Menu -->
<div id="mobile-menu" class="lg:hidden" role="dialog" aria-modal="true">
  <div class="flex items-center justify-between">
    <!-- ... other content ... -->
    <!-- Close Button -->
    <button phx-click={JS.toggle(to: "#mobile-menu")} class="-m-2.5 rounded-md p-2.5 text-gray-700">
      <!-- SVG for the close icon -->
    </button>
  </div>
  <!-- ... other content ... -->
</div>

When we look at a dead homepage: http://localhost:4000/ and when we look at a liveview page: http://localhost:4000/auth/users/settings we will have our new reactive navbar!

Let’s put a new custom icon in to the path: priv/static/images/logo.png - ideally an SVG, but for now I will just use a png.

Now we can update our logo and the url links with - note variables and information INSIDE an html tag must be places within {} and information we place in HTML will use <% %? or <%= %> formatting.

        <div class="flex lg:flex-1">
          <!-- link to homepage -->
          <.link href={~p"/"} class="-m-1.5 p-1.5" >
            <span class="sr-only">TaskBoard</span>
            <!-- the site logo in the navbar -->
            <img
              class="h-8 w-auto"
              src={~p"/images/logo.png"}
              alt="logo"
            >
          </.link>
        </div>

NOTE: ~p within HEEX uses the internal route checking to ensure we are linking to our urls and images properly.

we can also link our SiteAdmin link in the menubar with:

<a
    href={~p"/admin/projects"}
    class="text-sm font-semibold leading-6 text-gray-900 hover:text-blue-700">
  SiteAdmin
</a>

Reactive Dropdown Menu

non-reactive dropdown css outline

<div class="relative">
  <button type="button" class="flex items-center gap-x-1 text-sm font-semibold leading-6 text-gray-900" aria-expanded="false">
    Company
    <svg class="h-5 w-5 flex-none text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
      <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
    </svg>
  </button>

  <div class="absolute -left-8 top-full z-10 mt-3 w-56 rounded-xl bg-white p-2 shadow-lg ring-1 ring-gray-900/5">
    <a href="#" class="block rounded-lg px-3 py-2 text-sm font-semibold leading-6 text-gray-900 hover:bg-gray-50">Settings</a>
    <a href="#" class="block rounded-lg px-3 py-2 text-sm font-semibold leading-6 text-gray-900 hover:bg-gray-50">Logout</a>
  </div>
</div>

now we add our reactive JS components - we want the dropdown hidden by default so we load it with hidden we also add hidden back after we click on a menu item (and of course we toggle it open and closed when clicking on the menu bar item):

<div class="relative">
  <button phx-click={JS.toggle(to: "#user-dropdown")} class="flex items-center gap-x-1 text-sm font-semibold leading-6 text-gray-900">
    <%= @current_user.email %>
    <!-- Chevron SVG here -->
  </button>

  <div id="user-dropdown" class="hidden absolute -left-8 top-full z-10 mt-3 w-56 rounded-xl bg-white p-2 shadow-lg ring-1 ring-gray-900/5">
    <a phx-click={JS.hide(to: "#user-dropdown")} href="/settings" class="block rounded-lg px-3 py-2 text-sm font-semibold leading-6 text-gray-900 hover:bg-gray-50">Settings</a>
    <a phx-click={JS.hide(to: "#user-dropdown")} href="/logout" class="block rounded-lg px-3 py-2 text-sm font-semibold leading-6 text-gray-900 hover:bg-gray-50">Log out</a>
    <!-- ... more links ... -->
  </div>
</div>

Resources:

<header class="sticky top-0 z-50"></header>

but now transparent - lets add a background color

Once the navbar is fixed, it will need a background color bg-slate-100 to the navbar header (can be any color desired)

https://tailwindcss.com/docs/background-color

<header class="absolute sticky inset-x-0 top-0 z-50 bg-slate-100">

Oft Repeated CSS classes

we can use the tailwind @apply to define our own reusable ’easier to read’ css class. Go to assets/css/app.css and make it look like:

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

/* This file is for your main application CSS */
.nav-link {
  @apply text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-blue-700
}

now it can be used withsome think like:

<.link class="nav-link" html={~p"/"}>Home</.link>

In this way you will no longer see both the navbar and the contents below it - which is quite confusing.

optional Live Homepage

to make a ‘Live’ /homehage we an do the following:

mkdir lib/taskboard_web/live/home_live
touch lib/taskboard_web/live/home_live/index.ex
touch lib/taskboard_web/live/home_live/index.html.heex

now lets populate the heex lib/taskboard_web/live/home_live/index.html.heex file with:

<.flash_group flash={@flash} />

<div class="bg-white">

  <!-- page hero content -->
  <div class="relative isolate overflow-hidden bg-gradient-to-b from-indigo-100/20 pt-14">
    <div class="absolute inset-y-0 right-1/2 -z-10 -mr-96 w-[200%] origin-top-right skew-x-[-30deg] bg-white shadow-xl shadow-indigo-600/10 ring-1 ring-indigo-50 sm:-mr-80 lg:-mr-96" aria-hidden="true"></div>
    <div class="mx-auto max-w-7xl px-6 py-32 sm:py-40 lg:px-8">
      <div class="mx-auto max-w-2xl lg:mx-0 lg:grid lg:max-w-none lg:grid-cols-2 lg:gap-x-16 lg:gap-y-6 xl:grid-cols-1 xl:grid-rows-1 xl:gap-x-8">
        <h1 class="max-w-2xl text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl lg:col-span-2 xl:col-auto">
          Changing Collaboration.
        </h1>
        <div class="mt-6 max-w-xl lg:mt-0 xl:col-end-1 xl:row-start-1">
          <p class="text-lg leading-8 text-gray-600">
            Share tasks, progress and collaborate with partners
          </p>
          <div class="mt-10 flex items-center gap-x-6">
            <.link href={~p"/auth/users/register"}
              class="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
              Get started
            </.link>
          </div>
        </div>
        <img src={~p"/images/task.png"}
          alt="" class="mt-10 aspect-[6/5] w-full max-w-lg rounded-2xl object-cover sm:mt-16 lg:mt-0 lg:max-w-none xl:row-span-2 xl:row-end-2 xl:mt-36">
      </div>
    </div>
    <div class="absolute inset-x-0 bottom-0 -z-10 h-24 bg-gradient-to-t from-white sm:h-32"></div>
  </div>

</div>

Now we can write a minimalistic liveview action lib/taskboard_web/live/home_live/index.ex page (without any DB model included):

defmodule TaskboardWeb.HomeLive.Index do
  use TaskboardWeb, :live_view

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

  @impl true
  @spec handle_params(any(), any(), %{
          :assigns => atom() | %{:live_action => :index, optional(any()) => any()},
          optional(any()) => any()
        }) :: {:noreply, map()}
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Landing Page")
  end
end

now we can update the routes lib/taskboard_web/router.ex file.

We need to change the default root route from: get "/", PageController, :home to: live "/", HomeLive.Index, :index

now this part of the route should look like:

defmodule TaskboardWeb.Router do
  use TaskboardWeb, :router

  import TaskboardWeb.Auth.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {TaskboardWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_user
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", TaskboardWeb do
    pipe_through :browser

    # get "/", PageController, :home
    live "/", HomeLive.Index, :index
  end
  ...
end

now you can delete the unneeded files with:

rm lib/taskboard_web/controllers/page_controller.ex
rm lib/taskboard_web/controllers/page_html.ex
rm lib/taskboard_web/controllers/page_html/home.html.heex

and tou can if desired move the navbar into app.html.heex if user notifications are needed in the navbar.

add role to user

  • is_admin - role based access to the admin pages
  • name - backfill existing with the email and add name to the registration

Resources:

User Migration

mix ecto.gen.migration add_user_attributes
defmodule Taskboard.Repo.Migrations.AddUserAttributes do
  use Ecto.Migration

  def change do
    alter table(:users) do
      add :name, :string
      add :is_admin, :boolean, default: false, null: false
    end
  end
end

Update the admin page

Update User settings page

Update Signup page

backfill to user name?

Role based Restrictions

Resources:

project owner area

add collaborators to projects

Add Epics to projects

Add Tasks to projects with status

Add a collaboration (task update) area for non-owners

add drag and drop to tasks to update status

add a todo list for a task?

fly.io deployment

Resources:

PS - Be aware that the ‘Free’ instances require adding a payment method.

# authenticate
fly auth login

# region can't be Germany for free account (creates Docker images)
fly launch

#
flyctl deploy

# add DB info
fly secrets set DATABASE_URL="postgres://--------:*********@----.db.elephantsql.com/-------"

fly deploy

# tiny turtle only allows 5 connections and we need one for deployment migrations - thus "4"
fly secrets set POOL_SIZE=4

fly deploy

# if you get the error: `The database does not exist`
# and `To fix the first issue, run "mix ecto.create" for the desired MIX_ENV.``
# then: open `rel/env.sh.eex` and disable: `export ECTO_IPV6="true"` so it looks like:

#!/bin/sh

# configure node for distributed erlang with IPV6 support
export ERL_AFLAGS="-proto_dist inet6_tcp"
# export ECTO_IPV6="true"
export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal"
export RELEASE_DISTRIBUTION="name"
export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}"


fly deploy
Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature