Merge pull request 'Custom Fields: Handle Deletion of custom fields, closes #199' (#206) from feature/custom-field-deletion into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #206
Reviewed-by: carla <carla@noreply.git.local-it.org>
This commit is contained in:
moritz 2025-11-20 15:10:22 +01:00
commit 70b3875154
11 changed files with 939 additions and 16 deletions

View file

@ -28,7 +28,10 @@ defmodule Mv.Membership.CustomField do
## Constraints ## Constraints
- Name must be unique across all custom fields - Name must be unique across all custom fields
- Name maximum length: 100 characters - Name maximum length: 100 characters
- Cannot delete a custom field that has existing custom field values (RESTRICT) - 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
## Examples ## Examples
# Create a new custom field # Create a new custom field
@ -55,7 +58,7 @@ defmodule Mv.Membership.CustomField do
end end
actions do actions do
defaults [:read, :update, :destroy] defaults [:read, :update]
default_accept [:name, :value_type, :description, :immutable, :required] default_accept [:name, :value_type, :description, :immutable, :required]
create :create do create :create do
@ -63,6 +66,17 @@ defmodule Mv.Membership.CustomField do
change Mv.Membership.CustomField.Changes.GenerateSlug change Mv.Membership.CustomField.Changes.GenerateSlug
validate string_length(:slug, min: 1) validate string_length(:slug, min: 1)
end 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 end
attributes do attributes do
@ -111,6 +125,17 @@ defmodule Mv.Membership.CustomField do
has_many :custom_field_values, Mv.Membership.CustomFieldValue has_many :custom_field_values, Mv.Membership.CustomFieldValue
end 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 identities do
identity :unique_name, [:name] identity :unique_name, [:name]
identity :unique_slug, [:slug] identity :unique_slug, [:slug]

View file

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

View file

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

View file

@ -8,7 +8,7 @@ defmodule MvWeb.CustomFieldLive.Index do
- Show immutable and required flags - Show immutable and required flags
- Create new custom fields - Create new custom fields
- Edit existing custom fields - Edit existing custom fields
- Delete custom fields (if no custom field values use them) - Delete custom fields with confirmation (cascades to all custom field values)
## Displayed Information ## Displayed Information
- Name: Unique identifier for the custom field - Name: Unique identifier for the custom field
@ -18,10 +18,14 @@ defmodule MvWeb.CustomFieldLive.Index do
- Required: Whether all members must have this custom field (future feature) - Required: Whether all members must have this custom field (future feature)
## Events ## Events
- `delete` - Remove a custom field (only if no custom field values exist) - `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
## Security ## Security
Custom field management is restricted to admin users. Custom field management is restricted to admin users.
Deletion requires entering the custom field's slug to prevent accidental deletions.
""" """
use MvWeb, :live_view use MvWeb, :live_view
@ -55,15 +59,76 @@ defmodule MvWeb.CustomFieldLive.Index do
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link> <.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
</:action> </:action>
<:action :let={{id, custom_field}}> <:action :let={{_id, custom_field}}>
<.link <.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}>
phx-click={JS.push("delete", value: %{id: custom_field.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete Delete
</.link> </.link>
</:action> </:action>
</.table> </.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> </Layouts.app>
""" """
end end
@ -73,14 +138,62 @@ defmodule MvWeb.CustomFieldLive.Index do
{:ok, {:ok,
socket socket
|> assign(:page_title, "Listing Custom fields") |> 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))} |> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
end end
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id) custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
Ash.destroy!(custom_field)
{:noreply, stream_delete(socket, :custom_fields, 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, "")}
end end
end end

View file

@ -253,6 +253,7 @@ 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:67 #: 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_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
@ -659,4 +660,41 @@ msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Date
#: lib/mv_web/live/custom_field_live/show.ex:56 #: lib/mv_web/live/custom_field_live/show.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)" msgid "Auto-generated identifier (immutable)"
msgstr "Automatisch generierter Identifier" 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

@ -254,6 +254,7 @@ msgid "Your password has successfully been reset"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:67 #: 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_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
@ -661,3 +662,35 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)" msgid "Auto-generated identifier (immutable)"
msgstr "" 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

@ -254,6 +254,7 @@ msgid "Your password has successfully been reset"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:67 #: 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_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
@ -661,3 +662,40 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Auto-generated identifier (immutable)" msgid "Auto-generated identifier (immutable)"
msgstr "" 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

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

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

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

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