Reputation: 4578
The image below is an app that I am working on and attempting to add a feature to.
Quick description of problem.
In the video below when I click the Hardware heading each item sorts alphabetically. This is good and what I want. When I click the Available or Taken buttons to update the status, the items immediately re-order based on updated_at property and this is not what I want. My goal is to write code that ensures that no sorting changes happen when the user changes the Available/Taken status.
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.
Upvotes: 0
Views: 26
Reputation: 15673
So the issue starts with having the sorting in rendering. It doesn't belong there and you've already improved it by moving it out of there, so good on that!
There is a misunderstanding here as well. You write:
but the ordering is broadcast to everyone
Actually, no ordering is broadcast to anyone. The issue is rather the way you wrote it your view doesn't remember what you want to sort by, so when a new list comes in you just take it in whatever order it happens to be rather than the one you actually want.
Some remaining issues:
sort_by_name_ascending
field and why is it a boolean? This could just be a single field sort_by: {:name | ... | :hardware, :asc | :desc}
.So let's fix these one by one.
mount
, initially record we'd like to sort by name ascending:assign(socket,
...,
sort_by: {:name, :asc})
Add a helper function to sort our test beds by our chosen option:
defp sort_testbeds(%{assigns: %{testbeds: testbeds, sort_by: {field, order}} = socket) do
# sort_by already takes an option to order asc/descending, no need for a Enum.reverse here
assign(socket, testbeds: Enum.sort_by(testbeds, &Map.get(&1, field), order))
end
Update the handle event:
# I like to use ecto here, but of course you don't have to
@sort_by_enum Ecto.Parameterized.init(Ecto.Enum, values: [:name, :hardware])
def handle_event("sort_by_string", %{"field" => field}, socket) do
{sort_by_field, order} = socket.assigns.sort_by
{:ok, new_sort_by_field} = Ecto.Type.cast(@sort_by_enum, field)
new_sort_by = if new_sort_by_field == sort_by_field do
{sort_by_field, if(order == :asc, do: :desc, else: :asc)}
else
{sort_by_field, :asc}
end
socket |> assign(sort_by: new_sort_by) |> sort_testbeds()
end
And make sure we sort again when the new testbeds come in:
{:noreply, socket |> assign(testbeds: TestBeds.list_testbeds()) |> sort_testbeds()}
Now even more likely though is that you the sorting code shouldn't be in the View code at all; I don't know what your application is, maybe this Testbeds list will only ever contain a handful of items, but you certainly don't want to load, reload and sort a list of thousands, tens of thousands or millions of items whenever something happens. More likely you'll want to move your sorting closer to your data storage layer, so in the end you'll just call
Testbeds.list_testbeds(sort_by: socket.assigns.sort_by)
whenever you get notified of a change in testbeds or the order changes; And probably you'll want to add limits or a way to stream the results there too. But the right thing to do there really depends on the specifics of your application.
Another important point: This simple sort option will work for 'basic' fields like string, atom or integer fields. However, for things like Date
the builtin sort-order doesn't work anymore, you'd need to tell Enum.sort_by
to use the DateTime
order: Enum.sort_by(items, & &1.updated_at, {:asc, DateTime})
for instance - otherwise you might find somewhat unintuitive results like
~U[2004-05-01 00:00:00Z] < ~U[1992-05-01 23:59:59Z]
# => true
Enum.sort([~U[2004-05-01 00:00:00Z], ~U[1992-05-01 23:59:59Z]])
# => [~U[2004-05-01 00:00:00Z], ~U[1992-05-01 23:59:59Z]]
When what you probably meant was
DateTime.before?(~U[2004-05-01 00:00:00Z], ~U[1992-05-01 23:59:59Z])
# => false
Enum.sort([~U[2004-05-01 00:00:00Z], ~U[1992-05-01 23:59:59Z]], DateTime)
# => [~U[1992-05-01 23:59:59Z], ~U[2004-05-01 00:00:00Z]]
The same thing applies for all other types that may need different sort orders than the default erlang term order. That is of course moot if you apply your sort order to your query instead, which like I mention above you're likely to do at some point anyway.
Upvotes: 1