defmodule Mv.Membership.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 ## Usage Works for any resource with `name` and `slug` attributes. Used by CustomField and Group resources. create :create do accept [:name, :description] change Mv.Membership.Changes.GenerateSlug validate string_length(:slug, min: 1) end ## Examples # Create with automatic slug generation CustomField.create!(%{name: "Mobile Phone"}) # => %CustomField{name: "Mobile Phone", slug: "mobile-phone"} Group.create!(%{name: "Test Group"}) # => %Group{name: "Test Group", slug: "test-group"} # 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, URL routes). """ 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 - `_opts` - Options passed to the change (unused) - `_context` - Ash context map (unused) ## Returns The changeset with the `:slug` attribute set to the generated slug. """ @impl true 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) _ -> changeset 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 ## Parameters - `name` - The string to convert to a slug ## Returns A URL-friendly slug string, or empty string if input is invalid. ## 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" """ @spec generate_slug(String.t()) :: String.t() 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