Skip to content

Latest commit

 

History

History
779 lines (579 loc) · 24.1 KB

pic_chat_messages.livemd

File metadata and controls

779 lines (579 loc) · 24.1 KB

PicChat: Messages

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"}
])

Navigation

Review Questions

Upon completing this lesson, a student should be able to answer the following questions.

  • How does the live action in the router interact with handle_params/3 in MessageLive.Index to render UI for the :index, :new, and :edit actions?
  • Explain the LiveComponent LifeCycle.

PicChat: Messages

Over the next several lessons, we're going to build a PicChat application where users can create messages with uploaded pictures. This lesson will focus on creating the Messages resource with just text content.

Initialize The Project

Initialize the pic_chat phoenix project.

mix phx.new pic_chat

Create the database.

mix ecto.create

Use the LiveView Generators to generate all of the LiveView boilerplate needed to manage a Messages resource.

mix phx.gen.live Chat Message messages content:text

Routes

Add the routes for the generated resource.

# Router.ex
scope "/", PicChatWeb do
  pipe_through :browser

  get "/", PageController, :home
  live "/messages", MessageLive.Index, :index
  live "/messages/new", MessageLive.Index, :new
  live "/messages/:id/edit", MessageLive.Index, :edit

  live "/messages/:id", MessageLive.Show, :show
  live "/messages/:id/show/edit", MessageLive.Show, :edit
end

Order Messages

Ensure messages are ordered from newest -> oldest. We order by both the inserted_at and the id fields to ensure consistent ordering for messages created at the same time.

# Chat.ex
def list_messages do
  Message
  |> from(order_by: [desc: :inserted_at, desc: :id])
  |> Repo.all()
end

Notify Parent

We'll also have to modify how we notify the parent LiveView when we save a message, as streams will append messages by default, but we want to prepend them. See []

Change the calls to notify_parent in form_component.ex to handle :edit and :new separately rather than using the :saved event for both.

# Save_message :edit
notify_parent({:edit, message})

# Save_message :new
notify_parent({:new, message})

Then add a separate handler for each in the parent LiveView.

# Index.ex
@impl true
def handle_info({PicChatWeb.MessageLive.FormComponent, {:new, message}}, socket) do
  # prepends the new message
  {:noreply, stream_insert(socket, :messages, message, at: 0)}
end

@impl true
def handle_info({PicChatWeb.MessageLive.FormComponent, {:edit, message}}, socket) do
  # updates the new message in its current position
  {:noreply, stream_insert(socket, :messages, message)}
end

That's it! The rest of this lesson will focus on understanding what the generators built for us and the changes we've added.

Building Understanding

The generators have done a lot for us, but it's important we understand what was generated.

Here's a broad view of our application's new MessageLive.Index LiveView and how it ultimately renders the HTML response for the following routes:

sequenceDiagram
  autonumber
  participant R as Router
  participant L as MessageLive.Index
  participant FC as MessageLive.FormComponent

  R->>L: GET "/messages" (live_action = :index, :new, or :edit)
  L --> L: disconnected mount/3
  L --> L: connected mount/3
  L --> L: handle_params/3
  L --> L: apply_action/3
  L --> L: render/3 index.html.heex
  L->> FC: live_component (:new and :edit only)
  FC --> FC : mount/1
  FC --> FC : update/2
  FC --> FC : render/1 form_component.html.heex
Loading

We're going to dive deeper into each part of the application. If you're taking the official DockYard Academy course your teacher is going to walk you through this process.

LiveView

We're going to breakdown the MessageLive.Index liveview found in live/message_live/index.ex to better understand how LiveView works with Ecto, and what the LiveView generators provide for us as scaffolding.

defmodule PicChatWeb.MessageLive.Index do
  use PicChatWeb, :live_view

  alias PicChat.Chat
  alias PicChat.Chat.Message

  @impl true
  def mount(_params, _session, socket) do
    {:ok, stream(socket, :messages, Chat.list_messages())}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :edit, %{"id" => id}) do
    socket
    |> assign(:page_title, "Edit Message")
    |> assign(:message, Chat.get_message!(id))
  end

  defp apply_action(socket, :new, _params) do
    socket
    |> assign(:page_title, "New Message")
    |> assign(:message, %Message{})
  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Messages")
    |> assign(:message, nil)
  end

  @impl true
  def handle_info({PicChatWeb.MessageLive.FormComponent, {:new, message}}, socket) do
    {:noreply, stream_insert(socket, :messages, message, at: 0)}
  end

  @impl true
  def handle_info({PicChatWeb.MessageLive.FormComponent, {:edit, message}}, socket) do
    {:noreply, stream_insert(socket, :messages, message)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    message = Chat.get_message!(id)
    {:ok, _} = Chat.delete_message(message)

    {:noreply, stream_delete(socket, :messages, message)}
  end
end

When the LiveView first loads, it calls the mount/3 function to assign a list of messages in the socket.

Then, handle_params/3 delegates to the apply_action/3 function to assign more data to the socket depending on whether or not the live action is :index, :edit, or :new:

  • The page_title is different for each page. It controls the text displayed at the top of the browser tab.
  • The :new page creates a Message struct for the new message form.
  • The :edit page retrieves an existing Message based on the "id" url param.
  • handle_info/3 receives a message from the form_component to insert a Message into the list of messages.
  • handle_event/3 deletes a message.

Stream

Phoenix 1.7 and LiveView 1.18 Introduced the stream/4, stream_insert/4, and stream_delete/3 functions.

Previously, messages would have been stored in a list and the user would append and remove elements from the list.

Now, the list of messages is instead treated as a stream, a more performant alternative to a list does not store data on the server but only stores the data on the client. This is ideal when dealing with large amounts of data.

Streams are stored in @streams in the socket for a given key.

@impl true
def mount(_params, _session, socket) do
  {:ok, stream(socket, :messages, Chat.list_messages())}
end

Elements are inserted into or removed from the stream.

@impl true
def handle_info({PicChatWeb.MessageLive.FormComponent, {:new, message}}, socket) do
  # prepends the new message
  {:noreply, stream_insert(socket, :messages, message, at: 0)}
end

@impl true
def handle_info({PicChatWeb.MessageLive.FormComponent, {:edit, message}}, socket) do
  # updates the new message in its current position
  {:noreply, stream_insert(socket, :messages, message)}
end

@impl true
def handle_event("delete", %{"id" => id}, socket) do
  message = Chat.get_message!(id)
  {:ok, _} = Chat.delete_message(message)

  {:noreply, stream_delete(socket, :messages, message)}
end

Rendering Messages

Streams are rendered in the template using a key in the @streams field from the assigns. The phx-update="stream" attribute configures the parent container to support stream operations. Elements in the stream are typically rendered using a comprehension.

Here's a simplified example.

<section id="messages" phx-update="stream">
  <article
    :for={{dom_id, message} <- @streams.messages}
    id={dom_id}
  >
    <%= message.content %>
  </article>
</section>

A table of messages is rendered in the corresponding template file index.html.heex. This table relies on Phoenix.LiveView.JS for various actions such as navigation and pushing events to the server.

<.table
  id="messages"
  rows={@streams.messages}
  row_click={fn {_id, message} -> JS.navigate(~p"/messages/#{message}") end}
>
  <:col :let={{_id, message}} label="Content"><%= message.content %></:col>
  <:action :let={{_id, message}}>
    <div class="sr-only">
      <.link navigate={~p"/messages/#{message}"}>Show</.link>
    </div>
    <.link patch={~p"/messages/#{message}/edit"}>Edit</.link>
  </:action>
  <:action :let={{id, message}}>
    <.link
      phx-click={JS.push("delete", value: %{id: message.id}) |> hide("##{id}")}
      data-confirm="Are you sure?"
    >
      Delete
    </.link>
  </:action>
</.table>

Modals

A modal containing the FormComponent live component is rendered for the :new and :edit live actions.

<.modal :if={@live_action in [:new, :edit]} id="message-modal" show on_cancel={JS.patch(~p"/messages")}>
  <.live_component
    module={PicChatWeb.MessageLive.FormComponent}
    id={@message.id || :new}
    title={@page_title}
    action={@live_action}
    message={@message}
    patch={~p"/messages"}
  />
</.modal>

LiveComponent

Phoenix.LiveComponent encapsulates the behavior of a LiveView (state, message handling, displaying html) into reusable components within other LiveViews.

We use the Phoenix.Component.live_component/1 to render a LiveComponent. The id and module attributes are required. Other attributes are bound to the socket assigns. LiveComponents do not inherit socket values from their parent, so socket values must be explicitly provided.

<.live_component
  module={PicChatWeb.MessageLive.FormComponent}
  id={@message.id || :new}
  title={@page_title}
  action={@live_action}
  message={@message}
  patch={~p"/messages"}
/>

We're going to break down the MessageLive.FormComponent to better understand LiveComponents.

defmodule PicChatWeb.MessageLive.FormComponent do
  use PicChatWeb, :live_component

  alias PicChat.Chat

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

      <.simple_form
        for={@form}
        id="message-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.input field={@form[:content]} type="text" label="Content" />
        <:actions>
          <.button phx-disable-with="Saving...">Save Message</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

  @impl true
  def update(%{message: message} = assigns, socket) do
    changeset = Chat.change_message(message)

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

  @impl true
  def handle_event("validate", %{"message" => message_params}, socket) do
    changeset =
      socket.assigns.message
      |> Chat.change_message(message_params)
      |> Map.put(:action, :validate)

    {:noreply, assign_form(socket, changeset)}
  end

  def handle_event("save", %{"message" => message_params}, socket) do
    save_message(socket, socket.assigns.action, message_params)
  end

  defp save_message(socket, :edit, message_params) do
    case Chat.update_message(socket.assigns.message, message_params) do
      {:ok, message} ->
        notify_parent({:edit, message})

        {:noreply,
         socket
         |> put_flash(:info, "Message updated successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp save_message(socket, :new, message_params) do
    case Chat.create_message(message_params) do
      {:ok, message} ->
        notify_parent({:new, message})

        {:noreply,
         socket
         |> put_flash(:info, "Message created successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    assign(socket, :form, to_form(changeset))
  end

  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

LiveComponent Life-Cycle

The LiveComponent life-cycle is similar to a LiveView with some important differences.

sequenceDiagram
    LiveView-->>LiveComponent: render/1
    LiveComponent-->>LiveComponent: mount/1
    LiveComponent-->>LiveComponent: update/2
    LiveComponent-->>LiveComponent: render/1
Loading

Unlike a LiveView, we don't typically retrieve data in the mount/1 callback. Instead, the parent LiveView usually provides the component with any initial data it needs. The LiveComponent then calls the update/2 callback anytime the LiveComponent is re-rendered (usually if the data provided by the parent LiveView changes).

@impl true
def update(%{message: message} = assigns, socket) do
  changeset = Chat.change_message(message)

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

See LiveComponent life-cycle for more information.

Sending/Receiving Process Messages

By default, a LiveComponent sends messages to the parent. We can use the phx-target={@myself} attribute on an element to instead send messages to the LiveComponent itself.

<.simple_form
  for={@form}
  id="message-form"
  phx-target={@myself}
  phx-change="validate"
  phx-submit="save"
>
  <.input field={@form[:content]} type="text" label="Content" />
  <:actions>
    <.button phx-disable-with="Saving...">Save Message</.button>
  </:actions>
</.simple_form>

These messages are then handled in the LiveComponent rather than the parent LiveView.

def handle_event("save", %{"message" => message_params}, socket) do
  save_message(socket, socket.assigns.action, message_params)
end

The FormComponent also provides an example of sending the parent LiveView process a message when a new Message is created.

defp notify_parent(msg), do: send(self(), {__MODULE__, msg})

This is handled in the parent LiveView to update the stream of Message structs.

@impl true
def handle_info({PicChatWeb.MessageLive.FormComponent, {:new, message}}, socket) do
  {:noreply, stream_insert(socket, :messages, message, at: 0)}
end

@impl true
def handle_info({PicChatWeb.MessageLive.FormComponent, {:edit, message}}, socket) do
  {:noreply, stream_insert(socket, :messages, message)}
end

Assign_form

The FormComponent uses an assign_form/2 helper function to assign the Phoenix.HTML.Form struct in the socket.

defp assign_form(socket, %Ecto.Changeset{} = changeset) do
  assign(socket, :form, to_form(changeset))
end

This form is used to initialize form data and display errors. Observe the :error case when creating a new message. The assign_form/2 function works with the changeset to display errors.

defp save_message(socket, :new, message_params) do
  case Chat.create_message(message_params) do
    {:ok, message} ->
      notify_parent({:new, message})

      {:noreply,
        socket
        |> put_flash(:info, "Message created successfully")
        |> push_patch(to: socket.assigns.patch)}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign_form(socket, changeset)}
  end
end

LiveView Testing

The MessageLiveTest module found in test/pic_chat_web/live/message_live_test.ex demonstrates how to mount a LiveView, simulate user interactions, and assert on the behavior and response of the LiveView.

Read through the MessageLiveTest module to better understand patterns for testing LiveViews.

defmodule PicChatWeb.MessageLiveTest do
  use PicChatWeb.ConnCase

  import Phoenix.LiveViewTest
  import PicChat.ChatFixtures

  @create_attrs %{content: "some content"}
  @update_attrs %{content: "some updated content"}
  @invalid_attrs %{content: nil}

  defp create_message(_) do
    message = message_fixture()
    %{message: message}
  end

  describe "Index" do
    setup [:create_message]

    test "lists all messages", %{conn: conn, message: message} do
      {:ok, _index_live, html} = live(conn, ~p"/messages")

      assert html =~ "Listing Messages"
      assert html =~ message.content
    end

    test "saves new message", %{conn: conn} do
      {:ok, index_live, _html} = live(conn, ~p"/messages")

      assert index_live |> element("a", "New Message") |> render_click() =~
               "New Message"

      assert_patch(index_live, ~p"/messages/new")

      assert index_live
             |> form("#message-form", message: @invalid_attrs)
             |> render_change() =~ "can&#39;t be blank"

      assert index_live
             |> form("#message-form", message: @create_attrs)
             |> render_submit()

      assert_patch(index_live, ~p"/messages")

      html = render(index_live)
      assert html =~ "Message created successfully"
      assert html =~ "some content"
    end

    test "updates message in listing", %{conn: conn, message: message} do
      {:ok, index_live, _html} = live(conn, ~p"/messages")

      assert index_live |> element("#messages-#{message.id} a", "Edit") |> render_click() =~
               "Edit Message"

      assert_patch(index_live, ~p"/messages/#{message}/edit")

      assert index_live
             |> form("#message-form", message: @invalid_attrs)
             |> render_change() =~ "can&#39;t be blank"

      assert index_live
             |> form("#message-form", message: @update_attrs)
             |> render_submit()

      assert_patch(index_live, ~p"/messages")

      html = render(index_live)
      assert html =~ "Message updated successfully"
      assert html =~ "some updated content"
    end

    test "deletes message in listing", %{conn: conn, message: message} do
      {:ok, index_live, _html} = live(conn, ~p"/messages")

      assert index_live |> element("#messages-#{message.id} a", "Delete") |> render_click()
      refute has_element?(index_live, "#messages-#{message.id}")
    end
  end

  describe "Show" do
    setup [:create_message]

    test "displays message", %{conn: conn, message: message} do
      {:ok, _show_live, html} = live(conn, ~p"/messages/#{message}")

      assert html =~ "Show Message"
      assert html =~ message.content
    end

    test "updates message within modal", %{conn: conn, message: message} do
      {:ok, show_live, _html} = live(conn, ~p"/messages/#{message}")

      assert show_live |> element("a", "Edit") |> render_click() =~
               "Edit Message"

      assert_patch(show_live, ~p"/messages/#{message}/show/edit")

      assert show_live
             |> form("#message-form", message: @invalid_attrs)
             |> render_change() =~ "can&#39;t be blank"

      assert show_live
             |> form("#message-form", message: @update_attrs)
             |> render_submit()

      assert_patch(show_live, ~p"/messages/#{message}")

      html = render(show_live)
      assert html =~ "Message updated successfully"
      assert html =~ "some updated content"
    end
  end
end

Further Reading

Consider the following resource(s) to deepen your understanding of the topic.

Commit Your Progress

DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.

Run git status to ensure there are no undesirable changes. Then run the following in your command line from the curriculum folder to commit your progress.

$ git add .
$ git commit -m "finish PicChat: Messages reading"
$ git push

We're proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.

We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.

Navigation