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