From 0135dafa3a7803871b728abeddb8dfa891016b9f Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 19:17:18 +0100 Subject: [PATCH 1/7] feat: add custom field slug --- docs/database_schema.dbml | 23 +- lib/membership/custom_field.ex | 19 +- .../custom_field/changes/generate_slug.ex | 118 ++++++ lib/mv_web/live/custom_field_live/form.ex | 17 + lib/mv_web/live/custom_field_live/index.ex | 3 +- lib/mv_web/live/custom_field_live/show.ex | 6 +- mix.exs | 3 +- ...251113180429_add_slug_to_custom_fields.exs | 47 +++ .../repo/custom_fields/20251113180429.json | 132 ++++++ test/membership/custom_field_slug_test.exs | 397 ++++++++++++++++++ 10 files changed, 756 insertions(+), 9 deletions(-) create mode 100644 lib/membership/custom_field/changes/generate_slug.ex create mode 100644 priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251113180429.json create mode 100644 test/membership/custom_field_slug_test.exs diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 431e064..33c0647 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -6,7 +6,7 @@ // - https://dbdocs.io // - VS Code Extensions: "DBML Language" or "dbdiagram.io" // -// Version: 1.1 +// Version: 1.2 // Last Updated: 2025-11-13 Project mila_membership_management { @@ -236,6 +236,7 @@ Table custom_field_values { Table custom_fields { id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier'] name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")'] + slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.'] value_type text [not null, note: 'Data type: string | integer | boolean | date | email'] description text [null, note: 'Human-readable description'] immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation'] @@ -243,6 +244,7 @@ Table custom_fields { indexes { name [unique, name: 'custom_fields_unique_name_index'] + slug [unique, name: 'custom_fields_unique_slug_index'] } Note: ''' @@ -252,21 +254,32 @@ Table custom_fields { **Attributes:** - `name`: Unique identifier for the custom field + - `slug`: URL-friendly, human-readable identifier (auto-generated, immutable) - `value_type`: Enforces data type consistency - `description`: Documentation for users/admins - `immutable`: Prevents changes after initial creation (e.g., membership numbers) - `required`: Enforces that all members must have this custom field + **Slug Generation:** + - Automatically generated from `name` on creation + - Immutable after creation (does not change when name is updated) + - Lowercase, spaces replaced with hyphens, special characters removed + - UTF-8 support (ä → a, ß → ss, etc.) + - Used for human-readable identifiers (CSV export/import, API, etc.) + - Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller" + **Constraints:** - `value_type` must be one of: string, integer, boolean, date, email - `name` must be unique across all custom fields + - `slug` must be unique across all custom fields + - `slug` cannot be empty (validated on creation) - Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT) **Examples:** - - Membership Number (string, immutable, required) - - Emergency Contact (string, mutable, optional) - - Certified Trainer (boolean, mutable, optional) - - Certification Date (date, immutable, optional) + - Membership Number (string, immutable, required) → slug: "membership-number" + - Emergency Contact (string, mutable, optional) → slug: "emergency-contact" + - Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer" + - Certification Date (date, immutable, optional) → slug: "certification-date" ''' } diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 90bbcaa..4c84c20 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -9,6 +9,7 @@ defmodule Mv.Membership.CustomField do ## Attributes - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") + - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) - `description` - Optional human-readable description - `immutable` - If true, custom field values cannot be changed after creation @@ -54,8 +55,14 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:create, :read, :update, :destroy] + defaults [:read, :update, :destroy] default_accept [:name, :value_type, :description, :immutable, :required] + + create :create do + accept [:name, :value_type, :description, :immutable, :required] + change Mv.Membership.CustomField.Changes.GenerateSlug + validate string_length(:slug, min: 1) + end end attributes do @@ -69,6 +76,15 @@ defmodule Mv.Membership.CustomField do trim?: true ] + attribute :slug, :string, + allow_nil?: false, + public?: true, + writable?: false, + constraints: [ + max_length: 100, + trim?: true + ] + attribute :value_type, :atom, constraints: [one_of: [:string, :integer, :boolean, :date, :email]], allow_nil?: false, @@ -97,5 +113,6 @@ defmodule Mv.Membership.CustomField do identities do identity :unique_name, [:name] + identity :unique_slug, [:slug] end end diff --git a/lib/membership/custom_field/changes/generate_slug.ex b/lib/membership/custom_field/changes/generate_slug.ex new file mode 100644 index 0000000..061d7e7 --- /dev/null +++ b/lib/membership/custom_field/changes/generate_slug.ex @@ -0,0 +1,118 @@ +defmodule Mv.Membership.CustomField.Changes.GenerateSlug do + @moduledoc """ + Ash Change that automatically generates a URL-friendly slug from the `name` attribute. + + ## Behavior + + - **On Create**: Generates a slug from the name attribute using slugify + - **On Update**: Slug remains unchanged (immutable after creation) + - **Slug Generation**: Uses the `slugify` library to convert name to slug + - Converts to lowercase + - Replaces spaces with hyphens + - Removes special characters + - Handles UTF-8 characters (e.g., ä → a, ß → ss) + - Trims leading/trailing hyphens + - Truncates to max 100 characters + + ## Examples + + # Create with automatic slug generation + CustomField.create!(%{name: "Mobile Phone"}) + # => %CustomField{name: "Mobile Phone", slug: "mobile-phone"} + + # German umlauts are converted + CustomField.create!(%{name: "Café Müller"}) + # => %CustomField{name: "Café Müller", slug: "cafe-muller"} + + # Slug is immutable on update + custom_field = CustomField.create!(%{name: "Original"}) + CustomField.update!(custom_field, %{name: "New Name"}) + # => %CustomField{name: "New Name", slug: "original"} # slug unchanged! + + ## Implementation Note + + This change only runs on `:create` actions. The slug is immutable by design, + as changing slugs would break external references (e.g., CSV imports/exports). + """ + use Ash.Resource.Change + + @doc """ + Generates a slug from the changeset's `name` attribute. + + Only runs on create actions. Returns the changeset unchanged if: + - The action is not :create + - The name is not being changed + - The name is nil or empty + + ## Parameters + + - `changeset` - The Ash changeset + + ## Returns + + The changeset with the `:slug` attribute set to the generated slug. + """ + def change(changeset, _opts, _context) do + # Only generate slug on create, not on update (immutability) + if changeset.action_type == :create do + case Ash.Changeset.get_attribute(changeset, :name) do + nil -> + changeset + + name when is_binary(name) -> + slug = generate_slug(name) + Ash.Changeset.force_change_attribute(changeset, :slug, slug) + end + else + # On update, don't touch the slug (immutable) + changeset + end + end + + @doc """ + Generates a URL-friendly slug from a given string. + + Uses the `slugify` library to create a clean, lowercase slug with: + - Spaces replaced by hyphens + - Special characters removed + - UTF-8 characters transliterated (ä → a, ß → ss, etc.) + - Multiple consecutive hyphens reduced to single hyphen + - Leading/trailing hyphens removed + - Maximum length of 100 characters + + ## Examples + + iex> generate_slug("Mobile Phone") + "mobile-phone" + + iex> generate_slug("Café Müller") + "cafe-muller" + + iex> generate_slug("TEST NAME") + "test-name" + + iex> generate_slug("E-Mail & Address!") + "e-mail-address" + + iex> generate_slug("Multiple Spaces") + "multiple-spaces" + + iex> generate_slug("-Test-") + "test" + + iex> generate_slug("Straße") + "strasse" + + """ + def generate_slug(name) when is_binary(name) do + slug = Slug.slugify(name) + + case slug do + nil -> "" + "" -> "" + slug when is_binary(slug) -> String.slice(slug, 0, 100) + end + end + + def generate_slug(_), do: "" +end diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index b1d3f86..176edc8 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -19,6 +19,9 @@ defmodule MvWeb.CustomFieldLive.Form do - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) + **Read-only (Edit mode only):** + - slug - Auto-generated URL-friendly identifier (immutable) + ## Value Type Selection - `:string` - Text data (unlimited length) - `:integer` - Numeric data @@ -48,6 +51,20 @@ defmodule MvWeb.CustomFieldLive.Form do <.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save"> <.input field={@form[:name]} type="text" label={gettext("Name")} /> + + <%!-- Show slug in edit mode (read-only) --%> +
+ +
+ {@custom_field.slug} +
+

+ {gettext("Auto-generated identifier (immutable)")} +

+
+ <.input field={@form[:value_type]} type="select" diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex index 2870611..bbd8603 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -11,6 +11,7 @@ defmodule MvWeb.CustomFieldLive.Index do - Delete custom fields (if no custom field values use them) ## Displayed Information + - Slug: URL-friendly identifier (auto-generated from name) - Name: Unique identifier for the custom field - Value type: Data type constraint (string, integer, boolean, date, email) - Description: Human-readable explanation @@ -43,7 +44,7 @@ defmodule MvWeb.CustomFieldLive.Index do rows={@streams.custom_fields} row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} > - <:col :let={{_id, custom_field}} label="Id">{custom_field.id} + <:col :let={{_id, custom_field}} label="Slug">{custom_field.slug} <:col :let={{_id, custom_field}} label="Name">{custom_field.name} diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex index 783cb4e..2b2ba65 100644 --- a/lib/mv_web/live/custom_field_live/show.ex +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -9,6 +9,8 @@ defmodule MvWeb.CustomFieldLive.Show do - Return to custom field list ## Displayed Information + - ID: Internal UUID identifier + - Slug: URL-friendly identifier (auto-generated, immutable) - Name: Unique identifier - Value type: Data type constraint - Description: Optional explanation @@ -29,7 +31,7 @@ defmodule MvWeb.CustomFieldLive.Show do ~H""" <.header> - Custom field {@custom_field.id} + Custom field {@custom_field.slug} <:subtitle>This is a custom_field record from your database. <:actions> @@ -48,6 +50,8 @@ defmodule MvWeb.CustomFieldLive.Show do <.list> <:item title="Id">{@custom_field.id} + <:item title="Slug">{@custom_field.slug} + <:item title="Name">{@custom_field.name} <:item title="Description">{@custom_field.description} diff --git a/mix.exs b/mix.exs index b215d59..c6e4fb5 100644 --- a/mix.exs +++ b/mix.exs @@ -75,7 +75,8 @@ defmodule Mv.MixProject do {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:ecto_commons, "~> 0.3"} + {:ecto_commons, "~> 0.3"}, + {:slugify, "~> 1.3"} ] end diff --git a/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs b/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs new file mode 100644 index 0000000..bebf799 --- /dev/null +++ b/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs @@ -0,0 +1,47 @@ +defmodule Mv.Repo.Migrations.AddSlugToCustomFields do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + # Step 1: Add slug column as nullable first + alter table(:custom_fields) do + add :slug, :text, null: true + end + + # Step 2: Generate slugs for existing custom fields + execute(""" + UPDATE custom_fields + SET slug = lower( + regexp_replace( + regexp_replace( + regexp_replace(name, '[^a-zA-Z0-9\\s-]', '', 'g'), + '\\s+', '-', 'g' + ), + '-+', '-', 'g' + ) + ) + WHERE slug IS NULL + """) + + # Step 3: Make slug NOT NULL + alter table(:custom_fields) do + modify :slug, :text, null: false + end + + # Step 4: Create unique index + create unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index") + end + + def down do + drop_if_exists unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index") + + alter table(:custom_fields) do + remove :slug + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20251113180429.json b/priv/resource_snapshots/repo/custom_fields/20251113180429.json new file mode 100644 index 0000000..5a89de9 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251113180429.json @@ -0,0 +1,132 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "immutable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "DB1D3D9F2F76F518CAEEA2CC855996CCD87FC4C8FDD3A37345CEF2980674D8F3", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/test/membership/custom_field_slug_test.exs b/test/membership/custom_field_slug_test.exs new file mode 100644 index 0000000..ae6c42e --- /dev/null +++ b/test/membership/custom_field_slug_test.exs @@ -0,0 +1,397 @@ +defmodule Mv.Membership.CustomFieldSlugTest do + @moduledoc """ + Tests for automatic slug generation on CustomField resource. + + This test suite verifies: + 1. Slugs are automatically generated from the name attribute + 2. Slugs are unique (cannot have duplicates) + 3. Slugs are immutable (don't change when name changes) + 4. Slugs handle various edge cases (unicode, special chars, etc.) + 5. Slugs can be used for lookups + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "automatic slug generation on create" do + test "generates slug from name with simple ASCII text" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Mobile Phone", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "mobile-phone" + end + + test "generates slug from name with German umlauts" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Café Müller", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "cafe-muller" + end + + test "generates slug with lowercase conversion" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "TEST NAME", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "test-name" + end + + test "generates slug by removing special characters" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "E-Mail & Address!", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "e-mail-address" + end + + test "generates slug by replacing multiple spaces with single hyphen" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Multiple Spaces", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "multiple-spaces" + end + + test "trims leading and trailing hyphens" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "-Test-", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "test" + end + + test "handles unicode characters properly (ß becomes ss)" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Straße", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "strasse" + end + end + + describe "slug uniqueness" do + test "prevents creating custom field with duplicate slug" do + # Create first custom field + {:ok, _custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Attempt to create second custom field with same slug (different case in name) + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test", + value_type: :integer + }) + |> Ash.create() + + assert Exception.message(error) =~ "has already been taken" + end + + test "allows custom fields with different slugs" do + {:ok, custom_field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test One", + value_type: :string + }) + |> Ash.create() + + {:ok, custom_field2} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test Two", + value_type: :string + }) + |> Ash.create() + + assert custom_field1.slug == "test-one" + assert custom_field2.slug == "test-two" + assert custom_field1.slug != custom_field2.slug + end + + test "prevents duplicate slugs when names differ only in special characters" do + {:ok, custom_field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test!!!", + value_type: :string + }) + |> Ash.create() + + assert custom_field1.slug == "test" + + # Second custom field with name that generates the same slug should fail + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test???", + value_type: :string + }) + |> Ash.create() + + # Should fail with uniqueness constraint error + assert Exception.message(error) =~ "has already been taken" + end + end + + describe "slug immutability" do + test "slug cannot be manually set on create" do + # Attempting to set slug manually should fail because slug is not writable + result = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string, + slug: "custom-slug" + }) + |> Ash.create() + + # Should fail because slug is not an accepted input + assert {:error, %Ash.Error.Invalid{}} = result + assert Exception.message(elem(result, 1)) =~ "No such input" + end + + test "slug does not change when name is updated" do + # Create custom field + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Original Name", + value_type: :string + }) + |> Ash.create() + + original_slug = custom_field.slug + assert original_slug == "original-name" + + # Update the name + {:ok, updated_custom_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "New Different Name" + }) + |> Ash.update() + + # Slug should remain unchanged + assert updated_custom_field.slug == original_slug + assert updated_custom_field.slug == "original-name" + assert updated_custom_field.name == "New Different Name" + end + + test "slug cannot be manually updated" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + original_slug = custom_field.slug + assert original_slug == "test" + + # Attempt to manually update slug should fail because slug is not writable + result = + custom_field + |> Ash.Changeset.for_update(:update, %{ + slug: "new-slug" + }) + |> Ash.update() + + # Should fail because slug is not an accepted input + assert {:error, %Ash.Error.Invalid{}} = result + assert Exception.message(elem(result, 1)) =~ "No such input" + + # Reload to verify slug hasn't changed + reloaded = Ash.get!(CustomField, custom_field.id) + assert reloaded.slug == "test" + end + end + + describe "slug edge cases" do + test "handles very long names by truncating slug" do + # Create a name at the maximum length (100 chars) + long_name = String.duplicate("abcdefghij", 10) + # 100 characters exactly + + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: long_name, + value_type: :string + }) + |> Ash.create() + + # Slug should be truncated to maximum 100 characters + assert String.length(custom_field.slug) <= 100 + # Should be the full slugified version since name is exactly 100 chars + assert custom_field.slug == long_name + end + + test "rejects name with only special characters" do + # When name contains only special characters, slug would be empty + # This should fail validation + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "!!!", + value_type: :string + }) + |> Ash.create() + + # Should fail because slug would be empty + error_message = Exception.message(error) + assert error_message =~ "Slug cannot be empty" or error_message =~ "is required" + end + + test "handles mixed special characters and text" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test@#$%Name", + value_type: :string + }) + |> Ash.create() + + # slugify keeps the hyphen between words + assert custom_field.slug == "test-name" + end + + test "handles numbers in name" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Field 123 Test", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "field-123-test" + end + + test "handles consecutive hyphens in name" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test---Name", + value_type: :string + }) + |> Ash.create() + + # Should reduce multiple hyphens to single hyphen + assert custom_field.slug == "test-name" + end + + test "handles name with dots and underscores" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test.field_name", + value_type: :string + }) + |> Ash.create() + + # Dots and underscores should be handled (either kept or converted) + assert custom_field.slug =~ ~r/^[a-z0-9-]+$/ + end + end + + describe "slug in queries and responses" do + test "slug is included in struct after create" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Slug should be present in the struct + assert Map.has_key?(custom_field, :slug) + assert custom_field.slug != nil + end + + test "can load custom field and slug is present" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Load it back + loaded_custom_field = Ash.get!(CustomField, custom_field.id) + + assert loaded_custom_field.slug == "test" + end + + test "slug is returned in list queries" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + custom_fields = Ash.read!(CustomField) + + found = Enum.find(custom_fields, &(&1.id == custom_field.id)) + assert found.slug == "test" + end + end + + describe "slug-based lookup (future feature)" do + @tag :skip + test "can find custom field by slug" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test Field", + value_type: :string + }) + |> Ash.create() + + # This test is for future implementation + # We might add a custom action like :by_slug + found = Ash.get!(CustomField, custom_field.slug, load: [:slug]) + assert found.id == custom_field.id + end + end +end From bc75a5853a5a0fa78133713a89549058b9544bcc Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 13:48:05 +0100 Subject: [PATCH 2/7] fix: correction of some english translation --- lib/mv_web/live/custom_field_value_live/form.ex | 2 +- priv/gettext/de/LC_MESSAGES/default.po | 10 +++++----- priv/gettext/default.pot | 10 +++++----- priv/gettext/en/LC_MESSAGES/default.po | 10 +++++----- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/mv_web/live/custom_field_value_live/form.ex b/lib/mv_web/live/custom_field_value_live/form.ex index 7df4c69..4a7b02d 100644 --- a/lib/mv_web/live/custom_field_value_live/form.ex +++ b/lib/mv_web/live/custom_field_value_live/form.ex @@ -39,7 +39,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do <.header> {@page_title} <:subtitle> - {gettext("Use this form to manage custom_field_value records in your database.")} + {gettext("Use this form to manage Custom Field Value records in your database.")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f6acdca..32822bf 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -646,12 +646,12 @@ msgstr "Benutzerdefinierten Feldwert speichern" msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Use this form to manage custom_field_value records in your database." -msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage Custom Field Value records in your database." +msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d150a60..1dca601 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -647,12 +647,12 @@ msgstr "" msgid "Use this form to manage custom_field records in your database." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Use this form to manage custom_field_value records in your database." -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format +msgid "Use this form to manage Custom Field Value records in your database." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index df56e75..e4e1d29 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -647,12 +647,12 @@ msgstr "" msgid "Use this form to manage custom_field records in your database." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format, fuzzy -msgid "Use this form to manage custom_field_value records in your database." -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format, fuzzy msgid "Custom Fields" msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage Custom Field Value records in your database." +msgstr "" From 3bd3a42be4c89bfe398c30924e87bfd0a8c19c08 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 14:13:56 +0100 Subject: [PATCH 3/7] feat: hide slug from user --- lib/mv_web/live/custom_field_live/form.ex | 16 ---------------- lib/mv_web/live/custom_field_live/index.ex | 3 --- lib/mv_web/live/custom_field_live/show.ex | 7 ++++++- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index 176edc8..ab8f104 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -19,9 +19,6 @@ defmodule MvWeb.CustomFieldLive.Form do - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) - **Read-only (Edit mode only):** - - slug - Auto-generated URL-friendly identifier (immutable) - ## Value Type Selection - `:string` - Text data (unlimited length) - `:integer` - Numeric data @@ -52,19 +49,6 @@ defmodule MvWeb.CustomFieldLive.Form do <.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save"> <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <%!-- Show slug in edit mode (read-only) --%> -
- -
- {@custom_field.slug} -
-

- {gettext("Auto-generated identifier (immutable)")} -

-
- <.input field={@form[:value_type]} type="select" diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex index bbd8603..65a3ab3 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -11,7 +11,6 @@ defmodule MvWeb.CustomFieldLive.Index do - Delete custom fields (if no custom field values use them) ## Displayed Information - - Slug: URL-friendly identifier (auto-generated from name) - Name: Unique identifier for the custom field - Value type: Data type constraint (string, integer, boolean, date, email) - Description: Human-readable explanation @@ -44,8 +43,6 @@ defmodule MvWeb.CustomFieldLive.Index do rows={@streams.custom_fields} row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} > - <:col :let={{_id, custom_field}} label="Slug">{custom_field.slug} - <:col :let={{_id, custom_field}} label="Name">{custom_field.name} <:col :let={{_id, custom_field}} label="Description">{custom_field.description} diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex index 2b2ba65..239b844 100644 --- a/lib/mv_web/live/custom_field_live/show.ex +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -50,7 +50,12 @@ defmodule MvWeb.CustomFieldLive.Show do <.list> <:item title="Id">{@custom_field.id} - <:item title="Slug">{@custom_field.slug} + <:item title="Slug"> + {@custom_field.slug} +

+ {gettext("Auto-generated identifier (immutable)")} +

+ <:item title="Name">{@custom_field.name} From 038299140ba98f4ffefb39aeeeb3f71e9a581c8a Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 14:16:34 +0100 Subject: [PATCH 4/7] feat: add translation --- priv/gettext/de/LC_MESSAGES/default.po | 25 +++++++++++++++---------- priv/gettext/default.pot | 25 +++++++++++++++---------- priv/gettext/en/LC_MESSAGES/default.po | 25 +++++++++++++++---------- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f6acdca..d779a49 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -158,7 +158,7 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -203,14 +203,14 @@ msgstr "Dies ist ein Mitglied aus deiner Datenbank." msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -252,7 +252,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:127 @@ -265,7 +265,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -285,7 +285,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -355,7 +355,7 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" @@ -411,7 +411,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -616,7 +616,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -631,7 +631,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -655,3 +655,8 @@ msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Date #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "Automatisch generierter Identifier" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d150a60..b9aa2e0 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -204,14 +204,14 @@ msgstr "" msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +253,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:127 @@ -266,7 +266,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -286,7 +286,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -356,7 +356,7 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" @@ -412,7 +412,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -617,7 +617,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -632,7 +632,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -656,3 +656,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "" + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index df56e75..af24cf0 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -204,14 +204,14 @@ msgstr "" msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +253,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:127 @@ -266,7 +266,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -286,7 +286,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -356,7 +356,7 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" @@ -412,7 +412,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -617,7 +617,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -632,7 +632,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -656,3 +656,8 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Custom Fields" msgstr "" + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" From edf8b2b79e643b6d3af95c7c76cc602069d1f48a Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 19:17:18 +0100 Subject: [PATCH 5/7] feat: add custom field slug --- docs/database_schema.dbml | 23 +- lib/membership/custom_field.ex | 19 +- .../custom_field/changes/generate_slug.ex | 118 ++++++ lib/mv_web/live/custom_field_live/form.ex | 17 + lib/mv_web/live/custom_field_live/index.ex | 3 +- lib/mv_web/live/custom_field_live/show.ex | 6 +- mix.exs | 3 +- ...251113180429_add_slug_to_custom_fields.exs | 47 +++ .../repo/custom_fields/20251113180429.json | 132 ++++++ test/membership/custom_field_slug_test.exs | 397 ++++++++++++++++++ 10 files changed, 756 insertions(+), 9 deletions(-) create mode 100644 lib/membership/custom_field/changes/generate_slug.ex create mode 100644 priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251113180429.json create mode 100644 test/membership/custom_field_slug_test.exs diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 431e064..33c0647 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -6,7 +6,7 @@ // - https://dbdocs.io // - VS Code Extensions: "DBML Language" or "dbdiagram.io" // -// Version: 1.1 +// Version: 1.2 // Last Updated: 2025-11-13 Project mila_membership_management { @@ -236,6 +236,7 @@ Table custom_field_values { Table custom_fields { id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier'] name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")'] + slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.'] value_type text [not null, note: 'Data type: string | integer | boolean | date | email'] description text [null, note: 'Human-readable description'] immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation'] @@ -243,6 +244,7 @@ Table custom_fields { indexes { name [unique, name: 'custom_fields_unique_name_index'] + slug [unique, name: 'custom_fields_unique_slug_index'] } Note: ''' @@ -252,21 +254,32 @@ Table custom_fields { **Attributes:** - `name`: Unique identifier for the custom field + - `slug`: URL-friendly, human-readable identifier (auto-generated, immutable) - `value_type`: Enforces data type consistency - `description`: Documentation for users/admins - `immutable`: Prevents changes after initial creation (e.g., membership numbers) - `required`: Enforces that all members must have this custom field + **Slug Generation:** + - Automatically generated from `name` on creation + - Immutable after creation (does not change when name is updated) + - Lowercase, spaces replaced with hyphens, special characters removed + - UTF-8 support (ä → a, ß → ss, etc.) + - Used for human-readable identifiers (CSV export/import, API, etc.) + - Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller" + **Constraints:** - `value_type` must be one of: string, integer, boolean, date, email - `name` must be unique across all custom fields + - `slug` must be unique across all custom fields + - `slug` cannot be empty (validated on creation) - Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT) **Examples:** - - Membership Number (string, immutable, required) - - Emergency Contact (string, mutable, optional) - - Certified Trainer (boolean, mutable, optional) - - Certification Date (date, immutable, optional) + - Membership Number (string, immutable, required) → slug: "membership-number" + - Emergency Contact (string, mutable, optional) → slug: "emergency-contact" + - Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer" + - Certification Date (date, immutable, optional) → slug: "certification-date" ''' } diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 90bbcaa..4c84c20 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -9,6 +9,7 @@ defmodule Mv.Membership.CustomField do ## Attributes - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") + - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) - `description` - Optional human-readable description - `immutable` - If true, custom field values cannot be changed after creation @@ -54,8 +55,14 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:create, :read, :update, :destroy] + defaults [:read, :update, :destroy] default_accept [:name, :value_type, :description, :immutable, :required] + + create :create do + accept [:name, :value_type, :description, :immutable, :required] + change Mv.Membership.CustomField.Changes.GenerateSlug + validate string_length(:slug, min: 1) + end end attributes do @@ -69,6 +76,15 @@ defmodule Mv.Membership.CustomField do trim?: true ] + attribute :slug, :string, + allow_nil?: false, + public?: true, + writable?: false, + constraints: [ + max_length: 100, + trim?: true + ] + attribute :value_type, :atom, constraints: [one_of: [:string, :integer, :boolean, :date, :email]], allow_nil?: false, @@ -97,5 +113,6 @@ defmodule Mv.Membership.CustomField do identities do identity :unique_name, [:name] + identity :unique_slug, [:slug] end end diff --git a/lib/membership/custom_field/changes/generate_slug.ex b/lib/membership/custom_field/changes/generate_slug.ex new file mode 100644 index 0000000..061d7e7 --- /dev/null +++ b/lib/membership/custom_field/changes/generate_slug.ex @@ -0,0 +1,118 @@ +defmodule Mv.Membership.CustomField.Changes.GenerateSlug do + @moduledoc """ + Ash Change that automatically generates a URL-friendly slug from the `name` attribute. + + ## Behavior + + - **On Create**: Generates a slug from the name attribute using slugify + - **On Update**: Slug remains unchanged (immutable after creation) + - **Slug Generation**: Uses the `slugify` library to convert name to slug + - Converts to lowercase + - Replaces spaces with hyphens + - Removes special characters + - Handles UTF-8 characters (e.g., ä → a, ß → ss) + - Trims leading/trailing hyphens + - Truncates to max 100 characters + + ## Examples + + # Create with automatic slug generation + CustomField.create!(%{name: "Mobile Phone"}) + # => %CustomField{name: "Mobile Phone", slug: "mobile-phone"} + + # German umlauts are converted + CustomField.create!(%{name: "Café Müller"}) + # => %CustomField{name: "Café Müller", slug: "cafe-muller"} + + # Slug is immutable on update + custom_field = CustomField.create!(%{name: "Original"}) + CustomField.update!(custom_field, %{name: "New Name"}) + # => %CustomField{name: "New Name", slug: "original"} # slug unchanged! + + ## Implementation Note + + This change only runs on `:create` actions. The slug is immutable by design, + as changing slugs would break external references (e.g., CSV imports/exports). + """ + use Ash.Resource.Change + + @doc """ + Generates a slug from the changeset's `name` attribute. + + Only runs on create actions. Returns the changeset unchanged if: + - The action is not :create + - The name is not being changed + - The name is nil or empty + + ## Parameters + + - `changeset` - The Ash changeset + + ## Returns + + The changeset with the `:slug` attribute set to the generated slug. + """ + def change(changeset, _opts, _context) do + # Only generate slug on create, not on update (immutability) + if changeset.action_type == :create do + case Ash.Changeset.get_attribute(changeset, :name) do + nil -> + changeset + + name when is_binary(name) -> + slug = generate_slug(name) + Ash.Changeset.force_change_attribute(changeset, :slug, slug) + end + else + # On update, don't touch the slug (immutable) + changeset + end + end + + @doc """ + Generates a URL-friendly slug from a given string. + + Uses the `slugify` library to create a clean, lowercase slug with: + - Spaces replaced by hyphens + - Special characters removed + - UTF-8 characters transliterated (ä → a, ß → ss, etc.) + - Multiple consecutive hyphens reduced to single hyphen + - Leading/trailing hyphens removed + - Maximum length of 100 characters + + ## Examples + + iex> generate_slug("Mobile Phone") + "mobile-phone" + + iex> generate_slug("Café Müller") + "cafe-muller" + + iex> generate_slug("TEST NAME") + "test-name" + + iex> generate_slug("E-Mail & Address!") + "e-mail-address" + + iex> generate_slug("Multiple Spaces") + "multiple-spaces" + + iex> generate_slug("-Test-") + "test" + + iex> generate_slug("Straße") + "strasse" + + """ + def generate_slug(name) when is_binary(name) do + slug = Slug.slugify(name) + + case slug do + nil -> "" + "" -> "" + slug when is_binary(slug) -> String.slice(slug, 0, 100) + end + end + + def generate_slug(_), do: "" +end diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index b1d3f86..176edc8 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -19,6 +19,9 @@ defmodule MvWeb.CustomFieldLive.Form do - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) + **Read-only (Edit mode only):** + - slug - Auto-generated URL-friendly identifier (immutable) + ## Value Type Selection - `:string` - Text data (unlimited length) - `:integer` - Numeric data @@ -48,6 +51,20 @@ defmodule MvWeb.CustomFieldLive.Form do <.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save"> <.input field={@form[:name]} type="text" label={gettext("Name")} /> + + <%!-- Show slug in edit mode (read-only) --%> +
+ +
+ {@custom_field.slug} +
+

+ {gettext("Auto-generated identifier (immutable)")} +

+
+ <.input field={@form[:value_type]} type="select" diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex index 2870611..bbd8603 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -11,6 +11,7 @@ defmodule MvWeb.CustomFieldLive.Index do - Delete custom fields (if no custom field values use them) ## Displayed Information + - Slug: URL-friendly identifier (auto-generated from name) - Name: Unique identifier for the custom field - Value type: Data type constraint (string, integer, boolean, date, email) - Description: Human-readable explanation @@ -43,7 +44,7 @@ defmodule MvWeb.CustomFieldLive.Index do rows={@streams.custom_fields} row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} > - <:col :let={{_id, custom_field}} label="Id">{custom_field.id} + <:col :let={{_id, custom_field}} label="Slug">{custom_field.slug} <:col :let={{_id, custom_field}} label="Name">{custom_field.name} diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex index 783cb4e..2b2ba65 100644 --- a/lib/mv_web/live/custom_field_live/show.ex +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -9,6 +9,8 @@ defmodule MvWeb.CustomFieldLive.Show do - Return to custom field list ## Displayed Information + - ID: Internal UUID identifier + - Slug: URL-friendly identifier (auto-generated, immutable) - Name: Unique identifier - Value type: Data type constraint - Description: Optional explanation @@ -29,7 +31,7 @@ defmodule MvWeb.CustomFieldLive.Show do ~H""" <.header> - Custom field {@custom_field.id} + Custom field {@custom_field.slug} <:subtitle>This is a custom_field record from your database. <:actions> @@ -48,6 +50,8 @@ defmodule MvWeb.CustomFieldLive.Show do <.list> <:item title="Id">{@custom_field.id} + <:item title="Slug">{@custom_field.slug} + <:item title="Name">{@custom_field.name} <:item title="Description">{@custom_field.description} diff --git a/mix.exs b/mix.exs index b215d59..c6e4fb5 100644 --- a/mix.exs +++ b/mix.exs @@ -75,7 +75,8 @@ defmodule Mv.MixProject do {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:ecto_commons, "~> 0.3"} + {:ecto_commons, "~> 0.3"}, + {:slugify, "~> 1.3"} ] end diff --git a/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs b/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs new file mode 100644 index 0000000..bebf799 --- /dev/null +++ b/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs @@ -0,0 +1,47 @@ +defmodule Mv.Repo.Migrations.AddSlugToCustomFields do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + # Step 1: Add slug column as nullable first + alter table(:custom_fields) do + add :slug, :text, null: true + end + + # Step 2: Generate slugs for existing custom fields + execute(""" + UPDATE custom_fields + SET slug = lower( + regexp_replace( + regexp_replace( + regexp_replace(name, '[^a-zA-Z0-9\\s-]', '', 'g'), + '\\s+', '-', 'g' + ), + '-+', '-', 'g' + ) + ) + WHERE slug IS NULL + """) + + # Step 3: Make slug NOT NULL + alter table(:custom_fields) do + modify :slug, :text, null: false + end + + # Step 4: Create unique index + create unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index") + end + + def down do + drop_if_exists unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index") + + alter table(:custom_fields) do + remove :slug + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20251113180429.json b/priv/resource_snapshots/repo/custom_fields/20251113180429.json new file mode 100644 index 0000000..5a89de9 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251113180429.json @@ -0,0 +1,132 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "immutable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "DB1D3D9F2F76F518CAEEA2CC855996CCD87FC4C8FDD3A37345CEF2980674D8F3", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/test/membership/custom_field_slug_test.exs b/test/membership/custom_field_slug_test.exs new file mode 100644 index 0000000..ae6c42e --- /dev/null +++ b/test/membership/custom_field_slug_test.exs @@ -0,0 +1,397 @@ +defmodule Mv.Membership.CustomFieldSlugTest do + @moduledoc """ + Tests for automatic slug generation on CustomField resource. + + This test suite verifies: + 1. Slugs are automatically generated from the name attribute + 2. Slugs are unique (cannot have duplicates) + 3. Slugs are immutable (don't change when name changes) + 4. Slugs handle various edge cases (unicode, special chars, etc.) + 5. Slugs can be used for lookups + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "automatic slug generation on create" do + test "generates slug from name with simple ASCII text" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Mobile Phone", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "mobile-phone" + end + + test "generates slug from name with German umlauts" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Café Müller", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "cafe-muller" + end + + test "generates slug with lowercase conversion" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "TEST NAME", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "test-name" + end + + test "generates slug by removing special characters" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "E-Mail & Address!", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "e-mail-address" + end + + test "generates slug by replacing multiple spaces with single hyphen" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Multiple Spaces", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "multiple-spaces" + end + + test "trims leading and trailing hyphens" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "-Test-", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "test" + end + + test "handles unicode characters properly (ß becomes ss)" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Straße", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "strasse" + end + end + + describe "slug uniqueness" do + test "prevents creating custom field with duplicate slug" do + # Create first custom field + {:ok, _custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Attempt to create second custom field with same slug (different case in name) + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test", + value_type: :integer + }) + |> Ash.create() + + assert Exception.message(error) =~ "has already been taken" + end + + test "allows custom fields with different slugs" do + {:ok, custom_field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test One", + value_type: :string + }) + |> Ash.create() + + {:ok, custom_field2} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test Two", + value_type: :string + }) + |> Ash.create() + + assert custom_field1.slug == "test-one" + assert custom_field2.slug == "test-two" + assert custom_field1.slug != custom_field2.slug + end + + test "prevents duplicate slugs when names differ only in special characters" do + {:ok, custom_field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test!!!", + value_type: :string + }) + |> Ash.create() + + assert custom_field1.slug == "test" + + # Second custom field with name that generates the same slug should fail + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test???", + value_type: :string + }) + |> Ash.create() + + # Should fail with uniqueness constraint error + assert Exception.message(error) =~ "has already been taken" + end + end + + describe "slug immutability" do + test "slug cannot be manually set on create" do + # Attempting to set slug manually should fail because slug is not writable + result = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string, + slug: "custom-slug" + }) + |> Ash.create() + + # Should fail because slug is not an accepted input + assert {:error, %Ash.Error.Invalid{}} = result + assert Exception.message(elem(result, 1)) =~ "No such input" + end + + test "slug does not change when name is updated" do + # Create custom field + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Original Name", + value_type: :string + }) + |> Ash.create() + + original_slug = custom_field.slug + assert original_slug == "original-name" + + # Update the name + {:ok, updated_custom_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "New Different Name" + }) + |> Ash.update() + + # Slug should remain unchanged + assert updated_custom_field.slug == original_slug + assert updated_custom_field.slug == "original-name" + assert updated_custom_field.name == "New Different Name" + end + + test "slug cannot be manually updated" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + original_slug = custom_field.slug + assert original_slug == "test" + + # Attempt to manually update slug should fail because slug is not writable + result = + custom_field + |> Ash.Changeset.for_update(:update, %{ + slug: "new-slug" + }) + |> Ash.update() + + # Should fail because slug is not an accepted input + assert {:error, %Ash.Error.Invalid{}} = result + assert Exception.message(elem(result, 1)) =~ "No such input" + + # Reload to verify slug hasn't changed + reloaded = Ash.get!(CustomField, custom_field.id) + assert reloaded.slug == "test" + end + end + + describe "slug edge cases" do + test "handles very long names by truncating slug" do + # Create a name at the maximum length (100 chars) + long_name = String.duplicate("abcdefghij", 10) + # 100 characters exactly + + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: long_name, + value_type: :string + }) + |> Ash.create() + + # Slug should be truncated to maximum 100 characters + assert String.length(custom_field.slug) <= 100 + # Should be the full slugified version since name is exactly 100 chars + assert custom_field.slug == long_name + end + + test "rejects name with only special characters" do + # When name contains only special characters, slug would be empty + # This should fail validation + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "!!!", + value_type: :string + }) + |> Ash.create() + + # Should fail because slug would be empty + error_message = Exception.message(error) + assert error_message =~ "Slug cannot be empty" or error_message =~ "is required" + end + + test "handles mixed special characters and text" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test@#$%Name", + value_type: :string + }) + |> Ash.create() + + # slugify keeps the hyphen between words + assert custom_field.slug == "test-name" + end + + test "handles numbers in name" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Field 123 Test", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "field-123-test" + end + + test "handles consecutive hyphens in name" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test---Name", + value_type: :string + }) + |> Ash.create() + + # Should reduce multiple hyphens to single hyphen + assert custom_field.slug == "test-name" + end + + test "handles name with dots and underscores" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test.field_name", + value_type: :string + }) + |> Ash.create() + + # Dots and underscores should be handled (either kept or converted) + assert custom_field.slug =~ ~r/^[a-z0-9-]+$/ + end + end + + describe "slug in queries and responses" do + test "slug is included in struct after create" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Slug should be present in the struct + assert Map.has_key?(custom_field, :slug) + assert custom_field.slug != nil + end + + test "can load custom field and slug is present" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Load it back + loaded_custom_field = Ash.get!(CustomField, custom_field.id) + + assert loaded_custom_field.slug == "test" + end + + test "slug is returned in list queries" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + custom_fields = Ash.read!(CustomField) + + found = Enum.find(custom_fields, &(&1.id == custom_field.id)) + assert found.slug == "test" + end + end + + describe "slug-based lookup (future feature)" do + @tag :skip + test "can find custom field by slug" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test Field", + value_type: :string + }) + |> Ash.create() + + # This test is for future implementation + # We might add a custom action like :by_slug + found = Ash.get!(CustomField, custom_field.slug, load: [:slug]) + assert found.id == custom_field.id + end + end +end From c246ca59dbe79eddb785c53512a48d356e224f8a Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 14:13:56 +0100 Subject: [PATCH 6/7] feat: hide slug from user --- lib/mv_web/live/custom_field_live/form.ex | 16 ---------------- lib/mv_web/live/custom_field_live/index.ex | 3 --- lib/mv_web/live/custom_field_live/show.ex | 7 ++++++- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index 176edc8..ab8f104 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -19,9 +19,6 @@ defmodule MvWeb.CustomFieldLive.Form do - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) - **Read-only (Edit mode only):** - - slug - Auto-generated URL-friendly identifier (immutable) - ## Value Type Selection - `:string` - Text data (unlimited length) - `:integer` - Numeric data @@ -52,19 +49,6 @@ defmodule MvWeb.CustomFieldLive.Form do <.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save"> <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <%!-- Show slug in edit mode (read-only) --%> -
- -
- {@custom_field.slug} -
-

- {gettext("Auto-generated identifier (immutable)")} -

-
- <.input field={@form[:value_type]} type="select" diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex index bbd8603..65a3ab3 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -11,7 +11,6 @@ defmodule MvWeb.CustomFieldLive.Index do - Delete custom fields (if no custom field values use them) ## Displayed Information - - Slug: URL-friendly identifier (auto-generated from name) - Name: Unique identifier for the custom field - Value type: Data type constraint (string, integer, boolean, date, email) - Description: Human-readable explanation @@ -44,8 +43,6 @@ defmodule MvWeb.CustomFieldLive.Index do rows={@streams.custom_fields} row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} > - <:col :let={{_id, custom_field}} label="Slug">{custom_field.slug} - <:col :let={{_id, custom_field}} label="Name">{custom_field.name} <:col :let={{_id, custom_field}} label="Description">{custom_field.description} diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex index 2b2ba65..239b844 100644 --- a/lib/mv_web/live/custom_field_live/show.ex +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -50,7 +50,12 @@ defmodule MvWeb.CustomFieldLive.Show do <.list> <:item title="Id">{@custom_field.id} - <:item title="Slug">{@custom_field.slug} + <:item title="Slug"> + {@custom_field.slug} +

+ {gettext("Auto-generated identifier (immutable)")} +

+ <:item title="Name">{@custom_field.name} From efb3e1cc37b7a43ffde8827ef588a8ebd6495b79 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 14:16:34 +0100 Subject: [PATCH 7/7] feat: add translation --- priv/gettext/de/LC_MESSAGES/default.po | 25 +++++++++++++++---------- priv/gettext/default.pot | 25 +++++++++++++++---------- priv/gettext/en/LC_MESSAGES/default.po | 25 +++++++++++++++---------- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 32822bf..527a279 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -158,7 +158,7 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -203,14 +203,14 @@ msgstr "Dies ist ein Mitglied aus deiner Datenbank." msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -252,7 +252,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:127 @@ -265,7 +265,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -285,7 +285,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -355,7 +355,7 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" @@ -411,7 +411,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -616,7 +616,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -631,7 +631,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -655,3 +655,8 @@ msgstr "Benutzerdefinierte Felder" #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage Custom Field Value records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "Automatisch generierter Identifier" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 1dca601..6035e4a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -204,14 +204,14 @@ msgstr "" msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +253,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:127 @@ -266,7 +266,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -286,7 +286,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -356,7 +356,7 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" @@ -412,7 +412,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -617,7 +617,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -632,7 +632,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -656,3 +656,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Use this form to manage Custom Field Value records in your database." msgstr "" + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e4e1d29..cbc0a5d 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -204,14 +204,14 @@ msgstr "" msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +253,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:127 @@ -266,7 +266,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -286,7 +286,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -356,7 +356,7 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" @@ -412,7 +412,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -617,7 +617,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -632,7 +632,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -656,3 +656,8 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage Custom Field Value records in your database." msgstr "" + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr ""