feat(elixir): robust sort in component list

This commit is contained in:
Schuwi
2025-09-17 19:10:04 +02:00
parent b6e137632a
commit 6a1122c3be
2 changed files with 181 additions and 44 deletions

View File

@@ -266,7 +266,7 @@ defmodule ComponentsElixir.Inventory do
def list_components(opts \\ []) do
Component
|> apply_component_filters(opts)
|> order_by([c], [asc: c.name])
|> apply_component_sorting(opts)
|> preload([:category, :storage_location])
|> Repo.all()
end
@@ -292,6 +292,20 @@ defmodule ComponentsElixir.Inventory do
end)
end
defp apply_component_sorting(query, opts) do
case Keyword.get(opts, :sort_criteria, "name_asc") do
"name_asc" -> order_by(query, [c], [asc: c.name])
"name_desc" -> order_by(query, [c], [desc: c.name])
"inserted_at_asc" -> order_by(query, [c], [asc: c.inserted_at])
"inserted_at_desc" -> order_by(query, [c], [desc: c.inserted_at])
"updated_at_asc" -> order_by(query, [c], [asc: c.updated_at])
"updated_at_desc" -> order_by(query, [c], [desc: c.updated_at])
"count_asc" -> order_by(query, [c], [asc: c.count])
"count_desc" -> order_by(query, [c], [desc: c.count])
_ -> order_by(query, [c], [asc: c.name]) # Default fallback
end
end
@doc """
Gets a single component.
"""

View File

@@ -23,12 +23,16 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|> assign(:storage_locations, storage_locations)
|> assign(:stats, stats)
|> assign(:search, "")
|> assign(:sort_criteria, "all_not_id")
|> assign(:sort_criteria, "name_asc")
|> assign(:selected_category, nil)
|> assign(:offset, 0)
|> assign(:components, [])
|> assign(:has_more, false)
|> assign(:loading, false)
|> assign(:sort_freeze_until, nil)
|> assign(:interacting_with, nil)
|> assign(:sort_freeze_timer, nil)
|> assign(:sort_frozen, false)
|> assign(:show_add_form, false)
|> assign(:show_edit_form, false)
|> assign(:editing_component, nil)
@@ -48,7 +52,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
@impl true
def handle_params(params, _uri, socket) do
search = Map.get(params, "search", "")
criteria = Map.get(params, "criteria", "all_not_id")
criteria = Map.get(params, "criteria", "name_asc")
{:noreply,
socket
@@ -114,10 +118,36 @@ defmodule ComponentsElixirWeb.ComponentsLive do
case Inventory.increment_component_count(component) do
{:ok, _updated_component} ->
{:noreply,
socket
|> put_flash(:info, "Count updated")
|> load_components()}
# Only apply sort freeze for dynamic sorting criteria
should_freeze = socket.assigns.sort_criteria in ["count_asc", "count_desc", "updated_at_asc", "updated_at_desc"]
if should_freeze do
# Cancel any existing timer
if socket.assigns.sort_freeze_timer do
Process.cancel_timer(socket.assigns.sort_freeze_timer)
end
# Set sort freeze for 3 seconds and mark component as interacting
freeze_until = DateTime.add(DateTime.utc_now(), 3, :second)
# Set new timer to clear interaction state
timer_ref = Process.send_after(self(), {:clear_interaction, id}, 3000)
{:noreply,
socket
|> put_flash(:info, "Count updated")
|> assign(:sort_freeze_until, freeze_until)
|> assign(:interacting_with, id)
|> assign(:sort_freeze_timer, timer_ref)
|> assign(:sort_frozen, true)
|> load_components()}
else
# Normal behavior for stable sorts
{:noreply,
socket
|> put_flash(:info, "Count updated")
|> load_components()}
end
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to update count")}
@@ -129,10 +159,36 @@ defmodule ComponentsElixirWeb.ComponentsLive do
case Inventory.decrement_component_count(component) do
{:ok, _updated_component} ->
{:noreply,
socket
|> put_flash(:info, "Count updated")
|> load_components()}
# Only apply sort freeze for dynamic sorting criteria
should_freeze = socket.assigns.sort_criteria in ["count_asc", "count_desc", "updated_at_asc", "updated_at_desc"]
if should_freeze do
# Cancel any existing timer
if socket.assigns.sort_freeze_timer do
Process.cancel_timer(socket.assigns.sort_freeze_timer)
end
# Set sort freeze for 3 seconds and mark component as interacting
freeze_until = DateTime.add(DateTime.utc_now(), 3, :second)
# Set new timer to clear interaction state
timer_ref = Process.send_after(self(), {:clear_interaction, id}, 3000)
{:noreply,
socket
|> put_flash(:info, "Count updated")
|> assign(:sort_freeze_until, freeze_until)
|> assign(:interacting_with, id)
|> assign(:sort_freeze_timer, timer_ref)
|> assign(:sort_frozen, true)
|> load_components()}
else
# Normal behavior for stable sorts
{:noreply,
socket
|> put_flash(:info, "Count updated")
|> load_components()}
end
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to update count")}
@@ -270,35 +326,78 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
end
@impl true
def handle_info({:clear_interaction, component_id}, socket) do
# Only clear if this timer is for the currently interacting component
if socket.assigns.interacting_with == component_id do
# Clear interaction state and allow sorting to resume
{:noreply,
socket
|> assign(:sort_freeze_until, nil)
|> assign(:interacting_with, nil)
|> assign(:sort_freeze_timer, nil)
|> assign(:sort_frozen, false)
|> load_components()}
else
# Ignore stale timer messages
{:noreply, socket}
end
end
defp load_components(socket, opts \\ []) do
append = Keyword.get(opts, :append, false)
filters = [
search: socket.assigns.search,
sort_criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
limit: @items_per_page,
offset: socket.assigns.offset
]
|> Enum.reject(fn
{_, v} when is_nil(v) -> true
{:search, v} when v == "" -> true
{_, _} -> false
end)
# Check if sorting should be frozen
now = DateTime.utc_now()
should_reload = is_nil(socket.assigns.sort_freeze_until) ||
DateTime.compare(now, socket.assigns.sort_freeze_until) != :lt
%{components: new_components, has_more: has_more} =
Inventory.paginate_components(filters)
if should_reload do
# Normal loading - query database with current sort criteria
filters = [
search: socket.assigns.search,
sort_criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
limit: @items_per_page,
offset: socket.assigns.offset
]
|> Enum.reject(fn
{_, v} when is_nil(v) -> true
{:search, v} when v == "" -> true
{_, _} -> false
end)
components =
if append do
socket.assigns.components ++ new_components
%{components: new_components, has_more: has_more} =
Inventory.paginate_components(filters)
components =
if append do
socket.assigns.components ++ new_components
else
new_components
end
socket
|> assign(:components, components)
|> assign(:has_more, has_more)
else
# Frozen - just update the specific component in place without reordering
if socket.assigns.interacting_with do
updated_components =
Enum.map(socket.assigns.components, fn component ->
if to_string(component.id) == socket.assigns.interacting_with do
# Reload this specific component to get updated count
Inventory.get_component!(component.id)
else
component
end
end)
assign(socket, :components, updated_components)
else
new_components
socket
end
socket
|> assign(:components, components)
|> assign(:has_more, has_more)
end
end
defp build_query_params(socket, overrides) do
@@ -421,24 +520,44 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</form>
</div>
<div>
<form phx-change="sort_change">
<form phx-change="sort_change" class="relative">
<select
name="sort_criteria"
class="block w-full px-3 py-2 border border-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
>
<option value="all_not_id" selected={@sort_criteria == "all_not_id"}>
All (without IDs)
<option value="name_asc" selected={@sort_criteria == "name_asc"}>
Name A-Z
</option>
<option value="all" selected={@sort_criteria == "all"}>All</option>
<option value="name" selected={@sort_criteria == "name"}>Name</option>
<option value="description" selected={@sort_criteria == "description"}>
Description
<option value="name_desc" selected={@sort_criteria == "name_desc"}>
Name Z-A
</option>
<option value="id" selected={@sort_criteria == "id"}>ID</option>
<option value="category_id" selected={@sort_criteria == "category_id"}>
Category ID
<option value="inserted_at_desc" selected={@sort_criteria == "inserted_at_desc"}>
Entry Date (Newest)
</option>
<option value="inserted_at_asc" selected={@sort_criteria == "inserted_at_asc"}>
Entry Date (Oldest)
</option>
<option value="updated_at_desc" selected={@sort_criteria == "updated_at_desc"}>
Update Date (Newest)
</option>
<option value="updated_at_asc" selected={@sort_criteria == "updated_at_asc"}>
Update Date (Oldest)
</option>
<option value="count_desc" selected={@sort_criteria == "count_desc"}>
Count (Highest)
</option>
<option value="count_asc" selected={@sort_criteria == "count_asc"}>
Count (Lowest)
</option>
</select>
<%= if @sort_frozen do %>
<div class="absolute -bottom-5 left-0 text-xs text-yellow-600 flex items-center transition-opacity duration-200 pointer-events-none">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2L3 7v11h14V7l-7-5z"/>
</svg>
Sort temporarily frozen
</div>
<% end %>
</form>
</div>
</div>
@@ -689,7 +808,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-base-300" id="components-list" phx-update="replace">
<%= for component <- @components do %>
<li id={"component-#{component.id}"} class={"px-6 py-6 hover:bg-base-200 #{if @focused_component_id == component.id, do: "bg-base-50 border-l-4 border-primary", else: "cursor-pointer"}"} phx-click={if @focused_component_id != component.id, do: "toggle_focus", else: nil} phx-value-id={component.id}>
<li id={"component-#{component.id}"} class={[
"px-6 py-6 hover:bg-base-200 transition-all duration-200",
if(@focused_component_id == component.id, do: "bg-base-50 border-l-4 border-primary", else: "cursor-pointer"),
if(@interacting_with == to_string(component.id), do: "ring-2 ring-yellow-400 ring-opacity-50 bg-yellow-50", else: "")
]} phx-click={if @focused_component_id != component.id, do: "toggle_focus", else: nil} phx-value-id={component.id}>
<%= if @focused_component_id == component.id do %>
<!-- Expanded/Focused View -->
<div class="space-y-6">