diff --git a/lib/modules/entities/web/data_form.ex b/lib/modules/entities/web/data_form.ex index 17a5fb751..ab9323fc3 100644 --- a/lib/modules/entities/web/data_form.ex +++ b/lib/modules/entities/web/data_form.ex @@ -7,7 +7,8 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do use PhoenixKitWeb, :live_view on_mount PhoenixKit.Modules.Entities.Web.Hooks - alias Phoenix.LiveView.JS + import PhoenixKitWeb.Components.MultilangForm + alias PhoenixKit.Modules.Entities alias PhoenixKit.Modules.Entities.EntityData alias PhoenixKit.Modules.Entities.Events @@ -93,9 +94,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do live_source = ensure_live_source(socket) # Multilang state (driven by Languages module globally) - multilang_enabled = Multilang.enabled?() - primary_language = if multilang_enabled, do: Multilang.primary_language(), else: nil - language_tabs = Multilang.build_language_tabs() + multilang_enabled = multilang_enabled?() # Lazy re-key: if global primary changed since this record was saved, # restructure data around the new primary language. @@ -122,10 +121,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do |> assign(:form_record_topic_key, normalize_record_key(form_record_key)) |> assign(:live_source, live_source) |> assign(:has_unsaved_changes, false) - |> assign(:multilang_enabled, multilang_enabled) - |> assign(:primary_language, primary_language) - |> assign(:current_lang, primary_language) - |> assign(:language_tabs, language_tabs) + |> mount_multilang() socket = if connected?(socket) do @@ -220,251 +216,42 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do @impl true def handle_event("switch_language", %{"lang" => lang_code}, socket) do - enabled = Multilang.enabled_languages() - - if lang_code in enabled do - {:noreply, assign(socket, :current_lang, lang_code)} - else - {:noreply, socket} - end + {:noreply, handle_switch_language(socket, lang_code)} end def handle_event("validate", %{"phoenix_kit_entity_data" => data_params}, socket) do if socket.assigns[:lock_owner?] do - entity_uuid = socket.assigns.entity.uuid - record_uuid = socket.assigns.data_record.uuid - form_data = Map.get(data_params, "data", %{}) - - data_params = - if socket.assigns.data_record.uuid do - data_params - else - data_params - |> Map.put("created_by_uuid", socket.assigns.current_user.uuid) - end - - data_params = - if is_nil(record_uuid) do - # Always use the primary language title for slug generation - current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset) - previous_title = current_data.title || "" - - title = - if data_params["title"] do - data_params["title"] - else - # On secondary language tab, title param is absent — keep the existing title - previous_title - end - - current_slug = data_params["slug"] || "" - - auto_generated_slug = - auto_generate_entity_slug(entity_uuid, record_uuid, previous_title) - - if current_slug == "" || current_slug == auto_generated_slug do - Map.put( - data_params, - "slug", - auto_generate_entity_slug(entity_uuid, record_uuid, title) - ) - else - data_params - end - else - data_params - end - - current_lang = socket.assigns[:current_lang] - - # Inject _title and _slug into form data so they flow through merge_multilang_data - form_data = - form_data - |> inject_title_into_form_data(data_params, current_lang, socket.assigns) - |> inject_slug_into_form_data(data_params, current_lang, socket.assigns) - - # On secondary language tabs, preserve primary-language fields that aren't in the form - data_params = preserve_primary_fields(data_params, socket.assigns.changeset) - - case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do - {:ok, validated_data} -> - validated_data = - validated_data - |> inject_title_into_form_data(data_params, current_lang, socket.assigns) - |> inject_slug_into_form_data(data_params, current_lang, socket.assigns) - - # Strip lang_title/lang_slug after injection — not schema fields - data_params = - data_params - |> Map.delete("lang_title") - |> Map.delete("lang_slug") - - final_data = merge_multilang_data(socket.assigns, current_lang, validated_data) - params = Map.put(data_params, "data", final_data) - - changeset = - socket.assigns.data_record - |> EntityData.change(params) - |> Map.put(:action, :validate) - - socket = - socket - |> assign(:changeset, changeset) - |> broadcast_data_form_state(params) - - {:noreply, socket} - - {:error, errors} -> - # Preserve full multilang data in both changeset and broadcast - error_data = merge_multilang_data(socket.assigns, current_lang, form_data) - - data_params = - data_params - |> Map.delete("lang_title") - |> Map.delete("lang_slug") - - error_params = Map.put(data_params, "data", error_data) - - changeset = - socket.assigns.data_record - |> EntityData.change(error_params) - |> add_form_errors(errors) - |> Map.put(:action, :validate) - - socket = - socket - |> assign(:changeset, changeset) - |> broadcast_data_form_state(error_params) - - {:noreply, socket} - end + do_validate(data_params, socket) else # Spectator - ignore local changes, wait for broadcasts {:noreply, socket} end + rescue + e -> + require Logger + + Logger.error( + "Entity data validate failed: #{Exception.message(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}" + ) + + {:noreply, put_flash(socket, :error, gettext("Validation error — your data is preserved."))} end def handle_event("save", %{"phoenix_kit_entity_data" => data_params}, socket) do if socket.assigns[:lock_owner?] do - # Extract the data field from params - form_data = Map.get(data_params, "data", %{}) - - current_lang = socket.assigns[:current_lang] - - # Inject _title and _slug into form data so they flow through merge_multilang_data - form_data = - form_data - |> inject_title_into_form_data(data_params, current_lang, socket.assigns) - |> inject_slug_into_form_data(data_params, current_lang, socket.assigns) - - # On secondary language tabs, preserve primary-language fields that aren't in the form - data_params = preserve_primary_fields(data_params, socket.assigns.changeset) - - # Validate the form data against entity field definitions - case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do - {:ok, validated_data} -> - validated_data = - validated_data - |> inject_title_into_form_data(data_params, current_lang, socket.assigns) - |> inject_slug_into_form_data(data_params, current_lang, socket.assigns) - - # Strip lang_title/lang_slug after injection — not schema fields - data_params = - data_params - |> Map.delete("lang_title") - |> Map.delete("lang_slug") - - final_data = merge_multilang_data(socket.assigns, current_lang, validated_data) - - # Add metadata to params - params = - data_params - |> Map.put("data", final_data) - |> maybe_add_creator_uuid(socket.assigns.current_user, socket.assigns.data_record) - - try do - case save_data_record(socket, params) do - {:ok, saved_record} -> - if socket.assigns.data_record.uuid do - # Update — stay on page, refresh changeset from saved record - changeset = EntityData.change(saved_record) - - socket = - socket - |> assign(:data_record, saved_record) - |> assign(:changeset, changeset) - |> put_flash(:info, gettext("Data record saved successfully")) - |> broadcast_data_form_state(params) - - {:noreply, socket} - else - # Create — navigate to the edit page for the new record - entity_name = socket.assigns.entity.name - - socket = - socket - |> put_flash(:info, gettext("Data record created successfully")) - |> push_navigate( - to: - Routes.path( - "/admin/entities/#{entity_name}/data/#{saved_record.uuid}/edit", - locale: socket.assigns.current_locale_base - ) - ) - - {:noreply, socket} - end - - {:error, %Ecto.Changeset{} = changeset} -> - socket = - socket - |> assign(:changeset, changeset) - |> broadcast_data_form_state(params) - - {:noreply, socket} - end - rescue - e -> - require Logger - Logger.error("Entity data save failed: #{Exception.message(e)}") - - {:noreply, - put_flash(socket, :error, gettext("Something went wrong. Please try again."))} - end - - {:error, errors} -> - # Preserve full multilang data in both changeset and broadcast - error_data = merge_multilang_data(socket.assigns, current_lang, form_data) - - data_params = - data_params - |> Map.delete("lang_title") - |> Map.delete("lang_slug") - - error_params = Map.put(data_params, "data", error_data) - - changeset = - socket.assigns.data_record - |> EntityData.change(error_params) - |> add_form_errors(errors) - - error_list = - Enum.map_join(errors, "; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end) - - socket = - socket - |> assign(:changeset, changeset) - |> put_flash( - :error, - gettext("Field validation errors: %{errors}", errors: error_list) - ) - |> broadcast_data_form_state(error_params) - - {:noreply, socket} - end + do_save(data_params, socket) else {:noreply, put_flash(socket, :error, gettext("Cannot save - you are spectating"))} end + rescue + e -> + require Logger + + Logger.error( + "Entity data save failed: #{Exception.message(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}" + ) + + {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))} end def handle_event("reset", _params, socket) do @@ -510,6 +297,245 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do end end + # ── Validate/Save helpers (below all handle_event clauses to avoid grouping warnings) ── + + defp do_validate(data_params, socket) do + entity_uuid = socket.assigns.entity.uuid + record_uuid = socket.assigns.data_record.uuid + form_data = Map.get(data_params, "data", %{}) + + data_params = + if socket.assigns.data_record.uuid do + data_params + else + data_params + |> Map.put("created_by_uuid", socket.assigns.current_user.uuid) + end + + data_params = + if is_nil(record_uuid) do + # Always use the primary language title for slug generation + current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset) + previous_title = current_data.title || "" + + title = + if data_params["title"] do + data_params["title"] + else + # On secondary language tab, title param is absent — keep the existing title + previous_title + end + + current_slug = data_params["slug"] || "" + + auto_generated_slug = + auto_generate_entity_slug(entity_uuid, record_uuid, previous_title) + + if current_slug == "" || current_slug == auto_generated_slug do + Map.put( + data_params, + "slug", + auto_generate_entity_slug(entity_uuid, record_uuid, title) + ) + else + data_params + end + else + data_params + end + + current_lang = socket.assigns[:current_lang] + + # Inject _title and _slug into form data so they flow through multilang merge + 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) + + # On secondary language tabs, preserve primary-language fields that aren't in the form + data_params = preserve_primary_fields(data_params, socket.assigns.changeset) + + case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do + {:ok, validated_data} -> + validated_data = + validated_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) + + data_params = strip_lang_params(data_params) + + final_data = + merge_multilang_data( + socket.assigns.changeset, + current_lang, + validated_data, + socket.assigns + ) + + params = Map.put(data_params, "data", final_data) + + changeset = + socket.assigns.data_record + |> EntityData.change(params) + |> Map.put(:action, :validate) + + socket = + socket + |> assign(:changeset, changeset) + |> broadcast_data_form_state(params) + + {:noreply, socket} + + {:error, errors} -> + # Preserve full multilang data in both changeset and broadcast + error_data = + merge_multilang_data( + socket.assigns.changeset, + current_lang, + form_data, + socket.assigns + ) + + data_params = + data_params + |> Map.delete("lang_title") + |> Map.delete("lang_slug") + + error_params = Map.put(data_params, "data", error_data) + + changeset = + socket.assigns.data_record + |> EntityData.change(error_params) + |> add_form_errors(errors) + |> Map.put(:action, :validate) + + socket = + socket + |> assign(:changeset, changeset) + |> broadcast_data_form_state(error_params) + + {:noreply, socket} + end + end + + defp do_save(data_params, socket) do + # Extract the data field from params + form_data = Map.get(data_params, "data", %{}) + + current_lang = socket.assigns[:current_lang] + + # Inject _title and _slug into form data so they flow through multilang merge + 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) + + # On secondary language tabs, preserve primary-language fields that aren't in the form + data_params = preserve_primary_fields(data_params, socket.assigns.changeset) + + # Validate the form data against entity field definitions + case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do + {:ok, validated_data} -> + validated_data = + validated_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) + + data_params = strip_lang_params(data_params) + + final_data = + merge_multilang_data( + socket.assigns.changeset, + current_lang, + validated_data, + socket.assigns + ) + + # Add metadata to params + params = + data_params + |> Map.put("data", final_data) + |> maybe_add_creator_uuid(socket.assigns.current_user, socket.assigns.data_record) + + case save_data_record(socket, params) do + {:ok, saved_record} -> + if socket.assigns.data_record.uuid do + # Update — stay on page, refresh changeset from saved record + changeset = EntityData.change(saved_record) + + socket = + socket + |> assign(:data_record, saved_record) + |> assign(:changeset, changeset) + |> put_flash(:info, gettext("Data record saved successfully")) + |> broadcast_data_form_state(params) + + {:noreply, socket} + else + # Create — navigate to the edit page for the new record + entity_name = socket.assigns.entity.name + + socket = + socket + |> put_flash(:info, gettext("Data record created successfully")) + |> push_navigate( + to: + Routes.path( + "/admin/entities/#{entity_name}/data/#{saved_record.uuid}/edit", + locale: socket.assigns.current_locale_base + ) + ) + + {:noreply, socket} + end + + {:error, %Ecto.Changeset{} = changeset} -> + socket = + socket + |> assign(:changeset, changeset) + |> broadcast_data_form_state(params) + + {:noreply, socket} + end + + {:error, errors} -> + # Preserve full multilang data in both changeset and broadcast + error_data = + merge_multilang_data( + socket.assigns.changeset, + current_lang, + form_data, + socket.assigns + ) + + data_params = + data_params + |> Map.delete("lang_title") + |> Map.delete("lang_slug") + + error_params = Map.put(data_params, "data", error_data) + + changeset = + socket.assigns.data_record + |> EntityData.change(error_params) + |> add_form_errors(errors) + + error_list = + Enum.map_join(errors, "; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end) + + socket = + socket + |> assign(:changeset, changeset) + |> put_flash( + :error, + gettext("Field validation errors: %{errors}", errors: error_list) + ) + |> broadcast_data_form_state(error_params) + + {:noreply, socket} + end + end + ## Live updates @impl true @@ -664,6 +690,14 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do end end + # Strip lang_title/lang_slug from params — these are translation input names + # that shouldn't be passed to the changeset as DB fields. + defp strip_lang_params(params) do + params + |> Map.delete("lang_title") + |> Map.delete("lang_slug") + end + # ── Lazy re-keying helpers (primary language change) ──────── # Re-keys JSONB data in changeset if embedded primary != global primary. @@ -793,27 +827,8 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do # Helper Functions - defp merge_multilang_data(assigns, lang_code, validated_data) do - existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{} - - cond do - # Multilang enabled — use language-aware merge - assigns[:multilang_enabled] == true -> - Multilang.put_language_data(existing_data, lang_code, validated_data) - - # Multilang disabled but data has multilang structure — preserve it - # (prevents translation loss if Languages module is temporarily disabled) - Multilang.multilang_data?(existing_data) -> - Multilang.put_language_data(existing_data, lang_code, validated_data) - - # Flat data, no multilang — pass through as-is - true -> - validated_data - end - end - # When on a secondary language tab, preserve primary-language DB fields. - # Title on secondary tab goes to JSONB _title via inject_title_into_form_data; + # Title on secondary tab goes to JSONB _title via inject_db_field_into_data; # the DB title column must keep the primary language value. @preserve_fields %{"title" => :title, "slug" => :slug, "status" => :status} @@ -828,36 +843,6 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do end) end - # Injects _title into form data map so it flows through merge_multilang_data/3. - # On primary tab: _title comes from data_params["title"] (the DB column field). - # On secondary tab: _title comes from data_params["lang_title"] (separate input). - defp inject_title_into_form_data(form_data, data_params, current_lang, assigns) do - if assigns[:multilang_enabled] == true do - primary = assigns[:primary_language] - - title = - if current_lang == primary do - data_params["title"] - else - data_params["lang_title"] - end - - if is_binary(title) do - Map.put(form_data, "_title", title) - else - # No title submitted — preserve existing _title from JSONB data - existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{} - - case Multilang.get_raw_language_data(existing_data, current_lang) do - %{"_title" => existing_title} -> Map.put(form_data, "_title", existing_title) - _ -> form_data - end - end - else - form_data - end - end - defp do_generate_slug(socket) do changeset = socket.assigns.changeset current_lang = socket.assigns[:current_lang] @@ -944,36 +929,6 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do {slug_text, data} end - # Injects _slug into form data map so it flows through merge_multilang_data/3. - # On primary tab: _slug comes from data_params["slug"] (the DB column field). - # On secondary tab: _slug comes from data_params["lang_slug"] (separate input). - defp inject_slug_into_form_data(form_data, data_params, current_lang, assigns) do - if assigns[:multilang_enabled] == true do - primary = assigns[:primary_language] - - slug = - if current_lang == primary do - data_params["slug"] - else - data_params["lang_slug"] - end - - if is_binary(slug) do - Map.put(form_data, "_slug", slug) - else - # No slug submitted — preserve existing _slug from JSONB data - existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{} - - case Multilang.get_raw_language_data(existing_data, current_lang) do - %{"_slug" => existing_slug} -> Map.put(form_data, "_slug", existing_slug) - _ -> form_data - end - end - else - form_data - end - end - defp broadcast_data_form_state(socket, params) when is_map(params) do socket = if connected?(socket) && @@ -1051,30 +1006,11 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do |> EntityData.change(params) |> Map.put(:action, :validate) - # Refresh multilang assigns (driven by Languages module globally) - multilang_enabled = Multilang.enabled?() - primary_language = if multilang_enabled, do: Multilang.primary_language(), else: nil - language_tabs = Multilang.build_language_tabs() - - current_lang = socket.assigns[:current_lang] - enabled_langs = Multilang.enabled_languages() - - # Reset current_lang when multilang is disabled or language was removed - current_lang = - cond do - not multilang_enabled -> nil - current_lang not in enabled_langs -> primary_language - true -> current_lang - end - socket |> assign(:entity, entity) |> assign(:data_record, data_record) |> assign(:changeset, changeset) - |> assign(:multilang_enabled, multilang_enabled) - |> assign(:primary_language, primary_language) - |> assign(:current_lang, current_lang) - |> assign(:language_tabs, language_tabs) + |> refresh_multilang() end defp extract_changeset_params(changeset) do @@ -1126,17 +1062,6 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do defp normalize_record_key(key) when is_binary(key), do: key defp normalize_record_key(key), do: to_string(key) - defp switch_lang_js(lang_code, current_lang) do - if lang_code == current_lang do - # Already on this tab — no-op to prevent skeleton ghosts - %JS{} - else - JS.push("switch_language", value: %{lang: lang_code}) - |> JS.add_class("hidden", to: "[data-translatable=fields]") - |> JS.remove_class("hidden", to: "[data-translatable=skeletons]") - end - end - defp auto_generate_entity_slug(_entity_uuid, _record_uuid, title) when title in [nil, ""], do: "" diff --git a/lib/modules/entities/web/data_form.html.heex b/lib/modules/entities/web/data_form.html.heex index 1dcca9e3e..281ca816d 100644 --- a/lib/modules/entities/web/data_form.html.heex +++ b/lib/modules/entities/web/data_form.html.heex @@ -70,149 +70,46 @@ <%= if @multilang_enabled && length(@language_tabs) > 1 do %> <%!-- Multilang: unified card with language tabs wrapping all content --%> + <% lang_data = get_lang_data(@changeset, @current_lang, @multilang_enabled) %>
- <%!-- Language tab bar --%> -
-
-
- <.icon name="hero-language" class="w-5 h-5 text-primary" /> -

{gettext("Content Language")}

-
- <% primary_tab = - Enum.find(@language_tabs, fn t -> t.is_primary end) %> - <%= if primary_tab do %> -
- <.icon name="hero-star-solid" class="w-3.5 h-3.5 text-primary" /> - {gettext("Primary: %{lang}", lang: primary_tab.name)} -
- <% end %> -
+ <.multilang_tabs + multilang_enabled={@multilang_enabled} + language_tabs={@language_tabs} + current_lang={@current_lang} + /> -
- <.icon name="hero-information-circle" class="w-4 h-4" /> - - {gettext( - "Use the language tabs below to translate this record's content. The primary language (marked with a star) is required. Other languages are optional — any empty fields will fall back to the primary language value." - )} - -
- - <% compact = length(@language_tabs) > 5 %> -
- <%= if compact do %> - <%= for {tab, idx} <- Enum.with_index(@language_tabs) do %> - <%= if idx > 0 do %> - | - <% end %> - - <% end %> - <% else %> - <%= for tab <- @language_tabs do %> - <%= if tab.is_primary do %> - - - <% else %> - - <% end %> - <% end %> - <% end %> -
-
- - <%!-- Skeleton placeholders (shown instantly on tab click). - IMPORTANT: IDs MUST include @current_lang so morphdom treats them as new - elements on language switch. Static IDs would preserve JS-modified classes - across LiveView patches, causing skeletons to stick. --%> - <%!-- Record Settings (non-translatable, separate card) --%> @@ -400,11 +239,6 @@ name="phoenix_kit_entity_data[entity_uuid]" value={@entity.uuid} /> -
@@ -511,11 +345,6 @@ name="phoenix_kit_entity_data[entity_uuid]" value={@entity.uuid} /> - diff --git a/lib/modules/entities/web/data_view.ex b/lib/modules/entities/web/data_view.ex index dd8c0a55c..dcf8db025 100644 --- a/lib/modules/entities/web/data_view.ex +++ b/lib/modules/entities/web/data_view.ex @@ -11,10 +11,11 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do use PhoenixKitWeb, :live_view on_mount PhoenixKit.Modules.Entities.Web.Hooks + import PhoenixKitWeb.Components.MultilangForm + alias PhoenixKit.Modules.Entities alias PhoenixKit.Modules.Entities.EntityData alias PhoenixKit.Modules.Entities.FormBuilder - alias PhoenixKit.Modules.Entities.Multilang alias PhoenixKit.Settings @impl true @@ -78,11 +79,6 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do # Create a modified entity with only other fields for display other_entity = %{entity | fields_definition: other_fields} - # Multilang assigns (driven by Languages module globally) - multilang_enabled = Multilang.enabled?() - primary_language = Multilang.primary_language() - language_tabs = Multilang.build_language_tabs() - socket = socket |> assign(:current_locale, locale) @@ -101,10 +97,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do |> assign(:public_form_description, Map.get(settings, "public_form_description", "")) |> assign(:metadata, data_record.metadata || %{}) |> assign(:is_public_submission, public_submission?(data_record.metadata)) - |> assign(:multilang_enabled, multilang_enabled) - |> assign(:primary_language, primary_language) - |> assign(:current_lang, primary_language) - |> assign(:language_tabs, language_tabs) + |> mount_multilang() {:ok, socket} end @@ -114,13 +107,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do @impl true def handle_event("switch_language", %{"lang" => lang_code}, socket) do - enabled = Multilang.enabled_languages() - - if lang_code in enabled do - {:noreply, assign(socket, :current_lang, lang_code)} - else - {:noreply, socket} - end + {:noreply, handle_switch_language(socket, lang_code)} end @impl true @@ -202,100 +189,20 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do <%!-- Language Selector (only when multilang enabled) --%> <%= if @multilang_enabled && length(@language_tabs) > 1 do %>
-
-
-
- <.icon name="hero-language" class="w-5 h-5 text-primary" /> -

{gettext("Content Language")}

-
- <% primary_tab = - Enum.find(@language_tabs, fn t -> t.is_primary end) %> - <%= if primary_tab do %> -
- <.icon name="hero-star-solid" class="w-3.5 h-3.5 text-primary" /> - {gettext("Primary: %{lang}", lang: primary_tab.name)} -
- <% end %> -
- - <% compact = length(@language_tabs) > 5 %> -
- <%= if compact do %> - <%= for {tab, idx} <- Enum.with_index(@language_tabs) do %> - <%= if idx > 0 do %> - | - <% end %> - - <% end %> - <% else %> - <%= for tab <- @language_tabs do %> - <%= if tab.is_primary do %> - -
- <% else %> - - <% end %> - <% end %> - <% end %> -
- + <.multilang_tabs + multilang_enabled={@multilang_enabled} + language_tabs={@language_tabs} + current_lang={@current_lang} + show_info={false} + /> +
<%= if @current_lang == @primary_language do %> -

+

<.icon name="hero-information-circle" class="w-3.5 h-3.5 inline -mt-0.5" /> {gettext("This is the primary language.")}

<% else %> -

+

<.icon name="hero-information-circle" class="w-3.5 h-3.5 inline -mt-0.5" /> {gettext("Fields without a value show the primary language value.")}

diff --git a/lib/modules/entities/web/entity_form.ex b/lib/modules/entities/web/entity_form.ex index 7f87830c8..60b964240 100644 --- a/lib/modules/entities/web/entity_form.ex +++ b/lib/modules/entities/web/entity_form.ex @@ -9,13 +9,13 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do require Logger - alias Phoenix.LiveView.JS + import PhoenixKitWeb.Components.MultilangForm + alias PhoenixKit.Modules.Entities alias PhoenixKit.Modules.Entities.Events alias PhoenixKit.Modules.Entities.FieldTypes alias PhoenixKit.Modules.Entities.Mirror.Exporter alias PhoenixKit.Modules.Entities.Mirror.Storage - alias PhoenixKit.Modules.Entities.Multilang alias PhoenixKit.Modules.Entities.Presence alias PhoenixKit.Modules.Entities.PresenceHelpers alias PhoenixKit.Settings @@ -61,11 +61,6 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do live_source = ensure_live_source(socket) - # Multilang state - multilang_enabled = Multilang.enabled?() - primary_language = if multilang_enabled, do: Multilang.primary_language(), else: nil - language_tabs = Multilang.build_language_tabs() - socket = socket |> assign(:page_title, page_title) @@ -90,11 +85,8 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do |> assign(:delete_confirm_index, nil) |> assign(:has_unsaved_changes, false) |> assign(:mirror_path, Storage.root_path()) - |> assign(:multilang_enabled, multilang_enabled) - |> assign(:primary_language, primary_language) - |> assign(:current_lang, primary_language) - |> assign(:language_tabs, language_tabs) |> assign(:sort_mode, Entities.get_sort_mode(entity)) + |> mount_multilang() socket = if connected?(socket) do @@ -183,25 +175,8 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do end end - defp switch_lang_js(lang_code, current_lang) do - if lang_code == current_lang do - # Already on this tab — no-op to prevent skeleton ghosts - %JS{} - else - JS.push("switch_language", value: %{lang: lang_code}) - |> JS.add_class("hidden", to: "[data-translatable=fields]") - |> JS.remove_class("hidden", to: "[data-translatable=skeletons]") - end - end - def handle_event("switch_language", %{"lang" => lang_code}, socket) do - enabled_langs = Multilang.enabled_languages() - - if lang_code in enabled_langs do - {:noreply, assign(socket, :current_lang, lang_code)} - else - {:noreply, socket} - end + {:noreply, handle_switch_language(socket, lang_code)} end @impl true diff --git a/lib/modules/entities/web/entity_form.html.heex b/lib/modules/entities/web/entity_form.html.heex index d4e923b8e..e75178e30 100644 --- a/lib/modules/entities/web/entity_form.html.heex +++ b/lib/modules/entities/web/entity_form.html.heex @@ -62,10 +62,13 @@ )} - <% translations = (@entity.settings || %{})["translations"] || %{} %> - <% is_secondary = @multilang_enabled && @current_lang != @primary_language %> <% lang_translations = - if is_secondary, do: translations[@current_lang] || %{}, else: %{} %> + if @multilang_enabled && @current_lang != @primary_language do + translations = (@entity.settings || %{})["translations"] || %{} + translations[@current_lang] || %{} + else + %{} + end %> <%!-- Language tabs --%> <%= if @multilang_enabled && length(@language_tabs) > 1 do %> @@ -77,187 +80,110 @@ )}
- - <% compact = length(@language_tabs) > 5 %> -
- <%= if compact do %> - <%= for {tab, idx} <- Enum.with_index(@language_tabs) do %> - <%= if idx > 0 do %> - | - <% end %> - - <% end %> - <% else %> - <%= for tab <- @language_tabs do %> - <%= if tab.is_primary do %> - - - <% else %> - - <% end %> - <% end %> - <% end %> -
<% end %> - <%!-- Skeleton placeholders (shown instantly on tab click). - IMPORTANT: IDs MUST include @current_lang so morphdom treats them as new - elements on language switch. Static IDs would preserve JS-modified classes - across LiveView patches, causing skeletons to stick. --%> - + - <%!-- Translatable fields --%> -
<%!-- Entity Name (Singular) --%> -
- <.label for="entity_display_name"> - {gettext("Entity Name (Singular)")}{unless is_secondary, do: " *"} - - <%= if is_secondary do %> - - <% else %> - <.input - field={f[:display_name]} - type="text" - placeholder={gettext("Brand")} - phx-debounce="300" - disabled={@readonly?} - /> - <% end %> - <.label class="label"> - - {gettext("Singular form (e.g., \"Brand\")")} - - -
+ <.translatable_field + field_name="display_name" + form_prefix="entities" + changeset={@changeset} + schema_field={:display_name} + multilang_enabled={@multilang_enabled} + current_lang={@current_lang} + primary_language={@primary_language} + lang_data={lang_translations} + secondary_name={"entities[translations][#{@current_lang}][display_name]"} + lang_data_key="display_name" + label={gettext("Entity Name (Singular)")} + placeholder={gettext("Brand")} + required + disabled={@readonly?} + class="w-full" + hint={gettext("Singular form (e.g., \"Brand\")")} + /> <%!-- Entity Name (Plural) --%> -
- <.label for="entity_display_name_plural"> - {gettext("Entity Name (Plural)")}{unless is_secondary, do: " *"} - - <%= if is_secondary do %> - - <% else %> - <.input - field={f[:display_name_plural]} - type="text" - placeholder={gettext("Brands")} - phx-debounce="300" - disabled={@readonly?} - /> - <% end %> - <.label class="label"> - - {gettext("Plural form (e.g., \"Brands\")")} - - -
+ <.translatable_field + field_name="display_name_plural" + form_prefix="entities" + changeset={@changeset} + schema_field={:display_name_plural} + multilang_enabled={@multilang_enabled} + current_lang={@current_lang} + primary_language={@primary_language} + lang_data={lang_translations} + secondary_name={"entities[translations][#{@current_lang}][display_name_plural]"} + lang_data_key="display_name_plural" + label={gettext("Entity Name (Plural)")} + placeholder={gettext("Brands")} + required + disabled={@readonly?} + class="w-full" + hint={gettext("Plural form (e.g., \"Brands\")")} + />
<%!-- Slug (translatable) --%> -
- <.label for="entity_name"> - {gettext("Slug")}{unless is_secondary, do: " *"} - <%= unless is_secondary do %> + <.translatable_field + field_name="name" + form_prefix="entities" + changeset={@changeset} + schema_field={:name} + multilang_enabled={@multilang_enabled} + current_lang={@current_lang} + primary_language={@primary_language} + lang_data={lang_translations} + secondary_name={"entities[translations][#{@current_lang}][name]"} + lang_data_key="name" + label={gettext("Slug")} + placeholder={gettext("brand")} + required + disabled={@readonly?} + class="w-full" + hint={gettext("snake_case identifier used in the system")} + > + <:label_extra> + <%= if !@multilang_enabled || @current_lang == @primary_language do %> <% end %> - - <%= if is_secondary do %> - - <% else %> - <.input - field={f[:name]} - type="text" - placeholder={gettext("brand")} - phx-debounce="300" - disabled={@readonly?} - /> - <% end %> - <.label class="label"> - - {gettext("snake_case identifier used in the system")} - - -
+ + <%!-- Description (translatable) --%> -
- <.label for="entity_description">{gettext("Description (Optional)")} - <%= if is_secondary do %> - - <% else %> - - <% end %> -
-
+ <.translatable_field + field_name="description" + form_prefix="entities" + changeset={@changeset} + schema_field={:description} + multilang_enabled={@multilang_enabled} + current_lang={@current_lang} + primary_language={@primary_language} + lang_data={lang_translations} + secondary_name={"entities[translations][#{@current_lang}][description]"} + lang_data_key="description" + label={gettext("Description (Optional)")} + placeholder={gettext("Describe what this entity represents...")} + type="textarea" + rows={3} + disabled={@readonly?} + class="w-full" + /> +
diff --git a/lib/modules/publishing/web/components/language_switcher.ex b/lib/modules/publishing/web/components/language_switcher.ex deleted file mode 100644 index 728303f53..000000000 --- a/lib/modules/publishing/web/components/language_switcher.ex +++ /dev/null @@ -1,442 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do - @moduledoc """ - Unified language switcher component for publishing posts. - - Displays available languages as a compact inline list with status indicators. - Flexible enough for admin interfaces (with status dots) and public pages (links only). - - ## Display Format - - Admin mode: ● EN | ● FR | ○ ES - - Green dot (●): Published - - Yellow dot (●): Draft - - Gray dot (●): Archived - - Empty dot (○): No translation exists - - Public mode: EN | FR | ES - - No status indicators - - Only shows languages with published translations - - ## Examples - - # Admin: publishing listing with edit links and status indicators - <.publishing_language_switcher - languages={@languages} - current_language="en" - on_click="switch_language" - show_status={true} - /> - - # Admin: editor with switch functionality - <.publishing_language_switcher - languages={@languages} - current_language={@current_language} - on_click="switch_language" - show_status={true} - show_add={true} - /> - - # Public: post page with translation links - <.publishing_language_switcher - languages={@translations} - current_language={@current_language} - show_status={false} - /> - """ - - use Phoenix.Component - use Gettext, backend: PhoenixKitWeb.Gettext - - @doc """ - Renders a compact inline language switcher. - - ## Attributes - - - `languages` - List of language maps with keys: `code`, `name`, `status`, `url`, `exists` - - `current_language` - Currently active language code - - `show_status` - Show status indicator dots (default: true) - - `show_add` - Show "add" option for missing languages (default: false) - - `show_flags` - Show flag emojis (default: false) - - `on_click` - Event name for click handler (optional, uses href if not provided) - - `class` - Additional CSS classes - - `size` - Size variant: :xs, :sm, :md (default: :sm) - - ## Language Map Structure - - Each language in the list should have: - - `code` - Language code (e.g., "en", "fr") - - `name` - Display name (e.g., "English", "French") - optional - - `flag` - Flag emoji (e.g., "🇺🇸") - optional - - `status` - Post status: "published", "draft", "archived", or nil if not exists - - `url` - URL to navigate to (for public mode or href navigation) - - `exists` - Boolean, whether translation exists (default: inferred from status) - - `enabled` - Boolean, whether the language is enabled in the Languages module (default: true) - - `known` - Boolean, whether the language code is recognized (default: true) - """ - attr :languages, :list, required: true - attr :current_language, :string, default: nil - attr :show_status, :boolean, default: true - attr :show_add, :boolean, default: false - attr :show_flags, :boolean, default: false - attr :on_click, :string, default: nil - attr :on_click_js, :any, default: nil, doc: "fn(lang_code) -> %JS{} for custom click behavior" - attr :phx_target, :any, default: nil - attr :class, :string, default: "" - attr :size, :atom, default: :sm, values: [:xs, :sm, :md] - - def publishing_language_switcher(assigns) do - # Filter languages for public mode (only show existing/published) - languages = - if assigns.show_status do - assigns.languages - else - Enum.filter(assigns.languages, fn lang -> - lang_exists?(lang) && lang[:status] == "published" - end) - end - - assigns = assign(assigns, :filtered_languages, languages) - - ~H""" -
- <%= for {lang, index} <- Enum.with_index(@filtered_languages) do %> - <%= if index > 0 do %> - | - <% end %> - <.language_item - lang={lang} - current_language={@current_language} - show_status={@show_status} - show_add={@show_add} - show_flags={@show_flags} - on_click={@on_click} - on_click_js={@on_click_js} - phx_target={@phx_target} - size={@size} - /> - <% end %> -
- """ - end - - attr :lang, :map, required: true - attr :current_language, :string, default: nil - attr :show_status, :boolean, default: true - attr :show_add, :boolean, default: false - attr :show_flags, :boolean, default: false - attr :on_click, :string, default: nil - attr :on_click_js, :any, default: nil - attr :phx_target, :any, default: nil - attr :size, :atom, default: :sm - - defp language_item(assigns) do - lang = assigns.lang - is_current = assigns.current_language == lang[:code] - exists = lang_exists?(lang) - status = lang[:status] - enabled = lang_enabled?(lang) - known = lang_known?(lang) - is_primary = lang_is_primary?(lang) - - assigns = - assigns - |> assign(:is_current, is_current) - |> assign(:exists, exists) - |> assign(:status, status) - |> assign(:enabled, enabled) - |> assign(:known, known) - |> assign(:is_primary, is_primary) - - ~H""" - <%= if @on_click_js || @on_click do %> - - <% else %> - <%= if @lang[:url] do %> - <.link - navigate={@lang[:url]} - class={item_classes(@is_current, @exists, @show_add, @size, @enabled, @known)} - title={language_title(@lang, @exists, @status, @show_status, @enabled, @known)} - > - <.language_content - lang={@lang} - exists={@exists} - status={@status} - show_status={@show_status} - show_add={@show_add} - show_flags={@show_flags} - is_current={@is_current} - size={@size} - enabled={@enabled} - known={@known} - is_primary={@is_primary} - /> - - <% else %> - - <.language_content - lang={@lang} - exists={@exists} - status={@status} - show_status={@show_status} - show_add={@show_add} - show_flags={@show_flags} - is_current={@is_current} - size={@size} - enabled={@enabled} - known={@known} - is_primary={@is_primary} - /> - - <% end %> - <% end %> - """ - end - - attr :lang, :map, required: true - attr :exists, :boolean, required: true - attr :status, :string, default: nil - attr :show_status, :boolean, default: true - attr :show_add, :boolean, default: false - attr :show_flags, :boolean, default: false - attr :is_current, :boolean, default: false - attr :size, :atom, default: :sm - attr :enabled, :boolean, default: true - attr :known, :boolean, default: true - attr :is_primary, :boolean, default: false - - defp language_content(assigns) do - ~H""" - - <%= if @show_status do %> - - - <% end %> - <%= if @show_flags && @lang[:flag] do %> - {@lang[:flag]} - <% end %> - - {get_display_code(@lang)} - - - """ - end - - # Check if language translation exists - defp lang_exists?(%{exists: exists}) when is_boolean(exists), do: exists - defp lang_exists?(%{status: status}) when is_binary(status), do: true - defp lang_exists?(%{"exists" => exists}) when is_boolean(exists), do: exists - defp lang_exists?(%{"status" => status}) when is_binary(status), do: true - defp lang_exists?(_), do: false - - defp lang_enabled?(%{enabled: enabled}) when is_boolean(enabled), do: enabled - defp lang_enabled?(%{"enabled" => enabled}) when is_boolean(enabled), do: enabled - defp lang_enabled?(_), do: true - - defp lang_known?(%{known: known}) when is_boolean(known), do: known - defp lang_known?(%{"known" => known}) when is_boolean(known), do: known - defp lang_known?(_), do: true - - defp lang_is_primary?(%{is_primary: is_primary}) when is_boolean(is_primary), do: is_primary - defp lang_is_primary?(%{"is_primary" => is_primary}) when is_boolean(is_primary), do: is_primary - defp lang_is_primary?(_), do: false - - # Get display code for a language - # Uses display_code if provided (for dialect-aware display), otherwise formats code - defp get_display_code(%{display_code: display_code}) when is_binary(display_code) do - String.upcase(display_code) - end - - defp get_display_code(%{"display_code" => display_code}) when is_binary(display_code) do - String.upcase(display_code) - end - - defp get_display_code(%{code: code}) when is_binary(code) do - format_code(code) - end - - defp get_display_code(%{"code" => code}) when is_binary(code) do - format_code(code) - end - - defp get_display_code(_), do: "" - - # Format language code for display (base code only) - defp format_code(nil), do: "" - - defp format_code(code) when is_binary(code) do - code - |> String.split("-") - |> List.first() - |> String.upcase() - end - - # Status dot styling - # Dot color follows status regardless of enabled/known state - # (strikethrough on text indicates disabled/unknown, not the dot) - defp status_dot_classes(exists, status, size, _enabled, _known, _is_primary) do - base = ["rounded-full", "inline-block", dot_size_class(size)] - - color = - cond do - !exists -> "bg-base-content/20" - status == "published" -> "bg-success" - status == "draft" -> "bg-warning" - status == "archived" -> "bg-base-content/40" - true -> "bg-base-content/20" - end - - base ++ [color] - end - - # Item container classes - defp item_classes(is_current, exists, show_add, size, enabled, known) do - base = [ - "inline-flex items-center rounded transition-colors", - size_padding_class(size) - ] - - state = - cond do - # Disabled or unknown languages: grey styling but still clickable - is_current and (!enabled or !known) -> - "bg-base-content/30 text-base-content/50 font-semibold cursor-pointer" - - (!enabled or !known) and exists -> - "text-base-content/40 hover:bg-base-200/50 cursor-pointer" - - is_current -> - "bg-primary/30 text-primary font-semibold" - - !exists && show_add -> - "text-success hover:bg-success/10 cursor-pointer" - - exists -> - "hover:bg-base-200 cursor-pointer" - - true -> - "text-base-content/40" - end - - base ++ [state] - end - - # Code text classes - # Text color matches the status dot color - defp code_classes(exists, status, is_current, size, enabled, known, is_primary) do - base = [size_text_class(size)] - - # Primary language gets bold, current gets semibold, others get medium - weight = - cond do - is_primary -> "font-bold" - is_current -> "font-semibold" - true -> "font-medium" - end - - # Add line-through for disabled or unknown languages - decoration = if !enabled or !known, do: "line-through", else: nil - - # Text color matches the dot color - color = - cond do - !exists -> "text-base-content/20" - status == "published" -> "text-success" - status == "draft" -> "text-warning" - status == "archived" -> "text-base-content/40" - true -> "text-base-content/20" - end - - Enum.filter(base ++ [weight, decoration, color], & &1) - end - - # Size-based classes - defp dot_size_class(:xs), do: "w-1.5 h-1.5" - defp dot_size_class(:sm), do: "w-2 h-2" - defp dot_size_class(:md), do: "w-2.5 h-2.5" - - defp size_text_class(:xs), do: "text-xs" - defp size_text_class(:sm), do: "text-sm" - defp size_text_class(:md), do: "text-base" - - defp size_padding_class(:xs), do: "px-1 py-0.5" - defp size_padding_class(:sm), do: "px-1.5 py-0.5" - defp size_padding_class(:md), do: "px-2 py-1" - - defp size_gap_class(:xs), do: "gap-0.5" - defp size_gap_class(:sm), do: "gap-1" - defp size_gap_class(:md), do: "gap-1.5" - - defp size_separator_class(:xs), do: "text-xs" - defp size_separator_class(:sm), do: "text-sm" - defp size_separator_class(:md), do: "text-base" - - defp flag_size_class(:xs), do: "text-sm" - defp flag_size_class(:sm), do: "text-base" - defp flag_size_class(:md), do: "text-lg" - - # Generate title/tooltip text - # Only show status in tooltip when show_status is true (admin mode) - defp language_title(lang, exists, status, show_status, enabled, known) do - name = lang[:name] || format_code(lang[:code]) - - if show_status do - build_admin_title(name, exists, status, enabled, known) - else - name - end - end - - # Build title for admin mode with status indicators - defp build_admin_title(name, exists, status, enabled, known) do - cond do - !known and exists -> gettext("%{language} (Unknown language)", language: name) - !enabled and exists -> build_disabled_title(name, status) - !exists -> gettext("Add %{language} translation", language: name) - true -> build_status_title(name, status) - end - end - - defp build_disabled_title(name, status) do - status_text = status_label(status) - gettext("%{language} (Disabled - %{status})", language: name, status: status_text) - end - - defp build_status_title(name, "published"), - do: gettext("%{language} (Published)", language: name) - - defp build_status_title(name, "draft"), do: gettext("%{language} (Draft)", language: name) - defp build_status_title(name, "archived"), do: gettext("%{language} (Archived)", language: name) - defp build_status_title(name, _), do: name - - defp status_label("published"), do: gettext("Published") - defp status_label("draft"), do: gettext("Draft") - defp status_label("archived"), do: gettext("Archived") - defp status_label(_), do: gettext("Unknown") -end diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 87dedc024..1fe86f437 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -42,7 +42,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do alias PhoenixKit.Utils.Date, as: UtilsDate # Import publishing-specific components - import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher + import PhoenixKitWeb.Components.LanguageSwitcher import PhoenixKit.Modules.Publishing.Web.Components.VersionSwitcher require Logger diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index fa8c51bd5..b363aff4a 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -204,7 +204,7 @@ {gettext("Language:")} - <.publishing_language_switcher + <.language_switcher languages={other_languages} current_language={@current_language} show_status={true} diff --git a/lib/modules/publishing/web/html.ex b/lib/modules/publishing/web/html.ex index fe608e4c3..51d27c6ce 100644 --- a/lib/modules/publishing/web/html.ex +++ b/lib/modules/publishing/web/html.ex @@ -15,8 +15,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do @timestamp_modes Constants.timestamp_modes() @slug_modes Constants.slug_modes() - # Import publishing-specific components for templates - import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher + import PhoenixKitWeb.Components.LanguageSwitcher embed_templates("templates/*") diff --git a/lib/modules/publishing/web/index.ex b/lib/modules/publishing/web/index.ex index db1f65ebd..624aa4f31 100644 --- a/lib/modules/publishing/web/index.ex +++ b/lib/modules/publishing/web/index.ex @@ -13,12 +13,15 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers @group_statuses Constants.group_statuses() + alias PhoenixKit.Modules.Publishing.LanguageHelpers alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Settings alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Routes + import PhoenixKitWeb.Components.LanguageSwitcher + @impl true def mount(_params, _session, socket) do # Load date/time format settings once for performance @@ -404,4 +407,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do end defp extract_endpoint_url(_), do: "" + + defp build_language_pills(language_codes) when is_list(language_codes) do + Enum.map(language_codes, fn lang -> + info = LanguageHelpers.get_language_info(lang) + + %{ + code: lang, + short_code: String.upcase(lang), + name: if(info, do: info.name, else: lang), + exists: true + } + end) + end end diff --git a/lib/modules/publishing/web/index.html.heex b/lib/modules/publishing/web/index.html.heex index 0627b95fb..8ea90b02b 100644 --- a/lib/modules/publishing/web/index.html.heex +++ b/lib/modules/publishing/web/index.html.heex @@ -164,14 +164,13 @@ <%= if insight.languages != [] do %> -
- <%= for language <- insight.languages do %> - - {language - |> PhoenixKit.Modules.Languages.DialectMapper.extract_base() - |> String.upcase()} - - <% end %> +
+ <.language_switcher + languages={build_language_pills(insight.languages)} + show_status={false} + variant={:pills} + size={:xs} + />
<% else %>

{gettext("None")}

diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index ae11c89c3..f3bffa48a 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -17,8 +17,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Routes - # Import publishing-specific components - import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher + import PhoenixKitWeb.Components.LanguageSwitcher @impl true def mount(params, _session, socket) do diff --git a/lib/modules/publishing/web/listing.html.heex b/lib/modules/publishing/web/listing.html.heex index 3e011fabe..683ffa0c8 100644 --- a/lib/modules/publishing/web/listing.html.heex +++ b/lib/modules/publishing/web/listing.html.heex @@ -385,7 +385,7 @@ <%!-- Other translations (excluding primary) --%> <%= if @post_view_mode == "trashed" do %>
- <.publishing_language_switcher + <.language_switcher languages={other_languages} show_status={true} show_add={false} @@ -393,7 +393,7 @@ />
<% else %> - <.publishing_language_switcher + <.language_switcher languages={other_languages} show_status={true} show_add={true} diff --git a/lib/modules/publishing/web/preview.ex b/lib/modules/publishing/web/preview.ex index 914d6c7bb..9074d8003 100644 --- a/lib/modules/publishing/web/preview.ex +++ b/lib/modules/publishing/web/preview.ex @@ -20,7 +20,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes - import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher + import PhoenixKitWeb.Components.LanguageSwitcher @impl true def mount(params, _session, socket) do diff --git a/lib/modules/publishing/web/preview.html.heex b/lib/modules/publishing/web/preview.html.heex index 03c7f2c26..dc7b6eebb 100644 --- a/lib/modules/publishing/web/preview.html.heex +++ b/lib/modules/publishing/web/preview.html.heex @@ -70,7 +70,7 @@
<%!-- Language Switcher (links to preview URLs) --%> <%= if length(@translations) > 1 do %> - <.publishing_language_switcher + <.language_switcher languages={build_preview_translations(@translations, @post, @group_slug)} current_language={@current_language} show_status={false} diff --git a/lib/modules/publishing/web/templates/index.html.heex b/lib/modules/publishing/web/templates/index.html.heex index fa8a16962..3133660cb 100644 --- a/lib/modules/publishing/web/templates/index.html.heex +++ b/lib/modules/publishing/web/templates/index.html.heex @@ -27,7 +27,7 @@ <%!-- Language Switcher --%> <%= if length(@translations) > 1 do %>
- <.publishing_language_switcher + <.language_switcher languages={build_public_translations(@translations, @current_language)} current_language={@current_language} show_status={false} diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex index 29d53eead..b76d32cfb 100644 --- a/lib/modules/publishing/web/templates/show.html.heex +++ b/lib/modules/publishing/web/templates/show.html.heex @@ -41,7 +41,7 @@
<%!-- Language Switcher --%> <%= if length(@translations) > 1 do %> - <.publishing_language_switcher + <.language_switcher languages={build_public_translations(@translations, @current_language)} current_language={@current_language} show_status={false} diff --git a/lib/phoenix_kit_web/components/language_switcher.ex b/lib/phoenix_kit_web/components/language_switcher.ex new file mode 100644 index 000000000..375b5eed9 --- /dev/null +++ b/lib/phoenix_kit_web/components/language_switcher.ex @@ -0,0 +1,1025 @@ +defmodule PhoenixKitWeb.Components.LanguageSwitcher do + @moduledoc """ + Shared language switcher component for PhoenixKit. + + A configurable row of language options that works across all contexts: + admin form tabs, publishing editors, public page navigation, etc. + + ## Display modes + + - `:auto` (default) — shows full names when ≤ `auto_threshold` languages, + short codes when more + - `:full` — always show full names (e.g., "English", "French") + - `:compact` — always show short codes (e.g., "EN", "FR") + + ## Visual variants + + - `:inline` (default) — pipe-separated items: `EN | FR | ES` + - `:tabs` — pill-shaped buttons on a rounded background bar + - `:pills` — individual pill-shaped chips, each with its own background + + ## Interaction modes + + - **Button** — set `on_click` (event name) or `on_click_js` (fn returning `%JS{}`) + - **Link** — each language map has a `:url` key, renders `<.link navigate={url}>` + - **Display-only** — no click handler and no URL, renders a `` + + ## Status dots + + When `show_status` is true, a colored dot appears before each language code. + The dot color is resolved in priority order: + + 1. `dot_color` — explicit daisyUI color class (e.g., `"success"`, `"warning"`) + 2. `status` — mapped automatically: `"published"` → green, `"draft"` → yellow, + `"archived"` → gray + 3. `exists` — `true` → green dot, `false` → dim dot + + This makes the dots work for any use case: publishing post status, form content + indicators ("has translations"), or custom per-module states. + + ## Examples + + <%!-- Admin form: tabs with primary star, skeleton switching --%> + <.language_switcher + languages={@language_tabs} + current_language={@current_lang} + on_click_js={&switch_lang_js(&1, @current_lang)} + show_primary={true} + primary_divider={true} + variant={:tabs} + /> + + <%!-- Publishing editor: compact codes with status dots --%> + <.language_switcher + languages={@editor_languages} + current_language={@current_language} + on_click_js={&switch_lang_js(&1, @current_language)} + show_status={true} + show_add={true} + primary_divider={true} + /> + + <%!-- Public page: navigation links, no dots --%> + <.language_switcher + languages={@translations} + current_language={@current_language} + /> + + <%!-- Post overview: pills with status dots and "Primary" label --%> + <.language_switcher + languages={@post_languages} + current_language={@current_language} + show_status={true} + show_primary_label={true} + variant={:pills} + prefix_urls={true} + /> + + <%!-- Form with content indicators --%> + <.language_switcher + languages={Enum.map(@language_tabs, &Map.put(&1, :exists, has_content?(&1.code)))} + current_language={@current_lang} + on_click_js={&switch_lang_js(&1, @current_lang)} + show_status={true} + variant={:tabs} + /> + """ + + use Phoenix.Component + use Gettext, backend: PhoenixKitWeb.Gettext + + alias PhoenixKit.Utils.Routes + + @doc """ + Renders a language switcher. + + ## Language map structure + + Each item in `:languages` should be a map with: + + - `code` (required) — language code (e.g., "en-US", "fr") + - `name` — full display name (e.g., "English"). Falls back to uppercased code. + - `short_code` — short display code (e.g., "EN"). Auto-derived from code if absent. + - `flag` — flag emoji (e.g., "🇺🇸") + - `url` — navigation URL (enables link mode) + - `is_primary` — boolean, marks the primary language + - `status` — "published", "draft", "archived", or nil (for dot color) + - `exists` — boolean, whether content exists (inferred from status if absent) + - `dot_color` — explicit daisyUI color class override. Valid values: + `"success"`, `"warning"`, `"error"`, `"info"`, `"primary"`, `"secondary"`, + `"accent"`, `"neutral"`, `"base-content/20"`, `"base-content/40"`. + Invalid values are silently ignored (falls back to status/exists color). + - `enabled` — boolean, whether this language is enabled in the system (default: true) + - `known` — boolean, whether this language code is recognized (default: true) + - `uuid` — optional ID, forwarded as `phx-value-uuid` in button mode + + ## Attributes + + - `languages` — list of language maps (required) + - `current_language` — currently active language code + - `display` — `:auto`, `:full`, or `:compact`. Default: `:auto` + - `auto_threshold` — show full names when language count ≤ this. Default: 3 + - `show_status` — show status indicator dots. Default: false + - `show_flags` — show flag emojis. Default: false + - `show_primary` — show star icon on primary language. Default: false + - `show_primary_label` — show "Primary" text label on primary language. Default: false + - `show_add` — style missing languages as addable (green). Default: false + - `exclude_primary` — exclude the primary language from the list. Default: false + - `primary_divider` — show a vertical divider after the primary language. Default: false + - `on_click` — event name for button click. Default: nil + - `on_click_js` — `fn(lang_code) -> %JS{}` for custom click. Default: nil + - `phx_target` — target for phx-click. Default: nil + - `variant` — `:inline`, `:tabs`, or `:pills`. Default: `:inline` + - `size` — `:xs`, `:sm`, or `:md`. Default: `:sm` + - `prefix_urls` — when true, URLs in language maps are passed through + `PhoenixKit.Utils.Routes.path/1` for prefix-aware routing. Default: false + - `id` — optional HTML id for the container element. Default: nil + - `class` — additional CSS classes. Default: "" + + ## Notes + + - `auto_threshold` is evaluated against the *displayed* language count + (after filtering and `exclude_primary`), so excluding the primary + language may change the display mode. + - `on_click_js` must be a pure function returning `%Phoenix.LiveView.JS{}` + — it is called during render. + """ + attr :languages, :list, required: true + attr :current_language, :string, default: nil + attr :display, :atom, default: :auto, values: [:auto, :full, :compact] + attr :auto_threshold, :integer, default: 3 + attr :show_status, :boolean, default: false + attr :show_flags, :boolean, default: false + attr :show_primary, :boolean, default: false + attr :show_primary_label, :boolean, default: false + attr :show_add, :boolean, default: false + attr :exclude_primary, :boolean, default: false + attr :primary_divider, :boolean, default: false + attr :on_click, :string, default: nil + attr :on_click_js, :any, default: nil + attr :phx_target, :any, default: nil + attr :variant, :atom, default: :inline, values: [:inline, :tabs, :pills] + attr :size, :atom, default: :sm, values: [:xs, :sm, :md] + attr :prefix_urls, :boolean, default: false + attr :id, :string, default: nil + attr :class, :string, default: "" + + def language_switcher(assigns) do + assigns = coerce_attrs(assigns) + validate_config(assigns) + languages = filter_languages(assigns.languages, assigns.show_status) + + languages = + if assigns.exclude_primary, + do: Enum.reject(languages, &lang_primary?/1), + else: languages + + languages = + if assigns.prefix_urls, + do: prefix_language_urls(languages), + else: languages + + use_full_names = + case assigns.display do + :full -> true + :compact -> false + :auto -> length(languages) <= assigns.auto_threshold + end + + last_idx = max(length(languages) - 1, 0) + + assigns = + assigns + |> assign(:filtered_languages, languages) + |> assign(:use_full_names, use_full_names) + |> assign(:last_idx, last_idx) + + ~H""" +
+ <%= for {lang, idx} <- Enum.with_index(@filtered_languages) do %> + <% prev_lang = if idx > 0, do: Enum.at(@filtered_languages, idx - 1) %> + <% prev_had_divider = + prev_lang && show_divider?(@primary_divider, prev_lang, idx - 1, @last_idx) %> + 0 && !prev_had_divider && show_separator?(@variant, @use_full_names)} + class={["text-base-content/30", size_text_class(@size)]} + > + | + + <.switcher_item + lang={lang} + current_language={@current_language} + use_full_names={@use_full_names} + show_status={@show_status} + show_flags={@show_flags} + show_primary={@show_primary} + show_primary_label={@show_primary_label} + show_add={@show_add} + on_click={@on_click} + on_click_js={@on_click_js} + phx_target={@phx_target} + variant={@variant} + size={@size} + /> + + <% end %> +
+ """ + end + + # ── Item rendering ───────────────────────────────────────────── + + attr :lang, :map, required: true + attr :current_language, :string, default: nil + attr :use_full_names, :boolean, required: true + attr :show_status, :boolean, default: false + attr :show_flags, :boolean, default: false + attr :show_primary, :boolean, default: false + attr :show_primary_label, :boolean, default: false + attr :show_add, :boolean, default: false + attr :on_click, :string, default: nil + attr :on_click_js, :any, default: nil + attr :phx_target, :any, default: nil + attr :variant, :atom, default: :inline + attr :size, :atom, default: :sm + + defp switcher_item(assigns) do + lang = assigns.lang + is_current = assigns.current_language == lang_code(lang) + exists = lang_exists?(lang) + is_primary = lang_primary?(lang) + enabled = lang_enabled?(lang) + known = lang_known?(lang) + status = lang_status(lang) + + assigns = + assigns + |> assign(:is_current, is_current) + |> assign(:exists, exists) + |> assign(:is_primary, is_primary) + |> assign(:enabled, enabled) + |> assign(:known, known) + |> assign(:status, status) + |> assign(:code, lang_code(lang)) + |> assign(:uuid, lang_field(lang, :uuid)) + |> assign(:url, lang_field(lang, :url)) + |> assign(:flag, lang_field(lang, :flag)) + |> assign(:item_title, build_title(lang, exists, enabled, known, assigns.show_status)) + |> assign(:resolved_click_js, resolve_click_js(assigns.on_click_js, lang_code(lang))) + + ~H""" + <%= if @on_click_js || @on_click do %> + + <% else %> + <%= if @url do %> + <.link + navigate={@url} + class={[ + "cursor-pointer" + | item_classes( + @is_current, + @exists, + @is_primary, + @show_add, + @variant, + @size, + @enabled, + @known + ) + ]} + title={@item_title} + aria-current={@is_current && "true"} + > + <.item_content {item_content_assigns(assigns)} /> + + <% else %> + + <.item_content {item_content_assigns(assigns)} /> + + <% end %> + <% end %> + """ + end + + # Extracts the assigns needed by item_content to avoid repeating them 3 times. + defp item_content_assigns(assigns) do + Map.take(assigns, [ + :lang, + :is_current, + :exists, + :is_primary, + :enabled, + :known, + :status, + :flag, + :use_full_names, + :show_status, + :show_flags, + :show_primary, + :show_primary_label, + :variant, + :size + ]) + end + + # ── Item content (dot + flag + label + star/label) ───────────── + + attr :lang, :map, required: true + attr :is_current, :boolean, required: true + attr :exists, :boolean, required: true + attr :is_primary, :boolean, required: true + attr :enabled, :boolean, required: true + attr :known, :boolean, required: true + attr :status, :string, default: nil + attr :flag, :string, default: nil + attr :use_full_names, :boolean, required: true + attr :show_status, :boolean, required: true + attr :show_flags, :boolean, required: true + attr :show_primary, :boolean, required: true + attr :show_primary_label, :boolean, required: true + attr :variant, :atom, required: true + attr :size, :atom, required: true + + defp item_content(assigns) do + ~H""" + + + + {@flag} + + + {display_label(@lang, @use_full_names)} + + <.primary_star :if={@show_primary && @is_primary} size={@size} /> + + {gettext("Primary")} + + + """ + end + + attr :size, :atom, required: true + + defp primary_star(assigns) do + ~H""" + + + + """ + end + + # ── Language map accessors ───────────────────────────────────── + + @doc false + def lang_code(%{code: code}), do: code + def lang_code(%{"code" => code}), do: code + def lang_code(_), do: nil + + defp lang_exists?(%{exists: exists}) when is_boolean(exists), do: exists + defp lang_exists?(%{"exists" => exists}) when is_boolean(exists), do: exists + defp lang_exists?(%{status: status}) when is_binary(status), do: true + defp lang_exists?(%{"status" => status}) when is_binary(status), do: true + defp lang_exists?(_), do: false + + defp lang_primary?(%{is_primary: val}) when is_boolean(val), do: val + defp lang_primary?(%{"is_primary" => val}) when is_boolean(val), do: val + defp lang_primary?(_), do: false + + defp lang_enabled?(%{enabled: val}) when is_boolean(val), do: val + defp lang_enabled?(%{"enabled" => val}) when is_boolean(val), do: val + defp lang_enabled?(_), do: true + + defp lang_known?(%{known: val}) when is_boolean(val), do: val + defp lang_known?(%{"known" => val}) when is_boolean(val), do: val + defp lang_known?(_), do: true + + # Prepends cursor-default and strips hover: classes for display-only spans. + # Preserves title tooltip (unlike pointer-events-none) while removing + # visual interactivity cues. + defp display_only_classes(classes) when is_list(classes) do + ["cursor-default" | Enum.map(classes, &strip_hover/1)] + end + + defp strip_hover(class) when is_binary(class) do + class + |> String.split(" ") + |> Enum.reject(&String.starts_with?(&1, "hover:")) + |> Enum.join(" ") + end + + defp strip_hover(other), do: other + + # Generic field accessor for optional fields (uuid, url, flag, etc.) + defp lang_field(lang, key) when is_map(lang) and is_atom(key) do + lang[key] || lang[Atom.to_string(key)] + end + + defp lang_field(_lang, _key), do: nil + + # Safely invoke on_click_js function, returning nil on error. + defp resolve_click_js(nil, _code), do: nil + + defp resolve_click_js(fun, code) when is_function(fun, 1) do + fun.(code) + rescue + _ -> nil + end + + defp resolve_click_js(_invalid, _code), do: nil + + # ── Display helpers ──────────────────────────────────────────── + + defp display_label(lang, true = _full_names) when is_map(lang) do + lang[:name] || lang["name"] || format_short_code(lang) + end + + defp display_label(lang, false = _compact) when is_map(lang) do + format_short_code(lang) + end + + defp format_short_code(lang) when is_map(lang) do + code = + lang[:short_code] || lang["short_code"] || + lang[:display_code] || lang["display_code"] || + derive_short_code(lang) + + if is_binary(code), do: String.upcase(code), else: code + end + + defp derive_short_code(lang) do + case lang[:code] || lang["code"] do + code when is_binary(code) -> code |> String.split("-") |> List.first() |> String.upcase() + _ -> "?" + end + end + + defp prefix_language_urls(languages) do + Enum.map(languages, fn lang -> + case lang_field(lang, :url) do + nil -> + lang + + url when is_binary(url) -> + try do + Map.put(lang, :url, Routes.path(url)) + rescue + _ -> lang + end + + _ -> + lang + end + end) + end + + defp filter_languages(languages, true = _show_status), do: languages + + # When show_status is false (public mode), only show languages with published content. + # Languages without exists/status fields pass through — they're UI tabs, not content entries. + defp filter_languages(languages, false = _public) do + Enum.filter(languages, fn lang -> + has_content_fields = + Map.has_key?(lang, :exists) || Map.has_key?(lang, "exists") || + Map.has_key?(lang, :status) || Map.has_key?(lang, "status") + + if has_content_fields do + lang_exists?(lang) && lang_status(lang) in [nil, "published"] + else + true + end + end) + end + + defp lang_status(%{status: status}), do: status + defp lang_status(%{"status" => status}), do: status + defp lang_status(_), do: nil + + # Pills can render a single language (useful for status display). + # Inline and tabs need at least 2 to be meaningful. + defp show_switcher?([], _variant), do: false + defp show_switcher?(_languages, :pills), do: true + defp show_switcher?([_single], _variant), do: false + defp show_switcher?(_languages, _variant), do: true + + # Pipe separators: always for inline, only in compact mode for tabs, never for pills. + defp show_separator?(:inline, _use_full_names), do: true + defp show_separator?(:tabs, use_full_names), do: !use_full_names + defp show_separator?(:pills, _use_full_names), do: false + + defp show_divider?(false, _lang, _idx, _last_idx), do: false + + defp show_divider?(true, lang, idx, last_idx) do + lang_primary?(lang) && idx < last_idx + end + + # ── Styling ──────────────────────────────────────────────────── + + defp switcher_container_class(:inline, size, extra) do + ["inline-flex items-center flex-wrap", size_gap_class(size), extra] + end + + defp switcher_container_class(:tabs, _size, extra) do + [ + "inline-flex flex-wrap items-center gap-1 p-1 bg-base-200 rounded-box", + extra + ] + end + + defp switcher_container_class(:pills, _size, extra) do + ["flex flex-wrap gap-2", extra] + end + + defp item_classes(is_current, exists, is_primary, show_add, variant, size, enabled, known) do + case variant do + :pills -> + pill_classes(is_current, is_primary, exists, show_add, size, enabled, known) + + other -> + base = [ + "inline-flex items-center rounded transition-all", + size_padding_class(size) + ] + + state = + case other do + :tabs -> tab_state_class(is_current) + :inline -> inline_state_class(is_current, exists, show_add, enabled, known) + end + + base ++ [state] + end + end + + defp pill_classes(is_current, is_primary, exists, show_add, size, enabled, known) do + degraded = !enabled or !known + + base = [ + "inline-flex items-center gap-1.5 rounded-lg transition-all", + pill_padding_class(size) + ] + + state = pill_state(is_current, is_primary, exists, show_add, degraded) + base ++ [state] + end + + defp pill_state(_current, true = _primary, _exists, _add, true = _degraded), + do: "bg-primary/10 border border-primary/20 opacity-60" + + defp pill_state(_current, true = _primary, _exists, _add, false), + do: "bg-primary/10 border border-primary/20 hover:bg-primary/20" + + defp pill_state(true = _current, _primary, _exists, _add, true = _degraded), + do: "bg-base-300 border border-base-content/20 opacity-60" + + defp pill_state(_current, _primary, _exists, _add, true = _degraded), + do: "bg-base-200/50 border border-base-content/10 opacity-60" + + defp pill_state(true = _current, _primary, _exists, _add, _degraded), + do: "bg-base-300 border border-base-content/20" + + defp pill_state(_current, _primary, false = _exists, true = _add, _degraded), + do: "bg-success/5 border border-dashed border-success/20 hover:bg-success/10" + + defp pill_state(_current, _primary, false = _exists, _add, _degraded), + do: "bg-base-200/50 border border-dashed border-base-content/10 hover:bg-base-200" + + defp pill_state(_current, _primary, _exists, _add, _degraded), + do: "bg-base-200 hover:bg-base-300" + + defp pill_padding_class(:xs), do: "px-2 py-1" + defp pill_padding_class(:sm), do: "px-3 py-1.5" + defp pill_padding_class(:md), do: "px-4 py-2" + + defp tab_state_class(true = _current), do: "bg-primary/20 text-primary font-semibold shadow-sm" + defp tab_state_class(false), do: "hover:bg-base-100/50" + + defp inline_state_class(is_current, exists, show_add, enabled, known) do + cond do + is_current and (!enabled or !known) -> + "bg-base-content/30 text-base-content/50 font-semibold" + + !enabled or !known -> + "text-base-content/40 hover:bg-base-200/50" + + is_current -> + "bg-primary/30 text-primary font-semibold" + + !exists && show_add -> + "text-success hover:bg-success/10" + + exists -> + "hover:bg-base-200" + + true -> + "text-base-content/40" + end + end + + defp label_classes(exists, status, is_current, is_primary, enabled, known, variant, size) do + base = [size_text_class(size)] + + weight = + cond do + is_primary -> "font-bold" + is_current -> "font-semibold" + true -> "font-medium" + end + + decoration = if !enabled or !known, do: "line-through", else: nil + color = label_color(exists, status, is_current, variant) + + Enum.filter(base ++ [weight, decoration, color], & &1) + end + + # In :tabs and :pills variants, text color is neutral (controlled by container/pill bg). + # In :inline variant, text color reflects content status. + defp label_color(_exists, _status, true = _current, _variant), do: nil + defp label_color(_exists, _status, _current, :tabs), do: nil + defp label_color(_exists, _status, _current, :pills), do: nil + + defp label_color(exists, status, false, :inline) do + cond do + !exists -> "text-base-content/40" + status == "published" -> "text-success" + status == "draft" -> "text-warning" + status == "archived" -> "text-base-content/40" + true -> nil + end + end + + # Dot color resolution: dot_color > status > exists + defp dot_classes(lang, exists, size) do + base = ["rounded-full inline-block", dot_size_class(size)] + color = resolve_dot_color(lang, exists) + base ++ [color] + end + + @valid_dot_colors ~w(success warning error info primary secondary accent neutral base-content/20 base-content/40) + + defp resolve_dot_color(lang, exists) when is_map(lang) do + explicit = lang[:dot_color] || lang["dot_color"] + + if explicit && explicit in @valid_dot_colors do + "bg-#{explicit}" + else + status_dot_color(lang_status(lang), exists) + end + end + + defp status_dot_color("published", _exists), do: "bg-success" + defp status_dot_color("draft", _exists), do: "bg-warning" + defp status_dot_color("archived", _exists), do: "bg-base-content/40" + defp status_dot_color(_status, true), do: "bg-success" + defp status_dot_color(_status, false), do: "bg-base-content/20" + defp status_dot_color(_status, _exists), do: "bg-base-content/20" + + # ── Size classes ─────────────────────────────────────────────── + + defp dot_size_class(:xs), do: "w-1.5 h-1.5" + defp dot_size_class(:sm), do: "w-2 h-2" + defp dot_size_class(:md), do: "w-2.5 h-2.5" + + defp size_text_class(:xs), do: "text-xs" + defp size_text_class(:sm), do: "text-sm" + defp size_text_class(:md), do: "text-base" + + defp size_padding_class(:xs), do: "px-1 py-0.5" + defp size_padding_class(:sm), do: "px-1.5 py-0.5" + defp size_padding_class(:md), do: "px-2 py-1" + + defp size_gap_class(:xs), do: "gap-0.5" + defp size_gap_class(:sm), do: "gap-1" + defp size_gap_class(:md), do: "gap-1.5" + + defp star_size_class(:xs), do: "w-2.5 h-2.5" + defp star_size_class(:sm), do: "w-3 h-3" + defp star_size_class(:md), do: "w-3.5 h-3.5" + + defp flag_size_class(:xs), do: "text-sm" + defp flag_size_class(:sm), do: "text-base" + defp flag_size_class(:md), do: "text-lg" + + # ── Titles / tooltips ────────────────────────────────────────── + + defp build_title(lang, exists, enabled, known, show_status) when is_map(lang) do + name = lang[:name] || lang["name"] || derive_short_code(lang) + + if show_status do + build_status_title(name, exists, lang_status(lang), enabled, known) + else + name + end + end + + defp build_status_title(name, _exists, _status, _enabled, false = _known) do + gettext("%{language} (Unknown language)", language: name) + end + + defp build_status_title(name, exists, status, false = _enabled, _known) do + if exists do + status_text = status_label(status) + gettext("%{language} (Disabled — %{status})", language: name, status: status_text) + else + gettext("Add %{language} translation", language: name) + end + end + + defp build_status_title(name, false = _exists, _status, _enabled, _known) do + gettext("Add %{language} translation", language: name) + end + + defp build_status_title(name, true, "published", _enabled, _known) do + gettext("%{language} (Published)", language: name) + end + + defp build_status_title(name, true, "draft", _enabled, _known) do + gettext("%{language} (Draft)", language: name) + end + + defp build_status_title(name, true, "archived", _enabled, _known) do + gettext("%{language} (Archived)", language: name) + end + + defp build_status_title(name, _, _, _, _), do: name + + defp status_label("published"), do: gettext("Published") + defp status_label("draft"), do: gettext("Draft") + defp status_label("archived"), do: gettext("Archived") + defp status_label(_), do: gettext("Unknown") + + # ── Safe coercion ─────────────────────────────────────────────── + # Ensures invalid attr values don't crash the render. Replaces bad values + # with safe defaults so the component always produces valid HTML. + + defp coerce_attrs(assigns) do + assigns + |> coerce_attr(:languages, &is_list/1, []) + |> coerce_languages() + |> coerce_attr(:variant, &(&1 in [:inline, :tabs, :pills]), :inline) + |> coerce_attr(:size, &(&1 in [:xs, :sm, :md]), :sm) + |> coerce_attr(:display, &(&1 in [:auto, :full, :compact]), :auto) + |> coerce_attr(:auto_threshold, &(is_integer(&1) and &1 >= 1), 3) + |> coerce_on_click_js() + end + + # Filter out non-map items from the languages list so they never reach switcher_item. + defp coerce_languages(assigns) do + languages = assigns.languages + + if Enum.all?(languages, &is_map/1) do + assigns + else + assign(assigns, :languages, Enum.filter(languages, &is_map/1)) + end + end + + defp coerce_attr(assigns, key, valid_fn, default) do + if valid_fn.(assigns[key]), do: assigns, else: assign(assigns, key, default) + end + + defp coerce_on_click_js(assigns) do + case assigns.on_click_js do + nil -> assigns + fun when is_function(fun, 1) -> assigns + _ -> assign(assigns, :on_click_js, nil) + end + end + + # ── Configuration validation ─────────────────────────────────── + # Logs warnings for conflicting or nonsensical attribute combinations. + # Never crashes — the component renders with sensible precedence regardless. + + require Logger + + defp validate_config(assigns) do + if Application.get_env(:phoenix_kit, :env) != :prod do + do_validate_config(assigns) + end + end + + defp do_validate_config(assigns) do + validate_attr_types(assigns) + validate_click_conflict(assigns) + validate_exclude_primary_conflicts(assigns) + validate_show_add(assigns) + validate_pills_divider(assigns) + validate_language_codes(assigns) + end + + @valid_variants [:inline, :tabs, :pills] + @valid_sizes [:xs, :sm, :md] + @valid_displays [:auto, :full, :compact] + + defp validate_attr_types(assigns) do + unless is_list(assigns.languages) do + Logger.warning( + "language_switcher: languages must be a list, got #{inspect(assigns.languages)}. " <> + "Defaulting to empty list." + ) + end + + unless assigns.variant in @valid_variants do + Logger.warning( + "language_switcher: invalid variant #{inspect(assigns.variant)}. " <> + "Valid values: #{inspect(@valid_variants)}. Falling back to :inline." + ) + end + + unless assigns.size in @valid_sizes do + Logger.warning( + "language_switcher: invalid size #{inspect(assigns.size)}. " <> + "Valid values: #{inspect(@valid_sizes)}. Falling back to :sm." + ) + end + + unless assigns.display in @valid_displays do + Logger.warning( + "language_switcher: invalid display #{inspect(assigns.display)}. " <> + "Valid values: #{inspect(@valid_displays)}. Falling back to :auto." + ) + end + + if assigns.on_click_js && !is_function(assigns.on_click_js, 1) do + Logger.warning( + "language_switcher: on_click_js must be a 1-arity function, " <> + "got #{inspect(assigns.on_click_js)}. It will be ignored." + ) + end + + if assigns.on_click && !is_binary(assigns.on_click) do + Logger.warning( + "language_switcher: on_click must be a string event name, " <> + "got #{inspect(assigns.on_click)}. It may not work as expected." + ) + end + + if assigns.auto_threshold && + (!is_integer(assigns.auto_threshold) || assigns.auto_threshold < 1) do + Logger.warning( + "language_switcher: auto_threshold must be a positive integer, " <> + "got #{inspect(assigns.auto_threshold)}. Falling back to 3." + ) + end + + validate_language_maps(assigns.languages) + end + + defp validate_language_maps(languages) when is_list(languages) do + non_maps = Enum.count(languages, fn lang -> !is_map(lang) end) + + if non_maps > 0 do + Logger.warning( + "language_switcher: #{non_maps} item(s) in languages list are not maps. " <> + "Each language must be a map with at least a :code key." + ) + end + end + + defp validate_language_maps(_), do: :ok + + defp validate_click_conflict(%{on_click: on_click, on_click_js: on_click_js}) + when not is_nil(on_click) and not is_nil(on_click_js) do + Logger.warning( + "language_switcher: both on_click and on_click_js are set. " <> + "on_click_js takes precedence; on_click is used as fallback only if on_click_js returns nil." + ) + end + + defp validate_click_conflict(_assigns), do: :ok + + defp validate_exclude_primary_conflicts(%{exclude_primary: true} = assigns) do + if assigns.primary_divider do + Logger.warning( + "language_switcher: exclude_primary and primary_divider are both set. " <> + "The divider has no effect since the primary language is excluded." + ) + end + + if assigns.show_primary || assigns.show_primary_label do + Logger.warning( + "language_switcher: exclude_primary is set with show_primary or show_primary_label. " <> + "Primary indicators have no effect since the primary language is excluded." + ) + end + end + + defp validate_exclude_primary_conflicts(_assigns), do: :ok + + defp validate_show_add(%{show_add: true} = assigns) do + if !assigns.show_status do + Logger.warning( + "language_switcher: show_add is set but show_status is false. " <> + "Non-existing languages are filtered out when show_status is false, " <> + "so show_add styling will never be visible. Set show_status={true} to show addable languages." + ) + end + + if !assigns.on_click && !assigns.on_click_js do + has_urls = Enum.any?(assigns.languages, fn lang -> lang_field(lang, :url) != nil end) + + unless has_urls do + Logger.warning( + "language_switcher: show_add is set but no interaction mode is configured " <> + "(no on_click, on_click_js, or url keys). " <> + "Addable languages will be styled but not clickable." + ) + end + end + end + + defp validate_show_add(_assigns), do: :ok + + defp validate_pills_divider(%{variant: :pills, primary_divider: true}) do + Logger.warning( + "language_switcher: primary_divider has no visual effect with variant :pills. " <> + "Pills are separated by gap spacing, not dividers." + ) + end + + defp validate_pills_divider(_assigns), do: :ok + + defp validate_language_codes(%{languages: languages}) do + codeless_count = + Enum.count(languages, fn lang -> lang_code(lang) == nil end) + + if codeless_count > 0 do + Logger.warning( + "language_switcher: #{codeless_count} language(s) in the list have no :code key. " <> + "These items will render with \"?\" as the display code and nil for event values." + ) + end + end +end diff --git a/lib/phoenix_kit_web/components/multilang_form.ex b/lib/phoenix_kit_web/components/multilang_form.ex new file mode 100644 index 000000000..474147f5c --- /dev/null +++ b/lib/phoenix_kit_web/components/multilang_form.ex @@ -0,0 +1,798 @@ +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` + """ + def mount_multilang(socket) do + multilang_enabled = multilang_enabled?() + primary_language = if multilang_enabled, do: safe_primary_language(), else: nil + + Phoenix.Component.assign(socket, + multilang_enabled: multilang_enabled, + primary_language: primary_language, + current_lang: primary_language, + language_tabs: if(multilang_enabled, do: safe_build_language_tabs(), else: []) + ) + 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 + ) + 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 "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""" +
1} class={@class}> +
+
+ <.icon name="hero-language" class="w-5 h-5 text-primary" /> +

{gettext("Content Language")}

+
+ <% primary_tab = Enum.find(@language_tabs, fn t -> t.is_primary end) %> +
+ <.icon name="hero-star-solid" class="w-3.5 h-3.5 text-primary" /> + {gettext("Primary: %{lang}", lang: primary_tab.name)} +
+
+ +
+ <.icon name="hero-information-circle" class="w-4 h-4" /> + + {gettext( + "Use the language tabs below to translate this record's content. The primary language (marked with a star) is required. Other languages are optional — any empty fields will fall back to the primary language value." + )} + +
+ +
+ <.language_switcher + languages={@language_tabs} + current_language={@current_lang} + on_click_js={&switch_lang_js(&1, @current_lang)} + display={@display} + auto_threshold={5} + show_flags={true} + show_primary={true} + primary_divider={true} + variant={:tabs} + size={:sm} + /> +
+
+ """ + end + + @doc """ + Renders skeleton loading placeholders and a content wrapper for translatable fields. + + The skeleton is shown instantly on tab switch via JS, then hidden when LiveView + re-renders with the new language data. + + Wrap your translatable form fields inside this component's inner block. + + ## Customizing skeletons + + Use the `:skeleton` slot to provide custom skeleton markup that matches + your form layout. If omitted, a default two-field skeleton is rendered. + + ## Attributes + + * `multilang_enabled` — boolean + * `current_lang` — current language code (used in element IDs for morphdom) + * `skeleton_class` — CSS class for the skeleton container. Default: `"card-body pt-4"` + * `fields_class` — CSS class for the fields container. Default: nil + + ## Example — default skeleton (card context) + + <.multilang_fields_wrapper multilang_enabled={@multilang_enabled} current_lang={@current_lang}> + <%!-- translatable fields here --%> + + + ## Example — custom skeleton and classes (non-card context) + + <.multilang_fields_wrapper + multilang_enabled={@multilang_enabled} + current_lang={@current_lang} + skeleton_class="space-y-6" + fields_class="space-y-6" + > + <:skeleton> +
+
+
+
+ + <%!-- translatable fields here --%> + + """ + attr :multilang_enabled, :boolean, required: true + attr :current_lang, :string, required: true + attr :skeleton_class, :string, default: "card-body pt-4" + attr :fields_class, :string, default: nil + slot :skeleton + slot :inner_block, required: true + + def multilang_fields_wrapper(assigns) do + ~H""" + <%!-- Skeleton placeholders (shown instantly on tab click). + IDs include current_lang so morphdom treats them as new elements. --%> +
+ <%= if @skeleton != [] do %> + {render_slot(@skeleton)} + <% else %> +
+
+
+
+
+
+
+
+
+
+ <% end %> +
+ +
+ {render_slot(@inner_block)} +
+ """ + end + + @doc """ + Renders a translatable text input or textarea field. + + On the primary language tab, renders a standard input reading from the changeset. + On secondary language tabs, renders with a language-specific name and uses the + primary language value as placeholder text. + + Works both inside a `<.multilang_fields_wrapper>` (whole-form translation) and + standalone (single-field translation). + + ## Translation models + + Supports two naming patterns for secondary language inputs: + + **Default (JSONB data column)** — used by entity data records: + - Secondary name: `form_prefix[lang_field_name]` + - Lang data key: `"_field_name"` + + **Settings translations** — used by entity definitions and other models + that store translations in a `settings["translations"]` map. Set + `secondary_name` and `lang_data_key` to override the defaults: + + <.translatable_field + field_name="display_name" + form_prefix="entities" + secondary_name={"entities[translations][\#{@current_lang}][display_name]"} + lang_data_key="display_name" + ... + /> + + ## Attributes + + * `field_name` — the DB column name (e.g., "name") + * `form_prefix` — the form name prefix (e.g., "catalogue") + * `changeset` — the Ecto changeset + * `schema_field` — the schema field atom (e.g., `:name`) + * `multilang_enabled` — boolean + * `current_lang` — current language code + * `primary_language` — primary language code + * `lang_data` — raw language data map for the current language + * `label` — field label text + * `placeholder` — placeholder for primary language (optional) + * `type` — "input" or "textarea". Default: "input" + * `rows` — textarea rows (only for type="textarea"). Default: 3 + * `required` — marks the primary language field as required. Default: false + * `disabled` — disables the field. Default: false + * `class` — additional CSS class(es) for the input element. Default: nil + * `pattern` — HTML pattern attribute for input validation. Default: nil + * `title` — HTML title attribute (for pattern validation message). Default: nil + * `hint` — hint text shown below the field. Default: nil + * `secondary_hint` — hint text shown only on secondary language tabs. Default: nil + * `secondary_name` — override the secondary tab input name. Default: `"form_prefix[lang_field_name]"` + * `lang_data_key` — key to look up in `lang_data` for secondary value. + Default: `"_field_name"`. Set to `"field_name"` for settings translations. + """ + attr :field_name, :string, required: true + attr :form_prefix, :string, required: true + attr :changeset, :any, required: true + attr :schema_field, :atom, required: true + attr :multilang_enabled, :boolean, required: true + attr :current_lang, :string, required: true + attr :primary_language, :string, required: true + attr :lang_data, :map, required: true + attr :label, :string, required: true + attr :placeholder, :string, default: nil + attr :type, :string, default: "input" + attr :rows, :integer, default: 3 + attr :required, :boolean, default: false + attr :disabled, :boolean, default: false + attr :class, :string, default: nil + attr :pattern, :string, default: nil + attr :title, :string, default: nil + attr :hint, :string, default: nil + attr :secondary_hint, :string, default: nil + attr :secondary_name, :string, default: nil + attr :lang_data_key, :string, default: nil + slot :label_extra + + def translatable_field(assigns) do + # Coerce lang_data to map if nil/invalid + assigns = + if is_map(assigns.lang_data), do: assigns, else: assign(assigns, :lang_data, %{}) + + is_primary = !assigns.multilang_enabled || assigns.current_lang == assigns.primary_language + + # Resolve the lang_data lookup key: custom or default "_field_name" + data_key = assigns.lang_data_key || "_#{assigns.field_name}" + + # Resolve the secondary input name: custom or default "prefix[lang_field]" + sec_name = + assigns.secondary_name || "#{assigns.form_prefix}[lang_#{assigns.field_name}]" + + # Extract changeset errors for this field (primary tab only) + errors = field_errors(assigns.changeset, assigns.schema_field, is_primary) + + assigns = + assigns + |> assign(:is_primary, is_primary) + |> assign(:errors, errors) + |> assign_new(:primary_value, fn -> + safe_get_field(assigns.changeset, assigns.schema_field) + end) + |> assign_new(:lang_value, fn -> + Map.get(assigns.lang_data, data_key) + end) + |> assign(:secondary_input_name, sec_name) + |> assign_new(:input_id, fn -> + if is_primary do + "#{assigns.form_prefix}_#{assigns.field_name}" + else + "#{assigns.form_prefix}_#{assigns.field_name}_#{assigns.current_lang}" + end + end) + |> assign_new(:input_class, fn -> + base = + if assigns.type == "textarea", + do: "textarea textarea-bordered", + else: "input input-bordered" + + base = if assigns.class, do: "#{base} #{assigns.class}", else: base + if errors != [], do: "#{base} input-error", else: base + end) + + ~H""" +
+ + <%= if @type == "textarea" do %> + <%= if @is_primary do %> + + <% else %> + + <% end %> + <% else %> + <%= if @is_primary do %> + + <% else %> + + <% end %> + <% end %> + <.error :for={msg <- @errors}>{msg} + + +
+ """ + end + + # Extracts translated error messages for a field from the changeset. + # Only returns errors on the primary tab — secondary tabs don't validate DB columns. + defp field_errors(%Ecto.Changeset{action: action, errors: errors}, field, true = _is_primary) + when not is_nil(action) do + errors + |> Keyword.get_values(field) + |> Enum.map(&translate_error/1) + end + + defp field_errors(_changeset, _field, _is_primary), do: [] + + # ── JS helper ────────────────────────────────────────────────── + + @doc """ + Returns a `Phoenix.LiveView.JS` command that switches languages. + + Pushes the `"switch_language"` event, hides current field content, + and shows skeleton placeholders for instant visual feedback. + + Returns a no-op when `lang_code == current_lang` to prevent skeleton ghosts. + """ + def switch_lang_js(lang_code, current_lang) do + if lang_code == current_lang do + %JS{} + else + JS.push("switch_language", value: %{lang: lang_code}) + |> JS.add_class("hidden", to: "[data-translatable=fields]") + |> JS.remove_class("hidden", to: "[data-translatable=skeletons]") + end + end +end diff --git a/test/phoenix_kit_web/components/language_switcher_test.exs b/test/phoenix_kit_web/components/language_switcher_test.exs new file mode 100644 index 000000000..9739548f5 --- /dev/null +++ b/test/phoenix_kit_web/components/language_switcher_test.exs @@ -0,0 +1,740 @@ +defmodule PhoenixKitWeb.Components.LanguageSwitcherTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest, only: [rendered_to_string: 1] + import Phoenix.Component, only: [sigil_H: 2] + import PhoenixKitWeb.Components.LanguageSwitcher + + alias Phoenix.LiveView.JS + + # ── Test data ──────────────────────────────────────────────── + + defp two_languages do + [ + %{code: "en-US", name: "English (US)", short_code: "EN-US", flag: "🇺🇸", is_primary: true}, + %{code: "es", name: "Spanish", short_code: "ES", flag: "🇪🇸", is_primary: false} + ] + end + + defp six_languages do + [ + %{code: "en", name: "English", short_code: "EN", is_primary: true}, + %{code: "fr", name: "French", short_code: "FR", is_primary: false}, + %{code: "de", name: "German", short_code: "DE", is_primary: false}, + %{code: "es", name: "Spanish", short_code: "ES", is_primary: false}, + %{code: "it", name: "Italian", short_code: "IT", is_primary: false}, + %{code: "ja", name: "Japanese", short_code: "JA", is_primary: false} + ] + end + + defp publishing_languages do + [ + %{ + code: "en", + name: "English", + display_code: "en", + status: "published", + exists: true, + is_primary: true + }, + %{ + code: "fr", + name: "French", + display_code: "fr", + status: "draft", + exists: true, + is_primary: false + }, + %{ + code: "de", + name: "German", + display_code: "de", + status: nil, + exists: false, + is_primary: false + } + ] + end + + defp string_keyed_languages do + [ + %{ + "code" => "en", + "name" => "English", + "is_primary" => true, + "status" => "published", + "exists" => true + }, + %{ + "code" => "fr", + "name" => "French", + "is_primary" => false, + "status" => "draft", + "exists" => true + } + ] + end + + defp html(assigns) do + rendered_to_string(assigns) + end + + # ── Basic rendering ───────────────────────────────────────── + + describe "basic rendering" do + test "renders nothing with empty list" do + assigns = %{} + result = html(~H"<.language_switcher languages={[]} />") + assert result =~ "" + end + + test "renders nothing with single language for inline/tabs" do + assigns = %{langs: [%{code: "en", name: "English"}]} + result = html(~H"<.language_switcher languages={@langs} />") + refute result =~ "tablist" + end + + test "renders two languages with inline variant" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" /> + """) + + assert result =~ "English (US)" + assert result =~ "Spanish" + assert result =~ "|" + end + + test "renders with tabs variant" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" variant={:tabs} /> + """) + + assert result =~ "bg-base-200 rounded-box" + assert result =~ "role=\"tablist\"" + end + + test "renders with pills variant" do + assigns = %{langs: publishing_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" variant={:pills} show_status={true} /> + """) + + assert result =~ "rounded-lg" + end + + test "renders single language for pills variant" do + assigns = %{langs: [%{code: "en", name: "English", exists: true}]} + + result = + html(~H""" + <.language_switcher languages={@langs} variant={:pills} show_status={true} /> + """) + + # Single language (<=3) uses full names in auto mode + assert result =~ "English" + end + end + + # ── Display modes ─────────────────────────────────────────── + + describe "display modes" do + test "auto mode shows full names when <= threshold" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher + languages={@langs} + current_language="en-US" + display={:auto} + auto_threshold={3} + /> + """) + + assert result =~ "English (US)" + assert result =~ "Spanish" + end + + test "auto mode shows short codes when > threshold" do + assigns = %{langs: six_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" display={:auto} auto_threshold={3} /> + """) + + assert result =~ "EN" + assert result =~ "FR" + end + + test "compact mode always shows short codes" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" display={:compact} /> + """) + + assert result =~ "EN-US" + assert result =~ "ES" + end + + test "full mode always shows full names" do + assigns = %{langs: six_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" display={:full} /> + """) + + assert result =~ "English" + assert result =~ "French" + assert result =~ "German" + end + end + + # ── Filtering ─────────────────────────────────────────────── + + describe "filtering" do + test "show_status=false filters non-published languages with exists/status" do + assigns = %{langs: publishing_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" /> + """) + + # Only English (published) passes the filter, but single language + # means inline variant renders nothing + assert result == "" + end + + test "show_status=true shows all languages" do + assigns = %{langs: publishing_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" show_status={true} /> + """) + + # Titles include status annotations + assert result =~ "title=\"English (Published)" + assert result =~ "title=\"French (Draft)" + assert result =~ "title=\"Add German translation" + end + + test "languages without exists/status pass through when show_status=false" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" /> + """) + + # 2 languages <= 3 threshold => full names in auto mode + assert result =~ "English (US)" + assert result =~ "Spanish" + end + + test "exclude_primary removes primary language" do + assigns = %{langs: publishing_languages()} + + result = + html(~H""" + <.language_switcher + languages={@langs} + current_language="en" + show_status={true} + exclude_primary={true} + /> + """) + + refute result =~ "title=\"English" + # 2 languages <= 3 threshold => full names in auto mode + assert result =~ "French" + assert result =~ "German" + end + + test "string-keyed maps work with filtering" do + assigns = %{langs: string_keyed_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" show_status={true} /> + """) + + # 2 languages <= 3 threshold => full names in auto mode + assert result =~ "English" + assert result =~ "French" + end + end + + # ── Status dots ───────────────────────────────────────────── + + describe "status dots" do + test "renders dots when show_status=true" do + assigns = %{langs: publishing_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" show_status={true} /> + """) + + assert result =~ "rounded-full" + assert result =~ "bg-success" + assert result =~ "bg-warning" + end + + test "no dots when show_status=false" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" show_status={false} /> + """) + + refute result =~ "rounded-full" + end + + test "custom dot_color overrides status" do + assigns = %{ + langs: [ + %{code: "en", name: "English", exists: true, dot_color: "error"}, + %{code: "fr", name: "French", exists: true, dot_color: "info"} + ] + } + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" show_status={true} /> + """) + + assert result =~ "bg-error" + assert result =~ "bg-info" + end + + test "invalid dot_color falls back to status" do + langs = [ + %{code: "en", name: "English", exists: true, status: "published", dot_color: "invalid"}, + %{code: "fr", name: "French", exists: true, status: "draft"} + ] + + assigns = %{langs: langs} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" show_status={true} /> + """) + + assert result =~ "bg-success" + assert result =~ "bg-warning" + end + end + + # ── Primary indicators ───────────────────────────────────── + + describe "primary indicators" do + test "show_primary renders star SVG" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" show_primary={true} /> + """) + + assert result =~ " + """) + + assert result =~ "Primary" + end + + test "primary_divider renders vertical line" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher + languages={@langs} + current_language="en-US" + show_primary={true} + primary_divider={true} + /> + """) + + assert result =~ "w-px h-4 bg-base-content/20" + end + end + + # ── Separators ────────────────────────────────────────────── + + describe "separators" do + test "inline always shows pipe separators" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" variant={:inline} /> + """) + + assert result =~ "|" + end + + test "tabs compact shows pipe separators" do + assigns = %{langs: six_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" variant={:tabs} display={:compact} /> + """) + + assert result =~ "|" + end + + test "tabs full does not show pipe separators" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" variant={:tabs} display={:full} /> + """) + + refute result =~ "text-base-content/30" + end + + test "pills never shows pipe separators" do + assigns = %{langs: publishing_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" variant={:pills} show_status={true} /> + """) + + refute result =~ "text-base-content/30" + end + end + + # ── Interaction modes ─────────────────────────────────────── + + describe "interaction modes" do + test "on_click renders buttons" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" on_click="switch_language" /> + """) + + assert result =~ " JS.push("switch", value: %{lang: code}) end + assigns = %{langs: two_languages(), js_fn: js_fn} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" on_click_js={@js_fn} /> + """) + + assert result =~ " + """) + + assert result =~ " + """) + + assert result =~ " + """) + + assert result =~ "aria-pressed=\"true\"" + assert result =~ "aria-pressed=\"false\"" + end + + test "aria-current on links" do + langs = [ + %{code: "en", name: "English", url: "/en", status: "published", exists: true}, + %{code: "fr", name: "French", url: "/fr", status: "published", exists: true} + ] + + assigns = %{langs: langs} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" show_status={true} /> + """) + + assert result =~ "aria-current=\"true\"" + end + end + + # ── Flags ─────────────────────────────────────────────────── + + describe "flags" do + test "show_flags renders flag emojis" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" show_flags={true} /> + """) + + assert result =~ "🇺🇸" + assert result =~ "🇪🇸" + end + + test "flags hidden by default" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" /> + """) + + refute result =~ "🇺🇸" + end + end + + # ── Active styling ────────────────────────────────────────── + + describe "active styling" do + test "current tab has primary styling in tabs" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" variant={:tabs} on_click="switch" /> + """) + + assert result =~ "bg-primary/20 text-primary" + end + + test "current item has primary styling in inline" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" on_click="switch" /> + """) + + assert result =~ "bg-primary/30 text-primary" + end + end + + # ── Coercion / safety ────────────────────────────────────── + + describe "coercion and safety" do + test "non-map items filtered out" do + langs = [%{code: "en", name: "English"}, "not a map", 42, %{code: "fr", name: "French"}] + assigns = %{langs: langs} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" /> + """) + + # 2 valid languages <= 3 threshold => full names in auto mode + assert result =~ "English" + assert result =~ "French" + end + + test "language without code renders with ? in compact mode" do + langs = [%{name: "No Code"}, %{code: "en", name: "English"}] + assigns = %{langs: langs} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" display={:compact} /> + """) + + assert result =~ "?" + end + + test "on_click_js that raises is handled" do + bad_fn = fn _code -> raise "boom" end + assigns = %{langs: two_languages(), bad_fn: bad_fn} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" on_click_js={@bad_fn} /> + """) + + assert result =~ " + """) + + # 2 languages <= 3 threshold => full names in auto mode + assert result =~ "English" + assert result =~ "French" + assert result =~ "bg-success" + assert result =~ "bg-warning" + end + end + + # ── Container attributes ──────────────────────────────────── + + describe "container attributes" do + test "id is set" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" id="my-switcher" /> + """) + + assert result =~ "id=\"my-switcher\"" + end + + test "custom class applied" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" class="mt-4" /> + """) + + assert result =~ "mt-4" + end + end + + # ── Size variants ─────────────────────────────────────────── + + describe "sizes" do + test "xs uses smaller classes" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" size={:xs} /> + """) + + assert result =~ "text-xs" + end + + test "md uses larger classes" do + assigns = %{langs: two_languages()} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" size={:md} /> + """) + + assert result =~ "text-base" + end + end + + # ── Short code uppercasing ────────────────────────────────── + + describe "uppercasing" do + test "display_code is uppercased" do + langs = [ + %{code: "en", name: "English", display_code: "en"}, + %{code: "fr", name: "French", display_code: "fr"} + ] + + assigns = %{langs: langs} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en" display={:compact} /> + """) + + assert result =~ "EN" + assert result =~ "FR" + end + + test "auto-derived code is uppercased" do + langs = [ + %{code: "en-US", name: "English"}, + %{code: "fr-FR", name: "French"} + ] + + assigns = %{langs: langs} + + result = + html(~H""" + <.language_switcher languages={@langs} current_language="en-US" display={:compact} /> + """) + + assert result =~ "EN" + assert result =~ "FR" + end + end + + # ── lang_code/1 ───────────────────────────────────────────── + + describe "lang_code/1" do + test "reads atom key" do + assert lang_code(%{code: "en"}) == "en" + end + + test "reads string key" do + assert lang_code(%{"code" => "fr"}) == "fr" + end + + test "returns nil for missing key" do + assert lang_code(%{name: "English"}) == nil + end + + test "returns nil for non-map" do + assert lang_code("en") == nil + end + end +end diff --git a/test/phoenix_kit_web/components/multilang_form_test.exs b/test/phoenix_kit_web/components/multilang_form_test.exs new file mode 100644 index 000000000..a6ef1f8be --- /dev/null +++ b/test/phoenix_kit_web/components/multilang_form_test.exs @@ -0,0 +1,684 @@ +defmodule PhoenixKitWeb.Components.MultilangFormTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest, only: [rendered_to_string: 1] + import Phoenix.Component, only: [sigil_H: 2] + import PhoenixKitWeb.Components.MultilangForm + + # ── Test helpers ───────────────────────────────────────────── + + defp html(assigns) do + rendered_to_string(assigns) + end + + defp make_changeset(data \\ %{}, changes \\ %{}) do + types = %{ + title: :string, + slug: :string, + name: :string, + display_name: :string, + display_name_plural: :string, + description: :string, + status: :string, + data: :map + } + + {data, types} + |> Ecto.Changeset.cast(changes, Map.keys(types)) + end + + defp make_error_changeset(field, message) do + make_changeset() + |> Ecto.Changeset.add_error(field, message) + |> Map.put(:action, :validate) + end + + # ── multilang_enabled? ────────────────────────────────────── + + describe "multilang_enabled?/0" do + test "returns boolean without crashing" do + result = multilang_enabled?() + assert is_boolean(result) + end + end + + # ── primary_tab? ──────────────────────────────────────────── + + describe "primary_tab?/1" do + test "returns true when multilang disabled" do + assert primary_tab?(%{multilang_enabled: false, current_lang: nil, primary_language: nil}) + end + + test "returns true when on primary language" do + assert primary_tab?(%{multilang_enabled: true, current_lang: "en", primary_language: "en"}) + end + + test "returns false when on secondary language" do + refute primary_tab?(%{multilang_enabled: true, current_lang: "fr", primary_language: "en"}) + end + end + + # ── get_lang_data ─────────────────────────────────────────── + + describe "get_lang_data/3" do + test "returns empty map when multilang disabled" do + changeset = make_changeset() + assert get_lang_data(changeset, "en", false) == %{} + end + + test "returns empty map when changeset is nil" do + assert get_lang_data(nil, "en", true) == %{} + end + end + + # ── translatable_field primary tab ────────────────────────── + + describe "translatable_field primary tab" do + test "renders input with primary name" do + changeset = make_changeset(%{title: "Hello"}) + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={false} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Title" + /> + """) + + assert result =~ ~s(name="record[title]") + assert result =~ ~s(id="record_title") + assert result =~ "Title" + assert result =~ "Hello" + end + + test "renders required marker" do + changeset = make_changeset() + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={true} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Title" + required + /> + """) + + assert result =~ "*" + assert result =~ "required" + end + + test "renders textarea" do + changeset = make_changeset(%{description: "Some text"}) + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="description" + form_prefix="record" + changeset={@changeset} + schema_field={:description} + multilang_enabled={false} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Description" + type="textarea" + rows={5} + /> + """) + + assert result =~ " + """) + + assert result =~ "Lowercase only" + end + + test "renders label_extra slot" do + changeset = make_changeset() + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="slug" + form_prefix="record" + changeset={@changeset} + schema_field={:slug} + multilang_enabled={true} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Slug" + > + <:label_extra> + + + + """) + + assert result =~ "Generate" + end + end + + # ── translatable_field secondary tab ──────────────────────── + + describe "translatable_field secondary tab" do + test "renders with lang_ prefix by default" do + changeset = make_changeset(%{title: "Primary Title"}) + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={true} + current_lang="fr" + primary_language="en" + lang_data={%{"_title" => "Titre"}} + label="Title" + /> + """) + + assert result =~ ~s(name="record[lang_title]") + assert result =~ ~s(id="record_title_fr") + assert result =~ ~s(value="Titre") + assert result =~ ~s(placeholder="Primary Title") + end + + test "renders with custom secondary_name" do + changeset = make_changeset(%{display_name: "Brand"}) + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="display_name" + form_prefix="entities" + changeset={@changeset} + schema_field={:display_name} + multilang_enabled={true} + current_lang="es" + primary_language="en" + lang_data={%{"display_name" => "Marca"}} + secondary_name="entities[translations][es][display_name]" + lang_data_key="display_name" + label="Display Name" + /> + """) + + assert result =~ ~s(name="entities[translations][es][display_name]") + assert result =~ ~s(value="Marca") + end + + test "no required marker on secondary" do + changeset = make_changeset() + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={true} + current_lang="fr" + primary_language="en" + lang_data={%{}} + label="Title" + required + /> + """) + + refute result =~ "*" + refute result =~ "required" + end + + test "renders secondary_hint" do + changeset = make_changeset() + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="slug" + form_prefix="record" + changeset={@changeset} + schema_field={:slug} + multilang_enabled={true} + current_lang="fr" + primary_language="en" + lang_data={%{}} + label="Slug" + hint="Auto-generated" + secondary_hint="Leave empty for primary slug" + /> + """) + + assert result =~ "Leave empty for primary slug" + refute result =~ "Auto-generated" + end + end + + # ── Error display ─────────────────────────────────────────── + + describe "error display" do + test "shows errors on primary tab with action" do + changeset = make_error_changeset(:title, "can't be blank") + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={true} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Title" + required + /> + """) + + assert result =~ "input-error" + end + + test "no errors on secondary tab" do + changeset = make_error_changeset(:title, "can't be blank") + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={true} + current_lang="fr" + primary_language="en" + lang_data={%{}} + label="Title" + /> + """) + + refute result =~ "input-error" + end + + test "no errors without action" do + changeset = + make_changeset() + |> Ecto.Changeset.add_error(:title, "can't be blank") + + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={true} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Title" + /> + """) + + refute result =~ "input-error" + end + + test "hint hidden when errors present" do + changeset = make_error_changeset(:title, "can't be blank") + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={true} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Title" + hint="Enter a title" + /> + """) + + refute result =~ "Enter a title" + end + end + + # ── Field attributes ──────────────────────────────────────── + + describe "field attributes" do + test "disabled" do + changeset = make_changeset() + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={false} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Title" + disabled + /> + """) + + assert result =~ "disabled" + end + + test "pattern and title" do + changeset = make_changeset() + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="slug" + form_prefix="record" + changeset={@changeset} + schema_field={:slug} + multilang_enabled={false} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Slug" + pattern="[a-z0-9-]+" + title="Lowercase only" + /> + """) + + assert result =~ ~s(pattern="[a-z0-9-]+") + assert result =~ "Lowercase only" + end + + test "custom class" do + changeset = make_changeset() + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={false} + current_lang="en" + primary_language="en" + lang_data={%{}} + label="Title" + class="w-full" + /> + """) + + assert result =~ "input input-bordered w-full" + end + end + + # ── Safety ────────────────────────────────────────────────── + + describe "safety" do + test "nil lang_data coerced to empty map" do + changeset = make_changeset(%{title: "Test"}) + assigns = %{changeset: changeset} + + result = + html(~H""" + <.translatable_field + field_name="title" + form_prefix="record" + changeset={@changeset} + schema_field={:title} + multilang_enabled={true} + current_lang="fr" + primary_language="en" + lang_data={nil} + label="Title" + /> + """) + + assert result =~ "record[lang_title]" + end + end + + # ── multilang_fields_wrapper ──────────────────────────────── + + describe "multilang_fields_wrapper" do + test "renders skeleton and fields containers" do + assigns = %{} + + result = + html(~H""" + <.multilang_fields_wrapper multilang_enabled={true} current_lang="en"> +

Content

+ + """) + + assert result =~ "translatable-skeletons-en" + assert result =~ "translatable-fields-en" + assert result =~ "Content" + assert result =~ ~s(data-translatable="skeletons") + assert result =~ ~s(data-translatable="fields") + end + + test "skeleton is hidden" do + assigns = %{} + + result = + html(~H""" + <.multilang_fields_wrapper multilang_enabled={true} current_lang="en"> +

Content

+ + """) + + assert result =~ "hidden" + end + + test "custom skeleton slot" do + assigns = %{} + + result = + html(~H""" + <.multilang_fields_wrapper multilang_enabled={true} current_lang="en"> + <:skeleton> +
Loading...
+ +

Content

+ + """) + + assert result =~ "custom-skeleton" + assert result =~ "Loading..." + end + + test "custom skeleton_class" do + assigns = %{} + + result = + html(~H""" + <.multilang_fields_wrapper multilang_enabled={true} current_lang="en" skeleton_class="space-y-6"> +

Content

+ + """) + + assert result =~ "space-y-6" + end + + test "custom fields_class" do + assigns = %{} + + result = + html(~H""" + <.multilang_fields_wrapper multilang_enabled={true} current_lang="en" fields_class="space-y-6"> +

Content

+ + """) + + assert result =~ "space-y-6" + end + + test "no skeleton when multilang disabled" do + assigns = %{} + + result = + html(~H""" + <.multilang_fields_wrapper multilang_enabled={false} current_lang="en"> +

Content

+ + """) + + refute result =~ "translatable-skeletons" + assert result =~ "Content" + end + + test "IDs change with language" do + assigns = %{} + + en = + html(~H""" + <.multilang_fields_wrapper multilang_enabled={true} current_lang="en"> +

Content

+ + """) + + fr = + html(~H""" + <.multilang_fields_wrapper multilang_enabled={true} current_lang="fr"> +

Content

+ + """) + + assert en =~ "translatable-fields-en" + assert fr =~ "translatable-fields-fr" + refute en =~ "translatable-fields-fr" + end + end + + # ── switch_lang_js ────────────────────────────────────────── + + describe "switch_lang_js/2" do + test "no-op for same language" do + assert switch_lang_js("en", "en") == %Phoenix.LiveView.JS{} + end + + test "returns JS commands for different language" do + refute switch_lang_js("fr", "en") == %Phoenix.LiveView.JS{} + end + end + + # ── inject_db_field_into_data ─────────────────────────────── + + describe "inject_db_field_into_data/5" do + test "injects on primary tab" do + assigns = %{ + multilang_enabled: true, + primary_language: "en", + changeset: make_changeset(%{data: %{}}) + } + + result = inject_db_field_into_data(%{}, "title", %{"title" => "Hello"}, "en", assigns) + assert result == %{"_title" => "Hello"} + end + + test "injects lang_ prefixed on secondary tab" do + assigns = %{ + multilang_enabled: true, + primary_language: "en", + changeset: make_changeset(%{data: %{}}) + } + + result = + inject_db_field_into_data(%{}, "title", %{"lang_title" => "Bonjour"}, "fr", assigns) + + assert result == %{"_title" => "Bonjour"} + end + + test "passes through when disabled" do + assigns = %{multilang_enabled: false} + result = inject_db_field_into_data(%{"existing" => "data"}, "title", %{}, "en", assigns) + assert result == %{"existing" => "data"} + end + end + + # ── merge_translatable_params ─────────────────────────────── + + describe "merge_translatable_params/4" do + test "passes through when disabled" do + changeset = make_changeset() + + socket = %Phoenix.LiveView.Socket{ + assigns: %{ + multilang_enabled: false, + primary_language: nil, + current_lang: nil, + __changed__: %{} + } + } + + result = + merge_translatable_params(%{"name" => "Test"}, socket, ["name"], changeset: changeset) + + refute Map.has_key?(result, "data") + assert result["name"] == "Test" + end + end +end