From 2b961e1798a765cf832189269da5a6599cffe2b2 Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 18 Feb 2026 02:04:55 +0200 Subject: [PATCH 1/3] Fix entities docs namespace, routing consistency, and add maybe_rekey_data tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix PhoenixKit.Entities → PhoenixKit.Modules.Entities in DEEP_DIVE.md (88), OVERVIEW.md (14), README.md (5) - Fix field type count 11 → 12 and clarify file vs image registration status - Fix utc_datetime_usec → utc_datetime in DEEP_DIVE.md schema docs - Fix inconsistent Routes.locale_aware_path → Routes.path in data_navigator.ex - Replace String.to_existing_atom with compile-time @preserve_fields map in data_form.ex - Add 4 tests for maybe_rekey_data/1 (rekey on mismatch, no-op, flat data, nil) --- lib/modules/entities/DEEP_DIVE.md | 192 ++++++++++----------- lib/modules/entities/OVERVIEW.md | 30 ++-- lib/modules/entities/README.md | 10 +- lib/modules/entities/web/data_form.ex | 10 +- lib/modules/entities/web/data_navigator.ex | 2 +- test/modules/entities/multilang_test.exs | 38 ++++ 6 files changed, 161 insertions(+), 121 deletions(-) diff --git a/lib/modules/entities/DEEP_DIVE.md b/lib/modules/entities/DEEP_DIVE.md index 349472b9..9637aa9e 100644 --- a/lib/modules/entities/DEEP_DIVE.md +++ b/lib/modules/entities/DEEP_DIVE.md @@ -29,7 +29,7 @@ The PhoenixKit Entities System is a dynamic content type management system. It a ### Key Features - **Dynamic Schema Creation**: Create custom content types with flexible field definitions stored as JSONB -- **11 Field Types**: Comprehensive field type support including text, textarea, email, url, number, boolean, date, select, radio, checkbox, and rich text. *(Image, file, and relation fields exist in the form builder as placeholders but are not registered in FieldTypes.)* +- **12 Field Types**: Comprehensive field type support including text, textarea, email, url, number, boolean, date, select, radio, checkbox, rich text, and file. *(Image and relation fields exist in the form builder as placeholders but are not registered in FieldTypes.)* - **Admin Interfaces**: Complete CRUD interfaces for both entity definitions and entity data - **Dynamic Form Generation**: Forms automatically generated from entity field definitions - **System-Wide Toggle**: Enable/disable the entire entities system via Settings @@ -111,8 +111,8 @@ Stores content type blueprints with field definitions. | `fields_definition` | jsonb | Array of field definitions | | `settings` | jsonb | Entity-specific settings | | `created_by` | integer | User ID of creator | -| `date_created` | utc_datetime_usec | Creation timestamp | -| `date_updated` | utc_datetime_usec | Last update timestamp | +| `date_created` | utc_datetime | Creation timestamp | +| `date_updated` | utc_datetime | Last update timestamp | **Indexes:** - Unique index on `name` @@ -122,7 +122,7 @@ Stores content type blueprints with field definitions. **Example Entity Record:** ```elixir -%PhoenixKit.Entities{ +%PhoenixKit.Modules.Entities{ id: 1, name: "blog_post", display_name: "Blog Post", @@ -170,8 +170,8 @@ Stores actual content records based on entity blueprints. | `data` | jsonb | All field values as key-value pairs | | `metadata` | jsonb | Additional metadata (tags, categories, etc.) | | `created_by` | integer | User ID of creator | -| `date_created` | utc_datetime_usec | Creation timestamp | -| `date_updated` | utc_datetime_usec | Last update timestamp | +| `date_created` | utc_datetime | Creation timestamp | +| `date_updated` | utc_datetime | Last update timestamp | **Indexes:** - Index on `entity_id` @@ -186,7 +186,7 @@ Stores actual content records based on entity blueprints. **Example Data Record:** ```elixir -%PhoenixKit.Entities.EntityData{ +%PhoenixKit.Modules.Entities.EntityData{ id: 1, entity_id: 1, title: "Getting Started with PhoenixKit", @@ -251,14 +251,14 @@ The system supports 11 fully functional field types organized into 5 categories, | `radio` | Radio Buttons | Single choice from radio buttons | **Yes** | | `checkbox` | Checkboxes | Multiple choices from checkboxes | **Yes** | -### Media Fields *(Coming Soon)* +### Media Fields | Type | Label | Description | Requires Options | Status | |--------------|--------------------| --------------------------------------|------------------|--------| +| `file` | File Upload | File upload with configurable constraints | No | **Registered** | | `image` | Image Upload | Image file upload | No | Placeholder UI | -| `file` | File Upload | Generic file upload | No | Placeholder UI | -> **Note**: Media fields are defined in the schema but render "Coming Soon" placeholders in the form builder. No actual file upload functionality is implemented yet. +> **Note**: `file` is fully registered in `FieldTypes` and can be created via `file_field/3`. `image` is defined in the form builder schema but renders a "Coming Soon" placeholder — no actual image upload functionality is implemented yet. ### Relational Fields *(Coming Soon)* @@ -335,7 +335,7 @@ The `FieldTypes.validate_field/1` function validates: ## Core Modules -### 1. PhoenixKit.Entities +### 1. PhoenixKit.Modules.Entities **File**: `lib/modules/entities/entities.ex` @@ -345,28 +345,28 @@ Main module for entity management with both Ecto schema and business logic. ```elixir # List all entities -PhoenixKit.Entities.list_entities() -# => [%PhoenixKit.Entities{}, ...] +PhoenixKit.Modules.Entities.list_entities() +# => [%PhoenixKit.Modules.Entities{}, ...] # List only published entities -PhoenixKit.Entities.list_active_entities() -# => [%PhoenixKit.Entities{status: "published"}, ...] +PhoenixKit.Modules.Entities.list_active_entities() +# => [%PhoenixKit.Modules.Entities{status: "published"}, ...] # Get entity by ID (raises if not found) -PhoenixKit.Entities.get_entity!(1) -# => %PhoenixKit.Entities{} +PhoenixKit.Modules.Entities.get_entity!(1) +# => %PhoenixKit.Modules.Entities{} # Get entity by ID (returns nil if not found) -PhoenixKit.Entities.get_entity(1) -# => %PhoenixKit.Entities{} | nil +PhoenixKit.Modules.Entities.get_entity(1) +# => %PhoenixKit.Modules.Entities{} | nil # Get entity by unique name -PhoenixKit.Entities.get_entity_by_name("blog_post") -# => %PhoenixKit.Entities{} +PhoenixKit.Modules.Entities.get_entity_by_name("blog_post") +# => %PhoenixKit.Modules.Entities{} # Create entity # Note: created_by is optional - it auto-fills with first admin user if not provided -PhoenixKit.Entities.create_entity(%{ +PhoenixKit.Modules.Entities.create_entity(%{ name: "blog_post", display_name: "Blog Post", description: "Blog post content type", @@ -375,31 +375,31 @@ PhoenixKit.Entities.create_entity(%{ # created_by: user_id, # Optional! Auto-filled if omitted fields_definition: [...] }) -# => {:ok, %PhoenixKit.Entities{}} +# => {:ok, %PhoenixKit.Modules.Entities{}} # Update entity -PhoenixKit.Entities.update_entity(entity, %{status: "published"}) -# => {:ok, %PhoenixKit.Entities{}} +PhoenixKit.Modules.Entities.update_entity(entity, %{status: "published"}) +# => {:ok, %PhoenixKit.Modules.Entities{}} # Delete entity (also deletes all associated data) -PhoenixKit.Entities.delete_entity(entity) -# => {:ok, %PhoenixKit.Entities{}} +PhoenixKit.Modules.Entities.delete_entity(entity) +# => {:ok, %PhoenixKit.Modules.Entities{}} # Get changeset for forms -PhoenixKit.Entities.change_entity(entity, attrs) +PhoenixKit.Modules.Entities.change_entity(entity, attrs) # => %Ecto.Changeset{} # System stats -PhoenixKit.Entities.get_system_stats() +PhoenixKit.Modules.Entities.get_system_stats() # => %{total_entities: 5, active_entities: 4, total_data_records: 150} # Check if enabled -PhoenixKit.Entities.enabled?() +PhoenixKit.Modules.Entities.enabled?() # => true # Enable/disable system -PhoenixKit.Entities.enable_system() -PhoenixKit.Entities.disable_system() +PhoenixKit.Modules.Entities.enable_system() +PhoenixKit.Modules.Entities.disable_system() ``` **Validations:** @@ -411,7 +411,7 @@ PhoenixKit.Entities.disable_system() - **Fields Definition**: Must be valid array of field definitions - **Timestamps**: Auto-set on create/update -### 2. PhoenixKit.Entities.EntityData +### 2. PhoenixKit.Modules.Entities.EntityData **File**: `lib/modules/entities/entity_data.ex` @@ -421,24 +421,24 @@ Module for entity data records with dynamic validation. ```elixir # List all data for an entity -PhoenixKit.Entities.EntityData.list_by_entity(entity_id) -# => [%PhoenixKit.Entities.EntityData{}, ...] +PhoenixKit.Modules.Entities.EntityData.list_by_entity(entity_id) +# => [%PhoenixKit.Modules.Entities.EntityData{}, ...] # List all data across all entities -PhoenixKit.Entities.EntityData.list_all() -# => [%PhoenixKit.Entities.EntityData{}, ...] +PhoenixKit.Modules.Entities.EntityData.list_all() +# => [%PhoenixKit.Modules.Entities.EntityData{}, ...] # Get data record by ID (raises if not found) -PhoenixKit.Entities.EntityData.get!(id) -# => %PhoenixKit.Entities.EntityData{} +PhoenixKit.Modules.Entities.EntityData.get!(id) +# => %PhoenixKit.Modules.Entities.EntityData{} # Get data record by ID (returns nil if not found) -PhoenixKit.Entities.EntityData.get(id) -# => %PhoenixKit.Entities.EntityData{} | nil +PhoenixKit.Modules.Entities.EntityData.get(id) +# => %PhoenixKit.Modules.Entities.EntityData{} | nil # Create data record # Note: created_by is optional - it auto-fills with first admin user if not provided -PhoenixKit.Entities.EntityData.create(%{ +PhoenixKit.Modules.Entities.EntityData.create(%{ entity_id: 1, title: "My First Post", slug: "my-first-post", @@ -446,18 +446,18 @@ PhoenixKit.Entities.EntityData.create(%{ data: %{"title" => "My First Post", "content" => "..."} # created_by: user_id # Optional! Auto-filled if omitted }) -# => {:ok, %PhoenixKit.Entities.EntityData{}} +# => {:ok, %PhoenixKit.Modules.Entities.EntityData{}} # Update data record -PhoenixKit.Entities.EntityData.update(data_record, %{status: "published"}) -# => {:ok, %PhoenixKit.Entities.EntityData{}} +PhoenixKit.Modules.Entities.EntityData.update(data_record, %{status: "published"}) +# => {:ok, %PhoenixKit.Modules.Entities.EntityData{}} # Delete data record -PhoenixKit.Entities.EntityData.delete(data_record) -# => {:ok, %PhoenixKit.Entities.EntityData{}} +PhoenixKit.Modules.Entities.EntityData.delete(data_record) +# => {:ok, %PhoenixKit.Modules.Entities.EntityData{}} # Get changeset -PhoenixKit.Entities.EntityData.change(data_record, attrs) +PhoenixKit.Modules.Entities.EntityData.change(data_record, attrs) # => %Ecto.Changeset{} ``` @@ -470,7 +470,7 @@ The `validate_data_against_entity/1` function validates data records against the 3. **Options**: For choice fields, validates values are in allowed options 4. **Data Completeness**: Ensures data map contains entries for defined fields -### 3. PhoenixKit.Entities.FieldTypes +### 3. PhoenixKit.Modules.Entities.FieldTypes **File**: `lib/modules/entities/field_types.ex` @@ -480,60 +480,60 @@ Field type definitions and validation. ```elixir # Get all field types -PhoenixKit.Entities.FieldTypes.all() +PhoenixKit.Modules.Entities.FieldTypes.all() # => %{"text" => %{name: "text", label: "Text", ...}, ...} # Get field types by category -PhoenixKit.Entities.FieldTypes.by_category(:basic) +PhoenixKit.Modules.Entities.FieldTypes.by_category(:basic) # => [%{name: "text", label: "Text", ...}, ...] # Get category list -PhoenixKit.Entities.FieldTypes.category_list() +PhoenixKit.Modules.Entities.FieldTypes.category_list() # => [{:basic, "Basic Fields"}, {:numeric, "Numeric"}, ...] # Get specific type -PhoenixKit.Entities.FieldTypes.get_type("text") +PhoenixKit.Modules.Entities.FieldTypes.get_type("text") # => %{name: "text", label: "Text", category: :basic, icon: "hero-document-text"} # Check if type requires options -PhoenixKit.Entities.FieldTypes.requires_options?("select") +PhoenixKit.Modules.Entities.FieldTypes.requires_options?("select") # => true # Validate field definition -PhoenixKit.Entities.FieldTypes.validate_field(field_map) +PhoenixKit.Modules.Entities.FieldTypes.validate_field(field_map) # => {:ok, validated_field} | {:error, error_message} # Format for picker UI -PhoenixKit.Entities.FieldTypes.for_picker() +PhoenixKit.Modules.Entities.FieldTypes.for_picker() # => Structured data for UI dropdowns # Field Builder Helpers (for programmatic entity creation) # These helpers make it easy to create field definitions with proper structure # Create a field with options -PhoenixKit.Entities.FieldTypes.new_field("text", "title", "Title", required: true) +PhoenixKit.Modules.Entities.FieldTypes.new_field("text", "title", "Title", required: true) # => %{"type" => "text", "key" => "title", "label" => "Title", "required" => true, ...} # Create choice fields with options -PhoenixKit.Entities.FieldTypes.select_field("category", "Category", ["Tech", "Business", "Other"]) +PhoenixKit.Modules.Entities.FieldTypes.select_field("category", "Category", ["Tech", "Business", "Other"]) # => %{"type" => "select", "key" => "category", "label" => "Category", "options" => [...], ...} -PhoenixKit.Entities.FieldTypes.radio_field("priority", "Priority", ["Low", "Medium", "High"]) +PhoenixKit.Modules.Entities.FieldTypes.radio_field("priority", "Priority", ["Low", "Medium", "High"]) # => %{"type" => "radio", "key" => "priority", "label" => "Priority", "options" => [...], ...} -PhoenixKit.Entities.FieldTypes.checkbox_field("tags", "Tags", ["Featured", "Popular", "New"]) +PhoenixKit.Modules.Entities.FieldTypes.checkbox_field("tags", "Tags", ["Featured", "Popular", "New"]) # => %{"type" => "checkbox", "key" => "tags", "label" => "Tags", "options" => [...], ...} # Convenience helpers for common field types -PhoenixKit.Entities.FieldTypes.text_field("name", "Full Name", required: true) -PhoenixKit.Entities.FieldTypes.textarea_field("bio", "Biography") -PhoenixKit.Entities.FieldTypes.email_field("email", "Email Address", required: true) -PhoenixKit.Entities.FieldTypes.number_field("age", "Age") -PhoenixKit.Entities.FieldTypes.boolean_field("active", "Is Active", default: true) -PhoenixKit.Entities.FieldTypes.rich_text_field("content", "Content") +PhoenixKit.Modules.Entities.FieldTypes.text_field("name", "Full Name", required: true) +PhoenixKit.Modules.Entities.FieldTypes.textarea_field("bio", "Biography") +PhoenixKit.Modules.Entities.FieldTypes.email_field("email", "Email Address", required: true) +PhoenixKit.Modules.Entities.FieldTypes.number_field("age", "Age") +PhoenixKit.Modules.Entities.FieldTypes.boolean_field("active", "Is Active", default: true) +PhoenixKit.Modules.Entities.FieldTypes.rich_text_field("content", "Content") ``` -### 4. PhoenixKit.Entities.FormBuilder +### 4. PhoenixKit.Modules.Entities.FormBuilder **File**: `lib/modules/entities/form_builder.ex` @@ -543,15 +543,15 @@ Dynamic form generation from entity field definitions. ```elixir # Generate form fields from entity (returns Phoenix.Component HTML) -PhoenixKit.Entities.FormBuilder.build_fields(entity, changeset, opts \\ []) +PhoenixKit.Modules.Entities.FormBuilder.build_fields(entity, changeset, opts \\ []) # => Phoenix.LiveView.Rendered (HEEx template) # Generate single field (multi-clause function handles all field types) -PhoenixKit.Entities.FormBuilder.build_field(field_definition, changeset, opts \\ []) +PhoenixKit.Modules.Entities.FormBuilder.build_field(field_definition, changeset, opts \\ []) # => Phoenix.LiveView.Rendered (HEEx template) # Validate entity data against field definitions -PhoenixKit.Entities.FormBuilder.validate_data(entity, data_params) +PhoenixKit.Modules.Entities.FormBuilder.validate_data(entity, data_params) # => {:ok, validated_data} | {:error, errors} ``` @@ -929,11 +929,11 @@ end ```elixir # Sanitize a single string -PhoenixKit.Entities.HtmlSanitizer.sanitize("

Hello

") +PhoenixKit.Modules.Entities.HtmlSanitizer.sanitize("

Hello

") # => "

Hello

" # Sanitize all rich_text fields in data map -PhoenixKit.Entities.HtmlSanitizer.sanitize_rich_text_fields(fields_definition, data) +PhoenixKit.Modules.Entities.HtmlSanitizer.sanitize_rich_text_fields(fields_definition, data) ``` --- @@ -1029,7 +1029,7 @@ The multilang system is built around three principles: 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` +### Core Module: `PhoenixKit.Modules.Entities.Multilang` Pure-function module with zero side effects. All functions operate on data maps without touching the database. @@ -1091,7 +1091,7 @@ This approach avoids bulk migrations and is idempotent — if the user doesn't s 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`): +**Entity definitions** (`PhoenixKit.Modules.Entities`): ```elixir Entities.set_entity_translation(entity, "es-ES", %{"display_name" => "Productos"}) Entities.get_entity_translation(entity, "es-ES") @@ -1100,7 +1100,7 @@ Entities.remove_entity_translation(entity, "es-ES") Entities.multilang_enabled?() ``` -**Data records** (`PhoenixKit.Entities.EntityData`): +**Data records** (`PhoenixKit.Modules.Entities.EntityData`): ```elixir EntityData.set_translation(record, "es-ES", %{"name" => "Acme España"}) EntityData.get_translation(record, "es-ES") @@ -1141,7 +1141,7 @@ EntityData.get_all_title_translations(record) ```elixir # 1. Create the entity definition -{:ok, blog_entity} = PhoenixKit.Entities.create_entity(%{ +{:ok, blog_entity} = PhoenixKit.Modules.Entities.create_entity(%{ name: "blog_post", display_name: "Blog Post", description: "Blog post content type with rich text and categories", @@ -1197,7 +1197,7 @@ EntityData.get_all_title_translations(record) }) # 2. Create blog post data records -{:ok, post} = PhoenixKit.Entities.EntityData.create(%{ +{:ok, post} = PhoenixKit.Modules.Entities.EntityData.create(%{ entity_id: blog_entity.id, title: "Getting Started with PhoenixKit Entities", slug: "getting-started-phoenixkit-entities", @@ -1221,7 +1221,7 @@ EntityData.get_all_title_translations(record) # 3. Query published blog posts published_posts = - PhoenixKit.Entities.EntityData.list_by_entity(blog_entity.id) + PhoenixKit.Modules.Entities.EntityData.list_by_entity(blog_entity.id) |> Enum.filter(&(&1.status == "published")) |> Enum.sort_by(&(&1.data["publish_date"]), :desc) ``` @@ -1229,7 +1229,7 @@ published_posts = ### Creating a Product Catalog ```elixir -{:ok, product_entity} = PhoenixKit.Entities.create_entity(%{ +{:ok, product_entity} = PhoenixKit.Modules.Entities.create_entity(%{ name: "product", display_name: "Product", description: "Product catalog with pricing and inventory", @@ -1253,7 +1253,7 @@ published_posts = ### Creating Team Members ```elixir -{:ok, team_entity} = PhoenixKit.Entities.create_entity(%{ +{:ok, team_entity} = PhoenixKit.Modules.Entities.create_entity(%{ name: "team_member", display_name: "Team Member", description: "Team member profiles with bio and social links", @@ -1373,10 +1373,10 @@ end **Feature**: Entities navigation menu items only appear when the system is enabled. -**Implementation**: Used `PhoenixKit.Entities.enabled?()` check in `layout_wrapper.ex`: +**Implementation**: Used `PhoenixKit.Modules.Entities.enabled?()` check in `layout_wrapper.ex`: ```heex -<%= if PhoenixKit.Entities.enabled?() do %> +<%= if PhoenixKit.Modules.Entities.enabled?() do %> <.admin_nav_item href={Routes.locale_aware_path(assigns, "/admin/entities")} icon="entities" @@ -1386,7 +1386,7 @@ end <%= if submenu_open?(@current_path, ["/admin/entities"]) do %> <%!-- Dynamically list each published entity --%> - <%= for entity <- PhoenixKit.Entities.list_entities() do %> + <%= for entity <- PhoenixKit.Modules.Entities.list_entities() do %> <%= if entity.status == "published" do %> <.admin_nav_item href={Routes.locale_aware_path(assigns, "/admin/entities/#{entity.name}/data")} @@ -1451,27 +1451,27 @@ ON CONFLICT (key) DO NOTHING ```elixir # Check if system is enabled -PhoenixKit.Entities.enabled?() +PhoenixKit.Modules.Entities.enabled?() # => false # Enable system -PhoenixKit.Entities.enable_system() +PhoenixKit.Modules.Entities.enable_system() # => {:ok, %Setting{}} # Disable system -PhoenixKit.Entities.disable_system() +PhoenixKit.Modules.Entities.disable_system() # => {:ok, %Setting{}} # Get max entities per user -PhoenixKit.Entities.get_max_per_user() +PhoenixKit.Modules.Entities.get_max_per_user() # => 100 # Validate user hasn't exceeded limit -PhoenixKit.Entities.validate_user_entity_limit(user_id) +PhoenixKit.Modules.Entities.validate_user_entity_limit(user_id) # => {:ok, :valid} | {:error, "You have reached the maximum limit of 100 entities"} # Get full config -PhoenixKit.Entities.get_config() +PhoenixKit.Modules.Entities.get_config() # => %{ # enabled: false, # max_per_user: 100, @@ -1725,16 +1725,16 @@ test "unique constraint on entity name" **Solution**: Ensure `phx-change="update_field_form"` is set and `field_form` assign is properly merged. **Issue**: Entities menu not appearing -**Solution**: Enable the entities system via Settings or run `PhoenixKit.Entities.enable_system()`. +**Solution**: Enable the entities system via Settings or run `PhoenixKit.Modules.Entities.enable_system()`. --- ## API Reference -### PhoenixKit.Entities +### PhoenixKit.Modules.Entities ```elixir -@type t :: %PhoenixKit.Entities{ +@type t :: %PhoenixKit.Modules.Entities{ id: integer(), name: String.t(), display_name: String.t(), @@ -1772,10 +1772,10 @@ test "unique constraint on entity name" Note: `create_entity/1` auto-fills `created_by` with the first admin user if not provided. -### PhoenixKit.Entities.EntityData +### PhoenixKit.Modules.Entities.EntityData ```elixir -@type t :: %PhoenixKit.Entities.EntityData{ +@type t :: %PhoenixKit.Modules.Entities.EntityData{ id: integer(), entity_id: integer(), title: String.t(), @@ -1810,7 +1810,7 @@ Note: `create_entity/1` auto-fills `created_by` with the first admin user if not Note: `create/1` auto-fills `created_by` with the first admin user if not provided. -### PhoenixKit.Entities.Multilang +### PhoenixKit.Modules.Entities.Multilang ```elixir @spec enabled?() :: boolean() @@ -1828,7 +1828,7 @@ Note: `create/1` auto-fills `created_by` with the first admin user if not provid @spec build_language_tabs() :: [map()] ``` -### PhoenixKit.Entities.FieldTypes +### PhoenixKit.Modules.Entities.FieldTypes ```elixir @spec all() :: map() diff --git a/lib/modules/entities/OVERVIEW.md b/lib/modules/entities/OVERVIEW.md index 23451d16..061105a4 100644 --- a/lib/modules/entities/OVERVIEW.md +++ b/lib/modules/entities/OVERVIEW.md @@ -7,7 +7,7 @@ PhoenixKit's Entities layer is a dynamic content type engine. It lets administra ## High-level capabilities - **Entity blueprints** – Define reusable content types (`phoenix_kit_entities`) with metadata, singular/plural labels, icon, status, JSON field schema, and optional custom settings. -- **Dynamic fields** – 11 built-in field types (text, textarea, number, boolean, date, email, URL, select, radio, checkbox, rich text). Field definitions live in JSONB and are validated at creation time. *(Note: image, file, and relation fields are defined but not yet fully implemented—UI shows "coming soon" placeholders.)* +- **Dynamic fields** – 12 built-in field types (text, textarea, number, boolean, date, email, URL, select, radio, checkbox, rich text, file). Field definitions live in JSONB and are validated at creation time. *(Note: image and relation fields are defined but not yet fully implemented—UI shows "coming soon" placeholders.)* - **Entity data records** – Store instances of an entity (`phoenix_kit_entity_data`) with slug support, status workflow (draft/published/archived), JSONB data payload, metadata, creator tracking, and timestamps. - **Admin UI** – LiveView dashboards for managing blueprints, browsing/creating data, filtering, and adjusting module settings. - **Settings + security** – Feature toggle and max entities per user are enforced; additional settings (relation/file flags, auto slugging, etc.) are persisted in `phoenix_kit_settings` but reserved for future use. All surfaces are gated behind the admin scope. @@ -96,7 +96,7 @@ Indexes cover `entity_id`, `slug`, `status`, `created_by`, `title`. FK cascades ## Core modules -### `PhoenixKit.Entities` +### `PhoenixKit.Modules.Entities` Responsible for entity blueprints: - Schema + changeset enforcing unique names, valid field definitions, timestamps, etc. - CRUD helpers (`list_entities/0`, `get_entity!/1`, `get_entity/1`, `get_entity_by_name/1`, `create_entity/1`, `update_entity/2`, `delete_entity/1`, `change_entity/2`). @@ -108,7 +108,7 @@ Note: `create_entity/1` auto-fills `created_by` with the first admin user if not Field validation pipeline ensures every entry in `fields_definition` has `type/key/label` and uses a supported type. Note: the changeset validates but does not enrich field definitions—use `FieldTypes.new_field/4` to apply default properties. -### `PhoenixKit.Entities.EntityData` +### `PhoenixKit.Modules.Entities.EntityData` Manages actual records: - Schema + changeset verifying required fields, slug format, status, and cross-checking submitted JSON against the entity definition. - CRUD and query helpers (`list_all/0`, `list_by_entity/1`, `get!/1`, `get/1`, `search_by_title/2`, `create/1`, `update/2`, `delete/1`, `change/2`). @@ -116,7 +116,7 @@ Manages actual records: Note: `create/1` auto-fills `created_by` with the first admin user if not provided. -### `PhoenixKit.Entities.FieldTypes` +### `PhoenixKit.Modules.Entities.FieldTypes` Registry of supported field types with metadata: - `all/0`, `list_types/0`, `for_picker/0` – introspection for UI builders. - Category helpers, default properties, and `validate_field/1` to ensure field definitions are complete. @@ -126,7 +126,7 @@ 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` +### `PhoenixKit.Modules.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`. @@ -134,7 +134,7 @@ Pure-function module for multi-language data transformations. No database calls - 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` +### `PhoenixKit.Modules.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. @@ -204,7 +204,7 @@ Each field definition is a map like: > **Note**: Settings marked "Not yet enforced" are persisted in the database and visible in the admin UI, but the underlying functionality is not yet implemented. They are placeholders for future features. -`PhoenixKit.Entities.get_config/0` returns a map: +`PhoenixKit.Modules.Entities.get_config/0` returns a map: ```elixir %{ enabled: boolean, @@ -222,8 +222,8 @@ Each field definition is a map like: ### Enabling the module ```elixir -{:ok, _setting} = PhoenixKit.Entities.enable_system() -PhoenixKit.Entities.enabled?() +{:ok, _setting} = PhoenixKit.Modules.Entities.enable_system() +PhoenixKit.Modules.Entities.enabled?() # => true/false ``` @@ -231,7 +231,7 @@ PhoenixKit.Entities.enabled?() ```elixir # Note: created_by is optional - auto-fills with first admin user if omitted {:ok, blog_entity} = - PhoenixKit.Entities.create_entity(%{ + PhoenixKit.Modules.Entities.create_entity(%{ name: "blog_post", display_name: "Blog Post", display_name_plural: "Blog Posts", @@ -246,7 +246,7 @@ PhoenixKit.Entities.enabled?() ### Creating fields with builder helpers ```elixir -alias PhoenixKit.Entities.FieldTypes +alias PhoenixKit.Modules.Entities.FieldTypes # Build fields programmatically fields = [ @@ -257,7 +257,7 @@ fields = [ FieldTypes.boolean_field("featured", "Featured Post", default: false) ] -{:ok, entity} = PhoenixKit.Entities.create_entity(%{ +{:ok, entity} = PhoenixKit.Modules.Entities.create_entity(%{ name: "article", display_name: "Article", fields_definition: fields @@ -268,7 +268,7 @@ fields = [ ```elixir # Note: created_by is optional - auto-fills with first admin user if omitted {:ok, _record} = - PhoenixKit.Entities.EntityData.create(%{ + PhoenixKit.Modules.Entities.EntityData.create(%{ entity_id: blog_entity.id, title: "My First Post", status: "published", @@ -279,13 +279,13 @@ fields = [ ### Counting statistics ```elixir -PhoenixKit.Entities.get_system_stats() +PhoenixKit.Modules.Entities.get_system_stats() # => %{total_entities: 5, active_entities: 4, total_data_records: 23} ``` ### Enforcing limits ```elixir -PhoenixKit.Entities.validate_user_entity_limit(admin.id) +PhoenixKit.Modules.Entities.validate_user_entity_limit(admin.id) # {:ok, :valid} or {:error, "You have reached the maximum limit of 100 entities"} ``` diff --git a/lib/modules/entities/README.md b/lib/modules/entities/README.md index 2ac2231e..8e3a1588 100644 --- a/lib/modules/entities/README.md +++ b/lib/modules/entities/README.md @@ -2,7 +2,7 @@ The Entities module delivers PhoenixKit's dynamic content type system. It allows administrators to design structured content types with custom fields without writing migrations or code. This README gives a quick orientation for contributors working on the LiveView -layer; the business logic lives in the `PhoenixKit.Entities` context. +layer; the business logic lives in the `PhoenixKit.Modules.Entities` context. ## LiveViews & Components @@ -23,13 +23,13 @@ All templates follow Phoenix 1.8 layout conventions (`` with `@ - **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`). -- **Event Broadcasting** – Hooks integrate with `PhoenixKit.Entities.Events` for lifecycle tracking. +- **Event Broadcasting** – Hooks integrate with `PhoenixKit.Modules.Entities.Events` for lifecycle tracking. ## 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`. +- Context modules: `PhoenixKit.Modules.Entities`, `PhoenixKit.Modules.Entities.EntityData`, `PhoenixKit.Modules.Entities.FieldTypes`. +- Multilang module: `PhoenixKit.Modules.Entities.Multilang` – pure-function helpers for multilang JSONB. +- Supporting modules: `PhoenixKit.Modules.Entities.Events`, `PhoenixKit.Modules.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()`. diff --git a/lib/modules/entities/web/data_form.ex b/lib/modules/entities/web/data_form.ex index ca48541e..51bcadcd 100644 --- a/lib/modules/entities/web/data_form.ex +++ b/lib/modules/entities/web/data_form.ex @@ -706,13 +706,15 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do # When on a secondary language tab, the form doesn't submit title/slug/status. # Preserve their current values so the changeset doesn't clear them. + @preserve_fields %{"title" => :title, "slug" => :slug, "status" => :status} + defp preserve_primary_fields(data_params, changeset) do - Enum.reduce(~w(title slug status), data_params, fn field, acc -> - if Map.has_key?(acc, field) do + Enum.reduce(@preserve_fields, data_params, fn {str_key, atom_key}, acc -> + if Map.has_key?(acc, str_key) do acc else - value = Ecto.Changeset.get_field(changeset, String.to_existing_atom(field)) - if value, do: Map.put(acc, field, value), else: acc + value = Ecto.Changeset.get_field(changeset, atom_key) + if value, do: Map.put(acc, str_key, value), else: acc end end) end diff --git a/lib/modules/entities/web/data_navigator.ex b/lib/modules/entities/web/data_navigator.ex index ce148546..4e5c9de5 100644 --- a/lib/modules/entities/web/data_navigator.ex +++ b/lib/modules/entities/web/data_navigator.ex @@ -279,7 +279,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do socket = socket |> assign(:selected_ids, MapSet.new()) - |> push_patch(to: Routes.locale_aware_path(socket.assigns, full_path)) + |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) {:noreply, socket} end diff --git a/test/modules/entities/multilang_test.exs b/test/modules/entities/multilang_test.exs index 052f9133..f594fa18 100644 --- a/test/modules/entities/multilang_test.exs +++ b/test/modules/entities/multilang_test.exs @@ -349,6 +349,44 @@ defmodule PhoenixKit.Modules.Entities.MultilangTest do end end + # --- maybe_rekey_data/1 --- + # Note: In test env without Languages module DB, primary_language() falls + # back to "en-US". So data with embedded "en-US" is a no-op, while data + # with any other embedded primary will be re-keyed to "en-US". + + describe "maybe_rekey_data/1" do + test "re-keys when embedded primary differs from global" do + # Embedded is "es-ES", global fallback is "en-US" → should re-key + data = %{ + "_primary_language" => "es-ES", + "es-ES" => %{"name" => "Acme España", "category" => "Tech"}, + "en-US" => %{"name" => "Acme"} + } + + result = Multilang.maybe_rekey_data(data) + + assert result["_primary_language"] == "en-US" + # New primary promoted with all fields from old primary + assert result["en-US"] == %{"name" => "Acme", "category" => "Tech"} + end + + test "returns data unchanged when already using global primary" do + # Embedded is "en-US" which matches the fallback global + result = Multilang.maybe_rekey_data(multilang_data()) + + assert result == multilang_data() + end + + test "returns non-multilang data unchanged" do + result = Multilang.maybe_rekey_data(flat_data()) + assert result == flat_data() + end + + test "returns nil unchanged" do + assert Multilang.maybe_rekey_data(nil) == nil + end + end + # --- Integration: migrate then put --- describe "migrate + put workflow" do From 1e36eb4bdc26aabc05164d9a1cc99f56f81ead4b Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 18 Feb 2026 05:58:16 +0200 Subject: [PATCH 2/3] Fix 7 remaining issues from PR #341 review - Return error atoms from can_edit_role_permissions? and translate with gettext at LiveView call sites instead of hardcoded English strings - Allow system role users to edit roles they also hold (Admin+Editor can edit Editor), only block self-edit for non-system role users - Sort custom_keys/0 explicitly instead of relying on Erlang map ordering - Add catch-all fallback clauses to Scope functions (owner?, admin?, system_role?, has_role?, user_roles) to prevent FunctionClauseError when cached_roles is nil with a non-nil user - Use UUID instead of integer ID in auto_grant_to_admin_roles - Document intentional return type difference between all_module_keys (list) and enabled_module_keys (MapSet) - Fix custom keys two-column grid layout to use CSS columns for proper item distribution across both columns Co-Authored-By: Claude Opus 4.6 --- lib/phoenix_kit/users/auth/scope.ex | 10 ++--- lib/phoenix_kit/users/permissions.ex | 31 +++++++++------- .../live/users/permissions_matrix.ex | 15 +++++++- .../live/users/roles.html.heex | 28 +++++++------- test/phoenix_kit/users/permissions_test.exs | 37 ++++++++++--------- 5 files changed, 68 insertions(+), 53 deletions(-) diff --git a/lib/phoenix_kit/users/auth/scope.ex b/lib/phoenix_kit/users/auth/scope.ex index aaedabc5..35733326 100644 --- a/lib/phoenix_kit/users/auth/scope.ex +++ b/lib/phoenix_kit/users/auth/scope.ex @@ -236,7 +236,7 @@ defmodule PhoenixKit.Users.Auth.Scope do role_name in cached_roles end - def has_role?(%__MODULE__{user: nil}, _role_name), do: false + def has_role?(_, _role_name), do: false @doc """ Checks if the user is an owner. @@ -259,7 +259,7 @@ defmodule PhoenixKit.Users.Auth.Scope do roles.owner in cached_roles end - def owner?(%__MODULE__{user: nil}), do: false + def owner?(_), do: false @doc """ Checks if the user can access the admin panel. @@ -291,7 +291,7 @@ defmodule PhoenixKit.Users.Auth.Scope do (not is_nil(perms) and MapSet.size(perms) > 0) end - def admin?(%__MODULE__{user: nil}), do: false + def admin?(_), do: false @doc """ Gets all roles for the user. @@ -312,7 +312,7 @@ defmodule PhoenixKit.Users.Auth.Scope do cached_roles end - def user_roles(%__MODULE__{user: nil}), do: [] + def user_roles(_), do: [] @doc """ Gets the user's full name. @@ -461,5 +461,5 @@ defmodule PhoenixKit.Users.Auth.Scope do roles.owner in cached_roles or roles.admin in cached_roles end - def system_role?(%__MODULE__{user: nil}), do: false + def system_role?(_), do: false end diff --git a/lib/phoenix_kit/users/permissions.ex b/lib/phoenix_kit/users/permissions.ex index e0d0e627..087db6ab 100644 --- a/lib/phoenix_kit/users/permissions.ex +++ b/lib/phoenix_kit/users/permissions.ex @@ -240,7 +240,7 @@ defmodule PhoenixKit.Users.Permissions do """ @spec custom_keys() :: [String.t()] def custom_keys do - Map.keys(custom_keys_map()) + custom_keys_map() |> Map.keys() |> Enum.sort() end @doc """ @@ -290,7 +290,7 @@ defmodule PhoenixKit.Users.Permissions do # --- Constants --- - @doc "Returns all built-in and custom permission keys." + @doc "Returns all built-in and custom permission keys as a list. See `enabled_module_keys/0` for filtered MapSet variant." @spec all_module_keys() :: [String.t()] def all_module_keys, do: @all_module_keys ++ custom_keys() @@ -303,9 +303,12 @@ defmodule PhoenixKit.Users.Permissions do def feature_module_keys, do: @feature_module_keys @doc """ - Returns module keys that are currently enabled (core sections + enabled feature modules + custom keys). - Core sections and custom keys are always included. Feature modules are included only if their - module reports enabled status. + Returns module keys that are currently enabled (core sections + enabled feature modules + custom keys) + as a `MapSet` for efficient membership checks. Core sections and custom keys are always included. + Feature modules are included only if their module reports enabled status. + + Returns `MapSet.t()` unlike `all_module_keys/0` which returns a list — callers use this + primarily for `MapSet.member?/2` and `MapSet.intersection/2` checks. """ @spec enabled_module_keys() :: MapSet.t() def enabled_module_keys do @@ -838,14 +841,14 @@ defmodule PhoenixKit.Users.Permissions do - Users cannot edit their own role (prevents self-lockout) - Only Owner can edit Admin role (prevents privilege escalation) """ - @spec can_edit_role_permissions?(Scope.t() | nil, Role.t()) :: :ok | {:error, String.t()} - def can_edit_role_permissions?(nil, _role), do: {:error, "Not authenticated"} + @spec can_edit_role_permissions?(Scope.t() | nil, Role.t()) :: :ok | {:error, atom()} + def can_edit_role_permissions?(nil, _role), do: {:error, :not_authenticated} def can_edit_role_permissions?(scope, role) do if Scope.authenticated?(scope) do can_edit_role_permissions_check(scope, role) else - {:error, "Not authenticated"} + {:error, :not_authenticated} end end @@ -854,13 +857,13 @@ defmodule PhoenixKit.Users.Permissions do cond do role.name == "Owner" -> - {:error, "Owner role always has full access and cannot be modified"} + {:error, :owner_immutable} - role.name in user_roles -> - {:error, "You cannot edit permissions for your own role"} + role.name in user_roles and not Scope.system_role?(scope) -> + {:error, :self_role} role.name == "Admin" and not Scope.owner?(scope) -> - {:error, "Only the Owner can edit Admin permissions"} + {:error, :admin_owner_only} true -> :ok @@ -982,8 +985,8 @@ defmodule PhoenixKit.Users.Permissions do :ok else case Roles.get_role_by_name(Role.system_roles().admin) do - %{id: admin_id} when not is_nil(admin_id) -> - case grant_permission(admin_id, key, nil) do + %{uuid: admin_uuid} when not is_nil(admin_uuid) -> + case grant_permission(admin_uuid, key, nil) do {:ok, _} -> Settings.update_setting(flag_key, "true") diff --git a/lib/phoenix_kit_web/live/users/permissions_matrix.ex b/lib/phoenix_kit_web/live/users/permissions_matrix.ex index fc64c5d7..aa0be7f6 100644 --- a/lib/phoenix_kit_web/live/users/permissions_matrix.ex +++ b/lib/phoenix_kit_web/live/users/permissions_matrix.ex @@ -90,7 +90,7 @@ defmodule PhoenixKitWeb.Live.Users.PermissionsMatrix do end else {:error, reason} -> - {:noreply, put_flash(socket, :error, reason)} + {:noreply, put_flash(socket, :error, permission_error_message(reason))} nil -> {:noreply, put_flash(socket, :error, gettext("Role not found"))} @@ -214,4 +214,17 @@ defmodule PhoenixKitWeb.Live.Users.PermissionsMatrix do matrix = Permissions.get_permissions_matrix() assign(socket, :matrix, matrix) end + + defp permission_error_message(:not_authenticated), do: gettext("Not authenticated") + + defp permission_error_message(:owner_immutable), + do: gettext("Owner role always has full access and cannot be modified") + + defp permission_error_message(:self_role), + do: gettext("You cannot edit permissions for your own role") + + defp permission_error_message(:admin_owner_only), + do: gettext("Only the Owner can edit Admin permissions") + + defp permission_error_message(_), do: gettext("Permission denied") end diff --git a/lib/phoenix_kit_web/live/users/roles.html.heex b/lib/phoenix_kit_web/live/users/roles.html.heex index 1ceccf0a..9cb36e7a 100644 --- a/lib/phoenix_kit_web/live/users/roles.html.heex +++ b/lib/phoenix_kit_web/live/users/roles.html.heex @@ -351,21 +351,19 @@

{gettext("Custom")}

-
-
- <%= for key <- PhoenixKit.Users.Permissions.custom_keys(), MapSet.member?(@permissions_grantable_keys, key) do %> - - <% end %> -
+
+ <%= for key <- PhoenixKit.Users.Permissions.custom_keys(), MapSet.member?(@permissions_grantable_keys, key) do %> + + <% end %>
<% end %> diff --git a/test/phoenix_kit/users/permissions_test.exs b/test/phoenix_kit/users/permissions_test.exs index abf397ed..cf06e4d6 100644 --- a/test/phoenix_kit/users/permissions_test.exs +++ b/test/phoenix_kit/users/permissions_test.exs @@ -400,24 +400,21 @@ defmodule PhoenixKit.Users.PermissionsTest do describe "can_edit_role_permissions?/2" do test "returns error for nil scope" do role = build_role("User") - assert {:error, "Not authenticated"} = Permissions.can_edit_role_permissions?(nil, role) + assert {:error, :not_authenticated} = Permissions.can_edit_role_permissions?(nil, role) end test "blocks editing Owner role" do scope = build_scope(["Owner"]) role = build_role("Owner") - assert {:error, msg} = Permissions.can_edit_role_permissions?(scope, role) - assert msg =~ "Owner" - assert msg =~ "cannot be modified" + assert {:error, :owner_immutable} = Permissions.can_edit_role_permissions?(scope, role) end - test "blocks editing own role" do - scope = build_scope(["Admin"]) - role = build_role("Admin") + test "blocks editing own role for non-system roles" do + scope = build_scope(["Editor"]) + role = build_role("Editor", is_system_role: false) - assert {:error, msg} = Permissions.can_edit_role_permissions?(scope, role) - assert msg =~ "your own role" + assert {:error, :self_role} = Permissions.can_edit_role_permissions?(scope, role) end test "blocks non-Owner from editing Admin role" do @@ -425,8 +422,7 @@ defmodule PhoenixKit.Users.PermissionsTest do scope = build_scope(["Editor"]) role = build_role("Admin") - assert {:error, msg} = Permissions.can_edit_role_permissions?(scope, role) - assert msg =~ "Only the Owner" + assert {:error, :admin_owner_only} = Permissions.can_edit_role_permissions?(scope, role) end test "allows Owner to edit Admin role" do @@ -457,13 +453,20 @@ defmodule PhoenixKit.Users.PermissionsTest do assert :ok = Permissions.can_edit_role_permissions?(scope, role) end - test "blocks user with multiple roles from editing any of their own" do + test "system role user can edit roles they also hold (except Owner/Admin restrictions)" do + # Admin who also holds Editor can still edit Editor because they have system role scope = build_scope(["Admin", "Editor"]) - admin_role = build_role("Admin") editor_role = build_role("Editor", is_system_role: false) - assert {:error, _} = Permissions.can_edit_role_permissions?(scope, admin_role) - assert {:error, _} = Permissions.can_edit_role_permissions?(scope, editor_role) + assert :ok = Permissions.can_edit_role_permissions?(scope, editor_role) + end + + test "non-system role user blocked from editing own role" do + # User with only custom roles can't edit their own role + scope = build_scope(["Editor", "Support"]) + editor_role = build_role("Editor", is_system_role: false) + + assert {:error, :self_role} = Permissions.can_edit_role_permissions?(scope, editor_role) end test "Owner check takes priority over own-role check" do @@ -472,9 +475,7 @@ defmodule PhoenixKit.Users.PermissionsTest do scope = build_scope(["Owner"]) role = build_role("Owner") - assert {:error, msg} = Permissions.can_edit_role_permissions?(scope, role) - assert msg =~ "cannot be modified" - refute msg =~ "your own role" + assert {:error, :owner_immutable} = Permissions.can_edit_role_permissions?(scope, role) end end end From 0a0211212f9da3a4e5dfd39b2aa3b11d33ae1d6b Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 18 Feb 2026 07:15:35 +0200 Subject: [PATCH 3/3] Fix entities multilang: unify title storage, fix rekey logic, add gettext - Fix rekey_primary/2 to strip old primary to overrides and recompute all secondary languages against the new promoted primary - Move title translations from metadata["translations"] into JSONB data column as "_title" key, unifying with other field translations - Add seed_title_in_data for lazy backwards-compat migration on mount - Disable slug generation button on secondary language tabs - Remove unused timestamp fields from spectator cast - Wrap 9 validation error messages in gettext for i18n - Fix set_title_translation/3 to merge _title into existing data - Update OVERVIEW.md, DEEP_DIVE.md, and multilang.ex docs - Add 12 title translation tests and 7 updated rekey tests Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 +- lib/modules/entities/DEEP_DIVE.md | 4 +- lib/modules/entities/OVERVIEW.md | 8 +- lib/modules/entities/entity_data.ex | 122 +++++------ lib/modules/entities/multilang.ex | 54 ++++- lib/modules/entities/web/data_form.ex | 200 ++++++++++++------ lib/modules/entities/web/data_form.html.heex | 23 +- test/modules/entities/multilang_test.exs | 54 ++++- .../entities/title_translation_test.exs | 165 +++++++++++++++ 9 files changed, 471 insertions(+), 164 deletions(-) create mode 100644 test/modules/entities/title_translation_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b9accb38..a3f3361c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,14 @@ - 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 + - Lazy re-keying when global primary language changes (recomputes all secondary overrides) - 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 + - Title translations stored as `_title` in JSONB data column (unified with other field translations) + - Slug generation disabled on secondary language tabs + - Validation error messages wrapped in gettext for i18n ## 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 9637aa9e..5b8381ea 100644 --- a/lib/modules/entities/DEEP_DIVE.md +++ b/lib/modules/entities/DEEP_DIVE.md @@ -1070,7 +1070,7 @@ The `_primary_language` key cannot collide with user field keys because field ke | Content | Primary language | Secondary languages | |---------|-----------------|---------------------| | Data custom fields | `data[primary_lang]` | `data[lang_code]` (overrides) | -| Record title | `title` column | `metadata["translations"][lang_code]["title"]` | +| Record title | `title` column + `data[primary]["_title"]` | `data[lang_code]["_title"]` (overrides) | | Entity display_name | `display_name` column | `settings["translations"][lang_code]["display_name"]` | | Entity description | `description` column | `settings["translations"][lang_code]["description"]` | @@ -1082,7 +1082,7 @@ When the global primary language changes (via Languages admin), existing records 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 + - Recompute all secondary language overrides against new primary (including `_title`) - 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. diff --git a/lib/modules/entities/OVERVIEW.md b/lib/modules/entities/OVERVIEW.md index 061105a4..f0829cc2 100644 --- a/lib/modules/entities/OVERVIEW.md +++ b/lib/modules/entities/OVERVIEW.md @@ -80,7 +80,7 @@ Indexes cover `name`, `status`, `created_by`. A comment block documents JSON col - `slug` – optional unique slug per entity - `status` – `draft | published | archived` - `data` – JSONB map keyed by field definition (or multilang structure, see below) -- `metadata` – optional JSONB extras (includes title translations when multilang enabled) +- `metadata` – optional JSONB extras (tags, categories, etc.) - `created_by` – admin user id - `date_created`, `date_updated` @@ -315,7 +315,7 @@ When the **Languages module** is enabled with 2+ languages, all entities automat - 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"]` +- `title` and `slug` DB columns remain primary-language-only; secondary title translations are stored as `_title` overrides in the JSONB `data` column alongside other fields - Entity definition translations (display_name, etc.) are in `entity.settings["translations"]` ### Translation Storage Summary @@ -323,7 +323,7 @@ When the **Languages module** is enabled with 2+ languages, all entities automat | 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"]` | +| Record title | `title` column + `data[primary]["_title"]` | `data["es-ES"]["_title"]` (overrides) | | Entity display_name | `display_name` column | `settings["translations"]["es-ES"]["display_name"]` | ### Enabling Multilang @@ -389,7 +389,7 @@ When the global primary language changes (via Languages admin), existing records 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 +4. All secondary languages are recomputed against the new primary; `_title` is re-keyed with other fields 5. Changes persist when the user saves Records that are never edited continue to work — read paths use the embedded primary for correct display. diff --git a/lib/modules/entities/entity_data.ex b/lib/modules/entities/entity_data.ex index ed8e2782..e57681bd 100644 --- a/lib/modules/entities/entity_data.ex +++ b/lib/modules/entities/entity_data.ex @@ -68,6 +68,7 @@ defmodule PhoenixKit.Modules.Entities.EntityData do """ use Ecto.Schema + use Gettext, backend: PhoenixKitWeb.Gettext import Ecto.Changeset import Ecto.Query, warn: false @@ -179,7 +180,7 @@ defmodule PhoenixKit.Modules.Entities.EntityData do add_error( changeset, :slug, - "must contain only lowercase letters, numbers, and hyphens" + gettext("must contain only lowercase letters, numbers, and hyphens") ) end end @@ -239,7 +240,7 @@ defmodule PhoenixKit.Modules.Entities.EntityData do id -> case Entities.get_entity!(id) do nil -> - add_error(changeset, :entity_id, "does not exist") + add_error(changeset, :entity_id, gettext("does not exist")) entity -> validate_data_fields(changeset, entity, data || %{}) @@ -247,7 +248,7 @@ defmodule PhoenixKit.Modules.Entities.EntityData do end rescue Ecto.NoResultsError -> - add_error(changeset, :entity_id, "does not exist") + add_error(changeset, :entity_id, gettext("does not exist")) end defp validate_data_fields(changeset, entity, data) do @@ -276,7 +277,7 @@ defmodule PhoenixKit.Modules.Entities.EntityData do add_error( changeset, :data, - "field '#{field_def["label"]}' is required" + gettext("field '%{label}' is required", label: field_def["label"]) ) !is_nil(field_value) && field_value != "" -> @@ -303,7 +304,11 @@ defmodule PhoenixKit.Modules.Entities.EntityData do if is_number(value) || (is_binary(value) && Regex.match?(~r/^\d+(\.\d+)?$/, value)) do changeset else - add_error(changeset, :data, "field '#{field_def["label"]}' must be a number") + add_error( + changeset, + :data, + gettext("field '%{label}' must be a number", label: field_def["label"]) + ) end end @@ -311,7 +316,11 @@ defmodule PhoenixKit.Modules.Entities.EntityData do if is_boolean(value) do changeset else - add_error(changeset, :data, "field '#{field_def["label"]}' must be true or false") + add_error( + changeset, + :data, + gettext("field '%{label}' must be true or false", label: field_def["label"]) + ) end end @@ -319,7 +328,11 @@ defmodule PhoenixKit.Modules.Entities.EntityData do if is_binary(value) && Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, value) do changeset else - add_error(changeset, :data, "field '#{field_def["label"]}' must be a valid email") + add_error( + changeset, + :data, + gettext("field '%{label}' must be a valid email", label: field_def["label"]) + ) end end @@ -327,7 +340,11 @@ defmodule PhoenixKit.Modules.Entities.EntityData do if is_binary(value) && String.starts_with?(value, ["http://", "https://"]) do changeset else - add_error(changeset, :data, "field '#{field_def["label"]}' must be a valid URL") + add_error( + changeset, + :data, + gettext("field '%{label}' must be a valid URL", label: field_def["label"]) + ) end end @@ -338,7 +355,7 @@ defmodule PhoenixKit.Modules.Entities.EntityData do add_error( changeset, :data, - "field '#{field_def["label"]}' must be a valid date (YYYY-MM-DD)" + gettext("field '%{label}' must be a valid date (YYYY-MM-DD)", label: field_def["label"]) ) end end @@ -352,7 +369,10 @@ defmodule PhoenixKit.Modules.Entities.EntityData do add_error( changeset, :data, - "field '#{field_def["label"]}' must be one of: #{Enum.join(options, ", ")}" + gettext("field '%{label}' must be one of: %{options}", + label: field_def["label"], + options: Enum.join(options, ", ") + ) ) end end @@ -1115,8 +1135,9 @@ defmodule PhoenixKit.Modules.Entities.EntityData do @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"]`. + Reads from `data[lang]["_title"]` (unified JSONB storage). Falls back to + the old `metadata["translations"]` location for unmigrated records, and + finally to the `title` column. ## Examples @@ -1128,21 +1149,24 @@ defmodule PhoenixKit.Modules.Entities.EntityData do """ 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 + case Multilang.get_language_data(entity_data.data, lang_code) do + %{"_title" => title} when is_binary(title) and title != "" -> + title + + _ -> + # Transitional fallback: check old metadata location for unmigrated records + case get_in(entity_data.metadata || %{}, ["translations", lang_code, "title"]) do + title when is_binary(title) and title != "" -> title + _ -> entity_data.title + end 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"]`. + Stores `_title` in the JSONB `data` column using `put_language_data`. + For the primary language, also updates the `title` DB column. ## Examples @@ -1154,34 +1178,17 @@ defmodule PhoenixKit.Modules.Entities.EntityData do """ 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 + # Merge _title into existing raw overrides to preserve other fields + existing_lang_data = Multilang.get_raw_language_data(entity_data.data, lang_code) + merged = Map.put(existing_lang_data, "_title", title) + updated_data = Multilang.put_language_data(entity_data.data, lang_code, merged) - updated_metadata = - if map_size(updated_trans) == 0, - do: Map.delete(metadata, "translations"), - else: Map.put(metadata, "translations", updated_trans) + # If setting primary language, also update the DB column + primary = (entity_data.data || %{})["_primary_language"] || Multilang.primary_language() + attrs = %{data: updated_data} + attrs = if lang_code == primary, do: Map.put(attrs, :title, title), else: attrs - __MODULE__.update(entity_data, %{metadata: updated_metadata}) - end + __MODULE__.update(entity_data, attrs) end @doc """ @@ -1195,31 +1202,12 @@ defmodule PhoenixKit.Modules.Entities.EntityData do %{"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 + {lang, get_title_translation(entity_data, lang)} 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/multilang.ex b/lib/modules/entities/multilang.ex index 070f1d55..704f63fc 100644 --- a/lib/modules/entities/multilang.ex +++ b/lib/modules/entities/multilang.ex @@ -11,13 +11,17 @@ defmodule PhoenixKit.Modules.Entities.Multilang do %{ "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme", "tagline" => "Quality products"}, - "es-ES" => %{"name" => "Acme España"} + "en-US" => %{"_title" => "Acme", "name" => "Acme", "tagline" => "Quality products"}, + "es-ES" => %{"_title" => "Acme España", "name" => "Acme España"} } The primary language always has complete data. Secondary languages store only overrides — fields that differ from primary. Display merges primary values as defaults with language-specific overrides. + + The `_title` key stores the record title alongside custom fields, + unifying title translation with the same override-only storage pattern. + The `title` DB column remains a denormalized copy for queries/sorting. """ alias PhoenixKit.Modules.Languages @@ -206,7 +210,8 @@ defmodule PhoenixKit.Modules.Entities.Multilang do Updates `_primary_language` to the new primary and ensures the new primary has complete data (fills missing fields from the old primary). - Other languages are left untouched. + All secondary languages are recomputed: their overrides are recalculated + against the new promoted primary, and languages with zero overrides are removed. Returns data unchanged if already using the given primary or not multilang. """ @@ -222,15 +227,20 @@ defmodule PhoenixKit.Modules.Entities.Multilang do data true -> - old_primary_data = Map.get(data, primary_language_from_data(data), %{}) + old_primary = primary_language_from_data(data) + old_primary_data = Map.get(data, old_primary, %{}) new_primary_data = Map.get(data, new_primary, %{}) # Promote: fill missing fields in new primary from old primary promoted = Map.merge(old_primary_data, new_primary_data) - data - |> Map.put(@primary_language_key, new_primary) - |> Map.put(new_primary, promoted) + data = + data + |> Map.put(@primary_language_key, new_primary) + |> Map.put(new_primary, promoted) + + # Recompute all secondaries (including old primary) against the new base + recompute_all_secondaries(data, new_primary, promoted, old_primary_data) end end @@ -301,6 +311,36 @@ defmodule PhoenixKit.Modules.Entities.Multilang do if collision, do: String.upcase(code), else: base end + # After rekeying, recompute overrides for every secondary language against the + # new promoted primary. Removes language keys that have zero overrides. + defp recompute_all_secondaries(data, new_primary, promoted, old_primary_data) do + Enum.reduce(data, data, fn + {@primary_language_key, _}, acc -> + acc + + {^new_primary, _}, acc -> + acc + + {lang, lang_data}, acc when is_map(lang_data) -> + # Reconstruct full data using OLD primary as base (overrides were against old primary) + full_lang_data = Map.merge(old_primary_data, lang_data) + # Then diff against the NEW primary to compute new overrides + overrides = compute_overrides(full_lang_data, promoted) + put_or_remove_language(acc, lang, overrides) + + {_key, _value}, acc -> + acc + end) + end + + defp put_or_remove_language(data, lang, overrides) do + if map_size(overrides) == 0 do + Map.delete(data, lang) + else + Map.put(data, lang, overrides) + end + end + defp compute_overrides(lang_data, primary_data) do lang_data |> Enum.filter(fn {key, value} -> diff --git a/lib/modules/entities/web/data_form.ex b/lib/modules/entities/web/data_form.ex index 51bcadcd..b78d276c 100644 --- a/lib/modules/entities/web/data_form.ex +++ b/lib/modules/entities/web/data_form.ex @@ -97,12 +97,13 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do language_tabs = Multilang.build_language_tabs() # Lazy re-key: if global primary changed since this record was saved, - # restructure data + title around the new primary language. + # restructure data around the new primary language. + # Also seed _title into JSONB data for backwards compat. changeset = if multilang_enabled and data_record.id do changeset |> rekey_data_on_mount() - |> rekey_title_on_mount(data_record, primary_language) + |> seed_title_in_data(data_record) else changeset end @@ -270,14 +271,23 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do current_lang = socket.assigns[:current_lang] - # Merge title translations into metadata - data_params = merge_title_translations(data_params, socket.assigns.changeset) + # Inject _title into form data so it flows through merge_multilang_data + form_data = + inject_title_into_form_data(form_data, data_params, current_lang, socket.assigns) # On secondary language tabs, preserve primary-language fields that aren't in the form data_params = preserve_primary_fields(data_params, socket.assigns.changeset) case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do {:ok, validated_data} -> + validated_data = + inject_title_into_form_data( + validated_data, + data_params, + current_lang, + socket.assigns + ) + final_data = merge_multilang_data(socket.assigns, current_lang, validated_data) params = Map.put(data_params, "data", final_data) @@ -324,15 +334,27 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do current_lang = socket.assigns[:current_lang] - # Merge title translations into metadata - data_params = merge_title_translations(data_params, socket.assigns.changeset) + # Inject _title into form data so it flows through merge_multilang_data + form_data = + inject_title_into_form_data(form_data, data_params, current_lang, socket.assigns) # On secondary language tabs, preserve primary-language fields that aren't in the form data_params = preserve_primary_fields(data_params, socket.assigns.changeset) + # Strip lang_title — it's only used by inject_title_into_form_data, not a schema field + data_params = Map.delete(data_params, "lang_title") + # Validate the form data against entity field definitions case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do {:ok, validated_data} -> + validated_data = + inject_title_into_form_data( + validated_data, + data_params, + current_lang, + socket.assigns + ) + final_data = merge_multilang_data(socket.assigns, current_lang, validated_data) # Add metadata to params @@ -648,38 +670,92 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do end end - # Swaps the title column and metadata translations when data was re-keyed. - # Old title goes into translations[old_primary], new primary's title becomes the column value. - defp rekey_title_on_mount(changeset, data_record, global_primary) do - old_data = data_record.data + # Seeds `_title` into the JSONB data column for existing records on mount. + # Handles backwards compat: migrates from metadata["translations"] to data[lang]["_title"]. + defp seed_title_in_data(changeset, data_record) do + data = Ecto.Changeset.get_field(changeset, :data) || %{} - with true <- Multilang.multilang_data?(old_data), - embedded when is_binary(embedded) <- old_data["_primary_language"], - true <- embedded != global_primary do - metadata = Ecto.Changeset.get_field(changeset, :metadata) || %{} - translations = metadata["translations"] || %{} - old_title = Ecto.Changeset.get_field(changeset, :title) - new_title = get_in(translations, [global_primary, "title"]) + if Multilang.multilang_data?(data) do + primary = data["_primary_language"] + primary_data = Map.get(data, primary, %{}) - # Always preserve old title under old primary in translations - updated_trans = Map.put(translations, embedded, %{"title" => old_title}) + if Map.has_key?(primary_data, "_title") do + changeset + else + title = Ecto.Changeset.get_field(changeset, :title) + do_seed_title(changeset, data, data_record, primary, primary_data, title) + end + else + changeset + end + end - if new_title do - # Swap: new primary's title goes to column, remove it from translations - updated_trans = Map.delete(updated_trans, global_primary) - updated_metadata = Map.put(metadata, "translations", updated_trans) + defp do_seed_title(changeset, data, data_record, primary, primary_data, title) do + # Seed primary _title from the title column + updated_primary = Map.put(primary_data, "_title", title || "") + data = Map.put(data, primary, updated_primary) - changeset - |> Ecto.Changeset.put_change(:title, new_title) - |> Ecto.Changeset.put_change(:metadata, updated_metadata) + # Migrate secondary titles from metadata["translations"] + metadata = Ecto.Changeset.get_field(changeset, :metadata) || %{} + {data, metadata} = migrate_title_translations(data, metadata, title) + + changeset = Ecto.Changeset.put_change(changeset, :data, data) + + # Update title column if primary was rekeyed + changeset = maybe_sync_rekeyed_title(changeset, data, data_record, primary, title) + + if metadata != (Ecto.Changeset.get_field(changeset, :metadata) || %{}) do + Ecto.Changeset.put_change(changeset, :metadata, metadata) + else + changeset + end + end + + defp migrate_title_translations(data, metadata, primary_title) do + translations = metadata["translations"] || %{} + + Enum.reduce(translations, {data, metadata}, fn + {lang_code, %{"title" => lang_title}}, {d, m} + when is_binary(lang_title) and lang_title != "" -> + d = put_secondary_title(d, lang_code, lang_title, primary_title) + m = clean_title_translation(m, lang_code) + {d, m} + + _, acc -> + acc + end) + end + + defp put_secondary_title(data, _lang_code, lang_title, primary_title) + when lang_title == primary_title, + do: data + + defp put_secondary_title(data, lang_code, lang_title, _primary_title) do + lang_data = Map.get(data, lang_code, %{}) + Map.put(data, lang_code, Map.put(lang_data, "_title", lang_title)) + end + + defp clean_title_translation(metadata, lang_code) do + cleaned = metadata |> Map.get("translations", %{}) |> Map.delete(lang_code) + + if map_size(cleaned) == 0, + do: Map.delete(metadata, "translations"), + else: Map.put(metadata, "translations", cleaned) + end + + defp maybe_sync_rekeyed_title(changeset, data, data_record, primary, title) do + old_embedded = get_in(data_record.data || %{}, ["_primary_language"]) + + if old_embedded && old_embedded != primary do + new_title = get_in(data, [primary, "_title"]) + + if is_binary(new_title) and new_title != "" and new_title != title do + Ecto.Changeset.put_change(changeset, :title, new_title) else - # No translation for new primary — keep old title in column as fallback, - # but still preserve it under old primary in translations - updated_metadata = Map.put(metadata, "translations", updated_trans) - Ecto.Changeset.put_change(changeset, :metadata, updated_metadata) + changeset end else - _ -> changeset + changeset end end @@ -704,8 +780,9 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do end end - # When on a secondary language tab, the form doesn't submit title/slug/status. - # Preserve their current values so the changeset doesn't clear them. + # When on a secondary language tab, preserve primary-language DB fields. + # Title on secondary tab goes to JSONB _title via inject_title_into_form_data; + # the DB title column must keep the primary language value. @preserve_fields %{"title" => :title, "slug" => :slug, "status" => :status} defp preserve_primary_fields(data_params, changeset) do @@ -719,40 +796,33 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do end) end - defp merge_title_translations(data_params, changeset) do - title_translations = Map.get(data_params, "title_translations", %{}) - existing_metadata = Ecto.Changeset.get_field(changeset, :metadata) || %{} + # Injects _title into form data map so it flows through merge_multilang_data/3. + # On primary tab: _title comes from data_params["title"] (the DB column field). + # On secondary tab: _title comes from data_params["lang_title"] (separate input). + defp inject_title_into_form_data(form_data, data_params, current_lang, assigns) do + if assigns[:multilang_enabled] == true do + primary = assigns[:primary_language] - if map_size(title_translations) == 0 do - # No new translations in this submission, but preserve existing metadata - # (translations may have been set on a different language tab earlier) - if map_size(existing_metadata) > 0 and not Map.has_key?(data_params, "metadata") do - Map.put(data_params, "metadata", existing_metadata) - else - data_params - end - else - existing_trans = existing_metadata["translations"] || %{} - - updated_trans = - Enum.reduce(title_translations, existing_trans, fn {lang_code, title_value}, acc -> - if is_nil(title_value) or title_value == "" do - Map.delete(acc, lang_code) - else - Map.put(acc, lang_code, %{"title" => title_value}) - end - end) - - metadata = - if map_size(updated_trans) == 0 do - Map.delete(existing_metadata, "translations") + title = + if current_lang == primary do + data_params["title"] else - Map.put(existing_metadata, "translations", updated_trans) + data_params["lang_title"] end - data_params - |> Map.delete("title_translations") - |> Map.put("metadata", metadata) + if is_binary(title) do + Map.put(form_data, "_title", title) + else + # No title submitted — preserve existing _title from JSONB data + existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{} + + case Multilang.get_raw_language_data(existing_data, current_lang) do + %{"_title" => existing_title} -> Map.put(form_data, "_title", existing_title) + _ -> form_data + end + end + else + form_data end end @@ -802,9 +872,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataForm do :status, :data, :metadata, - :created_by, - :date_created, - :date_updated + :created_by ]) |> Map.put(:action, :validate) diff --git a/lib/modules/entities/web/data_form.html.heex b/lib/modules/entities/web/data_form.html.heex index 02ac5ffe..9e2b8363 100644 --- a/lib/modules/entities/web/data_form.html.heex +++ b/lib/modules/entities/web/data_form.html.heex @@ -181,8 +181,6 @@ {gettext("Basic Information")} - <% title_translations = - (Ecto.Changeset.get_field(@changeset, :metadata) || %{})["translations"] || %{} %>
<%= if @current_lang == @primary_language do %> <.label for="phoenix_kit_entity_data_title"> @@ -200,14 +198,19 @@ disabled={@readonly?} /> <% else %> + <% lang_data = + Multilang.get_raw_language_data( + Ecto.Changeset.get_field(@changeset, :data), + @current_lang + ) %> <.label for={"phoenix_kit_entity_data_title_#{@current_lang}"}> {gettext("Title")} <.label for="phoenix_kit_entity_data_slug"> {gettext("Slug (URL-friendly identifier)")} + <% slug_disabled = + @readonly? || + (@multilang_enabled && @current_lang != @primary_language) %> diff --git a/test/modules/entities/multilang_test.exs b/test/modules/entities/multilang_test.exs index f594fa18..bee83fc1 100644 --- a/test/modules/entities/multilang_test.exs +++ b/test/modules/entities/multilang_test.exs @@ -285,20 +285,24 @@ defmodule PhoenixKit.Modules.Entities.MultilangTest do } end - test "preserves old primary data untouched" do + test "strips old primary to overrides" 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" - } + # Old primary (en-US) is now secondary — only fields differing from new primary are kept. + # New primary has: name="Acme España", category="Tech", desc="A company" + # Old primary had: name="Acme", category="Tech", desc="A company" + # Only "name" differs → stored as override + assert result["en-US"] == %{"name" => "Acme"} end - test "preserves other languages untouched" do + test "recomputes other secondaries against new primary" do result = Multilang.rekey_primary(multilang_data(), "es-ES") - assert result["fr-FR"] == %{"desc" => "Une entreprise"} + + # fr-FR had override: desc="Une entreprise" + # New primary has: name="Acme España", category="Tech", desc="A company" + # fr-FR full data: name="Acme", category="Tech", desc="Une entreprise" + # Overrides vs new primary: name differs ("Acme" vs "Acme España"), desc differs + assert result["fr-FR"] == %{"name" => "Acme", "desc" => "Une entreprise"} end test "returns data unchanged when already using that primary" do @@ -326,6 +330,26 @@ defmodule PhoenixKit.Modules.Entities.MultilangTest do "category" => "Tech", "desc" => "A company" } + + # Old primary (en-US) now matches de-DE exactly → key removed entirely + refute Map.has_key?(result, "en-US") + end + + test "removes secondary when all fields match new primary" do + # Create data where es-ES has overrides that match what de-DE would promote to + data = %{ + "_primary_language" => "en-US", + "en-US" => %{"name" => "Acme", "color" => "red"}, + "es-ES" => %{"name" => "Acme"} + } + + # Rekey to es-ES: promoted = merge(en-US, es-ES) = %{name: "Acme", color: "red"} + # en-US vs promoted: name same, color same → removed entirely + result = Multilang.rekey_primary(data, "es-ES") + + assert result["_primary_language"] == "es-ES" + assert result["es-ES"] == %{"name" => "Acme", "color" => "red"} + refute Map.has_key?(result, "en-US") end test "is idempotent" do @@ -334,7 +358,7 @@ defmodule PhoenixKit.Modules.Entities.MultilangTest do assert once == twice end - test "round-trip preserves all data" do + test "round-trip preserves all translatable data" do rekeyed = Multilang.rekey_primary(multilang_data(), "es-ES") back = Multilang.rekey_primary(rekeyed, "en-US") @@ -346,6 +370,12 @@ defmodule PhoenixKit.Modules.Entities.MultilangTest do "category" => "Tech", "desc" => "A company" } + + # es-ES becomes overrides-only (name differs from restored primary) + assert back["es-ES"] == %{"name" => "Acme España"} + + # fr-FR still has its override + assert back["fr-FR"] == %{"desc" => "Une entreprise"} end end @@ -366,8 +396,10 @@ defmodule PhoenixKit.Modules.Entities.MultilangTest do result = Multilang.maybe_rekey_data(data) assert result["_primary_language"] == "en-US" - # New primary promoted with all fields from old primary + # New primary promoted: merge(es-ES base, en-US overrides) = name="Acme", category="Tech" assert result["en-US"] == %{"name" => "Acme", "category" => "Tech"} + # Old primary (es-ES) stripped to overrides: only name differs + assert result["es-ES"] == %{"name" => "Acme España"} end test "returns data unchanged when already using global primary" do diff --git a/test/modules/entities/title_translation_test.exs b/test/modules/entities/title_translation_test.exs new file mode 100644 index 00000000..1e479b2d --- /dev/null +++ b/test/modules/entities/title_translation_test.exs @@ -0,0 +1,165 @@ +defmodule PhoenixKit.Modules.Entities.TitleTranslationTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Entities.EntityData + + # Pure-function tests for get_title_translation/2 and get_all_title_translations/1. + # set_title_translation/3 requires DB access and is covered in parent app integration tests. + + # --- Test Fixtures --- + + defp record_with_title_in_data do + %EntityData{ + title: "Acme", + data: %{ + "_primary_language" => "en-US", + "en-US" => %{"name" => "Acme Corp", "_title" => "Acme"}, + "es-ES" => %{"name" => "Acme España", "_title" => "Acme ES"} + }, + metadata: %{} + } + end + + defp record_with_old_metadata_translations do + %EntityData{ + title: "Acme", + data: %{ + "_primary_language" => "en-US", + "en-US" => %{"name" => "Acme Corp"} + }, + metadata: %{ + "translations" => %{ + "es-ES" => %{"title" => "Acme Metadata ES"} + } + } + } + end + + defp record_with_no_translations do + %EntityData{ + title: "Acme", + data: %{ + "_primary_language" => "en-US", + "en-US" => %{"name" => "Acme Corp"} + }, + metadata: %{} + } + end + + defp record_with_flat_data do + %EntityData{ + title: "Acme", + data: %{"name" => "Acme Corp"}, + metadata: %{} + } + end + + defp record_with_nil_data do + %EntityData{ + title: "Acme", + data: nil, + metadata: nil + } + end + + # --- get_title_translation/2 --- + + describe "get_title_translation/2" do + test "returns _title from JSONB data for primary language" do + assert EntityData.get_title_translation(record_with_title_in_data(), "en-US") == "Acme" + end + + test "returns _title from JSONB data for secondary language" do + assert EntityData.get_title_translation(record_with_title_in_data(), "es-ES") == "Acme ES" + end + + test "falls back to metadata translations for unmigrated records" do + assert EntityData.get_title_translation(record_with_old_metadata_translations(), "es-ES") == + "Acme Metadata ES" + end + + test "falls back to title column when no translations exist" do + assert EntityData.get_title_translation(record_with_no_translations(), "es-ES") == "Acme" + end + + test "falls back to title column for unknown language" do + assert EntityData.get_title_translation(record_with_title_in_data(), "de-DE") == "Acme" + end + + test "handles flat (non-multilang) data" do + assert EntityData.get_title_translation(record_with_flat_data(), "en-US") == "Acme" + end + + test "handles nil data" do + assert EntityData.get_title_translation(record_with_nil_data(), "en-US") == "Acme" + end + + test "prefers JSONB _title over metadata translations" do + # Record has _title in data AND old metadata translations + record = %EntityData{ + title: "Fallback", + data: %{ + "_primary_language" => "en-US", + "en-US" => %{"_title" => "From Data"}, + "es-ES" => %{"_title" => "Desde Datos"} + }, + metadata: %{ + "translations" => %{ + "es-ES" => %{"title" => "Desde Metadata"} + } + } + } + + assert EntityData.get_title_translation(record, "es-ES") == "Desde Datos" + end + + test "skips empty _title and falls back" do + record = %EntityData{ + title: "Fallback Title", + data: %{ + "_primary_language" => "en-US", + "en-US" => %{"_title" => ""}, + "es-ES" => %{"_title" => ""} + }, + metadata: %{} + } + + assert EntityData.get_title_translation(record, "en-US") == "Fallback Title" + assert EntityData.get_title_translation(record, "es-ES") == "Fallback Title" + end + + test "secondary language without override inherits primary _title" do + record = %EntityData{ + title: "Acme", + data: %{ + "_primary_language" => "en-US", + "en-US" => %{"name" => "Acme Corp", "_title" => "Acme Products"} + }, + metadata: %{} + } + + # fr-FR has no override, get_language_data merges primary → _title inherited + assert EntityData.get_title_translation(record, "fr-FR") == "Acme Products" + end + end + + # --- get_all_title_translations/1 --- + + describe "get_all_title_translations/1" do + test "returns map with all enabled languages" do + result = EntityData.get_all_title_translations(record_with_title_in_data()) + + # In test env, enabled_languages falls back to ["en-US"] + assert is_map(result) + assert Map.has_key?(result, "en-US") + assert result["en-US"] == "Acme" + end + + test "handles record with no translations" do + result = EntityData.get_all_title_translations(record_with_no_translations()) + + assert is_map(result) + assert result["en-US"] == "Acme" + end + end +end