feat: collapsable hierarchical view

This commit is contained in:
Schuwi
2025-09-19 21:54:34 +02:00
parent 8fe199f50c
commit 288d84614a
3 changed files with 353 additions and 106 deletions

View File

@@ -26,6 +26,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:form, nil)
|> assign(:apriltag_scanner_open, false)
|> assign(:scanned_tags, [])
|> assign(:expanded_locations, MapSet.new())
|> assign(:page_title, "Storage Location Management")}
end
end
@@ -183,6 +184,19 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply, assign(socket, :scanned_tags, [])}
end
def handle_event("toggle_expand", %{"id" => id}, socket) do
location_id = String.to_integer(id)
expanded_locations = socket.assigns.expanded_locations
new_expanded = if MapSet.member?(expanded_locations, location_id) do
MapSet.delete(expanded_locations, location_id)
else
MapSet.put(expanded_locations, location_id)
end
{:noreply, assign(socket, :expanded_locations, new_expanded)}
end
def handle_event("set_apriltag_mode", %{"mode" => mode}, socket) do
{:noreply, assign(socket, :apriltag_mode, mode)}
end
@@ -290,6 +304,21 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
_ -> "hero-cube" # Box/Container
end
children = child_storage_locations(assigns.storage_locations, assigns.location.id)
has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id)
# Calculate component counts including descendants
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants(
assigns.location.id,
assigns.storage_locations,
&(&1.parent_id),
&count_components_in_location/1
)
# Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns = assigns
|> assign(:margin_left, margin_left)
|> assign(:border_class, border_class)
@@ -298,82 +327,152 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:text_size, text_size)
|> assign(:title_tag, title_tag)
|> assign(:icon_name, icon_name)
|> assign(:children, child_storage_locations(assigns.storage_locations, assigns.location.id))
|> assign(:children, children)
|> assign(:has_children, has_children)
|> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
<div class="flex items-center justify-between">
<div class="flex items-center flex-1 space-x-4">
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"}"} />
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4>
<% end %>
<%= if @location.description do %>
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
<% end %>
<div class="flex items-center space-x-2 mt-1">
<p class="text-xs text-base-content/50">
{count_components_in_location(@location.id)} components
</p>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
</div>
<%= if @location.apriltag_id do %>
<div class="flex items-center space-x-3">
<%= if get_apriltag_url(@location) do %>
<div class="apriltag-container flex-shrink-0">
<img
src={get_apriltag_url(@location)}
alt={"AprilTag for #{@location.name}"}
class="w-16 h-auto border border-base-300 rounded bg-base-100"
onerror="this.style.display='none'"
/>
</div>
<div class="flex items-start justify-between p-2 hover:bg-base-50 rounded-md">
<div class="flex items-start flex-1 space-x-2">
<!-- Expand/Collapse button - always aligned to top -->
<%= if @has_children do %>
<button
phx-click="toggle_expand"
phx-value-id={@location.id}
class="flex-shrink-0 p-1 hover:bg-base-200 rounded mt-0.5"
>
<%= if @is_expanded do %>
<.icon name="hero-chevron-down" class="w-4 h-4 text-base-content/60" />
<% else %>
<div class="w-16 h-16 border border-base-300 rounded bg-base-200 flex items-center justify-center flex-shrink-0">
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
</div>
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
<% end %>
<button
phx-click="download_apriltag"
phx-value-id={@location.id}
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
title="Download AprilTag"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
Download
</button>
</div>
</button>
<% else %>
<div class="w-6"></div> <!-- Spacer for alignment -->
<% end %>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
phx-click="show_edit_form"
phx-value-id={@location.id}
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
<.icon name="hero-pencil" class="w-4 h-4" />
</button>
<button
phx-click="delete_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} />
<!-- Content area - always starts at same vertical position -->
<div class="flex-1">
<!-- Minimized view (default) -->
<%= unless @is_expanded do %>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4>
<% end %>
<span class="text-xs text-base-content/50">
({@count_display})
</span>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
phx-click="show_edit_form"
phx-value-id={@location.id}
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
<.icon name="hero-pencil" class="w-3 h-3" />
</button>
<button
phx-click="delete_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
>
<.icon name="hero-trash" class="w-3 h-3" />
</button>
</div>
</div>
<% end %>
<!-- Expanded view -->
<%= if @is_expanded do %>
<div class="flex items-start justify-between">
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4>
<% end %>
<%= if @location.description do %>
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
<% end %>
<div class="flex items-center space-x-2 mt-1">
<p class="text-xs text-base-content/50">
{@count_display}
</p>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
</div>
<div class="flex items-start space-x-3 ml-4">
<%= if @location.apriltag_id do %>
<%= if get_apriltag_url(@location) do %>
<div class="apriltag-container flex-shrink-0">
<img
src={get_apriltag_url(@location)}
alt={"AprilTag for #{@location.name}"}
class="w-16 h-auto border border-base-300 rounded bg-base-100"
onerror="this.style.display='none'"
/>
</div>
<% else %>
<div class="w-16 h-16 border border-base-300 rounded bg-base-200 flex items-center justify-center flex-shrink-0">
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
</div>
<% end %>
<button
phx-click="download_apriltag"
phx-value-id={@location.id}
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
title="Download AprilTag"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
Download
</button>
<% end %>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@location.id}
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
<.icon name="hero-pencil" class="w-4 h-4" />
</button>
<button
phx-click="delete_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Render children recursively -->
<%= for child <- @children do %>
<.location_item location={child} storage_locations={@storage_locations} depth={@depth + 1} />
<!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %>
<%= for child <- @children do %>
<.location_item location={child} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={@depth + 1} />
<% end %>
<% end %>
</div>
"""
@@ -780,7 +879,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<!-- Recursive Storage Location Tree -->
<%= for location <- root_storage_locations(@storage_locations) do %>
<div class="px-6 py-4">
<.location_item location={location} storage_locations={@storage_locations} depth={0} />
<.location_item location={location} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={0} />
</div>
<% end %>
</div>