feat: custom field deletion

This commit is contained in:
Moritz 2025-11-13 20:03:58 +01:00
parent 21ec86839a
commit 2af23f4042
Signed by: moritz
GPG key ID: 1020A035E5DD0824
11 changed files with 938 additions and 16 deletions

View file

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

View file

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

View file

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

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 (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,75 @@ 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("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
</.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 the custom field slug:")}
</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 slug to confirm")}
autocomplete="off"
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
@ -73,14 +137,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