Trying to sort and filter data tables in streams, LiveView. But, there is one problem

There must be someone try to add sorting and filtering functions to LiveView streams. Below is my try based on the book, Building Table Views with Phoenix LiveView.

IO.inspect socket shows products are sorted as intended, but, streams are not changed. Screen also DOES NOT reflect the change of products.

How to reflect the change of products to streams?

Below is my code, anyone who have sorted data tables before can easily grasp how the code works.

defmodule MarketWeb.ProductLive.Index do
  use MarketWeb, :live_view
  # context
  alias Market.Catalog
  # db schema
  alias Market.Catalog.Product
  # live component
  alias MarketWeb.Forms.SortingForm

  def mount(_params, _session, socket) do
    # auto generated
    {:ok, stream(socket, :products, Catalog.list_products())}
  end

  def handle_params(params, _url, socket) do
    # verity params from live component, SortingComponent.ex, and add the to socket.assigns
    socket =
      socket
      |> parse_params(params)

    # auto generated
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp parse_params(socket, params) do
    with {:ok, sorting_opts} <- SortingForm.parse(params) do
      assign_sorting(socket, sorting_opts)
    else
      _error ->
        assign_sorting(socket)
    end
  end

  defp assign_sorting(socket, overrides \\ %{}) do
    # allocate default values when no params from live component, SortingComponent.ex
    opts = Map.merge(SortingForm.default_values(), overrides)
    assign(socket, :sorting, opts)
  end

  defp apply_action(socket, :index, params) do
    socket
    |> assign(:page_title, "Listing Products")
    |> assign(:products, Catalog.list_products(params))
    |> IO.inspect # Let's see data table.
  end

  # receive event from live component, SortingComponent.ex
  def handle_info({:update, params}, socket) do
    path = ~p"/products/?#{params}"
    {:noreply, push_patch(socket, to: path, replace: true)}
  end

... unnecessary codes are omitted.
end

Below is the result of IO.inspect socket in the code above. Sorting order is unit_price and descending as shown below.

#Phoenix.LiveView.Socket<
  id: "phx-F1Pr9Wrf_AgiPRth",
  endpoint: MarketWeb.Endpoint,
  view: MarketWeb.ProductLive.Index,
  parent_pid: nil,
  root_pid: #PID<0.1837.0>,
  router: MarketWeb.Router,
  assigns: %{
    __changed__: %{
      current_user: true,
      page_title: true,
      products: true,
      sorting: true,
      streams: true
    },
    current_user: #Market.Accounts.User<
      __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
      id: 1,
      email: "jupeter@jeju.kr",
      confirmed_at: nil,
      inserted_at: ~N[2023-04-08 07:41:07],
      updated_at: ~N[2023-04-08 07:41:07],
      ...
    >,
    flash: %{},
    live_action: :index,
    page_title: "Listing Products",
    products: [
      %Market.Catalog.Product{
        __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
        id: 3,
        name: "장갑",
        unit_price: 59.742,
        inserted_at: ~N[2023-04-08 07:40:48],
        updated_at: ~N[2023-04-08 07:40:48]
      },
      %Market.Catalog.Product{
        __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
        id: 2,
        name: "부동산",
        unit_price: 50.378,
        inserted_at: ~N[2023-04-08 07:40:48],
        updated_at: ~N[2023-04-08 07:40:48]
      },
      %Market.Catalog.Product{
        __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
        id: 1,
        name: "한국 국채",
        unit_price: 23.598,
        inserted_at: ~N[2023-04-08 07:40:48],
        updated_at: ~N[2023-04-08 07:40:48]
      }
    ],
    sorting: %{sort_by: :unit_price, sort_dir: :desc},
    streams: %{
      __changed__: MapSet.new([:products]),
      products: %Phoenix.LiveView.LiveStream{
        name: :products,
        dom_id: #Function<3.113057034/1 in Phoenix.LiveView.LiveStream.new/3>,
        inserts: [
          {"products-1", -1,
           %Market.Catalog.Product{
             __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
             id: 1,
             name: "한국 국채",
             unit_price: 23.598,
             inserted_at: ~N[2023-04-08 07:40:48],
             updated_at: ~N[2023-04-08 07:40:48]
           }},
          {"products-2", -1,
           %Market.Catalog.Product{
             __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
             id: 2,
             name: "부동산",
             unit_price: 50.378,
             inserted_at: ~N[2023-04-08 07:40:48],
             updated_at: ~N[2023-04-08 07:40:48]
           }},
          {"products-3", -1,
           %Market.Catalog.Product{
             __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
             id: 3,
             name: "장갑",
             unit_price: 59.742,
             inserted_at: ~N[2023-04-08 07:40:48],
             updated_at: ~N[2023-04-08 07:40:48]
           }}
        ],
        deletes: []
      }
    }
  },
  transport_pid: #PID<0.1831.0>,
  ...
>

Screen is reflecting data of streams, not that of products above, but sorting changes products only.

2 Likes

I’d assume that you need to use stream/3 instead of assign/3 when you update :products in apply_action/3.

2 Likes

Like @aenglisc said, there’s a confusing mix of stream and assign for list of products. It should usually be one or the other for any particular data structure.

The simplest approach to get the book code working would likely be switching the use of stream in the mount callback to assign i.e. {:ok, assign(socket, :products, Catalog.list_products()}. All of the book code’s view templates are referencing the products in the top level of the socket assigns and not the products nested under the streams key in the socket assigns.

If you really want to use the new streaming functionality, you’ll have to update the book code to re-stream_insert all the individual products within the :products stream since there’s no API to replace an entire stream at the collection level – at least for now. For more details, see: How to replace LiveView stream with new items? (reset or re-assigning) - #2 by codeanpeace

2 Likes

I didn’t notice that. Thank you aenglisc.

Anyway, yesterday, I put stream in place of assign products, but that always resulted the same error below.
** (ArgumentError) existing hook :products already attached on :after_render.

Any idea?

Thank you codeanpeace for giving me another valuable piece. I am trying to understand how streams work.

For clarity, stream in the code above is autogenerated by mix phx.gen.live ..., and assigns are from my old knowledge.

I’m assuming we are waiting for Support resetting streams and bulk inserts by chrismccord · Pull Request #2577 · phoenixframework/phoenix_live_view · GitHub to land to the next version bump.

2 Likes

Going through Peter’s excellent book myself now, and thankfully, I could use the later shipped reset: true option for stream/4.

As we anticipate an update regarding this matter, I wanted to share my approach that yielded positive results. Essentially, rather than initiating streaming upon mounting, I chose to commence streaming during the handle_params phase. In cases where the stream already exists within the socket, I opted to remove and subsequently replace it as follows.

@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end

@impl true
def handle_params(%{“sort_field” => sort_field} = params, _url, socket) do
user_roles = DataService.run(params)
assigns =
socket.assigns
|> Map.delete(:streams)

socket =
  socket
  |> Map.update(:assigns, assigns, fn _existing_value -> assigns end)


{:noreply,
  apply_action(socket, socket.assigns.live_action, params)
  |> assign(:selected_column, sort_field)
  |> assign(:filter_params, params)
  |> stream(:user_roles, user_roles)
}

end

def handle_params(params, _url, socket) do
{:noreply,
apply_action(socket, socket.assigns.live_action, params)
|> stream(:user_roles, DataService.run())
}
end

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…

1 Like