Managing user locale in Phoenix LiveView

Managing user locale in Phoenix LiveView
Photo by Jonatan Pie / Unsplash

Browsing the web, there are MANY ways to manage a users locale in Phoenix LiveView.

Here's the way I've decided to go for my application.

Let me paint the picture for you:

I have a top navbar which is a LiveView and rendered in the root.html.heex template using live_render. On this navbar exists a locale switcher which triggers a traditional phoenix controller action. Once the locale is changed, the navbar along with the currently router mounted LiveView should be remounted.

Let's dive in and see how to do this, as well as other niceties that allow for a smooth user experience.

First the relevant portion of mix.exs

# /mix.exs

defp deps do
  [
    {:phoenix, "~> 1.6"},
    {:phoenix_live_view, "~> 0.18"},
    {:gettext, "~> 0.18"}
  ]
end

We'll start with the plug which is responsible for putting items into the user session.

# lib/app_web/controllers/user_session_controller.ex

defmodule AppWeb.UserSessionController do
  import Plug.Conn
  ...
  @max_age 60 * 60 * 24 * 60
  @locale_cookie "_app_web_user_locale"
  @locale_options [sign: true, max_age: @max_age, same_site: "Lax"]
  ...

  def put_user_session(conn, _opts) do                                                                                                                                                                                                                                          
    with {:ok, %{locale: locale}} <- fetch_cookie_by_name(conn, "_app_web_user_locale") do                                                                                                                                                                                      
      conn =                                                                                                                                                                                                                                                                    
        conn                                                                                                                                                                                                                                                                    
        |> put_session(:user_locale, locale)                                                                                                                                                                                                                                    
                                                                                                                                                                                                                                                                                
      case conn.assigns.current_user do                                                                                                                                                                                                                                         
        nil ->                                                                                                                                                                                                                                                                  
          conn                                                                                                                                                                                                                                                                  
                                                                                                                                                                                                                                                                                
        user ->                                                                                                                                                                                                                                                                 
          conn                                                                                                                                                                                                                                                                  
          |> put_session(:user_id, user.id)                                                                                                                                                                                                                                     
      end                                                                                                                                                                                                                                                                       
    else                                                                                                                                                                                                                                                                        
      _ ->                                                                                                                                                                                                                                                                      
        locale =                                                                                                                                                                                                                                                                
          conn                                                                                                                                                                                                                                                                  
          |> fetch_locale_from_header()                                                                                                                                                                                                                                         
                                                                                                                                                                                                                                                                                
        conn =                                                                                                                                                                                                                                                                  
          conn                                                                                                                                                                                                                                                                  
          |> put_session(:user_locale, locale)                                                                                                                                                                                                                                  
                                                                                                                                                                                                                                                                                
        case conn.assigns.current_user do                                                                                                                                                                                                                                       
          nil ->                                                                                                                                                                                                                                                                
            conn                                                                                                                                                                                                                                                                
                                                                                                                                                                                                                                                                                
          user ->                                                                                                                                                                                                                                                               
            conn                                                                                                                                                                                                                                                                
            |> put_session(:user_id, user.id)                                                                                                                                                                                                                                   
        end                                                                                                                                                                                                                                                                     
    end                                                                                                                                                                                                                                                                         
  end                                                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                
  defp fetch_cookie_by_name(conn, cookie) do                                                                                                                                                                                                                                    
    conn                                                                                                                                                                                                                                                                        
    |> fetch_cookies(signed: ~w(#{cookie}))                                                                                                                                                                                                                                     
    |> Map.get(:cookies)                                                                                                                                                                                                                                                        
    |> Map.get(cookie)                                                                                                                                                                                                                                                          
    |> case do                                                                                                                                                                                                                                                                  
      nil -> {:error, :dne}                                                                                                                                                                                                                                                     
      cookie -> {:ok, cookie}                                                                                                                                                                                                                                                   
    end                                                                                                                                                                                                                                                                         
  end                                                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                
  defp fetch_locale_from_header(conn) do                                                                                                                                                                                                                                        
    conn                                                                                                                                                                                                                                                                        
    |> get_req_header("accept-language")                                                                                                                                                                                                                                        
    |> List.first()                                                                                                                                                                                                                                                             
    |> String.split(",")                                                                                                                                                                                                                                                        
    |> List.first()                                                                                                                                                                                                                                                             
    |> String.downcase()                                                                                                                                                                                                                                                        
    |> String.replace("-", "_")                                                                                                                                                                                                                                                 
  end
end

This plug first checks for a user locale cookie, if it doesn't exist it checks the accept-language header. The case where neither of these exists is handled in a LiveView which will be shown.

Then implement this plug within the :browser pipeline

Now we have the user locale potentially placed into the session from a cookie or from the accept-language header.

Now let's see how we're setting the cookie.

Within the navbar LiveView .heex file:

# lib/app_web/live/navigation/navigation_live.html.heex


<!-- Locale Selector -->                                                                                                                                                                                                                                                    
      <div class="dropdown dropdown-bottom mr-6">                                                                                                                                                                                                                               
        <button class="flex flex-row">                                                                                                                                                                                                                                          
          <%= MountHelpers.active_locale(@locale, %{class: "w-6 h-6 rounded-full mr-2"}) %>                                                                                                                                                                                     
        </button>                                                                                                                                                                                                                                                               
                                                                                                                                                                                                                                                                                
        <.form for={:locale_form} id="locale_form" :let={f} action={Routes.user_session_path(@socket, :set_locale)} phx-trigger-action={@trigger_submit}>                                                                                                                       
          <%= hidden_input(f, :locale, value: @locale) %>                                                                                                                                                                                                                       
               <div tabindex="0" class="dropdown-content w-52 card card-compact shadow bg-base-100">                                                                                                                                                                            
                 <ul tabindex="0">                                                                                                                                                                                                                                              
                   <li class="px-4 py-2 block hover:bg-base-200 flex flex-row hover:cursor-pointer" phx-click="set-locale" phx-value-locale="en_us">                                                                                                                            
                     <img class="w-6 h-6 rounded-full mr-2" src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Flag_of_the_United_States_%28fixed%29.svg/512px-Flag_of_the_United_States_%28fixed%29.svg.png">                                                      
                     English (US)                                                                                                                                                                                                                                               
                   </li>                                                                                                                                                                                                                                                        
                   <li class="px-4 py-2 block hover:bg-base-200 flex flex-row hover:cursor-pointer" phx-click="set-locale" phx-value-locale="es_mx">                                                                                                                            
                     <img class="w-6 h-6 rounded-full mr-2" alt="Flag of Mexico" src="https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Flag_of_Mexico.svg/512px-Flag_of_Mexico.svg.png">                                                                               
                     Espa&#241;ol                                                                                                                                                                                                                                               
                   </li>                                                                                                                                                                                                                                                        
                 </ul>                                                                                                                                                                                                                                                          
               </div>                                                                                                                                                                                                                                                           
        </.form>                                                                                                                                                                                                                                                                
      </div>

First of all we have the button which visually looks like whatever is rendered by calling MountHelpers.active_locale/2 with the @locale which is present in the LiveView assigns and looks like "en_us" for example.

The important bits here are the form. When one of the list items is clicked the LiveView event set-locale is triggered – see phx-click="set-locale'. This handle_event simply changes the locale assigns, and changes the trigger_submit assigns to true.

This causes a re-render, and phx-trigger-action={@trigger_submit} is rendered with true, which triggers the action action={Routes.user_session_path(@socket, :set_locale)}. This calls the :set_locale function defined in user_session_controller.ex.

# lib/app_web/controllers/user_session_controller.ex

defmodule AppWeb.UserSessionController do
  ...
  
  def set_locale(conn, %{"locale_form" => %{"locale" => locale}}) do                                                                                                                                                                                                            
    referer =                                                                                                                                                                                                                                                                   
      conn                                                                                                                                                                                                                                                                      
      |> get_req_header("referer")                                                                                                                                                                                                                                              
      |> List.first()                                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                
    redirect_to =                                                                                                                                                                                                                                                               
      referer                                                                                                                                                                                                                                                                   
      |> String.split("/", parts: 4)                                                                                                                                                                                                                                            
      |> Enum.at(3)                                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                                                                
    conn                                                                                                                                                                                                                                                                        
    |> put_resp_cookie(@locale_cookie, %{locale: locale}, @locale_options)                                                                                                                                                                                                      
    |> redirect(to: "/#{redirect_to}")                                                                                                                                                                                                                                          
  end
end

When this action is called the user is navigated to the path you've set for this action in router.ex. For me is users/set_locale. Once the action is taken, you must direct the user somewhere. To create a continuous user experience, I redirect them to the page they was visiting when they triggered the action! From the users perspective, the page refreshed and the visible text has been translated.

This is possible using the "referer" header in the request. Which simply tracks the path that referred the user to the current url, in this instance /users/set_locale.

Now say there is no cookie and no acccept-language header. Well if a LiveView is unable to pull a user_locale from the user session, it defaults to "en_us".

Using an on_mount hook which is called whenever a LiveView is connected or disconnected we can do:

defmodule AppWeb.Hooks.RestoreLocale do                                                                                                                                                                                                                                         
  import Phoenix.LiveView                                                                                                                                                                                                                                                       
  import Phoenix.Component                                                                                                                                                                                                                                                      
                                                                                                                                                                                                                                                                                
  def on_mount(:default, _params, session, socket) do                                                                                                                                                                                                                           
    locale =                                                                                                                                                                                                                                                                    
      get_locale(session)                                                                                                                                                                                                                                                       
      |> gettext_set()                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                
    {:cont, assign(socket, :locale, locale)}                                                                                                                                                                                                                                    
  end                                                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                
  defp get_locale(session) do                                                                                                                                                                                                                                                   
    session                                                                                                                                                                                                                                                                     
    |> Map.get("user_locale")                                                                                                                                                                                                                                                   
    |> case do                                                                                                                                                                                                                                                                  
      nil -> "en_us"                                                                                                                                                                                                                                                            
      locale -> locale                                                                                                                                                                                                                                                          
    end                                                                                                                                                                                                                                                                         
  end                                                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                
  defp gettext_set(locale) do                                                                                                                                                                                                                                                   
    Gettext.put_locale(DogWeb.Gettext, locale)                                                                                                                                                                                                                                  
    locale                                                                                                                                                                                                                                                                      
  end                                                                                                                                                                                                                                                                           
end

This hook can be utilized by:

# lib/app_web.ex

defmodule AppWeb do
  ...
  def live_view do
    quote do
      ...
      on_mount AppWeb.Hooks.RestoreLocale
      ...
    end
  end
  ...
end

and in the navbar liveview

# lib/app_web/live/navigation/navigation_live.ex

defmodule AppWeb.NavigationLive do                                                                                                                                                                                                                                              
  use AppWeb, :live_view                                                                                                                                                                                                                                                        
                                                                                                                                                                                                                                                                                
  alias App.Accounts.User                                                                                                                                                                                                                                                       
  alias App.Repo                                                                                                                                                                                                                                                                
                                                                                                                                                                                                                                                                                
  def mount(_params, session, socket) do                                                                                                                                                                                                                                        
    {:ok, assign(socket, trigger_submit: false)}                                                                                                                                                                                                                                
  end                                                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                
  def handle_event("set-locale", %{"locale" => locale}, socket) do                                                                                                                                                                                                              
    socket =                                                                                                                                                                                                                                                                    
      socket                                                                                                                                                                                                                                                                    
      |> assign(locale: locale)                                                                                                                                                                                                                                                 
                                                                                                                                                                                                                                                                                
    {:noreply, assign(socket, trigger_submit: true)}                                                                                                                                                                                                                            
  end                                                                                                                                                                                                                                                                           
end

https://twitter.com/i_ofin