refactor(elixir): hierarchical refactor
to extract common code patterns from category/storage location systems
This commit is contained in:
@@ -11,73 +11,25 @@ defmodule ComponentsElixir.Inventory do
|
||||
## Storage Locations
|
||||
|
||||
@doc """
|
||||
Returns the list of storage locations with computed hierarchy fields.
|
||||
Returns the list of storage locations with optimized parent preloading.
|
||||
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
|
||||
"""
|
||||
def list_storage_locations do
|
||||
# Get all locations with preloaded parents in a single query
|
||||
locations =
|
||||
StorageLocation
|
||||
|> order_by([sl], asc: sl.name)
|
||||
|> preload(:parent)
|
||||
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|
||||
|> Repo.all()
|
||||
|
||||
# Compute hierarchy fields for all locations efficiently
|
||||
processed_locations =
|
||||
compute_hierarchy_fields_batch(locations)
|
||||
|> Enum.sort_by(&{&1.level, &1.name})
|
||||
|
||||
# Ensure AprilTag SVGs exist for all locations
|
||||
spawn(fn ->
|
||||
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
|
||||
end)
|
||||
|
||||
processed_locations
|
||||
locations
|
||||
end
|
||||
|
||||
# Efficient batch computation of hierarchy fields
|
||||
defp compute_hierarchy_fields_batch(locations) do
|
||||
# Create a map for quick parent lookup to avoid N+1 queries
|
||||
location_map = Map.new(locations, fn loc -> {loc.id, loc} end)
|
||||
|
||||
Enum.map(locations, fn location ->
|
||||
level = compute_level_efficient(location, location_map, 0)
|
||||
path = compute_path_efficient(location, location_map, 0)
|
||||
|
||||
%{location | level: level, path: path}
|
||||
end)
|
||||
end
|
||||
|
||||
defp compute_level_efficient(%{parent_id: nil}, _location_map, _depth), do: 0
|
||||
|
||||
defp compute_level_efficient(%{parent_id: parent_id}, location_map, depth) when depth < 10 do
|
||||
case Map.get(location_map, parent_id) do
|
||||
# Orphaned record
|
||||
nil -> 0
|
||||
parent -> 1 + compute_level_efficient(parent, location_map, depth + 1)
|
||||
end
|
||||
end
|
||||
|
||||
# Prevent infinite recursion
|
||||
defp compute_level_efficient(_location, _location_map, _depth), do: 0
|
||||
|
||||
defp compute_path_efficient(%{parent_id: nil, name: name}, _location_map, _depth), do: name
|
||||
|
||||
defp compute_path_efficient(%{parent_id: parent_id, name: name}, location_map, depth)
|
||||
when depth < 10 do
|
||||
case Map.get(location_map, parent_id) do
|
||||
# Orphaned record
|
||||
nil ->
|
||||
name
|
||||
|
||||
parent ->
|
||||
parent_path = compute_path_efficient(parent, location_map, depth + 1)
|
||||
"#{parent_path}/#{name}"
|
||||
end
|
||||
end
|
||||
|
||||
# Prevent infinite recursion
|
||||
defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name
|
||||
|
||||
@doc """
|
||||
Returns the list of root storage locations (no parent).
|
||||
"""
|
||||
@@ -89,37 +41,12 @@ defmodule ComponentsElixir.Inventory do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single storage location with computed hierarchy fields.
|
||||
Gets a single storage location with preloaded associations.
|
||||
"""
|
||||
def get_storage_location!(id) do
|
||||
location =
|
||||
StorageLocation
|
||||
|> preload(:parent)
|
||||
|> Repo.get!(id)
|
||||
|
||||
# Compute hierarchy fields
|
||||
level = compute_level_for_single(location)
|
||||
path = compute_path_for_single(location)
|
||||
%{location | level: level, path: path}
|
||||
end
|
||||
|
||||
# Simple computation for single location (allows DB queries)
|
||||
defp compute_level_for_single(%{parent_id: nil}), do: 0
|
||||
|
||||
defp compute_level_for_single(%{parent_id: parent_id}) do
|
||||
case Repo.get(StorageLocation, parent_id) do
|
||||
nil -> 0
|
||||
parent -> 1 + compute_level_for_single(parent)
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_path_for_single(%{parent_id: nil, name: name}), do: name
|
||||
|
||||
defp compute_path_for_single(%{parent_id: parent_id, name: name}) do
|
||||
case Repo.get(StorageLocation, parent_id) do
|
||||
nil -> name
|
||||
parent -> "#{compute_path_for_single(parent)}/#{name}"
|
||||
end
|
||||
StorageLocation
|
||||
|> preload(:parent)
|
||||
|> Repo.get!(id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -130,15 +57,6 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> where([sl], sl.apriltag_id == ^apriltag_id)
|
||||
|> preload(:parent)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
location ->
|
||||
level = compute_level_for_single(location)
|
||||
path = compute_path_for_single(location)
|
||||
%{location | level: level, path: path}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -221,7 +139,7 @@ defmodule ComponentsElixir.Inventory do
|
||||
def compute_storage_location_path(nil), do: nil
|
||||
|
||||
def compute_storage_location_path(%StorageLocation{} = location) do
|
||||
compute_path_for_single(location)
|
||||
StorageLocation.full_path(location)
|
||||
end
|
||||
|
||||
# Convert string keys to atoms for consistency
|
||||
@@ -239,11 +157,12 @@ defmodule ComponentsElixir.Inventory do
|
||||
## Categories
|
||||
|
||||
@doc """
|
||||
Returns the list of categories.
|
||||
Returns the list of categories with optimized parent preloading.
|
||||
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
|
||||
"""
|
||||
def list_categories do
|
||||
Category
|
||||
|> preload(:parent)
|
||||
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule ComponentsElixir.Inventory.Category do
|
||||
Categories can be hierarchical with parent-child relationships.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias ComponentsElixir.Inventory.{Category, Component}
|
||||
@@ -34,11 +35,20 @@ defmodule ComponentsElixir.Inventory.Category do
|
||||
@doc """
|
||||
Returns the full path of the category including parent names.
|
||||
"""
|
||||
def full_path(%Category{parent: nil} = category), do: category.name
|
||||
def full_path(%Category{parent: %Category{} = parent} = category) do
|
||||
"#{full_path(parent)} > #{category.name}"
|
||||
end
|
||||
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
|
||||
category.name
|
||||
@impl true
|
||||
def full_path(%Category{} = category) do
|
||||
Hierarchical.full_path(category, &(&1.parent), path_separator())
|
||||
end
|
||||
|
||||
@impl true
|
||||
def parent(%Category{parent: parent}), do: parent
|
||||
|
||||
@impl true
|
||||
def children(%Category{children: children}), do: children
|
||||
|
||||
@impl true
|
||||
def path_separator(), do: " > "
|
||||
|
||||
@impl true
|
||||
def entity_type(), do: :category
|
||||
end
|
||||
|
||||
252
lib/components_elixir/inventory/hierarchical.ex
Normal file
252
lib/components_elixir/inventory/hierarchical.ex
Normal file
@@ -0,0 +1,252 @@
|
||||
defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
@moduledoc """
|
||||
Shared hierarchical behavior for entities with parent-child relationships.
|
||||
|
||||
This module provides common functionality for:
|
||||
- Path computation (e.g., "Parent > Child > Grandchild")
|
||||
- Cycle detection and prevention
|
||||
- Parent/child filtering for UI dropdowns
|
||||
- Tree traversal utilities
|
||||
|
||||
Based on the elegant category implementation approach.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Computes full hierarchical path for an entity.
|
||||
Uses recursive traversal of parent chain, loading parents from database if needed.
|
||||
Optimized to minimize database queries by trying preloaded associations first.
|
||||
|
||||
## Examples
|
||||
iex> category = %Category{name: "Resistors", parent: %Category{name: "Electronics", parent: nil}}
|
||||
iex> Hierarchical.full_path(category, &(&1.parent))
|
||||
"Electronics > Resistors"
|
||||
"""
|
||||
def full_path(entity, parent_accessor_fn, separator \\ " > ")
|
||||
|
||||
def full_path(nil, _parent_accessor_fn, _separator), do: ""
|
||||
|
||||
def full_path(entity, parent_accessor_fn, separator) do
|
||||
case parent_accessor_fn.(entity) do
|
||||
nil ->
|
||||
entity.name
|
||||
%Ecto.Association.NotLoaded{} ->
|
||||
# Parent not loaded - fall back to database lookup
|
||||
# This is a fallback and should be rare if preloading is done correctly
|
||||
build_path_with_db_lookup(entity, separator)
|
||||
parent ->
|
||||
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to build path when parent associations are not loaded
|
||||
# This is optimized to minimize database queries
|
||||
defp build_path_with_db_lookup(entity, separator) do
|
||||
# Build path by walking up the parent chain via database queries
|
||||
# Collect parent names from root to leaf
|
||||
path_parts = collect_path_from_root(entity, [])
|
||||
Enum.join(path_parts, separator)
|
||||
end
|
||||
|
||||
defp collect_path_from_root(entity, path_so_far) do
|
||||
case entity.parent_id do
|
||||
nil ->
|
||||
# This is a root entity, add its name and return the complete path
|
||||
[entity.name | path_so_far]
|
||||
parent_id ->
|
||||
# Load parent from database
|
||||
case load_parent_entity(entity, parent_id) do
|
||||
nil ->
|
||||
# Parent not found (orphaned record), treat this as root
|
||||
[entity.name | path_so_far]
|
||||
parent ->
|
||||
# Recursively get the path from the parent, then add current entity
|
||||
collect_path_from_root(parent, [entity.name | path_so_far])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp load_parent_entity(%{__struct__: module} = _entity, parent_id) do
|
||||
# Note: This function makes individual database queries
|
||||
# For better performance, consider preloading parent associations properly
|
||||
# or implementing batch loading if this becomes a bottleneck
|
||||
ComponentsElixir.Repo.get(module, parent_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters entities to remove circular reference options for parent selection.
|
||||
Prevents an entity from being its own ancestor.
|
||||
|
||||
## Examples
|
||||
iex> categories = [%{id: 1, parent_id: nil}, %{id: 2, parent_id: 1}]
|
||||
iex> Hierarchical.filter_parent_options(categories, 1, &(&1.id), &(&1.parent_id))
|
||||
[%{id: 2, parent_id: 1}] # ID 1 filtered out (self-reference)
|
||||
"""
|
||||
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn)
|
||||
|
||||
def filter_parent_options(entities, nil, _id_accessor_fn, _parent_id_accessor_fn) do
|
||||
entities
|
||||
end
|
||||
|
||||
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn) do
|
||||
entities
|
||||
|> Enum.reject(fn entity ->
|
||||
entity_id = id_accessor_fn.(entity)
|
||||
|
||||
# Remove self-reference
|
||||
entity_id == editing_entity_id ||
|
||||
# Remove descendants (they would create a cycle)
|
||||
is_descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an entity is a descendant of an ancestor entity.
|
||||
Used for cycle detection in parent selection.
|
||||
"""
|
||||
def is_descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
|
||||
descendant = Enum.find(entities, fn e -> e.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
entity -> is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn)
|
||||
end
|
||||
end
|
||||
|
||||
defp is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn) do
|
||||
case parent_id_accessor_fn.(entity) do
|
||||
nil -> false
|
||||
^ancestor_id -> true
|
||||
parent_id ->
|
||||
parent = Enum.find(entities, fn e -> e.id == parent_id end)
|
||||
case parent do
|
||||
nil -> false
|
||||
parent_entity -> is_descendant_recursive(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all root entities (entities with no parent).
|
||||
"""
|
||||
def root_entities(entities, parent_id_accessor_fn) do
|
||||
Enum.filter(entities, fn entity ->
|
||||
is_nil(parent_id_accessor_fn.(entity))
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all child entities of a specific parent.
|
||||
"""
|
||||
def child_entities(entities, parent_id, parent_id_accessor_fn) do
|
||||
Enum.filter(entities, fn entity ->
|
||||
parent_id_accessor_fn.(entity) == parent_id
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates display name for entity including parent context.
|
||||
For dropdown displays: "Parent > Child"
|
||||
"""
|
||||
def display_name(entity, parent_accessor_fn, separator \\ " > ") do
|
||||
full_path(entity, parent_accessor_fn, separator)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates options for a parent selection dropdown.
|
||||
Includes proper filtering to prevent cycles and formatted display names.
|
||||
Results are sorted hierarchically for intuitive navigation.
|
||||
"""
|
||||
def parent_select_options(entities, editing_entity_id, parent_accessor_fn, nil_option_text \\ "No parent") do
|
||||
available_entities =
|
||||
filter_parent_options(
|
||||
entities,
|
||||
editing_entity_id,
|
||||
&(&1.id),
|
||||
&(&1.parent_id)
|
||||
)
|
||||
|> sort_hierarchically(&(&1.parent_id))
|
||||
|> Enum.map(fn entity ->
|
||||
{display_name(entity, parent_accessor_fn), entity.id}
|
||||
end)
|
||||
|
||||
[{nil_option_text, nil}] ++ available_entities
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates options for a general selection dropdown (like filters).
|
||||
Results are sorted hierarchically for intuitive navigation.
|
||||
"""
|
||||
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
|
||||
sorted_entities =
|
||||
entities
|
||||
|> sort_hierarchically(&(&1.parent_id))
|
||||
|> Enum.map(fn entity ->
|
||||
{display_name(entity, parent_accessor_fn), entity.id}
|
||||
end)
|
||||
|
||||
if nil_option_text do
|
||||
[{nil_option_text, nil}] ++ sorted_entities
|
||||
else
|
||||
sorted_entities
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Computes the depth/level of an entity in the hierarchy.
|
||||
Root entities have level 0.
|
||||
"""
|
||||
def compute_level(entity, parent_accessor_fn) do
|
||||
case parent_accessor_fn.(entity) do
|
||||
nil -> 0
|
||||
%Ecto.Association.NotLoaded{} -> 0
|
||||
parent -> 1 + compute_level(parent, parent_accessor_fn)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the separator string used for a specific entity type.
|
||||
Categories use " > " while storage locations use " / ".
|
||||
"""
|
||||
def separator_for(:category), do: " > "
|
||||
def separator_for(:storage_location), do: " / "
|
||||
def separator_for(_), do: " > "
|
||||
|
||||
@doc """
|
||||
Sorts entities hierarchically in depth-first order.
|
||||
Each parent is followed immediately by all its children (recursively).
|
||||
Within each level, entities are sorted alphabetically by name.
|
||||
|
||||
## Examples
|
||||
iex> entities = [
|
||||
...> %{id: 1, name: "Resistors", parent_id: nil},
|
||||
...> %{id: 2, name: "Wire", parent_id: 1},
|
||||
...> %{id: 3, name: "Capacitors", parent_id: nil},
|
||||
...> %{id: 4, name: "Ceramic", parent_id: 3}
|
||||
...> ]
|
||||
iex> Hierarchical.sort_hierarchically(entities, &(&1.parent_id))
|
||||
# Returns: [Capacitors, Capacitors>Ceramic, Resistors, Resistors>Wire]
|
||||
"""
|
||||
def sort_hierarchically(entities, parent_id_accessor_fn) do
|
||||
# First, get all root entities sorted alphabetically
|
||||
root_entities =
|
||||
entities
|
||||
|> root_entities(parent_id_accessor_fn)
|
||||
|> Enum.sort_by(& &1.name)
|
||||
|
||||
# Then recursively add children after each parent
|
||||
Enum.flat_map(root_entities, fn root ->
|
||||
[root | sort_children_recursively(entities, root.id, parent_id_accessor_fn)]
|
||||
end)
|
||||
end
|
||||
|
||||
defp sort_children_recursively(entities, parent_id, parent_id_accessor_fn) do
|
||||
children =
|
||||
entities
|
||||
|> child_entities(parent_id, parent_id_accessor_fn)
|
||||
|> Enum.sort_by(& &1.name)
|
||||
|
||||
Enum.flat_map(children, fn child ->
|
||||
[child | sort_children_recursively(entities, child.id, parent_id_accessor_fn)]
|
||||
end)
|
||||
end
|
||||
end
|
||||
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal file
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule ComponentsElixir.Inventory.HierarchicalSchema do
|
||||
@moduledoc """
|
||||
Behaviour for schemas that implement hierarchical relationships.
|
||||
|
||||
Provides a contract for entities with parent-child relationships,
|
||||
ensuring consistent interface across different hierarchical entities.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns the full hierarchical path as a string.
|
||||
Example: "Electronics > Components > Resistors"
|
||||
"""
|
||||
@callback full_path(struct()) :: String.t()
|
||||
|
||||
@doc """
|
||||
Returns the parent entity or nil if this is a root entity.
|
||||
"""
|
||||
@callback parent(struct()) :: struct() | nil
|
||||
|
||||
@doc """
|
||||
Returns the children entities as a list.
|
||||
"""
|
||||
@callback children(struct()) :: [struct()]
|
||||
|
||||
@doc """
|
||||
Returns the separator used for path display.
|
||||
"""
|
||||
@callback path_separator() :: String.t()
|
||||
|
||||
@doc """
|
||||
Returns the entity type for use with the Hierarchical module.
|
||||
"""
|
||||
@callback entity_type() :: atom()
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour ComponentsElixir.Inventory.HierarchicalSchema
|
||||
alias ComponentsElixir.Inventory.Hierarchical
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,10 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
Schema for storage locations with hierarchical organization.
|
||||
|
||||
Storage locations can be nested (shelf -> drawer -> box) and each
|
||||
has a unique QR code for quick scanning and identification.
|
||||
has a unique AprilTag for quick scanning and identification.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
@@ -16,10 +17,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
field :description, :string
|
||||
field :apriltag_id, :integer
|
||||
|
||||
# Computed/virtual fields - not stored in database
|
||||
field :level, :integer, virtual: true
|
||||
field :path, :string, virtual: true
|
||||
|
||||
# Only parent relationship is stored
|
||||
belongs_to :parent, StorageLocation
|
||||
has_many :children, StorageLocation, foreign_key: :parent_id
|
||||
@@ -37,47 +34,26 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
|> validate_length(:description, max: 500)
|
||||
|> validate_apriltag_id()
|
||||
|> foreign_key_constraint(:parent_id)
|
||||
|> validate_no_circular_reference()
|
||||
|> put_apriltag_id()
|
||||
end
|
||||
|
||||
# Prevent circular references (location being its own ancestor)
|
||||
defp validate_no_circular_reference(changeset) do
|
||||
case get_change(changeset, :parent_id) do
|
||||
nil -> changeset
|
||||
parent_id ->
|
||||
location_id = changeset.data.id
|
||||
if location_id && would_create_cycle?(location_id, parent_id) do
|
||||
add_error(changeset, :parent_id, "cannot be a descendant of this location")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
# HierarchicalSchema implementations
|
||||
@impl true
|
||||
def full_path(%StorageLocation{} = storage_location) do
|
||||
Hierarchical.full_path(storage_location, &(&1.parent), path_separator())
|
||||
end
|
||||
|
||||
defp would_create_cycle?(location_id, parent_id) do
|
||||
# Check if parent_id is the same as location_id or any of its descendants
|
||||
location_id == parent_id or
|
||||
(parent_id && is_descendant_of?(parent_id, location_id))
|
||||
end
|
||||
@impl true
|
||||
def parent(%StorageLocation{parent: parent}), do: parent
|
||||
|
||||
defp is_descendant_of?(potential_descendant, ancestor_id) do
|
||||
case ComponentsElixir.Repo.get(StorageLocation, potential_descendant) do
|
||||
nil -> false
|
||||
%{parent_id: nil} -> false
|
||||
%{parent_id: ^ancestor_id} -> true
|
||||
%{parent_id: parent_id} -> is_descendant_of?(parent_id, ancestor_id)
|
||||
end
|
||||
end
|
||||
@impl true
|
||||
def children(%StorageLocation{children: children}), do: children
|
||||
|
||||
@doc """
|
||||
Returns the full hierarchical path as a human-readable string.
|
||||
"""
|
||||
def full_path(storage_location) do
|
||||
storage_location.path
|
||||
|> String.split("/")
|
||||
|> Enum.join(" → ")
|
||||
end
|
||||
@impl true
|
||||
def path_separator(), do: " / "
|
||||
|
||||
@impl true
|
||||
def entity_type(), do: :storage_location
|
||||
|
||||
@doc """
|
||||
Returns the AprilTag format for this storage location.
|
||||
@@ -102,28 +78,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
end
|
||||
end
|
||||
|
||||
# Compute the hierarchy level based on parent chain
|
||||
def compute_level(%StorageLocation{parent_id: nil}), do: 0
|
||||
def compute_level(%StorageLocation{parent: %StorageLocation{} = parent}) do
|
||||
compute_level(parent) + 1
|
||||
end
|
||||
def compute_level(%StorageLocation{parent_id: parent_id}) when not is_nil(parent_id) do
|
||||
# Parent not loaded, fetch it
|
||||
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
|
||||
compute_level(parent) + 1
|
||||
end
|
||||
|
||||
# Compute the full path based on parent chain
|
||||
def compute_path(%StorageLocation{name: name, parent_id: nil}), do: name
|
||||
def compute_path(%StorageLocation{name: name, parent: %StorageLocation{} = parent}) do
|
||||
"#{compute_path(parent)}/#{name}"
|
||||
end
|
||||
def compute_path(%StorageLocation{name: name, parent_id: parent_id}) when not is_nil(parent_id) do
|
||||
# Parent not loaded, fetch it
|
||||
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
|
||||
"#{compute_path(parent)}/#{name}"
|
||||
end
|
||||
|
||||
defp get_next_available_apriltag_id do
|
||||
# Get all used AprilTag IDs
|
||||
used_ids = ComponentsElixir.Repo.all(
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.Category
|
||||
alias ComponentsElixir.Inventory.{Category, Hierarchical}
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
@@ -121,45 +121,20 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
end
|
||||
|
||||
defp parent_category_options(categories, editing_category_id \\ nil) do
|
||||
available_categories =
|
||||
categories
|
||||
|> Enum.reject(fn cat ->
|
||||
cat.id == editing_category_id ||
|
||||
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id))
|
||||
end)
|
||||
|> Enum.map(fn category ->
|
||||
{category_display_name(category), category.id}
|
||||
end)
|
||||
|
||||
[{"No parent (Root category)", nil}] ++ available_categories
|
||||
end
|
||||
|
||||
defp is_descendant?(categories, descendant_id, ancestor_id) do
|
||||
# Check if descendant_id is a descendant of ancestor_id
|
||||
descendant = Enum.find(categories, fn cat -> cat.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
%{parent_id: nil} -> false
|
||||
%{parent_id: parent_id} when parent_id == ancestor_id -> true
|
||||
%{parent_id: parent_id} -> is_descendant?(categories, parent_id, ancestor_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp category_display_name(category) do
|
||||
if category.parent do
|
||||
"#{category.parent.name} > #{category.name}"
|
||||
else
|
||||
category.name
|
||||
end
|
||||
Hierarchical.parent_select_options(
|
||||
categories,
|
||||
editing_category_id,
|
||||
&(&1.parent),
|
||||
"No parent (Root category)"
|
||||
)
|
||||
end
|
||||
|
||||
defp root_categories(categories) do
|
||||
Enum.filter(categories, fn cat -> is_nil(cat.parent_id) end)
|
||||
Hierarchical.root_entities(categories, &(&1.parent_id))
|
||||
end
|
||||
|
||||
defp child_categories(categories, parent_id) do
|
||||
Enum.filter(categories, fn cat -> cat.parent_id == parent_id end)
|
||||
Hierarchical.child_entities(categories, parent_id, &(&1.parent_id))
|
||||
end
|
||||
|
||||
defp count_components_in_category(category_id) do
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.Component
|
||||
alias ComponentsElixir.Inventory.{Component, Category, StorageLocation, Hierarchical}
|
||||
|
||||
@items_per_page 20
|
||||
|
||||
@@ -430,31 +430,15 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
end
|
||||
|
||||
defp category_options(categories) do
|
||||
[{"Select a category", nil}] ++
|
||||
Enum.map(categories, fn category ->
|
||||
{category.name, category.id}
|
||||
end)
|
||||
Hierarchical.select_options(categories, &(&1.parent), "Select a category")
|
||||
end
|
||||
|
||||
defp storage_location_display_name(location) do
|
||||
# Use the computed path from Inventory context for full hierarchy, or fall back to location.path
|
||||
path = Inventory.compute_storage_location_path(location) || location.path
|
||||
|
||||
if path do
|
||||
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
|
||||
path
|
||||
|> String.split("/")
|
||||
|> Enum.join(" > ")
|
||||
else
|
||||
location.name
|
||||
end
|
||||
StorageLocation.full_path(location)
|
||||
end
|
||||
|
||||
defp storage_location_options(storage_locations) do
|
||||
[{"No storage location", nil}] ++
|
||||
Enum.map(storage_locations, fn location ->
|
||||
{storage_location_display_name(location), location.id}
|
||||
end)
|
||||
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location")
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -503,7 +487,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
@@ -525,13 +509,9 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
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="" selected={is_nil(@selected_category)}>All Categories</option>
|
||||
<%= for category <- @categories do %>
|
||||
<option value={category.id} selected={@selected_category == category.id}>
|
||||
<%= if category.parent do %>
|
||||
{category.parent.name} > {category.name}
|
||||
<% else %>
|
||||
{category.name}
|
||||
<% end %>
|
||||
<%= for {category_name, category_id} <- Hierarchical.select_options(@categories, &(&1.parent)) do %>
|
||||
<option value={category_id} selected={@selected_category == category_id}>
|
||||
{category_name}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
@@ -580,7 +560,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Add Component Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
@@ -714,7 +694,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Edit Component Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
@@ -858,7 +838,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Components List -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6">
|
||||
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||
@@ -918,7 +898,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Content area with image and details -->
|
||||
<div class="flex gap-6">
|
||||
<!-- Large Image -->
|
||||
@@ -944,7 +924,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1 space-y-4 select-text">
|
||||
<!-- Full Description -->
|
||||
@@ -956,7 +936,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<%= if component.storage_location do %>
|
||||
@@ -1013,7 +993,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
|
||||
<button
|
||||
@@ -1098,7 +1078,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Middle row: Description -->
|
||||
<%= if component.description do %>
|
||||
<div class="mt-1">
|
||||
@@ -1107,7 +1087,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Bottom row: Metadata -->
|
||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 text-sm text-base-content/60">
|
||||
<%= if component.storage_location do %>
|
||||
@@ -1135,7 +1115,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Keywords row -->
|
||||
<%= if component.keywords do %>
|
||||
<div class="mt-2">
|
||||
@@ -1218,7 +1198,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
>
|
||||
<!-- Background overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
|
||||
|
||||
|
||||
<!-- Modal content -->
|
||||
<div
|
||||
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
|
||||
@@ -1236,7 +1216,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 bg-base-100 rounded-b-lg">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -5,7 +5,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.StorageLocation
|
||||
alias ComponentsElixir.Inventory.{StorageLocation, Hierarchical}
|
||||
alias ComponentsElixir.AprilTag
|
||||
|
||||
@impl true
|
||||
@@ -240,48 +240,24 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
end
|
||||
|
||||
defp parent_location_options(storage_locations, editing_location_id \\ nil) do
|
||||
available_locations =
|
||||
storage_locations
|
||||
|> Enum.reject(fn loc ->
|
||||
loc.id == editing_location_id ||
|
||||
(editing_location_id && is_descendant?(storage_locations, loc.id, editing_location_id))
|
||||
end)
|
||||
|> Enum.map(fn location ->
|
||||
{location_display_name(location), location.id}
|
||||
end)
|
||||
|
||||
[{"No parent (Root location)", nil}] ++ available_locations
|
||||
end
|
||||
|
||||
defp is_descendant?(storage_locations, descendant_id, ancestor_id) do
|
||||
# Check if descendant_id is a descendant of ancestor_id
|
||||
descendant = Enum.find(storage_locations, fn loc -> loc.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
%{parent_id: nil} -> false
|
||||
%{parent_id: parent_id} when parent_id == ancestor_id -> true
|
||||
%{parent_id: parent_id} -> is_descendant?(storage_locations, parent_id, ancestor_id)
|
||||
end
|
||||
Hierarchical.parent_select_options(
|
||||
storage_locations,
|
||||
editing_location_id,
|
||||
&(&1.parent),
|
||||
"No parent (Root location)"
|
||||
)
|
||||
end
|
||||
|
||||
defp location_display_name(location) do
|
||||
if location.path do
|
||||
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
|
||||
location.path
|
||||
|> String.split("/")
|
||||
|> Enum.join(" > ")
|
||||
else
|
||||
location.name
|
||||
end
|
||||
StorageLocation.full_path(location)
|
||||
end
|
||||
|
||||
defp root_storage_locations(storage_locations) do
|
||||
Enum.filter(storage_locations, fn loc -> is_nil(loc.parent_id) end)
|
||||
Hierarchical.root_entities(storage_locations, &(&1.parent_id))
|
||||
end
|
||||
|
||||
defp child_storage_locations(storage_locations, parent_id) do
|
||||
Enum.filter(storage_locations, fn loc -> loc.parent_id == parent_id end)
|
||||
Hierarchical.child_entities(storage_locations, parent_id, &(&1.parent_id))
|
||||
end
|
||||
|
||||
defp count_components_in_location(location_id) do
|
||||
@@ -766,7 +742,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span>
|
||||
</div>
|
||||
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
|
||||
Level {scan.location.level}
|
||||
Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user