Sorting Problem Phoenix Liveview Juggles Pub-Sub and Render (medium difficulty)

The image below is an app that I am working on and attempting to add a feature to.

Quick description of initial code

The items that are stacked on top of one another are called “testbeds” and these are pulled from Postgres via Ecto. They are then assigned to the socket, rendered and iterated through. TestBeds uses a pub-sub feature so when changes are made to this collection the changes are broadcast to other users.

 def mount(_params, _session, socket) do
    TestBeds.subscribe()
           
    {:ok,
     assign(socket,
       testbeds: TestBeds.list_testbeds(),    #Get Testbeds
       # code ...
     )}
  end

Render

def render(assigns) do
   ~H"""
   <body>
   
   <div>
   
   <%= for testbed <- Enum.filter(@testbeds, fn(item)-> item.group_id != nil end)|>Enum.sort_by(&("# {&1.group.name}#{&1.name}"), :asc) do %>
       
       <%!-- code.... --%>
     
   <% end %>
   </div>

   
   <%!-- code ..... --%>
   
   </body>
   """
end

In the code above the testbeds are explicitly sorted by a property named group.name. The reason I am sorting it like this is because if I iterate and render testbeds with no sorting, the testbeds will automatically sort by the update_at property (and this change will be broadcast to other users) and this is not what I want. Choosing to sort it by group.name is arbritary, I could have used any property.

What is the problem?

You see those headers at the top? I want the user to be able to click each one and as a result the testbeds automatically sort by that columns data.

So far I have it partially working. I updated the testbeds with the previous sorting removed and so the code now looks like this:

   <%= for testbed <- @testbeds do %>
       
       <%!-- code.... --%>

      <%-- Example of sorting applied to a heading. The actual code has about 12 of these --%>
      <h2 class="info-button" phx-click="sort_by_string" phx-value-field="hardware"> Hardware</h2>   

      <%!-- code.... --%>

     
   <% end %>

The code to sort the testbeds looks like this:

  def handle_event("sort_by_string", %{"field" => field}, socket) do

     atomParam = String.to_existing_atom(field)

     IO.inspect atomParam

    if socket.assigns[:sort_by_name_ascending] == false do
      sorted_testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, atomParam) end)   #hardware is hard coded as an atom
      {:noreply, assign(socket, testbeds: sorted_testbeds, sort_by_name_ascending: true)}
    else
      sorted_testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, atomParam) end) |> Enum.reverse()  #hardware is hard coded as an atom
        dbg(sorted_testbeds)
      {:noreply, assign(socket, testbeds: sorted_testbeds, sort_by_name_ascending: false)}
    end
  end

The Problem

The field titled Set Status (see image) has a button that lets the user make an update to a property of testbeds named status (in the image this is the button displaying Available or Taken). When this happens, the testbed order changes and re-orders based on the values in update_at property. I do not want this. I want the order of the testbeds to stay as-is and not to be broadcast to users. However, I do want the status update (the change from Available to Taken) to be broadcast. To be clear, when a user changes the status no sorting should be visibly made or broadcast but the status value change should be broadcast. The status values are Available and Taken (see image).

To make this worse, as mentioned, I have integrated the pub-sub feature so that when a user changes the status of a testbed, not only does the status change but the ordering is broadcast to everyone. I want the ordering to visibly stay as-is for any user that changes a testbed status.

The status code is here:

  def handle_event("create-status", params, socket) do
    # Create  Record

    %{"status" => status, "testbed_id" => testbed_id, "developer" => developer} = params

    stripped_developer = String.trim(developer)


     if stripped_developer === "" || String.trim(developer) === "" || String.trim(developer) === "None" do
      {:noreply, assign(socket, developer: developer,  name_warning: " (can't be empty or set to None)")}

     else
      TestBeds.get_test_bed!(testbed_id)
      |> TestBeds.update_test_bed(%{status: status, developer: developer})

      current_testbed = TestBeds.get_test_bed!(testbed_id)

      StatusActions.create_status_action(%{
        testbed_name: current_testbed.name,
        testbed_value: testbed_id,
        status: status,
        developer: developer
      })

   
      {:noreply, assign(socket, developer: developer)}
    end
  end
The reset (Changes **Taken** back to **Available**) is here:


  def handle_event("reset", %{"id" => id}, socket) do
    testbed = TestBeds.get_test_bed!(id)

    StatusActions.create_status_action(%{
      testbed_name: testbed.name,
      testbed_value: id,
      status: "Available",
      developer: testbed.developer
    })


    TestBeds.get_test_bed!(id)
    |> TestBeds.update_test_bed(%{status: "Available", developer: "None"})


     # {:noreply, socket}
    {:noreply, assign(socket, testbeds: TestBeds.list_testbeds())}
  end

Summary

When a user sorts data by clicking the headings. the testbeds should sort by column data and this sorting should not be broadcasted to other users. This currently works with one exception.

The exception
When a user changes the Status value sorting takes and is broadcast to other users. This should not happen.

Writing all this out made me realize I need to be working exclusively with “params” instead of “socket”

@wktdev, I’m assuming you have a handle_info/2 callback in the LiveViews for handling the PubSub message, what are you doing in that callback? Are you refetching the testbeds from the database and not re-applying the user-defined sorting?

Not 100% following what the issue is, but one thing that jumped out was that the code in the handler for "sort_by_string" doesn’t record the value of atomParam in the socket anywhere, so subsequent refreshes of testbeds can’t possibly maintain the same order.

That’s intended. I don’t want the sorting to be retained on refreshes ( I know their is a way to do this that is bound to a URL but I’m not doing it that way … maybe I should have, but I’m already going down this path).

  def handle_info({TestBeds, [:testbed | _], _}, socket) do
    # IO.inspect("_______________SOCKET")
    # IO.inspect(socket)
    {:noreply, assign(socket, testbeds: TestBeds.list_testbeds())}
  end

I tried adding a video to make this more clear but the forum doesn’t let me.

Socket assigns are not retained on refreshes. It’s hard for us to be sure without more code, but it seems to me like he identified your problem:

When the status is updated, you are probably reloading the list of testbeds, right? And when that happens the sort order is reset because you’re not storing and re-applying the sort field after updating the list.

I’m looking for direction on how to fix it not just diagnose it. If this was React.js I could probably fix it in 20 min, but I’m not familiar with Liveview enough to be confident on how to untangle myself out of the box I’m in.

That is replacing your previously sorted testbeds with a new list that’s no longer using the user-specified sort order

1 Like

I see it, I don’t know what to do about it so that I can retain the order and still change the status.

I tried playing around trying to cache the testbeds and edit it something like


    cache = socket.testbeds
    {:noreply, assign(socket, testbeds: cache)}

It’s all a dead end.

In your mount/3 callback I would set your default sort field and direction:

assign(socket, sort_direction: :asc, sort_field: :some_field)

In the following callback:

def handle_event("sort_by_string", %{"field" => field}, socket) do

I would update the sort_direction and sort_field assigns, and use them to sort your list:

testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, sort_field) end, sort_direction)

Inside of your handle_info/2 callback you would sort the new list in the same way, getting the sort field & direction from the assigns

Excuse me if I am mistaken, but I am confused as to why handle_info/2 is being mentioned. I don’t want the sorting of any user to affect the sorting of any other user. If a user sorts a column it should not be viewable by another user.

Apologies if I am not understanding your post.

To be clear: I’m referring to a handle_info/2 callback in the LiveView.

I understand that, and I’m not suggesting you do that.

If I’m understanding your problem correctly, it’s not that someone’s sorting preference is being shared with others, but instead when someone changes a testbed everyone’s list of testbeds is being overwritten by a new list that is not sorted how they want it. And that’s because when someone changes a testbed, a message is broadcasted about that change, all the LiveViews are subscribed to that change, and in response to that change are overwriting their list of testbeds with a new (unsorted) list.

My suggestion is that in the handle_info/2 callback responsible for responding to the PubSub broadcast you reapply each person’s desired sort order to the new list of testbeds. Keep in mind: that handle_info/2 callback will be run in each LiveView process separately

Also keep in mind: if you and I are each viewing the “same” LiveView, there are 2 separate processes running on the server, one for each of us. What happens in the process for your LiveView will not affect mine, and vice versa.

I attempted to try your suggestion and it didn’t change anything except create a red “we can’t find the internet” prompt at the top of the page.

This is a gist of the entire LiveView page:

Here is step by step how I attempted to follow your instructions:

  1. In your mount/3 callback I would set your default sort field and direction:

This is the code I changed/used

  def mount(_params, _session, socket) do
    TestBeds.subscribe()
    Alerts.subscribe()

    {:ok,
     assign(socket,
       testbeds: TestBeds.list_testbeds(),
       sort_direction: false,               # Here
       sort_field: :name,                   # Here 
       alerts: Alerts.list_alerts(),
       developer: "None",
        name_warning: ""
     )}
  end

def handle_event(“sort_by_string”, %{“field” => field}, socket) do


I would update the `sort_direction` and `sort_field` assigns, and use them to sort your list:

This is the code I changed/used

  def sort_by(socket, field) do
    atomParam = String.to_existing_atom(field)

    if socket.assigns[:sort_direction] == false do
      sorted_testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, atomParam) end)
      {:noreply, assign(socket, testbeds: sorted_testbeds, sort_direction: true, sort_field: atomParam)}
    else
      sorted_testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, atomParam) end)
      |> Enum.reverse()
      {:noreply, assign(socket, testbeds: sorted_testbeds, sort_direction: false, sort_field: atomParam)}
    end
  end


  def handle_event("sort_by_string", %{"field" => field}, socket) do
    sort_by(socket, field)
  end
  1. Inside of your handle_info/2 callback you would sort the new list in the same way, getting the sort field & direction from the assigns

This is the code I changed/used

  def handle_info({TestBeds, [:testbed | _], _}, socket) do
    testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, socket.assigns.sort_field) end, socket.assigns.sort_direction)
    {:noreply, assign(socket, testbeds: testbeds)}
  end

I get this error:


[error] GenServer #PID<0.2240.0> terminating
** (UndefinedFunctionError) function false.compare/2 is undefined (module false is not available)
    false.compare("Bill", "SAC")
    (elixir 1.17.3) lib/list.ex:511: anonymous fn/4 in List.keysort_fun/2
    (stdlib 5.2.3.1) lists.erl:1210: :lists.sort/2
    (elixir 1.17.3) lib/enum.ex:3349: Enum.sort_by/3
    (app 0.1.0) lib/app_web/live/page_live.ex:75: AppWeb.PageLive.handle_info/2
    (phoenix_live_view 0.18.18) lib/phoenix_live_view/channel.ex:276: Phoenix.LiveView.Channel.handle_info/2
    (stdlib 5.2.3.1) gen_server.erl:1095: :gen_server.try_handle_info/3
    (stdlib 5.2.3.1) gen_server.erl:1183: :gen_server.handle_msg/6
    (stdlib 5.2.3.1) proc_lib.erl:251: :proc_lib.wake_up/3

You are passing false as the second argument to sort_by. Embedded in my suggestion but not called out explicitly was to pass :asc/:desc as the second argument (and storing that as the sort direction), instead of conditionally using Enum.reverse

I updated the code. Now when I click the Available button, it opens the modal but does not change to “taken” when submitted. Instead, the user needs to refresh to see the value has changed.


  def mount(_params, _session, socket) do
    TestBeds.subscribe()
    Alerts.subscribe()

    {:ok,
     assign(socket,
       testbeds: TestBeds.list_testbeds(),
       sort_direction: :asc,               # Here
       sort_field: :name,                   # Here
       alerts: Alerts.list_alerts(),
       developer: "None",
        name_warning: ""
     )}
  end
def handle_info({TestBeds, [:testbed | _], _}, socket) do
  # IO.inspect("_______________SOCKET")
  testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, socket.assigns.sort_field) end, socket.assigns.sort_direction)

  {:noreply, assign(socket, testbeds: testbeds)}
end

  def sort_by(socket, field) do
    atomParam = String.to_existing_atom(field)

    if socket.assigns[:sort_direction] == :desc do
      sorted_testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, atomParam) end)
      {:noreply, assign(socket, testbeds: sorted_testbeds, sort_direction: :asc, sort_field: atomParam)}
    else
      sorted_testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, atomParam) end)
      |> Enum.reverse()
      {:noreply, assign(socket, testbeds: sorted_testbeds, sort_direction: :desc, sort_field: atomParam)}
    end
  end


  def handle_event("sort_by_string", %{"field" => field}, socket) do
    sort_by(socket, field)
  end

Wait, I think this change might have fixed it:

I changed your code from this:

testbeds = Enum.sort_by(socket.assigns.testbeds, fn item -> Map.get(item, sort_field) end, sort_direction)

To this


def handle_info({TestBeds, [:testbed | _], _}, socket) do
  # IO.inspect("_______________SOCKET")
  testbeds = Enum.sort_by(TestBeds.list_testbeds(), fn item -> Map.get(item, socket.assigns.sort_field) end, socket.assigns.sort_direction)

  {:noreply, assign(socket, testbeds: testbeds)}
end

Right, in your previous post you were not fetching the updated testbed in response to the PubSub message