Merge pull request 'Custom Fields: Add slugs closes #195' (#205) from feature/custom-field-slug into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #205
This commit is contained in:
moritz 2025-11-20 14:27:57 +01:00
commit 21ec86839a
13 changed files with 788 additions and 40 deletions

View file

@ -6,7 +6,7 @@
// - https://dbdocs.io // - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io" // - VS Code Extensions: "DBML Language" or "dbdiagram.io"
// //
// Version: 1.1 // Version: 1.2
// Last Updated: 2025-11-13 // Last Updated: 2025-11-13
Project mila_membership_management { Project mila_membership_management {
@ -236,6 +236,7 @@ Table custom_field_values {
Table custom_fields { Table custom_fields {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier'] 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")'] 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'] value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
description text [null, note: 'Human-readable description'] description text [null, note: 'Human-readable description']
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation'] immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
@ -243,6 +244,7 @@ Table custom_fields {
indexes { indexes {
name [unique, name: 'custom_fields_unique_name_index'] name [unique, name: 'custom_fields_unique_name_index']
slug [unique, name: 'custom_fields_unique_slug_index']
} }
Note: ''' Note: '''
@ -252,21 +254,32 @@ Table custom_fields {
**Attributes:** **Attributes:**
- `name`: Unique identifier for the custom field - `name`: Unique identifier for the custom field
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
- `value_type`: Enforces data type consistency - `value_type`: Enforces data type consistency
- `description`: Documentation for users/admins - `description`: Documentation for users/admins
- `immutable`: Prevents changes after initial creation (e.g., membership numbers) - `immutable`: Prevents changes after initial creation (e.g., membership numbers)
- `required`: Enforces that all members must have this custom field - `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:** **Constraints:**
- `value_type` must be one of: string, integer, boolean, date, email - `value_type` must be one of: string, integer, boolean, date, email
- `name` must be unique across all custom fields - `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) - Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
**Examples:** **Examples:**
- Membership Number (string, immutable, required) - Membership Number (string, immutable, required) → slug: "membership-number"
- Emergency Contact (string, mutable, optional) - Emergency Contact (string, mutable, optional) → slug: "emergency-contact"
- Certified Trainer (boolean, mutable, optional) - Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer"
- Certification Date (date, immutable, optional) - Certification Date (date, immutable, optional) → slug: "certification-date"
''' '''
} }

View file

@ -9,6 +9,7 @@ defmodule Mv.Membership.CustomField do
## Attributes ## Attributes
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `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`) - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `description` - Optional human-readable description - `description` - Optional human-readable description
- `immutable` - If true, custom field values cannot be changed after creation - `immutable` - If true, custom field values cannot be changed after creation
@ -54,8 +55,14 @@ defmodule Mv.Membership.CustomField do
end end
actions do actions do
defaults [:create, :read, :update, :destroy] defaults [:read, :update, :destroy]
default_accept [:name, :value_type, :description, :immutable, :required] 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 end
attributes do attributes do
@ -69,6 +76,15 @@ defmodule Mv.Membership.CustomField do
trim?: true trim?: true
] ]
attribute :slug, :string,
allow_nil?: false,
public?: true,
writable?: false,
constraints: [
max_length: 100,
trim?: true
]
attribute :value_type, :atom, attribute :value_type, :atom,
constraints: [one_of: [:string, :integer, :boolean, :date, :email]], constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
allow_nil?: false, allow_nil?: false,
@ -97,5 +113,6 @@ defmodule Mv.Membership.CustomField do
identities do identities do
identity :unique_name, [:name] identity :unique_name, [:name]
identity :unique_slug, [:slug]
end end
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

View file

@ -48,6 +48,7 @@ defmodule MvWeb.CustomFieldLive.Form do
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} /> <.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input <.input
field={@form[:value_type]} field={@form[:value_type]}
type="select" type="select"

View file

@ -43,8 +43,6 @@ defmodule MvWeb.CustomFieldLive.Index do
rows={@streams.custom_fields} rows={@streams.custom_fields}
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} 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>
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col> <:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col> <:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>

View file

@ -9,6 +9,8 @@ defmodule MvWeb.CustomFieldLive.Show do
- Return to custom field list - Return to custom field list
## Displayed Information ## Displayed Information
- ID: Internal UUID identifier
- Slug: URL-friendly identifier (auto-generated, immutable)
- Name: Unique identifier - Name: Unique identifier
- Value type: Data type constraint - Value type: Data type constraint
- Description: Optional explanation - Description: Optional explanation
@ -29,7 +31,7 @@ defmodule MvWeb.CustomFieldLive.Show do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
Custom field {@custom_field.id} Custom field {@custom_field.slug}
<:subtitle>This is a custom_field record from your database.</:subtitle> <:subtitle>This is a custom_field record from your database.</:subtitle>
<:actions> <:actions>
@ -48,6 +50,13 @@ defmodule MvWeb.CustomFieldLive.Show do
<.list> <.list>
<:item title="Id">{@custom_field.id}</:item> <:item title="Id">{@custom_field.id}</:item>
<:item title="Slug">
{@custom_field.slug}
<p class="mt-2 text-sm leading-6 text-zinc-600">
{gettext("Auto-generated identifier (immutable)")}
</p>
</:item>
<:item title="Name">{@custom_field.name}</:item> <:item title="Name">{@custom_field.name}</:item>
<:item title="Description">{@custom_field.description}</:item> <:item title="Description">{@custom_field.description}</:item>

View file

@ -75,7 +75,8 @@ defmodule Mv.MixProject do
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", 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 end

View file

@ -158,7 +158,7 @@ msgstr "Postleitzahl"
msgid "Save Member" msgid "Save Member"
msgstr "Mitglied speichern" 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/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124 #: lib/mv_web/live/user_live/form.ex:124
@ -203,14 +203,14 @@ msgstr "Dies ist ein Mitglied aus deiner Datenbank."
msgid "Yes" msgid "Yes"
msgstr "Ja" 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/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "erstellt" 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/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -252,7 +252,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt"
msgid "Your password has successfully been reset" msgid "Your password has successfully been reset"
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" 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/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127 #: lib/mv_web/live/user_live/form.ex:127
@ -265,7 +265,7 @@ msgstr "Abbrechen"
msgid "Choose a member" msgid "Choose a member"
msgstr "Mitglied auswählen" 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 #, elixir-autogen, elixir-format
msgid "Description" msgid "Description"
msgstr "Beschreibung" msgstr "Beschreibung"
@ -285,7 +285,7 @@ msgstr "Aktiviert"
msgid "ID" msgid "ID"
msgstr "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 #, elixir-autogen, elixir-format
msgid "Immutable" msgid "Immutable"
msgstr "Unveränderlich" msgstr "Unveränderlich"
@ -355,7 +355,7 @@ msgstr "Passwort-Authentifizierung"
msgid "Profil" msgid "Profil"
msgstr "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 #, elixir-autogen, elixir-format
msgid "Required" msgid "Required"
msgstr "Erforderlich" msgstr "Erforderlich"
@ -411,7 +411,7 @@ msgstr "Benutzer*in"
msgid "Value" msgid "Value"
msgstr "Wert" 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 #, elixir-autogen, elixir-format
msgid "Value type" msgid "Value type"
msgstr "Wertetyp" msgstr "Wertetyp"
@ -616,7 +616,7 @@ msgstr "Benutzerdefinierte Feldwerte"
msgid "Custom field" msgid "Custom field"
msgstr "Benutzerdefiniertes Feld" 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 #, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully" msgid "Custom field %{action} successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
@ -631,7 +631,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" 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 #, elixir-autogen, elixir-format
msgid "Save Custom field" msgid "Save Custom field"
msgstr "Benutzerdefiniertes Feld speichern" msgstr "Benutzerdefiniertes Feld speichern"
@ -655,3 +655,8 @@ msgstr "Benutzerdefinierte Felder"
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage Custom Field Value records in your database." 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." 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"

View file

@ -159,7 +159,7 @@ msgstr ""
msgid "Save Member" msgid "Save Member"
msgstr "" 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/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124 #: lib/mv_web/live/user_live/form.ex:124
@ -204,14 +204,14 @@ msgstr ""
msgid "Yes" msgid "Yes"
msgstr "" 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/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "" 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/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -253,7 +253,7 @@ msgstr ""
msgid "Your password has successfully been reset" msgid "Your password has successfully been reset"
msgstr "" 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/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127 #: lib/mv_web/live/user_live/form.ex:127
@ -266,7 +266,7 @@ msgstr ""
msgid "Choose a member" msgid "Choose a member"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Description" msgid "Description"
msgstr "" msgstr ""
@ -286,7 +286,7 @@ msgstr ""
msgid "ID" msgid "ID"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Immutable" msgid "Immutable"
msgstr "" msgstr ""
@ -356,7 +356,7 @@ msgstr ""
msgid "Profil" msgid "Profil"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Required" msgid "Required"
msgstr "" msgstr ""
@ -412,7 +412,7 @@ msgstr ""
msgid "Value" msgid "Value"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Value type" msgid "Value type"
msgstr "" msgstr ""
@ -617,7 +617,7 @@ msgstr ""
msgid "Custom field" msgid "Custom field"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully" msgid "Custom field %{action} successfully"
msgstr "" msgstr ""
@ -632,7 +632,7 @@ msgstr ""
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Save Custom field" msgid "Save Custom field"
msgstr "" msgstr ""
@ -656,3 +656,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Use this form to manage Custom Field Value records in your database." msgid "Use this form to manage Custom Field Value records in your database."
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)"
msgstr ""

View file

@ -159,7 +159,7 @@ msgstr ""
msgid "Save Member" msgid "Save Member"
msgstr "" 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/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:124 #: lib/mv_web/live/user_live/form.ex:124
@ -204,14 +204,14 @@ msgstr ""
msgid "Yes" msgid "Yes"
msgstr "" 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/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138 #: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "" 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/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139 #: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -253,7 +253,7 @@ msgstr ""
msgid "Your password has successfully been reset" msgid "Your password has successfully been reset"
msgstr "" 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/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/member_live/form.ex:82
#: lib/mv_web/live/user_live/form.ex:127 #: lib/mv_web/live/user_live/form.ex:127
@ -266,7 +266,7 @@ msgstr ""
msgid "Choose a member" msgid "Choose a member"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Description" msgid "Description"
msgstr "" msgstr ""
@ -286,7 +286,7 @@ msgstr ""
msgid "ID" msgid "ID"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Immutable" msgid "Immutable"
msgstr "" msgstr ""
@ -356,7 +356,7 @@ msgstr ""
msgid "Profil" msgid "Profil"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Required" msgid "Required"
msgstr "" msgstr ""
@ -412,7 +412,7 @@ msgstr ""
msgid "Value" msgid "Value"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Value type" msgid "Value type"
msgstr "" msgstr ""
@ -617,7 +617,7 @@ msgstr ""
msgid "Custom field" msgid "Custom field"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully" msgid "Custom field %{action} successfully"
msgstr "" msgstr ""
@ -632,7 +632,7 @@ msgstr ""
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "" 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 #, elixir-autogen, elixir-format
msgid "Save Custom field" msgid "Save Custom field"
msgstr "" msgstr ""
@ -656,3 +656,8 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage Custom Field Value records in your database." msgid "Use this form to manage Custom Field Value records in your database."
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)"
msgstr ""

View file

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

View file

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

View file

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