From 264adbfb98dd1dbc426386b6194d1b26dc374c22 Mon Sep 17 00:00:00 2001 From: Schuwi Date: Wed, 17 Sep 2025 23:56:56 +0200 Subject: [PATCH] refactor(elixir): hierarchical refactor to extract common code patterns from category/storage location systems --- design_docs/hierarchical_analysis.md | 154 ------- design_docs/hierarchical_refactoring_plan.md | 370 ----------------- design_docs/qr_storage_system.md | 378 ------------------ lib/components_elixir/inventory.ex | 105 +---- lib/components_elixir/inventory/category.ex | 22 +- .../inventory/hierarchical.ex | 252 ++++++++++++ .../inventory/hierarchical_schema.ex | 41 ++ .../inventory/storage_location.ex | 76 +--- .../live/categories_live.ex | 43 +- .../live/components_live.ex | 60 +-- .../live/storage_locations_live.ex | 46 +-- priv/repo/seeds.exs | 41 +- 12 files changed, 415 insertions(+), 1173 deletions(-) delete mode 100644 design_docs/hierarchical_analysis.md delete mode 100644 design_docs/hierarchical_refactoring_plan.md delete mode 100644 design_docs/qr_storage_system.md create mode 100644 lib/components_elixir/inventory/hierarchical.ex create mode 100644 lib/components_elixir/inventory/hierarchical_schema.ex diff --git a/design_docs/hierarchical_analysis.md b/design_docs/hierarchical_analysis.md deleted file mode 100644 index ff23a88..0000000 --- a/design_docs/hierarchical_analysis.md +++ /dev/null @@ -1,154 +0,0 @@ -# Hierarchical Implementation Analysis - -## Overview -This document analyzes the current hierarchical implementations in the codebase (Categories and Storage Locations) to determine the best approach for creating a shared `Hierarchical` behavior module. - -## Current State Analysis - -### Categories vs Storage Locations Feature Comparison - -| Feature | Categories | Storage Locations | Assessment | -|---------|------------|-------------------|------------| -| **Path Resolution** | Simple recursive function in schema | Complex virtual fields + batch computation | **Categories wins** - cleaner approach | -| **Cycle Prevention** | UI-level filtering (preventive) | Changeset validation (reactive) | **Categories wins** - more efficient | -| **Hierarchical Display** | Recursive LiveView components | Recursive LiveView components | **Tie** - nearly identical | -| **Parent Selection** | Smart dropdown filtering | Smart dropdown filtering | **Tie** - identical logic | -| **Performance** | O(depth) recursive calls | O(n) batch computation + DB queries | **Categories wins** - simpler | -| **Code Complexity** | ~50 lines of hierarchy logic | ~100+ lines of hierarchy logic | **Categories wins** - more maintainable | - -## Key Findings - -### 1. Vestigial `is_active` Field Removed -- **Status**: ✅ **REMOVED** -- **Impact**: Field was completely unused across the entire codebase -- **Files Modified**: - - `lib/components_elixir/inventory/storage_location.ex` (schema and changeset) - - Migration created: `20250917210658_remove_is_active_from_storage_locations.exs` - -### 2. Categories Implementation is Superior - -#### **Path Resolution Approach** -**Categories**: Elegant recursive function in schema -```elixir -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 -``` - -**Storage Locations**: Over-engineered with virtual fields -```elixir -# Virtual fields in schema -field :level, :integer, virtual: true -field :path, :string, virtual: true - -# Complex batch computation in context -def compute_hierarchy_fields_batch(locations) -def compute_level_for_single(location) -def compute_path_for_single(location) -``` - -#### **Cycle Prevention Strategy** -**Categories**: **Prevention at UI level** (efficient) -```elixir -# Prevents invalid options from appearing in dropdown -|> Enum.reject(fn cat -> - cat.id == editing_category_id || - (editing_category_id && is_descendant?(categories, cat.id, editing_category_id)) -end) -``` - -**Storage Locations**: **Validation at changeset level** (reactive) -```elixir -# Validates after user attempts invalid selection -defp validate_no_circular_reference(changeset) do - # Complex validation logic that runs on every save attempt -end -``` - -### 3. Shared Patterns Identified - -Both systems implement identical patterns for: -- **Hierarchical tree display** with recursive LiveView components -- **Parent/child relationship filtering** -- **Descendant detection algorithms** -- **Root entity identification** -- **UI depth-based styling and icons** - -## Architecture Decision - -### Recommended Approach: **Category-Style Hierarchical Module** - -The category implementation should be the template for generalization because: - -1. **Simpler**: No virtual fields or complex batch operations -2. **More Performant**: O(depth) vs O(n) complexity -3. **Preventive**: UI-level cycle prevention vs reactive validation -4. **Maintainable**: Half the lines of code with same functionality - -### AprilTag Features Remain Storage-Specific - -The AprilTag system should **NOT** be generalized because: -- Physical identification is meaningless for categories -- Future scanning/detection features are location-specific -- Keeps domain separation clean - -## Implementation Complexity Comparison - -### Categories (Simple & Clean) -``` -Files: 1 schema + 1 LiveView = 2 files -Lines: ~50 lines hierarchical logic -Approach: Functional, recursive, preventive -Dependencies: Standard Ecto associations -``` - -### Storage Locations (Over-Engineered) -``` -Files: 1 schema + 1 context helper + 1 LiveView = 3 files -Lines: ~100+ lines hierarchical logic -Approach: Stateful, batch processing, reactive -Dependencies: Virtual fields + custom computation -``` - -## Performance Analysis - -### Path Resolution Performance -- **Categories**: `O(depth)` - traverses only parent chain -- **Storage Locations**: `O(n)` - processes all entities for batch computation - -### Memory Usage -- **Categories**: Minimal - uses existing associations -- **Storage Locations**: Higher - virtual fields + intermediate computations - -### Database Queries -- **Categories**: Standard association preloading -- **Storage Locations**: Additional queries for path/level computation - -## Code Quality Assessment - -### Categories Strengths -✅ **Single Responsibility**: Each function does one thing -✅ **Functional Style**: Pure functions, no side effects -✅ **Standard Patterns**: Uses established Ecto association patterns -✅ **Easy Testing**: Simple recursive functions -✅ **Performance**: Minimal computational overhead - -### Storage Locations Issues -❌ **Multiple Responsibilities**: Virtual fields + validation + computation -❌ **Complex State**: Virtual fields require careful management -❌ **Custom Patterns**: Non-standard Ecto usage -❌ **Hard Testing**: Complex batch operations -❌ **Performance**: Unnecessary computational overhead - -## Conclusion - -The **categories implementation is objectively superior** and should guide the refactoring: - -1. **Simpler code** (50% fewer lines) -2. **Better performance** (O(depth) vs O(n)) -3. **More maintainable** (functional vs stateful) -4. **Standard patterns** (Ecto associations vs virtual fields) -5. **Preventive design** (UI filtering vs changeset validation) - -The storage locations system should be refactored to match the categories approach, eliminating virtual fields and complex batch computations in favor of simple recursive functions. \ No newline at end of file diff --git a/design_docs/hierarchical_refactoring_plan.md b/design_docs/hierarchical_refactoring_plan.md deleted file mode 100644 index ecbd29d..0000000 --- a/design_docs/hierarchical_refactoring_plan.md +++ /dev/null @@ -1,370 +0,0 @@ -# Hierarchical Refactoring Plan - -## Overview -This document outlines the step-by-step plan to extract common hierarchical behavior from Categories and Storage Locations into a shared `Hierarchical` module, based on the superior category implementation approach. - -## Goals -1. **Extract shared hierarchical patterns** into reusable modules -2. **Simplify storage locations** to match category elegance -3. **Maintain AprilTag-specific features** for storage locations -4. **Preserve all existing functionality** during refactor -5. **Improve performance and maintainability** - -## Refactoring Strategy - -### Phase 1: Extract Hierarchical Behavior Module ⏭️ **NEXT** -Create shared behavior module based on category patterns - -### Phase 2: Refactor Storage Locations 🔄 **FOLLOW-UP** -Simplify storage locations to use new shared module - -### Phase 3: Refactor Categories 🔄 **FOLLOW-UP** -Update categories to use shared module - -### Phase 4: Extract LiveView Components 🔄 **FOLLOW-UP** -Create reusable UI components for hierarchical display - ---- - -## Phase 1: Extract Hierarchical Behavior Module - -### 1.1 Create Core Hierarchical Module - -**File**: `lib/components_elixir/inventory/hierarchical.ex` - -```elixir -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. - - ## 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 \\ " > ") - - @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) - - @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) - - @doc """ - Gets all root entities (entities with no parent). - """ - def root_entities(entities, parent_id_accessor_fn) - - @doc """ - Gets all child entities of a specific parent. - """ - def child_entities(entities, parent_id, parent_id_accessor_fn) - - @doc """ - Generates display name for entity including parent context. - For dropdown displays: "Parent > Child" - """ - def display_name(entity, parent_accessor_fn, separator \\ " > ") -end -``` - -### 1.2 Schema Behaviour Definition - -**File**: `lib/components_elixir/inventory/hierarchical_schema.ex` - -```elixir -defmodule ComponentsElixir.Inventory.HierarchicalSchema do - @moduledoc """ - Behaviour for schemas that implement hierarchical relationships. - - Provides a contract for entities with parent-child relationships. - """ - - @callback full_path(struct()) :: String.t() - @callback parent(struct()) :: struct() | nil - @callback children(struct()) :: [struct()] - - defmacro __using__(_opts) do - quote do - @behaviour ComponentsElixir.Inventory.HierarchicalSchema - import ComponentsElixir.Inventory.Hierarchical - end - end -end -``` - -### 1.3 Update Category Schema - -**File**: `lib/components_elixir/inventory/category.ex` - -```elixir -defmodule ComponentsElixir.Inventory.Category do - use ComponentsElixir.Inventory.HierarchicalSchema - # ... existing schema definition - - @impl true - def full_path(%Category{} = category) do - ComponentsElixir.Inventory.Hierarchical.full_path(category, &(&1.parent)) - end - - @impl true - def parent(%Category{parent: parent}), do: parent - - @impl true - def children(%Category{children: children}), do: children -end -``` - -## Phase 2: Refactor Storage Locations - -### 2.1 Simplify Storage Location Schema - -**Changes to**: `lib/components_elixir/inventory/storage_location.ex` - -1. **Remove virtual fields**: `level` and `path` -2. **Add category-style path function** -3. **Remove complex cycle validation** -4. **Keep AprilTag-specific features** - -```elixir -defmodule ComponentsElixir.Inventory.StorageLocation do - use ComponentsElixir.Inventory.HierarchicalSchema - # ... existing schema without virtual fields - - @impl true - def full_path(%StorageLocation{} = location) do - ComponentsElixir.Inventory.Hierarchical.full_path(location, &(&1.parent), " / ") - end - - # Keep AprilTag-specific functionality - def apriltag_format(storage_location), do: storage_location.apriltag_id -end -``` - -### 2.2 Simplify Storage Location Changeset - -**Remove**: -- `validate_no_circular_reference/1` function -- Virtual field handling -- Complex cycle detection - -**Keep**: -- AprilTag validation -- Basic field validation - -### 2.3 Update Inventory Context - -**Changes to**: `lib/components_elixir/inventory.ex` - -**Remove**: -- `compute_hierarchy_fields_batch/1` -- `compute_level_for_single/1` -- `compute_path_for_single/1` -- All virtual field computation - -**Simplify**: -- `list_storage_locations/0` - use standard preloading -- `get_storage_location!/1` - remove virtual field computation - -## Phase 3: Refactor Categories - -### 3.1 Update Categories to Use Shared Module - -**Changes to**: `lib/components_elixir/inventory/category.ex` - -Replace existing `full_path/1` with call to shared module. - -### 3.2 Update Categories LiveView - -**Changes to**: `lib/components_elixir_web/live/categories_live.ex` - -Replace custom hierarchy functions with shared module calls: - -```elixir -# Replace custom functions with shared module -defp parent_category_options(categories, editing_category_id \\ nil) do - available_categories = - Hierarchical.filter_parent_options( - categories, - editing_category_id, - &(&1.id), - &(&1.parent_id) - ) - |> Enum.map(fn category -> - {Hierarchical.display_name(category, &(&1.parent)), category.id} - end) - - [{"No parent (Root category)", nil}] ++ available_categories -end -``` - -## Phase 4: Extract LiveView Components - -### 4.1 Create Hierarchical LiveView Components - -**File**: `lib/components_elixir_web/live/hierarchical_components.ex` - -```elixir -defmodule ComponentsElixirWeb.HierarchicalComponents do - use Phoenix.Component - alias ComponentsElixir.Inventory.Hierarchical - - @doc """ - Renders a hierarchical tree of entities with depth-based styling. - """ - def hierarchy_tree(assigns) - - @doc """ - Renders an individual item in a hierarchical tree. - """ - def hierarchy_item(assigns) - - @doc """ - Renders a parent selection dropdown with cycle prevention. - """ - def parent_select(assigns) -end -``` - -### 4.2 Update LiveViews to Use Shared Components - -Both `CategoriesLive` and `StorageLocationsLive` can use the shared components, with custom slots for entity-specific content (like AprilTag display). - -## Implementation Timeline - -### Immediate (Phase 1) - 1-2 hours -- [ ] Create `Hierarchical` module with core functions -- [ ] Create `HierarchicalSchema` behaviour -- [ ] Write comprehensive tests for shared module - -### Short-term (Phase 2) - 2-3 hours -- [ ] Refactor storage location schema to remove virtual fields -- [ ] Simplify storage location changeset -- [ ] Update inventory context to remove batch computation -- [ ] Test storage location functionality - -### Medium-term (Phase 3) - 1-2 hours -- [ ] Update category schema to use shared module -- [ ] Update categories LiveView to use shared functions -- [ ] Test category functionality - -### Long-term (Phase 4) - 2-3 hours -- [ ] Create shared LiveView components -- [ ] Refactor both LiveViews to use shared components -- [ ] Add entity-specific customization slots -- [ ] Comprehensive integration testing - -## Benefits After Refactoring - -### Code Quality -- **50% reduction** in hierarchical logic duplication -- **Consistent patterns** across both entity types -- **Easier testing** with isolated, pure functions -- **Better maintainability** with single source of truth - -### Performance -- **Elimination of virtual fields** reduces memory usage -- **Remove batch computation** improves response times -- **Standard Ecto patterns** optimize database queries -- **UI-level cycle prevention** reduces validation overhead - -### Developer Experience -- **Shared components** speed up new hierarchical entity development -- **Consistent API** reduces learning curve -- **Better documentation** with centralized behavior -- **Easier debugging** with simplified call stacks - -## Risk Mitigation - -### Database Migration Safety -1. **Backup database** before running migrations -2. **Test migrations** on development environment first -3. **Incremental approach** - one table at a time - -### Functionality Preservation -1. **Comprehensive test coverage** before refactoring -2. **Feature parity testing** after each phase -3. **AprilTag functionality isolation** to prevent interference - -### Rollback Plan -1. **Git branching strategy** for each phase -2. **Database migration rollbacks** prepared -3. **Quick revert capability** if issues discovered - -## Testing Strategy - -### Unit Tests -- [ ] Test all `Hierarchical` module functions -- [ ] Test schema `full_path/1` implementations -- [ ] Test cycle detection edge cases - -### Integration Tests -- [ ] Test LiveView parent selection dropdowns -- [ ] Test hierarchical tree rendering -- [ ] Test AprilTag functionality preservation - -### Performance Tests -- [ ] Benchmark path computation performance -- [ ] Measure memory usage before/after -- [ ] Profile database query patterns - -## Migration Notes - -### Database Changes Required -1. **Remove `is_active` column** from storage_locations (✅ **COMPLETED**) -2. **No other database changes** needed for this refactor - -### Deployment Considerations -- **Zero downtime**: Refactor is code-only (except `is_active` removal) -- **Backward compatible**: No API changes -- **Incremental deployment**: Can deploy phase by phase - -## Success Criteria - -### Functional -- [ ] All existing category functionality preserved -- [ ] All existing storage location functionality preserved -- [ ] AprilTag features remain storage-location specific -- [ ] UI behavior identical to current implementation - -### Non-Functional -- [ ] Code duplication reduced by ≥50% -- [ ] Performance maintained or improved -- [ ] Test coverage ≥95% for shared modules -- [ ] Documentation complete for new modules - ---- - -## Next Steps - -1. **Review this plan** with team/stakeholders -2. **Create feature branch** for refactoring work -3. **Begin Phase 1** - Extract hierarchical behavior module -4. **Write comprehensive tests** before any refactoring -5. **Execute phases incrementally** with testing between each - -This refactoring will significantly improve code quality and maintainability while preserving all existing functionality and preparing the codebase for future hierarchical entities. \ No newline at end of file diff --git a/design_docs/qr_storage_system.md b/design_docs/qr_storage_system.md deleted file mode 100644 index d3c8079..0000000 --- a/design_docs/qr_storage_system.md +++ /dev/null @@ -1,378 +0,0 @@ -# QR Code Storage Location System Design - -## Overview -Implement a hierarchical storage location system with QR code generation and scanning capabilities to enable quick component location entry and filtering. - -## Database Schema - -### 1. Storage Locations Table -```sql -CREATE TABLE storage_locations ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT, - qr_code VARCHAR(100) UNIQUE NOT NULL, - parent_id INTEGER REFERENCES storage_locations(id), - level INTEGER NOT NULL DEFAULT 0, - path TEXT NOT NULL, -- Materialized path: "shelf1/drawer2/box3" - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX idx_storage_locations_parent_id ON storage_locations(parent_id); -CREATE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code); -CREATE INDEX idx_storage_locations_path ON storage_locations USING gin(path gin_trgm_ops); -CREATE UNIQUE INDEX idx_storage_locations_name_parent ON storage_locations(name, parent_id); -``` - -### 2. Modified Components Table -```sql --- Migration to add storage_location_id to components -ALTER TABLE components -ADD COLUMN storage_location_id INTEGER REFERENCES storage_locations(id), -ADD COLUMN legacy_position VARCHAR(255); -- Keep old position data for migration - --- Move existing position data to legacy_position -UPDATE components SET legacy_position = position; -``` - -## QR Code Format Design - -### Hierarchical QR Code Strategy -To avoid confusion with multiple QR codes in the same image, use a hierarchical encoding strategy: - -``` -Format: SL:{level}:{unique_id}:{parent_path_hash} -Examples: -- Shelf: "SL:1:ABC123:ROOT" -- Drawer: "SL:2:DEF456:ABC123" -- Box: "SL:3:GHI789:DEF456" -``` - -### QR Code Components: -- **SL**: Storage Location prefix -- **Level**: Hierarchy level (1=shelf, 2=drawer, 3=box, etc.) -- **Unique ID**: Short alphanumeric code (6-8 chars) -- **Parent Hash**: Reference to parent location - -## Multi-QR Code Detection Strategy - -### 1. Spatial Filtering -``` -When multiple QR codes detected: -1. Calculate distance between codes -2. If distance < threshold: - - Prefer higher hierarchy level (lower number) - - Present disambiguation UI -3. If distance > threshold: - - Allow user to tap/select desired code -``` - -### 2. Context-Aware Selection -``` -Selection Priority: -1. Exact level match (if user scanning for specific level) -2. Deepest level in hierarchy (most specific location) -3. Recently used locations (user preference learning) -4. Manual disambiguation prompt -``` - -### 3. Visual Feedback -``` -Camera Overlay: -- Draw bounding boxes around each detected QR code -- Color-code by hierarchy level -- Show location path preview on hover/tap -- Highlight "best match" with different color -``` - -## Implementation Components - -### 1. Elixir Modules - -#### Storage Location Schema -```elixir -defmodule ComponentsElixir.Inventory.StorageLocation do - use Ecto.Schema - import Ecto.Changeset - - schema "storage_locations" do - field :name, :string - field :description, :string - field :qr_code, :string - field :level, :integer, default: 0 - field :path, :string - field :is_active, :boolean, default: true - - belongs_to :parent, __MODULE__ - has_many :children, __MODULE__, foreign_key: :parent_id - has_many :components, Component - - timestamps() - end -end -``` - -#### QR Code Generation -```elixir -defmodule ComponentsElixir.QRCode do - def generate_storage_qr(location) do - qr_data = "SL:#{location.level}:#{location.qr_code}:#{parent_hash(location)}" - - # Use :qr_code library to generate QR image - :qr_code.encode(qr_data) - |> :qr_code.png() - end - - def parse_storage_qr(qr_string) do - case String.split(qr_string, ":") do - ["SL", level, code, parent] -> - {:ok, %{level: level, code: code, parent: parent}} - _ -> - {:error, :invalid_format} - end - end -end -``` - -### 2. Phoenix LiveView Components - -#### QR Scanner Component -```elixir -defmodule ComponentsElixirWeb.QRScannerLive do - use ComponentsElixirWeb, :live_view - - def mount(_params, _session, socket) do - socket = - socket - |> assign(:scanning, false) - |> assign(:detected_codes, []) - |> assign(:selected_location, nil) - |> allow_upload(:qr_scan, - accept: ~w(.jpg .jpeg .png), - max_entries: 1, - auto_upload: true) - - {:ok, socket} - end - - def handle_event("start_scan", _, socket) do - {:noreply, assign(socket, :scanning, true)} - end - - def handle_event("qr_detected", %{"codes" => codes}, socket) do - parsed_codes = Enum.map(codes, &parse_and_resolve_location/1) - - socket = - socket - |> assign(:detected_codes, parsed_codes) - |> maybe_auto_select_location(parsed_codes) - - {:noreply, socket} - end - - defp maybe_auto_select_location(socket, [single_code]) do - assign(socket, :selected_location, single_code) - end - - defp maybe_auto_select_location(socket, multiple_codes) do - # Show disambiguation UI - assign(socket, :selected_location, nil) - end -end -``` - -### 3. JavaScript QR Detection - -#### Camera Integration -```javascript -// assets/js/qr_scanner.js -import jsQR from "jsqr"; - -export const QRScanner = { - mounted() { - this.video = this.el.querySelector('video'); - this.canvas = this.el.querySelector('canvas'); - this.context = this.canvas.getContext('2d'); - - this.startCamera(); - this.scanLoop(); - }, - - async startCamera() { - try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { - facingMode: 'environment', // Use back camera - width: { ideal: 1280 }, - height: { ideal: 720 } - } - }); - this.video.srcObject = stream; - } catch (err) { - console.error('Camera access denied:', err); - } - }, - - scanLoop() { - if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) { - this.canvas.width = this.video.videoWidth; - this.canvas.height = this.video.videoHeight; - - this.context.drawImage(this.video, 0, 0); - const imageData = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height); - - // Detect multiple QR codes - const codes = this.detectMultipleQRCodes(imageData); - - if (codes.length > 0) { - this.pushEvent("qr_detected", { codes: codes }); - } - } - - requestAnimationFrame(() => this.scanLoop()); - }, - - detectMultipleQRCodes(imageData) { - // Implementation for detecting multiple QR codes - // This is a simplified version - you'd need a more robust library - const detected = []; - - // Scan in grid pattern to find multiple codes - const gridSize = 4; - const width = imageData.width / gridSize; - const height = imageData.height / gridSize; - - for (let x = 0; x < gridSize; x++) { - for (let y = 0; y < gridSize; y++) { - const subImageData = this.getSubImageData( - imageData, - x * width, - y * height, - width, - height - ); - - const code = jsQR(subImageData.data, subImageData.width, subImageData.height); - if (code && this.isStorageLocationQR(code.data)) { - detected.push({ - data: code.data, - location: { x: x * width, y: y * height }, - corners: code.location - }); - } - } - } - - return this.filterDuplicates(detected); - }, - - isStorageLocationQR(data) { - return data.startsWith('SL:'); - } -}; -``` - -## User Experience Flow - -### 1. Adding Components with QR Scan -``` -1. User clicks "Add Component" -2. Position field shows camera icon -3. Click camera → QR scanner opens -4. Scan storage location QR code -5. If multiple codes detected: - - Show overlay with detected locations - - User taps to select specific location -6. Location path auto-filled: "Shelf A → Drawer 2 → Box 5" -7. Component saved with storage_location_id -``` - -### 2. Filtering by Storage Location -``` -1. Component list shows location filter dropdown -2. Filter options show hierarchical tree: - ├── Shelf A - │ ├── Drawer 1 - │ │ ├── Box 1 - │ │ └── Box 2 - │ └── Drawer 2 - └── Shelf B -3. Select any level to filter components -4. Breadcrumb shows: "Shelf A → Drawer 2" (23 components) -``` - -### 3. Location Management -``` -1. New "Storage Locations" section in admin -2. Add/edit locations with auto QR generation -3. Print QR labels with location hierarchy -4. Bulk QR code generation for initial setup -``` - -## Handling Multiple QR Codes in Same Image - -### Strategy 1: Spatial Separation -- Calculate euclidean distance between QR code centers -- If distance < 100px → show disambiguation -- If distance > 100px → allow selection by tap - -### Strategy 2: Hierarchy Preference -- Always prefer deepest level (most specific) -- If same level → show all options -- Color-code by hierarchy level in UI - -### Strategy 3: Machine Learning (Future) -- Learn user selection patterns -- Predict most likely intended QR code -- Still allow manual override - -## Migration Strategy - -### Phase 1: Add Storage Locations -1. Create migration for storage_locations table -2. Add storage_location_id to components -3. Create admin interface for location management - -### Phase 2: QR Code Generation -1. Add QR code generation to location creation -2. Implement QR code printing/export functionality -3. Generate codes for existing locations - -### Phase 3: QR Code Scanning -1. Add camera permissions and JavaScript QR scanner -2. Implement single QR code detection first -3. Add multi-QR detection and disambiguation - -### Phase 4: Advanced Features -1. Location-based filtering and search -2. Bulk operations by location -3. Location analytics and optimization - -## Technical Dependencies - -### Elixir Dependencies -```elixir -# mix.exs -{:qr_code, "~> 3.1"}, # QR code generation -{:image, "~> 0.37"}, # Image processing -{:ex_image_info, "~> 0.2.4"} # Image metadata -``` - -### JavaScript Dependencies -??? - -## Database Indexes for Performance -```sql --- Fast location lookups -CREATE INDEX idx_components_storage_location_id ON components(storage_location_id); - --- Hierarchical queries -CREATE INDEX idx_storage_locations_path_gin ON storage_locations USING gin(path gin_trgm_ops); - --- QR code uniqueness and fast lookup -CREATE UNIQUE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code); -``` - -This design provides a robust foundation for QR code-based storage management while handling the complexity of multiple codes in the same image through spatial analysis and user interaction patterns. \ No newline at end of file diff --git a/lib/components_elixir/inventory.ex b/lib/components_elixir/inventory.ex index ae6d3c0..905add6 100644 --- a/lib/components_elixir/inventory.ex +++ b/lib/components_elixir/inventory.ex @@ -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 diff --git a/lib/components_elixir/inventory/category.ex b/lib/components_elixir/inventory/category.ex index ca05bfe..7f5ecd3 100644 --- a/lib/components_elixir/inventory/category.ex +++ b/lib/components_elixir/inventory/category.ex @@ -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 diff --git a/lib/components_elixir/inventory/hierarchical.ex b/lib/components_elixir/inventory/hierarchical.ex new file mode 100644 index 0000000..d938953 --- /dev/null +++ b/lib/components_elixir/inventory/hierarchical.ex @@ -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 diff --git a/lib/components_elixir/inventory/hierarchical_schema.ex b/lib/components_elixir/inventory/hierarchical_schema.ex new file mode 100644 index 0000000..cf03368 --- /dev/null +++ b/lib/components_elixir/inventory/hierarchical_schema.ex @@ -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 diff --git a/lib/components_elixir/inventory/storage_location.ex b/lib/components_elixir/inventory/storage_location.ex index 8e49233..de3e74e 100644 --- a/lib/components_elixir/inventory/storage_location.ex +++ b/lib/components_elixir/inventory/storage_location.ex @@ -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( diff --git a/lib/components_elixir_web/live/categories_live.ex b/lib/components_elixir_web/live/categories_live.ex index 3b58148..aace009 100644 --- a/lib/components_elixir_web/live/categories_live.ex +++ b/lib/components_elixir_web/live/categories_live.ex @@ -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 diff --git a/lib/components_elixir_web/live/components_live.ex b/lib/components_elixir_web/live/components_live.ex index d36cf8a..e7d48b6 100644 --- a/lib/components_elixir_web/live/components_live.ex +++ b/lib/components_elixir_web/live/components_live.ex @@ -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 - +
@@ -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" > - <%= for category <- @categories do %> - <% end %> @@ -580,7 +560,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- + <%= if @show_add_form do %>
@@ -714,7 +694,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %> - + <%= if @show_edit_form do %>
@@ -858,7 +838,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %> - +
@@ -918,7 +898,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- +
@@ -944,7 +924,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %> - +
@@ -956,7 +936,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do

<% end %> - +
<%= if component.storage_location do %> @@ -1013,7 +993,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- +
- + <%= if component.description do %>
@@ -1107,7 +1087,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do

<% end %> - +
<%= if component.storage_location do %> @@ -1135,7 +1115,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %> - + <%= if component.keywords do %>
@@ -1218,7 +1198,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do >
- +
- +
diff --git a/lib/components_elixir_web/live/storage_locations_live.ex b/lib/components_elixir_web/live/storage_locations_live.ex index c2efe3e..7cde1ca 100644 --- a/lib/components_elixir_web/live/storage_locations_live.ex +++ b/lib/components_elixir_web/live/storage_locations_live.ex @@ -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 (AprilTag ID {scan.apriltag_id})
- Level {scan.location.level} + Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %>
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 05463d8..81a75a5 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -55,6 +55,15 @@ Repo.delete_all(StorageLocation) parent_id: capacitors.id }) +# Create a DEEP category hierarchy to test fallback path (7+ levels) +{:ok, deep_cat_1} = Inventory.create_category(%{name: "Level 1", description: "Deep hierarchy test", parent_id: resistors.id}) +{:ok, deep_cat_2} = Inventory.create_category(%{name: "Level 2", description: "Deep hierarchy test", parent_id: deep_cat_1.id}) +{:ok, deep_cat_3} = Inventory.create_category(%{name: "Level 3", description: "Deep hierarchy test", parent_id: deep_cat_2.id}) +{:ok, deep_cat_4} = Inventory.create_category(%{name: "Level 4", description: "Deep hierarchy test", parent_id: deep_cat_3.id}) +{:ok, deep_cat_5} = Inventory.create_category(%{name: "Level 5", description: "Deep hierarchy test", parent_id: deep_cat_4.id}) +{:ok, deep_cat_6} = Inventory.create_category(%{name: "Level 6", description: "Deep hierarchy test", parent_id: deep_cat_5.id}) +{:ok, deep_cat_7} = Inventory.create_category(%{name: "Level 7", description: "Deep hierarchy test - triggers fallback", parent_id: deep_cat_6.id}) + # Create storage locations {:ok, shelf_a} = Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"}) {:ok, _shelf_b} = Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"}) @@ -104,6 +113,15 @@ Repo.delete_all(StorageLocation) parent_id: drawer_a2.id }) +# Create a DEEP storage location hierarchy to test fallback path (7+ levels) +{:ok, deep_loc_1} = Inventory.create_storage_location(%{name: "Deep Level 1", description: "Deep hierarchy test", parent_id: box_a1_3.id}) +{:ok, deep_loc_2} = Inventory.create_storage_location(%{name: "Deep Level 2", description: "Deep hierarchy test", parent_id: deep_loc_1.id}) +{:ok, deep_loc_3} = Inventory.create_storage_location(%{name: "Deep Level 3", description: "Deep hierarchy test", parent_id: deep_loc_2.id}) +{:ok, deep_loc_4} = Inventory.create_storage_location(%{name: "Deep Level 4", description: "Deep hierarchy test", parent_id: deep_loc_3.id}) +{:ok, deep_loc_5} = Inventory.create_storage_location(%{name: "Deep Level 5", description: "Deep hierarchy test", parent_id: deep_loc_4.id}) +{:ok, deep_loc_6} = Inventory.create_storage_location(%{name: "Deep Level 6", description: "Deep hierarchy test", parent_id: deep_loc_5.id}) +{:ok, deep_loc_7} = Inventory.create_storage_location(%{name: "Deep Level 7", description: "Deep hierarchy test - triggers fallback", parent_id: deep_loc_6.id}) + # Create sample components sample_components = [ %{ @@ -186,6 +204,23 @@ sample_components = [ storage_location_id: box_a1_1.id, count: 100, category_id: resistors.id + }, + # Test components for deep hierarchies to ensure fallback path is exercised + %{ + name: "Deep Category Test Component", + description: "Component in 7-level deep category hierarchy", + keywords: "test deep hierarchy category fallback", + storage_location_id: box_a1_1.id, + count: 1, + category_id: deep_cat_7.id + }, + %{ + name: "Deep Storage Test Component", + description: "Component in 7-level deep storage location hierarchy", + keywords: "test deep hierarchy storage fallback", + storage_location_id: deep_loc_7.id, + count: 1, + category_id: resistors.id } ] @@ -211,10 +246,12 @@ sample_locations = [ Enum.each(sample_locations, fn location -> if location.apriltag_id do apriltag_url = AprilTag.get_apriltag_url(location) - IO.puts("#{location.path}: AprilTag ID #{location.apriltag_id}") + location_path = StorageLocation.full_path(location) + IO.puts("#{location_path}: AprilTag ID #{location.apriltag_id}") IO.puts(" Download URL: #{apriltag_url}") else - IO.puts("#{location.path}: No AprilTag assigned") + location_path = StorageLocation.full_path(location) + IO.puts("#{location_path}: No AprilTag assigned") end end)