← Back to Visual editor v2

03 phoenix liveview components

Documentation for 03_phoenix_liveview_components from the Pipeline ex repository.

Phoenix LiveView Components

Component Architecture

The visual editor is built with modular LiveView components that handle specific aspects of pipeline management. Each component is self-contained, reusable, and communicates through well-defined interfaces.

Component Hierarchy

PipelineEditorLive (Root)
├── HeaderComponent
│   ├── PipelineName
│   ├── SaveStatus
│   └── UserPresence
├── ToolbarComponent
│   ├── StepLibrary
│   ├── ViewModeToggle
│   └── ActionButtons
├── CanvasComponent
│   ├── StepNode
│   ├── Connection
│   └── SelectionBox
├── PropertiesPanel
│   ├── StepConfig
│   ├── ValidationMessages
│   └── Documentation
└── FooterComponent
    ├── ZoomControls
    ├── MiniMap
    └── StatusBar

Core Components

1. PipelineEditorLive (Root Component)

The main LiveView that orchestrates all child components and manages global state.

defmodule PipelineEditorWeb.PipelineLive.Editor do
  use PipelineEditorWeb, :live_view
  
  alias PipelineEditorWeb.Components.{
    Header, Toolbar, Canvas, PropertiesPanel, Footer
  }
  
  @impl true
  def mount(%{"id" => pipeline_id}, _session, socket) do
    if connected?(socket) do
      # Subscribe to pipeline updates
      PipelineEditor.subscribe_pipeline(pipeline_id)
      
      # Track user presence
      {:ok, _} = Presence.track(self(), "pipeline:#{pipeline_id}", 
        socket.assigns.current_user.id, %{
          name: socket.assigns.current_user.name,
          color: generate_user_color(socket.assigns.current_user.id)
        })
    end
    
    socket =
      socket
      |> assign(:pipeline_id, pipeline_id)
      |> assign(:pipeline, load_pipeline(pipeline_id))
      |> assign(:selected_step_ids, [])
      |> assign(:view_mode, :visual)
      |> assign(:zoom_level, 100)
      |> assign(:pan_offset, {0, 0})
      |> assign(:show_properties, true)
      |> assign(:validation_errors, [])
    
    {:ok, socket}
  end
  
  @impl true
  def render(assigns) do
    ~H"""
    <div class="pipeline-editor h-screen flex flex-col" 
         phx-hook="PipelineEditor"
         data-pipeline-id={@pipeline_id}>
      
      <.live_component module={Header} 
        id="header"
        pipeline={@pipeline}
        save_status={@save_status}
        users={@present_users} />
      
      <div class="flex flex-1 overflow-hidden">
        <.live_component module={Toolbar}
          id="toolbar"
          view_mode={@view_mode} />
        
        <div class="flex-1 relative">
          <.live_component module={Canvas}
            id="canvas"
            pipeline={@pipeline}
            selected_step_ids={@selected_step_ids}
            zoom_level={@zoom_level}
            pan_offset={@pan_offset} />
        </div>
        
        <%= if @show_properties do %>
          <.live_component module={PropertiesPanel}
            id="properties"
            selected_steps={get_selected_steps(@pipeline, @selected_step_ids)}
            validation_errors={@validation_errors} />
        <% end %>
      </div>
      
      <.live_component module={Footer}
        id="footer"
        zoom_level={@zoom_level}
        pipeline={@pipeline} />
    </div>
    """
  end
end

2. Canvas Component

The main editing area where steps are visualized and manipulated.

defmodule PipelineEditorWeb.Components.Canvas do
  use PipelineEditorWeb, :live_component
  
  @impl true
  def render(assigns) do
    ~H"""
    <div class="canvas-container relative w-full h-full overflow-hidden bg-gray-50"
         phx-hook="Canvas"
         id={"canvas-#{@id}"}
         phx-target={@myself}>
      
      <!-- Grid Background -->
      <svg class="absolute inset-0 w-full h-full pointer-events-none">
        <defs>
          <pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
            <circle cx="1" cy="1" r="0.5" fill="#e5e7eb" />
          </pattern>
        </defs>
        <rect width="100%" height="100%" fill="url(#grid)" />
      </svg>
      
      <!-- Canvas Content -->
      <div class="canvas-content absolute"
           style={"transform: scale(#{@zoom_level / 100}) translate(#{elem(@pan_offset, 0)}px, #{elem(@pan_offset, 1)}px)"}>
        
        <!-- Connections Layer -->
        <svg class="connections-layer absolute inset-0 pointer-events-none">
          <%= for connection <- @pipeline.connections do %>
            <.connection 
              connection={connection}
              from_step={get_step(@pipeline, connection.from_step_id)}
              to_step={get_step(@pipeline, connection.to_step_id)} />
          <% end %>
        </svg>
        
        <!-- Steps Layer -->
        <%= for step <- @pipeline.steps do %>
          <.step_node
            step={step}
            selected={step.id in @selected_step_ids}
            phx-target={@myself} />
        <% end %>
        
        <!-- Selection Box -->
        <%= if @selecting do %>
          <div class="selection-box absolute border-2 border-blue-500 bg-blue-500/10"
               style={"left: #{@selection_start.x}px; top: #{@selection_start.y}px; width: #{@selection_end.x - @selection_start.x}px; height: #{@selection_end.y - @selection_start.y}px"}>
          </div>
        <% end %>
      </div>
      
      <!-- Other Users' Cursors -->
      <%= for {user_id, user} <- @present_users, user_id != @current_user_id do %>
        <.user_cursor user={user} />
      <% end %>
    </div>
    """
  end
  
  # Component for individual step nodes
  def step_node(assigns) do
    ~H"""
    <div class={"step-node absolute rounded-lg border-2 bg-white shadow-sm transition-all #{step_classes(@step, @selected)}"}
         style={"left: #{@step.position_x}px; top: #{@step.position_y}px; width: #{@step.width}px; height: #{@step.height}px"}
         data-step-id={@step.id}
         phx-click="select_step"
         phx-value-id={@step.id}
         phx-hook="StepNode">
      
      <!-- Step Header -->
      <div class="step-header px-3 py-2 border-b flex items-center justify-between">
        <div class="flex items-center gap-2">
          <.step_icon type={@step.type} />
          <span class="font-medium text-sm"><%= @step.name %></span>
        </div>
        <%= if not @step.enabled do %>
          <span class="text-xs text-gray-500">Disabled</span>
        <% end %>
      </div>
      
      <!-- Step Content Preview -->
      <div class="step-content px-3 py-2 text-xs text-gray-600">
        <%= step_preview(@step) %>
      </div>
      
      <!-- Connection Points -->
      <div class="input-port absolute -left-2 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full bg-gray-400 border-2 border-white"></div>
      <div class="output-port absolute -right-2 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full bg-gray-400 border-2 border-white"></div>
    </div>
    """
  end
  
  # SVG connection component
  def connection(assigns) do
    path = calculate_connection_path(assigns.from_step, assigns.to_step)
    
    ~H"""
    <g class="connection" data-connection-id={@connection.id}>
      <path d={path} 
            stroke="#6b7280" 
            stroke-width="2" 
            fill="none"
            marker-end="url(#arrowhead)" />
    </g>
    """
  end
end

3. Step Library Component

Draggable palette of available step types.

defmodule PipelineEditorWeb.Components.StepLibrary do
  use PipelineEditorWeb, :live_component
  
  @step_categories [
    {"AI Providers", ~w(claude gemini claude_smart claude_session claude_extract claude_batch claude_robust parallel_claude gemini_instructor)},
    {"Control Flow", ~w(pipeline for_loop while_loop switch)},
    {"Data & Files", ~w(data_transform file_ops codebase_query)},
    {"State", ~w(set_variable checkpoint)}
  ]
  
  @impl true
  def render(assigns) do
    ~H"""
    <div class="step-library bg-white rounded-lg shadow-sm p-4">
      <div class="mb-4">
        <input type="text" 
               placeholder="Search steps..." 
               class="w-full px-3 py-2 border rounded-md text-sm"
               phx-keyup="search_steps"
               phx-target={@myself} />
      </div>
      
      <%= for {category, steps} <- @step_categories do %>
        <div class="mb-4">
          <h4 class="text-xs font-semibold text-gray-500 uppercase mb-2">
            <%= category %>
          </h4>
          <div class="space-y-1">
            <%= for step_type <- steps do %>
              <div class="step-template flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 cursor-move"
                   draggable="true"
                   phx-hook="DraggableStep"
                   data-step-type={step_type}>
                <.step_icon type={step_type} />
                <span class="text-sm"><%= humanize_step_type(step_type) %></span>
              </div>
            <% end %>
          </div>
        </div>
      <% end %>
    </div>
    """
  end
end

4. Properties Panel Component

Configuration panel for selected steps.

defmodule PipelineEditorWeb.Components.PropertiesPanel do
  use PipelineEditorWeb, :live_component
  
  @impl true
  def render(assigns) do
    ~H"""
    <div class="properties-panel w-96 bg-white border-l overflow-y-auto">
      <%= if @selected_steps == [] do %>
        <div class="p-6 text-center text-gray-500">
          <svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor">
            <!-- Icon -->
          </svg>
          <p class="text-sm">Select a step to view its properties</p>
        </div>
      <% else %>
        <%= if length(@selected_steps) == 1 do %>
          <.single_step_properties step={hd(@selected_steps)} errors={@validation_errors} />
        <% else %>
          <.multi_step_properties steps={@selected_steps} />
        <% end %>
      <% end %>
    </div>
    """
  end
  
  def single_step_properties(assigns) do
    ~H"""
    <div class="p-4">
      <!-- Step Info -->
      <div class="mb-6">
        <div class="flex items-center gap-3 mb-4">
          <.step_icon type={@step.type} size="lg" />
          <div class="flex-1">
            <input type="text" 
                   value={@step.name}
                   class="font-semibold text-lg w-full"
                   phx-blur="update_step_name"
                   phx-value-id={@step.id} />
            <p class="text-sm text-gray-500"><%= @step.type %></p>
          </div>
        </div>
        
        <!-- Enable/Disable Toggle -->
        <label class="flex items-center gap-2">
          <input type="checkbox" 
                 checked={@step.enabled}
                 phx-click="toggle_step"
                 phx-value-id={@step.id} />
          <span class="text-sm">Enabled</span>
        </label>
      </div>
      
      <!-- Dynamic Configuration Form -->
      <.step_config_form step={@step} />
      
      <!-- Validation Errors -->
      <%= if @errors != [] do %>
        <div class="mt-4 p-3 bg-red-50 rounded-md">
          <h4 class="text-sm font-medium text-red-800 mb-1">Validation Errors</h4>
          <ul class="list-disc list-inside text-sm text-red-700">
            <%= for error <- @errors do %>
              <li><%= error %></li>
            <% end %>
          </ul>
        </div>
      <% end %>
    </div>
    """
  end
  
  # Dynamic form generation based on step type
  def step_config_form(%{step: %{type: "claude"}} = assigns) do
    ~H"""
    <form phx-change="update_step_config" phx-value-id={@step.id}>
      <div class="space-y-4">
        <!-- Model Selection -->
        <div>
          <label class="block text-sm font-medium mb-1">Model</label>
          <select name="config[model]" class="w-full rounded-md border-gray-300">
            <option value="claude-3-opus-20240229">Claude 3 Opus</option>
            <option value="claude-3-sonnet-20240229">Claude 3 Sonnet</option>
            <option value="claude-3-haiku-20240307">Claude 3 Haiku</option>
          </select>
        </div>
        
        <!-- Tools -->
        <div>
          <label class="block text-sm font-medium mb-1">Tools</label>
          <div class="space-y-1">
            <%= for tool <- ~w(bash read write edit search glob grep) do %>
              <label class="flex items-center gap-2">
                <input type="checkbox" 
                       name={"config[tools][]"}
                       value={tool}
                       checked={tool in (@step.config["tools"] || [])} />
                <span class="text-sm"><%= tool %></span>
              </label>
            <% end %>
          </div>
        </div>
        
        <!-- Max Tokens -->
        <div>
          <label class="block text-sm font-medium mb-1">Max Tokens</label>
          <input type="number" 
                 name="config[max_tokens]"
                 value={@step.config["max_tokens"] || 4000}
                 class="w-full rounded-md border-gray-300" />
        </div>
        
        <!-- Prompt Editor -->
        <div>
          <label class="block text-sm font-medium mb-1">Prompt</label>
          <.prompt_editor prompts={@step.config["prompt"] || []} step_id={@step.id} />
        </div>
      </div>
    </form>
    """
  end
end

5. JavaScript Hooks

Phoenix Hooks for client-side interactivity.

// assets/js/hooks/pipeline_editor.js

export const PipelineEditor = {
  mounted() {
    this.handleKeyboardShortcuts()
    this.initializeCanvas()
  },
  
  handleKeyboardShortcuts() {
    document.addEventListener('keydown', (e) => {
      // Ctrl/Cmd + S: Save
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        e.preventDefault()
        this.pushEvent('save_pipeline', {})
      }
      
      // Delete: Remove selected steps
      if (e.key === 'Delete') {
        this.pushEvent('delete_selected_steps', {})
      }
      
      // Ctrl/Cmd + Z: Undo
      if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
        e.preventDefault()
        this.pushEvent('undo', {})
      }
    })
  },
  
  initializeCanvas() {
    // Set up drag and drop, pan, zoom, etc.
  }
}

export const Canvas = {
  mounted() {
    this.canvas = this.el
    this.initializeDragDrop()
    this.initializePanZoom()
    this.initializeSelection()
  },
  
  initializeDragDrop() {
    this.canvas.addEventListener('dragover', (e) => {
      e.preventDefault()
      e.dataTransfer.dropEffect = 'copy'
    })
    
    this.canvas.addEventListener('drop', (e) => {
      e.preventDefault()
      const stepType = e.dataTransfer.getData('step-type')
      const rect = this.canvas.getBoundingClientRect()
      
      this.pushEvent('add_step', {
        type: stepType,
        x: e.clientX - rect.left,
        y: e.clientY - rect.top
      })
    })
  },
  
  initializePanZoom() {
    let isPanning = false
    let startX, startY
    
    this.canvas.addEventListener('wheel', (e) => {
      if (e.ctrlKey) {
        e.preventDefault()
        const delta = e.deltaY > 0 ? 0.9 : 1.1
        this.pushEvent('zoom', {delta})
      }
    })
    
    this.canvas.addEventListener('mousedown', (e) => {
      if (e.button === 1 || (e.button === 0 && e.altKey)) {
        isPanning = true
        startX = e.clientX
        startY = e.clientY
      }
    })
  }
}

export const StepNode = {
  mounted() {
    this.initializeDragging()
    this.initializeConnections()
  },
  
  initializeDragging() {
    let isDragging = false
    let startX, startY
    let initialX, initialY
    
    this.el.addEventListener('mousedown', (e) => {
      if (e.target.closest('.input-port, .output-port')) return
      
      isDragging = true
      startX = e.clientX
      startY = e.clientY
      initialX = parseInt(this.el.style.left)
      initialY = parseInt(this.el.style.top)
      
      this.el.classList.add('dragging')
    })
    
    document.addEventListener('mousemove', (e) => {
      if (!isDragging) return
      
      const dx = e.clientX - startX
      const dy = e.clientY - startY
      
      this.el.style.left = `${initialX + dx}px`
      this.el.style.top = `${initialY + dy}px`
    })
    
    document.addEventListener('mouseup', (e) => {
      if (!isDragging) return
      
      isDragging = false
      this.el.classList.remove('dragging')
      
      this.pushEvent('move_step', {
        id: this.el.dataset.stepId,
        x: parseInt(this.el.style.left),
        y: parseInt(this.el.style.top)
      })
    })
  }
}

6. Tailwind Components

Custom Tailwind components for consistent styling.

/* assets/css/components.css */

@layer components {
  .step-node {
    @apply transition-all duration-150;
  }
  
  .step-node.selected {
    @apply border-blue-500 shadow-lg ring-2 ring-blue-200;
  }
  
  .step-node.dragging {
    @apply opacity-75 cursor-move;
  }
  
  .step-icon {
    @apply w-5 h-5 flex-shrink-0;
  }
  
  .connection {
    @apply transition-all duration-150;
  }
  
  .connection:hover {
    @apply stroke-blue-500;
  }
  
  .user-cursor {
    @apply absolute w-4 h-4 -mt-1 -ml-1 pointer-events-none transition-all duration-100;
  }
}

Component Communication

Events Flow

User Action → JavaScript Hook → LiveView Event → State Update → Re-render
                    ↓                                  ↓
              Electric Sync ← Database Update ← Business Logic

PubSub Patterns

# Broadcasting changes
defmodule PipelineEditor.PipelineEvents do
  def broadcast_step_added(pipeline_id, step) do
    Phoenix.PubSub.broadcast(
      PipelineEditor.PubSub,
      "pipeline:#{pipeline_id}",
      {:step_added, step}
    )
  end
end

# Handling broadcasts in LiveView
def handle_info({:step_added, step}, socket) do
  {:noreply, update(socket, :pipeline, &add_step_to_pipeline(&1, step))}
end

Performance Optimizations

1. Temporary Assigns

def mount(params, session, socket) do
  socket = assign(socket, 
    # Permanent assigns
    pipeline_id: params["id"],
    
    # Temporary assigns (not kept in memory)
    __temporary__: [:search_results, :notifications]
  )
  
  {:ok, socket}
end

2. Async Data Loading

def handle_event("load_step_documentation", %{"type" => type}, socket) do
  self = self()
  
  Task.start(fn ->
    docs = PipelineEditor.Documentation.get_step_docs(type)
    send(self, {:documentation_loaded, docs})
  end)
  
  {:noreply, assign(socket, :loading_docs, true)}
end

3. Debounced Updates

def handle_event("search_steps", %{"query" => query}, socket) do
  # Cancel previous timer
  if socket.assigns[:search_timer] do
    Process.cancel_timer(socket.assigns.search_timer)
  end
  
  # Set new timer
  timer = Process.send_after(self(), {:do_search, query}, 300)
  
  {:noreply, assign(socket, search_timer: timer, search_query: query)}
end

Testing Components

defmodule PipelineEditorWeb.Components.CanvasTest do
  use PipelineEditorWeb.ConnCase
  import Phoenix.LiveViewTest
  
  test "adds a new step on drop", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/pipelines/#{pipeline.id}/edit")
    
    view
    |> element(".canvas-container")
    |> render_hook("add_step", %{type: "claude", x: 100, y: 100})
    
    assert has_element?(view, "[data-step-type='claude']")
  end
  
  test "updates step position on drag", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/pipelines/#{pipeline.id}/edit")
    
    view
    |> element("[data-step-id='#{step.id}']")
    |> render_hook("move_step", %{id: step.id, x: 200, y: 200})
    
    assert has_element?(view, "[style*='left: 200px'][style*='top: 200px']")
  end
end