towerops/AGENTS.md
Graham McIntire 3bf21c746f refactor: upgrade to Elixir 1.20, extract GraphLive.Polling, async subscriber impact
- Bump elixir requirement from ~> 1.19 to ~> 1.20 for gradual typing
- Extract SNMP polling functions from graph_live/show.ex (1492→1118 lines)
  into new GraphLive.Polling module (363 lines) — 374 lines net reduction
- Defer subscriber impact API call via start_async/handle_async in
  device_live/show.ex, avoiding synchronous Gaiia API call in handle_params
- Add terminate/2 to graph_live/show.ex for live_poll timer cleanup
- Fix .tool-versions to match installed Erlang 27.3.4.2
- Relax AGENTS.md dialyzer policy: allow ignore file for deps/vendored code
2026-07-22 11:01:00 -05:00

34 KiB
Raw Permalink Blame History

This is a web application written using the Phoenix web framework.

Project guidelines

  • Use mix precommit alias when you are done with all changes and fix any pending issues
  • Use the already included and available :req (Req) library for HTTP requests, avoid :httpoison, :tesla, and :httpc. Req is included by default and is the preferred HTTP client for Phoenix apps
  • Always provide translations of all text that is visible in the UI
  • Never put Ecto queries directly in LiveViews. Instead always put them in the appropriate context module

Module design and complexity

IMPORTANT: When working with modules that are becoming too large or complex:

  • Monitor module size and complexity: If a module exceeds ~500-600 lines or contains deeply nested logic with high cyclomatic complexity, consider refactoring
  • Break out logical concerns: Extract related functionality into separate, focused modules that handle a single responsibility
  • Use helper modules: For complex domains, consider creating dedicated modules for specific sub-concerns:
    • Positioning logic (e.g., Tasks.Positioning)
    • Dependency management (e.g., Tasks.Dependencies)
    • Validation logic (e.g., Tasks.Validation)
    • Query builders (e.g., Tasks.Queries)
  • Extract helper functions: When a function becomes complex (cyclomatic complexity > 9), extract complex conditional logic into smaller, well-named helper functions
  • Maintain clear module boundaries: Each module should have a clear, single purpose with a well-defined public API
  • Document module organization: When splitting modules, update documentation to explain the new structure and how modules relate to each other

This approach improves:

  • Code maintainability and readability
  • Test isolation and coverage
  • Collaboration between developers
  • Ability to reason about individual components
  • Credo compliance and code quality metrics

Quality guidelines

ALWAYS follow these quality guidelines:

  • IMPORTANT: When you complete a task that has new functions, write unit tests for the new function
  • IMPORTANT: When you complete a task that updates code, make sure all existing unit tests pass and write new tests if needed
  • Each time you write or update a unit test run them with mix test and ensure they pass
  • IMPORTANT: When you complete a task run mix test --cover and ensure coverage is above the threshold
  • IMPORTANT: When you complete a task run mix credo --strict to check for code quality issues and fix them
  • IMPORTANT: Run mix dialyzer for static type analysis — use .dialyzer_ignore.exs only for dependency PLT gaps, vendored code, and external language artifacts (Gleam, NIFs). Never suppress warnings from project source code; fix those at the root cause. If legacy warnings are too numerous for a single PR, add them to the ignore file temporarily with a TODO comment and scheduled cleanup date.
  • IMPORTANT: Run mix precommit before committing: compiles with warnings as errors, formats, runs tests

Security guidelines

ALWAYS follow these security guidelines:

  • IMPORTANT: When you add or update a dependency run mix deps.audit and mix hex.audit to check for security issues
  • IMPORTANT: When you add or update a dependency run mix hex.outdated to check for outdated dependencies

Phoenix v1.8 guidelines

  • Always begin your LiveView templates with <Layouts.app flash={@flash} ...> which wraps all inner content
  • The MyAppWeb.Layouts module is aliased in the my_app_web.ex file, so you can use it without needing to alias it again
  • Anytime you run into errors with no current_scope assign:
    • You failed to follow the Authenticated Routes guidelines, or you failed to pass current_scope to <Layouts.app>
    • Always fix the current_scope error by moving your routes to the proper live_session and ensure you pass current_scope as needed
  • Phoenix v1.8 moved the <.flash_group> component to the Layouts module. You are forbidden from calling <.flash_group> outside of the layouts.ex module
  • Out of the box, core_components.ex imports an <.icon name="hero-x-mark" class="w-5 h-5"/> component for for hero icons. Always use the <.icon> component for icons, never use Heroicons modules or similar
  • Always use the imported <.input> component for form inputs from core_components.ex when available. <.input> is imported and using it will save steps and prevent errors
  • If you override the default input classes (<.input class="myclass px-2 py-1 rounded-lg">)) class with your own values, no default classes are inherited, so your custom classes must fully style the input

JS and CSS guidelines

  • Use Tailwind CSS classes and custom CSS rules to create polished, responsive, and visually stunning interfaces.

  • Tailwindcss v4 no longer needs a tailwind.config.js and uses a new import syntax in app.css:

    @import "tailwindcss" source(none);
    @source "../css";
    @source "../js";
    @source "../../lib/towerops_web";
    
  • Always use and maintain this import syntax in the app.css file for projects generated with phx.new

  • Never use @apply when writing raw css

  • Always manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design

  • Out of the box only the app.js and app.css bundles are supported

    • You cannot reference an external vendor'd script src or link href in the layouts
    • You must import the vendor deps into app.js and app.css to use them
    • Never write inline <script> tags within templates — use LiveView hooks or colocated hook scripts

UI/UX & design guidelines

  • Produce world-class UI designs with a focus on usability, aesthetics, and modern design principles
  • Always follow existing application styles and patterns when adding new UI elements
  • Before creating custom styles, check core_components.ex for existing components like <.input>, <.button>, <.form>, etc.
  • When adding form fields, use the standard component structure to match existing forms
  • New buttons should use the <.button> component without custom classes unless specifically requested
  • Implement subtle micro-interactions (e.g., button hover effects, and smooth transitions)
  • Ensure clean typography, spacing, and layout balance for a refined, premium look
  • Focus on delightful details like hover effects, loading states, and smooth page transitions
  • Maintain consistency with existing color schemes, spacing, and typography throughout the application

Authentication

  • Always handle authentication flow at the router level with proper redirects
  • Always be mindful of where to place routes. phx.gen.auth creates multiple router plugs and live_session scopes:
    • A plug :fetch_current_scope_for_user that is included in the default browser pipeline
    • A plug :require_authenticated_user that redirects to the log in page when the user is not authenticated
    • A live_session :current_user scope - for routes that need the current user but don't require authentication
    • A live_session :require_authenticated_user scope - for routes that require authentication
    • In both cases, a @current_scope is assigned to the Plug connection and LiveView socket
    • A plug redirect_if_user_is_authenticated that redirects to a default path in case the user is authenticated
  • Always let the user know in which router scopes, live_session, and pipeline you are placing the route, AND SAY WHY
  • phx.gen.auth assigns the current_scope assign - it does not assign a current_user assign
  • Always pass the assign current_scope to context modules as first argument. When performing queries, use current_scope.user to filter the query results
  • To derive/access current_user in templates, always use the @current_scope.user, never use @current_user in templates or LiveViews
  • Never duplicate live_session names. A live_session :current_user can only be defined once in the router, so all routes for the live_session :current_user must be grouped in a single block
  • Anytime you hit current_scope errors or the logged in session isn't displaying the right content, always double check the router and ensure you are using the correct plug and live_session as described below

Routes that require authentication

LiveViews that require login should always be placed inside the existing live_session :require_authenticated_user block:

scope "/", ToweropsWeb do
  pipe_through [:browser, :require_authenticated_user]

  live_session :require_authenticated_user,
    on_mount: [{ToweropsWeb.UserAuth, :require_authenticated}] do
    # phx.gen.auth generated routes
    live "/users/settings", UserLive.Settings, :edit
    # our own routes that require logged in user
    live "/", MyLiveThatRequiresAuth, :index
  end
end

Controller routes must be placed in a scope that sets the :require_authenticated_user plug:

scope "/", ToweropsWeb do
  pipe_through [:browser, :require_authenticated_user]

  get "/", MyControllerThatRequiresAuth, :index
end

Routes that work with or without authentication

Controllers automatically have the current_scope available if they use the :browser pipeline.

Elixir guidelines

  • Elixir lists do not support index based access via the access syntax

    Never do this (invalid):

    i = 0
    mylist = ["blue", "green"]
    mylist[i]
    

    Instead, always use Enum.at, pattern matching, or List for index based list access, ie:

    i = 0
    mylist = ["blue", "green"]
    Enum.at(mylist, i)
    
  • Elixir variables are immutable, but can be rebound, so for block expressions like if, case, cond, etc you must bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:

    # INVALID: we are rebinding inside the `if` and the result never gets assigned
    if connected?(socket) do
      socket = assign(socket, :val, val)
    end
    
    # VALID: we rebind the result of the `if` to a new variable
    socket =
      if connected?(socket) do
        assign(socket, :val, val)
      end
    
  • Never nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors

  • Never use map access syntax (changeset[:field]) on structs as they do not implement the Access behaviour by default. For regular structs, you must access the fields directly, such as my_struct.field or use higher level APIs that are available on the struct if they exist, Ecto.Changeset.get_field/2 for changesets

  • Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common Time, Date, DateTime, and Calendar interfaces by accessing their documentation as necessary. Never install additional dependencies unless asked or for date/time parsing (which you can use the date_time_parser package)

  • Don't use String.to_atom/1 on user input (memory leak risk)

  • Predicate function names should not start with is_ and should end in a question mark. Names like is_thing should be reserved for guards

  • Elixir's builtin OTP primitives like DynamicSupervisor and Registry, require names in the child spec, such as {DynamicSupervisor, name: MyApp.MyDynamicSup}, then you can use DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)

  • Use Task.async_stream(collection, callback, options) for concurrent enumeration with back-pressure. The majority of times you will want to pass timeout: :infinity as option

  • Elixir has no return statement, nor early returns. The last expression in a block is always returned

  • Don't use Enum functions on large collections when Stream is more appropriate

  • Avoid nested case statements — refactor to a single case, with, or separate functions

  • Prefer Enum functions like Enum.reduce over manual recursion

  • When recursion is necessary, prefer to use pattern matching in function heads for base case detection

  • Use guard clauses: when is_binary(name) and byte_size(name) > 0

  • Prefer multiple function clauses over complex conditional logic

  • Use structs over maps when the shape is known: defstruct [:name, :age]

  • Prefer to prepend to lists [new | list] rather than list ++ [new]

  • Use with for chaining operations that return {:ok, _} or {:error, _}

  • Use {:ok, result} and {:error, reason} tuples for operations that can fail

  • Using the process dictionary is typically a sign of unidiomatic code

  • Only use macros if explicitly requested

  • Use dbg/1 to print values while debugging — displays formatted value and context in console

OTP guidelines

  • Keep GenServer state simple and serializable
  • Handle all expected messages explicitly
  • Use handle_continue/2 for post-init work
  • Implement proper cleanup in terminate/2 when necessary
  • Use GenServer.call/3 for synchronous requests expecting replies
  • Use GenServer.cast/2 for fire-and-forget messages — when in doubt, prefer call for back-pressure
  • Set appropriate timeouts for call/3 operations
  • Set up processes such that they can handle crashing and being restarted by supervisors
  • Use :max_restarts and :max_seconds to prevent restart loops
  • Use Task.Supervisor for better fault tolerance in async tasks
  • Handle task failures with Task.yield/2 or Task.shutdown/2

Mix guidelines

  • Read the docs and options before using tasks (by using mix help task_name)
  • To debug test failures, run tests in a specific file with mix test test/my_test.exs or run all previously failed tests with mix test --failed
  • mix deps.clean --all is almost never needed. Avoid using it unless you have good reason

Test guidelines

  • Always use start_supervised!/1 to start processes in tests as it guarantees cleanup between tests
  • Avoid Process.sleep/1 and Process.alive?/1 in tests
    • Instead of sleeping to wait for a process to finish, always use Process.monitor/1 and assert on the DOWN message:

      ref = Process.monitor(pid) assert_receive {:DOWN, ^ref, :process, ^pid, :normal}

    • Instead of sleeping to synchronize before the next call, always use _ = :sys.get_state/1 to ensure the process has handled prior messages

  • Use @tag to tag specific tests, and mix test --only tag to run only those tests
  • Use assert_raise for testing expected exceptions: assert_raise ArgumentError, fn -> invalid_function() end
  • Limit the number of failed tests with mix test --max-failures n
  • Use DataCase for database tests, ConnCase for controllers/LiveView
  • Use async: true when possible
  • Never disable tests instead of fixing them

SNMP test mocking (project-specific)

  • snmp_adapter().get/3 returns {:ok, value} or {:error, reason}
  • snmp_adapter().walk/3 returns {:ok, [%{oid: "...", value: ...}]} (list, not map)
  • Values already extracted (no type wrapper needed)

Common pitfalls:

  • walk returning {:ok, %{}} instead of {:ok, []}
  • Returning type wrappers like {:integer, 123}
  • Not matching OIDs in get expectations

Phoenix guidelines

  • Remember Phoenix router scope blocks include an optional alias which is prefixed for all routes within the scope. Always be mindful of this when creating routes within a scope to avoid duplicate module prefixes.

  • You never need to create your own alias for route definitions! The scope provides the alias, ie:

    scope "/admin", ToweropsWeb.Admin do
      pipe_through :browser
    
      live "/users", UserLive, :index
    end
    

    the UserLive route would point to the ToweropsWeb.Admin.UserLive module

  • Phoenix.View no longer is needed or included with Phoenix, don't use it

Ecto Guidelines

  • Always preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the message.user.email

  • Remember import Ecto.Query and other supporting modules when you write seeds.exs

  • Ecto.Schema fields always use the :string type, even for :text, columns, ie: field :name, :string

  • Ecto.Changeset.validate_number/2 DOES NOT SUPPORT the :allow_nil option. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such an option is never needed

  • You must use Ecto.Changeset.get_field(changeset, :field) to access changeset fields

  • Fields which are set programatically, such as user_id, must not be listed in cast calls or similar for security purposes. Instead they must be explicitly set when creating the struct

  • Always invoke mix ecto.gen.migration migration_name_using_underscores when generating migration files, so the correct timestamp and conventions are applied

  • Binary UUID Primary Keys: All tables MUST use :binary_id, not bigint:

    create table(:table_name, primary_key: false) do
      add :id, :binary_id, primary_key: true
    end
    
  • SNMP Binary Data: Convert to printable strings before saving. Non-printable bytes → colon-separated hex. See lib/towerops/snmp/neighbor_discovery.ex:sanitize_string_field/1.

  • Fragment Queries with Binary UUIDs: When using PostgreSQL fragments that aggregate or manipulate UUIDs (like array_agg, string_agg), ALWAYS cast to ::text to avoid binary UUID data that causes Jason.EncodeError during LiveView socket serialization:

    # ❌ Wrong - returns binary UUID
    fragment("(array_agg(? ORDER BY ? DESC))[1]", table.id, table.inserted_at)
    
    # ✅ Correct - cast to text
    fragment("(array_agg(?::text ORDER BY ? DESC))[1]", table.id, table.inserted_at)
    

    Without ::text, PostgreSQL returns raw 16-byte binary UUIDs containing bytes like 0xFD that aren't valid UTF-8. When interpolated into strings (e.g., "id-#{row.id}"), these create invalid UTF-8 sequences that crash Jason encoding when sending data to LiveView clients.

Phoenix HTML guidelines

  • Phoenix templates always use ~H or .html.heex files (known as HEEx), never use ~E

  • Always use the imported Phoenix.Component.form/1 and Phoenix.Component.inputs_for/1 function to build forms. Never use Phoenix.HTML.form_for or Phoenix.HTML.inputs_for as they are outdated

  • When building forms always use the already imported Phoenix.Component.to_form/2 (assign(socket, form: to_form(...)) and <.form for={@form} id="msg-form">), then access those forms in the template via @form[:field]

  • Always add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (<.form for={@form} id="product-form">)

  • For "app wide" template imports, you can import/alias into the towerops_web.ex's html_helpers block, so they will be available to all LiveViews, LiveComponents, and all modules that do use ToweropsWeb, :html

  • Elixir supports if/else but does NOT support if/else if or if/elsif. Never use else if or elseif in Elixir, always use cond or case for multiple conditionals.

    Never do this (invalid):

    <%= if condition do %>
      ...
    <% else if other_condition %>
      ...
    <% end %>
    

    Instead always do this:

    <%= cond do %>
      <% condition -> %>
        ...
      <% condition2 -> %>
        ...
      <% true -> %>
        ...
    <% end %>
    
  • HEEx require special tag annotation if you want to insert literal curly's like { or }. If you want to show a textual code snippet on the page in a <pre> or <code> block you must annotate the parent tag with phx-no-curly-interpolation:

    <code phx-no-curly-interpolation>
      let obj = {key: "val"}
    </code>
    

    Within phx-no-curly-interpolation annotated tags, you can use { and } without escaping them, and dynamic Elixir expressions can still be used with <%= ... %> syntax

  • HEEx class attrs support lists, but you must always use list [...] syntax. You can use the class list syntax to conditionally add classes, always do this for multiple class values:

    <a class={[
      "px-2 text-white",
      @some_flag && "py-5",
      if(@other_condition, do: "border-red-500", else: "border-blue-100"),
      ...
    ]}>Text</a>
    

    and always wrap if's inside {...} expressions with parens, like done above (if(@other_condition, do: "...", else: "..."))

    and never do this, since it's invalid (note the missing [ and ]):

    <a class={
      "px-2 text-white",
      @some_flag && "py-5"
    }> ...
    => Raises compile syntax error on invalid HEEx attr syntax
    
  • Never use <% Enum.each %> or non-for comprehensions for generating template content, instead always use <%= for item <- @collection do %>

  • HEEx HTML comments use <%!-- comment --%>. Always use the HEEx HTML comment syntax for template comments (<%!-- comment --%>)

  • HEEx allows interpolation via {...} and <%= ... %>, but the <%= %> only works within tag bodies. Always use the {...} syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. Always interpolate block constructs (if, cond, case, for) within tag bodies using <%= ... %>.

    Always do this:

    <div id={@id}>
      {@my_assign}
      <%= if @some_block_condition do %>
        {@another_assign}
      <% end %>
    </div>
    

    and Never do this the program will terminate with a syntax error:

    <%!-- THIS IS INVALID NEVER EVER DO THIS --%>
    <div id="<%= @invalid_interpolation %>">
      {if @invalid_block_construct do}
      {end}
    </div>
    

Phoenix LiveView guidelines

  • Never use the deprecated live_redirect and live_patch functions, instead always use the <.link navigate={href}> and <.link patch={href}> in templates, and push_navigate and push_patch functions in LiveViews
  • Avoid LiveComponents unless you have a strong, specific need for them
  • LiveViews should be named like ToweropsWeb.WeatherLive, with a Live suffix. When you go to add LiveView routes to the router, the default :browser scope is already aliased with the ToweropsWeb module, so you can just do live "/weather", WeatherLive

Browser Navigation and URL State

Critical: All LiveViews MUST sync UI state to URL for browser back/forward buttons.

Core Principle: The URL is the single source of truth for visible state (tabs, modals, filters, pagination).

Pattern: Use push_patch/2 and handle_params/3:

# Tab navigation
def handle_params(params, _url, socket) do
  tab = case params["tab"] do
    tab when tab in ~w(overview sensors) -> tab
    _ -> "overview"  # Safe default
  end
  {:noreply, assign(socket, :active_tab, tab) |> load_tab_data(tab)}
end

def handle_event("select_tab", %{"tab" => tab}, socket) do
  {:noreply, push_patch(socket, to: ~p"/devices/#{socket.assigns.device.id}?tab=#{tab}")}
end

Template:

<.link patch={~p"/devices/#{@device.id}?tab=overview"}
       class={if @active_tab == "overview", do: "active"}>Overview</.link>

Modal state:

def handle_params(params, _url, socket) do
  modal = params["modal"]
  {:noreply, assign(socket, show_modal: modal != nil, modal_name: modal)}
end

def handle_event("show_modal", _, socket) do
  {:noreply, push_patch(socket, to: ~p"/settings?modal=add_token")}
end

# Close modal by removing ?modal param
{:noreply, push_patch(socket, to: ~p"/settings")}

Rules:

  1. Every LiveView implements handle_params/3 — ONLY place to read URL params
  2. Validate all params, provide safe defaults
  3. Never use assign for URL-backed state — use push_patch
  4. Test browser back/forward buttons

LiveView streams

  • Always use LiveView streams for collections to avoid memory ballooning with the following operations:

    • basic append of N items - stream(socket, :messages, [new_msg])
    • resetting stream with new items - stream(socket, :messages, [new_msg], reset: true) (e.g. for filtering items)
    • prepend to stream - stream(socket, :messages, [new_msg], at: -1)
    • deleting items - stream_delete(socket, :messages, msg)
  • When using the stream/3 interfaces in the LiveView, the LiveView template must 1) always set phx-update="stream" on the parent element, with a DOM id on the parent element like id="messages" and 2) consume the @streams.stream_name collection and use the id as the DOM id for each child. For a call like stream(socket, :messages, [new_msg]) in the LiveView, the template would be:

    <div id="messages" phx-update="stream">
      <div :for={{id, msg} <- @streams.messages} id={id}>
        {msg.text}
      </div>
    </div>
    
  • LiveView streams are not enumerable, so you cannot use Enum.filter/2 or Enum.reject/2 on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you must refetch the data and re-stream the entire stream collection, passing reset: true:

    def handle_event("filter", %{"filter" => filter}, socket) do
      messages = list_messages(filter)
    
      {:noreply,
       socket
       |> assign(:messages_empty?, messages == [])
       |> stream(:messages, messages, reset: true)}
    end
    
  • LiveView streams do not support counting or empty states. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:

    <div id="tasks" phx-update="stream">
      <div class="hidden only:block">No tasks yet</div>
      <div :for={{id, task} <- @stream.tasks} id={id}>
        {task.name}
      </div>
    </div>
    
  • When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items along with the updated assign.

  • Never use the deprecated phx-update="append" or phx-update="prepend" for collections

LiveView JavaScript interop

  • Remember anytime you use phx-hook="MyHook" and that JS hook manages its own DOM, you must also set the phx-update="ignore" attribute
  • Always provide a unique DOM id alongside phx-hook otherwise a compiler error will be raised

LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx, and 2) external phx-hook annotations where JavaScript object literals are defined and passed to the LiveSocket constructor.

Inline colocated js hooks

Never write raw embedded <script> tags in heex as they are incompatible with LiveView. Instead, always use a colocated js hook script tag (:type={Phoenix.LiveView.ColocatedHook}) when writing scripts inside the template:

<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
  export default {
    mounted() {
      this.el.addEventListener("input", e => {
        let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
        if(match) {
          this.el.value = `${match[1]}-${match[2]}-${match[3]}`
        }
      })
    }
  }
</script>
  • colocated hooks are automatically integrated into the app.js bundle
  • colocated hooks names MUST ALWAYS start with a . prefix, i.e. .PhoneNumber

External phx-hook

External JS hooks (<div id="myhook" phx-hook="MyHook">) must be placed in assets/js/ and passed to the LiveSocket constructor:

const MyHook = {
  mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { MyHook }
});

Memory leak prevention in JS hooks

Always remove event listeners in destroyed():

const MyHook = {
  handleClick: null as ((e: Event) => void) | null,

  mounted(this: any) {
    this.handleClick = (e: Event) => { /* logic */ }
    this.el.addEventListener("click", this.handleClick)
  },

  destroyed(this: any) {
    if (this.handleClick) {
      this.el.removeEventListener("click", this.handleClick)
      this.handleClick = null
    }
  }
}
  • Clean up third-party instances (Chart.js, Cytoscape, etc.) in destroyed()
  • Store event handler references — never pass anonymous functions to addEventListener
  • Set hook properties to null in destroyed()
  • Phoenix.PubSub auto-cleans when LiveView terminates — no manual cleanup needed

Pushing events between client and server

Use LiveView's push_event/3 when you need to push events/data to the client for a phx-hook to handle. Always return or rebind the socket on push_event/3 when pushing events:

# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})

# or return the modified socket directly:
def handle_event("some_event", _, socket) do
  {:noreply, push_event(socket, "my_event", %{...})}
end

Pushed events can then be picked up in a JS hook with this.handleEvent:

mounted() {
  this.handleEvent("my_event", data => console.log("from server:", data));
}

Clients can also push an event to the server and receive a reply with this.pushEvent:

mounted() {
  this.el.addEventListener("click", e => {
    this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
  })
}

Where the server handled it via:

def handle_event("my_event", %{"one" => 1}, socket) do
  {:reply, %{two: 2}, socket}
end

LiveView tests

  • Phoenix.LiveViewTest module and LazyHTML (included) for making your assertions

  • Form tests are driven by Phoenix.LiveViewTest's render_submit/2 and render_change/2 functions

  • Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests

  • Always reference the key element IDs you added in the LiveView templates in your tests for Phoenix.LiveViewTest functions like element/2, has_element/2, selectors, etc

  • Never test against raw HTML, always use element/2, has_element/2, and similar: assert has_element?(view, "#my-form")

  • Instead of relying on testing text content, which can change, favor testing for the presence of key elements

  • Focus on testing outcomes rather than implementation details

  • Be aware that Phoenix.Component functions like <.form> might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be

  • When facing test failures with element selectors, add debug statements to print the actual HTML, but use LazyHTML selectors to limit the output, ie:

    html = render(view)
    document = LazyHTML.from_fragment(html)
    matches = LazyHTML.filter(document, "your-complex-selector")
    IO.inspect(matches, label: "Matches")
    

Form handling

Creating a form from params

If you want to create a form based on handle_event params:

def handle_event("submitted", params, socket) do
  {:noreply, assign(socket, form: to_form(params))}
end

When you pass a map to to_form/1, it assumes said map contains the form params, which are expected to have string keys.

You can also specify a name to nest the params:

def handle_event("submitted", %{"user" => user_params}, socket) do
  {:noreply, assign(socket, form: to_form(user_params, as: :user))}
end

Creating a form from changesets

When using changesets, the underlying data, form params, and errors are retrieved from it. The :as option is automatically computed too. E.g. if you have a user schema:

defmodule Towerops.Users.User do
  use Ecto.Schema
  ...
end

And then you create a changeset that you pass to to_form:

%Towerops.Users.User{}
|> Ecto.Changeset.change()
|> to_form()

Once the form is submitted, the params will be available under %{"user" => user_params}.

In the template, the form assign can be passed to the <.form> function component:

<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
  <.input field={@form[:field]} type="text" />
</.form>

Always give the form an explicit, unique DOM ID, like id="todo-form".

Accessing form data from LiveView events

Problem: form.params is empty until phx-change="validate" fires.

Solution: Access current values from changeset:

def handle_event("test_connection", _params, socket) do
  changeset = socket.assigns.form.source
  form_data = Ecto.Changeset.apply_changes(changeset)  # Returns struct with all values

  # Convert to map if needed
  device_map = %{"ip_address" => form_data.ip_address, ...}
end

This combines: DB values + default values + user changes.

Avoiding form errors

Always use a form assigned via to_form/2 in the LiveView, and the <.input> component in the template. In the template always access forms this:

<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
  <.input field={@form[:field]} type="text" />
</.form>

And never do this:

<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
  <.input field={@changeset[:field]} type="text" />
</.form>
  • You are FORBIDDEN from accessing the changeset in the template as it will cause errors
  • Never use <.form let={f} ...> in the template, instead always use <.form for={@form} ...>, then drive all form references from the form assign as in @form[:field]. The UI should always be driven by a to_form/2 assigned in the LiveView module that is derived from a changeset