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

@@ -275,4 +275,60 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
[child | sort_children_recursively(entities, child.id, parent_id_accessor_fn)]
end)
end
@doc """
Calculates component counts for an entity and all its descendants.
Returns a tuple of {self_count, children_count, total_count}.
## Parameters
- entity_id: The ID of the entity to count for
- all_entities: List of all entities in the hierarchy
- parent_id_accessor_fn: Function to get parent_id from an entity
- count_fn: Function that takes an entity_id and returns the direct count for that entity
## Examples
iex> count_fn = fn id -> MyRepo.count_components_for(id) end
iex> Hierarchical.count_with_descendants(1, entities, &(&1.parent_id), count_fn)
{3, 7, 10} # 3 in self, 7 in children, 10 total
"""
def count_with_descendants(entity_id, all_entities, parent_id_accessor_fn, count_fn) do
# Get direct count for this entity
self_count = count_fn.(entity_id)
# Get all descendant entity IDs (excluding self)
all_descendant_ids = descendant_ids(all_entities, entity_id, parent_id_accessor_fn)
descendant_ids_only = List.delete(all_descendant_ids, entity_id)
# Sum counts for all descendants
children_count = Enum.reduce(descendant_ids_only, 0, fn id, acc ->
acc + count_fn.(id)
end)
{self_count, children_count, self_count + children_count}
end
@doc """
Formats component count display based on expansion state.
When collapsed: Shows total count only: "10 components"
When expanded: Shows breakdown: "10 components (3 self, 7 children)"
## Parameters
- self_count: Number of components directly in this entity
- children_count: Number of components in all descendant entities
- is_expanded: Whether the entity is currently expanded
- singular_noun: What to call a single item (default: "component")
- plural_noun: What to call multiple items (default: "components")
"""
def format_count_display(self_count, children_count, is_expanded, singular_noun \\ "component", plural_noun \\ "components") do
total_count = self_count + children_count
count_noun = if total_count == 1, do: singular_noun, else: plural_noun
if is_expanded and children_count > 0 do
"#{total_count} #{count_noun} (#{self_count} self, #{children_count} children)"
else
"#{total_count} #{count_noun}"
end
end
end

View File

@@ -20,6 +20,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> assign(:show_edit_form, false)
|> assign(:editing_category, nil)
|> assign(:form, nil)
|> assign(:expanded_categories, MapSet.new())
|> assign(:page_title, "Category Management")}
end
end
@@ -115,6 +116,19 @@ defmodule ComponentsElixirWeb.CategoriesLive do
end
end
def handle_event("toggle_expand", %{"id" => id}, socket) do
category_id = String.to_integer(id)
expanded_categories = socket.assigns.expanded_categories
new_expanded = if MapSet.member?(expanded_categories, category_id) do
MapSet.delete(expanded_categories, category_id)
else
MapSet.put(expanded_categories, category_id)
end
{:noreply, assign(socket, :expanded_categories, new_expanded)}
end
defp reload_categories(socket) do
categories = Inventory.list_categories()
assign(socket, :categories, categories)
@@ -156,6 +170,21 @@ defmodule ComponentsElixirWeb.CategoriesLive do
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
children = child_categories(assigns.categories, assigns.category.id)
has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id)
# Calculate component counts including descendants
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants(
assigns.category.id,
assigns.categories,
&(&1.parent_id),
&count_components_in_category/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)
@@ -163,50 +192,113 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> assign(:button_size, button_size)
|> assign(:text_size, text_size)
|> assign(:title_tag, title_tag)
|> assign(:children, child_categories(assigns.categories, assigns.category.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-1">
<div class="flex items-center">
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mr-3"} />
<div>
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h3>
<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={@category.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 %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h4>
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
<% end %>
<%= if @category.description do %>
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
<% end %>
<p class="text-xs text-base-content/50 mt-1">
{count_components_in_category(@category.id)} components
</p>
</div>
</button>
<% else %>
<div class="w-6"></div> <!-- Spacer for alignment -->
<% end %>
<.icon name="hero-folder" 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"}"}>{@category.name}</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h4>
<% end %>
<span class="text-xs text-base-content/50">
({@count_display})
</span>
</div>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@category.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_category"
phx-value-id={@category.id}
data-confirm="Are you sure you want to delete this category? 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"}"}>{@category.name}</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h4>
<% end %>
<%= if @category.description do %>
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
<% end %>
<p class="text-xs text-base-content/50 mt-1">
{@count_display}
</p>
</div>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@category.id}
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
>
<.icon name="hero-pencil" class={@icon_size} />
</button>
<button
phx-click="delete_category"
phx-value-id={@category.id}
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
>
<.icon name="hero-trash" class={@icon_size} />
</button>
</div>
</div>
<% end %>
</div>
</div>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@category.id}
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
>
<.icon name="hero-pencil" class={@icon_size} />
</button>
<button
phx-click="delete_category"
phx-value-id={@category.id}
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
>
<.icon name="hero-trash" class={@icon_size} />
</button>
</div>
</div>
<!-- Render children recursively -->
<%= for child <- @children do %>
<.category_item category={child} categories={@categories} depth={@depth + 1} />
<!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %>
<%= for child <- @children do %>
<.category_item category={child} categories={@categories} expanded_categories={@expanded_categories} depth={@depth + 1} />
<% end %>
<% end %>
</div>
"""
@@ -391,7 +483,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<!-- Recursive Category Tree -->
<%= for category <- root_categories(@categories) do %>
<div class="px-6 py-4">
<.category_item category={category} categories={@categories} depth={0} />
<.category_item category={category} categories={@categories} expanded_categories={@expanded_categories} depth={0} />
</div>
<% end %>
</div>

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>