mitgliederverwaltung/lib/mv_web/live/join_request_live/show.ex
Simon 2bb01bd201
All checks were successful
continuous-integration/drone/push Build is passing
Improve UX of join requests and fix minor bugs (#492)
## Description of the implemented changes
The changes were:
- [x] Bugfixing
- [x] New Feature
- [ ] Breaking Change
- [ ] Refactoring

This PR improves the join-request flow and presentation quality, fixes several data-display issues in join/join-request screens, and adds a usability improvement in global settings (directly opening the join link). It also includes dependency updates and changelog maintenance.

## What has been changed?
- Join form (`JoinLive`) now renders inputs based on actual field types (including checkbox/date/number/email behavior instead of generic text-only handling).
- Join form custom-field labels are resolved from configured custom fields (fallback remains safe if lookup fails).
- Join-request details page (`JoinRequestLive.Show`) now:
  - resolves and shows custom field names instead of raw IDs,
  - formats boolean-like values (`on/true/1`, `off/false/0`) as localized `Yes/No`,
  - formats ISO date strings for better readability,
  - keeps legacy field handling while improving output consistency.
- Join-request detail layout was improved semantically and visually (`dl/dt/dd` structure for label/value rows).
- Global settings page now includes an **Open** button for the join URL (`target="_blank"`, `rel="noopener noreferrer"`, ARIA label).
- Added/updated tests around:
  - join field type rendering,
  - custom field labels in join-request views,
  - related auth/global-settings behavior.
- Updated translations (`default.pot`, `en`, `de`) for new UI strings.
- Updated dependencies/tooling (`mix.lock`, `mix.exs`, CI/renovate-related updates).
- Updated `CHANGELOG.md` entries for unreleased changes.

## Definition of Done
### Code Quality
- [x] No new technical depths
- [x] Linting passed
- [x] Documentation is added were needed

### Accessibility
- [x] New elements are properly defined with html-tags
- [x] Colour contrast follows WCAG criteria
- [x] Aria labels are added when needed
- [x] Everything is accessible by keyboard
- [x] Tab-Order is comprehensible
- [x] All interactive elements have a visible focus

### Testing
- [x] Tests for new code are written
- [ ] All tests pass
- [ ] axe-core dev tools show no critical or major issues

## Additional Notes
- Reviewer focus areas:
  - `lib/mv_web/live/join_live.ex`: input type derivation and custom field lookup strategy (`authorize?: false` read path used intentionally for field metadata).
  - `lib/mv_web/live/join_request_live/show.ex`: value-formatting logic (especially backward compatibility for legacy `form_data` payloads).
  - `lib/mv_web/live/global_settings_live.ex`: external-link behavior and accessibility attributes.
- The branch also contains dependency update commits; please review lockfile and CI-related changes separately from functional join/join-request changes.

Reviewed-on: #492
Co-authored-by: Simon <s.thiessen@local-it.org>
Co-committed-by: Simon <s.thiessen@local-it.org>
2026-05-06 14:34:42 +02:00

387 lines
13 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MvWeb.JoinRequestLive.Show do
@moduledoc """
LiveView for displaying a single join request and performing approve/reject actions.
## Features
- Show all request data (typed fields + form_data rendered by field)
- Approve action: transitions to :approved, creates Member
- Reject action: transitions to :rejected (no Member created)
- Actions only available when status is :submitted
## Security
- Page access controlled by CheckPagePermission plug and can_access_page? guard
- Ash policy (HasPermission) enforces JoinRequest update :all for normal_user and admin
"""
use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization
alias Mv.Constants
alias Mv.Membership
alias Mv.Membership.CustomFieldLookup
alias MvWeb.Helpers.DateFormatter
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations
@impl true
def mount(_params, _session, socket) do
if Membership.join_form_enabled?() do
{:ok,
socket
|> assign(:join_request, nil)
|> assign(:custom_field_by_id, %{})
|> assign(:join_form_field_ids, [])
|> Layouts.assign_page_title(gettext("Join request"))}
else
{:ok, redirect(socket, to: ~p"/members")}
end
end
@impl true
def handle_params(%{"id" => id}, _url, socket) do
actor = current_actor(socket)
if Membership.join_form_enabled?() and can_access_page?(actor, "/join_requests/:id") do
case Membership.get_join_request(id, actor: actor) do
{:ok, nil} ->
{:noreply,
socket
|> put_flash(:error, gettext("Join request not found."))
|> push_navigate(to: ~p"/join_requests")}
{:ok, request} ->
field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id)
custom_field_by_id =
CustomFieldLookup.fetch_map_by_ids(field_ids ++ Map.keys(request.form_data || %{}),
actor: actor,
select: [:id, :name, :value_type]
)
{:noreply,
socket
|> assign(:join_request, request)
|> assign(:custom_field_by_id, custom_field_by_id)
|> assign(:join_form_field_ids, field_ids)
|> Layouts.assign_page_title(gettext("Join request %{email}", email: request.email))}
{:error, _error} ->
{:noreply,
socket
|> put_flash(:error, gettext("Failed to load join request."))
|> push_navigate(to: ~p"/join_requests")}
end
else
{:noreply, redirect(socket, to: ~p"/members")}
end
end
@impl true
def handle_event("approve", _params, socket) do
actor = current_actor(socket)
request = socket.assigns.join_request
case Membership.approve_join_request(request.id, actor: actor) do
{:ok, _approved} ->
{:noreply,
socket
|> put_flash(:info, gettext("Join request approved. Member created."))
|> push_navigate(to: ~p"/join_requests")}
{:error, error} ->
Logger.warning("Failed to approve join request #{request.id}: #{inspect(error)}")
{:noreply, put_flash(socket, :error, gettext("Failed to approve join request."))}
end
end
@impl true
def handle_event("reject", _params, socket) do
actor = current_actor(socket)
request = socket.assigns.join_request
case Membership.reject_join_request(request.id, actor: actor) do
{:ok, _rejected} ->
{:noreply,
socket
|> put_flash(:info, gettext("Join request rejected."))
|> push_navigate(to: ~p"/join_requests")}
{:error, error} ->
Logger.warning("Failed to reject join request #{request.id}: #{inspect(error)}")
{:noreply, put_flash(socket, :error, gettext("Failed to reject join request."))}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
<:leading>
<.button
navigate={~p"/join_requests"}
variant="neutral"
aria-label={gettext("Back to join requests")}
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@content_title}
</.header>
<%= if @join_request do %>
<div class="mt-6 space-y-6 max-w-2xl">
<%!-- Single block: all applicant-provided data in join form order --%>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Applicant data")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<dl class="grid gap-1 md:grid-cols-[14rem_minmax(0,1fr)] md:gap-2">
<%= for {label, value} <-
applicant_data_rows(
@join_request,
@join_form_field_ids || [],
@custom_field_by_id || %{}
) do %>
<.field_row label={label} value={value} empty_text={gettext("Not specified")} />
<% end %>
</dl>
</div>
</div>
<%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Status and review")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<dl class="grid gap-1 md:grid-cols-[14rem_minmax(0,1fr)] md:gap-2">
<.field_row
label={gettext("Submitted at")}
value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)}
/>
<.field_row label={gettext("Status")}>
<.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}>
{JoinRequestHelpers.format_status(@join_request.status)}
</.badge>
</.field_row>
<%= if @join_request.status in [:approved, :rejected] do %>
<%= if @join_request.approved_at do %>
<.field_row
label={gettext("Approved at")}
value={
DateFormatter.format_datetime(@join_request.approved_at, @browser_timezone)
}
/>
<% end %>
<%= if @join_request.rejected_at do %>
<.field_row
label={gettext("Rejected at")}
value={
DateFormatter.format_datetime(@join_request.rejected_at, @browser_timezone)
}
/>
<% end %>
<.field_row
label={gettext("Review by")}
value={JoinRequestHelpers.reviewer_display(@join_request)}
empty_text="-"
/>
<% end %>
</dl>
</div>
</div>
<%= if @join_request.status == :submitted do %>
<div class="flex flex-wrap items-center justify-between gap-3 pt-2">
<.button
variant="danger"
phx-click="reject"
data-confirm={gettext("Reject this join request?")}
data-testid="join-request-reject-btn"
>
{gettext("Reject")}
</.button>
<.button
variant="primary"
phx-click="approve"
data-confirm={gettext("Approve this join request and create a member?")}
data-testid="join-request-approve-btn"
>
{gettext("Approve")}
</.button>
</div>
<% end %>
</div>
<% end %>
</Layouts.app>
"""
end
attr :label, :string, required: true
attr :value, :any, default: nil
attr :empty_text, :string, default: nil
slot :inner_block
defp field_row(assigns) do
~H"""
<dt class="m-0 text-base-content/60 whitespace-normal break-words">{@label}:</dt>
<dd class="m-0 min-w-0">
<%= cond do %>
<% @inner_block != [] -> %>
{render_slot(@inner_block)}
<% @value && @value != "" -> %>
{@value}
<% true -> %>
<span class="text-base-content/40 italic">
{@empty_text || gettext("Not specified")}
</span>
<% end %>
</dd>
"""
end
# Builds a single list of {label, display_value} for all applicant-provided data in join form
# order. Typed fields (email, first_name, last_name) and form_data are merged; legacy
# form_data keys (not in current join form config) are appended at the end.
defp applicant_data_rows(join_request, ordered_field_ids, custom_field_by_id) do
member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
form_data = join_request.form_data || %{}
typed = %{
"email" => join_request.email,
"first_name" => join_request.first_name,
"last_name" => join_request.last_name
}
in_order =
ordered_field_ids
|> Enum.map(fn key ->
value = Map.get(typed, key) || Map.get(form_data, key)
label = field_key_to_label(key, member_field_strings, custom_field_by_id)
value_type = field_key_to_value_type(key, member_field_strings, custom_field_by_id)
{label, format_applicant_value(value, value_type)}
end)
legacy_keys =
form_data
|> Map.keys()
|> Enum.reject(fn k ->
k in ordered_field_ids or k in ["email", "first_name", "last_name"]
end)
|> Enum.sort()
legacy_entries =
Enum.map(legacy_keys, fn key ->
label = field_key_to_label(key, member_field_strings, custom_field_by_id)
value_type = field_key_to_value_type(key, member_field_strings, custom_field_by_id)
{label, format_applicant_value(form_data[key], value_type)}
end)
in_order ++ legacy_entries
end
defp format_applicant_value(nil, _type), do: nil
defp format_applicant_value("", _type), do: nil
defp format_applicant_value(%Date{} = date, _type), do: DateFormatter.format_date(date)
defp format_applicant_value(value, type) when is_map(value),
do: format_applicant_value_from_map(value, type)
defp format_applicant_value(value, _type) when is_boolean(value),
do: if(value, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value(value, type) when is_binary(value),
do: format_binary_applicant_value(value, type)
defp format_applicant_value(value, _type) when is_number(value), do: to_string(value)
defp format_applicant_value(value, _type), do: to_string(value)
defp format_binary_applicant_value(value, type) do
trimmed_value = String.trim(value)
cond do
trimmed_value == "" ->
nil
String.downcase(trimmed_value) in ["on", "true", "1"] ->
gettext("Yes")
String.downcase(trimmed_value) in ["off", "false", "0"] ->
gettext("No")
type in [:date, Ash.Type.Date] ->
format_iso_date_string(trimmed_value)
true ->
trimmed_value
end
end
defp format_iso_date_string(value) do
case Date.from_iso8601(value) do
{:ok, date} -> DateFormatter.format_date(date)
_ -> value
end
end
defp format_applicant_value_from_map(value, fallback_type) do
raw = Map.get(value, "_union_value") || Map.get(value, "value")
type = Map.get(value, "_union_type") || Map.get(value, "type")
effective_type = type || fallback_type
if raw && effective_type in ["date", :date, Ash.Type.Date] do
format_applicant_value(raw, :date)
else
format_applicant_value_simple(raw, value)
end
end
defp format_applicant_value_simple(raw, _value) when is_binary(raw), do: raw
defp format_applicant_value_simple(raw, _value) when is_boolean(raw),
do: if(raw, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value_simple(raw, _value) when is_integer(raw), do: to_string(raw)
defp format_applicant_value_simple(_raw, value), do: to_string(value)
defp field_key_to_label(key, member_field_strings, custom_field_by_id)
when is_binary(key) do
if key in member_field_strings do
MemberFieldsTranslations.label(String.to_existing_atom(key))
else
case Map.get(custom_field_by_id, key) do
%{name: name} -> name
_ -> key
end
end
end
defp field_key_to_label(key, _, _), do: to_string(key)
defp field_key_to_value_type("email", _member_field_strings, _custom_field_by_id), do: :string
defp field_key_to_value_type("first_name", _member_field_strings, _custom_field_by_id),
do: :string
defp field_key_to_value_type("last_name", _member_field_strings, _custom_field_by_id),
do: :string
defp field_key_to_value_type(key, member_field_strings, custom_field_by_id)
when is_binary(key) do
if key in member_field_strings do
:string
else
case Map.get(custom_field_by_id, key) do
%{value_type: value_type} -> value_type
_ -> nil
end
end
end
defp field_key_to_value_type(_key, _member_field_strings, _custom_field_by_id), do: nil
end