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 alias PhoenixKit.Modules.Entities.Multilang # ═══════════════════════════════════════════════════════════════════ # 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` """ 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: []) Phoenix.Component.assign(socket, 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 ) 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 the updated socket with the new `current_lang`. Ignores unknown language codes. """ def handle_switch_language(socket, lang_code) do if lang_code in safe_enabled_languages() do Phoenix.Component.assign(socket, :current_lang, lang_code) else 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 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 `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 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?(Multilang) and Multilang.enabled?() rescue _ -> false end defp safe_primary_language do Multilang.primary_language() rescue _ -> "en-US" end defp safe_enabled_languages do Multilang.enabled_languages() rescue _ -> [] end defp safe_build_language_tabs do 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 -> Multilang.put_language_data(existing_data, lang_code, validated_data) Multilang.multilang_data?(existing_data) -> 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 `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 the info alert. Default: true """ 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"""