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