sb logoToday I Learned

5 posts about #phoenix

When to use the handle_params callback

The handle_params/3 callback is helpful for using the state in the URL to drive the presentation of your LiveView. This is nice because you can share the URL with anyone and see the same LiveView state. handle_params is invoked after mount or whenever there is a live navigation event. If your LiveView is changing state based on the URL, handle_params is the right place to assign values on your LiveView, as you will avoid processing both in mount/1 and handle_params/3. To trigger handle_params/3, push_patch/2 can be used server-side, while live_patch/2 will trigger handle_param/3 through a client-side interaction.

For example, imagine we want to use handle_params/3 to implement pagination, filtering, and sorting. Using these two examples, handle_params/3 can handle five different cases of URL state

  • only pagination /route?page=2&per_page=10
  • only filtering /route?filter=a
  • only sorting /route?sort_by=id&sort_order=asc
  • pagination, filtering, and sorting /route?page=2&per_page=10&filter=sneakers?sort_by=name&sort_order=asc
  • none specified (use defaults) /route
def handle_params(params, _url, socket) do
  paginate_options = %{page: params["page"], per_page: params["per_page"]}
  filter_options = %{filter: params["filter"]}
  sort_options = %{sort_by: params["sort_by"], sort_order: params["sort_order"]}

  shoes =
    Shoes.list_shoes(
      paginate: paginate_options,
      sort: sort_options,
      filter: filter_options
    )

  {:noreply,
    assign(socket,
      options: Map.merge(paginate_options, sort_options, filter_options),
      shoes: shoes
    )}
end

def handle_params(_params, _url, socket) do
  {:noreply, socket}
end

What you should know about the live_session macro

Imagine you have a few endpoints and would like to group their authorization rules. With live_session/3 , can achieve that!

live_session has three options:

  1. session - name of the session
  2. on_mount - callback function
  3. root_layout - apply a different layout to the group

It is important to understand the Security Considerations of live_session, especially for handling authentication and authorization in your LiveView.

In the following example, we use live_session to set a new root_layout only for admin users, and authorize admins only in the :adminUserHook

live_session :admins, 
  root_layout: {ExampleWeb.AdminLayoutView, :root},
  on_mount: {ExampleWeb.UserHook, :admin} do
  scope "/", ExampleWeb do
    pipe_through [:browser, :auth]

    live "/admin", HomeLive, :page
  end
end
defmodule ExampleWeb.AdminLayoutView do
  @moduledoc false
  
  use ExampleWeb, :view

  def render("root.html", assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <title>Admin Layout</title> 
      </head>
      <body>
        <h1>Admin</h1>
        <main>
          <%= @inner_content %>
        </main>
      </body>
    </html>
    """
  end
end

How to take leverage from on_mount to reduce code

Phoenix LiveView has implemented some cool features, and one of them is the on_mount/1 callback function.

This callback function will run before the mount/3 function in your LiveView.

There are two ways to set the on_mount callback function:

  1. In router using live_session/3.
  2. In your LiveView modules with on_mount macro.

If you need to do something before the mount/3 in all your LiveViews, live_session/3 is likely the best fit. However, if it isonly for a few them, the on_mount macro will be better for your needs.

on_mount is helpful for reducing repetitive code in your LiveViews. Let’s look at an example.

defmodule ExampleWeb.UserHook do
  import Phoenix.LiveView

  def on_mount(:default, _params, %{"current_user" => current_user} = _session, socket) do
    if authorized?(current_user) do
      {:cont, socket}
    else
      {:halt, socket}
    end
  end
  
  def on_mount(:admin, _params, %{"current_user" => current_user} = _session, socket) do
    if admin?(current_user) do
      {:cont, socket}
    else
      {:halt, socket}
    end
  end
end

The live_session/3 on Router:

live_session :default, on_mount: ExampleWeb.UserHook do
  scope "/", ExampleWeb do
    pipe_through [:browser, :auth]

    live "/", HomeLive, :page
  end
end

The on_mount macro:

defmodule ExampleWeb.HomeLive do
  use ExampleWeb, :live_view
  
  on_mount {ExampleWeb.UserHook, :admin}

  def mount(_params, _session, socket) do
    # ...
  end
end

Using Phoenix hooks to control parent DOM elements

I’m building a scrollable modal that overlays a screen that’s also scrollable. I find it to be a bit of an awkward UX if both the foreground and background are scrollable in this case, so I want to disable the background scrolling when the modal opens.

The problem is that the document body can’t see the state of my LiveView. Fortunately, LiveView (combined with Tailwind CSS in our case) can handle this in another way. Using hooks, we can tell our app to add a CSS class when our modal opens, and then remove the class on modal close.

<!-- root.html.eex -->
<body id="app">
  ...
  <%= @inner_content %>
</body>

And now we add our hook:

// assets/js/hooks/index.js
const hooks = {}

hooks.ToggleAppScroll = {
  mounted: () => {
    document.getElementById("app").classList.toggle('overflow-hidden');
  },
  destroyed: () => {
    document.getElementById("app").classList.toggle('overflow-hidden');
  }
}

export default hooks

And then in our modal component:

  def render(assigns) do
    ~L"""
      <div phx-hook="ToggleAppScroll" class="bg-gray-300 bg-opacity-50 fixed top-0">
          <!-- Modal content goes here -->
      </div>
     """
  end

And now any new component that wants to disable scrolling of the app simply has to add phx-hook="ToggleAppScroll" to its attributes. The phx-hook lifecycle will handle the rest.

Using Dynamic queries in Ecto

When you have a Phoenix Controller and you need to do a query based on the params, you might end up with something likes this:

defmodule App.PostController do
  def index(conn, params) do
     posts = App.Context.list_posts(params)
     render(conn, "index.html", posts: posts)
  end
end
defmodule App.Context do
   ......
   def  list_posts(params) do
    query = Post 

    query = if user_id = params["owner_id"] do
      query |> where([p], p.user_id == ^user_id)
    else
      query
    end

    Repo.all(query)
  end
  .....
end

There is a better way! Dynamic queries (https://hexdocs.pm/ecto/dynamic-queries.html)

defmodule App.Context do
   ......
   def  list_posts(params) do
    Post 
    |> where(^filter_where(params))
    |> Repo.all()
  end

  defp filter_where(params) do
    Enum.reduce(params, dynamic(true), fn
      {"owner_id", user_id}, dynamic ->
        dynamic([p], ^dynamic and p.user_id == ^user_id)
      {_, _}, dynamic ->
        dynamic
    end)
  end
  .....
end

Now, all your where clauses are in one place :)