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

671 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
<!-- phoenix-gen-auth-start -->
## 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.
<!-- phoenix-gen-auth-end -->
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## 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:elixir-end -->
<!-- phoenix:phoenix-start -->
## 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
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## 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:ecto-end -->
<!-- phoenix:html-start -->
## 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:html-end -->
<!-- phoenix:liveview-start -->
## 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`:
```elixir
# 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:
```heex
<.link patch={~p"/devices/#{@device.id}?tab=overview"}
class={if @active_tab == "overview", do: "active"}>Overview</.link>
```
**Modal state**:
```elixir
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()`:
```typescript
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:
```elixir
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
<!-- phoenix:liveview-end -->
<!-- usage-rules-end -->