Use a view model with Phoenix LiveView
I was working on the interface for Mailcast, where I wanted to highlight search matches and present a UI overlay for deleted items, with undo support. Each record, then, needed to know if it was selected or deleted.
The problem
For each record, you need to know if it is selected or deleted, and in the case of the deleted items that can be undone, some information to support the undo is needed.
There are a few options you might consider:
- You could keep lists of selected and deleted items and then compare them during render.
-
It would be nicer to be able to use
{item.selected}
and{item.deleted}
, but then you need to litter your model with fields to support the UI state (you would need virtual fields in an Ecto schema). - The third option is to use a view model to decorate the records with the UI state.
Using a view model to decorate records with UI state
Given a basic struct like this:
defmodule MyApp.Item do
defstruct [:id, :name]
end
A view model can be a simple struct that contains the original model and adds keys to support the UI state.
I duplicate the id
field to make it easier for live streams to match the original model.
I’ve included some convenience functions to make it easier to create and manipulate the view model.
defmodule MyApp.ItemView do
defstruct id: nil, item: nil, selected: false
@doc """
Create a new item view model from an item and optional keyword list of options.
"""
def new(item, opts \\ []) do
%MyApp.ItemView{
id: item.id,
item: item,
selected: Keyword.get(opts, :selected, false)
}
end
@doc """
Map a list of items to a list of item view models.
"""
def map(items) do
Enum.map(items, &new(&1))
end
end
In your LiveView, you can wrap records in the view model and then use the view model in the template.
Live Demo
Click on an item to select it and click again to deselect it.
- Item 1
- Item 2
- Item 3
- Item 4
- Item 5
- Item 6
- Item 7
- Item 8
- Item 9
- Item 10
Below is the source code for the LiveView
defmodule MyApp.SelectedItemsLive do
use MyApp.Web, :live_view
alias MyApp.{Item, ItemView}
@items 1..10 |> Enum.map(fn i -> %Item{id: i, name: "Item #{i}"} end)
def mount(_params, _session, socket) do
socket =
socket
|> stream_configure(:items, dom_id: &to_string(&1.id))
|> stream(:items, ItemView.map(@items))
{:ok, socket}
end
def handle_event("select", %{"id" => id}, socket) do
item = @items |> Enum.find(&(&1.id == id))
selected_item = ItemView.new(item, selected: true)
socket = stream_insert(socket, :items, selected_item)
{:noreply, socket}
end
def handle_event("deselect", %{"id" => id}, socket) do
item = @items |> Enum.find(&(&1.id == id))
selected_item = ItemView.new(item, selected: false)
socket = stream_insert(socket, :items, selected_item)
{:noreply, socket}
end
def render(assigns) do
~H"""
<div>
<ul phx-update="stream" id="items" class="w-fit">
<li
:for={{dom_id, item_view} <- @streams.items}
id={dom_id}
data-selected={item_view.selected}
class="px-2 my-1 cursor-pointer data-[selected]:bg-blue-500"
phx-click={
JS.push(if(item_view.selected, do: "deselect", else: "select"),
value: %{id: item_view.id}
)
}
>
{item_view.item.name}
</li>
</ul>
</div>
"""
end
end
As for deleting items with undo, I’ll write about how I did that in a future post.