Compare commits

..

1 commit

Author SHA1 Message Date
dec7550420 chore: add migration for show in overview flag
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-19 17:27:26 +01:00
20 changed files with 70 additions and 1741 deletions

View file

@ -6,7 +6,7 @@
// - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
//
// Version: 1.2
// Version: 1.1
// Last Updated: 2025-11-13
Project mila_membership_management {
@ -236,7 +236,6 @@ 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']
@ -244,7 +243,6 @@ Table custom_fields {
indexes {
name [unique, name: 'custom_fields_unique_name_index']
slug [unique, name: 'custom_fields_unique_slug_index']
}
Note: '''
@ -254,32 +252,21 @@ 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) → 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"
- Membership Number (string, immutable, required)
- Emergency Contact (string, mutable, optional)
- Certified Trainer (boolean, mutable, optional)
- Certification Date (date, immutable, optional)
'''
}

View file

@ -9,7 +9,6 @@ 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
@ -28,10 +27,7 @@ defmodule Mv.Membership.CustomField do
## Constraints
- Name must be unique across all custom fields
- Name maximum length: 100 characters
- Deleting a custom field will cascade delete all associated custom field values
## Calculations
- `assigned_members_count` - Returns the number of distinct members with values for this custom field
- Cannot delete a custom field that has existing custom field values (RESTRICT)
## Examples
# Create a new custom field
@ -58,25 +54,8 @@ defmodule Mv.Membership.CustomField do
end
actions do
defaults [:read, :update]
defaults [:create, :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
destroy :destroy_with_values do
primary? true
end
read :prepare_deletion do
argument :id, :uuid, allow_nil?: false
filter expr(id == ^arg(:id))
prepare build(load: [:assigned_members_count])
end
end
attributes do
@ -90,15 +69,6 @@ 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,
@ -125,19 +95,7 @@ defmodule Mv.Membership.CustomField do
has_many :custom_field_values, Mv.Membership.CustomFieldValue
end
calculations do
calculate :assigned_members_count,
:integer,
expr(
fragment(
"(SELECT COUNT(DISTINCT member_id) FROM custom_field_values WHERE custom_field_id = ?)",
id
)
)
end
identities do
identity :unique_name, [:name]
identity :unique_slug, [:slug]
end
end

View file

@ -1,118 +0,0 @@
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

@ -25,12 +25,11 @@ defmodule Mv.Membership.CustomFieldValue do
## Relationships
- `belongs_to :member` - The member this custom field value belongs to (CASCADE delete)
- `belongs_to :custom_field` - The custom field definition (CASCADE delete)
- `belongs_to :custom_field` - The custom field definition
## Constraints
- Each member can have only one custom field value per custom field (unique composite index)
- Custom field values are deleted when the associated member is deleted (CASCADE)
- Custom field values are deleted when the associated custom field is deleted (CASCADE)
- String values maximum length: 10,000 characters
- Email values maximum length: 254 characters (RFC 5321)
@ -47,19 +46,12 @@ defmodule Mv.Membership.CustomFieldValue do
references do
reference :member, on_delete: :delete
reference :custom_field, on_delete: :delete
end
end
actions do
defaults [:create, :read, :update, :destroy]
default_accept [:value, :member_id, :custom_field_id]
read :by_custom_field_id do
argument :custom_field_id, :uuid, allow_nil?: false
filter expr(custom_field_id == ^arg(:custom_field_id))
end
end
attributes do

View file

@ -42,8 +42,7 @@ defmodule Mv.Membership do
define :create_custom_field, action: :create
define :list_custom_fields, action: :read
define :update_custom_field, action: :update
define :destroy_custom_field, action: :destroy_with_values
define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id]
define :destroy_custom_field, action: :destroy
end
end
end

View file

@ -48,7 +48,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")} />
<.input
field={@form[:value_type]}
type="select"

View file

@ -8,7 +8,7 @@ defmodule MvWeb.CustomFieldLive.Index do
- Show immutable and required flags
- Create new custom fields
- Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values)
- Delete custom fields (if no custom field values use them)
## Displayed Information
- Name: Unique identifier for the custom field
@ -18,14 +18,10 @@ defmodule MvWeb.CustomFieldLive.Index do
- Required: Whether all members must have this custom field (future feature)
## Events
- `prepare_delete` - Opens deletion confirmation modal with member count
- `confirm_delete` - Executes deletion after slug verification
- `cancel_delete` - Cancels deletion and closes modal
- `update_slug_confirmation` - Updates slug input state
- `delete` - Remove a custom field (only if no custom field values exist)
## Security
Custom field management is restricted to admin users.
Deletion requires entering the custom field's slug to prevent accidental deletions.
"""
use MvWeb, :live_view
@ -47,6 +43,8 @@ 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>
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
@ -59,76 +57,15 @@ defmodule MvWeb.CustomFieldLive.Index do
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}>
<:action :let={{id, custom_field}}>
<.link
phx-click={JS.push("delete", value: %{id: custom_field.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg">{gettext("Delete Custom Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<div>
<p class="font-semibold">
{ngettext(
"%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this custom field.",
@custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count
)}
</p>
<p class="text-sm mt-2">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
)}
</p>
</div>
</div>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="font-mono font-bold text-lg mb-2 p-2 bg-base-200 rounded break-all">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation">
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="input input-bordered w-full"
/>
</form>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
</div>
</div>
</dialog>
</Layouts.app>
"""
end
@ -138,62 +75,14 @@ defmodule MvWeb.CustomFieldLive.Index do
{:ok,
socket
|> assign(:page_title, "Listing Custom fields")
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
end
@impl true
def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
def handle_event("delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id)
Ash.destroy!(custom_field)
{:noreply,
socket
|> assign(:custom_field_to_delete, custom_field)
|> assign(:show_delete_modal, true)
|> assign(:slug_confirmation, "")}
end
@impl true
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
{:noreply, assign(socket, :slug_confirmation, slug)}
end
@impl true
def handle_event("confirm_delete", _params, socket) do
custom_field = socket.assigns.custom_field_to_delete
if socket.assigns.slug_confirmation == custom_field.slug do
# Delete the custom field (CASCADE will handle custom field values)
case Ash.destroy(custom_field) do
:ok ->
{:noreply,
socket
|> put_flash(:info, "Custom field deleted successfully")
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream_delete(:custom_fields, custom_field)}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, "Failed to delete custom field: #{inspect(error)}")}
end
else
{:noreply,
socket
|> put_flash(:error, "Slug does not match. Deletion cancelled.")}
end
end
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
{:noreply, stream_delete(socket, :custom_fields, custom_field)}
end
end

View file

@ -9,8 +9,6 @@ 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
@ -31,7 +29,7 @@ defmodule MvWeb.CustomFieldLive.Show do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Custom field {@custom_field.slug}
Custom field {@custom_field.id}
<:subtitle>This is a custom_field record from your database.</:subtitle>
<:actions>
@ -50,13 +48,6 @@ defmodule MvWeb.CustomFieldLive.Show do
<.list>
<: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="Description">{@custom_field.description}</:item>

View file

@ -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.")}
</:subtitle>
</.header>

View file

@ -75,8 +75,7 @@ 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"},
{:slugify, "~> 1.3"}
{:ecto_commons, "~> 0.3"}
]
end

View file

@ -158,7 +158,7 @@ msgstr "Postleitzahl"
msgid "Save Member"
msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:63
#: 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:108
#: lib/mv_web/live/custom_field_live/form.ex:107
#: 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:109
#: lib/mv_web/live/custom_field_live/form.ex:108
#: 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,8 +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:67
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_live/form.ex:66
#: 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 +265,7 @@ msgstr "Abbrechen"
msgid "Choose a member"
msgstr "Mitglied auswählen"
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Description"
msgstr "Beschreibung"
@ -286,7 +285,7 @@ msgstr "Aktiviert"
msgid "ID"
msgstr "ID"
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr "Unveränderlich"
@ -356,7 +355,7 @@ msgstr "Passwort-Authentifizierung"
msgid "Profil"
msgstr "Profil"
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Required"
msgstr "Erforderlich"
@ -412,7 +411,7 @@ msgstr "Benutzer*in"
msgid "Value"
msgstr "Wert"
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:54
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr "Wertetyp"
@ -617,7 +616,7 @@ msgstr "Benutzerdefinierte Feldwerte"
msgid "Custom field"
msgstr "Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:114
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
@ -632,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:65
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr "Benutzerdefiniertes Feld speichern"
@ -647,54 +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."
#: lib/mv_web/live/custom_field_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)"
msgstr "Automatisch generierter Bezeichner (unveränderlich)"
#: lib/mv_web/live/custom_field_live/index.ex:79
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
#: lib/mv_web/live/custom_field_live/index.ex:87
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
#: lib/mv_web/live/custom_field_live/index.ex:72
#, elixir-autogen, elixir-format
msgid "Delete Custom Field"
msgstr "Benutzerdefiniertes Feld löschen"
#: lib/mv_web/live/custom_field_live/index.ex:127
#, elixir-autogen, elixir-format
msgid "Delete Custom Field and All Values"
msgstr "Benutzerdefiniertes Feld und alle Werte löschen"
#: lib/mv_web/live/custom_field_live/index.ex:109
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
msgstr "Obigen Text zur Bestätigung eingeben"
#: lib/mv_web/live/custom_field_live/index.ex:97
#, elixir-autogen, elixir-format, fuzzy
msgid "To confirm deletion, please enter this text:"
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
#~ #: lib/mv_web/live/custom_field_live/index.ex:97
#~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:"
#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:"

View file

@ -159,7 +159,7 @@ msgstr ""
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:63
#: 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:108
#: lib/mv_web/live/custom_field_live/form.ex:107
#: 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:109
#: lib/mv_web/live/custom_field_live/form.ex:108
#: 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,8 +253,7 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:67
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_live/form.ex:66
#: 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
@ -267,7 +266,7 @@ msgstr ""
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@ -287,7 +286,7 @@ msgstr ""
msgid "ID"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
@ -357,7 +356,7 @@ msgstr ""
msgid "Profil"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
@ -413,7 +412,7 @@ msgstr ""
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:54
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -618,7 +617,7 @@ msgstr ""
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:114
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
@ -633,7 +632,7 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:65
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
@ -648,49 +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 ""
#: lib/mv_web/live/custom_field_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:79
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/custom_field_live/index.ex:87
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:72
#, elixir-autogen, elixir-format
msgid "Delete Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:127
#, elixir-autogen, elixir-format
msgid "Delete Custom Field and All Values"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:109
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:97
#, elixir-autogen, elixir-format
msgid "To confirm deletion, please enter this text:"
msgstr ""

View file

@ -159,7 +159,7 @@ msgstr ""
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:63
#: 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:108
#: lib/mv_web/live/custom_field_live/form.ex:107
#: 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:109
#: lib/mv_web/live/custom_field_live/form.ex:108
#: 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,8 +253,7 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:67
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_live/form.ex:66
#: 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
@ -267,7 +266,7 @@ msgstr ""
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:59
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@ -287,7 +286,7 @@ msgstr ""
msgid "ID"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:60
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
@ -357,7 +356,7 @@ msgstr ""
msgid "Profil"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
@ -413,7 +412,7 @@ msgstr ""
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:54
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -618,7 +617,7 @@ msgstr ""
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:114
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
@ -633,7 +632,7 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:65
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
@ -648,54 +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 ""
#: lib/mv_web/live/custom_field_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:79
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/custom_field_live/index.ex:87
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:72
#, elixir-autogen, elixir-format
msgid "Delete Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:127
#, elixir-autogen, elixir-format
msgid "Delete Custom Field and All Values"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:109
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
msgstr ""
#: lib/mv_web/live/custom_field_live/index.ex:97
#, elixir-autogen, elixir-format, fuzzy
msgid "To confirm deletion, please enter this text:"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index.ex:97
#~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:"
#~ msgstr ""

View file

@ -1,47 +0,0 @@
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

@ -1,38 +0,0 @@
defmodule Mv.Repo.Migrations.ChangeCustomFieldDeleteCascade 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
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
alter table(:custom_field_values) do
modify :custom_field_id,
references(:custom_fields,
column: :id,
name: "custom_field_values_custom_field_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
end
end
def down do
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
alter table(:custom_field_values) do
modify :custom_field_id,
references(:custom_fields,
column: :id,
name: "custom_field_values_custom_field_id_fkey",
type: :uuid,
prefix: "public"
)
end
end
end

View file

@ -1,124 +0,0 @@
{
"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?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value",
"type": "map"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "custom_field_values_member_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "custom_field_values_custom_field_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "custom_fields"
},
"scale": null,
"size": null,
"source": "custom_field_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "BDEC02A7F12B14AB65FBA1A4BD834D291E3BEC61D065473D51BBE453486512ED",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_field_values_unique_custom_field_per_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "custom_field_id"
}
],
"name": "unique_custom_field_per_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_field_values"
}

View file

@ -1,132 +0,0 @@
{
"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

@ -1,254 +0,0 @@
defmodule Mv.Membership.CustomFieldDeletionTest do
@moduledoc """
Tests for CustomField deletion with CASCADE behavior.
Tests cover:
- Deletion of custom fields without assigned values
- Deletion of custom fields with assigned values (CASCADE)
- assigned_members_count calculation
- prepare_deletion action with count loading
- CASCADE deletion only affects specific custom field values
"""
use Mv.DataCase, async: true
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
describe "assigned_members_count calculation" do
test "returns 0 for custom field without any values" do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string
})
|> Ash.create()
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
assert custom_field_with_count.assigned_members_count == 0
end
test "returns correct count for custom field with one member" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, _custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
assert custom_field_with_count.assigned_members_count == 1
end
test "returns correct count for custom field with multiple members" do
{:ok, member1} = create_member()
{:ok, member2} = create_member()
{:ok, member3} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
# Create custom field value for each member
for member <- [member1, member2, member3] do
{:ok, _} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
end
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
assert custom_field_with_count.assigned_members_count == 3
end
test "counts distinct members (not multiple values per member)" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
# Create custom field value for member
{:ok, _} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
custom_field_with_count = Ash.load!(custom_field, :assigned_members_count)
# Should still be 1, not 2, even if we tried to create multiple (which would fail due to uniqueness)
assert custom_field_with_count.assigned_members_count == 1
end
end
describe "prepare_deletion action" do
test "loads assigned_members_count for deletion preparation" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, _} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
# Use prepare_deletion action
[prepared_custom_field] =
CustomField
|> Ash.Query.for_read(:prepare_deletion, %{id: custom_field.id})
|> Ash.read!()
assert prepared_custom_field.assigned_members_count == 1
assert prepared_custom_field.id == custom_field.id
end
test "returns empty list for non-existent custom field" do
non_existent_id = Ash.UUID.generate()
result =
CustomField
|> Ash.Query.for_read(:prepare_deletion, %{id: non_existent_id})
|> Ash.read!()
assert result == []
end
end
describe "destroy_with_values action" do
test "deletes custom field without any values" do
{:ok, custom_field} = create_custom_field("test_field", :string)
assert :ok = Ash.destroy(custom_field)
# Verify custom field is deleted
assert {:error, _} = Ash.get(CustomField, custom_field.id)
end
test "deletes custom field and cascades to all its values" do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, custom_field_value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
# Delete custom field
assert :ok = Ash.destroy(custom_field)
# Verify custom field is deleted
assert {:error, _} = Ash.get(CustomField, custom_field.id)
# Verify custom field value is also deleted (CASCADE)
assert {:error, _} = Ash.get(CustomFieldValue, custom_field_value.id)
# Verify member still exists
assert {:ok, _} = Ash.get(Member, member.id)
end
test "deletes only values of the specific custom field" do
{:ok, member} = create_member()
{:ok, custom_field1} = create_custom_field("field1", :string)
{:ok, custom_field2} = create_custom_field("field2", :string)
# Create value for custom_field1
{:ok, value1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field1.id,
value: %{"_union_type" => "string", "_union_value" => "value1"}
})
|> Ash.create()
# Create value for custom_field2
{:ok, value2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field2.id,
value: %{"_union_type" => "string", "_union_value" => "value2"}
})
|> Ash.create()
# Delete custom_field1
assert :ok = Ash.destroy(custom_field1)
# Verify custom_field1 and value1 are deleted
assert {:error, _} = Ash.get(CustomField, custom_field1.id)
assert {:error, _} = Ash.get(CustomFieldValue, value1.id)
# Verify custom_field2 and value2 still exist
assert {:ok, _} = Ash.get(CustomField, custom_field2.id)
assert {:ok, _} = Ash.get(CustomFieldValue, value2.id)
end
test "deletes custom field with values from multiple members" do
{:ok, member1} = create_member()
{:ok, member2} = create_member()
{:ok, member3} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
# Create value for each member
values =
for member <- [member1, member2, member3] do
{:ok, value} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create()
value
end
# Delete custom field
assert :ok = Ash.destroy(custom_field)
# Verify all values are deleted
for value <- values do
assert {:error, _} = Ash.get(CustomFieldValue, value.id)
end
# Verify all members still exist
for member <- [member1, member2, member3] do
assert {:ok, _} = Ash.get(Member, member.id)
end
end
end
# Helper functions
defp create_member do
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
end
defp create_custom_field(name, value_type) do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "#{name}_#{System.unique_integer([:positive])}",
value_type: value_type
})
|> Ash.create()
end
end

View file

@ -1,397 +0,0 @@
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

View file

@ -1,251 +0,0 @@
defmodule MvWeb.CustomFieldLive.DeletionTest do
@moduledoc """
Tests for CustomFieldLive.Index deletion modal and slug confirmation.
Tests cover:
- Opening deletion confirmation modal
- Displaying correct member count
- Slug confirmation input
- Successful deletion with correct slug
- Failed deletion with incorrect slug
- Canceling deletion
- Button states (enabled/disabled based on slug match)
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create admin user for testing
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = log_in_user(build_conn(), user)
%{conn: conn, user: user}
end
describe "delete button and modal" do
test "opens modal with correct member count when delete is clicked", %{conn: conn} do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
# Create custom field value
create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
# Click delete button
view
|> element("a", "Delete")
|> render_click()
# Modal should be visible
assert has_element?(view, "#delete-custom-field-modal")
# Should show correct member count (1 member)
assert render(view) =~ "1 member has a value assigned for this custom field"
# Should show the slug
assert render(view) =~ custom_field.slug
end
test "shows correct plural form for multiple members", %{conn: conn} do
{:ok, member1} = create_member()
{:ok, member2} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
# Create values for both members
create_custom_field_value(member1, custom_field, "test1")
create_custom_field_value(member2, custom_field, "test2")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("a", "Delete")
|> render_click()
# Should show plural form
assert render(view) =~ "2 members have values assigned for this custom field"
end
test "shows 0 members for custom field without values", %{conn: conn} do
{:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("a", "Delete")
|> render_click()
# Should show 0 members
assert render(view) =~ "0 members have values assigned for this custom field"
end
end
describe "slug confirmation input" do
test "updates confirmation state when typing", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("a", "Delete")
|> render_click()
# Type in slug input
view
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
# Confirm button should be enabled now (no disabled attribute)
html = render(view)
refute html =~ ~r/disabled(?:=""|(?!\w))/
end
test "delete button is disabled when slug doesn't match", %{conn: conn} do
{:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("a", "Delete")
|> render_click()
# Type wrong slug
view
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
# Button should be disabled
html = render(view)
assert html =~ ~r/disabled(?:=""|(?!\w))/
end
end
describe "confirm deletion" do
test "successfully deletes custom field with correct slug", %{conn: conn} do
{:ok, member} = create_member()
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
# Open modal
view
|> element("a", "Delete")
|> render_click()
# Enter correct slug
view
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
# Click confirm
view
|> element("button", "Delete Custom Field and All Values")
|> render_click()
# Should show success message
assert render(view) =~ "Custom field deleted successfully"
# Custom field should be gone from database
assert {:error, _} = Ash.get(CustomField, custom_field.id)
# Custom field value should also be gone (CASCADE)
assert {:error, _} = Ash.get(CustomFieldValue, custom_field_value.id)
# Member should still exist
assert {:ok, _} = Ash.get(Member, member.id)
end
test "shows error when slug doesn't match", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("a", "Delete")
|> render_click()
# Enter wrong slug
view
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
# Try to confirm (button should be disabled, but test the handler anyway)
view
|> render_click("confirm_delete", %{})
# Should show error message
assert render(view) =~ "Slug does not match"
# Custom field should still exist
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
end
end
describe "cancel deletion" do
test "closes modal without deleting", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
view
|> element("a", "Delete")
|> render_click()
# Modal should be visible
assert has_element?(view, "#delete-custom-field-modal")
# Click cancel
view
|> element("button", "Cancel")
|> render_click()
# Modal should be gone
refute has_element?(view, "#delete-custom-field-modal")
# Custom field should still exist
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
end
end
# Helper functions
defp create_member do
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "User#{System.unique_integer([:positive])}",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
end
defp create_custom_field(name, value_type) do
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "#{name}_#{System.unique_integer([:positive])}",
value_type: value_type
})
|> Ash.create()
end
defp create_custom_field_value(member, custom_field, value) do
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => value}
})
|> Ash.create()
end
defp log_in_user(conn, user) do
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
end
end