- 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
34 KiB
This is a web application written using the Phoenix web framework.
Project guidelines
- Use
mix precommitalias 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)
- Positioning logic (e.g.,
- 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 testand ensure they pass - IMPORTANT: When you complete a task run
mix test --coverand ensure coverage is above the threshold - IMPORTANT: When you complete a task run
mix credo --strictto check for code quality issues and fix them - IMPORTANT: Run
mix dialyzerfor static type analysis — use.dialyzer_ignore.exsonly 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 precommitbefore 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.auditandmix hex.auditto check for security issues - IMPORTANT: When you add or update a dependency run
mix hex.outdatedto 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.Layoutsmodule is aliased in themy_app_web.exfile, so you can use it without needing to alias it again - Anytime you run into errors with no
current_scopeassign:- You failed to follow the Authenticated Routes guidelines, or you failed to pass
current_scopeto<Layouts.app> - Always fix the
current_scopeerror by moving your routes to the properlive_sessionand ensure you passcurrent_scopeas needed
- You failed to follow the Authenticated Routes guidelines, or you failed to pass
- Phoenix v1.8 moved the
<.flash_group>component to theLayoutsmodule. You are forbidden from calling<.flash_group>outside of thelayouts.exmodule - Out of the box,
core_components.eximports an<.icon name="hero-x-mark" class="w-5 h-5"/>component for for hero icons. Always use the<.icon>component for icons, never useHeroiconsmodules or similar - Always use the imported
<.input>component for form inputs fromcore_components.exwhen 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
@applywhen 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
srcor linkhrefin 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
- You cannot reference an external vendor'd script
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.exfor 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.authcreates multiple router plugs andlive_sessionscopes:- A plug
:fetch_current_scope_for_userthat is included in the default browser pipeline - A plug
:require_authenticated_userthat redirects to the log in page when the user is not authenticated - A
live_session :current_userscope - for routes that need the current user but don't require authentication - A
live_session :require_authenticated_userscope - for routes that require authentication - In both cases, a
@current_scopeis assigned to the Plug connection and LiveView socket - A plug
redirect_if_user_is_authenticatedthat redirects to a default path in case the user is authenticated
- A plug
- Always let the user know in which router scopes,
live_session, and pipeline you are placing the route, AND SAY WHY phx.gen.authassigns thecurrent_scopeassign - it does not assign acurrent_userassign- Always pass the assign
current_scopeto context modules as first argument. When performing queries, usecurrent_scope.userto filter the query results - To derive/access
current_userin templates, always use the@current_scope.user, never use@current_userin templates or LiveViews - Never duplicate
live_sessionnames. Alive_session :current_usercan only be defined once in the router, so all routes for thelive_session :current_usermust be grouped in a single block - Anytime you hit
current_scopeerrors or the logged in session isn't displaying the right content, always double check the router and ensure you are using the correct plug andlive_sessionas 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, orListfor 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 asmy_struct.fieldor use higher level APIs that are available on the struct if they exist,Ecto.Changeset.get_field/2for changesets -
Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common
Time,Date,DateTime, andCalendarinterfaces by accessing their documentation as necessary. Never install additional dependencies unless asked or for date/time parsing (which you can use thedate_time_parserpackage) -
Don't use
String.to_atom/1on user input (memory leak risk) -
Predicate function names should not start with
is_and should end in a question mark. Names likeis_thingshould be reserved for guards -
Elixir's builtin OTP primitives like
DynamicSupervisorandRegistry, require names in the child spec, such as{DynamicSupervisor, name: MyApp.MyDynamicSup}, then you can useDynamicSupervisor.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 passtimeout: :infinityas option -
Elixir has no
returnstatement, nor early returns. The last expression in a block is always returned -
Don't use
Enumfunctions on large collections whenStreamis more appropriate -
Avoid nested
casestatements — refactor to a singlecase,with, or separate functions -
Prefer
Enumfunctions likeEnum.reduceover 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 thanlist ++ [new] -
Use
withfor 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/1to 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/2for post-init work - Implement proper cleanup in
terminate/2when necessary - Use
GenServer.call/3for synchronous requests expecting replies - Use
GenServer.cast/2for fire-and-forget messages — when in doubt, prefercallfor back-pressure - Set appropriate timeouts for
call/3operations - Set up processes such that they can handle crashing and being restarted by supervisors
- Use
:max_restartsand:max_secondsto prevent restart loops - Use
Task.Supervisorfor better fault tolerance in async tasks - Handle task failures with
Task.yield/2orTask.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.exsor run all previously failed tests withmix test --failed mix deps.clean --allis almost never needed. Avoid using it unless you have good reason
Test guidelines
- Always use
start_supervised!/1to start processes in tests as it guarantees cleanup between tests - Avoid
Process.sleep/1andProcess.alive?/1in tests-
Instead of sleeping to wait for a process to finish, always use
Process.monitor/1and 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/1to ensure the process has handled prior messages
-
- Use
@tagto tag specific tests, andmix test --only tagto run only those tests - Use
assert_raisefor testing expected exceptions:assert_raise ArgumentError, fn -> invalid_function() end - Limit the number of failed tests with
mix test --max-failures n - Use
DataCasefor database tests,ConnCasefor controllers/LiveView - Use
async: truewhen possible - Never disable tests instead of fixing them
SNMP test mocking (project-specific)
snmp_adapter().get/3returns{:ok, value}or{:error, reason}snmp_adapter().walk/3returns{:ok, [%{oid: "...", value: ...}]}(list, not map)- Values already extracted (no type wrapper needed)
Common pitfalls:
- ❌
walkreturning{:ok, %{}}instead of{:ok, []} - ❌ Returning type wrappers like
{:integer, 123} - ❌ Not matching OIDs in get expectations
Phoenix guidelines
-
Remember Phoenix router
scopeblocks 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
aliasfor route definitions! Thescopeprovides the alias, ie:scope "/admin", ToweropsWeb.Admin do pipe_through :browser live "/users", UserLive, :index endthe UserLive route would point to the
ToweropsWeb.Admin.UserLivemodule -
Phoenix.Viewno 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.Queryand other supporting modules when you writeseeds.exs -
Ecto.Schemafields always use the:stringtype, even for:text, columns, ie:field :name, :string -
Ecto.Changeset.validate_number/2DOES NOT SUPPORT the:allow_niloption. 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 incastcalls or similar for security purposes. Instead they must be explicitly set when creating the struct -
Always invoke
mix ecto.gen.migration migration_name_using_underscoreswhen generating migration files, so the correct timestamp and conventions are applied -
Binary UUID Primary Keys: All tables MUST use
:binary_id, notbigint: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::textto avoid binary UUID data that causesJason.EncodeErrorduring 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 like0xFDthat 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
~Hor .html.heex files (known as HEEx), never use~E -
Always use the imported
Phoenix.Component.form/1andPhoenix.Component.inputs_for/1function to build forms. Never usePhoenix.HTML.form_fororPhoenix.HTML.inputs_foras 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'shtml_helpersblock, so they will be available to all LiveViews, LiveComponents, and all modules that douse ToweropsWeb, :html -
Elixir supports
if/elsebut does NOT supportif/else iforif/elsif. Never useelse iforelseifin Elixir, always usecondorcasefor 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 withphx-no-curly-interpolation:<code phx-no-curly-interpolation> let obj = {key: "val"} </code>Within
phx-no-curly-interpolationannotated 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_redirectandlive_patchfunctions, instead always use the<.link navigate={href}>and<.link patch={href}>in templates, andpush_navigateandpush_patchfunctions in LiveViews - Avoid LiveComponents unless you have a strong, specific need for them
- LiveViews should be named like
ToweropsWeb.WeatherLive, with aLivesuffix. When you go to add LiveView routes to the router, the default:browserscope is already aliased with theToweropsWebmodule, so you can just dolive "/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:
- Every LiveView implements
handle_params/3— ONLY place to read URL params - Validate all params, provide safe defaults
- Never use
assignfor URL-backed state — usepush_patch - 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)
- basic append of N items -
-
When using the
stream/3interfaces in the LiveView, the LiveView template must 1) always setphx-update="stream"on the parent element, with a DOM id on the parent element likeid="messages"and 2) consume the@streams.stream_namecollection and use the id as the DOM id for each child. For a call likestream(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/2orEnum.reject/2on 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"orphx-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 thephx-update="ignore"attribute - Always provide a unique DOM id alongside
phx-hookotherwise 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
nullindestroyed() - 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.LiveViewTestmodule andLazyHTML(included) for making your assertions -
Form tests are driven by
Phoenix.LiveViewTest'srender_submit/2andrender_change/2functions -
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.LiveViewTestfunctions likeelement/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.Componentfunctions 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
LazyHTMLselectors 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 ato_form/2assigned in the LiveView module that is derived from a changeset