How to add active class to menu button dynamically?

I’ve got a header.html.eex where I’ve got my menu buttons. When a button is clicked to be able to add an active class to the button we check whether the phoenix_controller and phoenix_action matches. However let’s say we’ve got a Plug and we needed to halt in the plug because there was something wrong.

How do I add active class to the menu button then, since the code doesn’t reach the controller?

# header.html.eex
<%= link Locales.translate(@user.default_locale,"shared.templates.header.account"),
                           to: Routes.user_path(@conn, :edit),
                           class: "navbar-link text-header header-#{at_this_page(@conn, UserPanel.UserController, :edit)}"
%>
  def at_this_page(
        %{:private => %{:phoenix_controller => controller, :phoenix_action => action}},
        controller,
        action
      ),
      do: "active"

What would be a better way?

So the problem with a plug (mounted in the router) is that it executes before anything controller related does run. And the values you depend upon are only set by the controller itself. The router cannot do that, given not every route is matched to a controller. So basically any plug running before MyAppWeb.SomeController.call is invoked can’t be sure there’s actually a controller later in the chain and therefore not know the controller and action of it.

What should work is if you place the plug in the controller itself instead of a the router level. Then the added plug will be invoked after MyAppWeb.SomeController.call, but before MyAppWeb.SomeController.action.

P.S.:
You should not use private API (which everything within :private is). But you can use Phoenix.Controller.controller_module(conn) and Phoenix.Controller.action_name(conn) instead of the pattern match.

2 Likes

So I should use that plug in every controller separately then, right?

Not necessarily, you can also inject it via a macro.

Where/how would I call that macro to be able to inject into all the controllers I need?

It depends a little on your project but in lib/your_app_web.ex you might have some code like this:

 def view do
    quote do
      use Phoenix.View,
        root: "lib/kitch_web/templates",
        namespace: KitchWeb

      # Import convenience functions from controllers
      import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]

      # Include shared imports and aliases for views
      unquote(view_helpers())
    end
  end

A good starting place for adding utilities to all your views can be to add them the to view_helpers/0 function:


  defp view_helpers do
    quote do
      # removed some code here for clarity
      # this is how the project I am working on sets an active class
      # you could add your function and use it similarly

      @doc """
      Get the active class
      """
      def active(%Conn{request_path: request_path}, path, class) when is_binary(class) do
        if request_path === path do
          class
        else
          ""
        end
      end
    end
  end

Then you can use this in your views like:

<%= link to: route, class: "navlink #{active(@conn, route,"active")}" do %>
  Some link
<% end %>

If you end up with lots of helpers it might be better to write them in a separate module and import that module instead.

2 Likes

This works only on dead views, but it does not get updated on live navigation.

If you refresh the page, it works, but not on subsequent calls on live_redirect/2.

I was replying the this specific question “Where/how would I call that macro to be able to inject into all the controllers I need?”.

You don’t have access to the Conn in a live view so you certainly couldn’t use the active class function in my example.

I’m not sure what your use case is but if you wanted to change active/3 in my example to match on the current live view you could do this:

def active(%Socket{view: view}, class) when is_binary(class) do
  if view === __MODULE__ do
    class
  else
    ""
  end
end

which would be used like:

live_redirect(gettext("Back"),
  to: Routes.ingredients_index_path(@socket, :index),
  class: active(@socket, "your-active-class")
)

Or to match on the actual path you could do something like this:

Helper:

def active(current_path, path, class) when is_binary(class) do
  if is_binary(current_path) and current_path === path do
    class
  else
    ""
  end
end

Usage in a template:

live_redirect(gettext("Back"),
  to: Routes.ingredients_index_path(@socket, :index),
  class: active(@path, Routes.ingredients_index_path(@socket, :index), "your-active-class")
)

And you need to add path to the assigns in handle_params:

  @impl true
  def handle_params(_, uri, socket) do
    {:noreply, assign(socket, :path, uri_to_path(uri))}
  end

  defp uri_to_path(raw_uri) do
    uri = URI.parse(raw_uri)
    uri.path
  end

At that point maybe a wrapper around live_redirect/2 makes more sense:

def active_live_redirect(label, opts) do
  to = Keyword.get(opts, :to)
  active_class = Keyword.get(opts, :active_class)
  class = Keyword.get(opts, :class)
  current_path = Keyword.get(opts, :current_path)

  opts =
    if current_path == to do
      Keyword.put(opts, :class, "#{class} #{active_class}")
    else
      opts
    end

    live_redirect(label, opts)
end

Usage:

active_live_redirect(gettext("Back"),
  to: Routes.ingredients_show_path(@socket, :show, @ingredient),
  current_path: @path,
  active_class: "your-active-class"
)
2 Likes

My bad, I didn’t catch the specific “controller” situation, in my case (and maybe generally) those menu buttons/links are part of “root.html.leex” which can be tricky as it serve both dead and live views.

Thank you for this detailed response :slight_smile:
I’ll try to see what can fit both cases

All good. I’m using a different live view for each top level “page” so my root.html.leex just uses link in the menus, I haven’t tried using live_redirect in layouts.

It sounds like the root layout is only rendered once which is why you need to refresh to see the active class change. From the live view docs:

The “root” layout is shared by both “app” and “live” layouts. It is rendered only on the initial request and therefore it has access to the @conn assign. … Note : If you find yourself needing to dynamically patch other parts of the base layout, such as injecting new scripts or styles into the <head> during live navigation, then a regular, non-live, page navigation should be used instead . Assigning the @page_title updates the document.title directly, and therefore cannot be used to update any other part of the base layout.