Improve UX of join requests and fix minor bugs #492

Merged
simon merged 12 commits from bugfix/480-fix-minor-bugs into main 2026-05-06 14:34:44 +02:00
9 changed files with 253 additions and 161 deletions
Showing only changes of commit cc1df449c6 - Show all commits

View file

@ -0,0 +1,56 @@
defmodule Mv.Membership.CustomFieldLookup do
@moduledoc """
Shared helper for loading custom fields by ID.
"""
alias Mv.Constants
alias Mv.Membership
@spec fetch_map_by_ids([String.t()], keyword()) :: map()
def fetch_map_by_ids(field_ids, opts \\ []) when is_list(field_ids) do
member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
custom_field_ids =
field_ids
|> Enum.uniq()
|> Enum.reject(&(&1 in member_field_strings))
if custom_field_ids == [] do
%{}
else
select = Keyword.get(opts, :select, [:id, :name, :value_type])
query =
Membership.CustomField
|> Ash.Query.select(select)
read_opts =
[domain: Membership]
|> maybe_put_actor(opts)
|> maybe_put_authorize(opts)
case Ash.read(query, read_opts) do
{:ok, fields} ->
allowed_ids = MapSet.new(custom_field_ids)
fields |> Enum.filter(&MapSet.member?(allowed_ids, &1.id)) |> Map.new(&{&1.id, &1})
{:error, _} ->
%{}
end
end
end
defp maybe_put_actor(opts, read_opts) do
case Keyword.fetch(read_opts, :actor) do
{:ok, actor} -> Keyword.put(opts, :actor, actor)
:error -> opts
end
end
defp maybe_put_authorize(opts, read_opts) do
case Keyword.fetch(read_opts, :authorize?) do
{:ok, authorize?} -> Keyword.put(opts, :authorize?, authorize?)
:error -> opts
end
end
end

View file

@ -194,7 +194,7 @@ defmodule MvWeb.GlobalSettingsLive do
aria-label={gettext("Open join page URL in a new tab")} aria-label={gettext("Open join page URL in a new tab")}
> >
<.icon name="hero-arrow-top-right-on-square" class="size-4" /> <.icon name="hero-arrow-top-right-on-square" class="size-4" />
{gettext("Open")} {pgettext("action", "Open")}
</.link> </.link>
</div> </div>
</div> </div>

View file

@ -6,6 +6,7 @@ defmodule MvWeb.JoinLive do
use MvWeb, :live_view use MvWeb, :live_view
alias Ash.Resource.Info alias Ash.Resource.Info
alias Mv.Membership.CustomFieldLookup
alias Mv.Membership alias Mv.Membership
alias MvWeb.JoinRateLimit alias MvWeb.JoinRateLimit
alias MvWeb.Translations.MemberFields alias MvWeb.Translations.MemberFields
@ -87,6 +88,7 @@ defmodule MvWeb.JoinLive do
<span class="label-text">{field.label}{if field.required, do: " *"}</span> <span class="label-text">{field.label}{if field.required, do: " *"}</span>
</label> </label>
<%= if field.input_type == "checkbox" do %> <%= if field.input_type == "checkbox" do %>
<input type="hidden" name={field.id} value="off" />
<input <input
type="checkbox" type="checkbox"
name={field.id} name={field.id}
@ -249,32 +251,10 @@ defmodule MvWeb.JoinLive do
end end
end end
defp custom_field_map(allowlist, member_field_strings) do defp custom_field_map(allowlist, _member_field_strings) do
custom_field_ids = allowlist
allowlist |> Enum.map(& &1.id)
|> Enum.map(& &1.id) |> CustomFieldLookup.fetch_map_by_ids(authorize?: false, select: [:id, :name, :value_type])
|> Enum.reject(&(&1 in member_field_strings))
case custom_field_ids do
[] ->
%{}
ids ->
Mv.Membership.CustomField
|> Ash.Query.select([:id, :name, :value_type])
|> Ash.read(domain: Mv.Membership, authorize?: false)
|> case do
{:ok, fields} ->
allowed_ids = MapSet.new(ids)
fields
|> Enum.filter(&MapSet.member?(allowed_ids, &1.id))
|> Map.new(&{&1.id, &1})
{:error, _} ->
%{}
end
end
end end
defp initial_form_params(join_fields) do defp initial_form_params(join_fields) do
@ -342,9 +322,12 @@ defmodule MvWeb.JoinLive do
} }
form_data = form_data =
params join_fields
|> Enum.filter(fn {key, _} -> key in allowlist_ids and key not in typed end) |> Enum.filter(&(&1.id not in typed))
|> Map.new(fn {k, v} -> {k, String.trim(to_string(v))} end) |> Map.new(fn field ->
{field.id, normalize_join_field_value(params[field.id], field.input_type)}
end)
|> Map.take(MapSet.to_list(allowlist_ids))
attrs = %{attrs | form_data: form_data} attrs = %{attrs | form_data: form_data}
{:ok, attrs} {:ok, attrs}
@ -356,6 +339,10 @@ defmodule MvWeb.JoinLive do
if is_binary(v), do: String.trim(v), else: nil if is_binary(v), do: String.trim(v), else: nil
end end
defp normalize_join_field_value(raw, _input_type) when is_binary(raw), do: String.trim(raw)
defp normalize_join_field_value(_raw, "checkbox"), do: "off"
defp normalize_join_field_value(_raw, _input_type), do: ""
# Prefer X-Forwarded-For / X-Real-IP when behind a reverse proxy; fall back to peer_data. # Prefer X-Forwarded-For / X-Real-IP when behind a reverse proxy; fall back to peer_data.
# Uses :inet.ntoa/1 for correct IPv4 and IPv6 string representation. # Uses :inet.ntoa/1 for correct IPv4 and IPv6 string representation.
defp client_ip_from_socket(socket) do defp client_ip_from_socket(socket) do

View file

@ -21,6 +21,7 @@ defmodule MvWeb.JoinRequestLive.Show do
alias Mv.Constants alias Mv.Constants
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.CustomFieldLookup
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations
@ -31,7 +32,7 @@ defmodule MvWeb.JoinRequestLive.Show do
{:ok, {:ok,
socket socket
|> assign(:join_request, nil) |> assign(:join_request, nil)
|> assign(:custom_field_name_by_id, %{}) |> assign(:custom_field_by_id, %{})
|> assign(:join_form_field_ids, []) |> assign(:join_form_field_ids, [])
|> Layouts.assign_page_title(gettext("Join request"))} |> Layouts.assign_page_title(gettext("Join request"))}
else else
@ -54,13 +55,16 @@ defmodule MvWeb.JoinRequestLive.Show do
{:ok, request} -> {:ok, request} ->
field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id) field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id)
custom_field_name_by_id = custom_field_by_id =
custom_field_name_map(field_ids ++ Map.keys(request.form_data || %{}), actor) CustomFieldLookup.fetch_map_by_ids(field_ids ++ Map.keys(request.form_data || %{}),
actor: actor,
select: [:id, :name, :value_type]
)
{:noreply, {:noreply,
socket socket
|> assign(:join_request, request) |> assign(:join_request, request)
|> assign(:custom_field_name_by_id, custom_field_name_by_id) |> assign(:custom_field_by_id, custom_field_by_id)
|> assign(:join_form_field_ids, field_ids) |> assign(:join_form_field_ids, field_ids)
|> Layouts.assign_page_title(gettext("Join request %{email}", email: request.email))} |> Layouts.assign_page_title(gettext("Join request %{email}", email: request.email))}
@ -136,59 +140,58 @@ defmodule MvWeb.JoinRequestLive.Show do
<%!-- Single block: all applicant-provided data in join form order --%> <%!-- Single block: all applicant-provided data in join form order --%>
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Applicant data")}</h2> <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 space-y-2"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<%= for {label, value} <- <dl class="grid gap-1 md:grid-cols-[14rem_minmax(0,1fr)] md:gap-2">
<%= for {label, value} <-
applicant_data_rows( applicant_data_rows(
@join_request, @join_request,
@join_form_field_ids || [], @join_form_field_ids || [],
@custom_field_name_by_id || %{} @custom_field_by_id || %{}
) do %> ) do %>
<.field_row label={label} value={value} empty_text={gettext("Not specified")} /> <.field_row label={label} value={value} empty_text={gettext("Not specified")} />
<% end %> <% end %>
</dl>
</div> </div>
</div> </div>
<%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%> <%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%>
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Status and review")}</h2> <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 space-y-2"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<.field_row
label={gettext("Submitted at")}
value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)}
/>
<dl class="grid gap-1 md:grid-cols-[14rem_minmax(0,1fr)] md:gap-2"> <dl class="grid gap-1 md:grid-cols-[14rem_minmax(0,1fr)] md:gap-2">
<dt class="m-0 text-base-content/60 whitespace-normal break-words"> <.field_row
{gettext("Status")}: label={gettext("Submitted at")}
</dt> value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)}
<dd class="m-0 min-w-0"> />
<.field_row label={gettext("Status")}>
<.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}> <.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}>
{JoinRequestHelpers.format_status(@join_request.status)} {JoinRequestHelpers.format_status(@join_request.status)}
</.badge> </.badge>
</dd> </.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> </dl>
<%= 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 %>
</div> </div>
</div> </div>
@ -221,28 +224,30 @@ defmodule MvWeb.JoinRequestLive.Show do
attr :label, :string, required: true attr :label, :string, required: true
attr :value, :any, default: nil attr :value, :any, default: nil
attr :empty_text, :string, default: nil attr :empty_text, :string, default: nil
slot :inner_block
defp field_row(assigns) do defp field_row(assigns) do
~H""" ~H"""
<dl class="grid gap-1 md:grid-cols-[14rem_minmax(0,1fr)] md:gap-2"> <dt class="m-0 text-base-content/60 whitespace-normal break-words">{@label}:</dt>
<dt class="m-0 text-base-content/60 whitespace-normal break-words">{@label}:</dt> <dd class="m-0 min-w-0">
<dd class="m-0 min-w-0"> <%= cond do %>
<%= if @value && @value != "" do %> <% @inner_block != [] -> %>
{render_slot(@inner_block)}
<% @value && @value != "" -> %>
{@value} {@value}
<% else %> <% true -> %>
<span class="text-base-content/40 italic"> <span class="text-base-content/40 italic">
{@empty_text || gettext("Not specified")} {@empty_text || gettext("Not specified")}
</span> </span>
<% end %> <% end %>
</dd> </dd>
</dl>
""" """
end end
# Builds a single list of {label, display_value} for all applicant-provided data in join form # 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 # 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. # 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_name_by_id) do 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) member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
form_data = join_request.form_data || %{} form_data = join_request.form_data || %{}
@ -256,8 +261,9 @@ defmodule MvWeb.JoinRequestLive.Show do
ordered_field_ids ordered_field_ids
|> Enum.map(fn key -> |> Enum.map(fn key ->
value = Map.get(typed, key) || Map.get(form_data, key) value = Map.get(typed, key) || Map.get(form_data, key)
label = field_key_to_label(key, member_field_strings, custom_field_name_by_id) label = field_key_to_label(key, member_field_strings, custom_field_by_id)
{label, format_applicant_value(value)} value_type = field_key_to_value_type(key, member_field_strings, custom_field_by_id)
{label, format_applicant_value(value, value_type)}
end) end)
legacy_keys = legacy_keys =
@ -270,31 +276,32 @@ defmodule MvWeb.JoinRequestLive.Show do
legacy_entries = legacy_entries =
Enum.map(legacy_keys, fn key -> Enum.map(legacy_keys, fn key ->
label = field_key_to_label(key, member_field_strings, custom_field_name_by_id) label = field_key_to_label(key, member_field_strings, custom_field_by_id)
{label, format_applicant_value(form_data[key])} 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) end)
in_order ++ legacy_entries in_order ++ legacy_entries
end end
defp format_applicant_value(nil), do: nil defp format_applicant_value(nil, _type), do: nil
defp format_applicant_value(""), do: nil defp format_applicant_value("", _type), do: nil
defp format_applicant_value(%Date{} = date), do: DateFormatter.format_date(date) defp format_applicant_value(%Date{} = date, _type), do: DateFormatter.format_date(date)
defp format_applicant_value(value) when is_map(value), defp format_applicant_value(value, type) when is_map(value),
do: format_applicant_value_from_map(value) do: format_applicant_value_from_map(value, type)
defp format_applicant_value(value) when is_boolean(value), defp format_applicant_value(value, _type) when is_boolean(value),
do: if(value, do: gettext("Yes"), else: gettext("No")) do: if(value, do: gettext("Yes"), else: gettext("No"))
defp format_applicant_value(value) when is_binary(value), defp format_applicant_value(value, type) when is_binary(value),
do: format_binary_applicant_value(value) do: format_binary_applicant_value(value, type)
defp format_applicant_value(value) when is_number(value), do: to_string(value) defp format_applicant_value(value, _type) when is_number(value), do: to_string(value)
defp format_applicant_value(value), do: to_string(value) defp format_applicant_value(value, _type), do: to_string(value)
defp format_binary_applicant_value(value) do defp format_binary_applicant_value(value, type) do
trimmed_value = String.trim(value) trimmed_value = String.trim(value)
cond do cond do
@ -307,8 +314,11 @@ defmodule MvWeb.JoinRequestLive.Show do
String.downcase(trimmed_value) in ["off", "false", "0"] -> String.downcase(trimmed_value) in ["off", "false", "0"] ->
gettext("No") gettext("No")
true -> type in [:date, Ash.Type.Date] ->
format_iso_date_string(trimmed_value) format_iso_date_string(trimmed_value)
true ->
trimmed_value
end end
end end
@ -319,12 +329,13 @@ defmodule MvWeb.JoinRequestLive.Show do
end end
end end
defp format_applicant_value_from_map(value) do defp format_applicant_value_from_map(value, fallback_type) do
raw = Map.get(value, "_union_value") || Map.get(value, "value") raw = Map.get(value, "_union_value") || Map.get(value, "value")
type = Map.get(value, "_union_type") || Map.get(value, "type") type = Map.get(value, "_union_type") || Map.get(value, "type")
effective_type = type || fallback_type
if raw && type in ["date", :date] do if raw && effective_type in ["date", :date, Ash.Type.Date] do
format_applicant_value(raw) format_applicant_value(raw, :date)
else else
format_applicant_value_simple(raw, value) format_applicant_value_simple(raw, value)
end end
@ -338,44 +349,39 @@ defmodule MvWeb.JoinRequestLive.Show do
defp format_applicant_value_simple(raw, _value) when is_integer(raw), do: to_string(raw) 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 format_applicant_value_simple(_raw, value), do: to_string(value)
defp field_key_to_label(key, member_field_strings, custom_field_name_by_id) defp field_key_to_label(key, member_field_strings, custom_field_by_id)
when is_binary(key) do when is_binary(key) do
if key in member_field_strings do if key in member_field_strings do
MemberFieldsTranslations.label(String.to_existing_atom(key)) MemberFieldsTranslations.label(String.to_existing_atom(key))
else else
Map.get(custom_field_name_by_id, key, key) case Map.get(custom_field_by_id, key) do
%{name: name} -> name
_ -> key
end
end end
end end
defp field_key_to_label(key, _, _), do: to_string(key) defp field_key_to_label(key, _, _), do: to_string(key)
defp custom_field_name_map(field_keys, actor) do defp field_key_to_value_type("email", _member_field_strings, _custom_field_by_id), do: :string
member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1)
custom_field_ids = defp field_key_to_value_type("first_name", _member_field_strings, _custom_field_by_id),
field_keys do: :string
|> Enum.uniq()
|> Enum.reject(&(&1 in member_field_strings))
case custom_field_ids do defp field_key_to_value_type("last_name", _member_field_strings, _custom_field_by_id),
[] -> do: :string
%{}
ids -> defp field_key_to_value_type(key, member_field_strings, custom_field_by_id)
Mv.Membership.CustomField when is_binary(key) do
|> Ash.Query.select([:id, :name]) if key in member_field_strings do
|> Ash.read(actor: actor, domain: Mv.Membership) :string
|> case do else
{:ok, fields} -> case Map.get(custom_field_by_id, key) do
allowed_ids = MapSet.new(ids) %{value_type: value_type} -> value_type
_ -> nil
fields end
|> Enum.filter(&MapSet.member?(allowed_ids, &1.id))
|> Map.new(&{&1.id, &1.name})
{:error, _} ->
%{}
end
end end
end end
defp field_key_to_value_type(_key, _member_field_strings, _custom_field_by_id), do: nil
end end

View file

@ -1291,7 +1291,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp translate_receipt_status("paid"), do: gettext("Paid") defp translate_receipt_status("paid"), do: gettext("Paid")
defp translate_receipt_status("unpaid"), do: gettext("Unpaid") defp translate_receipt_status("unpaid"), do: gettext("Unpaid")
defp translate_receipt_status("suspended"), do: gettext("Suspended") defp translate_receipt_status("suspended"), do: gettext("Suspended")
defp translate_receipt_status("open"), do: gettext("Open") defp translate_receipt_status("open"), do: pgettext("status", "Open")
defp translate_receipt_status("cancelled"), do: gettext("Cancelled") defp translate_receipt_status("cancelled"), do: gettext("Cancelled")
defp translate_receipt_status("draft"), do: gettext("Draft") defp translate_receipt_status("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incomplete") defp translate_receipt_status("incompleted"), do: gettext("Incomplete")

View file

@ -2396,12 +2396,6 @@ msgstr "Nur Administrator*innen oder die verknüpfte*n Benutzer*in(nen) können
msgid "Only possible if no members are assigned to this type." msgid "Only possible if no members are assigned to this type."
msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind." msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr "Öffnen"
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients" msgid "Open email program with BCC recipients"
@ -3911,3 +3905,15 @@ msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt."
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open join page URL in a new tab" msgid "Open join page URL in a new tab"
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
msgid "Open"
msgstr "Öffnen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgctxt "status"
msgid "Open"
msgstr "Offen"

View file

@ -2397,12 +2397,6 @@ msgstr ""
msgid "Only possible if no members are assigned to this type." msgid "Only possible if no members are assigned to this type."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients" msgid "Open email program with BCC recipients"
@ -3911,3 +3905,15 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open join page URL in a new tab" msgid "Open join page URL in a new tab"
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgctxt "status"
msgid "Open"
msgstr ""

View file

@ -2397,12 +2397,6 @@ msgstr ""
msgid "Only possible if no members are assigned to this type." msgid "Only possible if no members are assigned to this type."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients" msgid "Open email program with BCC recipients"
@ -3911,3 +3905,15 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open join page URL in a new tab" msgid "Open join page URL in a new tab"
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
msgid "Open"
msgstr "Open"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgctxt "status"
msgid "Open"
msgstr "Open"

View file

@ -12,10 +12,9 @@ defmodule MvWeb.JoinLiveTest do
# async: false → shared sandbox; all processes (including LiveView) share the DB connection. # async: false → shared sandbox; all processes (including LiveView) share the DB connection.
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Ecto.Query
alias Mv.Membership alias Mv.Membership
alias Mv.Repo alias Mv.Membership.JoinRequest
describe "GET /join" do describe "GET /join" do
@tag role: :unauthenticated @tag role: :unauthenticated
@ -55,11 +54,12 @@ defmodule MvWeb.JoinLiveTest do
}) })
|> render_submit() |> render_submit()
# Anti-enumeration delay is applied in LiveView via send_after (100300 ms); wait for success UI. assert_eventually(fn -> count_join_requests() == count_before + 1 end)
Process.sleep(400)
assert_eventually(fn ->
view |> element("[data-testid='join-success-message']") |> has_element?()
end)
assert count_join_requests() == count_before + 1
assert view |> element("[data-testid='join-success-message']") |> has_element?()
assert render(view) =~ "saved your details" assert render(view) =~ "saved your details"
assert render(view) =~ "click the link" assert render(view) =~ "click the link"
end end
@ -298,10 +298,11 @@ defmodule MvWeb.JoinLiveTest do
"not_allowlisted" => "should-not-be-persisted" "not_allowlisted" => "should-not-be-persisted"
}) })
Process.sleep(400) assert_eventually(fn -> count_join_requests() == count_before + 1 end)
assert count_join_requests() == count_before + 1 assert_eventually(fn ->
assert view |> element("[data-testid='join-success-message']") |> has_element?() view |> element("[data-testid='join-success-message']") |> has_element?()
end)
form_data = latest_join_request_form_data() form_data = latest_join_request_form_data()
assert Map.get(form_data, boolean_field.id) == "on" assert Map.get(form_data, boolean_field.id) == "on"
@ -328,16 +329,40 @@ defmodule MvWeb.JoinLiveTest do
end end
defp count_join_requests do defp count_join_requests do
Repo.one(from j in "join_requests", select: count(j.id)) || 0 case Ash.count(JoinRequest, domain: Membership, authorize?: false) do
{:ok, count} -> count
_ -> 0
end
end end
defp latest_join_request_form_data do defp latest_join_request_form_data do
Repo.one( query =
from j in "join_requests", JoinRequest
order_by: [desc: j.inserted_at], |> Ash.Query.sort(inserted_at: :desc)
limit: 1, |> Ash.Query.limit(1)
select: j.form_data
) || %{} case Ash.read(query, domain: Membership, authorize?: false) do
{:ok, [request]} -> request.form_data || %{}
_ -> %{}
end
end
defp assert_eventually(fun, timeout_ms \\ 1500) when is_function(fun, 0) do
deadline = System.monotonic_time(:millisecond) + timeout_ms
do_assert_eventually(fun, deadline)
end
defp do_assert_eventually(fun, deadline) do
if fun.() do
true
else
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(25)
do_assert_eventually(fun, deadline)
else
assert fun.()
end
end
end end
defp reset_rate_limiter do defp reset_rate_limiter do