PicChat: Messages
Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.9", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"}
])
Navigation
Home Report An Issue Math GamePicChat: Image UploadReview Questions
Upon completing this lesson, a student should be able to answer the following questions.
- How can we navigate the user from the client, and from the server?
- How do we test a LiveView?
- How do we use Phoenix Components to abstract and reuse code?
- How can a LiveView render different UI based on URL params?
Overview
This is a companion reading for the PicChat: Messages exercise. This lesson is an overview of how to work with LiveView and Ecto in a Phoenix application. It also covers LiveComponents and some of the other topics you’ll encounter when using the LiveView Generators.
See the example_projects/pic_chat
project folder to contextualize the examples found throughout this lesson.
LiveGenerator
We can use mix phx.gen.live
similar to how we have already used mix phx.gen.html
to generate the boilerplate code for a resource.
mix phx.gen.live Context Schema table field_name:field_type
Live Routes
LiveComponents
Phoenix LiveComponent encapsulates the behavior of a LiveView (state, message handling, displaying html) into a sub-component that can be placed inside of another LiveView.
This allows us to break apart our LiveViews into several smaller components, that can even be re-used.
defmodule HelloComponent do
# If you generated an app with mix phx.new --live,
# the line below would be: use MyAppWeb, :live_component
use AppWeb, :live_component
def render(assigns) do
~H"""
Hello, @name!
"""
end
end
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.
<.live_component module={HelloComponent} id="hello" name={@name} />
Like LiveViews, LiveComponents can send messages using phx-*
bindings. By default, the message will be sent to their parent LiveView.
flowchart
LiveComponent --event--> ParentLiveView
Alternatively, you can use @myself
to send a message to itself.
flowchart
LiveComponent --event--> LiveComponent
Say hello!
LiveComponents can define event handlers using handle_event/3.
def handle_event("greeting", _params, socket) do
# ...
{:noreply, socket}
end
See HexDocs: Phoenix.LiveComponent for more.
Follow Along: PicChat Messages
Over the next few lessons we’re going to build a Chat Application that can also upload images called PicChat
.
Our application will include a global chat where users can send messages and see the feed of messages update in real-time.
We’ll also save messages in a database with Ecto to demonstrate how to use CRUD (Create, Read, Update, Delete) actions with LiveViews.
You can see the completed PicChat application for the sake of reference.
In this lesson, we’re going to create the initial feed of messages without images. Follow along with each step of the instruction to build your own PicChat
application.
Create The Phoenix Project
Create the Phoenix project
$ mix phx.new pic_chat
Make sure to create the Ecto database.
$ mix ecto.create
Create The Chat Context.
We’re going to create a Chat
context with a Message
resource in a messages
table.
Messages will have a :content
and :from
field.
We can use the mix phx.gen.live phoenix generator to create the scaffolding for CRUD actions using LiveViews.
$ mix phx.gen.live Chat Message messages content:text from:string
The majority of this lesson will be reading through the generated code in order to better understand how we can build applications with LiveView.
Define Routes
Add our routes in router.ex
. We’ll remove the PageController
route so we can use the base url "/"
.
scope "/", PicChatWeb do
pipe_through :browser
live "/", MessageLive.Index, :index
live "/new", MessageLive.Index, :new
live "/:id/edit", MessageLive.Index, :edit
live "/:id", MessageLive.Show, :show
live "/:id/show/edit", MessageLive.Show, :edit
end
Notice that unlike with MVC generators, the generator only generates two LiveViews MessageLive.Index
and MessageLive.Show
. It then uses the :index
, :new
, :edit
, and :show
parameters to conditionally render a modal. We’ll dive into how this works in a moment.
Since we removed the PageController
route, make sure to remove page_view_test.exs
, page_view.ex
, page_controller_test.ex
, and page_controller.ex
, and /page/index.html.heex
.
The MessageLive.Index LiveView
Start your server.
$ mix phx.server
Visit http://localhost:4000 to view your MessageLive.Index
page.
handle_params/3
When the MessageLive.Index
module in live/message_live/index.ex
mounts, it automatically invokes the handle_params/3
callback.
handle_params/3
accepts params
from the url and the live_action
atom (defined previously in our router.ex
for the corresponding url). It then calls the apply_action/3
function to assign some values in our socket state.
The :message
field on the socket is used when editing or showing a message. The :page_title
field displays a title in the tab in your browser.
Here’s a documented version of these functions for the sake of understanding.
@impl true
def mount(_params, _session, socket) do
# This assigns the result of `list_messages()` to the `:messages` key of `socket`.
{:ok, assign(socket, :messages, list_messages())}
end
@impl true
def handle_params(params, _url, socket) do
# The apply_action/3 function assigns values to the socket for the :edit, :show, and :index actions.
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
# Assigns Values To The Socket For The `:edit` Action.
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Message")
|> assign(:message, Chat.get_message!(id))
end
# Assigns Values To The Socket For The `:new` Action.
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Message")
|> assign(:message, %Message{})
end
# Assigns Values To The Socket For The `:index` Action.
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Messages")
|> assign(:message, nil)
end
Template
Our MessageLive.Index
function doesn’t define a render/1
function. Instead it uses a template file. If you don’t define a render/1
callback function in a LiveView, it automatically attempts to render a template matching the module’s name.
In this case, it renders a index.html.heex
template file. This file renders a list of messages using the :messages
field from the socket.
<%= for message <- @messages do %>
<tr>
<td><%= message.content %></td>
<td><%= message.from %></td>
<td>
<span><%= live_redirect "Show", to: Routes.message_show_path(@socket, :show, message) %></span>
<span><%= live_patch "Edit", to: Routes.message_index_path(@socket, :edit, message) %></span>
<span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: message.id, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
Creating A Message
The New button uses live_patch/2
to re-render the current LiveView with a different URL http://localhost:4000/new.
<span><%= live_patch "New Message", to: Routes.message_index_path(@socket, :new) %></span>
You can visit http://localhost:4000/new. or click on the New
button to see a modal for creating messages.
The :new
live_action
We’re going to walk through the somewhat complex flow how this form is rendered. Along the way we’re going to be introduced to many new concepts.
This re-triggered the handle_params/3
function in MessageLive.Index
. handle_params/3
calls apply_action/3
for the :new
action.
This assigns a new :page_title
and :message
field on our socket.
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Message")
|> assign(:message, %Message{})
end
<.modal>
The index.html.heex
template uses these fields to render a modal.
<%= if @live_action in [:new, :edit] do %>
<.modal return_to={Routes.message_index_path(@socket, :index)}>
<.live_component
module={PicChatWeb.MessageLive.FormComponent}
id={@message.id || :new}
title={@page_title}
action={@live_action}
message={@message}
return_to={Routes.message_index_path(@socket, :index)}
/>
<% end %>
@live_action
comes from router.ex
for our "/new"
URL when we define the :new
atom.
live "/new", MessageLive.Index, :new
The modal
component is built into Phoenix in our PicChat.LiveHelpers
module in live_helpers.ex
.
def modal(assigns) do
assigns = assign_new(assigns, :return_to, fn -> nil end)
~H"""
<%= if @return_to do %>
<%= live_patch "✖",
to: @return_to,
id: "close",
class: "phx-modal-close",
phx_click: hide_modal()
%>
<% else %>
✖
<% end %>
<%= render_slot(@inner_block) %>
"""
end
``
render_slot/2 renders the HTML we place inside of the <.modal>
element.
This renders the <.live_component>
element inside of the modal in index.html.heex
with some attributes.
<.live_component
module={PicChatWeb.MessageLive.FormComponent}
id={@message.id || :new}
title={@page_title}
action={@live_action}
message={@message}
return_to={Routes.message_index_path(@socket, :index)}
/>
LiveComponent: FormComponent
The live_component inside our modal renders the PicChatWeb.MessageLive.FormComponent
LiveComponent.
This PicChatWeb.MessageLive.FormComponent
renders the form_component.html.heex
template because they have the same name, and the FormComponent
does not define a render/1
function.
<h2><%= @title %></h2>
<.form
let={f}
for={@changeset}
id="message-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save">
<%= label f, :content %>
<%= textarea f, :content %>
<%= error_tag f, :content %>
<%= label f, :from %>
<%= text_input f, :from %>
<%= error_tag f, :from %>
<%= submit "Save", phx_disable_with: "Saving..." %>
This form uses the phx-change
binding to send the "validate"
event whenever we modify a field in the form. The form also uses the phx-submit
binding to send the "save"
event when we submit the form.
These correspond to handlers defined in the FormComponent
module. The "validate"
handler
uses the Chat.Message
changeset to validate the form and display errors. The "save"
handler attempts to save a message in the databasae.
def handle_event("validate", %{"message" => message_params}, socket) do
changeset =
socket.assigns.message
|> Chat.change_message(message_params)
# The changeset will not display errors unless there is an action
# Usually, Ecto sets the action for us when we attempt to create the resource
# However, in this case, we want to display errors before attempting to create the resource
# See https://hexdocs.pm/ecto/Ecto.Changeset.html#module-changeset-actions for information
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
def handle_event("save", %{"message" => message_params}, socket) do
save_message(socket, socket.assigns.action, message_params)
end
The save_message/3
function is called with the :new
action, which triggers the following function definition: This either saves a message and redirects the user to http://localhost:4000 or returns a changeset with errors.
defp save_message(socket, :new, message_params) do
case Chat.create_message(message_params) do
{:ok, _message} ->
{:noreply,
socket
|> put_flash(:info, "Message created successfully")
# socket.assigns.return_to comes from the `return_to` attribute
# in our `<.live_component>`
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
Use the form to create a message. It will display on your list of messages.
Your Turn: Updating A Message
Updating a message walks through the exact same flow as creating a message.
Click the Edit
button and you’ll be redirected to http://localhost:4000/1/edit to edit the message we created earlier.
Read through your code from the MessageLive.Index
module as your code flows down to the FormComponent
.
flowchart
MessageLive.Index --> mount --> handle_params --> apply_action --> index.html.heex --> modal[<.modal>] --> live_component[<.live_component>] --> FormComponent --> form.html.heex --> save[''save'' event] --> save_message/3
Notice two major differences. The MessageLive.Index.apply_action/3
is called with the :edit
live_action
and assigns a :message
to the socket.
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Message")
|> assign(:message, Chat.get_message!(id))
end
Also, the FormComponent
calls the save_message/3
function with the :edit
live_action.
defp save_message(socket, :edit, message_params) do
case Chat.update_message(socket.assigns.message, message_params) do
{:ok, _message} ->
{:noreply,
socket
|> put_flash(:info, "Message updated successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
The MessageLive.Show LiveView
Visit http://localhost:4000/1 or click on the Show
button to visit the MessageLive.Show
LiveView.
The MessageLive.Show
LiveView is simpler than MessageLive.Index
because it only handles displaying and editing a message. On mount, it invokes the handle_params/3
function to assign the :page_title
and :message
fields to the socket.
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:message, Chat.get_message!(id))}
end
defp page_title(:show), do: "Show Message"
defp page_title(:edit), do: "Edit Message"
This renders the show.html.heex
template, which renders the content and buttons.
<ul>
<li>
<strong>Content:</strong>
<%= @message.content %>
</li>
<li>
<strong>From:</strong>
<%= @message.from %>
</li>
</ul>
<span><%= live_patch "Edit", to: Routes.message_show_path(@socket, :edit, @message), class: "button" %></span> |
<span><%= live_redirect "Back", to: Routes.message_index_path(@socket, :index) %></span>
Clicking the Edit
button re-mounts the current liveview on http://localhost:4000/1/show/edit.
This sets the live_action
to :edit
because of how we declared the live route in router.ex
.
live "/:id/show/edit", MessageLive.Show, :edit
This sets the live_action
field in our socket to display the modal.
<%= if @live_action in [:edit] do %>
<.modal return_to={Routes.message_show_path(@socket, :show, @message)}>
<.live_component
module={PicChatWeb.MessageLive.FormComponent}
id={@message.id}
title={@page_title}
action={@live_action}
message={@message}
return_to={Routes.message_show_path(@socket, :show, @message)}
/>
<% end %>
This is the exact same modal form we’ve already seen, so we’re not going to walk through it again.
Delete A Message
The MessageLive.Index
LiveView also renders a delete button in the index.html.heex
template.
<span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: message.id, data: [confirm: "Are you sure?"] %></span>
This button uses the phx_click
and phx_value_id
attributes which map to the phx-click
and phx-value-id
bindings. When clicked, this sends a "delete"
message with the "id"
parameter to our MessageLive.Index
LiveView.
This triggers our handler in MessageLive.Index
to delete the message and re-render the page with an updated list of messages.
def handle_event("delete", %{"id" => id}, socket) do
message = Chat.get_message!(id)
{:ok, _} = Chat.delete_message(message)
{:noreply, assign(socket, :messages, list_messages())}
end
Your Turn: Tests
Read through the message_live_test.ex
file to get a better understanding of patterns for testing LiveViews.
Further Reading
Consider the following resource(s) to deepen your understanding of the topic.
- HexDocs: LiveView
- HexDocs: LiveComponent
- HexDocs: Phoenix.HTML
- Elixir Schools: LiveView
- HexDocs: Component
- PragProg: Programming Phoenix LiveView
(Bonus) Further Study
A student who wants to do further research beyond this material may consider the following questions as inspiration.
- What are slots?
- How do you create a stateless function component?
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.