feat: add custom field slug

This commit is contained in:
Moritz 2025-11-13 19:17:18 +01:00
parent bc75a5853a
commit edf8b2b79e
Signed by: moritz
GPG key ID: 1020A035E5DD0824
10 changed files with 756 additions and 9 deletions

View file

@ -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

View file

@ -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