diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 4c84c20..e1cf397 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -28,7 +28,10 @@ defmodule Mv.Membership.CustomField do ## Constraints - Name must be unique across all custom fields - 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 # Create a new custom field @@ -55,7 +58,7 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:read, :update, :destroy] + defaults [:read, :update] default_accept [:name, :value_type, :description, :immutable, :required] create :create do @@ -63,6 +66,17 @@ defmodule Mv.Membership.CustomField do 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 @@ -111,6 +125,17 @@ 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] diff --git a/lib/membership/custom_field_value.ex b/lib/membership/custom_field_value.ex index 2d6c025..232ba99 100644 --- a/lib/membership/custom_field_value.ex +++ b/lib/membership/custom_field_value.ex @@ -25,11 +25,12 @@ 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 + - `belongs_to :custom_field` - The custom field definition (CASCADE delete) ## 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) @@ -46,12 +47,19 @@ 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 diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index f51c2b9..7891d2e 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -42,7 +42,8 @@ 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 + define :destroy_custom_field, action: :destroy_with_values + define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id] end end end diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex index 65a3ab3..f711323 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -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 (if no custom field values use them) + - Delete custom fields with confirmation (cascades to all custom field values) ## Displayed Information - 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) ## 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 Custom field management is restricted to admin users. + Deletion requires entering the custom field's slug to prevent accidental deletions. """ use MvWeb, :live_view @@ -55,15 +59,76 @@ defmodule MvWeb.CustomFieldLive.Index do <.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit - <:action :let={{id, custom_field}}> - <.link - phx-click={JS.push("delete", value: %{id: custom_field.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > + <:action :let={{_id, custom_field}}> + <.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}> Delete + + <%!-- Delete Confirmation Modal --%> + + + """ end @@ -73,14 +138,62 @@ 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("delete", %{"id" => id}, socket) do - custom_field = Ash.get!(Mv.Membership.CustomField, id) - Ash.destroy!(custom_field) + def handle_event("prepare_delete", %{"id" => id}, socket) do + custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count]) - {: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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index b7f472d..18e1053 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -253,6 +253,7 @@ 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_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:237 @@ -663,7 +664,39 @@ msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Date #: lib/mv_web/live/custom_field_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Auto-generated identifier (immutable)" -msgstr "Automatisch generierte Kennung (unveränderlich)" +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/user_live/form.ex:210 #, elixir-autogen, elixir-format @@ -709,8 +742,3 @@ msgstr "Mitglied entverknüpfen" #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "Entverknüpfung geplant" - -#~ #: lib/mv_web/live/custom_field_live/form.ex:58 -#~ #, elixir-autogen, elixir-format -#~ msgid "Slug" -#~ msgstr "Slug" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 75cb2b1..a87d935 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -254,6 +254,7 @@ 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_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:237 @@ -666,6 +667,38 @@ msgstr "" 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 "" + #: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7cae329..e12b489 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -254,6 +254,7 @@ 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_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 #: lib/mv_web/live/user_live/form.ex:237 @@ -666,6 +667,38 @@ msgstr "" 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/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." @@ -710,8 +743,3 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "" - -#~ #: lib/mv_web/live/custom_field_live/form.ex:58 -#~ #, elixir-autogen, elixir-format -#~ msgid "Slug" -#~ msgstr "" diff --git a/priv/repo/migrations/20251113183538_change_custom_field_delete_cascade.exs b/priv/repo/migrations/20251113183538_change_custom_field_delete_cascade.exs new file mode 100644 index 0000000..32b8037 --- /dev/null +++ b/priv/repo/migrations/20251113183538_change_custom_field_delete_cascade.exs @@ -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 diff --git a/priv/resource_snapshots/repo/custom_field_values/20251113183538.json b/priv/resource_snapshots/repo/custom_field_values/20251113183538.json new file mode 100644 index 0000000..fc27f19 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_field_values/20251113183538.json @@ -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" +} \ No newline at end of file diff --git a/test/accounts/user_member_linking_email_test.exs b/test/accounts/user_member_linking_email_test.exs index 5d72ac9..d7c2817 100644 --- a/test/accounts/user_member_linking_email_test.exs +++ b/test/accounts/user_member_linking_email_test.exs @@ -5,7 +5,7 @@ defmodule Mv.Accounts.UserMemberLinkingEmailTest do Tests for Issue #168, specifically Problem #4: Email validation bug. """ - use Mv.DataCase, async: true + use Mv.DataCase, async: false alias Mv.Accounts alias Mv.Membership diff --git a/test/membership/custom_field_deletion_test.exs b/test/membership/custom_field_deletion_test.exs new file mode 100644 index 0000000..50623b6 --- /dev/null +++ b/test/membership/custom_field_deletion_test.exs @@ -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 diff --git a/test/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs index 602fdfd..af293e1 100644 --- a/test/membership/member_available_for_linking_test.exs +++ b/test/membership/member_available_for_linking_test.exs @@ -8,7 +8,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do - Special email-match logic: if user_email matches member email, only return that member - Optional search query filtering by name and email """ - use Mv.DataCase, async: true + use Mv.DataCase, async: false alias Mv.Membership describe "available_for_linking/2" do diff --git a/test/membership/member_fuzzy_search_linking_test.exs b/test/membership/member_fuzzy_search_linking_test.exs index fcaf5fd..4cbd8d9 100644 --- a/test/membership/member_fuzzy_search_linking_test.exs +++ b/test/membership/member_fuzzy_search_linking_test.exs @@ -4,7 +4,7 @@ defmodule Mv.Membership.MemberFuzzySearchLinkingTest do Verifies PostgreSQL trigram matching for member search. """ - use Mv.DataCase, async: true + use Mv.DataCase, async: false alias Mv.Accounts alias Mv.Membership diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs new file mode 100644 index 0000000..f0317e0 --- /dev/null +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -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