defmodule PhoenixKitWeb.Components.MultilangForm do @moduledoc """ Shared multilang form components and helpers for PhoenixKit modules. Provides the language tab switcher UI, skeleton loading placeholders, translatable field components, and Elixir-side helpers for merging multilang data in LiveView forms. Designed for two main use cases: 1. **Whole-form translation** — wrap all translatable fields in a card with language tabs. The tab bar, skeleton placeholders, and field wrappers are handled automatically. 2. **Single-field translation** — drop a `<.translatable_field>` into any form to make one field translatable, with no tab UI required (the caller manages `current_lang` however they like). ## Usage in a LiveView ### Mount import PhoenixKitWeb.Components.MultilangForm def mount(params, session, socket) do # ... load your record and changeset ... {:ok, mount_multilang(socket)} end ### Events def handle_event("switch_language", %{"lang" => lang_code}, socket) do {:noreply, handle_switch_language(socket, lang_code)} end def handle_event("validate", %{"record" => params}, socket) do params = merge_translatable_params(params, socket, ["name", "description"], changeset: socket.assigns.changeset) changeset = MySchema.changeset(socket.assigns.record, params) {:noreply, assign(socket, :changeset, changeset)} end ### Template — whole-form translation <.multilang_tabs multilang_enabled={@multilang_enabled} language_tabs={@language_tabs} current_lang={@current_lang} /> <.multilang_fields_wrapper multilang_enabled={@multilang_enabled} current_lang={@current_lang} > <.translatable_field field_name="name" form_prefix="catalogue" changeset={@changeset} schema_field={:name} multilang_enabled={@multilang_enabled} current_lang={@current_lang} primary_language={@primary_language} lang_data={@lang_data} label={gettext("Name")} required /> ### Template — single-field translation (no tabs needed) <.translatable_field field_name="description" form_prefix="product" changeset={@changeset} schema_field={:description} multilang_enabled={@multilang_enabled} current_lang={@current_lang} primary_language={@primary_language} lang_data={@lang_data} label={gettext("Description")} type="textarea" rows={5} /> """ use Phoenix.Component use Gettext, backend: PhoenixKitWeb.Gettext import PhoenixKitWeb.Components.Core.Icon import PhoenixKitWeb.Components.Core.FormFieldError, only: [error: 1] import PhoenixKitWeb.Components.Core.Input, only: [translate_error: 1] import PhoenixKitWeb.Components.LanguageSwitcher, only: [language_switcher: 1] alias Phoenix.LiveView.JS # PhoenixKit.Utils.Multilang is in an external package — referenced by full name # with Code.ensure_loaded?/rescue guards throughout this module. # ═══════════════════════════════════════════════════════════════════ # Mount & Event Helpers # ═══════════════════════════════════════════════════════════════════ @doc """ Adds multilang assigns to the socket. Call from `mount/3`. Adds: `:multilang_enabled`, `:primary_language`, `:current_lang`, `:language_tabs`, `:show_multilang_tabs`, `:switching_lang` (no-op compat assign — kept because consumer templates pass it through to the wrapper). Also attaches an internal `:handle_info` hook that receives the debounced language-switch timer message. """ def mount_multilang(socket) do multilang_enabled = multilang_enabled?() primary_language = if multilang_enabled, do: safe_primary_language(), else: nil language_tabs = if(multilang_enabled, do: safe_build_language_tabs(), else: []) socket |> Phoenix.Component.assign( multilang_enabled: multilang_enabled, primary_language: primary_language, current_lang: primary_language, language_tabs: language_tabs, show_multilang_tabs: multilang_enabled and length(language_tabs) > 1, # Kept for backwards compat with consumer templates that still pass # `switching_lang={@switching_lang}` to `<.multilang_fields_wrapper>`. # The wrapper no longer reads it — visibility is client-side JS. switching_lang: false ) |> attach_multilang_hook() end # Attaches a `:handle_info` hook that intercepts the internal # `{:__multilang_apply_lang__, lang}` message. The hook lives on the # consumer's socket but is invisible to them — no `handle_info/2` # clause needed. Returning `{:halt, socket}` prevents the message # from reaching the consumer's own handle_info (and suppresses the # "unhandled message" warning). Other messages pass through with # `{:cont, socket}`. # # `attach_hook/4` is idempotent-by-name within a process — re-running # `mount_multilang/1` (e.g. across reconnects) with the same hook id # simply replaces the prior callback, so we don't need a guard. defp attach_multilang_hook(socket) do Phoenix.LiveView.attach_hook( socket, :__phoenix_kit_multilang_apply_lang__, :handle_info, fn {:__multilang_apply_lang__, lang_code}, socket -> {:halt, handle_multilang_apply_lang(socket, lang_code)} _msg, socket -> {:cont, socket} end ) rescue # `attach_hook/4` only works on LiveView sockets, not LiveComponent # sockets. Multilang consumers are all `Phoenix.LiveView` today, but # if someone wires it into a component in the future, fall back # silently — they'll need to add the `handle_info` themselves. ArgumentError -> socket end @doc """ Refreshes multilang assigns after external changes (e.g. entity schema update). Unlike `mount_multilang/1`, this preserves `current_lang` when it's still valid, and resets it to the primary language if it was removed. """ def refresh_multilang(socket) do multilang_enabled = multilang_enabled?() primary_language = if multilang_enabled, do: safe_primary_language(), else: nil language_tabs = if multilang_enabled, do: safe_build_language_tabs(), else: [] current_lang = socket.assigns[:current_lang] enabled_langs = if multilang_enabled, do: safe_enabled_languages(), else: [] current_lang = cond do not multilang_enabled -> nil current_lang not in enabled_langs -> primary_language true -> current_lang end Phoenix.Component.assign(socket, multilang_enabled: multilang_enabled, primary_language: primary_language, current_lang: current_lang, language_tabs: language_tabs, show_multilang_tabs: multilang_enabled and length(language_tabs) > 1 ) end @doc """ Handles the `"switch_language"` event. Call from `handle_event/3`. Returns a socket that **defers** applying `:current_lang` via a short trailing debounce (150 ms). Rapid click-through (EN → JA → FR → DE) keeps rescheduling the timer; only the last click actually updates `:current_lang` and triggers a content re-render. Without this, every intermediate event caused its own server render and the client briefly flashed each language's content before landing on the final one. Nothing to do on the consumer's end — `mount_multilang/1` attaches a `:handle_info` hook via `Phoenix.LiveView.attach_hook/4` that intercepts the internal `{:__multilang_apply_lang__, lang}` message and applies the language transparently. Calling code never sees the message. Ignores unknown language codes. """ @multilang_debounce_ms 150 # The timer ref lives in `socket.private` (not `socket.assigns`) so # that storing it doesn't trigger a render+diff cycle that would fight # the client-side skeleton/fields visibility toggles. Private state # is per-socket — visible only inside the LV process — and survives # the LV's lifecycle the same way assigns do, but with zero diff cost. @multilang_timer_private_key :__phoenix_kit_multilang_timer__ def handle_switch_language(socket, lang_code) do if lang_code in safe_enabled_languages() do socket = cancel_multilang_timer(socket) timer_ref = Process.send_after( self(), {:__multilang_apply_lang__, lang_code}, @multilang_debounce_ms ) # Skeleton/fields visibility is driven entirely by # `switch_lang_js/2`'s client-side class toggles. The server just # holds off applying the new `current_lang` until the debounce # fires, which is what causes the final morphdom swap that # restores the fields (with new-lang content) and hides the # skeleton again. Phoenix.LiveView.put_private(socket, @multilang_timer_private_key, timer_ref) else socket end end @doc """ Applies a debounced language change. Call from `handle_info/2` when `{:__multilang_apply_lang__, lang_code}` is received. """ def handle_multilang_apply_lang(socket, lang_code) do socket |> Phoenix.LiveView.put_private(@multilang_timer_private_key, nil) |> Phoenix.Component.assign(:current_lang, lang_code) end defp cancel_multilang_timer(socket) do case Map.get(socket.private, @multilang_timer_private_key) do ref when is_reference(ref) -> Process.cancel_timer(ref) Phoenix.LiveView.put_private(socket, @multilang_timer_private_key, nil) _ -> socket end end @doc """ Merges translatable field params into the multilang `data` JSONB structure. Takes the raw form params, the socket, and a list of translatable field names (the DB column names, e.g. `["name", "description"]`). Returns updated params with the `"data"` key set to the merged multilang structure. On primary language tabs, reads from `params["name"]`. On secondary language tabs, reads from `params["lang_name"]`. Also preserves primary language values for non-translatable fields when on secondary tabs via the `preserve_fields` option. ## Options * `:changeset` — the current changeset (required) * `:preserve_fields` — map of `%{"field_name" => :schema_field}` for fields that should keep their primary language DB column value on secondary tabs. Defaults to `%{}`. """ def merge_translatable_params(params, socket, translatable_fields, opts \\ []) do changeset = Keyword.fetch!(opts, :changeset) preserve_fields = Keyword.get(opts, :preserve_fields, %{}) assigns = socket.assigns current_lang = assigns[:current_lang] primary = assigns[:primary_language] params = if assigns[:multilang_enabled] do form_data = extract_translatable_data(params, translatable_fields, current_lang, primary) final_data = do_merge_multilang_data(changeset, current_lang, form_data, assigns) Map.put(params, "data", final_data) else params end do_preserve_primary_fields(params, changeset, assigns, preserve_fields) end @doc """ Injects a DB column value into the JSONB `data` field for multilang storage. This handles the common pattern where a field exists both as a top-level DB column (for queries/sorting) and inside the JSONB `data` (for translations). On the primary language tab, reads from `params[field_name]` (the DB column input). On secondary language tabs, reads from `params["lang_" <> field_name]` (the translation input). The value is stored in the data map under `"_" <> field_name` (e.g. `"_title"`). If no value is submitted (field not in form), preserves the existing value from the changeset's JSONB data. ## Requirements `assigns` must contain: - `:multilang_enabled` — boolean - `:primary_language` — the primary language code - `:changeset` — an `Ecto.Changeset` with a `:data` field (JSONB) ## Examples # In handle_event("validate", ...) form_data = form_data |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns) |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns) """ def inject_db_field_into_data(form_data, field_name, params, current_lang, assigns) do if assigns[:multilang_enabled] == true do primary = assigns[:primary_language] value = if current_lang == primary, do: params[field_name], else: params["lang_#{field_name}"] data_key = "_#{field_name}" if is_binary(value) do Map.put(form_data, data_key, value) else # No value submitted — preserve existing from JSONB data existing_data = safe_get_changeset_data(assigns.changeset) case PhoenixKit.Utils.Multilang.get_raw_language_data(existing_data, current_lang) do %{^data_key => existing} -> Map.put(form_data, data_key, existing) _ -> form_data end end else form_data end end @doc """ Merges language-specific validated data into the full multilang JSONB structure. Reads existing data from the changeset's `:data` field, then merges the new `validated_data` for the given `lang_code`. Handles three cases: - Multilang enabled: uses `PhoenixKit.Utils.Multilang.put_language_data/3` - Multilang disabled but data has multilang structure: preserves translations - Flat data, no multilang: passes through as-is The `changeset` must be an `Ecto.Changeset` with a `:data` field. `assigns` must contain `:multilang_enabled`. """ def merge_multilang_data(changeset, lang_code, validated_data, assigns) do do_merge_multilang_data(changeset, lang_code, validated_data, assigns) end @doc """ Gets the raw language data for the current language from a changeset. Use this in templates to read override-only values for secondary language tabs. Returns `%{}` when multilang is disabled. """ def get_lang_data(changeset, current_lang, multilang_enabled) do if multilang_enabled && changeset do PhoenixKit.Utils.Multilang.get_raw_language_data( Ecto.Changeset.get_field(changeset, :data), current_lang ) else %{} end end @doc """ Returns true when on the primary language tab (or multilang is disabled). """ def primary_tab?(assigns) do !assigns[:multilang_enabled] || assigns[:current_lang] == assigns[:primary_language] end @doc """ Preserves primary-language DB field values when on a secondary language tab. On secondary tabs, some fields (like title, slug) are absent from form params because they're replaced by `lang_*` inputs. This function fills in the missing values from the changeset so the DB columns keep their primary-language values. `preserve_fields` is a map of `%{"field_name" => :schema_field}`. No-ops when multilang is disabled or on the primary tab. """ def preserve_primary_fields(params, changeset, assigns, preserve_fields) do do_preserve_primary_fields(params, changeset, assigns, preserve_fields) end @doc "Returns true when the Languages module is enabled with 2+ languages." def multilang_enabled? do Code.ensure_loaded?(PhoenixKit.Utils.Multilang) and PhoenixKit.Utils.Multilang.enabled?() rescue _ -> false end defp safe_primary_language do PhoenixKit.Utils.Multilang.primary_language() rescue _ -> "en-US" end defp safe_enabled_languages do PhoenixKit.Utils.Multilang.enabled_languages() rescue _ -> [] end defp safe_build_language_tabs do PhoenixKit.Utils.Multilang.build_language_tabs() rescue _ -> [] end # Safely reads a field from a changeset, returning "" on any error. defp safe_get_field(%Ecto.Changeset{} = changeset, field) when is_atom(field) do Ecto.Changeset.get_field(changeset, field) || "" rescue _ -> "" end defp safe_get_field(_changeset, _field), do: "" # Safely reads the :data field from a changeset, returning %{} on any error. defp safe_get_changeset_data(%Ecto.Changeset{} = changeset) do Ecto.Changeset.get_field(changeset, :data) || %{} rescue _ -> %{} end defp safe_get_changeset_data(_), do: %{} # ── Private helpers ──────────────────────────────────────────── defp extract_translatable_data(params, fields, current_lang, primary) do Enum.reduce(fields, %{}, fn field, acc -> value = if current_lang == primary, do: params[field], else: params["lang_#{field}"] if is_binary(value), do: Map.put(acc, "_#{field}", value), else: acc end) end defp do_merge_multilang_data(changeset, lang_code, validated_data, assigns) do existing_data = safe_get_changeset_data(changeset) cond do assigns[:multilang_enabled] == true -> PhoenixKit.Utils.Multilang.put_language_data(existing_data, lang_code, validated_data) PhoenixKit.Utils.Multilang.multilang_data?(existing_data) -> PhoenixKit.Utils.Multilang.put_language_data(existing_data, lang_code, validated_data) true -> validated_data end end defp do_preserve_primary_fields(params, _changeset, assigns, preserve_fields) when map_size(preserve_fields) == 0 or not is_map_key(assigns, :multilang_enabled) do params end defp do_preserve_primary_fields(params, changeset, assigns, preserve_fields) do if assigns[:multilang_enabled] && assigns[:current_lang] != assigns[:primary_language] do Enum.reduce(preserve_fields, params, fn {str_key, atom_key}, acc -> preserve_field_value(acc, changeset, str_key, atom_key) end) else params end end defp preserve_field_value(params, changeset, str_key, atom_key) do if Map.has_key?(params, str_key) do params else case Ecto.Changeset.get_field(changeset, atom_key) do nil -> params value -> Map.put(params, str_key, value) end end end # ═══════════════════════════════════════════════════════════════════ # Components # ═══════════════════════════════════════════════════════════════════ @doc """ Renders the language tab bar with compact/full mode. Shows a header with language icon, an info alert explaining the translation workflow, and the shared `<.language_switcher>` in `:tabs` variant with flags, names, and primary star indicator. Display mode: - `compact: nil` (default) — auto: full names when ≤ 5 languages, short codes when more - `compact: true` — always short codes - `compact: false` — always full names Delegates to `PhoenixKitWeb.Components.LanguageSwitcher.language_switcher/1` for the tab bar rendering. ## Attributes * `multilang_enabled` — boolean, whether multilang is active * `language_tabs` — list of tab maps from `PhoenixKit.Utils.Multilang.build_language_tabs/0` * `current_lang` — the currently selected language code * `compact` — force compact mode (short codes). Default: nil (auto) * `show_header` — show the "Content Language" header. Default: true * `show_info` — show an info tooltip next to the header. Default: true. The tooltip surface explains the primary-language / fallback semantics on hover; requires `show_header: true` to have an anchor element. """ attr :multilang_enabled, :boolean, required: true attr :language_tabs, :list, required: true attr :current_lang, :string, required: true attr :compact, :boolean, default: nil attr :show_header, :boolean, default: true attr :show_info, :boolean, default: true attr :class, :string, default: "card-body pb-0" def multilang_tabs(assigns) do display = cond do assigns.compact == true -> :compact assigns.compact == false -> :full true -> :auto end assigns = assign(assigns, :display, display) ~H"""