feat(member): deactivate and reactivate members via an exit-date dialog
This commit is contained in:
parent
bcab2e21c4
commit
3dc3a2b8ef
6 changed files with 1735 additions and 1326 deletions
|
|
@ -329,6 +329,14 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Deactivate/reactivate sub-flow (gated on :update, owns its own modal) --%>
|
||||
<.live_component
|
||||
module={MvWeb.MemberLive.Show.DeactivateComponent}
|
||||
id="member-deactivate"
|
||||
member={@member}
|
||||
current_user={@current_user}
|
||||
/>
|
||||
|
||||
<%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
|
||||
<%= if can?(@current_user, :destroy, @member) do %>
|
||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||
|
|
|
|||
203
lib/mv_web/live/member_live/show/deactivate_component.ex
Normal file
203
lib/mv_web/live/member_live/show/deactivate_component.ex
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
defmodule MvWeb.MemberLive.Show.DeactivateComponent do
|
||||
@moduledoc """
|
||||
LiveComponent owning the member deactivate/reactivate sub-flow on the member show page.
|
||||
|
||||
## Features
|
||||
- Deactivate control (shown when the member has no exit_date)
|
||||
- Reactivate control (shown when the member has an exit_date)
|
||||
- Date-selection modal (default: today, future dates allowed) for deactivation
|
||||
- Routes both actions through `Membership.update_member/2` with the real user actor,
|
||||
inheriting the `exit_date > join_date` validation and the cycle-regeneration hook.
|
||||
|
||||
Controls are gated on `:update` permission for the member. On success the component
|
||||
notifies the parent with `{:member_updated, member}`, which the parent already handles.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
import MvWeb.Authorization, only: [can?: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.Helpers.MemberHelpers
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<%= if @can_update do %>
|
||||
<section class="mt-8 mb-6" aria-labelledby="membership-state-heading">
|
||||
<h2 id="membership-state-heading" class="text-lg font-semibold mb-3">
|
||||
{gettext("Membership status")}
|
||||
</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<%= if @member.exit_date do %>
|
||||
<p class="text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"This member is deactivated (exit date set). Reactivating clears the exit date."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="reactivate-member-trigger"
|
||||
data-testid="member-reactivate"
|
||||
variant="primary"
|
||||
phx-click="reactivate"
|
||||
phx-target={@myself}
|
||||
aria-label={
|
||||
gettext("Reactivate member %{name}", name: MemberHelpers.display_name(@member))
|
||||
}
|
||||
>
|
||||
<.icon name="hero-arrow-uturn-left" class="size-4" />
|
||||
{gettext("Reactivate member")}
|
||||
</.button>
|
||||
<% else %>
|
||||
<p class="text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"Deactivating this member records an exit date. You can reactivate them later."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="deactivate-member-trigger"
|
||||
data-testid="member-deactivate"
|
||||
variant="outline"
|
||||
phx-click="open_modal"
|
||||
phx-target={@myself}
|
||||
aria-label={
|
||||
gettext("Deactivate member %{name}", name: MemberHelpers.display_name(@member))
|
||||
}
|
||||
>
|
||||
<.icon name="hero-arrow-right-on-rectangle" class="size-4" />
|
||||
{gettext("Deactivate member")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%= if @show_modal do %>
|
||||
<dialog
|
||||
id="deactivate-member-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="deactivate-member-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="deactivate-member-modal-title" class="text-lg font-bold">
|
||||
{gettext("When did this member leave?")}
|
||||
</h3>
|
||||
<form phx-submit="deactivate" phx-target={@myself}>
|
||||
<.input
|
||||
type="date"
|
||||
id="deactivate-exit-date"
|
||||
name="exit_date"
|
||||
label={gettext("Exit date")}
|
||||
value={@exit_date}
|
||||
errors={if @error, do: [@error], else: []}
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_modal"
|
||||
phx-target={@myself}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button type="submit" variant="primary">{gettext("Deactivate")}</.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:can_update, can?(assigns.current_user, :update, assigns.member))
|
||||
|> assign_new(:show_modal, fn -> false end)
|
||||
|> assign_new(:exit_date, fn -> Date.utc_today() end)
|
||||
|> assign_new(:error, fn -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_modal", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_modal, true)
|
||||
|> assign(:exit_date, Date.utc_today())
|
||||
|> assign(:error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_modal", _params, socket) do
|
||||
{:noreply, close_modal(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_modal(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("deactivate", %{"exit_date" => exit_date_str}, socket) do
|
||||
case Date.from_iso8601(exit_date_str) do
|
||||
{:ok, exit_date} ->
|
||||
apply_exit_date(socket, exit_date, show_inline_error: true)
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, assign(socket, :error, gettext("Invalid date format"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("reactivate", _params, socket) do
|
||||
apply_exit_date(socket, nil, show_inline_error: false)
|
||||
end
|
||||
|
||||
defp apply_exit_date(socket, exit_date, opts) do
|
||||
member = socket.assigns.member
|
||||
actor = socket.assigns.current_user
|
||||
|
||||
case Membership.update_member(member, %{exit_date: exit_date}, actor: actor) do
|
||||
{:ok, updated_member} ->
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> close_modal()}
|
||||
|
||||
{:error, error} ->
|
||||
if opts[:show_inline_error] do
|
||||
{:noreply, assign(socket, :error, format_error(error))}
|
||||
else
|
||||
send(self(), {:put_flash, :error, format_error(error)})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp close_modal(socket) do
|
||||
socket
|
||||
|> assign(:show_modal, false)
|
||||
|> assign(:error, nil)
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
Enum.map_join(errors, ", ", fn
|
||||
%{message: message} -> message
|
||||
other -> inspect(other)
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Forbidden{}) do
|
||||
gettext("You are not allowed to perform this action.")
|
||||
end
|
||||
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
end
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
108
test/mv_web/live/member_live/deactivate_test.exs
Normal file
108
test/mv_web/live/member_live/deactivate_test.exs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
defmodule MvWeb.MemberLive.DeactivateTest do
|
||||
@moduledoc """
|
||||
Tests for the member deactivate/reactivate sub-flow on the member show page,
|
||||
driven through the parent LiveView (the DeactivateComponent is stateful).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
defp reload_member(member) do
|
||||
Ash.get!(Mv.Membership.Member, member.id, actor: Mv.Helpers.SystemActor.get_system_actor())
|
||||
end
|
||||
|
||||
describe "deactivate/reactivate control visibility (§1.6, §1.8)" do
|
||||
@tag role: :admin
|
||||
test "shows Deactivate and hides Reactivate when member has no exit_date", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-deactivate]")
|
||||
refute has_element?(view, "[data-testid=member-reactivate]")
|
||||
end
|
||||
|
||||
@tag role: :admin
|
||||
test "shows Reactivate and hides Deactivate when member has an exit_date", %{conn: conn} do
|
||||
member = Fixtures.member_fixture(%{exit_date: Date.utc_today()})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-reactivate]")
|
||||
refute has_element?(view, "[data-testid=member-deactivate]")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "hides the deactivate/reactivate control for a user without :update permission", %{
|
||||
conn: conn
|
||||
} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
|
||||
|
||||
refute has_element?(view, "[data-testid=member-deactivate]")
|
||||
refute has_element?(view, "[data-testid=member-reactivate]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "deactivate modal (§1.3)" do
|
||||
@tag role: :admin
|
||||
test "opening the deactivate modal prefills the date input with today", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("[data-testid=member-deactivate]")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
~s(#deactivate-exit-date[value="#{Date.to_iso8601(Date.utc_today())}"])
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "submitting the deactivate modal (§1.2)" do
|
||||
@tag role: :admin
|
||||
test "submitting the deactivate modal with a future date sets exit_date", %{conn: conn} do
|
||||
member = Fixtures.member_fixture(%{join_date: Date.utc_today()})
|
||||
future_date = Date.add(Date.utc_today(), 30)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("[data-testid=member-deactivate]")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("#deactivate-member-modal form")
|
||||
|> render_submit(%{"exit_date" => Date.to_iso8601(future_date)})
|
||||
|
||||
assert reload_member(member).exit_date == future_date
|
||||
|
||||
# UI flips to offering Reactivate
|
||||
assert has_element?(view, "[data-testid=member-reactivate]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "reactivate (§1.7)" do
|
||||
@tag role: :admin
|
||||
test "reactivating a member clears exit_date", %{conn: conn} do
|
||||
member = Fixtures.member_fixture(%{exit_date: Date.utc_today()})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("[data-testid=member-reactivate]")
|
||||
|> render_click()
|
||||
|
||||
assert reload_member(member).exit_date == nil
|
||||
|
||||
# UI flips back to offering Deactivate
|
||||
assert has_element?(view, "[data-testid=member-deactivate]")
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue