Hi there,
I also worked on a sortable table with streams the last few hours.
I was not yet firm with the concept of streams, but what I think is the way to go is,
- to reset the stream every time, parameter with sorting params change.
- I also learned, that one should load an empty stream on mount, if you are working with live_actions. I build upon the latest generator boiler plate code, and they do crud stuff with distinguishing live_actions
- In general, I had the issue, that the stream was duplicated on page reloads, as mount and handle_params with live_action :index both loaded a stream. Resetting it every time did not fix it. I had to actively load an empty container on mount in order to get things working as expected…
Maybe that helps someone, maybe not, that is my code I came up with - its WIP:
defmodule PortalWeb.Applications.Wcm.Pages.IndexLive do
alias Portal.WebPages
use PortalWeb, :live_view
alias Portal.Accounts.User.Permission
alias Portal.WebPages.Page.Name, as: PageName
@default_sort_by :title
@default_sort_order :asc
on_mount({PortalWeb.UserAuth, Permission.WebContentAdmin})
@impl true
def render(assigns) do
~H"""
<.modal
:if={@live_action in [:new, :edit]}
id="page-modal"
show_initially
on_cancel={JS.patch(~p"/app/wcm/pages?#{@sort_url_params}")}
>
<:header>Edit</:header>
<:body>
<.live_component
module={PortalWeb.Applications.Wcm.Pages.FormComponent}
id={@page.id || :new}
title={@page_title}
action={@live_action}
page={@page}
patch={~p"/app/wcm/pages?#{@sort_url_params}"}
/>
</:body>
</.modal>
<.app_wrapper active_tab={@active_tab}>
<:tab title="Overview" navigate={~p"/app/wcm/overview"} active_value={PageName.AppWcmOverview}>
</:tab>
<:tab title="Pages" navigate={~p"/app/wcm/pages"} active_value={PageName.AppWcmPages}></:tab>
<:tab
title="Authorizations"
navigate={~p"/app/wcm/authorization"}
active_value={PageName.AppWcmAuthorization}
>
</:tab>
<.header class="text-left mb-5">
Pages
<:subtitle>Edit stuff</:subtitle>
</.header>
<.table
id="pages_list"
rows={@streams.pages}
active_sort_by={@active_sort_by}
toggle_sort_order={@toggle_sort_order}
sort_url={~p"/app/wcm/pages"}
>
<:col :let={{_id, page}} label="ID" sort_by={:name}>
<%= page.name %>
</:col>
<:col :let={{_id, page}} label="Title" sort_by={:title} highlight={true}>
<span class="whitespace-nowrap"><%= page.title %></span>
</:col>
<:col :let={{_id, page}} label="Path" sort_by={:path}>
<%= page.path %>
</:col>
<:col :let={{_id, page}} label="Status" sort_by={:status}>
<%= if page.status == WebPages.Page.Status.Draft do %>
<.badge color="grey" label="Draft" class="w-20" />
<% else %>
<.badge color="green" label="Published" class="w-20" />
<% end %>
</:col>
<:col :let={{_id, page}} label="Linkable" sort_by={:linkable}>
<%= page.linkable %>
</:col>
<:action :let={{_id, page}}>
<.link patch={~p"/app/wcm/pages/#{page}/edit"}>
<.button kind={:icon_button} aria-label="edit page">
<.icon name="hero-pencil-solid" />
</.button>
</.link>
</:action>
</.table>
</.app_wrapper>
"""
end
# Initially we deliver an empty stream container
# as the stream itself will be delivered by the :index live_action
# If I would not deliver an empty container, the streamed items duplicate in the UI
# ---------------------------------------------------------------------------------
@impl true
def mount(_params, _session, socket) do
PortalWeb.Endpoint.subscribe("portal:accounts")
{:ok,
stream(socket |> assign_defaults(@default_sort_by, @default_sort_order), :pages, [],
reset: true
)}
end
# Handle live_actions [:index, :edit, :new]
# -----------------------------------------
@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, WebPages.Cache.get_by_id(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page, %WebPages.Page{})
end
# Handle the sorting parameter changes in live_action :index
# ----------------------------------------------------------
defp apply_action(socket, :index, %{"sort_by" => sort_by, "sort_order" => "asc"})
when sort_by in ~w(name path title status linkable) do
{updated_socket, sorted_pages} =
sort_stream(socket, WebPages.Cache.get_all(), String.to_existing_atom(sort_by), :asc)
stream(updated_socket, :pages, sorted_pages, reset: true)
end
defp apply_action(socket, :index, %{"sort_by" => sort_by, "sort_order" => "desc"})
when sort_by in ~w(name path title status linkable) do
{updated_socket, sorted_pages} =
sort_stream(socket, WebPages.Cache.get_all(), String.to_existing_atom(sort_by), :desc)
stream(updated_socket, :pages, sorted_pages, reset: true)
end
# Currently not used, as we do not show dates
# -------------------------------------------
# defp apply_action(socket, :index, %{"sort_by" => sort_by, "sort_order" => "asc"})
# when sort_by in ~w(inserted_at) do
# {updated_socket, sorted_pages} =
# sort_stream(
# socket,
# WebPages.Cache.get_all(),
# String.to_existing_atom(sort_by),
# :asc,
# :for_date
# )
# stream(updated_socket, :pages, sorted_pages, reset: true)
# end
# defp apply_action(socket, :index, %{"sort_by" => sort_by, "sort_order" => "desc"})
# when sort_by in ~w(inserted_at) do
# {updated_socket, sorted_pages} =
# sort_stream(
# socket,
# WebPages.Cache.get_all(),
# String.to_existing_atom(sort_by),
# :desc,
# :for_date
# )
# stream(updated_socket, :pages, sorted_pages, reset: true)
# end
# Handle live_action :index with disregard of params
# ----------------------------------------------------------
defp apply_action(socket, :index, _params) do
{updated_socket, sorted_pages} =
sort_stream(socket, WebPages.Cache.get_all(), @default_sort_by, @default_sort_order)
stream(updated_socket, :pages, sorted_pages)
end
@impl true
def handle_info({PortalWeb.Applications.Wcm.Pages.FormComponent, {:saved, page}}, socket) do
{:noreply, stream_insert(socket, :pages, page)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
page = WebPages.Cache.get_by_id(id)
{:ok, _} = WebPages.delete_page(page)
# We later have to update the stream by Cache update broadcasts
# {:noreply, stream_delete(socket, :page, post)}
{:noreply, socket}
end
defp sort_stream(socket, pages, sort_by, sort_order, mode \\ :normal)
defp sort_stream(socket, pages, sort_by, sort_order, :normal) do
sorted_pages =
pages
|> Enum.map(fn page -> page |> Map.put(:is_selected, false) end)
|> Enum.sort_by(
fn page -> Map.get(page, sort_by) end,
sort_order
)
{socket |> assign_defaults(sort_by, sort_order), sorted_pages}
end
# Currently not used, as we do not show dates
# -------------------------------------------
# defp sort_stream(socket, pages, sort_by, sort_order, :for_date) do
# sorted_pages =
# pages
# |> Enum.map(fn page -> page |> Map.put(:is_selected, false) end)
# |> Enum.sort_by(
# fn page -> Map.get(page, sort_by) end,
# {sort_order, Date}
# )
# {socket |> assign_defaults(sort_by, sort_order), sorted_pages}
# end
defp assign_defaults(socket, sort_by, sort_order) do
socket
|> assign(toggle_sort_order: if(sort_order == :asc, do: :desc, else: :asc))
|> assign(sort_by: sort_by)
|> assign(active_sort_by: sort_by)
|> assign(sort_url_params: %{sort_by: sort_by, sort_order: sort_order})
|> assign(:page, nil)
end
end
For sake of completeness, here is my table core component (just one slight changes to the boilerplate table)
@doc """
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id"><%= user.id %></:col>
<:col :let={user} label="username"><%= user.username %></:col>
</.table>
"""
attr(:id, :string, required: true)
attr(:rows, :list, required: true)
attr(:row_id, :any, default: nil, doc: "the function for generating the row id")
attr(:row_click, :any, default: nil, doc: "the function for handling phx-click on each row")
attr(:active_sort_by, :any, default: nil, doc: "the currently activated column for sorting")
attr(:toggle_sort_order, :atom,
values: [:asc, :desc],
default: :asc,
doc: "the atom that describes sort order [:asc, :desc]"
)
attr(:sort_url, :any, default: nil, doc: "mostly the urls of the current live_view")
attr(:select_all, :boolean,
default: false,
doc: "if a select all column is used this is the state"
)
attr(:row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
)
slot :col, required: true do
attr(:label, :string)
attr(:sort_by, :atom)
attr(:is_select_all_column, :boolean)
attr(:highlight, :boolean)
end
slot(:action, doc: "the slot for showing user actions in the last table column")
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-400">
<thead class="text-xs uppercase bg-gray-700 text-gray-400">
<tr>
<th :for={col <- @col} class="px-6 py-3 uppercase whitespace-nowrap" scope="col">
<%= if col[:sort_by] do %>
<%= if col[:sort_by] === @active_sort_by do %>
<.link
patch={"#{@sort_url}?sort_by=#{col[:sort_by]}&sort_order=#{@toggle_sort_order}"}
class="flex items-center"
>
<%= col[:label] %>
<.icon
:if={@toggle_sort_order == :asc}
name="hero-arrow-up-circle"
class="ml-1 w-4 h-4"
/>
<.icon
:if={@toggle_sort_order == :desc}
name="hero-arrow-down-circle"
class="ml-1 w-4 h-4"
/>
</.link>
<% else %>
<.link
patch={"#{@sort_url}?sort_by=#{col[:sort_by]}&sort_order=#{:asc}"}
class="flex items-center"
>
<%= col[:label] %>
<.icon name="hero-arrow-up-circle" class="ml-1 w-4 h-4 invisible" />
</.link>
<% end %>
<% else %>
<%= if col[:is_select_all_column] do %>
<.input
name="select_all"
type="checkbox"
value={@select_all}
phx-click="select_all"
/>
<% else %>
<%= col[:label] %>
<% end %>
<% end %>
</th>
<th class="relative p-0 pb-4">
<span class="sr-only"><%= gettext("Actions") %></span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class={[
"border-b border-gray-700 hover:bg-gray-600 group",
(table_row_is_selected?(row) && "bg-blue-500/20") || "bg-gray-800"
]}
>
<td
:for={{col, _i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={[
"relative px-6",
@row_click && "hover:cursor-pointer",
col[:highlight] && "font-semibold text-white"
]}
>
<div class="block py-4 pr-6">
<span class="absolute -inset-y-px right-0 -left-4 sm:rounded-l-xl whitespace-nowrap" />
<span class={["relative"]}>
<%= render_slot(col, @row_item.(row)) %>
</span>
</div>
</td>
<td :if={@action != []}>
<div>
<span :for={action <- @action}>
<%= render_slot(action, @row_item.(row)) %>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
defp table_row_is_selected?({_id, %{:is_selected => is_selected}} = _row), do: is_selected
defp table_row_is_selected?({_id, _row}), do: false
defp table_row_is_selected?(%{} = row), do: row.is_selected
And that’s how it looks, one can click on the columns for sorting…