diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e7ccac2..b9accb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 1.7.43 - 2026-02-17 +## 1.7.43 - 2026-02-18 - Fix Language struct Access error on admin modules page and all bracket-access-on-struct bugs - Add 20 typed structs replacing plain maps across billing, entities, sync, emails, AI, and dashboard - Billing: CheckoutSession, SetupSession, WebhookEventData, PaymentMethodInfo, ChargeResult, RefundResult, ProviderInfo @@ -9,6 +9,15 @@ - Fix shop module .id to .uuid migration in Storage image lookups and import modules - Fix hardcoded "PhoenixKit" fallback in admin header project title - Fix 2 dialyzer warnings in checkout session and UUID migration +- Add multi-language support for Entities module + - New `Multilang` module with pure-function helpers for multilang JSONB data + - Language tabs in entity form, data form, and data view (adaptive compact mode for >5 languages) + - Override-only storage for secondary languages with ghost-text placeholders + - Lazy re-keying when global primary language changes + - Translation convenience API: `Entities.set_entity_translation/3`, `EntityData.set_translation/3`, `EntityData.set_title_translation/3`, and related get/remove functions + - Multilang-aware category extraction in data navigator and entity data + - Non-translatable fields (slug, status) separated into their own card + - Required field indicators hidden on secondary language tabs ## 1.7.42 - 2026-02-17 - Use PostgreSQL IF NOT EXISTS / IF EXISTS for UUID column operations diff --git a/lib/modules/entities/DEEP_DIVE.md b/lib/modules/entities/DEEP_DIVE.md index 3906b78e..349472b9 100644 --- a/lib/modules/entities/DEEP_DIVE.md +++ b/lib/modules/entities/DEEP_DIVE.md @@ -15,9 +15,10 @@ 7. [Public Form Builder](#public-form-builder) 8. [HTML Sanitization](#html-sanitization) 9. [Real-Time Collaboration](#real-time-collaboration) -10. [Usage Examples](#usage-examples) -11. [Implementation Details](#implementation-details) -12. [Settings Integration](#settings-integration) +10. [Multi-Language Support](#multi-language-support) +11. [Usage Examples](#usage-examples) +12. [Implementation Details](#implementation-details) +13. [Settings Integration](#settings-integration) --- @@ -1016,6 +1017,124 @@ def handle_info({:data_updated, entity_id, data_id}, socket) --- +## Multi-Language Support + +The Entities system integrates with the **Languages module** to provide multilang content storage. When the Languages module is enabled with 2+ languages, all entities automatically support multilang data — no per-entity configuration needed. + +### Architecture + +The multilang system is built around three principles: + +1. **Override-only storage** — Secondary languages only store fields that differ from primary. This minimizes storage and makes it clear what's been translated. +2. **Lazy migration** — Existing flat records are automatically wrapped into multilang structure on first edit. No bulk migration needed. +3. **Embedded primary** — Each record stores its own `_primary_language` key, allowing records created under different primary languages to coexist. + +### Core Module: `PhoenixKit.Entities.Multilang` + +Pure-function module with zero side effects. All functions operate on data maps without touching the database. + +| Function | Purpose | +|----------|---------| +| `enabled?/0` | Checks Languages module has 2+ enabled languages | +| `primary_language/0` | Gets global default language code | +| `enabled_languages/0` | Lists all enabled language codes | +| `multilang_data?/1` | Detects `_primary_language` key in data map | +| `get_language_data/2` | Returns merged data for a language (primary base + overrides) | +| `get_primary_data/1` | Returns primary language data only | +| `get_raw_language_data/2` | Returns raw overrides only (for UI inherited-vs-override detection) | +| `put_language_data/3` | Merges form data into multilang JSONB (primary: all fields, secondary: overrides only) | +| `migrate_to_multilang/2` | Wraps flat data into multilang structure | +| `flatten_to_primary/1` | Extracts primary language data from multilang structure | +| `rekey_primary/2` | Changes primary language, promotes new primary to full data | +| `maybe_rekey_data/1` | Auto-rekeys if embedded primary differs from global | +| `build_language_tabs/0` | Builds language tab UI data with adaptive short codes | + +### JSONB Data Structure + +``` +# Flat (single language) +data: {"name": "Acme", "category": "Tech"} + +# Multilang +data: { + "_primary_language": "en-US", + "en-US": {"name": "Acme", "category": "Tech", "desc": "A company"}, + "es-ES": {"name": "Acme España"} ← override only +} +``` + +The `_primary_language` key cannot collide with user field keys because field keys must match `^[a-z][a-z0-9_]*$` (start with lowercase letter). + +### Translation Storage Locations + +| Content | Primary language | Secondary languages | +|---------|-----------------|---------------------| +| Data custom fields | `data[primary_lang]` | `data[lang_code]` (overrides) | +| Record title | `title` column | `metadata["translations"][lang_code]["title"]` | +| Entity display_name | `display_name` column | `settings["translations"][lang_code]["display_name"]` | +| Entity description | `description` column | `settings["translations"][lang_code]["description"]` | + +### Primary Language Re-keying + +When the global primary language changes (via Languages admin), existing records have stale `_primary_language` values. The system handles this lazily: + +1. **Read paths** (navigator, data view) use the **embedded** `_primary_language` — old records display correctly without any migration. +2. **Edit paths** (data form) detect the mismatch on mount and silently restructure: + - Update `_primary_language` to the new global primary + - Promote new primary to have all fields (missing fields filled from old primary) + - Swap title between column and metadata translations + - Changes persist when the user saves + +This approach avoids bulk migrations and is idempotent — if the user doesn't save, re-keying happens again on next edit. + +### Convenience API + +The translation API provides high-level functions so that scripts and AI agents can manage translations without understanding the internal JSONB structure: + +**Entity definitions** (`PhoenixKit.Entities`): +```elixir +Entities.set_entity_translation(entity, "es-ES", %{"display_name" => "Productos"}) +Entities.get_entity_translation(entity, "es-ES") +Entities.get_entity_translations(entity) +Entities.remove_entity_translation(entity, "es-ES") +Entities.multilang_enabled?() +``` + +**Data records** (`PhoenixKit.Entities.EntityData`): +```elixir +EntityData.set_translation(record, "es-ES", %{"name" => "Acme España"}) +EntityData.get_translation(record, "es-ES") +EntityData.get_all_translations(record) +EntityData.get_raw_translation(record, "es-ES") +EntityData.remove_translation(record, "es-ES") + +EntityData.set_title_translation(record, "es-ES", "Mi Producto") +EntityData.get_title_translation(record, "es-ES") +EntityData.get_all_title_translations(record) +``` + +### Admin UI Behavior + +- **Language tabs** appear in entity form and data form when multilang is enabled +- Translatable fields (display_name, title, custom fields) are inside the language tab area +- Non-translatable fields (slug, icon, status) are in a separate card +- Secondary language fields show primary values as ghost text (placeholders) +- Required field indicators (`*`) are hidden on secondary language tabs +- When >5 languages, tabs show adaptive short codes (EN, ES) with full names on hover +- Tabs wrap and use `|` separators in compact mode + +### Known Limitations + +| Limitation | Details | Workaround | +|------------|---------|------------| +| **Search is primary-language only** | The data navigator search queries the primary language data. Secondary language content is not included in search results. | Use the convenience API (`get_translation/2`) for programmatic cross-language search. | +| **Public form builder creates flat data** | The public-facing entity form (`EntityFormBuilder`) writes flat JSONB (no multilang structure). Records created via public forms only contain one language. | Edit the record in the admin UI to add translations, or use `set_translation/3` programmatically. | +| **Clearing a secondary field inherits from primary** | When a secondary language field is cleared (empty string), the display falls back to the primary language value. There is no way to set a field to explicitly empty. | This is by design — override-only storage treats empty as "not overridden". | +| **Entity definition translations are manual** | When the global primary language changes, entity definition translations (display_name, description) are not automatically re-keyed. | Edit the entity definition to enter the new primary language values manually. This is acceptable since entity definitions are low-volume. | +| **Un-saved re-keying is repeated** | Lazy re-keying on edit is not persisted until the user saves. If the user opens and closes without saving, re-keying happens again on next edit. | This is idempotent and by design. | + +--- + ## Usage Examples ### Creating a Blog Post Entity @@ -1642,6 +1761,13 @@ test "unique constraint on entity name" @spec enable_system() :: {:ok, Setting.t()} @spec disable_system() :: {:ok, Setting.t()} @spec get_system_stats() :: map() + +# Translation API +@spec multilang_enabled?() :: boolean() +@spec get_entity_translations(t()) :: map() +@spec get_entity_translation(t(), String.t()) :: map() +@spec set_entity_translation(t(), String.t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} +@spec remove_entity_translation(t(), String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} ``` Note: `create_entity/1` auto-fills `created_by` with the first admin user if not provided. @@ -1670,10 +1796,38 @@ Note: `create_entity/1` auto-fills `created_by` with the first admin user if not @spec update(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} @spec delete(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} @spec change(t(), map()) :: Ecto.Changeset.t() + +# Translation API +@spec get_translation(t(), String.t()) :: map() +@spec get_raw_translation(t(), String.t()) :: map() +@spec get_all_translations(t()) :: map() +@spec set_translation(t(), String.t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} +@spec remove_translation(t(), String.t()) :: {:ok, t()} | {:error, :cannot_remove_primary} | {:error, :not_multilang} +@spec get_title_translation(t(), String.t()) :: String.t() | nil +@spec set_title_translation(t(), String.t(), String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} +@spec get_all_title_translations(t()) :: map() ``` Note: `create/1` auto-fills `created_by` with the first admin user if not provided. +### PhoenixKit.Entities.Multilang + +```elixir +@spec enabled?() :: boolean() +@spec primary_language() :: String.t() +@spec enabled_languages() :: [String.t()] +@spec multilang_data?(map() | nil) :: boolean() +@spec get_language_data(map() | nil, String.t()) :: map() +@spec get_primary_data(map() | nil) :: map() +@spec get_raw_language_data(map() | nil, String.t()) :: map() +@spec put_language_data(map() | nil, String.t(), map()) :: map() +@spec migrate_to_multilang(map() | nil, String.t()) :: map() +@spec flatten_to_primary(map() | nil) :: map() +@spec rekey_primary(map() | nil, String.t()) :: map() +@spec maybe_rekey_data(map() | nil) :: map() | nil +@spec build_language_tabs() :: [map()] +``` + ### PhoenixKit.Entities.FieldTypes ```elixir @@ -1732,6 +1886,18 @@ Note: `create/1` auto-fills `created_by` with the first admin user if not provid - `/admin/entities/:entity_slug/data/:id/edit` - Edit data record - `/admin/settings/entities` - Entities module settings +### Multi-Language Support (2026-02) + +**Added:** +- `Multilang` module — pure-function helpers for multilang JSONB data +- Language tabs in entity form, data form, and data view +- Override-only storage for secondary languages +- Ghost-text placeholders showing primary values on secondary tabs +- Adaptive compact tabs (short codes) for >5 languages +- Lazy re-keying when global primary language changes +- Translation convenience API on `Entities` and `EntityData` modules +- Multilang-aware category extraction and bulk operations + ### Recent Updates (2025-12) **Added:** @@ -1770,6 +1936,6 @@ For issues, questions, or contributions related to the entities system: --- -**Last Updated**: 2025-12-03 -**Version**: V17+ with Public Form Builder +**Last Updated**: 2026-02-18 +**Version**: V17+ with Public Form Builder & Multi-Language Support **Status**: Production Ready diff --git a/lib/modules/entities/OVERVIEW.md b/lib/modules/entities/OVERVIEW.md index 3867abed..23451d16 100644 --- a/lib/modules/entities/OVERVIEW.md +++ b/lib/modules/entities/OVERVIEW.md @@ -24,6 +24,7 @@ lib/modules/entities/ ├── entity_data.ex # Data record schema + CRUD helpers ├── field_types.ex # Registry of supported field types ├── form_builder.ex # Dynamic form rendering + validation helpers +├── multilang.ex # Multi-language data transformation helpers ├── html_sanitizer.ex # XSS prevention for rich_text fields ├── presence.ex # Phoenix.Presence for real-time collaboration ├── presence_helpers.ex # FIFO locking and presence utilities @@ -78,8 +79,8 @@ Indexes cover `name`, `status`, `created_by`. A comment block documents JSON col - `title` – record label - `slug` – optional unique slug per entity - `status` – `draft | published | archived` -- `data` – JSONB map keyed by field definition -- `metadata` – optional JSONB extras +- `data` – JSONB map keyed by field definition (or multilang structure, see below) +- `metadata` – optional JSONB extras (includes title translations when multilang enabled) - `created_by` – admin user id - `date_created`, `date_updated` @@ -125,9 +126,18 @@ Registry of supported field types with metadata: - `text_field/3`, `textarea_field/3`, `email_field/3`, `number_field/3`, `boolean_field/3`, `rich_text_field/3` – Common field types - Used both when saving entity definitions and when rendering forms. +### `PhoenixKit.Entities.Multilang` +Pure-function module for multi-language data transformations. No database calls — used by LiveViews and the convenience API. +- Global helpers: `enabled?/0`, `primary_language/0`, `enabled_languages/0`. +- Data reading: `get_language_data/2`, `get_primary_data/1`, `get_raw_language_data/2`, `multilang_data?/1`. +- Data writing: `put_language_data/3`, `migrate_to_multilang/2`, `flatten_to_primary/1`. +- Re-keying: `rekey_primary/2`, `maybe_rekey_data/1` — handles primary language changes. +- UI: `build_language_tabs/0` — builds tab data for language switcher UI. + ### `PhoenixKit.Entities.FormBuilder` - Renders form inputs dynamically based on field definitions (`build_fields/3`, `build_field/3`). - Provides `validate_data/2` and lower-level helpers to check payloads before they reach `EntityData.changeset/2`. +- Language-aware: accepts `lang_code` option to render fields for a specific language, with ghost-text placeholders showing primary language values on secondary tabs. - Produces consistent labels, placeholders, and helper text aligned with Tailwind/daisyUI styling. --- @@ -281,6 +291,125 @@ PhoenixKit.Entities.validate_user_entity_limit(admin.id) --- +## Multi-Language Support + +When the **Languages module** is enabled with 2+ languages, all entities automatically support multilang content. There is no per-entity toggle — languages are configured system-wide. + +### Data Structure + +**Flat (single language or multilang disabled):** +```json +{"name": "Acme", "category": "Tech"} +``` + +**Multilang (Languages module has 2+ languages):** +```json +{ + "_primary_language": "en-US", + "en-US": {"name": "Acme", "category": "Tech", "desc": "A company"}, + "es-ES": {"name": "Acme España"} +} +``` + +- `_primary_language` signals the multilang structure (cannot collide with field keys — they must match `^[a-z][a-z0-9_]*$`) +- Primary language stores ALL fields +- Secondary languages store ONLY overrides (fields that differ from primary) +- Display merges: `Map.merge(primary_data, language_overrides)` +- `title` and `slug` DB columns remain primary-language-only; secondary title translations are in `metadata["translations"]` +- Entity definition translations (display_name, etc.) are in `entity.settings["translations"]` + +### Translation Storage Summary + +| What | Primary language | Secondary languages | +|------|-----------------|---------------------| +| Entity data (custom fields) | `data["en-US"]` | `data["es-ES"]` (overrides only) | +| Record title | `title` column | `metadata["translations"]["es-ES"]["title"]` | +| Entity display_name | `display_name` column | `settings["translations"]["es-ES"]["display_name"]` | + +### Enabling Multilang + +```elixir +# 1. Enable Languages module +PhoenixKit.Modules.Languages.enable_system() + +# 2. Add secondary languages +PhoenixKit.Modules.Languages.add_language("es-ES") +PhoenixKit.Modules.Languages.add_language("fr-FR") + +# 3. Multilang is now active for all entities +PhoenixKit.Modules.Entities.multilang_enabled?() +# => true +``` + +### Translation API (Programmatic) + +```elixir +alias PhoenixKit.Modules.Entities +alias PhoenixKit.Modules.Entities.EntityData + +# --- Entity definition translations --- +entity = Entities.get_entity_by_name("products") + +Entities.set_entity_translation(entity, "es-ES", %{ + "display_name" => "Productos", + "display_name_plural" => "Productos", + "description" => "Catálogo de productos" +}) + +Entities.get_entity_translation(entity, "es-ES") +# => %{"display_name" => "Productos", "display_name_plural" => "Productos", ...} + +Entities.get_entity_translations(entity) +# => %{"es-ES" => %{...}, "fr-FR" => %{...}} + +# --- Entity data translations --- +record = EntityData.get(uuid) + +EntityData.set_translation(record, "es-ES", %{"name" => "Acme España", "desc" => "Una empresa"}) +EntityData.set_title_translation(record, "es-ES", "Mi Producto") + +EntityData.get_translation(record, "es-ES") +# => %{"name" => "Acme España", "category" => "Tech", "desc" => "Una empresa"} + +EntityData.get_all_translations(record) +# => %{"en-US" => %{...}, "es-ES" => %{...}} + +EntityData.get_all_title_translations(record) +# => %{"en-US" => "My Product", "es-ES" => "Mi Producto"} + +# Remove a language's translations +EntityData.remove_translation(record, "fr-FR") +Entities.remove_entity_translation(entity, "fr-FR") +``` + +### Primary Language Changes + +When the global primary language changes (via Languages admin), existing records lazily re-key on edit: + +1. User opens an existing record for editing +2. System detects embedded `_primary_language` differs from global primary +3. The new primary is promoted to have all fields (missing fields filled from old primary) +4. Title is swapped between the column and metadata translations +5. Changes persist when the user saves + +Records that are never edited continue to work — read paths use the embedded primary for correct display. + +### Admin UI + +- **Entity form** (`/admin/entities/:id/edit`): Language tabs above translatable fields (display_name, display_name_plural, description). Non-translatable fields (slug, icon, status) in a separate card. +- **Data form** (`/admin/entities/:slug/data/:id/edit`): Language tabs for title and custom fields. Slug, status, and entity type in a separate card. +- **Data view**: Read-only language tabs for viewing translations. +- **Compact mode**: When >5 languages, tabs show short codes (EN, ES) instead of full names. + +### Limitations + +- **Search** queries primary language data only; secondary translations are not searched. +- **Public form builder** creates flat (non-multilang) data. Use the admin UI or API to add translations afterward. +- **Clearing a secondary field** makes it inherit the primary value (by design — override-only storage). +- See `DEEP_DIVE.md § Known Limitations` for the full table. + +--- + ## Extending the system 1. **New field type** – update `FieldTypes` (definition + defaults), extend `FormBuilder`, and add validation handling to `EntityData` if needed. diff --git a/lib/modules/entities/README.md b/lib/modules/entities/README.md index c7ceff54..2ac2231e 100644 --- a/lib/modules/entities/README.md +++ b/lib/modules/entities/README.md @@ -19,6 +19,7 @@ All templates follow Phoenix 1.8 layout conventions (`` with `@ - **Entity Designer** – Build custom fields, validations, and display ordering for each entity type. - **JSONB Storage** – Field definitions stored as JSONB, no database migrations needed for schema changes. +- **Multi-Language Support** – Language tabs in forms, override-only storage for secondary languages, lazy re-keying on primary language change. Driven globally by the Languages module. - **Data Navigator** – Browse, search, and filter entity data with status filters and archive/restore workflow. - **Collaborative Editing** – Presence helpers in entity_form and data_form prevent overwrites when multiple admins edit the same record. - **Settings Guardrails** – Module can be toggled on/off via PhoenixKit Settings (`entities_enabled`). @@ -27,14 +28,17 @@ All templates follow Phoenix 1.8 layout conventions (`` with `@ ## Integration Points - Context modules: `PhoenixKit.Entities`, `PhoenixKit.Entities.EntityData`, `PhoenixKit.Entities.FieldTypes`. +- Multilang module: `PhoenixKit.Entities.Multilang` – pure-function helpers for multilang JSONB. - Supporting modules: `PhoenixKit.Entities.Events`, `PhoenixKit.Entities.PresenceHelpers`. +- Languages integration: multilang is auto-enabled when `PhoenixKit.Modules.Languages` has 2+ enabled languages. - Enabling flag: `PhoenixKit.Settings.get_setting("entities_enabled", "false")`. - Router: available under `{prefix}/admin/entities/*` via `phoenix_kit_routes()`. ## Additional Reading -- Deep dive: `lib/phoenix_kit/entities/DEEP_DIVE.md` -- Guide: `guides/making-pages-live.md` (sections on entity-driven pages) +- Overview: `OVERVIEW.md` (in this directory) +- Deep dive: `DEEP_DIVE.md` (in this directory) +- Languages module: `lib/modules/languages/README.md` Keep this README in sync whenever new submodules or major workflows are added to the Entities LiveView stack. diff --git a/lib/modules/entities/entities.ex b/lib/modules/entities/entities.ex index e5ae5204..36c9c391 100644 --- a/lib/modules/entities/entities.ex +++ b/lib/modules/entities/entities.ex @@ -92,6 +92,7 @@ defmodule PhoenixKit.Modules.Entities do alias PhoenixKit.Modules.Entities.Events alias PhoenixKit.Modules.Entities.Mirror.Exporter alias PhoenixKit.Modules.Entities.Mirror.Storage + alias PhoenixKit.Modules.Entities.Multilang alias PhoenixKit.Settings alias PhoenixKit.Users.Auth alias PhoenixKit.Users.Auth.User @@ -841,6 +842,7 @@ defmodule PhoenixKit.Modules.Entities do update_entity(entity, %{settings: new_settings}) end + # ============================================================================ @doc """ Lists all entities with their mirror status and data counts. @@ -952,6 +954,137 @@ defmodule PhoenixKit.Modules.Entities do {:ok, success_count} end + # ============================================================================ + # Translation convenience API + # ============================================================================ + + @doc """ + Gets all translations for an entity definition. + + Returns a map of language codes to translated fields. + Only includes languages that have at least one translated field. + + ## Examples + + iex> get_entity_translations(entity) + %{ + "es-ES" => %{"display_name" => "Productos", "display_name_plural" => "Productos"}, + "fr-FR" => %{"display_name" => "Produits"} + } + + iex> get_entity_translations(entity_without_translations) + %{} + """ + def get_entity_translations(%__MODULE__{settings: settings}) do + (settings || %{}) + |> Map.get("translations", %{}) + end + + @doc """ + Gets the translation for a specific language on an entity definition. + + Returns the translated fields merged with the primary language values + as defaults. Returns primary language values if no translation exists. + + ## Examples + + iex> get_entity_translation(entity, "es-ES") + %{"display_name" => "Productos", "display_name_plural" => "Productos", "description" => "..."} + """ + def get_entity_translation(%__MODULE__{} = entity, lang_code) when is_binary(lang_code) do + primary = %{ + "display_name" => entity.display_name, + "display_name_plural" => entity.display_name_plural, + "description" => entity.description + } + + translations = get_entity_translations(entity) + lang_overrides = Map.get(translations, lang_code, %{}) + + Map.merge(primary, lang_overrides) + end + + @doc """ + Sets the translation for a specific language on an entity definition. + + Merges the provided fields into the existing translation for that language. + Empty string values are treated as "remove override" (field falls back to primary). + + ## Examples + + iex> set_entity_translation(entity, "es-ES", %{ + ...> "display_name" => "Productos", + ...> "display_name_plural" => "Productos" + ...> }) + {:ok, %PhoenixKit.Modules.Entities{}} + """ + def set_entity_translation(%__MODULE__{} = entity, lang_code, attrs) + when is_binary(lang_code) and is_map(attrs) do + current_settings = entity.settings || %{} + translations = Map.get(current_settings, "translations", %{}) + + existing = Map.get(translations, lang_code, %{}) + merged = Map.merge(existing, attrs) + + # Remove empty values (fall back to primary) + cleaned = + merged + |> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) + |> Map.new() + + updated_translations = + if map_size(cleaned) == 0 do + Map.delete(translations, lang_code) + else + Map.put(translations, lang_code, cleaned) + end + + new_settings = + if map_size(updated_translations) == 0 do + Map.delete(current_settings, "translations") + else + Map.put(current_settings, "translations", updated_translations) + end + + update_entity(entity, %{settings: new_settings}) + end + + @doc """ + Removes all translations for a specific language from an entity definition. + + ## Examples + + iex> remove_entity_translation(entity, "es-ES") + {:ok, %PhoenixKit.Modules.Entities{}} + """ + def remove_entity_translation(%__MODULE__{} = entity, lang_code) + when is_binary(lang_code) do + current_settings = entity.settings || %{} + translations = Map.get(current_settings, "translations", %{}) + updated = Map.delete(translations, lang_code) + + new_settings = + if map_size(updated) == 0 do + Map.delete(current_settings, "translations") + else + Map.put(current_settings, "translations", updated) + end + + update_entity(entity, %{settings: new_settings}) + end + + @doc """ + Checks if multilang is globally enabled (Languages module has 2+ languages). + + Convenience wrapper around `Multilang.enabled?/0`. + + ## Examples + + iex> PhoenixKit.Modules.Entities.multilang_enabled?() + true + """ + def multilang_enabled?, do: Multilang.enabled?() + defp repo do PhoenixKit.RepoHelper.repo() end diff --git a/lib/modules/entities/entity_data.ex b/lib/modules/entities/entity_data.ex index 2e956c2d..ed8e2782 100644 --- a/lib/modules/entities/entity_data.ex +++ b/lib/modules/entities/entity_data.ex @@ -75,6 +75,7 @@ defmodule PhoenixKit.Modules.Entities.EntityData do alias PhoenixKit.Modules.Entities.Events alias PhoenixKit.Modules.Entities.HtmlSanitizer alias PhoenixKit.Modules.Entities.Mirror.Exporter + alias PhoenixKit.Modules.Entities.Multilang alias PhoenixKit.Users.Auth alias PhoenixKit.Users.Auth.User alias PhoenixKit.Utils.UUID, as: UUIDUtils @@ -199,7 +200,27 @@ defmodule PhoenixKit.Modules.Entities.EntityData do try do entity = Entities.get_entity!(id) fields_definition = entity.fields_definition || [] - sanitized_data = HtmlSanitizer.sanitize_rich_text_fields(fields_definition, data) + + sanitized_data = + if Multilang.multilang_data?(data) do + # Sanitize each language's data independently + Enum.reduce(data, %{}, fn + {"_primary_language", value}, acc -> + Map.put(acc, "_primary_language", value) + + {lang_code, lang_data}, acc when is_map(lang_data) -> + sanitized = + HtmlSanitizer.sanitize_rich_text_fields(fields_definition, lang_data) + + Map.put(acc, lang_code, sanitized) + + {key, value}, acc -> + Map.put(acc, key, value) + end) + else + HtmlSanitizer.sanitize_rich_text_fields(fields_definition, data) + end + put_change(changeset, :data, sanitized_data) rescue Ecto.NoResultsError -> changeset @@ -232,8 +253,16 @@ defmodule PhoenixKit.Modules.Entities.EntityData do defp validate_data_fields(changeset, entity, data) do fields_definition = entity.fields_definition || [] + # For multilang data, validate the primary language data (which must be complete) + validation_data = + if Multilang.multilang_data?(data) do + Multilang.get_primary_data(data) + else + data + end + Enum.reduce(fields_definition, changeset, fn field_def, acc -> - validate_single_data_field(acc, field_def, data) + validate_single_data_field(acc, field_def, validation_data) end) end @@ -833,13 +862,35 @@ defmodule PhoenixKit.Modules.Entities.EntityData do def bulk_update_category(uuids, category) when is_list(uuids) do now = DateTime.utc_now() + # Handle both flat and multilang data structures. + # For multilang: update category in every language sub-map. + # For flat: update category at the top level. + # We detect multilang by checking for the _primary_language key. from(d in __MODULE__, where: d.uuid in ^uuids, update: [ set: [ data: fragment( - "jsonb_set(COALESCE(?, '{}'::jsonb), '{category}', to_jsonb(?::text))", + """ + CASE WHEN jsonb_exists(COALESCE(?, '{}'::jsonb), '_primary_language') + THEN ( + SELECT jsonb_object_agg( + key, + CASE + WHEN jsonb_typeof(value) = 'object' + THEN jsonb_set(value, '{category}', to_jsonb(?::text)) + ELSE value + END + ) + FROM jsonb_each(COALESCE(?, '{}'::jsonb)) + ) + ELSE jsonb_set(COALESCE(?, '{}'::jsonb), '{category}', to_jsonb(?::text)) + END + """, + d.data, + ^category, + d.data, d.data, ^category ), @@ -877,7 +928,16 @@ defmodule PhoenixKit.Modules.Entities.EntityData do """ def extract_unique_categories(entity_data_records) when is_list(entity_data_records) do entity_data_records - |> Enum.map(fn r -> get_in(r.data, ["category"]) end) + |> Enum.map(fn r -> + data = r.data || %{} + + if Multilang.multilang_data?(data) do + primary = data["_primary_language"] + get_in(data, [primary, "category"]) + else + Map.get(data, "category") + end + end) |> Enum.reject(&(&1 == nil || &1 == "")) |> Enum.uniq() |> Enum.sort() @@ -940,6 +1000,226 @@ defmodule PhoenixKit.Modules.Entities.EntityData do } end + # ============================================================================ + # Translation convenience API + # ============================================================================ + + @doc """ + Gets the data fields for a specific language, merged with primary language defaults. + + For multilang records, returns `Map.merge(primary_data, language_overrides)`. + For flat (non-multilang) records, returns the data as-is. + + ## Examples + + iex> get_translation(record, "es-ES") + %{"name" => "Acme España", "category" => "Tech"} + + iex> get_translation(flat_record, "en-US") + %{"name" => "Acme", "category" => "Tech"} + """ + def get_translation(%__MODULE__{data: data}, lang_code) when is_binary(lang_code) do + Multilang.get_language_data(data, lang_code) + end + + @doc """ + Gets the raw (non-merged) data for a specific language. + + For secondary languages, returns only the override fields (not merged with primary). + Useful for seeing which fields have explicit translations. + + ## Examples + + iex> get_raw_translation(record, "es-ES") + %{"name" => "Acme España"} + """ + def get_raw_translation(%__MODULE__{data: data}, lang_code) when is_binary(lang_code) do + Multilang.get_raw_language_data(data, lang_code) + end + + @doc """ + Gets translations for all languages in a record. + + Returns a map of language codes to their merged data. + For flat records, returns the data under the primary language key. + + ## Examples + + iex> get_all_translations(record) + %{ + "en-US" => %{"name" => "Acme", "category" => "Tech"}, + "es-ES" => %{"name" => "Acme España", "category" => "Tech"} + } + """ + def get_all_translations(%__MODULE__{data: data}) do + if Multilang.multilang_data?(data) do + Multilang.enabled_languages() + |> Map.new(fn lang -> {lang, Multilang.get_language_data(data, lang)} end) + else + primary = Multilang.primary_language() + %{primary => data || %{}} + end + end + + @doc """ + Sets the data translation for a specific language on a record. + + For the primary language, stores all fields. + For secondary languages, only stores fields that differ from primary (overrides). + Persists to the database. + + ## Examples + + iex> set_translation(record, "es-ES", %{"name" => "Acme España"}) + {:ok, %EntityData{}} + + iex> set_translation(record, "en-US", %{"name" => "Acme Corp", "category" => "Tech"}) + {:ok, %EntityData{}} + """ + def set_translation(%__MODULE__{} = entity_data, lang_code, field_data) + when is_binary(lang_code) and is_map(field_data) do + updated_data = Multilang.put_language_data(entity_data.data, lang_code, field_data) + __MODULE__.update(entity_data, %{data: updated_data}) + end + + @doc """ + Removes all data for a specific language from a record. + + Cannot remove the primary language. Returns `{:error, :cannot_remove_primary}` + if the primary language is targeted. + + ## Examples + + iex> remove_translation(record, "es-ES") + {:ok, %EntityData{}} + + iex> remove_translation(record, "en-US") + {:error, :cannot_remove_primary} + """ + def remove_translation(%__MODULE__{data: data} = entity_data, lang_code) + when is_binary(lang_code) do + if Multilang.multilang_data?(data) do + primary = data["_primary_language"] + + if lang_code == primary do + {:error, :cannot_remove_primary} + else + updated_data = Map.delete(data, lang_code) + __MODULE__.update(entity_data, %{data: updated_data}) + end + else + {:error, :not_multilang} + end + end + + @doc """ + Gets the title translation for a specific language. + + The primary language title is stored in the `title` DB column. + Secondary language titles are stored in `metadata["translations"]`. + + ## Examples + + iex> get_title_translation(record, "en-US") + "My Product" + + iex> get_title_translation(record, "es-ES") + "Mi Producto" + """ + def get_title_translation(%__MODULE__{} = entity_data, lang_code) + when is_binary(lang_code) do + primary = title_primary_language(entity_data) + + if lang_code == primary do + entity_data.title + else + translations = (entity_data.metadata || %{})["translations"] || %{} + get_in(translations, [lang_code, "title"]) || entity_data.title + end + end + + @doc """ + Sets the title translation for a specific language. + + For the primary language, updates the `title` column directly. + For secondary languages, stores in `metadata["translations"]`. + + ## Examples + + iex> set_title_translation(record, "es-ES", "Mi Producto") + {:ok, %EntityData{}} + + iex> set_title_translation(record, "en-US", "My Product") + {:ok, %EntityData{}} + """ + def set_title_translation(%__MODULE__{} = entity_data, lang_code, title) + when is_binary(lang_code) and is_binary(title) do + primary = title_primary_language(entity_data) + + if lang_code == primary do + __MODULE__.update(entity_data, %{title: title}) + else + metadata = entity_data.metadata || %{} + translations = Map.get(metadata, "translations", %{}) + + updated_trans = + if title == "" do + lang_data = Map.get(translations, lang_code, %{}) + cleaned = Map.delete(lang_data, "title") + + if map_size(cleaned) == 0, + do: Map.delete(translations, lang_code), + else: Map.put(translations, lang_code, cleaned) + else + lang_data = Map.get(translations, lang_code, %{}) + Map.put(translations, lang_code, Map.put(lang_data, "title", title)) + end + + updated_metadata = + if map_size(updated_trans) == 0, + do: Map.delete(metadata, "translations"), + else: Map.put(metadata, "translations", updated_trans) + + __MODULE__.update(entity_data, %{metadata: updated_metadata}) + end + end + + @doc """ + Gets all title translations for a record. + + Returns a map of language codes to title strings. + + ## Examples + + iex> get_all_title_translations(record) + %{"en-US" => "My Product", "es-ES" => "Mi Producto", "fr-FR" => "Mon Produit"} + """ + def get_all_title_translations(%__MODULE__{} = entity_data) do + primary = title_primary_language(entity_data) + translations = (entity_data.metadata || %{})["translations"] || %{} + + Multilang.enabled_languages() + |> Map.new(fn lang -> + if lang == primary do + {lang, entity_data.title} + else + {lang, get_in(translations, [lang, "title"]) || entity_data.title} + end + end) + end + + # Returns the primary language for title translations. + # Uses the record's embedded _primary_language (from data JSONB) rather than + # the global primary, so un-rekeyed records are handled correctly. + defp title_primary_language(%__MODULE__{data: data}) when is_map(data) do + case data["_primary_language"] do + nil -> Multilang.primary_language() + primary -> primary + end + end + + defp title_primary_language(_entity_data), do: Multilang.primary_language() + defp repo do PhoenixKit.RepoHelper.repo() end diff --git a/lib/modules/entities/form_builder.ex b/lib/modules/entities/form_builder.ex index 4d9cefeb..55af1912 100644 --- a/lib/modules/entities/form_builder.ex +++ b/lib/modules/entities/form_builder.ex @@ -39,6 +39,8 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do import PhoenixKitWeb.Components.Core.FormFieldLabel, only: [label: 1] use Gettext, backend: PhoenixKitWeb.Gettext + alias PhoenixKit.Modules.Entities.Multilang + @doc """ Builds form fields HTML for an entire entity. @@ -68,6 +70,14 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do """ def build_fields(entity, changeset, opts \\ []) do fields_definition = entity.fields_definition || [] + lang_code = opts[:lang_code] + + # For secondary languages, extract primary data for placeholder text + opts = maybe_add_primary_placeholders(opts, changeset, entity, lang_code) + + # When multilang: extract language-specific data into a view changeset + # so all existing build_field/get_field_value calls work unchanged. + changeset = maybe_apply_language_view(changeset, entity, lang_code) assigns = %{ fields_definition: fields_definition, @@ -86,6 +96,96 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do """ end + # When a lang_code is provided, extract that language's data (merged with + # primary) and replace the :data field in the changeset so downstream + # build_field calls read the correct values via get_field_value/2. + defp maybe_apply_language_view(changeset, _entity, nil), do: changeset + + defp maybe_apply_language_view(%Phoenix.HTML.Form{} = form, _entity, lang_code) do + data = Ecto.Changeset.get_field(form.source, :data) + + if Multilang.multilang_data?(data) do + lang_data = Multilang.get_language_data(data, lang_code) + updated_changeset = Ecto.Changeset.put_change(form.source, :data, lang_data) + %{form | source: updated_changeset} + else + form + end + end + + defp maybe_apply_language_view(changeset, _entity, lang_code) do + data = Ecto.Changeset.get_field(changeset, :data) + + if Multilang.multilang_data?(data) do + lang_data = Multilang.get_language_data(data, lang_code) + Ecto.Changeset.put_change(changeset, :data, lang_data) + else + changeset + end + end + + # ── Multilang placeholder helpers ────────────────────────────── + + defp maybe_add_primary_placeholders(opts, _changeset, _entity, nil), do: opts + + defp maybe_add_primary_placeholders(opts, changeset, _entity, lang_code) do + primary = Multilang.primary_language() + + if lang_code == primary do + opts + else + data = extract_data_from_changeset(changeset) + + if Multilang.multilang_data?(data) do + primary_data = Multilang.get_primary_data(data) + Keyword.put(opts, :primary_placeholders, primary_data) + else + opts + end + end + end + + defp extract_data_from_changeset(%Phoenix.HTML.Form{} = form), + do: Ecto.Changeset.get_field(form.source, :data) + + defp extract_data_from_changeset(changeset), + do: Ecto.Changeset.get_field(changeset, :data) + + defp get_effective_placeholder(field, opts, default \\ "") do + case opts[:primary_placeholders] do + %{} = primary_data -> + primary_value = Map.get(primary_data, field["key"]) + + if primary_value != nil and to_string(primary_value) != "" do + to_string(primary_value) + else + field["placeholder"] || default + end + + _ -> + field["placeholder"] || default + end + end + + # For text-like fields on secondary languages, show empty when value + # matches primary (inherited) — the primary value appears as placeholder. + defp get_effective_text_value(changeset, field_key, opts) do + current = get_field_value(changeset, field_key) + + case opts[:primary_placeholders] do + %{} = primary_data -> + primary_value = Map.get(primary_data, field_key) + if inherited_value?(current, primary_value), do: nil, else: current + + _ -> + current + end + end + + defp inherited_value?(nil, _), do: true + defp inherited_value?("", _), do: true + defp inherited_value?(a, b), do: to_string(a) == to_string(b) + @doc """ Builds a single form field based on field definition. @@ -106,19 +206,30 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do # Text Input def build_field(%{"type" => "text"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} + placeholder = get_effective_placeholder(field, opts) + value = get_effective_text_value(changeset, field["key"], opts) + + assigns = %{ + field: field, + changeset: changeset, + opts: opts, + placeholder: placeholder, + value: value + } ~H"""
- <.label>{@field["label"]}{if @field["required"], do: " *"} + <.label> + {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} + <%= if @field["description"] do %> @@ -132,20 +243,31 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do # Textarea def build_field(%{"type" => "textarea"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} + placeholder = get_effective_placeholder(field, opts) + value = get_effective_text_value(changeset, field["key"], opts) + + assigns = %{ + field: field, + changeset: changeset, + opts: opts, + placeholder: placeholder, + value: value + } ~H"""
- <.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} + <.label for={@field["key"]}> + {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} + + >{@value} <%= if @field["description"] do %> <.label class="label"> {@field["description"]} @@ -157,18 +279,29 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do # Email Input def build_field(%{"type" => "email"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} + placeholder = get_effective_placeholder(field, opts, gettext("user@example.com")) + value = get_effective_text_value(changeset, field["key"], opts) + + assigns = %{ + field: field, + changeset: changeset, + opts: opts, + placeholder: placeholder, + value: value + } ~H"""
- <.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} + <.label for={@field["key"]}> + {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} + <%= if @field["description"] do %> @@ -182,18 +315,29 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do # URL Input def build_field(%{"type" => "url"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} + placeholder = get_effective_placeholder(field, opts, gettext("https://example.com")) + value = get_effective_text_value(changeset, field["key"], opts) + + assigns = %{ + field: field, + changeset: changeset, + opts: opts, + placeholder: placeholder, + value: value + } ~H"""
- <.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} + <.label for={@field["key"]}> + {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} + <%= if @field["description"] do %> @@ -207,19 +351,30 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do # Rich Text Editor def build_field(%{"type" => "rich_text"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} + placeholder = get_effective_placeholder(field, opts, gettext("Enter rich text content...")) + value = get_effective_text_value(changeset, field["key"], opts) + + assigns = %{ + field: field, + changeset: changeset, + opts: opts, + placeholder: placeholder, + value: value + } ~H"""
- <.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} + <.label for={@field["key"]}> + {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} + + >{@value} <.label class="label"> {gettext("Rich text editor (HTML supported)")} @@ -234,21 +389,32 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do # Number Input def build_field(%{"type" => "number"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} + placeholder = get_effective_placeholder(field, opts) + value = get_effective_text_value(changeset, field["key"], opts) + + assigns = %{ + field: field, + changeset: changeset, + opts: opts, + placeholder: placeholder, + value: value + } ~H"""
- <.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} + <.label for={@field["key"]}> + {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} + <%= if @field["description"] do %> @@ -269,7 +435,9 @@ defmodule PhoenixKit.Modules.Entities.FormBuilder do ~H"""
- <.label>{@field["label"]}{if @field["required"], do: " *"} + <.label> + {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} +
+
+ <% else %> + <%!-- Non-multilang: separate cards (original layout) --%> +
+
+

+ <.icon name="hero-information-circle" class="w-6 h-6" /> + {gettext("Basic Information")} +

- <%!-- Entity Type (Read-only) --%> -
- <.label>{gettext("Entity Type")} -
- <%= if @entity.icon do %> - <.icon name={@entity.icon} class="w-4 h-4 mr-2" /> - <% end %> - {@entity.display_name} +
+ <%!-- Title --%> +
+ <.label for="phoenix_kit_entity_data_title">{gettext("Title")} * + +
+ + <%!-- Slug with Generator --%> +
+ <.label for="phoenix_kit_entity_data_slug"> + {gettext("Slug (URL-friendly identifier)")} + + + + <.label class="label"> + + {gettext("Leave empty to auto-generate from title")} + + +
+ + <%!-- Status --%> +
+ <.label for="phoenix_kit_entity_data_status">{gettext("Status")} + +
+ + <%!-- Entity Type (Read-only) --%> +
+ <.label>{gettext("Entity Type")} +
+ <%= if @entity.icon do %> + <.icon name={@entity.icon} class="w-4 h-4 mr-2" /> + <% end %> + {@entity.display_name} +
+ +
- -
-
- <%!-- Dynamic Fields Section --%> -
-
-

- <.icon name="hero-list-bullet" class="w-6 h-6" /> {gettext("Custom Fields")} -

- - <%!-- Dynamic form fields generated by FormBuilder --%> - {PhoenixKit.Modules.Entities.FormBuilder.build_fields(@entity, f, - wrapper_class: "mb-6", - disabled: @readonly? - )} +
+
+

+ <.icon name="hero-list-bullet" class="w-6 h-6" /> {gettext("Custom Fields")} +

+ + <%!-- Dynamic form fields generated by FormBuilder --%> + {PhoenixKit.Modules.Entities.FormBuilder.build_fields(@entity, f, + wrapper_class: "mb-6", + disabled: @readonly?, + lang_code: nil + )} +
-
+ <% end %> <%!-- Form Actions --%>
diff --git a/lib/modules/entities/web/data_navigator.ex b/lib/modules/entities/web/data_navigator.ex index a33e4025..ce148546 100644 --- a/lib/modules/entities/web/data_navigator.ex +++ b/lib/modules/entities/web/data_navigator.ex @@ -10,6 +10,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do alias PhoenixKit.Modules.Entities alias PhoenixKit.Modules.Entities.EntityData alias PhoenixKit.Modules.Entities.Events + alias PhoenixKit.Modules.Entities.Multilang alias PhoenixKit.Settings alias PhoenixKit.Users.Auth.Scope alias PhoenixKit.Utils.Routes @@ -641,14 +642,14 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do defp filter_by_category(records, "uncategorized") do Enum.filter(records, fn record -> - cat = get_in(record.data, ["category"]) + cat = get_data_field(record, "category") is_nil(cat) || cat == "" end) end defp filter_by_category(records, category) do Enum.filter(records, fn record -> - get_in(record.data, ["category"]) == category + get_data_field(record, "category") == category end) end @@ -662,7 +663,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do slug_match = String.contains?(String.downcase(record.slug || ""), search_term_lower) category_match = - case get_in(record.data, ["category"]) do + case get_data_field(record, "category") do nil -> false cat -> String.contains?(String.downcase(cat), search_term_lower) end @@ -671,6 +672,18 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do end) end + # Extracts a field value from a data record, handling both flat and multilang data. + defp get_data_field(record, field_key) do + data = record.data || %{} + + if Multilang.multilang_data?(data) do + primary = data["_primary_language"] + get_in(data, [primary, field_key]) + else + Map.get(data, field_key) + end + end + defp refresh_data_stats(socket) do stats = EntityData.get_data_stats(socket.assigns.selected_entity_id) @@ -742,8 +755,15 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do def truncate_text(_, _), do: "" def format_data_preview(data) when is_map(data) do - # Show first few key-value pairs as preview - data + # For multilang data, show primary language fields + display_data = + if Multilang.multilang_data?(data) do + Multilang.flatten_to_primary(data) + else + data + end + + display_data |> Enum.take(3) |> Enum.map_join(" • ", fn {key, value} -> "#{key}: #{truncate_text(to_string(value), 30)}" diff --git a/lib/modules/entities/web/data_navigator.html.heex b/lib/modules/entities/web/data_navigator.html.heex index 3c74411c..181d4043 100644 --- a/lib/modules/entities/web/data_navigator.html.heex +++ b/lib/modules/entities/web/data_navigator.html.heex @@ -558,7 +558,7 @@ <% end %> <.table_default_cell> - <%= case get_in(data_record.data, ["category"]) do %> + <%= case Multilang.get_primary_data(data_record.data)["category"] do %> <% nil -> %> {gettext("Uncategorized")} @@ -682,7 +682,7 @@ <% end %> <%!-- Category --%> - <%= case get_in(data_record.data, ["category"]) do %> + <%= case Multilang.get_primary_data(data_record.data)["category"] do %> <% nil -> %> <.icon name="hero-tag" class="w-3 h-3 mr-1" /> diff --git a/lib/modules/entities/web/data_view.ex b/lib/modules/entities/web/data_view.ex index d533d9bc..2ec208a8 100644 --- a/lib/modules/entities/web/data_view.ex +++ b/lib/modules/entities/web/data_view.ex @@ -11,6 +11,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do alias PhoenixKit.Modules.Entities alias PhoenixKit.Modules.Entities.EntityData alias PhoenixKit.Modules.Entities.FormBuilder + alias PhoenixKit.Modules.Entities.Multilang alias PhoenixKit.Settings @impl true @@ -74,6 +75,11 @@ 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) @@ -92,6 +98,10 @@ 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) {:ok, socket} end @@ -99,6 +109,17 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do defp public_submission?(nil), do: false defp public_submission?(metadata), do: Map.get(metadata, "source") == "public_form" + @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 + end + @impl true def render(assigns) do ~H""" @@ -176,6 +197,111 @@ 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 %> +
+ + <%= 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.")} +

+ <% end %> +
+
+ <% end %> + <%= if (@public_form_enabled || @is_public_submission) && length(@form_fields) > 0 do %> <%!-- Public Form Fields Section - Using FormBuilder with disabled inputs --%>
@@ -195,7 +321,8 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do <%!-- Use FormBuilder with disabled fields --%> {FormBuilder.build_fields(@form_entity, @changeset, wrapper_class: "mb-4", - disabled: true + disabled: true, + lang_code: if(@multilang_enabled, do: @current_lang, else: nil) )}
@@ -359,7 +486,8 @@ defmodule PhoenixKit.Modules.Entities.Web.DataView do <%!-- Use FormBuilder with disabled fields --%> {FormBuilder.build_fields(@other_entity, @changeset, wrapper_class: "mb-4", - disabled: true + disabled: true, + lang_code: if(@multilang_enabled, do: @current_lang, else: nil) )}
diff --git a/lib/modules/entities/web/entity_form.ex b/lib/modules/entities/web/entity_form.ex index 2d17fcbe..2aa651e8 100644 --- a/lib/modules/entities/web/entity_form.ex +++ b/lib/modules/entities/web/entity_form.ex @@ -14,6 +14,7 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do 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 @@ -59,6 +60,11 @@ 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) @@ -83,6 +89,10 @@ 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) socket = if connected?(socket) do @@ -171,6 +181,16 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do 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 + end + @impl true def handle_event("validate", %{"entities" => entity_params}, socket) do if socket.assigns[:lock_owner?] do @@ -213,8 +233,10 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do # Add fields_definition to params for validation entity_params = Map.put(entity_params, "fields_definition", socket.assigns.fields) - # Add current settings to params for validation - entity_params = Map.put(entity_params, "settings", socket.assigns.entity.settings || %{}) + # Add current settings with merged translations to params + settings = merge_translation_params(socket, entity_params) + entity_params = Map.put(entity_params, "settings", settings) + entity_params = Map.delete(entity_params, "translations") # Add created_by for new entities during validation so changeset can be valid entity_params = @@ -230,7 +252,13 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do socket.assigns.entity |> Entities.change_entity(entity_params) - socket = assign(socket, :changeset, changeset) + # Keep entity in sync with updated settings + entity = %{socket.assigns.entity | settings: settings} + + socket = + socket + |> assign(:changeset, changeset) + |> assign(:entity, entity) reply_with_broadcast(socket) else @@ -244,8 +272,10 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do # Add current fields to entity params entity_params = Map.put(entity_params, "fields_definition", socket.assigns.fields) - # Add current settings to entity params - entity_params = Map.put(entity_params, "settings", socket.assigns.entity.settings || %{}) + # Add current settings with merged translations + settings = merge_translation_params(socket, entity_params) + entity_params = Map.put(entity_params, "settings", settings) + entity_params = Map.delete(entity_params, "translations") # Add created_by for new entities entity_params = @@ -258,7 +288,7 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do end case save_entity(socket, entity_params) do - {:ok, _entity} -> + {:ok, _saved_entity} -> # Presence will automatically clean up when LiveView process terminates locale = socket.assigns[:current_locale] || "en" @@ -1332,6 +1362,35 @@ defmodule PhoenixKit.Modules.Entities.Web.EntityForm do defp generate_slug_from_name(_), do: "" + defp merge_translation_params(socket, entity_params) do + settings = socket.assigns.entity.settings || %{} + existing_translations = settings["translations"] || %{} + + # Extract translation params from form (e.g., %{"es-ES" => %{"display_name" => "Marcas"}}) + new_translations = entity_params["translations"] || %{} + + # Merge new translations into existing, stripping empty values + updated_translations = + Enum.reduce(new_translations, existing_translations, fn {lang_code, fields}, acc -> + cleaned = + fields + |> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) + |> Map.new() + + if map_size(cleaned) == 0 do + Map.delete(acc, lang_code) + else + Map.put(acc, lang_code, cleaned) + end + end) + + if map_size(updated_translations) == 0 do + Map.delete(settings, "translations") + else + Map.put(settings, "translations", updated_translations) + end + end + defp new_field_form do %{ "type" => "text", diff --git a/lib/modules/entities/web/entity_form.html.heex b/lib/modules/entities/web/entity_form.html.heex index 54853a14..69f20f60 100644 --- a/lib/modules/entities/web/entity_form.html.heex +++ b/lib/modules/entities/web/entity_form.html.heex @@ -63,17 +63,121 @@ )} -
+ <% translations = (@entity.settings || %{})["translations"] || %{} %> + <% is_secondary = @multilang_enabled && @current_lang != @primary_language %> + <% lang_translations = + if is_secondary, do: translations[@current_lang] || %{}, else: %{} %> + + <%!-- Language tabs --%> + <%= if @multilang_enabled && length(@language_tabs) > 1 do %> + <% 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 %> +
+ + <%= if is_secondary do %> +
+ <.icon name="hero-information-circle" class="w-4 h-4" /> + + {gettext("Fields left empty will use the primary language value.")} + +
+ <% end %> + <% end %> + + <%!-- Translatable fields --%> +
<%!-- Entity Name (Singular) --%>
- <.label for="entity_display_name">{gettext("Entity Name (Singular)")} * - <.input - field={f[:display_name]} - type="text" - placeholder={gettext("Brand")} - phx-debounce="300" - disabled={@readonly?} - /> + <.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\")")} @@ -84,22 +188,74 @@ <%!-- Entity Name (Plural) --%>
<.label for="entity_display_name_plural"> - {gettext("Entity Name (Plural)")} * + {gettext("Entity Name (Plural)")}{unless is_secondary, do: " *"} - <.input - field={f[:display_name_plural]} - type="text" - placeholder={gettext("Brands")} - phx-debounce="300" - disabled={@readonly?} - /> + <%= 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\")")}
+
+ <%!-- Description (translatable) --%> +
+ <.label for="entity_description">{gettext("Description (Optional)")} + <%= if is_secondary do %> + + <% else %> + + <% end %> +
+
+
+ + <%!-- Entity System Settings (non-translatable) --%> +
+
+

+ <.icon name="hero-cog-6-tooth" class="w-6 h-6" /> {gettext("System Settings")} +

+ +
<%!-- Slug --%>
<.label for="entity_name"> @@ -201,20 +357,6 @@
- - <%!-- Description --%> -
- <.label for="entity_description">{gettext("Description (Optional)")} - -
diff --git a/lib/modules/entities/web/hooks.ex b/lib/modules/entities/web/hooks.ex index 0ef4abc4..39e7b115 100644 --- a/lib/modules/entities/web/hooks.ex +++ b/lib/modules/entities/web/hooks.ex @@ -35,6 +35,7 @@ defmodule PhoenixKit.Modules.Entities.Web.Hooks do if scope && Scope.authenticated?(scope) do user = %{ + uuid: scope.user.uuid, id: Scope.user_id(scope), email: Scope.user_email(scope) } diff --git a/lib/modules/languages/README.md b/lib/modules/languages/README.md index 0c1eb1b9..5cacbd82 100644 --- a/lib/modules/languages/README.md +++ b/lib/modules/languages/README.md @@ -172,6 +172,43 @@ Languages.move_language_down("es-ES") # Reorder <.language_switcher_inline current_locale={@current_locale} /> ``` +## Integration with Entities Module + +The Entities module uses Languages for **multi-language content storage**. When 2+ languages are enabled, all entity data automatically supports multilang. + +### How It Works + +1. `PhoenixKit.Modules.Entities.Multilang.enabled?/0` checks if Languages has 2+ enabled languages +2. `Multilang.primary_language/0` reads `Languages.get_default_language()` +3. `Multilang.enabled_languages/0` reads `Languages.get_enabled_language_codes()` +4. Entity data JSONB is structured by language code (e.g., `"en-US"`, `"es-ES"`) + +### Programmatic Translation Setup + +```elixir +# 1. Enable languages +PhoenixKit.Modules.Languages.enable_system() +PhoenixKit.Modules.Languages.add_language("es-ES") +PhoenixKit.Modules.Languages.add_language("fr-FR") + +# 2. Multilang is now active — use the convenience API +alias PhoenixKit.Modules.Entities.EntityData + +record = EntityData.get(uuid) +EntityData.set_translation(record, "es-ES", %{"name" => "Producto"}) +EntityData.set_title_translation(record, "es-ES", "Mi Producto") +``` + +### Primary Language Changes + +When `Languages.set_default_language/1` is called, existing entity data records lazily re-key on next edit. The new primary is promoted to have all fields. See `lib/modules/entities/OVERVIEW.md` for full details. + +### Key Dependency + +The Entities Multilang module gracefully degrades when Languages is unavailable — it uses `Code.ensure_loaded?/1` checks and falls back to `"en-US"` as the default language. + +--- + ## Integration with Publishing Module The Publishing module uses Languages for: diff --git a/lib/phoenix_kit/admin/simple_presence.ex b/lib/phoenix_kit/admin/simple_presence.ex index 82789e39..f962742a 100644 --- a/lib/phoenix_kit/admin/simple_presence.ex +++ b/lib/phoenix_kit/admin/simple_presence.ex @@ -67,7 +67,7 @@ defmodule PhoenixKit.Admin.SimplePresence do case GenServer.call(@server_name, {:track, key, metadata}) do :ok -> - Events.broadcast_user_session_connected(user.id, metadata) + Events.broadcast_user_session_connected(user.uuid, metadata) broadcast_presence_stats() :ok diff --git a/lib/phoenix_kit_web/live/dashboard.ex b/lib/phoenix_kit_web/live/dashboard.ex index 93e0fc6e..d5d3b257 100644 --- a/lib/phoenix_kit_web/live/dashboard.ex +++ b/lib/phoenix_kit_web/live/dashboard.ex @@ -135,8 +135,8 @@ defmodule PhoenixKitWeb.Live.Dashboard do user_id = Scope.user_id(scope) user_email = Scope.user_email(scope) - # Create a user struct for tracking - user = %{id: user_id, email: user_email} + # Create a user map for tracking (uuid required by SimplePresence) + user = %{uuid: scope.user.uuid, id: user_id, email: user_email} session_id = session["live_socket_id"] || generate_session_id() Presence.track_user(user, %{ diff --git a/lib/phoenix_kit_web/live/users/live_sessions.ex b/lib/phoenix_kit_web/live/users/live_sessions.ex index 55380ac3..2f207fc8 100644 --- a/lib/phoenix_kit_web/live/users/live_sessions.ex +++ b/lib/phoenix_kit_web/live/users/live_sessions.ex @@ -283,8 +283,8 @@ defmodule PhoenixKitWeb.Live.Users.LiveSessions do user_id = Scope.user_id(scope) user_email = Scope.user_email(scope) - # Create a user struct for tracking - user = %{id: user_id, email: user_email} + # Create a user map for tracking (uuid required by SimplePresence) + user = %{uuid: scope.user.uuid, id: user_id, email: user_email} session_id = session["live_socket_id"] || generate_session_id() Presence.track_user(user, %{ diff --git a/test/modules/entities/field_type_test.exs b/test/modules/entities/field_type_test.exs new file mode 100644 index 00000000..081e2351 --- /dev/null +++ b/test/modules/entities/field_type_test.exs @@ -0,0 +1,73 @@ +defmodule PhoenixKit.Modules.Entities.FieldTypeTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Entities.FieldType + + # --- from_map/1 --- + + describe "from_map/1" do + test "converts atom-key map to struct" do + map = %{ + name: "text", + label: "Text", + description: "Single-line text input", + category: :basic, + icon: "hero-pencil", + requires_options: false, + default_props: %{"max_length" => 255} + } + + result = FieldType.from_map(map) + + assert %FieldType{} = result + assert result.name == "text" + assert result.label == "Text" + assert result.description == "Single-line text input" + assert result.category == :basic + assert result.icon == "hero-pencil" + assert result.requires_options == false + assert result.default_props == %{"max_length" => 255} + end + + test "converts string-key map to struct" do + map = %{ + "name" => "select", + "label" => "Select", + "description" => "Dropdown", + "category" => :choice, + "icon" => "hero-chevron-down", + "requires_options" => true, + "default_props" => %{} + } + + result = FieldType.from_map(map) + + assert result.name == "select" + assert result.label == "Select" + assert result.requires_options == true + assert result.category == :choice + end + + test "defaults requires_options to false" do + map = %{name: "custom", label: "Custom", category: :basic} + result = FieldType.from_map(map) + + assert result.requires_options == false + end + + test "defaults default_props to empty map" do + map = %{name: "custom", label: "Custom", category: :basic} + result = FieldType.from_map(map) + + assert result.default_props == %{} + end + + test "handles nil description and icon" do + map = %{name: "custom", label: "Custom", category: :basic} + result = FieldType.from_map(map) + + assert result.description == nil + assert result.icon == nil + end + end +end diff --git a/test/modules/entities/field_types_test.exs b/test/modules/entities/field_types_test.exs new file mode 100644 index 00000000..38fcef55 --- /dev/null +++ b/test/modules/entities/field_types_test.exs @@ -0,0 +1,400 @@ +defmodule PhoenixKit.Modules.Entities.FieldTypesTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Entities.FieldType + alias PhoenixKit.Modules.Entities.FieldTypes + + # --- all/0 --- + + describe "all/0" do + test "returns a map of FieldType structs" do + result = FieldTypes.all() + + assert is_map(result) + assert map_size(result) > 0 + + Enum.each(result, fn {key, value} -> + assert is_binary(key) + assert %FieldType{} = value + end) + end + + test "includes all expected field types" do + result = FieldTypes.all() + + expected = + ~w(text textarea email url rich_text number boolean date select radio checkbox file) + + for type <- expected do + assert Map.has_key?(result, type), "Missing field type: #{type}" + end + end + end + + # --- list_types/0 --- + + describe "list_types/0" do + test "returns list of strings" do + result = FieldTypes.list_types() + assert is_list(result) + assert Enum.all?(result, &is_binary/1) + end + + test "includes core types" do + types = FieldTypes.list_types() + assert "text" in types + assert "select" in types + assert "number" in types + assert "boolean" in types + end + end + + # --- get_type/1 --- + + describe "get_type/1" do + test "returns FieldType struct for valid type" do + result = FieldTypes.get_type("text") + + assert %FieldType{} = result + assert result.name == "text" + assert result.label == "Text" + assert result.category == :basic + end + + test "returns nil for invalid type" do + assert FieldTypes.get_type("nonexistent") == nil + end + + test "returns correct info for choice type" do + result = FieldTypes.get_type("select") + + assert result.name == "select" + assert result.requires_options == true + assert result.category == :choice + end + + test "returns correct info for number type" do + result = FieldTypes.get_type("number") + + assert result.category == :numeric + assert result.requires_options == false + end + end + + # --- valid_type?/1 --- + + describe "valid_type?/1" do + test "returns true for all known types" do + for type <- FieldTypes.list_types() do + assert FieldTypes.valid_type?(type), "Expected #{type} to be valid" + end + end + + test "returns false for unknown types" do + refute FieldTypes.valid_type?("invalid") + refute FieldTypes.valid_type?("xml") + refute FieldTypes.valid_type?("") + end + end + + # --- by_category/1 --- + + describe "by_category/1" do + test "returns basic field types" do + result = FieldTypes.by_category(:basic) + + assert is_list(result) + assert result != [] + + names = Enum.map(result, & &1.name) + assert "text" in names + assert "textarea" in names + end + + test "returns choice field types" do + result = FieldTypes.by_category(:choice) + names = Enum.map(result, & &1.name) + + assert "select" in names + assert "radio" in names + assert "checkbox" in names + end + + test "returns empty list for nonexistent category" do + assert FieldTypes.by_category(:nonexistent) == [] + end + + test "all returned types have matching category" do + for {category, _label} <- FieldTypes.category_list() do + types = FieldTypes.by_category(category) + + Enum.each(types, fn type -> + assert type.category == category + end) + end + end + end + + # --- categories/0 --- + + describe "categories/0" do + test "returns map grouped by category atom" do + result = FieldTypes.categories() + + assert is_map(result) + assert Map.has_key?(result, :basic) + assert Map.has_key?(result, :choice) + end + + test "every type appears in exactly one category" do + result = FieldTypes.categories() + + all_names = + result + |> Map.values() + |> List.flatten() + |> Enum.map(& &1.name) + + assert length(all_names) == length(Enum.uniq(all_names)) + assert length(all_names) == map_size(FieldTypes.all()) + end + end + + # --- category_list/0 --- + + describe "category_list/0" do + test "returns list of {atom, string} tuples" do + result = FieldTypes.category_list() + + assert is_list(result) + + Enum.each(result, fn {key, label} -> + assert is_atom(key) + assert is_binary(label) + end) + end + + test "includes expected categories" do + keys = FieldTypes.category_list() |> Enum.map(&elem(&1, 0)) + + assert :basic in keys + assert :numeric in keys + assert :choice in keys + end + end + + # --- requires_options?/1 --- + + describe "requires_options?/1" do + test "returns true for select, radio, checkbox" do + assert FieldTypes.requires_options?("select") + assert FieldTypes.requires_options?("radio") + assert FieldTypes.requires_options?("checkbox") + end + + test "returns false for text, number, boolean" do + refute FieldTypes.requires_options?("text") + refute FieldTypes.requires_options?("number") + refute FieldTypes.requires_options?("boolean") + end + + test "returns false for unknown type" do + refute FieldTypes.requires_options?("unknown") + end + end + + # --- default_props/1 --- + + describe "default_props/1" do + test "returns props for text type" do + props = FieldTypes.default_props("text") + assert is_map(props) + assert Map.has_key?(props, "max_length") + end + + test "returns props for textarea type" do + props = FieldTypes.default_props("textarea") + assert Map.has_key?(props, "rows") + assert Map.has_key?(props, "max_length") + end + + test "returns empty map for unknown type" do + assert FieldTypes.default_props("unknown") == %{} + end + end + + # --- for_picker/0 --- + + describe "for_picker/0" do + test "returns list of maps with expected keys" do + result = FieldTypes.for_picker() + + assert is_list(result) + assert result != [] + + Enum.each(result, fn item -> + assert Map.has_key?(item, :value) + assert Map.has_key?(item, :label) + assert Map.has_key?(item, :category) + assert Map.has_key?(item, :icon) + end) + end + end + + # --- validate_field/1 --- + + describe "validate_field/1" do + test "validates a valid text field" do + field = %{"type" => "text", "key" => "title", "label" => "Title"} + assert {:ok, ^field} = FieldTypes.validate_field(field) + end + + test "validates a valid select field with options" do + field = %{ + "type" => "select", + "key" => "category", + "label" => "Category", + "options" => ["A", "B"] + } + + assert {:ok, ^field} = FieldTypes.validate_field(field) + end + + test "rejects missing required keys" do + assert {:error, msg} = FieldTypes.validate_field(%{"type" => "text"}) + assert msg =~ "Missing required keys" + end + + test "rejects invalid field type" do + field = %{"type" => "invalid", "key" => "test", "label" => "Test"} + assert {:error, msg} = FieldTypes.validate_field(field) + assert msg =~ "Invalid field type" + end + + test "rejects select field without options" do + field = %{"type" => "select", "key" => "cat", "label" => "Category"} + assert {:error, msg} = FieldTypes.validate_field(field) + assert msg =~ "requires options" + end + + test "rejects select field with empty options" do + field = %{"type" => "select", "key" => "cat", "label" => "Category", "options" => []} + assert {:error, msg} = FieldTypes.validate_field(field) + assert msg =~ "requires options" + end + end + + # --- new_field/4 --- + + describe "new_field/4" do + test "creates a text field with defaults" do + result = FieldTypes.new_field("text", "name", "Name") + + assert result["type"] == "text" + assert result["key"] == "name" + assert result["label"] == "Name" + assert result["required"] == false + assert Map.has_key?(result, "max_length") + end + + test "creates a field with required flag" do + result = FieldTypes.new_field("text", "name", "Name", required: true) + assert result["required"] == true + end + + test "creates a select field with options" do + result = FieldTypes.new_field("select", "status", "Status", options: ["A", "B"]) + + assert result["type"] == "select" + assert result["options"] == ["A", "B"] + end + + test "merges type-specific default props" do + result = FieldTypes.new_field("textarea", "bio", "Biography") + assert Map.has_key?(result, "rows") + end + + test "accepts custom default value for types without default_props default" do + result = FieldTypes.new_field("text", "name", "Name", default: "untitled") + assert result["default"] == "untitled" + end + end + + # --- Builder helpers --- + + describe "builder helpers" do + test "text_field/3" do + result = FieldTypes.text_field("name", "Name", required: true) + assert result["type"] == "text" + assert result["key"] == "name" + assert result["required"] == true + end + + test "textarea_field/3" do + result = FieldTypes.textarea_field("bio", "Bio") + assert result["type"] == "textarea" + assert Map.has_key?(result, "rows") + end + + test "email_field/3" do + result = FieldTypes.email_field("email", "Email") + assert result["type"] == "email" + end + + test "number_field/3" do + result = FieldTypes.number_field("age", "Age") + assert result["type"] == "number" + end + + test "boolean_field/3" do + result = FieldTypes.boolean_field("active", "Active") + assert result["type"] == "boolean" + # Boolean type has default_props %{"default" => false} which merges last + assert result["default"] == false + end + + test "rich_text_field/3" do + result = FieldTypes.rich_text_field("content", "Content") + assert result["type"] == "rich_text" + end + + test "select_field/4" do + result = FieldTypes.select_field("cat", "Category", ["A", "B"]) + assert result["type"] == "select" + assert result["options"] == ["A", "B"] + end + + test "radio_field/4" do + result = FieldTypes.radio_field("priority", "Priority", ["Low", "High"]) + assert result["type"] == "radio" + assert result["options"] == ["Low", "High"] + end + + test "checkbox_field/4" do + result = FieldTypes.checkbox_field("tags", "Tags", ["A", "B", "C"]) + assert result["type"] == "checkbox" + assert result["options"] == ["A", "B", "C"] + end + + test "file_field/3" do + result = FieldTypes.file_field("docs", "Docs") + assert result["type"] == "file" + assert Map.has_key?(result, "max_entries") + assert Map.has_key?(result, "max_file_size") + assert Map.has_key?(result, "accept") + end + + test "file_field/3 with custom constraints" do + result = + FieldTypes.file_field("docs", "Docs", + max_entries: 10, + max_file_size: 52_428_800, + accept: [".pdf"] + ) + + assert result["max_entries"] == 10 + assert result["max_file_size"] == 52_428_800 + assert result["accept"] == [".pdf"] + end + end +end diff --git a/test/modules/entities/html_sanitizer_test.exs b/test/modules/entities/html_sanitizer_test.exs new file mode 100644 index 00000000..5b394219 --- /dev/null +++ b/test/modules/entities/html_sanitizer_test.exs @@ -0,0 +1,190 @@ +defmodule PhoenixKit.Modules.Entities.HtmlSanitizerTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Entities.HtmlSanitizer + + # --- sanitize/1 --- + + describe "sanitize/1" do + test "preserves safe HTML" do + assert HtmlSanitizer.sanitize("

Hello world

") == + "

Hello world

" + end + + test "removes script tags and content" do + assert HtmlSanitizer.sanitize("

Hello

") == + "

Hello

" + end + + test "removes script tags with attributes" do + input = ~s[

Hi

] + result = HtmlSanitizer.sanitize(input) + refute result =~ "script" + assert result =~ "

Hi

" + end + + test "removes style tags and content" do + input = "

Visible

" + result = HtmlSanitizer.sanitize(input) + refute result =~ "style" + assert result =~ "

Visible

" + end + + test "removes onclick event handlers" do + result = HtmlSanitizer.sanitize(~s[

Hello

]) + assert result == "

Hello

" + end + + test "removes onerror event handlers" do + result = HtmlSanitizer.sanitize(~s[]) + refute result =~ "onerror" + end + + test "removes onload event handlers" do + input = ~s[

Content

] + result = HtmlSanitizer.sanitize(input) + refute result =~ "onload" + end + + test "removes javascript: URLs from href" do + result = HtmlSanitizer.sanitize(~s[Click]) + refute result =~ "javascript" + assert result =~ "Click" + end + + test "removes javascript: URLs from src" do + input = ~s[] + result = HtmlSanitizer.sanitize(input) + refute result =~ "javascript" + end + + test "removes data: URLs" do + input = ~s[Click] + result = HtmlSanitizer.sanitize(input) + refute result =~ "data:" + end + + test "removes iframe tags" do + input = ~s(

Safe

) + result = HtmlSanitizer.sanitize(input) + refute result =~ "iframe" + assert result =~ "

Safe

" + end + + test "removes object tags" do + input = ~s(

Safe

) + result = HtmlSanitizer.sanitize(input) + refute result =~ "object" + end + + test "removes embed tags" do + input = ~s(

Safe

) + result = HtmlSanitizer.sanitize(input) + refute result =~ "embed" + end + + test "removes form tags" do + input = ~s(
) + result = HtmlSanitizer.sanitize(input) + refute result =~ "form" + refute result =~ "input" + end + + test "preserves safe links" do + input = ~s(Link) + assert HtmlSanitizer.sanitize(input) == input + end + + test "preserves tables" do + input = "
Cell
" + assert HtmlSanitizer.sanitize(input) == input + end + + test "preserves lists" do + input = "
  • Item 1
  • Item 2
" + assert HtmlSanitizer.sanitize(input) == input + end + + test "returns nil for nil input" do + assert HtmlSanitizer.sanitize(nil) == nil + end + + test "returns empty string for empty input" do + assert HtmlSanitizer.sanitize("") == "" + end + + test "passes through non-string values" do + assert HtmlSanitizer.sanitize(42) == 42 + end + + test "trims whitespace from result" do + assert HtmlSanitizer.sanitize("

Hello

") == "

Hello

" + end + end + + # --- sanitize_rich_text_fields/2 --- + + describe "sanitize_rich_text_fields/2" do + test "sanitizes only rich_text fields" do + fields = [ + %{"type" => "rich_text", "key" => "content"}, + %{"type" => "text", "key" => "title"} + ] + + data = %{ + "content" => "

Hello

", + "title" => "Title" + } + + result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) + + assert result["content"] == "

Hello

" + # text field is NOT sanitized + assert result["title"] == "Title" + end + + test "handles multiple rich_text fields" do + fields = [ + %{"type" => "rich_text", "key" => "body"}, + %{"type" => "rich_text", "key" => "summary"} + ] + + data = %{ + "body" => "

Body

", + "summary" => "

Summary

" + } + + result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) + + assert result["body"] == "

Body

" + assert result["summary"] == "

Summary

" + end + + test "skips nil values in rich_text fields" do + fields = [%{"type" => "rich_text", "key" => "content"}] + data = %{"content" => nil} + + result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) + assert result["content"] == nil + end + + test "handles no rich_text fields" do + fields = [%{"type" => "text", "key" => "name"}] + data = %{"name" => "test"} + + result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) + assert result == data + end + + test "handles empty fields list" do + data = %{"content" => ""} + result = HtmlSanitizer.sanitize_rich_text_fields([], data) + assert result == data + end + + test "returns data unchanged for invalid fields argument" do + data = %{"test" => "value"} + assert HtmlSanitizer.sanitize_rich_text_fields("invalid", data) == data + end + end +end diff --git a/test/modules/entities/multilang_test.exs b/test/modules/entities/multilang_test.exs new file mode 100644 index 00000000..052f9133 --- /dev/null +++ b/test/modules/entities/multilang_test.exs @@ -0,0 +1,389 @@ +defmodule PhoenixKit.Modules.Entities.MultilangTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Entities.Multilang + + # --- Test Data --- + + defp multilang_data do + %{ + "_primary_language" => "en-US", + "en-US" => %{"name" => "Acme", "category" => "Tech", "desc" => "A company"}, + "es-ES" => %{"name" => "Acme España"}, + "fr-FR" => %{"desc" => "Une entreprise"} + } + end + + defp flat_data do + %{"name" => "Acme", "category" => "Tech"} + end + + # --- multilang_data?/1 --- + + describe "multilang_data?/1" do + test "returns true for data with _primary_language key" do + assert Multilang.multilang_data?(multilang_data()) + end + + test "returns false for flat data" do + refute Multilang.multilang_data?(flat_data()) + end + + test "returns false for nil" do + refute Multilang.multilang_data?(nil) + end + + test "returns false for empty map" do + refute Multilang.multilang_data?(%{}) + end + + test "returns false for non-map values" do + refute Multilang.multilang_data?("string") + refute Multilang.multilang_data?(42) + refute Multilang.multilang_data?([]) + end + end + + # --- get_language_data/2 --- + + describe "get_language_data/2" do + test "returns primary data for primary language" do + result = Multilang.get_language_data(multilang_data(), "en-US") + + assert result == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "A company" + } + end + + test "returns merged data for secondary language" do + result = Multilang.get_language_data(multilang_data(), "es-ES") + + assert result == %{ + "name" => "Acme España", + "category" => "Tech", + "desc" => "A company" + } + end + + test "secondary language overrides only differ from primary" do + result = Multilang.get_language_data(multilang_data(), "fr-FR") + + assert result == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "Une entreprise" + } + end + + test "returns primary data for language with no overrides" do + result = Multilang.get_language_data(multilang_data(), "de-DE") + + assert result == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "A company" + } + end + + test "returns flat data as-is for non-multilang data" do + result = Multilang.get_language_data(flat_data(), "en-US") + assert result == flat_data() + end + + test "returns empty map for nil data" do + assert Multilang.get_language_data(nil, "en-US") == %{} + end + end + + # --- get_primary_data/1 --- + + describe "get_primary_data/1" do + test "extracts primary language data from multilang" do + result = Multilang.get_primary_data(multilang_data()) + + assert result == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "A company" + } + end + + test "returns flat data as-is" do + assert Multilang.get_primary_data(flat_data()) == flat_data() + end + + test "returns empty map for nil" do + assert Multilang.get_primary_data(nil) == %{} + end + end + + # --- get_raw_language_data/2 --- + + describe "get_raw_language_data/2" do + test "returns raw primary data (all fields)" do + result = Multilang.get_raw_language_data(multilang_data(), "en-US") + + assert result == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "A company" + } + end + + test "returns raw overrides only for secondary language" do + result = Multilang.get_raw_language_data(multilang_data(), "es-ES") + assert result == %{"name" => "Acme España"} + end + + test "returns empty map for language with no overrides" do + result = Multilang.get_raw_language_data(multilang_data(), "de-DE") + assert result == %{} + end + + test "returns flat data as-is for non-multilang" do + result = Multilang.get_raw_language_data(flat_data(), "en-US") + assert result == flat_data() + end + + test "returns empty map for nil" do + assert Multilang.get_raw_language_data(nil, "en-US") == %{} + end + end + + # --- put_language_data/3 --- + + describe "put_language_data/3" do + test "stores all fields for primary language" do + new_fields = %{"name" => "Acme Corp", "category" => "Business", "desc" => "Updated"} + result = Multilang.put_language_data(multilang_data(), "en-US", new_fields) + + assert result["_primary_language"] == "en-US" + assert result["en-US"] == new_fields + # Other languages preserved + assert result["es-ES"] == %{"name" => "Acme España"} + end + + test "stores only overrides for secondary language" do + new_fields = %{"name" => "Acme Frankreich", "category" => "Tech", "desc" => "A company"} + result = Multilang.put_language_data(multilang_data(), "de-DE", new_fields) + + # Only "name" differs from primary, so only "name" is stored + assert result["de-DE"] == %{"name" => "Acme Frankreich"} + end + + test "removes secondary language key when all fields match primary" do + # Submit exact same data as primary + primary_data = %{"name" => "Acme", "category" => "Tech", "desc" => "A company"} + result = Multilang.put_language_data(multilang_data(), "es-ES", primary_data) + + refute Map.has_key?(result, "es-ES") + end + + test "removes secondary language key when all fields are empty" do + result = + Multilang.put_language_data(multilang_data(), "es-ES", %{"name" => "", "category" => ""}) + + refute Map.has_key?(result, "es-ES") + end + + test "converts flat data to multilang structure on first put" do + result = Multilang.put_language_data(flat_data(), "en-US", %{"name" => "Updated"}) + + assert Multilang.multilang_data?(result) + assert result["en-US"] == %{"name" => "Updated"} + end + + test "handles nil existing data" do + result = Multilang.put_language_data(nil, "en-US", %{"name" => "New"}) + + assert Multilang.multilang_data?(result) + end + + test "uses embedded primary for existing multilang data" do + data = multilang_data() + new_es = %{"name" => "Nuevo Nombre", "category" => "Tech", "desc" => "A company"} + result = Multilang.put_language_data(data, "es-ES", new_es) + + # Only the override (name) should be stored + assert result["es-ES"] == %{"name" => "Nuevo Nombre"} + # Primary unchanged + assert result["_primary_language"] == "en-US" + end + end + + # --- migrate_to_multilang/2 --- + + describe "migrate_to_multilang/2" do + test "wraps flat data into multilang structure" do + result = Multilang.migrate_to_multilang(flat_data(), "en-US") + + assert result["_primary_language"] == "en-US" + assert result["en-US"] == flat_data() + end + + test "handles nil data" do + result = Multilang.migrate_to_multilang(nil, "en-US") + + assert result["_primary_language"] == "en-US" + assert result["en-US"] == %{} + end + + test "uses provided language code" do + result = Multilang.migrate_to_multilang(flat_data(), "es-ES") + + assert result["_primary_language"] == "es-ES" + assert result["es-ES"] == flat_data() + end + end + + # --- flatten_to_primary/1 --- + + describe "flatten_to_primary/1" do + test "extracts primary language data" do + result = Multilang.flatten_to_primary(multilang_data()) + + assert result == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "A company" + } + end + + test "returns flat data as-is (no _primary_language key)" do + assert Multilang.flatten_to_primary(flat_data()) == flat_data() + end + + test "returns empty map for nil" do + assert Multilang.flatten_to_primary(nil) == %{} + end + + test "returns empty map for non-map input" do + assert Multilang.flatten_to_primary("string") == %{} + end + + test "handles missing primary language data gracefully" do + data = %{"_primary_language" => "ja-JP"} + assert Multilang.flatten_to_primary(data) == %{} + end + end + + # --- rekey_primary/2 --- + + describe "rekey_primary/2" do + test "promotes new primary with all fields from old primary" do + result = Multilang.rekey_primary(multilang_data(), "es-ES") + + assert result["_primary_language"] == "es-ES" + + # New primary gets merged: old primary base + its own overrides + assert result["es-ES"] == %{ + "name" => "Acme España", + "category" => "Tech", + "desc" => "A company" + } + end + + test "preserves old primary data untouched" do + result = Multilang.rekey_primary(multilang_data(), "es-ES") + + # Old primary still has its original data + assert result["en-US"] == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "A company" + } + end + + test "preserves other languages untouched" do + result = Multilang.rekey_primary(multilang_data(), "es-ES") + assert result["fr-FR"] == %{"desc" => "Une entreprise"} + end + + test "returns data unchanged when already using that primary" do + result = Multilang.rekey_primary(multilang_data(), "en-US") + assert result == multilang_data() + end + + test "returns non-multilang data unchanged" do + result = Multilang.rekey_primary(flat_data(), "es-ES") + assert result == flat_data() + end + + test "returns nil unchanged" do + assert Multilang.rekey_primary(nil, "es-ES") == nil + end + + test "re-keys to language with no existing overrides" do + result = Multilang.rekey_primary(multilang_data(), "de-DE") + + assert result["_primary_language"] == "de-DE" + + # de-DE gets all fields from old primary (no overrides existed) + assert result["de-DE"] == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "A company" + } + end + + test "is idempotent" do + once = Multilang.rekey_primary(multilang_data(), "es-ES") + twice = Multilang.rekey_primary(once, "es-ES") + assert once == twice + end + + test "round-trip preserves all data" do + rekeyed = Multilang.rekey_primary(multilang_data(), "es-ES") + back = Multilang.rekey_primary(rekeyed, "en-US") + + # Primary data should be fully restored + assert back["_primary_language"] == "en-US" + + assert back["en-US"] == %{ + "name" => "Acme", + "category" => "Tech", + "desc" => "A company" + } + end + end + + # --- Integration: migrate then put --- + + describe "migrate + put workflow" do + test "flat data -> multilang -> add secondary" do + data = flat_data() + multilang = Multilang.migrate_to_multilang(data, "en-US") + + assert Multilang.multilang_data?(multilang) + + result = + Multilang.put_language_data(multilang, "es-ES", %{ + "name" => "Acme España", + "category" => "Tech" + }) + + # Only name differs, category matches primary + assert result["es-ES"] == %{"name" => "Acme España"} + assert result["en-US"] == flat_data() + end + + test "get_language_data returns correct merged result after put" do + data = multilang_data() + + updated = + Multilang.put_language_data(data, "de-DE", %{ + "name" => "Acme DE", + "category" => "Tech", + "desc" => "A company" + }) + + result = Multilang.get_language_data(updated, "de-DE") + + assert result["name"] == "Acme DE" + assert result["category"] == "Tech" + assert result["desc"] == "A company" + end + end +end