CRUD
Deleting a ticket
Read time: 8 minutes
This lesson is a direct continuation of the previous one.
git clone https://github.com/adopt-liveview/lineup.git --branch show-data-done.
Let's skip to the last letter of CRUD: Delete. In this lesson, we'll see how simple it is to create a UX to delete an item using resources from our project.
#You probably guessed that we'd start with the Context.
The first step is to go back to our lib/lineup/queue.ex file and add a new function:
defmodule Lineup.Queue do
# ...
@doc """
Deletes a ticket.
## Examples
iex> delete_ticket(ticket)
{:ok, %Ticket{}}
iex> delete_ticket(ticket)
{:error, %Ecto.Changeset{}}
"""
def delete_ticket(%Ticket{} = ticket) do
Repo.delete(ticket)
end
end
The delete_ticket/1 function takes a struct of type %Ticket{} and simply applies the Repo.delete/2 method to it. The result will be {:ok, %Ticket{}} which is useful if it is necessary to know about the deleted ticket.
#Testing on iex
Using the reliable Elixir Interactive mode, we can fetch the last ticket with ticket = Lineup.Queue.list_tickets() |> List.last and delete it using Lineup.Queue.delete_ticket(ticket):
$ iex -S mix
[info] Migrations already up
Erlang/OTP 26 [erts-14.2.2] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit]
Interactive Elixir (1.16.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> ticket = Lineup.Queue.list_tickets() |> List.last
[debug] QUERY OK source="tickets" db=0.2ms queue=0.1ms idle=1192.5ms
SELECT p0."id", p0."name", p0."description" FROM "tickets" AS p0 []
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:405
%Lineup.Queue.Ticket{
__meta__: #Ecto.Schema.Metadata<:loaded, "tickets">,
id: 10,
name: "asda",
description: "ad"
}
iex(2)> Lineup.Queue.delete_ticket(ticket)
[debug] QUERY OK source="tickets" db=1.7ms idle=1366.3ms
DELETE FROM "tickets" WHERE "id" = ? [10]
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:405
{:ok,
%Lineup.Queue.Ticket{
__meta__: #Ecto.Schema.Metadata<:deleted, "tickets">,
id: 10,
name: "asda",
description: "ad"
}}
#Deleting tickets in the list LiveView
Instead of creating a new LiveView called TicketLive.Delete, we can reuse the ticket list for this purpose. Open your TicketLive.Index located in lib/lineup_web/live/ticket_live/index.ex.
#The <:action> slot of the <.table> component
Within your render/1, update your <.table> to the following code:
<.table
id="tickets"
rows={@streams.tickets}
row_click={fn {_id, ticket} -> JS.navigate(~p"/tickets/#{ticket}") end}
>
<:col :let={{_id, ticket}} label="ID">{ticket.id}</:col>
<:col :let={{_id, ticket}} label="Called at">{ticket.called_at || "n/a"}</:col>
<:action :let={{id, ticket}}>
<.link
phx-click={JS.push("delete", value: %{id: ticket.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</.table>
We added a slot called <:action> where we receive both the id and the ticket using the special attribute :let. This slot is placed as the last column to add action buttons to our row.
#The ID from the :let
This specific id is known as the "DOM ID" or "HTML ID" and, in this case, it should be something like "tickets-123" because our table has the ID "tickets" and assuming the ID in the database of the item is 123. It's useful for applying JS commands.
#Confirming actions with data-confirm
The next focus point is data-confirm. We don't want the item to be deleted immediately without any kind of confirmation, right? Phoenix checks that if you click on an element with data-confirm it triggers a confirm dialog in your browser and only applies the phx-click if the user confirms.
#The hide/2 command
Within our phx-click binding, two things occur:
- We send an event to our LiveView called "delete" (we still need to define it).
- We hide the element of the current row using the HTML ID.
As you can see, we're not directly using JS.hide/2 but rather just the hide/1 function. This is because Phoenix already provides this simplified function within CoreComponents, which applies transitions using CSS classes! Look at your CoreComponents:
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all transform ease-in duration-200",
"opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
Whenever possible, prefer to use hide/1 from CoreComponents. However, if you need to customize the transition, opt for JS.hide/2.
#Creating the delete event
To be able to test this code we need to create our handle_event/3. In your LiveView, below the mount/3 function, add this callback:
@impl true
def handle_event("delete", %{"id" => id}, socket) do
ticket = Queue.get_ticket!(id)
{:ok, _} = Queue.delete_ticket(ticket)
{:noreply, stream_delete(socket, :tickets, ticket)}
end
In this event we only receive the ID, we immediately check in the database if the ticket exists using the Queue.get_ticket/1 function that we built in the previous lesson. We then delete the ticket. As we already have the ticket variable we ignore the second result of the delete function.
#The stream_delete/3 function
In previous lessons we had already seen how to create streams using stream/4 to render lists in an efficient way. Now we can see the stream_delete/3 function to delete an item from the stream.
Remembering that streams do not store any data in memory about their items the stream_delete/3 function receives the name of the stream which is :tickets as we defined in our mount/3 and the ticket. Using these two variables, it infers that the HTML ID of the element will be #tickets-123 and sends a simple payload to the client indicating that LiveView should delete this element from the HTML.
#LiveView Code
With all the pieces together your TicketLive.Index should be close to this code:
defmodule LineupWeb.TicketLive.Index do
use LineupWeb, :live_view
alias Lineup.Queue
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Tickets")
|> stream(:tickets, list_tickets())}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
ticket = Queue.get_ticket!(id)
{:ok, _} = Queue.delete_ticket(ticket)
{:noreply, stream_delete(socket, :tickets, ticket)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Tickets
<:actions>
<.button variant="primary" navigate={~p"/tickets/new"}>
<.icon name="hero-plus" /> New Ticket
</.button>
</:actions>
</.header>
<.table
id="tickets"
rows={@streams.tickets}
row_click={fn {_id, ticket} -> JS.navigate(~p"/tickets/#{ticket}") end}
>
<:col :let={{_id, ticket}} label="ID">{ticket.id}</:col>
<:col :let={{_id, ticket}} label="Called at">{ticket.called_at || "n/a"}</:col>
<:action :let={{id, ticket}}>
<.link
phx-click={JS.push("delete", value: %{id: ticket.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
defp list_tickets() do
Queue.list_tickets()
end
end
#Adding more tests!
It should be no mystery to you how to add context tests under QueueTest. Head out to that test file and add:
test "delete_ticket/1 deletes the ticket" do
ticket = ticket_fixture()
assert {:ok, %Ticket{}} = Queue.delete_ticket(ticket)
assert_raise Ecto.NoResultsError, fn -> Queue.get_ticket!(ticket.id) end
end
In this test case we are now using assert_raise/2 to verify that calling Queue.get_ticket/1 again will properly return Ecto.NoResultsError as we expect. As for our TicketLiveTest, our test is as simple as triggering the button click event. Add this to your describe "Index" do section:
test "deletes ticket in listing", %{conn: conn, ticket: ticket} do
{:ok, index_live, _html} = live(conn, ~p"/tickets")
assert index_live |> element("#tickets-#{ticket.id} a", "Delete") |> render_click()
refute has_element?(index_live, "#tickets-#{ticket.id}")
end
#Final code
Ready! Now you just need to test your LiveView and verify that the current flow is working.
If you had any issues you can see the final code for this lesson using git checkout deleting-data-done or cloning it in another folder using git clone https://github.com/adopt-liveview/lineup.git --branch deleting-data-done.
#Recap!
-
The function
Repo.delete/2receives a struct from an Ecto schema and deletes it from the database. -
The
<:action>slot is useful for adding action buttons to your tables. -
The IDs that come from the special attribute
:letin component slots<.table>are called DOM ID or HTML ID and follow the formatname-of-your-stream-ID(where ID is the ID in the database element data). - The DOM ID is useful for applying JS commands.
-
The
CoreComponentsof Phoenix projects comes with ahide/1function that is justJS.hide/2but with a beautiful transition. -
We can use
data-confirmto confirm with the user before triggering an action like aphx-click. -
The
stream_delete/3function is a way to delete elements from a stream. This function optimizes sending the minimum amount of data to LiveView so it follows the idea that streams are an efficient way to manage lists in LiveView. -
assert_raise/2can be used to test that exceptions will be raised by some code.
Feedback
Got any feedback about this page? Let us know!