Managing user locale in Phoenix LiveView
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ñ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