From a6f6f402af6fb4780026e69a484f08861a9dd4e6 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 5 May 2026 17:01:10 +0200 Subject: [PATCH 01/79] test: add tests for custom field labels --- test/mv_web/live/join_live_test.exs | 28 ++++++++ .../live/join_request_live/show_test.exs | 71 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 test/mv_web/live/join_request_live/show_test.exs diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs index 4772e48..383b413 100644 --- a/test/mv_web/live/join_live_test.exs +++ b/test/mv_web/live/join_live_test.exs @@ -135,6 +135,34 @@ defmodule MvWeb.JoinLiveTest do end end + describe "join field labels" do + @tag role: :unauthenticated + test "renders custom field name as label for custom field IDs", %{conn: conn} do + {:ok, settings} = Membership.get_settings() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, custom_field} = + Membership.create_custom_field( + %{ + name: "Preferred Pronouns", + value_type: :string + }, + actor: system_actor + ) + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", custom_field.id], + join_form_field_required: %{"email" => true, custom_field.id => false} + }) + + {:ok, view, _html} = live(conn, "/join") + + assert has_element?(view, "label[for='join-field-#{custom_field.id}'] .label-text", custom_field.name) + end + end + defp enable_join_form(enabled) do {:ok, settings} = Membership.get_settings() {:ok, _} = Membership.update_settings(settings, %{join_form_enabled: enabled}) diff --git a/test/mv_web/live/join_request_live/show_test.exs b/test/mv_web/live/join_request_live/show_test.exs new file mode 100644 index 0000000..35e87dc --- /dev/null +++ b/test/mv_web/live/join_request_live/show_test.exs @@ -0,0 +1,71 @@ +defmodule MvWeb.JoinRequestLive.ShowTest do + @moduledoc """ + Tests for join request detail view label rendering. + + Focus: applicant data labels for custom fields should use custom field names, + not raw UUIDs. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Fixtures + alias Mv.Membership + + setup do + {:ok, settings} = Membership.get_settings() + + saved = %{ + join_form_enabled: settings.join_form_enabled, + join_form_field_ids: settings.join_form_field_ids, + join_form_field_required: settings.join_form_field_required + } + + on_exit(fn -> + {:ok, current_settings} = Membership.get_settings() + + Membership.update_settings(current_settings, %{ + join_form_enabled: saved.join_form_enabled, + join_form_field_ids: saved.join_form_field_ids || [], + join_form_field_required: saved.join_form_field_required || %{} + }) + end) + + :ok + end + + describe "custom field labels in applicant data" do + @tag role: :normal_user + test "renders custom field name instead of custom field UUID", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, settings} = Membership.get_settings() + + {:ok, custom_field} = + Membership.create_custom_field( + %{ + name: "Emergency contact", + value_type: :string + }, + actor: system_actor + ) + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", custom_field.id], + join_form_field_required: %{"email" => true, custom_field.id => false} + }) + + join_request = + Fixtures.submitted_join_request_fixture(%{ + form_data: %{custom_field.id => "Alice Example"} + }) + + {:ok, view, _html} = live(conn, "/join_requests/#{join_request.id}") + + assert has_element?(view, "span", "#{custom_field.name}:") + assert has_element?(view, "span", "Alice Example") + refute has_element?(view, "span", "#{custom_field.id}:") + end + end +end From 0a7bbc7fa6d135c8fb1b6230098aa16524d1113c Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 11:05:28 +0200 Subject: [PATCH 02/79] fix: labels for custom fields in join requests --- lib/mv_web/components/layouts.ex | 2 +- lib/mv_web/live/join_live.ex | 31 +++++++++++- lib/mv_web/live/join_request_live/show.ex | 61 +++++++++++++++++++---- test/mv_web/live/join_live_test.exs | 6 ++- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 54f589d..9aff23c 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -138,7 +138,7 @@ defmodule MvWeb.Layouts do # Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query. %{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings() - # TODO: unprocessed count runs on every page load when join form enabled; consider + # NOTE: Unprocessed count runs on every page load when join form is enabled; consider # loading only on navigation or caching briefly if performance becomes an issue. unprocessed_join_requests_count = get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled) diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index 3b8db05..d3d66f0 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -216,19 +216,48 @@ defmodule MvWeb.JoinLive do defp build_join_fields_with_labels(allowlist) do member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + custom_field_name_by_id = custom_field_name_map(allowlist, member_field_strings) Enum.map(allowlist, fn %{id: id, required: required} -> label = if id in member_field_strings do MemberFields.label(String.to_existing_atom(id)) else - gettext("Field") + Map.get(custom_field_name_by_id, id, gettext("Field")) end %{id: id, label: label, required: required} end) end + defp custom_field_name_map(allowlist, member_field_strings) do + custom_field_ids = + allowlist + |> Enum.map(& &1.id) + |> Enum.reject(&(&1 in member_field_strings)) + + case custom_field_ids do + [] -> + %{} + + ids -> + Mv.Membership.CustomField + |> Ash.Query.select([:id, :name]) + |> 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.name}) + + {:error, _} -> + %{} + end + end + end + defp initial_form_params(join_fields) do join_fields |> Enum.map(fn f -> {f.id, ""} end) diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex index 304cb6a..2aab87d 100644 --- a/lib/mv_web/live/join_request_live/show.ex +++ b/lib/mv_web/live/join_request_live/show.ex @@ -31,6 +31,7 @@ defmodule MvWeb.JoinRequestLive.Show do {:ok, socket |> assign(:join_request, nil) + |> assign(:custom_field_name_by_id, %{}) |> assign(:join_form_field_ids, []) |> Layouts.assign_page_title(gettext("Join request"))} else @@ -53,9 +54,13 @@ defmodule MvWeb.JoinRequestLive.Show do {:ok, request} -> field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id) + custom_field_name_by_id = + custom_field_name_map(field_ids ++ Map.keys(request.form_data || %{}), actor) + {:noreply, socket |> assign(:join_request, request) + |> assign(:custom_field_name_by_id, custom_field_name_by_id) |> assign(:join_form_field_ids, field_ids) |> Layouts.assign_page_title(gettext("Join request – %{email}", email: request.email))} @@ -132,7 +137,12 @@ defmodule MvWeb.JoinRequestLive.Show do

{gettext("Applicant data")}

- <%= for {label, value} <- applicant_data_rows(@join_request, @join_form_field_ids || []) do %> + <%= for {label, value} <- + applicant_data_rows( + @join_request, + @join_form_field_ids || [], + @custom_field_name_by_id || %{} + ) do %> <.field_row label={label} value={value} empty_text={gettext("Not specified")} /> <% end %>
@@ -230,7 +240,7 @@ defmodule MvWeb.JoinRequestLive.Show do # 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) do + defp applicant_data_rows(join_request, ordered_field_ids, custom_field_name_by_id) do member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1) form_data = join_request.form_data || %{} @@ -244,7 +254,7 @@ defmodule MvWeb.JoinRequestLive.Show do 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) + label = field_key_to_label(key, member_field_strings, custom_field_name_by_id) {label, format_applicant_value(value)} end) @@ -258,7 +268,7 @@ defmodule MvWeb.JoinRequestLive.Show do legacy_entries = Enum.map(legacy_keys, fn key -> - label = field_key_to_label(key, member_field_strings) + label = field_key_to_label(key, member_field_strings, custom_field_name_by_id) {label, format_applicant_value(form_data[key])} end) @@ -299,11 +309,44 @@ 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), do: to_string(value) - defp field_key_to_label(key, member_field_strings) when is_binary(key) do - if key in member_field_strings, - do: MemberFieldsTranslations.label(String.to_existing_atom(key)), - else: key + defp field_key_to_label(key, member_field_strings, custom_field_name_by_id) + when is_binary(key) do + if key in member_field_strings do + MemberFieldsTranslations.label(String.to_existing_atom(key)) + else + Map.get(custom_field_name_by_id, key, key) + 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 + member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + custom_field_ids = + field_keys + |> Enum.uniq() + |> Enum.reject(&(&1 in member_field_strings)) + + case custom_field_ids do + [] -> + %{} + + ids -> + Mv.Membership.CustomField + |> Ash.Query.select([:id, :name]) + |> Ash.read(actor: actor, domain: Mv.Membership) + |> case do + {:ok, fields} -> + allowed_ids = MapSet.new(ids) + + fields + |> Enum.filter(&MapSet.member?(allowed_ids, &1.id)) + |> Map.new(&{&1.id, &1.name}) + + {:error, _} -> + %{} + end + end + end end diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs index 383b413..273e786 100644 --- a/test/mv_web/live/join_live_test.exs +++ b/test/mv_web/live/join_live_test.exs @@ -159,7 +159,11 @@ defmodule MvWeb.JoinLiveTest do {:ok, view, _html} = live(conn, "/join") - assert has_element?(view, "label[for='join-field-#{custom_field.id}'] .label-text", custom_field.name) + assert has_element?( + view, + "label[for='join-field-#{custom_field.id}'] .label-text", + custom_field.name + ) end end From 95b666f04f77e9e1253974352d55f782421a617a Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 11:14:09 +0200 Subject: [PATCH 03/79] test: verify that join view respects custom field types --- test/mv_web/live/join_live_test.exs | 123 ++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs index 273e786..20cd5cf 100644 --- a/test/mv_web/live/join_live_test.exs +++ b/test/mv_web/live/join_live_test.exs @@ -167,6 +167,120 @@ defmodule MvWeb.JoinLiveTest do end end + describe "join field input types" do + @tag role: :unauthenticated + test "renders boolean custom field as checkbox input", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, settings} = Membership.get_settings() + + {:ok, boolean_field} = + Membership.create_custom_field( + %{ + name: "Subscribe to newsletter", + value_type: :boolean + }, + actor: system_actor + ) + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", boolean_field.id], + join_form_field_required: %{"email" => true, boolean_field.id => false} + }) + + {:ok, view, _html} = live(conn, "/join") + + assert has_element?(view, "#join-form") + assert has_element?(view, "input#join-field-#{boolean_field.id}[name='#{boolean_field.id}']") + assert has_element?(view, "input#join-field-#{boolean_field.id}[type='checkbox']") + refute has_element?(view, "input#join-field-#{boolean_field.id}[type='text']") + end + + @tag role: :unauthenticated + test "renders typed custom fields with matching HTML input types", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, settings} = Membership.get_settings() + + {:ok, integer_field} = + Membership.create_custom_field(%{name: "Lucky number", value_type: :integer}, actor: system_actor) + + {:ok, date_field} = + Membership.create_custom_field(%{name: "Birth date", value_type: :date}, actor: system_actor) + + {:ok, email_field} = + Membership.create_custom_field(%{name: "Secondary email", value_type: :email}, actor: system_actor) + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", integer_field.id, date_field.id, email_field.id], + join_form_field_required: %{ + "email" => true, + integer_field.id => false, + date_field.id => false, + email_field.id => false + } + }) + + {:ok, view, _html} = live(conn, "/join") + + assert has_element?(view, "input#join-field-#{integer_field.id}[type='number']") + assert has_element?(view, "input#join-field-#{date_field.id}[type='date']") + assert has_element?(view, "input#join-field-#{email_field.id}[type='email']") + end + end + + describe "submit join form with typed custom fields" do + setup do + reset_rate_limiter() + :ok + end + + @tag role: :unauthenticated + test "persists checked boolean custom field and ignores non-allowlisted field", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, settings} = Membership.get_settings() + + {:ok, boolean_field} = + Membership.create_custom_field( + %{ + name: "Receive announcements", + value_type: :boolean + }, + actor: system_actor + ) + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", boolean_field.id], + join_form_field_required: %{"email" => true, boolean_field.id => false} + }) + + count_before = count_join_requests() + {:ok, view, _html} = live(conn, "/join") + + view + |> element("#join-form") + |> render_submit(%{ + "email" => "typed#{System.unique_integer([:positive])}@example.com", + "website" => "", + boolean_field.id => "on", + "not_allowlisted" => "should-not-be-persisted" + }) + + Process.sleep(400) + + assert count_join_requests() == count_before + 1 + assert view |> element("[data-testid='join-success-message']") |> has_element?() + + form_data = latest_join_request_form_data() + assert Map.get(form_data, boolean_field.id) == "on" + refute Map.has_key?(form_data, "not_allowlisted") + end + end + defp enable_join_form(enabled) do {:ok, settings} = Membership.get_settings() {:ok, _} = Membership.update_settings(settings, %{join_form_enabled: enabled}) @@ -189,6 +303,15 @@ defmodule MvWeb.JoinLiveTest do Repo.one(from j in "join_requests", select: count(j.id)) || 0 end + defp latest_join_request_form_data do + Repo.one( + from j in "join_requests", + order_by: [desc: j.inserted_at], + limit: 1, + select: j.form_data + ) || %{} + end + defp reset_rate_limiter do :ets.delete_all_objects(MvWeb.JoinRateLimit) rescue From 6327ea00ebe0cb1488f30fd3bfb37f55b14a7f69 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 11:37:40 +0200 Subject: [PATCH 04/79] feat: respect field types in join requests --- lib/mv_web/live/join_live.ex | 110 +++++++++++++++++++++------- test/mv_web/live/join_live_test.exs | 36 ++++++++- 2 files changed, 115 insertions(+), 31 deletions(-) diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index d3d66f0..430a6fe 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -5,6 +5,7 @@ defmodule MvWeb.JoinLive do """ use MvWeb, :live_view + alias Ash.Resource.Info alias Mv.Membership alias MvWeb.JoinRateLimit alias MvWeb.Translations.MemberFields @@ -54,10 +55,6 @@ defmodule MvWeb.JoinLive do {gettext("Become a member")} -

- {gettext("Please enter your details for the membership application here.")} -

- <%= if @submitted do %>

@@ -67,6 +64,9 @@ defmodule MvWeb.JoinLive do

<% else %> +

+ {gettext("Please enter your details for the membership application here.")} +

<.form for={@form} id="join-form" @@ -80,18 +80,31 @@ defmodule MvWeb.JoinLive do <% end %> <%= for field <- @join_fields do %> -
+
- + <%= if field.input_type == "checkbox" do %> + + <% else %> + + <% end %>
<% end %> @@ -216,21 +229,27 @@ defmodule MvWeb.JoinLive do defp build_join_fields_with_labels(allowlist) do member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) - custom_field_name_by_id = custom_field_name_map(allowlist, member_field_strings) + custom_field_by_id = custom_field_map(allowlist, member_field_strings) Enum.map(allowlist, fn %{id: id, required: required} -> - label = - if id in member_field_strings do - MemberFields.label(String.to_existing_atom(id)) - else - Map.get(custom_field_name_by_id, id, gettext("Field")) - end - - %{id: id, label: label, required: required} + build_join_field(id, required, member_field_strings, custom_field_by_id) end) end - defp custom_field_name_map(allowlist, member_field_strings) do + defp build_join_field(id, required, member_field_strings, custom_field_by_id) do + if id in member_field_strings do + label = MemberFields.label(String.to_existing_atom(id)) + %{id: id, label: label, required: required, input_type: member_field_input_type(id)} + else + custom_field = Map.get(custom_field_by_id, id) + label = if custom_field, do: custom_field.name, else: gettext("Field") + input_type = custom_field_input_type(custom_field && custom_field.value_type) + + %{id: id, label: label, required: required, input_type: input_type} + end + end + + defp custom_field_map(allowlist, member_field_strings) do custom_field_ids = allowlist |> Enum.map(& &1.id) @@ -242,7 +261,7 @@ defmodule MvWeb.JoinLive do ids -> Mv.Membership.CustomField - |> Ash.Query.select([:id, :name]) + |> Ash.Query.select([:id, :name, :value_type]) |> Ash.read(domain: Mv.Membership, authorize?: false) |> case do {:ok, fields} -> @@ -250,7 +269,7 @@ defmodule MvWeb.JoinLive do fields |> Enum.filter(&MapSet.member?(allowed_ids, &1.id)) - |> Map.new(&{&1.id, &1.name}) + |> Map.new(&{&1.id, &1}) {:error, _} -> %{} @@ -265,8 +284,45 @@ defmodule MvWeb.JoinLive do |> Map.put(@honeypot_field, "") end - defp input_type("email"), do: "email" - defp input_type(_), do: "text" + defp member_field_input_type("email"), do: "email" + + defp member_field_input_type(field_id) when is_binary(field_id) do + case member_field_atom(field_id) do + nil -> + "text" + + field_atom -> + Mv.Membership.Member + |> Info.attribute(field_atom) + |> attribute_to_input_type() + end + end + + defp member_field_input_type(_), do: "text" + + defp member_field_atom(field_id) when is_binary(field_id) do + Mv.Constants.member_fields() + |> Enum.find(&(Atom.to_string(&1) == field_id)) + end + + defp custom_field_input_type(type), do: attribute_to_input_type(%{type: type}) + + defp attribute_to_input_type(%{type: type}) when type in [:date, Ash.Type.Date], do: "date" + + defp attribute_to_input_type(%{type: type}) when type in [:integer, Ash.Type.Integer], + do: "number" + + defp attribute_to_input_type(%{type: type}) when type in [:boolean, Ash.Type.Boolean], + do: "checkbox" + + defp attribute_to_input_type(%{type: type}) when type in [:email, Mv.Membership.Email], + do: "email" + + defp attribute_to_input_type(%{type: _}), do: "text" + defp attribute_to_input_type(nil), do: "text" + + defp checkbox_checked?(value) when value in [true, "true", "on", "1"], do: true + defp checkbox_checked?(_), do: false defp build_submit_attrs(params, join_fields) do allowlist_ids = MapSet.new(Enum.map(join_fields, & &1.id)) diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs index 20cd5cf..7bac60f 100644 --- a/test/mv_web/live/join_live_test.exs +++ b/test/mv_web/live/join_live_test.exs @@ -192,7 +192,12 @@ defmodule MvWeb.JoinLiveTest do {:ok, view, _html} = live(conn, "/join") assert has_element?(view, "#join-form") - assert has_element?(view, "input#join-field-#{boolean_field.id}[name='#{boolean_field.id}']") + + assert has_element?( + view, + "input#join-field-#{boolean_field.id}[name='#{boolean_field.id}']" + ) + assert has_element?(view, "input#join-field-#{boolean_field.id}[type='checkbox']") refute has_element?(view, "input#join-field-#{boolean_field.id}[type='text']") end @@ -203,13 +208,19 @@ defmodule MvWeb.JoinLiveTest do {:ok, settings} = Membership.get_settings() {:ok, integer_field} = - Membership.create_custom_field(%{name: "Lucky number", value_type: :integer}, actor: system_actor) + Membership.create_custom_field(%{name: "Lucky number", value_type: :integer}, + actor: system_actor + ) {:ok, date_field} = - Membership.create_custom_field(%{name: "Birth date", value_type: :date}, actor: system_actor) + Membership.create_custom_field(%{name: "Birth date", value_type: :date}, + actor: system_actor + ) {:ok, email_field} = - Membership.create_custom_field(%{name: "Secondary email", value_type: :email}, actor: system_actor) + Membership.create_custom_field(%{name: "Secondary email", value_type: :email}, + actor: system_actor + ) {:ok, _} = Membership.update_settings(settings, %{ @@ -229,6 +240,23 @@ defmodule MvWeb.JoinLiveTest do assert has_element?(view, "input#join-field-#{date_field.id}[type='date']") assert has_element?(view, "input#join-field-#{email_field.id}[type='email']") end + + @tag role: :unauthenticated + test "renders standard date member fields with date input type", %{conn: conn} do + {:ok, settings} = Membership.get_settings() + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "join_date"], + join_form_field_required: %{"email" => true, "join_date" => false} + }) + + {:ok, view, _html} = live(conn, "/join") + + assert has_element?(view, "input#join-field-join_date[type='date']") + refute has_element?(view, "input#join-field-join_date[type='text']") + end end describe "submit join form with typed custom fields" do From 15e9a52bc9ca53964263e5755c5d83b03c05eb8c Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 11:52:48 +0200 Subject: [PATCH 05/79] fix: wrap field labels in join request view --- lib/mv_web/live/join_request_live/show.ex | 10 +++++----- test/mv_web/live/join_request_live/show_test.exs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex index 2aab87d..cdc6521 100644 --- a/lib/mv_web/live/join_request_live/show.ex +++ b/lib/mv_web/live/join_request_live/show.ex @@ -222,9 +222,9 @@ defmodule MvWeb.JoinRequestLive.Show do defp field_row(assigns) do ~H""" -
- {@label}: - +
+
{@label}:
+
<%= if @value && @value != "" do %> {@value} <% else %> @@ -232,8 +232,8 @@ defmodule MvWeb.JoinRequestLive.Show do {@empty_text || gettext("Not specified")} <% end %> - -
+ + """ end diff --git a/test/mv_web/live/join_request_live/show_test.exs b/test/mv_web/live/join_request_live/show_test.exs index 35e87dc..5497d22 100644 --- a/test/mv_web/live/join_request_live/show_test.exs +++ b/test/mv_web/live/join_request_live/show_test.exs @@ -63,9 +63,9 @@ defmodule MvWeb.JoinRequestLive.ShowTest do {:ok, view, _html} = live(conn, "/join_requests/#{join_request.id}") - assert has_element?(view, "span", "#{custom_field.name}:") - assert has_element?(view, "span", "Alice Example") - refute has_element?(view, "span", "#{custom_field.id}:") + assert has_element?(view, "dt", "#{custom_field.name}:") + assert has_element?(view, "dd", "Alice Example") + refute has_element?(view, "dt", "#{custom_field.id}:") end end end From 0159d5352a2d936a8c8d1e2504f2235d3555bbb5 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 12:02:58 +0200 Subject: [PATCH 06/79] fix: joinrequest field formats --- lib/mv_web/live/join_request_live/show.ex | 43 ++++++++++++--- .../live/join_request_live/show_test.exs | 53 +++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex index cdc6521..644dbbe 100644 --- a/lib/mv_web/live/join_request_live/show.ex +++ b/lib/mv_web/live/join_request_live/show.ex @@ -156,14 +156,16 @@ defmodule MvWeb.JoinRequestLive.Show do label={gettext("Submitted at")} value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)} /> -
- {gettext("Status")}: - +
+
+ {gettext("Status")}: +
+
<.badge variant={JoinRequestHelpers.status_badge_variant(@join_request.status)}> {JoinRequestHelpers.format_status(@join_request.status)} - -
+ + <%= if @join_request.status in [:approved, :rejected] do %> <%= if @join_request.approved_at do %> <.field_row @@ -285,11 +287,38 @@ defmodule MvWeb.JoinRequestLive.Show do defp format_applicant_value(value) when is_boolean(value), do: if(value, do: gettext("Yes"), else: gettext("No")) - defp format_applicant_value(value) when is_binary(value) or is_number(value), - do: to_string(value) + defp format_applicant_value(value) when is_binary(value), + do: format_binary_applicant_value(value) + + defp format_applicant_value(value) when is_number(value), do: to_string(value) defp format_applicant_value(value), do: to_string(value) + defp format_binary_applicant_value(value) 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") + + true -> + format_iso_date_string(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) do raw = Map.get(value, "_union_value") || Map.get(value, "value") type = Map.get(value, "_union_type") || Map.get(value, "type") diff --git a/test/mv_web/live/join_request_live/show_test.exs b/test/mv_web/live/join_request_live/show_test.exs index 5497d22..40d5d5d 100644 --- a/test/mv_web/live/join_request_live/show_test.exs +++ b/test/mv_web/live/join_request_live/show_test.exs @@ -11,6 +11,7 @@ defmodule MvWeb.JoinRequestLive.ShowTest do alias Mv.Fixtures alias Mv.Membership + alias MvWeb.Helpers.DateFormatter setup do {:ok, settings} = Membership.get_settings() @@ -67,5 +68,57 @@ defmodule MvWeb.JoinRequestLive.ShowTest do assert has_element?(view, "dd", "Alice Example") refute has_element?(view, "dt", "#{custom_field.id}:") end + + @tag role: :normal_user + test "formats boolean/date values and renders status in aligned row", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, settings} = Membership.get_settings() + + {:ok, boolean_field} = + Membership.create_custom_field( + %{ + name: "Privacy accepted", + value_type: :boolean + }, + actor: system_actor + ) + + {:ok, date_field} = + Membership.create_custom_field( + %{ + name: "Birth date", + value_type: :date + }, + actor: system_actor + ) + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", boolean_field.id, date_field.id], + join_form_field_required: %{ + "email" => true, + boolean_field.id => false, + date_field.id => false + } + }) + + join_request = + Fixtures.submitted_join_request_fixture(%{ + form_data: %{ + boolean_field.id => "on", + date_field.id => "2000-01-12" + } + }) + + {:ok, view, _html} = live(conn, "/join_requests/#{join_request.id}") + + assert has_element?(view, "dt", "Privacy accepted:") + assert has_element?(view, "dd", "Yes") + assert has_element?(view, "dt", "Birth date:") + assert has_element?(view, "dd", DateFormatter.format_date(~D[2000-01-12])) + assert has_element?(view, "dt", "Status:") + assert has_element?(view, "dd", "Submitted") + end end end From d0d7d38c03ddaff2c5daf7c5a53a01df220e9f20 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 12:14:22 +0200 Subject: [PATCH 07/79] feat: add open button to join link in settings --- lib/mv_web/live/global_settings_live.ex | 10 ++++++++++ priv/gettext/de/LC_MESSAGES/default.po | 8 +++++++- priv/gettext/default.pot | 6 ++++++ priv/gettext/en/LC_MESSAGES/default.po | 6 ++++++ test/mv_web/live/global_settings_live_test.exs | 15 +++++++++++++++ 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index cb57631..b6f232b 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -186,6 +186,16 @@ defmodule MvWeb.GlobalSettingsLive do <.icon name="hero-clipboard-document" class="size-4" /> {gettext("Copy")} + <.link + href={@join_url} + target="_blank" + rel="noopener noreferrer" + class="btn btn-secondary btn-sm" + aria-label={gettext("Open join page URL in a new tab")} + > + <.icon name="hero-arrow-top-right-on-square" class="size-4" /> + {gettext("Open")} +
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 383fb1c..432fd33 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2396,10 +2396,11 @@ 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." 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 "Offen" +msgstr "Öffnen" #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format @@ -3905,3 +3906,8 @@ msgstr "Nur OIDC-Anmeldung ist aktiv. Diese Option ist deaktiviert." #, elixir-autogen, elixir-format msgid "Only sign-in via Single Sign-On (SSO) is allowed." msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Open join page URL in a new tab" +msgstr "" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 3e2eb5d..5039253 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2397,6 +2397,7 @@ msgstr "" msgid "Only possible if no members are assigned to this type." 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" @@ -3905,3 +3906,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Only sign-in via Single Sign-On (SSO) is allowed." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Open join page URL in a new tab" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 21314a5..b63ce04 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2397,6 +2397,7 @@ msgstr "" msgid "Only possible if no members are assigned to this type." 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" @@ -3905,3 +3906,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Only sign-in via Single Sign-On (SSO) is allowed." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Open join page URL in a new tab" +msgstr "" diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 92da11b..2edaf74 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -64,6 +64,21 @@ defmodule MvWeb.GlobalSettingsLiveTest do assert html =~ "must be present" end + + test "shows open button for join page URL in same row as copy", %{conn: conn} do + {:ok, settings} = Membership.get_settings() + {:ok, _} = Membership.update_settings(settings, %{join_form_enabled: true}) + + {:ok, view, _html} = live(conn, ~p"/settings") + + assert has_element?(view, "#copy-join-url-btn") + + assert has_element?( + view, + "a[href][target=\"_blank\"][rel=\"noopener noreferrer\"]", + "Open" + ) + end end describe "SMTP / E-Mail section" do From 104d945dd1fd38daf5f4d2f65951b42ceb61f804 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 12:26:10 +0200 Subject: [PATCH 08/79] chore: update change notes --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd65692..c17ea39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Join request display and settings workflow** – Improved join request rendering and related settings behavior in one cohesive update: + - Join request fields now respect their configured field types in the details view. + - Custom field labels in join request views were standardized. + - Join request field formatting was corrected for more consistent output. + - Join link settings now include a direct "Open" action in addition to copy/share workflows. + ### Fixed - **Runtime ENV handling** – Empty or invalid environment variables (e.g. `SMTP_PORT=`, `PORT=`, `POOL_SIZE=`, `DATABASE_PORT=`) no longer cause `ArgumentError` at boot. Instead raises clear errors for required vars set but empty (e.g. DATABASE_HOST, PHX_HOST/DOMAIN, SECRET_KEY_BASE). +- **PostgreSQL 18 Docker volume path** – Corrected the database volume path to match PostgreSQL 18 expectations. + +### Dependency updates +- Mix dependencies were updated. +- Renovate Docker image was updated to `v43.165`. +- Rauthy Docker image was updated to `v0.35.1`. +- `just` was updated to `v1.50.0`. ## [1.1.1] - 2026-03-16 From cc1df449c6fb55ee27d90934d99fc567e24080a6 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 13:54:22 +0200 Subject: [PATCH 09/79] refactor: fix review blockers --- lib/mv/membership/custom_field_lookup.ex | 56 +++++ lib/mv_web/live/global_settings_live.ex | 2 +- lib/mv_web/live/join_live.ex | 45 ++-- lib/mv_web/live/join_request_live/show.ex | 198 +++++++++--------- .../show/membership_fees_component.ex | 2 +- priv/gettext/de/LC_MESSAGES/default.po | 18 +- priv/gettext/default.pot | 18 +- priv/gettext/en/LC_MESSAGES/default.po | 18 +- test/mv_web/live/join_live_test.exs | 57 +++-- 9 files changed, 253 insertions(+), 161 deletions(-) create mode 100644 lib/mv/membership/custom_field_lookup.ex diff --git a/lib/mv/membership/custom_field_lookup.ex b/lib/mv/membership/custom_field_lookup.ex new file mode 100644 index 0000000..9d9b9f3 --- /dev/null +++ b/lib/mv/membership/custom_field_lookup.ex @@ -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 diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index b6f232b..492c813 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -194,7 +194,7 @@ defmodule MvWeb.GlobalSettingsLive do aria-label={gettext("Open join page URL in a new tab")} > <.icon name="hero-arrow-top-right-on-square" class="size-4" /> - {gettext("Open")} + {pgettext("action", "Open")} diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index 430a6fe..e3bc444 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -6,6 +6,7 @@ defmodule MvWeb.JoinLive do use MvWeb, :live_view alias Ash.Resource.Info + alias Mv.Membership.CustomFieldLookup alias Mv.Membership alias MvWeb.JoinRateLimit alias MvWeb.Translations.MemberFields @@ -87,6 +88,7 @@ defmodule MvWeb.JoinLive do {field.label}{if field.required, do: " *"} <%= if field.input_type == "checkbox" do %> + Enum.map(& &1.id) - |> 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 + defp custom_field_map(allowlist, _member_field_strings) do + allowlist + |> Enum.map(& &1.id) + |> CustomFieldLookup.fetch_map_by_ids(authorize?: false, select: [:id, :name, :value_type]) end defp initial_form_params(join_fields) do @@ -342,9 +322,12 @@ defmodule MvWeb.JoinLive do } form_data = - params - |> Enum.filter(fn {key, _} -> key in allowlist_ids and key not in typed end) - |> Map.new(fn {k, v} -> {k, String.trim(to_string(v))} end) + join_fields + |> Enum.filter(&(&1.id not in typed)) + |> 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} {:ok, attrs} @@ -356,6 +339,10 @@ defmodule MvWeb.JoinLive do if is_binary(v), do: String.trim(v), else: nil 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. # Uses :inet.ntoa/1 for correct IPv4 and IPv6 string representation. defp client_ip_from_socket(socket) do diff --git a/lib/mv_web/live/join_request_live/show.ex b/lib/mv_web/live/join_request_live/show.ex index 644dbbe..d634f53 100644 --- a/lib/mv_web/live/join_request_live/show.ex +++ b/lib/mv_web/live/join_request_live/show.ex @@ -21,6 +21,7 @@ defmodule MvWeb.JoinRequestLive.Show do 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 @@ -31,7 +32,7 @@ defmodule MvWeb.JoinRequestLive.Show do {:ok, socket |> assign(:join_request, nil) - |> assign(:custom_field_name_by_id, %{}) + |> assign(:custom_field_by_id, %{}) |> assign(:join_form_field_ids, []) |> Layouts.assign_page_title(gettext("Join request"))} else @@ -54,13 +55,16 @@ defmodule MvWeb.JoinRequestLive.Show do {:ok, request} -> field_ids = Membership.get_join_form_allowlist() |> Enum.map(& &1.id) - custom_field_name_by_id = - custom_field_name_map(field_ids ++ Map.keys(request.form_data || %{}), actor) + 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_name_by_id, custom_field_name_by_id) + |> 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))} @@ -136,59 +140,58 @@ defmodule MvWeb.JoinRequestLive.Show do <%!-- Single block: all applicant-provided data in join form order --%>

{gettext("Applicant data")}

-
- <%= for {label, value} <- +
+
+ <%= for {label, value} <- applicant_data_rows( @join_request, @join_form_field_ids || [], - @custom_field_name_by_id || %{} + @custom_field_by_id || %{} ) do %> - <.field_row label={label} value={value} empty_text={gettext("Not specified")} /> - <% end %> + <.field_row label={label} value={value} empty_text={gettext("Not specified")} /> + <% end %> +
<%!-- Status and review (submitted_at, status; if decided: approved/rejected at, reviewed by) --%>

{gettext("Status and review")}

-
- <.field_row - label={gettext("Submitted at")} - value={DateFormatter.format_datetime(@join_request.submitted_at, @browser_timezone)} - /> +
-
- {gettext("Status")}: -
-
+ <.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)} -
+ + <%= 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 %>
- <%= 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 %>
@@ -221,28 +224,30 @@ defmodule MvWeb.JoinRequestLive.Show do 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""" -
-
{@label}:
-
- <%= if @value && @value != "" do %> +
{@label}:
+
+ <%= cond do %> + <% @inner_block != [] -> %> + {render_slot(@inner_block)} + <% @value && @value != "" -> %> {@value} - <% else %> + <% true -> %> {@empty_text || gettext("Not specified")} - <% end %> -
-
+ <% end %> + """ 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_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) form_data = join_request.form_data || %{} @@ -256,8 +261,9 @@ defmodule MvWeb.JoinRequestLive.Show do 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_name_by_id) - {label, format_applicant_value(value)} + 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 = @@ -270,31 +276,32 @@ defmodule MvWeb.JoinRequestLive.Show do legacy_entries = Enum.map(legacy_keys, fn key -> - label = field_key_to_label(key, member_field_strings, custom_field_name_by_id) - {label, format_applicant_value(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(form_data[key], value_type)} end) in_order ++ legacy_entries end - defp format_applicant_value(nil), do: nil - defp format_applicant_value(""), do: nil - defp format_applicant_value(%Date{} = date), do: DateFormatter.format_date(date) + 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) when is_map(value), - do: format_applicant_value_from_map(value) + defp format_applicant_value(value, type) when is_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")) - defp format_applicant_value(value) when is_binary(value), - do: format_binary_applicant_value(value) + defp format_applicant_value(value, type) when is_binary(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) cond do @@ -307,8 +314,11 @@ defmodule MvWeb.JoinRequestLive.Show do String.downcase(trimmed_value) in ["off", "false", "0"] -> gettext("No") - true -> + type in [:date, Ash.Type.Date] -> format_iso_date_string(trimmed_value) + + true -> + trimmed_value end end @@ -319,12 +329,13 @@ defmodule MvWeb.JoinRequestLive.Show do 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") type = Map.get(value, "_union_type") || Map.get(value, "type") + effective_type = type || fallback_type - if raw && type in ["date", :date] do - format_applicant_value(raw) + if raw && effective_type in ["date", :date, Ash.Type.Date] do + format_applicant_value(raw, :date) else format_applicant_value_simple(raw, value) 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), 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 if key in member_field_strings do MemberFieldsTranslations.label(String.to_existing_atom(key)) 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 defp field_key_to_label(key, _, _), do: to_string(key) - defp custom_field_name_map(field_keys, actor) do - member_field_strings = Constants.member_fields() |> Enum.map(&Atom.to_string/1) + defp field_key_to_value_type("email", _member_field_strings, _custom_field_by_id), do: :string - custom_field_ids = - field_keys - |> Enum.uniq() - |> Enum.reject(&(&1 in member_field_strings)) + defp field_key_to_value_type("first_name", _member_field_strings, _custom_field_by_id), + do: :string - case custom_field_ids do - [] -> - %{} + defp field_key_to_value_type("last_name", _member_field_strings, _custom_field_by_id), + do: :string - ids -> - Mv.Membership.CustomField - |> Ash.Query.select([:id, :name]) - |> Ash.read(actor: actor, domain: Mv.Membership) - |> case do - {:ok, fields} -> - allowed_ids = MapSet.new(ids) - - fields - |> Enum.filter(&MapSet.member?(allowed_ids, &1.id)) - |> Map.new(&{&1.id, &1.name}) - - {:error, _} -> - %{} - end + 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 diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 370d4aa..e8ddff4 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -1291,7 +1291,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp translate_receipt_status("paid"), do: gettext("Paid") defp translate_receipt_status("unpaid"), do: gettext("Unpaid") 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("draft"), do: gettext("Draft") defp translate_receipt_status("incompleted"), do: gettext("Incomplete") diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 432fd33..4b047fd 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -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." 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 #, elixir-autogen, elixir-format 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 msgid "Open join page URL in a new tab" 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" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5039253..bd94594 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2397,12 +2397,6 @@ msgstr "" msgid "Only possible if no members are assigned to this type." 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 #, elixir-autogen, elixir-format msgid "Open email program with BCC recipients" @@ -3911,3 +3905,15 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Open join page URL in a new tab" 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index b63ce04..40a321e 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2397,12 +2397,6 @@ msgstr "" msgid "Only possible if no members are assigned to this type." 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 #, elixir-autogen, elixir-format msgid "Open email program with BCC recipients" @@ -3911,3 +3905,15 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Open join page URL in a new tab" 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" diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs index 7bac60f..d0efee9 100644 --- a/test/mv_web/live/join_live_test.exs +++ b/test/mv_web/live/join_live_test.exs @@ -12,10 +12,9 @@ defmodule MvWeb.JoinLiveTest do # async: false → shared sandbox; all processes (including LiveView) share the DB connection. use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest - import Ecto.Query alias Mv.Membership - alias Mv.Repo + alias Mv.Membership.JoinRequest describe "GET /join" do @tag role: :unauthenticated @@ -55,11 +54,12 @@ defmodule MvWeb.JoinLiveTest do }) |> render_submit() - # Anti-enumeration delay is applied in LiveView via send_after (100–300 ms); wait for success UI. - Process.sleep(400) + assert_eventually(fn -> count_join_requests() == count_before + 1 end) + + 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) =~ "click the link" end @@ -298,10 +298,11 @@ defmodule MvWeb.JoinLiveTest do "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 view |> element("[data-testid='join-success-message']") |> has_element?() + assert_eventually(fn -> + view |> element("[data-testid='join-success-message']") |> has_element?() + end) form_data = latest_join_request_form_data() assert Map.get(form_data, boolean_field.id) == "on" @@ -328,16 +329,40 @@ defmodule MvWeb.JoinLiveTest do end 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 defp latest_join_request_form_data do - Repo.one( - from j in "join_requests", - order_by: [desc: j.inserted_at], - limit: 1, - select: j.form_data - ) || %{} + query = + JoinRequest + |> Ash.Query.sort(inserted_at: :desc) + |> Ash.Query.limit(1) + + 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 defp reset_rate_limiter do From f8e1d399645a70c263588cffb5f2e52f3c598d33 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 14:11:29 +0200 Subject: [PATCH 10/79] refactor: fix review issues --- lib/mv_web/live/global_settings_live.ex | 4 +- lib/mv_web/live/join_live.ex | 59 ++++++++++++++----------- priv/gettext/de/LC_MESSAGES/default.po | 10 ++--- priv/gettext/default.pot | 10 ++--- priv/gettext/en/LC_MESSAGES/default.po | 10 ++--- 5 files changed, 49 insertions(+), 44 deletions(-) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 492c813..43851db 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -191,10 +191,10 @@ defmodule MvWeb.GlobalSettingsLive do target="_blank" rel="noopener noreferrer" class="btn btn-secondary btn-sm" - 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" aria-hidden="true" /> {pgettext("action", "Open")} + {gettext("join page URL in a new tab")}
diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index e3bc444..ba0e476 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -6,8 +6,8 @@ defmodule MvWeb.JoinLive do use MvWeb, :live_view alias Ash.Resource.Info - alias Mv.Membership.CustomFieldLookup alias Mv.Membership + alias Mv.Membership.CustomFieldLookup alias MvWeb.JoinRateLimit alias MvWeb.Translations.MemberFields @@ -81,14 +81,12 @@ defmodule MvWeb.JoinLive do <% end %> <%= for field <- @join_fields do %> -
- - <%= if field.input_type == "checkbox" do %> - + <%= if field.input_type == "checkbox" do %> + + + <% else %> +
+ - <% end %> -
+
+ <% end %> <% end %> <%!-- @@ -274,7 +282,8 @@ defmodule MvWeb.JoinLive do field_atom -> Mv.Membership.Member |> Info.attribute(field_atom) - |> attribute_to_input_type() + |> Map.get(:type) + |> input_type_for() end end @@ -285,21 +294,17 @@ defmodule MvWeb.JoinLive do |> Enum.find(&(Atom.to_string(&1) == field_id)) end - defp custom_field_input_type(type), do: attribute_to_input_type(%{type: type}) + defp custom_field_input_type(type), do: input_type_for(type) - defp attribute_to_input_type(%{type: type}) when type in [:date, Ash.Type.Date], do: "date" - - defp attribute_to_input_type(%{type: type}) when type in [:integer, Ash.Type.Integer], - do: "number" - - defp attribute_to_input_type(%{type: type}) when type in [:boolean, Ash.Type.Boolean], - do: "checkbox" - - defp attribute_to_input_type(%{type: type}) when type in [:email, Mv.Membership.Email], - do: "email" - - defp attribute_to_input_type(%{type: _}), do: "text" - defp attribute_to_input_type(nil), do: "text" + defp input_type_for(:date), do: "date" + defp input_type_for(Ash.Type.Date), do: "date" + defp input_type_for(:integer), do: "number" + defp input_type_for(Ash.Type.Integer), do: "number" + defp input_type_for(:boolean), do: "checkbox" + defp input_type_for(Ash.Type.Boolean), do: "checkbox" + defp input_type_for(:email), do: "email" + defp input_type_for(Mv.Membership.Email), do: "email" + defp input_type_for(_), do: "text" defp checkbox_checked?(value) when value in [true, "true", "on", "1"], do: true defp checkbox_checked?(_), do: false diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 4b047fd..52270cc 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3901,11 +3901,6 @@ msgstr "Nur OIDC-Anmeldung ist aktiv. Diese Option ist deaktiviert." msgid "Only sign-in via Single Sign-On (SSO) is allowed." msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt." -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Open join page URL in a new tab" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -3917,3 +3912,8 @@ msgstr "Öffnen" msgctxt "status" msgid "Open" msgstr "Offen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "join page URL in a new tab" +msgstr "Beitrittslink in einem neuen Tab" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index bd94594..5d48691 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3901,11 +3901,6 @@ msgstr "" msgid "Only sign-in via Single Sign-On (SSO) is allowed." msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Open join page URL in a new tab" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -3917,3 +3912,8 @@ msgstr "" msgctxt "status" msgid "Open" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "join page URL in a new tab" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 40a321e..ec6f305 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3901,11 +3901,6 @@ msgstr "" msgid "Only sign-in via Single Sign-On (SSO) is allowed." msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Open join page URL in a new tab" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -3917,3 +3912,8 @@ msgstr "Open" msgctxt "status" msgid "Open" msgstr "Open" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "join page URL in a new tab" +msgstr "join page URL in a new tab" From a62fceaf280f8b99b1049ec0ee8d1b0b5a566a97 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 6 May 2026 14:29:00 +0200 Subject: [PATCH 11/79] test: fix flaky test --- .../member_groups_relationship_test.exs | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/test/membership/member_groups_relationship_test.exs b/test/membership/member_groups_relationship_test.exs index a72c8bc..5ecddbd 100644 --- a/test/membership/member_groups_relationship_test.exs +++ b/test/membership/member_groups_relationship_test.exs @@ -16,9 +16,13 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do describe "Relationships" do test "member has many_to_many groups relationship (load with preloading)", %{actor: actor} do - {:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor) - {:ok, group1} = Membership.create_group(%{name: "Group One"}, actor: actor) - {:ok, group2} = Membership.create_group(%{name: "Group Two"}, actor: actor) + {:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor) + + {:ok, group1} = + Membership.create_group(%{name: unique_group_name("Group One")}, actor: actor) + + {:ok, group2} = + Membership.create_group(%{name: unique_group_name("Group Two")}, actor: actor) {:ok, _mg1} = Membership.create_member_group(%{member_id: member.id, group_id: group1.id}, @@ -40,9 +44,11 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do end test "load multiple members with groups preloaded (N+1 prevention)", %{actor: actor} do - {:ok, member1} = Membership.create_member(%{email: "member1@test.com"}, actor: actor) - {:ok, member2} = Membership.create_member(%{email: "member2@test.com"}, actor: actor) - {:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor) + {:ok, member1} = Membership.create_member(%{email: unique_email("member1")}, actor: actor) + {:ok, member2} = Membership.create_member(%{email: unique_email("member2")}, actor: actor) + + {:ok, group} = + Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor) {:ok, _mg1} = Membership.create_member_group(%{member_id: member1.id, group_id: group.id}, @@ -70,8 +76,10 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do describe "Member-Group Association Operations" do test "add member to group via Ash API", %{actor: actor} do - {:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor) - {:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor) + {:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor) + + {:ok, group} = + Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor) assert {:ok, member_group} = Membership.create_member_group(%{member_id: member.id, group_id: group.id}, @@ -83,8 +91,10 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do end test "remove member from group via Ash API", %{actor: actor} do - {:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor) - {:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor) + {:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor) + + {:ok, group} = + Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor) {:ok, member_group} = Membership.create_member_group(%{member_id: member.id, group_id: group.id}, @@ -107,10 +117,16 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do end test "add member to multiple groups in single operation", %{actor: actor} do - {:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor) - {:ok, group1} = Membership.create_group(%{name: "Group One"}, actor: actor) - {:ok, group2} = Membership.create_group(%{name: "Group Two"}, actor: actor) - {:ok, group3} = Membership.create_group(%{name: "Group Three"}, actor: actor) + {:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor) + + {:ok, group1} = + Membership.create_group(%{name: unique_group_name("Group One")}, actor: actor) + + {:ok, group2} = + Membership.create_group(%{name: unique_group_name("Group Two")}, actor: actor) + + {:ok, group3} = + Membership.create_group(%{name: unique_group_name("Group Three")}, actor: actor) # Add to all groups {:ok, _mg1} = @@ -138,8 +154,10 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do describe "Edge Cases" do test "adding member to same group twice fails (duplicate prevention)", %{actor: actor} do - {:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor) - {:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor) + {:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor) + + {:ok, group} = + Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor) {:ok, _mg1} = Membership.create_member_group(%{member_id: member.id, group_id: group.id}, @@ -154,8 +172,10 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do end test "removing member from group they're not in (idempotent, no error)", %{actor: actor} do - {:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor) - {:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor) + {:ok, member} = Membership.create_member(%{email: unique_email("member")}, actor: actor) + + {:ok, group} = + Membership.create_group(%{name: unique_group_name("Test Group")}, actor: actor) # Verify no association exists {:ok, nil} = @@ -194,4 +214,12 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do assert result == :ok || match?({:error, _}, result) end end + + defp unique_email(prefix) do + "#{prefix}-#{System.unique_integer([:positive])}@test.com" + end + + defp unique_group_name(prefix) do + "#{prefix} #{System.unique_integer([:positive])}" + end end From a244b1b07efeb22d2be711e37c49418e5acaec68 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 7 May 2026 09:38:36 +0200 Subject: [PATCH 12/79] test: verify smtp config either via env or UI only --- .../mv_web/live/global_settings_live_test.exs | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 2edaf74..9059a5e 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -1,8 +1,19 @@ defmodule MvWeb.GlobalSettingsLiveTest do - use MvWeb.ConnCase, async: true + use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest alias Mv.Membership + @smtp_env_keys [ + "SMTP_HOST", + "SMTP_PORT", + "SMTP_USERNAME", + "SMTP_PASSWORD", + "SMTP_PASSWORD_FILE", + "SMTP_SSL", + "MAIL_FROM_NAME", + "MAIL_FROM_EMAIL" + ] + describe "Global Settings LiveView" do setup %{conn: conn} do user = create_test_user(%{email: "admin@example.com"}) @@ -124,6 +135,43 @@ defmodule MvWeb.GlobalSettingsLiveTest do {:ok, _view, html} = live(conn, ~p"/settings") assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings" end + + @tag :ui + test "disables all SMTP inputs when SMTP_HOST is set", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-only.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, view, _html} = live(conn, ~p"/settings") + + assert has_element?(view, "#setting_smtp_host[disabled]") + assert has_element?(view, "#setting_smtp_port[disabled]") + assert has_element?(view, "#setting_smtp_ssl[disabled]") + assert has_element?(view, "#setting_smtp_username[disabled]") + assert has_element?(view, "#setting_smtp_password[disabled]") + assert has_element?(view, "#setting_smtp_from_email[disabled]") + assert has_element?(view, "#setting_smtp_from_name[disabled]") + end + + @tag :ui + test "does not render SMTP save action when SMTP_HOST is set", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-only.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, view, _html} = live(conn, ~p"/settings") + refute has_element?(view, "#smtp-form button", "Save SMTP Settings") + end + + @tag :ui + test "shows explicit ENV-only mode hint when SMTP_HOST is set", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-only.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, _view, html} = live(conn, ~p"/settings") + assert html =~ "SMTP is fully managed via environment variables" + end end describe "Authentication section when OIDC-only is enabled" do @@ -190,4 +238,8 @@ defmodule MvWeb.GlobalSettingsLiveTest do end end end + + defp clear_smtp_env do + Enum.each(@smtp_env_keys, &System.delete_env/1) + end end From 605a897045fdf7da112fba34ffc57c15fd9f300b Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 7 May 2026 10:01:19 +0200 Subject: [PATCH 13/79] fix: make sure smtp can be set either via env or ui --- CODE_GUIDELINES.md | 3 +- docs/smtp-configuration-concept.md | 13 ++- lib/mv/config.ex | 107 ++++++++++++++---- lib/mv_web/live/global_settings_live.ex | 40 +++++-- priv/gettext/de/LC_MESSAGES/default.po | 10 ++ priv/gettext/default.pot | 10 ++ priv/gettext/en/LC_MESSAGES/default.po | 10 ++ test/mv/config_smtp_test.exs | 22 +++- .../mv_web/live/global_settings_live_test.exs | 28 +++++ 9 files changed, 201 insertions(+), 42 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 0d478f9..d721a3a 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1277,7 +1277,8 @@ mix hex.outdated **SMTP configuration:** -- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht). +- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). +- **ENV-only policy:** If `SMTP_HOST` is set, SMTP is treated as environment-managed only. All SMTP fields in Settings are read-only, SMTP save action is hidden, and the UI shows a warning when required ENV values are missing (`SMTP_USERNAME`, and `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE`). - **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`. - **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox). - **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier. diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md index 4ae7760..6668485 100644 --- a/docs/smtp-configuration-concept.md +++ b/docs/smtp-configuration-concept.md @@ -25,7 +25,10 @@ Enable configurable SMTP for sending transactional emails (join confirmation, us | ENV | 1 | Production, Docker, 12-factor | | Settings | 2 | Admin UI, dev without ENV | -When an ENV variable is set, the corresponding Settings field is read-only in the UI (with hint "Set by environment"). +When `SMTP_HOST` is set, SMTP runs in **ENV-only mode**: +- all SMTP fields in Settings are read-only, +- saving SMTP settings in the UI is disabled, +- and the UI shows a warning block if required SMTP ENV values are missing. --- @@ -63,6 +66,14 @@ Support **SMTP_PASSWORD_FILE** (path to file containing the password), same patt - Show a warning in the Settings UI. - Delivery attempts silently fall back to the Local adapter (no crash). +### 6.1 Behaviour in ENV-only mode (`SMTP_HOST` set) + +- The SMTP source of truth is environment variables only. +- The UI does not allow editing SMTP fields in this mode. +- The Settings page shows a warning block when required values are missing: + - `SMTP_USERNAME` + - `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE` + --- ## 7. Test Email (Settings UI) diff --git a/lib/mv/config.ex b/lib/mv/config.ex index 3494937..c807193 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -478,48 +478,61 @@ defmodule Mv.Config do end @doc """ - Returns SMTP port as integer. ENV `SMTP_PORT` (parsed) overrides Settings. - Returns nil when neither ENV nor Settings provide a valid port. + Returns SMTP port as integer. + + Policy: + - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT` + - Settings mode: read from Settings only """ @spec smtp_port() :: non_neg_integer() | nil def smtp_port do - case System.get_env("SMTP_PORT") do - nil -> - get_from_settings_integer(:smtp_port) - - value when is_binary(value) -> - case Integer.parse(String.trim(value)) do - {port, _} when port > 0 -> port - _ -> nil - end + if smtp_env_mode?() do + parse_smtp_port_env(System.get_env("SMTP_PORT")) + else + get_from_settings_integer(:smtp_port) end end @doc """ - Returns SMTP username. ENV `SMTP_USERNAME` overrides Settings. + Returns SMTP username. + + Policy: + - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_USERNAME` + - Settings mode: read from Settings only """ @spec smtp_username() :: String.t() | nil def smtp_username do - smtp_env_or_setting("SMTP_USERNAME", :smtp_username) + if smtp_env_mode?() do + System.get_env("SMTP_USERNAME") |> trim_nil() + else + get_from_settings(:smtp_username) + end end @doc """ Returns SMTP password. - Priority: `SMTP_PASSWORD` ENV > `SMTP_PASSWORD_FILE` (file contents) > Settings. + Policy: + - ENV-only mode (`SMTP_HOST` set): `SMTP_PASSWORD` > `SMTP_PASSWORD_FILE` + - Settings mode: read from Settings only + Strips trailing whitespace/newlines from file contents. """ @spec smtp_password() :: String.t() | nil def smtp_password do - case System.get_env("SMTP_PASSWORD") do - nil -> smtp_password_from_file_or_settings() - value -> trim_nil(value) + if smtp_env_mode?() do + case System.get_env("SMTP_PASSWORD") do + nil -> smtp_password_from_file_or_settings() + value -> trim_nil(value) + end + else + get_smtp_password_from_settings() end end defp smtp_password_from_file_or_settings do case System.get_env("SMTP_PASSWORD_FILE") do - nil -> get_smtp_password_from_settings() + nil -> nil path -> read_smtp_password_file(path) end end @@ -533,11 +546,18 @@ defmodule Mv.Config do @doc """ Returns SMTP TLS/SSL mode string (e.g. 'tls', 'ssl', 'none'). - ENV `SMTP_SSL` overrides Settings. + + Policy: + - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_SSL` + - Settings mode: read from Settings only """ @spec smtp_ssl() :: String.t() | nil def smtp_ssl do - smtp_env_or_setting("SMTP_SSL", :smtp_ssl) + if smtp_env_mode?() do + System.get_env("SMTP_SSL") |> trim_nil() + else + get_from_settings(:smtp_ssl) + end end @doc """ @@ -549,12 +569,39 @@ defmodule Mv.Config do end @doc """ - Returns true when any SMTP ENV variable is set (used in Settings UI for hints). + Returns true when SMTP ENV mode is active. """ @spec smtp_env_configured?() :: boolean() def smtp_env_configured? do - smtp_host_env_set?() or smtp_port_env_set?() or smtp_username_env_set?() or - smtp_password_env_set?() or smtp_ssl_env_set?() + smtp_env_mode?() + end + + @doc """ + Returns true when SMTP is managed by environment variables. + + Policy: if `SMTP_HOST` is set, SMTP is treated as ENV-only. + """ + @spec smtp_env_mode?() :: boolean() + def smtp_env_mode? do + smtp_host_env_set?() + end + + @doc """ + Returns missing required SMTP ENV keys for ENV-only mode warnings. + + Required in ENV-only mode: + - `SMTP_USERNAME` + - one of `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE` + """ + @spec smtp_missing_required_env_keys() :: [String.t()] + def smtp_missing_required_env_keys do + if smtp_env_mode?() do + [] + |> maybe_add_missing("SMTP_USERNAME", smtp_username_env_set?()) + |> maybe_add_missing("SMTP_PASSWORD/SMTP_PASSWORD_FILE", smtp_password_env_set?()) + else + [] + end end @doc "Returns true if SMTP_HOST ENV is set." @@ -618,6 +665,17 @@ defmodule Mv.Config do @spec mail_from_email_env_set?() :: boolean() def mail_from_email_env_set?, do: env_set?("MAIL_FROM_EMAIL") + defp parse_smtp_port_env(nil), do: nil + + defp parse_smtp_port_env(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {port, _} when port > 0 -> port + _ -> nil + end + end + + defp parse_smtp_port_env(_), do: nil + # Reads a plain string SMTP setting: ENV first, then Settings. defp smtp_env_or_setting(env_key, setting_key) do case System.get_env(env_key) do @@ -626,6 +684,9 @@ defmodule Mv.Config do end end + defp maybe_add_missing(acc, _label, true), do: acc + defp maybe_add_missing(acc, label, false), do: acc ++ [label] + # Reads an integer setting attribute from Settings. defp get_from_settings_integer(key) do case Mv.Membership.get_settings() do diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 43851db..983f075 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -86,6 +86,8 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:registration_enabled, settings.registration_enabled != false) |> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?()) + |> assign(:smtp_env_mode, Mv.Config.smtp_env_mode?()) + |> assign(:smtp_missing_required_env_keys, Mv.Config.smtp_missing_required_env_keys()) |> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?()) |> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?()) |> assign(:smtp_username_env_set, Mv.Config.smtp_username_env_set?()) @@ -321,12 +323,25 @@ defmodule MvWeb.GlobalSettingsLive do <%!-- SMTP / E-Mail Section --%> <.form_section title={gettext("SMTP / E-Mail")}> - <%= if @smtp_env_configured do %> + <%= if @smtp_env_mode do %>

- {gettext("Some values are set via environment variables. Those fields are read-only.")} + {gettext( + "SMTP is fully managed via environment variables. All SMTP fields are read-only." + )}

<% end %> + <%= if @smtp_env_mode and @smtp_missing_required_env_keys != [] do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" /> + + {gettext("SMTP environment configuration appears incomplete. Missing: %{keys}", + keys: Enum.join(@smtp_missing_required_env_keys, ", ") + )} + +
+ <% end %> + <%= if @environment == :prod and not @smtp_configured do %>
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" /> @@ -345,7 +360,7 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_host]} type="text" label={gettext("Host")} - disabled={@smtp_host_env_set} + disabled={@smtp_env_mode} placeholder={ if(@smtp_host_env_set, do: gettext("From SMTP_HOST"), @@ -357,14 +372,14 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_port]} type="number" label={gettext("Port")} - disabled={@smtp_port_env_set} + disabled={@smtp_env_mode} placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")} /> <.input field={@form[:smtp_ssl]} type="select" label={gettext("TLS/SSL")} - disabled={@smtp_ssl_env_set} + disabled={@smtp_env_mode} options={[ {gettext("TLS (port 587, recommended)"), "tls"}, {gettext("SSL (port 465)"), "ssl"}, @@ -379,7 +394,7 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_username]} type="text" label={gettext("Username")} - disabled={@smtp_username_env_set} + disabled={@smtp_env_mode} placeholder={ if(@smtp_username_env_set, do: gettext("From SMTP_USERNAME"), @@ -391,7 +406,7 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_password]} type="password" label={gettext("Password")} - disabled={@smtp_password_env_set} + disabled={@smtp_env_mode} placeholder={ if(@smtp_password_env_set, do: gettext("From SMTP_PASSWORD"), @@ -410,7 +425,7 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_from_email]} type="email" label={gettext("Sender email (From)")} - disabled={@smtp_from_email_env_set} + disabled={@smtp_env_mode} placeholder={ if(@smtp_from_email_env_set, do: gettext("From MAIL_FROM_EMAIL"), @@ -422,7 +437,7 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_from_name]} type="text" label={gettext("Sender name (From)")} - disabled={@smtp_from_name_env_set} + disabled={@smtp_env_mode} placeholder={ if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila") } @@ -436,9 +451,10 @@ defmodule MvWeb.GlobalSettingsLive do

<.button :if={ - not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and - @smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and - @smtp_from_name_env_set) + not @smtp_env_mode and + not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and + @smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and + @smtp_from_name_env_set) } phx-disable-with={gettext("Saving...")} variant="primary" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 52270cc..1482490 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3917,3 +3917,13 @@ msgstr "Offen" #, elixir-autogen, elixir-format msgid "join page URL in a new tab" msgstr "Beitrittslink in einem neuen Tab" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5d48691..21e7b16 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3917,3 +3917,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "join page URL in a new tab" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index ec6f305..7d94b7c 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3917,3 +3917,13 @@ msgstr "Open" #, elixir-autogen, elixir-format msgid "join page URL in a new tab" msgstr "join page URL in a new tab" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "" diff --git a/test/mv/config_smtp_test.exs b/test/mv/config_smtp_test.exs index 5359366..7a1b895 100644 --- a/test/mv/config_smtp_test.exs +++ b/test/mv/config_smtp_test.exs @@ -23,7 +23,8 @@ defmodule Mv.ConfigSmtpTest do end describe "smtp_port/0" do - test "returns parsed integer when SMTP_PORT ENV is set" do + test "returns parsed integer when SMTP_PORT ENV is set in ENV-only mode" do + set_smtp_env("SMTP_HOST", "smtp.example.com") set_smtp_env("SMTP_PORT", "587") assert Mv.Config.smtp_port() == 587 after @@ -52,13 +53,21 @@ defmodule Mv.ConfigSmtpTest do end describe "smtp_env_configured?/0" do - test "returns true when any SMTP ENV variable is set" do + test "returns true when SMTP_HOST is set" do set_smtp_env("SMTP_HOST", "smtp.example.com") assert Mv.Config.smtp_env_configured?() == true after clear_smtp_env() end + test "returns false when SMTP_HOST is not set even if other SMTP ENV variables are set" do + set_smtp_env("SMTP_USERNAME", "user@example.com") + set_smtp_env("SMTP_PASSWORD", "secret") + refute Mv.Config.smtp_env_configured?() + after + clear_smtp_env() + end + test "returns false when no SMTP ENV variables are set" do clear_smtp_env() refute Mv.Config.smtp_env_configured?() @@ -66,15 +75,17 @@ defmodule Mv.ConfigSmtpTest do end describe "smtp_password/0 and SMTP_PASSWORD_FILE" do - test "returns value from SMTP_PASSWORD when set" do + test "returns value from SMTP_PASSWORD when set in ENV-only mode" do + set_smtp_env("SMTP_HOST", "smtp.example.com") set_smtp_env("SMTP_PASSWORD", "env-secret") assert Mv.Config.smtp_password() == "env-secret" after clear_smtp_env() end - test "returns content of file when SMTP_PASSWORD_FILE is set and SMTP_PASSWORD is not" do + test "returns content of file when SMTP_PASSWORD_FILE is set in ENV-only mode and SMTP_PASSWORD is not" do clear_smtp_env() + set_smtp_env("SMTP_HOST", "smtp.example.com") path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}") File.write!(path, "file-secret\n") Process.put(:smtp_password_file_path, path) @@ -85,7 +96,8 @@ defmodule Mv.ConfigSmtpTest do if path = Process.get(:smtp_password_file_path), do: File.rm(path) end - test "SMTP_PASSWORD overrides SMTP_PASSWORD_FILE when both are set" do + test "SMTP_PASSWORD overrides SMTP_PASSWORD_FILE in ENV-only mode when both are set" do + set_smtp_env("SMTP_HOST", "smtp.example.com") path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}") File.write!(path, "file-secret") Process.put(:smtp_password_file_path, path) diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 9059a5e..37c4e38 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -172,6 +172,34 @@ defmodule MvWeb.GlobalSettingsLiveTest do {:ok, _view, html} = live(conn, ~p"/settings") assert html =~ "SMTP is fully managed via environment variables" end + + @tag :ui + test "shows warning block for missing required SMTP ENV values in ENV-only mode", %{ + conn: conn + } do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-only.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, _view, html} = live(conn, ~p"/settings") + assert html =~ "SMTP environment configuration appears incomplete" + assert html =~ "SMTP_USERNAME" + assert html =~ "SMTP_PASSWORD/SMTP_PASSWORD_FILE" + end + + @tag :ui + test "does not enter ENV-only mode when SMTP_HOST is not set", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_USERNAME", "leftover@example.com") + on_exit(fn -> clear_smtp_env() end) + + {:ok, view, html} = live(conn, ~p"/settings") + + refute html =~ "SMTP is fully managed via environment variables" + refute html =~ "SMTP environment configuration appears incomplete" + refute has_element?(view, "#setting_smtp_host[disabled]") + refute has_element?(view, "#setting_smtp_username[disabled]") + end end describe "Authentication section when OIDC-only is enabled" do From 2443bc62ac6d726340fdd10a4fbc063d75884b82 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 7 May 2026 12:13:45 +0200 Subject: [PATCH 14/79] feat: Improve handling of association name config --- lib/membership/membership.ex | 12 +++++ lib/mv/config.ex | 21 +++++++++ lib/mv_web/components/layouts.ex | 7 ++- lib/mv_web/live/global_settings_live.ex | 43 +++++++++++++++++- priv/gettext/de/LC_MESSAGES/default.po | 10 +++++ priv/gettext/default.pot | 10 +++++ priv/gettext/en/LC_MESSAGES/default.po | 10 +++++ test/mv_web/components/layouts_test.exs | 26 +++++++++++ .../mv_web/live/global_settings_live_test.exs | 44 +++++++++++++++++++ 9 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 test/mv_web/components/layouts_test.exs diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 7fa35dc..ffe7703 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -178,6 +178,18 @@ defmodule Mv.Membership do end end + @doc """ + Invalidates the global settings cache. + + This should be used by callers that update settings through paths outside of + `update_settings/2` (for example, custom form submit flows) to keep reads via + `get_settings/0` consistent across views. + """ + @spec invalidate_settings_cache() :: :ok + def invalidate_settings_cache do + SettingsCache.invalidate() + end + @doc """ Lists only required custom fields. diff --git a/lib/mv/config.ex b/lib/mv/config.ex index c807193..33c92cf 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -143,6 +143,27 @@ defmodule Mv.Config do |> parse_and_validate_integer(default) end + # --------------------------------------------------------------------------- + # Association name + # ENV variable takes priority; fallback to Settings from database. + # --------------------------------------------------------------------------- + + @doc """ + Returns the association name. + + Reads from `ASSOCIATION_NAME` env first, then from Settings. + """ + @spec association_name() :: String.t() | nil + def association_name do + env_or_setting("ASSOCIATION_NAME", :club_name) + end + + @doc """ + Returns true if ASSOCIATION_NAME is set (field is read-only in Settings). + """ + @spec association_name_env_set?() :: boolean() + def association_name_env_set?, do: env_set?("ASSOCIATION_NAME") + # --------------------------------------------------------------------------- # Vereinfacht accounting software integration # ENV variables take priority; fallback to Settings from database. diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 9aff23c..c6e3e01 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -135,8 +135,11 @@ defmodule MvWeb.Layouts do slot :inner_block, required: true def app(assigns) do - # Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query. - %{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings() + # Single settings read for layout defaults. + # Use an explicitly provided club_name as source of truth to avoid stale + # values from cache reads immediately after a settings update in LiveViews. + %{club_name: fallback_club_name, join_form_enabled: join_form_enabled} = get_layout_settings() + club_name = assigns[:club_name] || fallback_club_name # NOTE: Unprocessed count runs on every page load when join form is enabled; consider # loading only on navigation or caching briefly if performance becomes an issue. diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 983f075..228970a 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -65,6 +65,7 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:settings, settings) |> assign(:locale, locale) |> assign(:environment, environment) + |> assign(:association_name_env_set, Mv.Config.association_name_env_set?()) |> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?()) |> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?()) |> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?()) @@ -125,6 +126,13 @@ defmodule MvWeb.GlobalSettingsLive do
<%!-- Club Settings Section --%> <.form_section title={gettext("Club Settings")}> + <%= if @association_name_env_set do %> +

+ {gettext( + "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." + )} +

+ <% end %> <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<.input @@ -132,10 +140,18 @@ defmodule MvWeb.GlobalSettingsLive do type="text" label={gettext("Association Name")} required + disabled={@association_name_env_set} + placeholder={ + if(@association_name_env_set, do: gettext("From ASSOCIATION_NAME"), else: nil) + } />
- <.button phx-disable-with={gettext("Saving...")} variant="primary"> + <.button + :if={not @association_name_env_set} + phx-disable-with={gettext("Saving...")} + variant="primary" + > {gettext("Save Name")} @@ -919,6 +935,7 @@ defmodule MvWeb.GlobalSettingsLive do # Never send blank API key / client secret / smtp password so we do not overwrite stored secrets setting_params_clean = setting_params + |> drop_env_managed_association_name() |> drop_blank_vereinfacht_api_key() |> drop_blank_oidc_client_secret() |> drop_blank_smtp_password() @@ -927,6 +944,10 @@ defmodule MvWeb.GlobalSettingsLive do case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do {:ok, updated_settings} -> + # Keep cross-view reads consistent after settings updates (layouts/sidebar + # read via Membership.get_settings/0). + Membership.invalidate_settings_cache() + # Use the returned record for the form so saved values show immediately; # get_settings() can return cached data without the new attribute until reload. test_result = @@ -1195,10 +1216,19 @@ defmodule MvWeb.GlobalSettingsLive do end end + defp drop_env_managed_association_name(params) when is_map(params) do + if Mv.Config.association_name_env_set?() do + Map.delete(params, "club_name") + else + params + end + end + defp assign_form(%{assigns: %{settings: settings}} = socket) do - # Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form + # Show ENV values in disabled fields (Association Name, Vereinfacht, OIDC, SMTP); never expose secrets in form settings_display = settings + |> merge_association_env_values() |> merge_vereinfacht_env_values() |> merge_oidc_env_values() |> merge_smtp_env_values() @@ -1225,6 +1255,15 @@ defmodule MvWeb.GlobalSettingsLive do defp put_if_env_set(map, _key, false, _value), do: map defp put_if_env_set(map, key, true, value), do: Map.put(map, key, value) + defp merge_association_env_values(s) do + put_if_env_set( + s, + :club_name, + Mv.Config.association_name_env_set?(), + Mv.Config.association_name() + ) + end + defp merge_vereinfacht_env_values(s) do s |> put_if_env_set( diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 1482490..31830ce 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3927,3 +3927,13 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." +msgstr "Der Vereinsname wird über die Umgebungsvariable ASSOCIATION_NAME gesetzt. Dieses Feld ist schreibgeschützt." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From ASSOCIATION_NAME" +msgstr "Aus ASSOCIATION_NAME" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 21e7b16..586d1f4 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3927,3 +3927,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From ASSOCIATION_NAME" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7d94b7c..6b8db53 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3927,3 +3927,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." +msgstr "Association name is set via environment variable ASSOCIATION_NAME. This field is read-only." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From ASSOCIATION_NAME" +msgstr "From ASSOCIATION_NAME" diff --git a/test/mv_web/components/layouts_test.exs b/test/mv_web/components/layouts_test.exs new file mode 100644 index 0000000..411750b --- /dev/null +++ b/test/mv_web/components/layouts_test.exs @@ -0,0 +1,26 @@ +defmodule MvWeb.LayoutsTest do + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Membership + alias MvWeb.Layouts + + describe "app/1" do + test "prefers provided club_name over settings fallback" do + {:ok, settings} = Membership.get_settings() + {:ok, _} = Membership.update_settings(settings, %{club_name: "Settings Club Name"}) + + html = + render_component(&Layouts.app/1, %{ + flash: %{}, + current_user: nil, + club_name: "Provided Club Name", + inner_block: [%{inner_block: fn _, _ -> "content" end}] + }) + + assert html =~ "Provided Club Name" + refute html =~ "Settings Club Name" + end + end +end diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 37c4e38..8d2963c 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -13,6 +13,7 @@ defmodule MvWeb.GlobalSettingsLiveTest do "MAIL_FROM_NAME", "MAIL_FROM_EMAIL" ] + @association_env_key "ASSOCIATION_NAME" describe "Global Settings LiveView" do setup %{conn: conn} do @@ -51,6 +52,17 @@ defmodule MvWeb.GlobalSettingsLiveTest do assert render(view) =~ "Updated Club Name" end + test "updated club name is shown after remount", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + + assert view + |> form("#settings-form", %{setting: %{club_name: "Remount Club Name"}}) + |> render_submit() + + {:ok, _view_after_remount, html_after_remount} = live(conn, ~p"/settings") + assert html_after_remount =~ "Remount Club Name" + end + test "shows error when club_name is empty", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/settings") @@ -90,6 +102,34 @@ defmodule MvWeb.GlobalSettingsLiveTest do "Open" ) end + + @tag :ui + test "disables association name input when ASSOCIATION_NAME is set", %{conn: conn} do + clear_association_name_env() + System.put_env(@association_env_key, "Association Name from ENV") + on_exit(fn -> clear_association_name_env() end) + + {:ok, view, _html} = live(conn, ~p"/settings") + + assert has_element?(view, "#setting_club_name[disabled]") + assert has_element?(view, "#setting_club_name[placeholder='From ASSOCIATION_NAME']") + refute has_element?(view, "#settings-form button", "Save Name") + assert render(view) =~ "Association name is set via environment variable ASSOCIATION_NAME" + end + + @tag :ui + test "keeps association name input editable when ASSOCIATION_NAME is not set", %{conn: conn} do + clear_association_name_env() + on_exit(fn -> clear_association_name_env() end) + + {:ok, view, _html} = live(conn, ~p"/settings") + + refute has_element?(view, "#setting_club_name[disabled]") + assert has_element?(view, "#settings-form button", "Save Name") + + refute render(view) =~ + "Association name is set via environment variable ASSOCIATION_NAME" + end end describe "SMTP / E-Mail section" do @@ -270,4 +310,8 @@ defmodule MvWeb.GlobalSettingsLiveTest do defp clear_smtp_env do Enum.each(@smtp_env_keys, &System.delete_env/1) end + + defp clear_association_name_env do + System.delete_env(@association_env_key) + end end From a12888de2f2997b269f94c84c31db3b7d121de1c Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 8 May 2026 15:04:53 +0200 Subject: [PATCH 15/79] Improve member view table behavior+style, fix config settings (#493) ## Description of the implemented changes The changes were: - [x] Bugfixing - [x] New Feature - [ ] Breaking Change - [x] Refactoring This PR standardizes interactive table behavior and improves settings robustness. It makes the new hover/focus-visible row highlight the default for clickable tables, keeps sticky first-column behavior configurable (and optimized for member selection UX), and tightens SMTP source-of-truth handling so ENV-based and UI-based configuration do not conflict. ## What has been changed? - Refactored `CoreComponents.table` to expose interaction state via `data-row-interactive` and moved default row hover/focus styling to CSS. - Made the new row highlight behavior (`hover` + `:has(:focus-visible)`) the default for clickable zebra tables. - Kept sticky-first-column as an explicit table option and preserved sticky-specific selection accent behavior. - Updated member overview table usage to the sticky-first-column mode and refined scrolling behavior (table scrollbar within container, not page-coupled). - Adjusted table-related tests to validate the new interaction contract (attribute/CSS-driven behavior instead of legacy ring classes). - Improved SMTP config handling: - clearer ENV-vs-Settings behavior (ENV-only mode when host env is set), - read-only and warning behavior in global settings UI when required env keys are missing, - updated related config/tests/docs. - Updated docs and changelog (`CHANGELOG.md`, `DESIGN_GUIDELINES.md`, `CODE_GUIDELINES.md`, SMTP concept docs). - Updated gettext catalogs (`default.pot`, `en`, `de`) for new/changed UI strings. ## 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 - [x] All tests pass - [ ] axe-core dev tools show no critical or major issues ## Additional Notes - Branch includes 4 commits: - `fix: make sure smtp can be set either via env or ui` - `fix: make horizontal scrollbars sticky to bottom` - `docs: update changelog` - `feat: make checkbox column in member view sticky` - Full fast suite passed (`mix test --exclude slow --exclude ui`): 2017 tests, 0 failures (plus expected non-failing warning logs in test output). - Reviewer focus areas: 1. **Cross-table UX consistency** after moving row interaction styling to component/CSS contract. 2. **Sticky table behavior** (selection accent stripe, zebra background, keyboard focus visibility). 3. **SMTP precedence and UI constraints** in global settings when ENV mode is active. 4. **Regression risk in tests** that previously asserted ring-based row classes. - No breaking API changes expected; behavior change is primarily visual/interaction-level and intentional. Reviewed-on: https://git.local-it.org/local-it/mitgliederverwaltung/pulls/493 Co-authored-by: Simon Co-committed-by: Simon --- CHANGELOG.md | 6 + CODE_GUIDELINES.md | 3 +- DESIGN_GUIDELINES.md | 4 +- assets/css/app.css | 65 ++++++ docs/smtp-configuration-concept.md | 14 +- lib/mv/config.ex | 130 ++++++++---- lib/mv_web/components/core_components.ex | 66 +++--- lib/mv_web/live/global_settings_live.ex | 122 +++++------ lib/mv_web/live/member_live/index.html.heex | 2 + priv/gettext/de/LC_MESSAGES/default.po | 40 +--- priv/gettext/default.pot | 40 +--- priv/gettext/en/LC_MESSAGES/default.po | 40 +--- test/mv/config_smtp_test.exs | 30 ++- .../components/core_components_table_test.exs | 192 +++++++++++++++++- .../mv_web/live/global_settings_live_test.exs | 95 ++++++++- test/mv_web/member_live/index_test.exs | 44 +++- 16 files changed, 635 insertions(+), 258 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c17ea39..e21f4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- **Clickable table row highlights** – The new hover/focus-visible row highlight behavior is now the CoreComponents default across clickable tables. Sticky-first-column tables keep zebra striping and show selection through the sticky-column accent stripe (checkboxes keep their default style). +- **Members overview scrolling** – The members table scrollbar now scrolls inside the table container instead of moving with the full page. - **Join request display and settings workflow** – Improved join request rendering and related settings behavior in one cohesive update: - Join request fields now respect their configured field types in the details view. - Custom field labels in join request views were standardized. @@ -17,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Runtime ENV handling** – Empty or invalid environment variables (e.g. `SMTP_PORT=`, `PORT=`, `POOL_SIZE=`, `DATABASE_PORT=`) no longer cause `ArgumentError` at boot. Instead raises clear errors for required vars set but empty (e.g. DATABASE_HOST, PHX_HOST/DOMAIN, SECRET_KEY_BASE). - **PostgreSQL 18 Docker volume path** – Corrected the database volume path to match PostgreSQL 18 expectations. +- **Association name ENV handling** – `ASSOCIATION_NAME` is now treated as source of truth; the field is read-only in Global Settings when managed via ENV. +- **Association name consistency after updates** – Layout now prefers explicitly assigned `club_name` values to avoid stale cached values right after settings changes. +- **SMTP ENV/UI source selection** – SMTP now follows a strict single-source policy: ENV-only when `SMTP_HOST` is set, otherwise Settings-only. +- **SMTP settings UI in ENV mode** – SMTP fields are read-only, save action is hidden, and missing required ENV keys are shown as a warning. ### Dependency updates - Mix dependencies were updated. diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 0d478f9..2b378ef 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1277,7 +1277,8 @@ mix hex.outdated **SMTP configuration:** -- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht). +- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). +- **ENV-only policy:** If `SMTP_HOST` is set, SMTP is treated as environment-managed only. All SMTP fields in Settings are read-only, SMTP save action is hidden, and the UI shows a warning when required ENV values are missing (`SMTP_USERNAME`, and `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE`). This keeps one source of truth for transport credentials and avoids mixed ENV/DB SMTP states. - **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`. - **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox). - **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier. diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md index 0ad562e..34c71b8 100644 --- a/DESIGN_GUIDELINES.md +++ b/DESIGN_GUIDELINES.md @@ -247,11 +247,13 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`. ### 8.1 Default behavior: row click opens details - **DEFAULT:** Clicking a row navigates to the details page. - **EXCEPTIONS:** Highly interactive rows may disable row-click (document why). -- **Row outline (CoreComponents):** When `row_click` is set, rows get a subtle hover and focus-within ring (theme-friendly). Use `selected_row_id` to show a stronger selected outline (e.g. from URL `?highlight=id` or last selection); the Back link from detail can use `?highlight=id` so the row is visually selected when returning to the index. +- **Row highlight (CoreComponents):** When `row_click` is set, rows use a neutral background highlight on `hover` and `tr:has(:focus-visible)` (see `assets/css/app.css`), so keyboard focus is visible while mouse-only focus does not appear "stuck". For non-sticky tables, `selected_row_id` can still add a stronger selected ring. For sticky-first-column tables, selection emphasis is handled by the sticky-column accent stripe. **IMPORTANT (correctness with our `<.table>` CoreComponent):** Our table implementation attaches the `phx-click` to the **``** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation. +**LiveStream rows:** Do not enumerate `@rows` with `Enum.with_index` in the table template; streams must be consumed only through `:for`. Sticky-first-column zebra striping for those tables is handled in CSS (`nth-child` under `data-sticky-first-col-rows`), not by assigning odd/even classes from an index. + So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute. ✅ Correct pattern (one click handler that both stops propagation and triggers an event): diff --git a/assets/css/app.css b/assets/css/app.css index d7f873c..611e9ad 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -708,3 +708,68 @@ background-color: transparent !important; color: inherit; } + +/* + * Default interactive table rows: neutral hover/focus-visible fill for clickable rows. + * Uses :has(:focus-visible) so keyboard navigation highlights the row without sticky mouse-focus artifacts. + */ +.table.table-zebra tbody tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)) > td { + background-color: var(--color-base-300); +} + +/* + * Sticky first column in zebra tables: opaque backgrounds per row. + * Use nth-child (not HEEx row index) so LiveStream rows stay iterable only via :for (Phoenix LV requirement). + */ +[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(odd) > td.sticky-first-col-cell { + background-color: var(--color-base-100); +} + +[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr:nth-child(even) > td.sticky-first-col-cell { + background-color: var(--color-base-200); +} + +/* + * Checkbox-selected rows: keep zebra backgrounds; only accent the sticky checkbox column. + */ +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr[data-selected="true"] + > td.sticky-first-col-cell { + box-shadow: inset 2px 0 0 var(--color-primary); +} + +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)) + > td.sticky-first-col-cell { + background-color: var(--color-base-300); + /* Left accent only; keep the familiar orange primary accent. */ + box-shadow: inset 2px 0 0 var(--color-primary); +} + +/* + * Sticky member selection table: drop mouse-only focus outlines that read like a thin frame around the row; + * keyboard :focus-visible keeps DaisyUI control outlines (checkbox / tabindex cell). + */ +[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr { + outline: none; +} + +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)):not(:last-child) { + /* DaisyUI draws a bottom border on each row; hiding it while highlighted avoids a boxy “frame”. */ + border-bottom-color: transparent; +} + +[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr td:focus:not(:focus-visible) { + outline: none; +} + +[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) { + outline: none; +} diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md index 4ae7760..b19f6e4 100644 --- a/docs/smtp-configuration-concept.md +++ b/docs/smtp-configuration-concept.md @@ -25,7 +25,11 @@ Enable configurable SMTP for sending transactional emails (join confirmation, us | ENV | 1 | Production, Docker, 12-factor | | Settings | 2 | Admin UI, dev without ENV | -When an ENV variable is set, the corresponding Settings field is read-only in the UI (with hint "Set by environment"). +When `SMTP_HOST` is set, SMTP runs in **ENV-only mode**: +- all SMTP fields in Settings are read-only, +- saving SMTP settings in the UI is disabled, +- and the UI shows a warning block if required SMTP ENV values are missing. +- the UI displays the effective ENV-driven SMTP values in disabled fields so admins can verify what is active. --- @@ -63,6 +67,14 @@ Support **SMTP_PASSWORD_FILE** (path to file containing the password), same patt - Show a warning in the Settings UI. - Delivery attempts silently fall back to the Local adapter (no crash). +### 6.1 Behaviour in ENV-only mode (`SMTP_HOST` set) + +- The SMTP source of truth is environment variables only. +- The UI does not allow editing SMTP fields in this mode. +- The Settings page shows a warning block when required values are missing: + - `SMTP_USERNAME` + - `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE` + --- ## 7. Test Email (Settings UI) diff --git a/lib/mv/config.ex b/lib/mv/config.ex index 3494937..870d1d3 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -470,56 +470,77 @@ defmodule Mv.Config do # --------------------------------------------------------------------------- @doc """ - Returns SMTP host. ENV `SMTP_HOST` overrides Settings. + Returns SMTP host. + + Policy: + - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_HOST` + - Settings mode: read from Settings only """ @spec smtp_host() :: String.t() | nil def smtp_host do - smtp_env_or_setting("SMTP_HOST", :smtp_host) - end - - @doc """ - Returns SMTP port as integer. ENV `SMTP_PORT` (parsed) overrides Settings. - Returns nil when neither ENV nor Settings provide a valid port. - """ - @spec smtp_port() :: non_neg_integer() | nil - def smtp_port do - case System.get_env("SMTP_PORT") do - nil -> - get_from_settings_integer(:smtp_port) - - value when is_binary(value) -> - case Integer.parse(String.trim(value)) do - {port, _} when port > 0 -> port - _ -> nil - end + if smtp_env_mode?() do + System.get_env("SMTP_HOST") |> trim_nil() + else + get_from_settings(:smtp_host) end end @doc """ - Returns SMTP username. ENV `SMTP_USERNAME` overrides Settings. + Returns SMTP port as integer. + + Policy: + - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT` + - Settings mode: read from Settings only + """ + @spec smtp_port() :: non_neg_integer() | nil + def smtp_port do + if smtp_env_mode?() do + parse_smtp_port_env(System.get_env("SMTP_PORT")) + else + get_from_settings_integer(:smtp_port) + end + end + + @doc """ + Returns SMTP username. + + Policy: + - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_USERNAME` + - Settings mode: read from Settings only """ @spec smtp_username() :: String.t() | nil def smtp_username do - smtp_env_or_setting("SMTP_USERNAME", :smtp_username) + if smtp_env_mode?() do + System.get_env("SMTP_USERNAME") |> trim_nil() + else + get_from_settings(:smtp_username) + end end @doc """ Returns SMTP password. - Priority: `SMTP_PASSWORD` ENV > `SMTP_PASSWORD_FILE` (file contents) > Settings. + Policy: + - ENV-only mode (`SMTP_HOST` set): `SMTP_PASSWORD` > `SMTP_PASSWORD_FILE` + - Settings mode: read from Settings only + Strips trailing whitespace/newlines from file contents. """ @spec smtp_password() :: String.t() | nil def smtp_password do - case System.get_env("SMTP_PASSWORD") do - nil -> smtp_password_from_file_or_settings() - value -> trim_nil(value) + if smtp_env_mode?() do + case System.get_env("SMTP_PASSWORD") do + nil -> smtp_password_from_file() + value -> trim_nil(value) + end + else + get_smtp_password_from_settings() end end - defp smtp_password_from_file_or_settings do + defp smtp_password_from_file do case System.get_env("SMTP_PASSWORD_FILE") do - nil -> get_smtp_password_from_settings() + nil -> nil path -> read_smtp_password_file(path) end end @@ -533,11 +554,18 @@ defmodule Mv.Config do @doc """ Returns SMTP TLS/SSL mode string (e.g. 'tls', 'ssl', 'none'). - ENV `SMTP_SSL` overrides Settings. + + Policy: + - ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_SSL` + - Settings mode: read from Settings only """ @spec smtp_ssl() :: String.t() | nil def smtp_ssl do - smtp_env_or_setting("SMTP_SSL", :smtp_ssl) + if smtp_env_mode?() do + System.get_env("SMTP_SSL") |> trim_nil() + else + get_from_settings(:smtp_ssl) + end end @doc """ @@ -549,12 +577,32 @@ defmodule Mv.Config do end @doc """ - Returns true when any SMTP ENV variable is set (used in Settings UI for hints). + Returns true when SMTP is managed by environment variables. + + Policy: if `SMTP_HOST` is set, SMTP is treated as ENV-only. """ - @spec smtp_env_configured?() :: boolean() - def smtp_env_configured? do - smtp_host_env_set?() or smtp_port_env_set?() or smtp_username_env_set?() or - smtp_password_env_set?() or smtp_ssl_env_set?() + @spec smtp_env_mode?() :: boolean() + def smtp_env_mode? do + smtp_host_env_set?() + end + + @doc """ + Returns missing required SMTP ENV keys for ENV-only mode warnings. + + Required in ENV-only mode: + - `SMTP_USERNAME` + - one of `SMTP_PASSWORD` or `SMTP_PASSWORD_FILE` + """ + @spec smtp_missing_required_env_keys() :: [String.t()] + def smtp_missing_required_env_keys do + if smtp_env_mode?() do + [] + |> maybe_add_missing("SMTP_USERNAME", smtp_username_env_set?()) + |> maybe_add_missing("SMTP_PASSWORD/SMTP_PASSWORD_FILE", smtp_password_env_set?()) + |> Enum.reverse() + else + [] + end end @doc "Returns true if SMTP_HOST ENV is set." @@ -618,14 +666,18 @@ defmodule Mv.Config do @spec mail_from_email_env_set?() :: boolean() def mail_from_email_env_set?, do: env_set?("MAIL_FROM_EMAIL") - # Reads a plain string SMTP setting: ENV first, then Settings. - defp smtp_env_or_setting(env_key, setting_key) do - case System.get_env(env_key) do - nil -> get_from_settings(setting_key) - value -> trim_nil(value) + defp parse_smtp_port_env(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {port, _} when port > 0 -> port + _ -> nil end end + defp parse_smtp_port_env(_), do: nil + + defp maybe_add_missing(acc, _label, true), do: acc + defp maybe_add_missing(acc, label, false), do: [label | acc] + # Reads an integer setting attribute from Settings. defp get_from_settings_integer(key) do case Mv.Membership.get_settings() do diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index b5bd763..465d41a 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -938,6 +938,16 @@ defmodule MvWeb.CoreComponents do doc: "when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop" + attr :wrapper_overflow_class, :string, + default: "overflow-x-auto", + doc: + "overflow class for the table wrapper; set to overflow-visible when outer container owns scrolling" + + attr :sticky_first_col, :boolean, + default: false, + doc: + "when true, first header/body column gets sticky left positioning to keep selection controls visible" + slot :col, required: true do attr :label, :string attr :class, :string @@ -974,15 +984,19 @@ defmodule MvWeb.CoreComponents do ~H"""
@@ -1026,6 +1046,13 @@ defmodule MvWeb.CoreComponents do has_click = col[:col_click] || @row_click classes = ["max-w-xs"] + classes = + if @sticky_first_col && col_idx == 0 do + ["sticky-first-col-cell sticky left-0 z-20" | classes] + else + classes + end + classes = if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do ["truncate" | classes] @@ -1040,7 +1067,7 @@ defmodule MvWeb.CoreComponents do classes end - # WCAG: no focus ring on the cell itself; row shows focus via focus-within + # WCAG: no focus ring on the cell itself; sticky zebra rows show keyboard focus via CSS :has(:focus-visible) classes = if @row_click && @first_row_click_col_idx == col_idx do [ @@ -1111,30 +1138,11 @@ defmodule MvWeb.CoreComponents do end end - # Returns CSS classes for table row: hover/focus-within outline when row_click is set, - # and stronger selected outline when selected (WCAG: not color-only). - # Hover/focus-within are omitted for the selected row so the selected ring stays visible. - defp table_row_tr_class(row_click, selected?) do - has_row_click? = not is_nil(row_click) - base = [] - - base = - if has_row_click? and not selected?, - do: - base ++ - [ - "hover:ring-2", - "hover:ring-inset", - "hover:ring-base-content/10", - "focus-within:ring-2", - "focus-within:ring-inset", - "focus-within:ring-base-content/10" - ], - else: base - - base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base - Enum.join(base, " ") - end + # Returns CSS classes for table row selection styles. + # Hover/focus row highlighting is CSS-driven via [data-row-interactive] selectors in app.css. + # Sticky-first-column zebra tables use CSS accents and omit selected row ring classes. + defp table_row_tr_class(true, false), do: "ring-2 ring-inset ring-primary" + defp table_row_tr_class(_, _), do: "" defp table_th_aria_sort(col, sort_field, sort_order) do col_sort = Map.get(col, :sort_field) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 43851db..6a1c926 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -85,14 +85,8 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> assign(:registration_enabled, settings.registration_enabled != false) - |> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?()) - |> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?()) - |> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?()) - |> assign(:smtp_username_env_set, Mv.Config.smtp_username_env_set?()) - |> assign(:smtp_password_env_set, Mv.Config.smtp_password_env_set?()) - |> assign(:smtp_ssl_env_set, Mv.Config.smtp_ssl_env_set?()) - |> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?()) - |> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?()) + |> assign(:smtp_env_mode, Mv.Config.smtp_env_mode?()) + |> assign(:smtp_missing_required_env_keys, Mv.Config.smtp_missing_required_env_keys()) |> assign(:smtp_password_set, present?(Mv.Config.smtp_password())) |> assign(:smtp_configured, Mv.Config.smtp_configured?()) |> assign(:smtp_test_result, nil) @@ -321,12 +315,25 @@ defmodule MvWeb.GlobalSettingsLive do <%!-- SMTP / E-Mail Section --%> <.form_section title={gettext("SMTP / E-Mail")}> - <%= if @smtp_env_configured do %> + <%= if @smtp_env_mode do %>

- {gettext("Some values are set via environment variables. Those fields are read-only.")} + {gettext( + "SMTP is fully managed via environment variables. All SMTP fields are read-only." + )}

<% end %> + <%= if @smtp_env_mode and @smtp_missing_required_env_keys != [] do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" /> + + {gettext("SMTP environment configuration appears incomplete. Missing: %{keys}", + keys: Enum.join(@smtp_missing_required_env_keys, ", ") + )} + +
+ <% end %> + <%= if @environment == :prod and not @smtp_configured do %>
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" /> @@ -345,32 +352,26 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_host]} type="text" label={gettext("Host")} - disabled={@smtp_host_env_set} - placeholder={ - if(@smtp_host_env_set, - do: gettext("From SMTP_HOST"), - else: "smtp.example.com" - ) - } + disabled={@smtp_env_mode} + placeholder="smtp.example.com" /> <.input field={@form[:smtp_port]} type="number" label={gettext("Port")} - disabled={@smtp_port_env_set} - placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")} + disabled={@smtp_env_mode} + placeholder="587" /> <.input field={@form[:smtp_ssl]} type="select" label={gettext("TLS/SSL")} - disabled={@smtp_ssl_env_set} + disabled={@smtp_env_mode} options={[ {gettext("TLS (port 587, recommended)"), "tls"}, {gettext("SSL (port 465)"), "ssl"}, {gettext("None (port 25, insecure)"), "none"} ]} - placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)} />
@@ -379,28 +380,20 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_username]} type="text" label={gettext("Username")} - disabled={@smtp_username_env_set} - placeholder={ - if(@smtp_username_env_set, - do: gettext("From SMTP_USERNAME"), - else: "user@example.com" - ) - } + disabled={@smtp_env_mode} + placeholder="user@example.com" /> <.input field={@form[:smtp_password]} type="password" label={gettext("Password")} - disabled={@smtp_password_env_set} + disabled={@smtp_env_mode} placeholder={ - if(@smtp_password_env_set, - do: gettext("From SMTP_PASSWORD"), - else: - if(@smtp_password_set, - do: gettext("Leave blank to keep current"), - else: nil - ) - ) + if @smtp_env_mode do + gettext("From SMTP_PASSWORD") + else + if @smtp_password_set, do: gettext("Leave blank to keep current"), else: nil + end } /> @@ -410,22 +403,15 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:smtp_from_email]} type="email" label={gettext("Sender email (From)")} - disabled={@smtp_from_email_env_set} - placeholder={ - if(@smtp_from_email_env_set, - do: gettext("From MAIL_FROM_EMAIL"), - else: "noreply@example.com" - ) - } + disabled={@smtp_env_mode} + placeholder="noreply@example.com" /> <.input field={@form[:smtp_from_name]} type="text" label={gettext("Sender name (From)")} - disabled={@smtp_from_name_env_set} - placeholder={ - if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila") - } + disabled={@smtp_env_mode} + placeholder="Mila" /> @@ -435,11 +421,7 @@ defmodule MvWeb.GlobalSettingsLive do )}

<.button - :if={ - not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and - @smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and - @smtp_from_name_env_set) - } + :if={not @smtp_env_mode} phx-disable-with={gettext("Saving...")} variant="primary" class="mt-2" @@ -925,9 +907,9 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:oidc_only, Mv.Config.oidc_only?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:smtp_configured, Mv.Config.smtp_configured?()) + |> assign(:smtp_env_mode, Mv.Config.smtp_env_mode?()) + |> assign(:smtp_missing_required_env_keys, Mv.Config.smtp_missing_required_env_keys()) |> assign(:smtp_password_set, present?(Mv.Config.smtp_password())) - |> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?()) - |> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?()) |> assign(:vereinfacht_test_result, test_result) |> put_flash(:success, gettext("Settings updated successfully")) |> assign_form() @@ -1267,25 +1249,17 @@ defmodule MvWeb.GlobalSettingsLive do end defp merge_smtp_env_values(s) do - s - |> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host()) - |> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port()) - |> put_if_env_set( - :smtp_username, - Mv.Config.smtp_username_env_set?(), - Mv.Config.smtp_username() - ) - |> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl()) - |> put_if_env_set( - :smtp_from_email, - Mv.Config.mail_from_email_env_set?(), - Mv.Config.mail_from_email() - ) - |> put_if_env_set( - :smtp_from_name, - Mv.Config.mail_from_name_env_set?(), - Mv.Config.mail_from_name() - ) + if Mv.Config.smtp_env_mode?() do + s + |> Map.put(:smtp_host, Mv.Config.smtp_host()) + |> Map.put(:smtp_port, Mv.Config.smtp_port()) + |> Map.put(:smtp_username, Mv.Config.smtp_username()) + |> Map.put(:smtp_ssl, Mv.Config.smtp_ssl()) + |> Map.put(:smtp_from_email, Mv.Config.mail_from_email()) + |> Map.put(:smtp_from_name, Mv.Config.mail_from_name()) + else + s + end end defp enrich_sync_errors([]), do: [] diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 4d86a62..efc1eb7 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -105,7 +105,9 @@ <.table id="members" rows={@members} + wrapper_overflow_class="overflow-visible" sticky_header={true} + sticky_first_col={true} row_id={fn member -> "row-#{member.id}" end} row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} row_tooltip={gettext("Click for member details")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 52270cc..a85b4cf 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -1384,16 +1384,6 @@ msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitrags msgid "From %{first} to %{last} (relevant years with membership data)" msgstr "Von %{first} bis %{last} (Jahre mit Mitgliederdaten)" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From MAIL_FROM_EMAIL" -msgstr "Aus MAIL_FROM_EMAIL" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From MAIL_FROM_NAME" -msgstr "Aus MAIL_FROM_NAME" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From OIDC_ADMIN_GROUP_NAME" @@ -1429,31 +1419,11 @@ msgstr "Aus OIDC_ONLY" msgid "From OIDC_REDIRECT_URI" msgstr "Aus OIDC_REDIRECT_URI" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_HOST" -msgstr "Von SMTP_HOST" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From SMTP_PASSWORD" msgstr "Von SMTP_PASSWORD" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_PORT" -msgstr "Von SMTP_PORT" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_SSL" -msgstr "Von SMTP_SSL" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_USERNAME" -msgstr "Von SMTP_USERNAME" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -3917,3 +3887,13 @@ msgstr "Offen" #, elixir-autogen, elixir-format msgid "join page URL in a new tab" msgstr "Beitrittslink in einem neuen Tab" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5d48691..b995b1a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -1385,16 +1385,6 @@ msgstr "" msgid "From %{first} to %{last} (relevant years with membership data)" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From MAIL_FROM_EMAIL" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From MAIL_FROM_NAME" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From OIDC_ADMIN_GROUP_NAME" @@ -1430,31 +1420,11 @@ msgstr "" msgid "From OIDC_REDIRECT_URI" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_HOST" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From SMTP_PASSWORD" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_PORT" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_SSL" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_USERNAME" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -3917,3 +3887,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "join page URL in a new tab" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index ec6f305..f4526d1 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1385,16 +1385,6 @@ msgstr "" msgid "From %{first} to %{last} (relevant years with membership data)" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From MAIL_FROM_EMAIL" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From MAIL_FROM_NAME" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From OIDC_ADMIN_GROUP_NAME" @@ -1430,31 +1420,11 @@ msgstr "" msgid "From OIDC_REDIRECT_URI" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_HOST" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From SMTP_PASSWORD" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_PORT" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_SSL" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "From SMTP_USERNAME" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -3917,3 +3887,13 @@ msgstr "Open" #, elixir-autogen, elixir-format msgid "join page URL in a new tab" msgstr "join page URL in a new tab" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP environment configuration appears incomplete. Missing: %{keys}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." +msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only." diff --git a/test/mv/config_smtp_test.exs b/test/mv/config_smtp_test.exs index 5359366..ebf2b83 100644 --- a/test/mv/config_smtp_test.exs +++ b/test/mv/config_smtp_test.exs @@ -2,7 +2,7 @@ defmodule Mv.ConfigSmtpTest do @moduledoc """ Unit tests for Mv.Config SMTP-related helpers. - ENV overrides Settings (same pattern as OIDC/Vereinfacht). Uses real ENV and + SMTP uses ENV-only mode when SMTP_HOST is set. Uses real ENV and Settings; no mocking so we test the actual precedence. async: false because we mutate ENV. """ @@ -23,7 +23,8 @@ defmodule Mv.ConfigSmtpTest do end describe "smtp_port/0" do - test "returns parsed integer when SMTP_PORT ENV is set" do + test "returns parsed integer when SMTP_PORT ENV is set in ENV-only mode" do + set_smtp_env("SMTP_HOST", "smtp.example.com") set_smtp_env("SMTP_PORT", "587") assert Mv.Config.smtp_port() == 587 after @@ -51,30 +52,40 @@ defmodule Mv.ConfigSmtpTest do end end - describe "smtp_env_configured?/0" do - test "returns true when any SMTP ENV variable is set" do + describe "smtp_env_mode?/0" do + test "returns true when SMTP_HOST is set" do set_smtp_env("SMTP_HOST", "smtp.example.com") - assert Mv.Config.smtp_env_configured?() == true + assert Mv.Config.smtp_env_mode?() == true + after + clear_smtp_env() + end + + test "returns false when SMTP_HOST is not set even if other SMTP ENV variables are set" do + set_smtp_env("SMTP_USERNAME", "user@example.com") + set_smtp_env("SMTP_PASSWORD", "secret") + refute Mv.Config.smtp_env_mode?() after clear_smtp_env() end test "returns false when no SMTP ENV variables are set" do clear_smtp_env() - refute Mv.Config.smtp_env_configured?() + refute Mv.Config.smtp_env_mode?() end end describe "smtp_password/0 and SMTP_PASSWORD_FILE" do - test "returns value from SMTP_PASSWORD when set" do + test "returns value from SMTP_PASSWORD when set in ENV-only mode" do + set_smtp_env("SMTP_HOST", "smtp.example.com") set_smtp_env("SMTP_PASSWORD", "env-secret") assert Mv.Config.smtp_password() == "env-secret" after clear_smtp_env() end - test "returns content of file when SMTP_PASSWORD_FILE is set and SMTP_PASSWORD is not" do + test "returns content of file when SMTP_PASSWORD_FILE is set in ENV-only mode and SMTP_PASSWORD is not" do clear_smtp_env() + set_smtp_env("SMTP_HOST", "smtp.example.com") path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}") File.write!(path, "file-secret\n") Process.put(:smtp_password_file_path, path) @@ -85,7 +96,8 @@ defmodule Mv.ConfigSmtpTest do if path = Process.get(:smtp_password_file_path), do: File.rm(path) end - test "SMTP_PASSWORD overrides SMTP_PASSWORD_FILE when both are set" do + test "SMTP_PASSWORD overrides SMTP_PASSWORD_FILE in ENV-only mode when both are set" do + set_smtp_env("SMTP_HOST", "smtp.example.com") path = Path.join(System.tmp_dir!(), "mv_smtp_test_#{System.unique_integer([:positive])}") File.write!(path, "file-secret") Process.put(:smtp_password_file_path, path) diff --git a/test/mv_web/components/core_components_table_test.exs b/test/mv_web/components/core_components_table_test.exs index 931b42a..03f1f71 100644 --- a/test/mv_web/components/core_components_table_test.exs +++ b/test/mv_web/components/core_components_table_test.exs @@ -9,7 +9,7 @@ defmodule MvWeb.Components.CoreComponentsTableTest do alias MvWeb.CoreComponents describe "table row_click styling" do - test "when row_click is set, table rows have hover and focus-within ring classes" do + test "when row_click is set, rows are marked interactive and omit ring hover classes" do rows = [%{id: "1", name: "Alice"}, %{id: "2", name: "Bob"}] assigns = %{ @@ -31,12 +31,12 @@ defmodule MvWeb.Components.CoreComponentsTableTest do html = render_component(&CoreComponents.table/1, assigns) - assert html =~ "hover:ring-2" - assert html =~ "focus-within:ring-2" - assert html =~ "hover:ring-base-content/10" + assert html =~ ~s(data-row-interactive="true") + refute html =~ "hover:ring-2" + refute html =~ "focus-within:ring-2" end - test "when row_click is nil, table rows do not have hover ring classes" do + test "when row_click is nil, rows are not marked interactive" do rows = [%{id: "1", name: "Alice"}] assigns = %{ @@ -58,8 +58,7 @@ defmodule MvWeb.Components.CoreComponentsTableTest do html = render_component(&CoreComponents.table/1, assigns) - refute html =~ "hover:ring-2" - refute html =~ "focus-within:ring-2" + refute html =~ ~s(data-row-interactive="true") end end @@ -151,4 +150,183 @@ defmodule MvWeb.Components.CoreComponentsTableTest do assert html =~ "ring-primary" end end + + describe "table scroll wrapper contract" do + test "sticky header table uses horizontal-only overflow wrapper" do + rows = [%{id: "1", name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: nil, + sticky_header: true, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ ~s(class="overflow-x-auto") + refute html =~ ~s(class="overflow-auto") + end + + test "table wrapper does not enable vertical overflow by default" do + rows = [%{id: "1", name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: nil, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ ~s(class="overflow-x-auto") + refute html =~ ~s(class="overflow-auto") + end + + test "table wrapper overflow class can be overridden by caller" do + rows = [%{id: "1", name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: nil, + wrapper_overflow_class: "overflow-visible", + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ ~s(class="overflow-visible") + refute html =~ ~s(class="overflow-x-auto") + end + end + + describe "sticky first column contract" do + test "when sticky_first_col is enabled, first header and body cells render sticky-left classes" do + rows = [%{id: "1", selected: true, name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: nil, + sticky_first_col: true, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Select", + inner_block: fn _socket, item -> [if(item[:selected], do: "x", else: "")] end + }, + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ "sticky" + assert html =~ "left-0" + assert html =~ "z-20" + assert html =~ "z-30" + end + + test "sticky first column marks wrapper and uses CSS row backgrounds instead of row ring classes" do + rows = [%{id: "1", name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: fn _ -> nil end, + sticky_first_col: true, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Select", + inner_block: fn _socket, _item -> ["x"] end + }, + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ ~s(data-sticky-first-col-rows="true") + assert html =~ "sticky-first-col-cell" + refute html =~ "hover:ring-2" + end + + test "sticky first column with selection sets data-selected without ring-primary" do + rows = [%{id: "one", name: "Alice"}, %{id: "two", name: "Bob"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: fn _ -> nil end, + sticky_first_col: true, + selected_row_id: "two", + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ ~s(data-selected="true") + refute html =~ "ring-primary" + end + end end diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs index 2edaf74..9be12b9 100644 --- a/test/mv_web/live/global_settings_live_test.exs +++ b/test/mv_web/live/global_settings_live_test.exs @@ -1,8 +1,22 @@ defmodule MvWeb.GlobalSettingsLiveTest do - use MvWeb.ConnCase, async: true + use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest alias Mv.Membership + defp clear_smtp_env do + [ + "SMTP_HOST", + "SMTP_PORT", + "SMTP_SSL", + "SMTP_USERNAME", + "SMTP_PASSWORD", + "SMTP_PASSWORD_FILE", + "MAIL_FROM_EMAIL", + "MAIL_FROM_NAME" + ] + |> Enum.each(&System.delete_env/1) + end + describe "Global Settings LiveView" do setup %{conn: conn} do user = create_test_user(%{email: "admin@example.com"}) @@ -124,6 +138,85 @@ defmodule MvWeb.GlobalSettingsLiveTest do {:ok, _view, html} = live(conn, ~p"/settings") assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings" end + + @tag :ui + test "disables all SMTP inputs when SMTP_HOST is set", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-only.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, view, _html} = live(conn, ~p"/settings") + + assert has_element?(view, "#setting_smtp_host[disabled]") + assert has_element?(view, "#setting_smtp_port[disabled]") + assert has_element?(view, "#setting_smtp_ssl[disabled]") + assert has_element?(view, "#setting_smtp_username[disabled]") + assert has_element?(view, "#setting_smtp_password[disabled]") + assert has_element?(view, "#setting_smtp_from_email[disabled]") + assert has_element?(view, "#setting_smtp_from_name[disabled]") + end + + @tag :ui + test "does not render SMTP save action when SMTP_HOST is set", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-only.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, view, _html} = live(conn, ~p"/settings") + refute has_element?(view, "#smtp-form button", "Save SMTP Settings") + end + + @tag :ui + test "shows explicit ENV-only mode hint when SMTP_HOST is set", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-only.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, _view, html} = live(conn, ~p"/settings") + assert html =~ "SMTP is fully managed via environment variables" + end + + @tag :ui + test "shows warning block for missing required SMTP ENV values in ENV-only mode", %{ + conn: conn + } do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-only.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, _view, html} = live(conn, ~p"/settings") + assert html =~ "SMTP environment configuration appears incomplete" + assert html =~ "SMTP_USERNAME" + assert html =~ "SMTP_PASSWORD/SMTP_PASSWORD_FILE" + end + + @tag :ui + test "does not enter ENV-only mode when SMTP_HOST is not set", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_USERNAME", "leftover@example.com") + on_exit(fn -> clear_smtp_env() end) + + {:ok, view, html} = live(conn, ~p"/settings") + + refute html =~ "SMTP is fully managed via environment variables" + refute html =~ "SMTP environment configuration appears incomplete" + refute has_element?(view, "#setting_smtp_host[disabled]") + refute has_element?(view, "#setting_smtp_username[disabled]") + end + + @tag :ui + test "shows effective ENV SMTP host value in disabled field", %{conn: conn} do + clear_smtp_env() + System.put_env("SMTP_HOST", "smtp.env-active.example") + on_exit(fn -> clear_smtp_env() end) + + {:ok, settings} = Membership.get_settings() + {:ok, _} = Membership.update_settings(settings, %{smtp_host: "smtp.db-legacy.example"}) + + {:ok, _view, html} = live(conn, ~p"/settings") + assert html =~ ~s(value="smtp.env-active.example") + refute html =~ ~s(value="smtp.db-legacy.example") + end end describe "Authentication section when OIDC-only is enabled" do diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 686a8e8..85c3385 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -78,6 +78,37 @@ defmodule MvWeb.MemberLive.IndexTest do assert html =~ "lg:top-0" assert html =~ "bg-base-100" end + + test "members page does not nest a second overflow wrapper inside members-table-scroll", %{ + conn: conn + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + assert html =~ ~s(id="members-keyboard") + assert html =~ ~s(class="overflow-visible") + refute html =~ ~s(id="members-keyboard" class="overflow-x-auto") + refute html =~ ~s(id="members-keyboard" class="overflow-auto") + end + + test "members table keeps checkbox column sticky while horizontally scrolling", %{conn: conn} do + system_actor = SystemActor.get_system_actor() + + {:ok, _member} = + Membership.create_member( + %{first_name: "Sticky", last_name: "Column", email: "sticky-column@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + # Contract: first column (select-all header + row checkbox cells) is sticky on the left + assert html =~ "left-0" + assert html =~ "sticky" + assert html =~ "z-30" + assert html =~ "z-20" + end end describe "translations" do @@ -339,10 +370,12 @@ defmodule MvWeb.MemberLive.IndexTest do assert_redirect(view, ~p"/members/#{member}") end - describe "table row outline (hover and selected)" do + describe "table row highlight (hover and selected)" do @describetag :ui - test "clickable rows have hover and focus-within ring classes", %{conn: conn} do + test "clickable rows with sticky first column use hover/focus background highlight", %{ + conn: conn + } do system_actor = SystemActor.get_system_actor() {:ok, _member} = @@ -354,10 +387,9 @@ defmodule MvWeb.MemberLive.IndexTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") - # CoreComponents table adds hover and focus-within ring when row_click is set - assert html =~ "hover:ring-2" - assert html =~ "focus-within:ring-2" - assert html =~ "hover:ring-base-content/10" + # Sticky-first-column tables: hover/focus fills live in CSS; wrapper is marked for tests. + assert html =~ ~s(data-sticky-first-col-rows="true") + refute html =~ "hover:ring-2" end test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do From efb9faf5377162b684d2996bccdba9147d08fd61 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 8 May 2026 15:20:18 +0200 Subject: [PATCH 16/79] CHANGELOG.md aktualisiert --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e21f4a8..8c8032c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.2.0] - 2026-05-08 ### Changed - **Clickable table row highlights** – The new hover/focus-visible row highlight behavior is now the CoreComponents default across clickable tables. Sticky-first-column tables keep zebra striping and show selection through the sticky-column accent stripe (checkboxes keep their default style). From fb59ef99c1bd97418aebdfa13750b2fe6bdfd4b4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 12 May 2026 23:14:44 +0200 Subject: [PATCH 17/79] Accept future join dates: remove past-only validation and update tests --- lib/membership/member.ex | 5 ----- test/membership/member_test.exs | 37 ++++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 4e85fa8..f87c468 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -473,11 +473,6 @@ defmodule Mv.Membership.Member do end end - # Join date not in future - validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), - where: [present(:join_date)], - message: "cannot be in the future" - # Exit date not before join date validate compare(:exit_date, greater_than: :join_date), where: [present([:join_date, :exit_date])], diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs index ca4d022..5e30da6 100644 --- a/test/membership/member_test.exs +++ b/test/membership/member_test.exs @@ -49,12 +49,43 @@ defmodule Mv.Membership.MemberTest do assert error_message(errors, :email) =~ "is not a valid email" end - test "Join date cannot be in the future", %{actor: actor} do + test "Join date can be in the future", %{actor: actor} do attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1)) - assert {:error, - %Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} = + assert {:ok, _member} = Membership.create_member(attrs, actor: actor) + end + + test "Join date far in the future (2099) is accepted", %{actor: actor} do + attrs = Map.put(@valid_attrs, :join_date, ~D[2099-12-31]) + + assert {:ok, _member} = Membership.create_member(attrs, actor: actor) + end + + test "Join date today is accepted", %{actor: actor} do + attrs = Map.put(@valid_attrs, :join_date, Date.utc_today()) + + assert {:ok, _member} = Membership.create_member(attrs, actor: actor) + end + + test "Join date in the future is accepted on update", %{actor: actor} do + {:ok, member} = Membership.create_member(@valid_attrs, actor: actor) + + assert {:ok, _updated} = + Membership.update_member(member, %{join_date: Date.utc_today() |> Date.add(30)}, + actor: actor + ) + end + + test "Exit date before future join date is rejected", %{actor: actor} do + attrs = + @valid_attrs + |> Map.put(:join_date, Date.utc_today() |> Date.add(10)) + |> Map.put(:exit_date, Date.utc_today() |> Date.add(5)) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs, actor: actor) + + assert error_message(errors, :exit_date) =~ "cannot be before join date" end test "Exit date is optional but must not be before join date if both are specified", %{ From 8062b2fd275ff272a0da54398e89f257e46e48c2 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 12 May 2026 23:16:31 +0200 Subject: [PATCH 18/79] Remove stale documentation of removed join_date future-date restriction --- docs/database-schema-readme.md | 1 - docs/database_schema.dbml | 3 +-- lib/membership/member.ex | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index f58cbea..fa6ea55 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -188,7 +188,6 @@ Settings (1) → MembershipFeeType (0..1) ### Member Constraints - First name and last name required (min 1 char) - Email unique, validated format (5-254 chars) -- Join date cannot be in future - Exit date must be after join date - Phone: `+?[0-9\- ]{6,20}` - Postal code: optional (no format validation) diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 61da063..16c9723 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -124,7 +124,7 @@ Table members { first_name text [null, note: 'Member first name (min length: 1 if present)'] last_name text [null, note: 'Member last name (min length: 1 if present)'] email text [not null, unique, note: 'Member email address (5-254 chars, validated)'] - join_date date [null, note: 'Date when member joined club (cannot be in future)'] + join_date date [null, note: 'Date when member joined club'] exit_date date [null, note: 'Date when member left club (must be after join_date)'] notes text [null, note: 'Additional notes about member'] city text [null, note: 'City of residence'] @@ -187,7 +187,6 @@ Table members { **Validation Rules:** - first_name, last_name: optional, but if present min 1 character - email: 5-254 characters, valid email format (required) - - join_date: cannot be in future - exit_date: must be after join_date (if both present) - postal_code: optional (no format validation) - country: optional diff --git a/lib/membership/member.ex b/lib/membership/member.ex index f87c468..85f5562 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -22,7 +22,7 @@ defmodule Mv.Membership.Member do ## Validations - Required: email (all other fields are optional) - Email format validation (using EctoCommons.EmailValidator) - - Date validations: join_date not in future, exit_date after join_date + - Date validations: exit_date after join_date - Email uniqueness: prevents conflicts with unlinked users - Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`) From ca1600d019a18c86a548255a9735374407c821a7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 13 May 2026 00:25:25 +0200 Subject: [PATCH 19/79] chore(deps): update decimal --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index 7c5b125..a1e9298 100644 --- a/mix.lock +++ b/mix.lock @@ -22,7 +22,7 @@ "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, "db_connection": {:hex, :db_connection, "2.10.0", "8ff756471e41765bd5563b633f73e9a94bbc138816e8644bb17d0d91bf260a95", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02cdd01b45efb1b550e68edbbea41be32de9b24bb07e1ea0e9cbc522ac377e54"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, "ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"}, From 1e639f7e77648afac00ab31b29fd64a1eee9a45d Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 19 May 2026 19:19:10 +0200 Subject: [PATCH 20/79] chore(justfile): set PATH literally so recipes work without per-shell asdf sourcing --- .gitignore | 3 +++ Justfile | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/.gitignore b/.gitignore index 058543c..e177b4d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ npm-debug.log # Docker secrets directory (generated by `just init-secrets`) /secrets/ notes.md + +# Do NOT commit these — they are local to the dev machine +.pipeline/ diff --git a/Justfile b/Justfile index d2c51e5..db53903 100644 --- a/Justfile +++ b/Justfile @@ -1,6 +1,10 @@ set dotenv-load := true set export := true +# PATH includes asdf shims so that mix / elixir / iex resolve without per-shell +# `source ~/.asdf/asdf.sh`. Recipes inherit this via `set export := true`. +PATH := "/root/.asdf/shims:/root/.asdf:/root/.local/bin:/usr/local/bin:/usr/bin:/bin" + MIX_QUIET := "1" run: install-dependencies start-database migrate-database seed-database @@ -69,6 +73,10 @@ test-all *args: install-dependencies format: mix format +# Catch-all wrapper for arbitrary mix commands not exposed as their own recipe. +mix *args: + mix {{args}} + build-docker-container: docker build --tag mitgliederverwaltung . From 85e9d40f7914d586e6f28096fafec2f46f5fd761 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 19 May 2026 19:42:24 +0200 Subject: [PATCH 21/79] chore(deps): cowlib, db_connection, postgrex --- mix.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mix.lock b/mix.lock index a1e9298..12acd0a 100644 --- a/mix.lock +++ b/mix.lock @@ -18,10 +18,10 @@ "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, - "db_connection": {:hex, :db_connection, "2.10.0", "8ff756471e41765bd5563b633f73e9a94bbc138816e8644bb17d0d91bf260a95", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02cdd01b45efb1b550e68edbbea41be32de9b24bb07e1ea0e9cbc522ac377e54"}, + "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, @@ -74,7 +74,7 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "postgrex": {:hex, :postgrex, "0.22.1", "b3665ad17e15441557da8f45eeebfcd56e4a2b0b98538b855679a13d05e5cc5d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "df59f828b167b49a5853f645b65f57eb1bc5f3b230497ceaca7af5d8ac05afef"}, + "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, @@ -90,7 +90,7 @@ "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.25.1", "569fcff34817da8a03f28775146b3c8b71b4c9b14f8f78d37ff3ef422862a18b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58b3e8db6406fe417a89b5042358d2e8f15d32a3317d4f8581d7a3ae501e410b"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, - "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, From 264a585d44a60e2c5ced5281186551e18945ab1d Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 19 May 2026 22:12:45 +0200 Subject: [PATCH 22/79] chore(justfile): set PATH user agnostic --- .gitignore | 1 + Justfile | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e177b4d..b9096bd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ notes.md # Do NOT commit these — they are local to the dev machine .pipeline/ +.claude/ diff --git a/Justfile b/Justfile index db53903..cae8cfb 100644 --- a/Justfile +++ b/Justfile @@ -1,9 +1,11 @@ set dotenv-load := true set export := true +# Non-interactive shells do not source .bashrc, # PATH includes asdf shims so that mix / elixir / iex resolve without per-shell # `source ~/.asdf/asdf.sh`. Recipes inherit this via `set export := true`. -PATH := "/root/.asdf/shims:/root/.asdf:/root/.local/bin:/usr/local/bin:/usr/bin:/bin" +home := env_var('HOME') +PATH := home + "/.asdf/shims:" + home + "/.asdf:" + home + "/.local/bin:/usr/local/bin:/usr/bin:/bin" MIX_QUIET := "1" From 143c0c5c24ea70f9989c98fb96ea0bb7675f5b34 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:16:27 +0200 Subject: [PATCH 23/79] chore(deps): suppress cowlib advisory and bump bandit, cowboy, plug --- .deps_audit_ignore | 9 +++++++++ Justfile | 2 +- mix.lock | 6 +++--- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .deps_audit_ignore diff --git a/.deps_audit_ignore b/.deps_audit_ignore new file mode 100644 index 0000000..27c623d --- /dev/null +++ b/.deps_audit_ignore @@ -0,0 +1,9 @@ +# Temporarily ignored security advisories +# +# Format: one GHSA ID per line. +# Remove an entry once a patched version is available and the dependency is updated. + +# cowlib >= 2.9.0 <= 2.16.1 — Cookie Request Header Injection via cow_cookie:cookie/1 +# Severity: low. No patched version available as of 2026-05-20. +# Tracked upstream: https://github.com/advisories/GHSA-g2wm-735q-3f56 +GHSA-g2wm-735q-3f56 diff --git a/Justfile b/Justfile index cae8cfb..e0bd0d3 100644 --- a/Justfile +++ b/Justfile @@ -45,7 +45,7 @@ lint: audit: mix sobelow --config - mix deps.audit + mix deps.audit --ignore-file .deps_audit_ignore mix hex.audit # Run all tests diff --git a/mix.lock b/mix.lock index 12acd0a..0a36e9e 100644 --- a/mix.lock +++ b/mix.lock @@ -7,7 +7,7 @@ "ash_postgres": {:hex, :ash_postgres, "2.9.1", "bf4229d65706f794650edb47c9f30138a6e2d5af6efe002ca38e619306cca9f6", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "72c0366649985a858d4ef8f906968cee339dfd7519bb0beaa2b4d87f3d5b0bb9"}, "ash_sql": {:hex, :ash_sql, "0.6.3", "a708b34ba71b40141dab9e75dc44a095885ae4635b25135d3fd4c3620b299b97", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "3ee461380d96dca32766a210ea60c64783f690ad5565f0434a00cd475e71e8b9"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, - "bandit": {:hex, :bandit, "1.11.0", "dbdd9c9963f146ee9da9860d1ee5b0ffd65cea51fe2aab3f3273df84329d133a", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c949d93a325a28da2333dde5a9ab61986ad2c2b7226347db6a28303b9139865e"}, + "bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, @@ -16,7 +16,7 @@ "cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, + "cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, @@ -71,7 +71,7 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, - "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, From ddd4a9a878f7f061ebe9e63706ca750764e94bed Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:24:08 +0200 Subject: [PATCH 24/79] feat(date-filter): introduce DateFilter module with URL codec and Ash query expressions --- lib/mv/constants.ex | 76 ++++ .../live/member_live/index/date_filter.ex | 402 ++++++++++++++++++ test/mv/constants_test.exs | 33 ++ .../date_filter_custom_field_test.exs | 210 +++++++++ .../member_live/date_filter_default_test.exs | 24 ++ .../member_live/date_filter_property_test.exs | 134 ++++++ .../live/member_live/date_filter_test.exs | 316 ++++++++++++++ 7 files changed, 1195 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/date_filter.ex create mode 100644 test/mv/constants_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_custom_field_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_default_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_property_test.exs create mode 100644 test/mv_web/live/member_live/date_filter_test.exs diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 517ad2f..4d09c89 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -26,6 +26,18 @@ defmodule Mv.Constants do @fee_type_filter_prefix "fee_type_" + @join_date_from_param "jd_from" + + @join_date_to_param "jd_to" + + @exit_date_mode_param "ed_mode" + + @exit_date_from_param "ed_from" + + @exit_date_to_param "ed_to" + + @custom_date_filter_prefix "cdf_" + @max_boolean_filters 50 @max_uuid_length 36 @@ -84,6 +96,70 @@ defmodule Mv.Constants do """ def fee_type_filter_prefix, do: @fee_type_filter_prefix + @doc """ + Returns the URL parameter name for the join_date lower bound filter. + + ## Examples + + iex> Mv.Constants.join_date_from_param() + "jd_from" + """ + def join_date_from_param, do: @join_date_from_param + + @doc """ + Returns the URL parameter name for the join_date upper bound filter. + + ## Examples + + iex> Mv.Constants.join_date_to_param() + "jd_to" + """ + def join_date_to_param, do: @join_date_to_param + + @doc """ + Returns the URL parameter name for the exit_date filter mode + (`active_only` | `inactive_only` | `all` | `custom`). + + ## Examples + + iex> Mv.Constants.exit_date_mode_param() + "ed_mode" + """ + def exit_date_mode_param, do: @exit_date_mode_param + + @doc """ + Returns the URL parameter name for the exit_date lower bound filter + (only relevant when ed_mode=custom). + + ## Examples + + iex> Mv.Constants.exit_date_from_param() + "ed_from" + """ + def exit_date_from_param, do: @exit_date_from_param + + @doc """ + Returns the URL parameter name for the exit_date upper bound filter + (only relevant when ed_mode=custom). + + ## Examples + + iex> Mv.Constants.exit_date_to_param() + "ed_to" + """ + def exit_date_to_param, do: @exit_date_to_param + + @doc """ + Returns the prefix for custom date field filter URL parameters + (e.g. cdf__from / cdf__to). + + ## Examples + + iex> Mv.Constants.custom_date_filter_prefix() + "cdf_" + """ + def custom_date_filter_prefix, do: @custom_date_filter_prefix + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex new file mode 100644 index 0000000..2a3e04e --- /dev/null +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -0,0 +1,402 @@ +defmodule MvWeb.MemberLive.Index.DateFilter do + @moduledoc """ + Encapsulates the complete lifecycle of date-range filters used on the + member overview page. + + Owns: + + - the default filter state (active members only) + - URL encoding / decoding of filter state + - DB-level Ash expression construction for built-in date fields + (`join_date`, `exit_date`) + - in-memory predicates for custom date-typed custom fields + + ## Filter state shape + + %{ + join_date: %{from: nil | %Date{}, to: nil | %Date{}}, + exit_date: %{ + mode: :active_only | :inactive_only | :all | :custom, + from: nil | %Date{}, + to: nil | %Date{} + }, + # optional custom date field entries (UUID string keys): + "" => %{from: nil | %Date{}, to: nil | %Date{}} + } + + The default mode for `exit_date` is `:active_only`, which means + `exit_date IS NULL OR exit_date > today` — a member who left today is hidden. + """ + + require Ash.Query + import Ash.Expr + + @join_date_from_param Mv.Constants.join_date_from_param() + @join_date_to_param Mv.Constants.join_date_to_param() + @exit_date_mode_param Mv.Constants.exit_date_mode_param() + @exit_date_from_param Mv.Constants.exit_date_from_param() + @exit_date_to_param Mv.Constants.exit_date_to_param() + @custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix() + + @doc """ + Returns the default date filter state used on fresh page load and after + "Clear filters". `exit_date` is set to `:active_only`; all other bounds are nil. + """ + @spec default() :: %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :active_only, from: nil, to: nil} + } + def default do + %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :active_only, from: nil, to: nil} + } + end + + @doc """ + Decodes URL params into a date filter state map. + + Recognized keys: + + * `"jd_from"` / `"jd_to"` — join_date bounds (ISO-8601 dates) + * `"ed_mode"` — exit_date mode (`"active_only"` | `"inactive_only"` | + `"all"` | `"custom"`); absent or unknown values fall back to + `:active_only` + * `"ed_from"` / `"ed_to"` — exit_date bounds (ISO-8601 dates, used when + `ed_mode=custom`) + * `"cdf__from"` / `"cdf__to"` — custom date field bounds; + the UUID must appear (by `to_string/1` on its `:id`) in + `date_custom_fields`, otherwise the entry is dropped + + Malformed ISO-8601 strings are silently discarded; the corresponding bound + stays `nil`. No exception is raised for any malformed input. + """ + @spec from_params(map(), list()) :: map() + def from_params(params, date_custom_fields) + when is_map(params) and is_list(date_custom_fields) do + base = %{ + join_date: %{ + from: parse_date(Map.get(params, @join_date_from_param)), + to: parse_date(Map.get(params, @join_date_to_param)) + }, + exit_date: %{ + mode: parse_exit_date_mode(Map.get(params, @exit_date_mode_param)), + from: parse_date(Map.get(params, @exit_date_from_param)), + to: parse_date(Map.get(params, @exit_date_to_param)) + } + } + + parse_custom_date_filters(params, date_custom_fields, base) + end + + @doc """ + Encodes a date filter state map into a URL params map (string keys, string + values). + + Encoding rules: + + * `join_date` from/to → `"jd_from"` / `"jd_to"` (omitted when nil) + * `exit_date` mode → + - `:active_only` is the default and is omitted entirely (no `ed_mode`, + no bounds — a fresh URL is the canonical representation of the default + state) + - `:all` / `:inactive_only` → `"ed_mode"` only; bounds are omitted + - `:custom` → `"ed_mode" => "custom"` plus `"ed_from"` / `"ed_to"` + when those bounds are set + * custom date field entries (UUID string keys) → `"cdf__from"` / + `"cdf__to"`; each bound is included only when non-nil; an entry + with both bounds nil produces no params + + All dates are serialized via `Date.to_iso8601/1`. + """ + @spec to_params(map()) :: %{optional(String.t()) => String.t()} + def to_params(filters) when is_map(filters) do + %{} + |> put_join_date_params(Map.get(filters, :join_date, %{})) + |> put_exit_date_params(Map.get(filters, :exit_date, %{})) + |> put_custom_date_params(filters) + end + + @doc """ + Applies the DB-level portion of the date filter — `join_date` and + `exit_date` constraints — to the given Ash query. + + Exit_date semantics by mode: + + * `:active_only` → `is_nil(exit_date) or exit_date > today` + * `:inactive_only` → `not is_nil(exit_date) and exit_date <= today` + * `:all` → no filter added for exit_date + * `:custom` → `not is_nil(exit_date)` plus the active bounds; if both + bounds are nil, no filter is added (the user picked "custom" but + entered nothing) + + Join_date is purely a range filter — nil join_date is always excluded when + any bound is set: + + * `from` set → `not is_nil(join_date) and join_date >= from` + * `to` set → `not is_nil(join_date) and join_date <= to` + * neither set → no filter + + Today's date is captured via `Date.utc_today/0`; callers needing a frozen + clock should wrap the call site, not this function. + """ + @spec apply_ash_filter(Ash.Query.t() | module(), map()) :: Ash.Query.t() + def apply_ash_filter(query_or_resource, filters) when is_map(filters) do + query_or_resource + |> Ash.Query.new() + |> apply_exit_date_filter(Map.get(filters, :exit_date, %{})) + |> apply_join_date_filter(Map.get(filters, :join_date, %{})) + end + + defp apply_exit_date_filter(query, %{mode: :all}), do: query + + defp apply_exit_date_filter(query, %{mode: :active_only}) do + today = Date.utc_today() + Ash.Query.filter(query, expr(is_nil(exit_date) or exit_date > ^today)) + end + + defp apply_exit_date_filter(query, %{mode: :inactive_only}) do + today = Date.utc_today() + Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^today)) + end + + defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: nil}), do: query + + defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: nil}) do + Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date >= ^from)) + end + + defp apply_exit_date_filter(query, %{mode: :custom, from: nil, to: to}) do + Ash.Query.filter(query, expr(not is_nil(exit_date) and exit_date <= ^to)) + end + + defp apply_exit_date_filter(query, %{mode: :custom, from: from, to: to}) do + Ash.Query.filter( + query, + expr(not is_nil(exit_date) and exit_date >= ^from and exit_date <= ^to) + ) + end + + defp apply_exit_date_filter(query, _), do: query + + defp apply_join_date_filter(query, %{from: nil, to: nil}), do: query + + defp apply_join_date_filter(query, %{from: from, to: nil}) when not is_nil(from) do + Ash.Query.filter(query, expr(not is_nil(join_date) and join_date >= ^from)) + end + + defp apply_join_date_filter(query, %{from: nil, to: to}) when not is_nil(to) do + Ash.Query.filter(query, expr(not is_nil(join_date) and join_date <= ^to)) + end + + defp apply_join_date_filter(query, %{from: from, to: to}) + when not is_nil(from) and not is_nil(to) do + Ash.Query.filter( + query, + expr(not is_nil(join_date) and join_date >= ^from and join_date <= ^to) + ) + end + + defp apply_join_date_filter(query, _), do: query + + @doc """ + Applies the in-memory portion of the date filter — custom date fields + whose values live in JSONB-backed `custom_field_values`. + + Behavior: + + * Only entries whose UUID key matches a `date_custom_fields` entry + (by `to_string(field.id)` and `value_type == :date`) are considered. + * Entries with both bounds nil add no constraint. + * For an active entry, a member is kept iff its custom field value is + present AND the value (unwrapped from `%Ash.Union{type: :date}`) + satisfies `value >= from` (when from set) AND `value <= to` + (when to set). + * Members with `custom_field_values` nil, `%Ash.NotLoaded{}`, an empty + list, or no entry for the active field — are excluded. + * Non-date `Ash.Union` types are treated as "no value" and exclude the + member. + + Returns the filtered list of members (order preserved). + """ + @spec apply_in_memory([map()], map(), [map()]) :: [map()] + def apply_in_memory(members, filters, date_custom_fields) + when is_list(members) and is_map(filters) and is_list(date_custom_fields) do + active_filters = active_custom_date_filters(filters, date_custom_fields) + + if active_filters == [] do + members + else + Enum.filter(members, &matches_all_custom_dates?(&1, active_filters)) + end + end + + defp matches_all_custom_dates?(member, active_filters) do + Enum.all?(active_filters, fn {id, bounds} -> + member_matches_custom_date?(member, id, bounds) + end) + end + + defp active_custom_date_filters(filters, date_custom_fields) do + valid_ids = + date_custom_fields + |> Enum.filter(&date_field?/1) + |> MapSet.new(&to_string(field_id(&1))) + + filters + |> Enum.filter(fn + {key, %{from: from, to: to}} when is_binary(key) -> + MapSet.member?(valid_ids, key) and (not is_nil(from) or not is_nil(to)) + + _ -> + false + end) + end + + defp member_matches_custom_date?(member, custom_field_id, %{from: from, to: to}) do + case extract_member_date(member, custom_field_id) do + %Date{} = date -> within_bounds?(date, from, to) + _ -> false + end + end + + defp extract_member_date(member, custom_field_id) do + case Map.get(member, :custom_field_values) do + values when is_list(values) -> + values + |> Enum.find(&cfv_matches_id?(&1, custom_field_id)) + |> extract_date_from_cfv() + + _ -> + nil + end + end + + defp cfv_matches_id?(%{custom_field_id: cfid}, id) when not is_nil(cfid), + do: to_string(cfid) == id + + defp cfv_matches_id?(%{custom_field: %{id: cfid}}, id) when not is_nil(cfid), + do: to_string(cfid) == id + + defp cfv_matches_id?(_, _), do: false + + defp extract_date_from_cfv(nil), do: nil + + defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value) + + defp extract_date_from_cfv(_), do: nil + + defp extract_date_value(%Ash.Union{value: %Date{} = date, type: :date}), do: date + defp extract_date_value(_), do: nil + + defp within_bounds?(%Date{} = date, from, to) do + from_ok? = is_nil(from) or Date.compare(date, from) != :lt + to_ok? = is_nil(to) or Date.compare(date, to) != :gt + from_ok? and to_ok? + end + + defp put_join_date_params(params, %{from: from, to: to}) do + params + |> maybe_put_date(@join_date_from_param, from) + |> maybe_put_date(@join_date_to_param, to) + end + + defp put_join_date_params(params, _), do: params + + defp put_exit_date_params(params, %{mode: :active_only}), do: params + + defp put_exit_date_params(params, %{mode: mode}) + when mode in [:all, :inactive_only] do + Map.put(params, @exit_date_mode_param, Atom.to_string(mode)) + end + + defp put_exit_date_params(params, %{mode: :custom, from: from, to: to}) do + params + |> Map.put(@exit_date_mode_param, "custom") + |> maybe_put_date(@exit_date_from_param, from) + |> maybe_put_date(@exit_date_to_param, to) + end + + defp put_exit_date_params(params, _), do: params + + defp put_custom_date_params(params, filters) do + prefix = @custom_date_filter_prefix + + filters + |> Enum.filter(fn {key, _value} -> is_binary(key) end) + |> Enum.reduce(params, fn {id, %{from: from, to: to}}, acc -> + acc + |> maybe_put_date("#{prefix}#{id}_from", from) + |> maybe_put_date("#{prefix}#{id}_to", to) + end) + end + + defp maybe_put_date(params, _key, nil), do: params + + defp maybe_put_date(params, key, %Date{} = date), + do: Map.put(params, key, Date.to_iso8601(date)) + + defp parse_date(nil), do: nil + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil + + defp parse_exit_date_mode("all"), do: :all + defp parse_exit_date_mode("inactive_only"), do: :inactive_only + defp parse_exit_date_mode("custom"), do: :custom + defp parse_exit_date_mode("active_only"), do: :active_only + defp parse_exit_date_mode(_), do: :active_only + + defp parse_custom_date_filters(params, date_custom_fields, base) do + valid_ids = + date_custom_fields + |> Enum.filter(&date_field?/1) + |> MapSet.new(&to_string(field_id(&1))) + + prefix = @custom_date_filter_prefix + + params + |> Enum.reduce(base, fn {key, value}, acc -> + with true <- is_binary(key), + true <- String.starts_with?(key, prefix), + {id, bound} <- split_custom_date_key(key, prefix), + true <- MapSet.member?(valid_ids, id), + %Date{} = date <- parse_date(value) do + update_custom_date_entry(acc, id, bound, date) + else + _ -> acc + end + end) + end + + defp date_field?(%{value_type: :date}), do: true + defp date_field?(_), do: false + + defp field_id(%{id: id}), do: id + + defp split_custom_date_key(key, prefix) do + rest = String.slice(key, String.length(prefix), String.length(key)) + + cond do + String.ends_with?(rest, "_from") -> + {String.slice(rest, 0, String.length(rest) - 5), :from} + + String.ends_with?(rest, "_to") -> + {String.slice(rest, 0, String.length(rest) - 3), :to} + + true -> + :error + end + end + + defp update_custom_date_entry(acc, id, bound, date) do + current = Map.get(acc, id, %{from: nil, to: nil}) + Map.put(acc, id, Map.put(current, bound, date)) + end +end diff --git a/test/mv/constants_test.exs b/test/mv/constants_test.exs new file mode 100644 index 0000000..4ae689f --- /dev/null +++ b/test/mv/constants_test.exs @@ -0,0 +1,33 @@ +defmodule Mv.ConstantsTest do + @moduledoc """ + Tests for Mv.Constants accessor functions. Focus is on the date filter + URL parameter prefixes that drive the bookmarkable filter state. + """ + use ExUnit.Case, async: true + + describe "date filter URL param prefixes" do + test "join_date_from_param/0 returns jd_from" do + assert Mv.Constants.join_date_from_param() == "jd_from" + end + + test "join_date_to_param/0 returns jd_to" do + assert Mv.Constants.join_date_to_param() == "jd_to" + end + + test "exit_date_mode_param/0 returns ed_mode" do + assert Mv.Constants.exit_date_mode_param() == "ed_mode" + end + + test "exit_date_from_param/0 returns ed_from" do + assert Mv.Constants.exit_date_from_param() == "ed_from" + end + + test "exit_date_to_param/0 returns ed_to" do + assert Mv.Constants.exit_date_to_param() == "ed_to" + end + + test "custom_date_filter_prefix/0 returns cdf_" do + assert Mv.Constants.custom_date_filter_prefix() == "cdf_" + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_custom_field_test.exs b/test/mv_web/live/member_live/date_filter_custom_field_test.exs new file mode 100644 index 0000000..2959e77 --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_custom_field_test.exs @@ -0,0 +1,210 @@ +defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do + @moduledoc """ + Unit tests for `DateFilter.apply_in_memory/3` — the post-`Ash.read!` + predicate that filters members by custom date field values stored as + JSONB `Ash.Union` types in `custom_field_values`. + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.DateFilter + + # ---- helpers --------------------------------------------------------- + + defp date_custom_field(id, name \\ "Birthday") do + %{id: id, value_type: :date, name: name} + end + + defp date_cfv(custom_field_id, %Date{} = date) do + %{ + custom_field_id: custom_field_id, + value: %Ash.Union{value: date, type: :date} + } + end + + defp member_with_dates(id, custom_field_values) do + %{id: id, custom_field_values: custom_field_values} + end + + # ---- no-op cases ----------------------------------------------------- + + describe "apply_in_memory/3 — no-op cases" do + test "returns members unchanged when filters has no custom date entries" do + filters = DateFilter.default() + members = [member_with_dates("m1", []), member_with_dates("m2", [])] + assert DateFilter.apply_in_memory(members, filters, []) == members + end + + test "ignores custom date entries whose UUID is not in the date_custom_fields list" do + id = "11111111-2222-3333-4444-555555555555" + other_id = "99999999-8888-7777-6666-555555555555" + + filters = + DateFilter.default() + |> Map.put(other_id, %{from: ~D[2024-01-01], to: nil}) + + m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])]) + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m] + end + + test "entry with both bounds nil is treated as inactive" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: nil, to: nil}) + + m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])]) + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m] + end + end + + # ---- inclusive range semantics -------------------------------------- + + describe "apply_in_memory/3 — inclusive range semantics" do + setup do + id = "11111111-2222-3333-4444-555555555555" + + members = [ + member_with_dates("before", [date_cfv(id, ~D[2024-05-31])]), + member_with_dates("from_boundary", [date_cfv(id, ~D[2024-06-01])]), + member_with_dates("inside", [date_cfv(id, ~D[2024-06-15])]), + member_with_dates("to_boundary", [date_cfv(id, ~D[2024-06-30])]), + member_with_dates("after", [date_cfv(id, ~D[2024-07-01])]) + ] + + %{id: id, members: members, fields: [date_custom_field(id)]} + end + + test "from-only includes member when value >= from (boundary inclusive)", ctx do + filters = + DateFilter.default() + |> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil}) + + ids = + ctx.members + |> DateFilter.apply_in_memory(filters, ctx.fields) + |> Enum.map(& &1.id) + + assert ids == ["from_boundary", "inside", "to_boundary", "after"] + end + + test "from-only excludes member when value < from", ctx do + filters = + DateFilter.default() + |> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil}) + + refute Enum.any?( + DateFilter.apply_in_memory(ctx.members, filters, ctx.fields), + &(&1.id == "before") + ) + end + + test "to-only includes member when value <= to (boundary inclusive)", ctx do + filters = + DateFilter.default() + |> Map.put(ctx.id, %{from: nil, to: ~D[2024-06-30]}) + + ids = + ctx.members + |> DateFilter.apply_in_memory(filters, ctx.fields) + |> Enum.map(& &1.id) + + assert ids == ["before", "from_boundary", "inside", "to_boundary"] + end + + test "from+to applies an inclusive range", ctx do + filters = + DateFilter.default() + |> Map.put(ctx.id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]}) + + ids = + ctx.members + |> DateFilter.apply_in_memory(filters, ctx.fields) + |> Enum.map(& &1.id) + + assert ids == ["from_boundary", "inside", "to_boundary"] + end + end + + # ---- exclusion of members without a value --------------------------- + + describe "apply_in_memory/3 — members without a value" do + test "excludes member with no custom_field_values when bound is active" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-01-01], to: nil}) + + members = [ + member_with_dates("present", [date_cfv(id, ~D[2024-06-01])]), + member_with_dates("nil_list", nil), + member_with_dates("empty_list", []), + # member missing the specific field but having other CFVs: + member_with_dates("other_field", [ + date_cfv("other-aaaa-bbbb-cccc-dddddddddddd", ~D[2024-06-01]) + ]) + ] + + ids = + members + |> DateFilter.apply_in_memory(filters, [date_custom_field(id)]) + |> Enum.map(& &1.id) + + assert ids == ["present"] + end + + test "treats Ash.NotLoaded custom_field_values as no value (excluded)" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-01-01], to: nil}) + + m = %{id: "m1", custom_field_values: %Ash.NotLoaded{}} + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [] + end + end + + # ---- Ash.Union unwrapping ------------------------------------------- + + describe "apply_in_memory/3 — Ash.Union unwrapping" do + test "unwraps %Ash.Union{value: %Date{}, type: :date}" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-06-01], to: nil}) + + m = + member_with_dates("m1", [ + %{ + custom_field_id: id, + value: %Ash.Union{value: ~D[2024-06-15], type: :date} + } + ]) + + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m] + end + + test "rejects values whose Ash.Union type is not :date" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-06-01], to: nil}) + + # A boolean-typed value for what the filter believes is a date field — + # treat as no value, exclude the member. + m = + member_with_dates("m1", [ + %{ + custom_field_id: id, + value: %Ash.Union{value: true, type: :boolean} + } + ]) + + assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [] + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_default_test.exs b/test/mv_web/live/member_live/date_filter_default_test.exs new file mode 100644 index 0000000..84400ef --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_default_test.exs @@ -0,0 +1,24 @@ +defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do + @moduledoc """ + Unit tests for DateFilter.default/0 — the initial filter map used when + no URL params are present (fresh load) and after "Clear filters". + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.DateFilter + + describe "default/0" do + test "returns :active_only mode for exit_date with nil bounds" do + assert %{exit_date: %{mode: :active_only, from: nil, to: nil}} = DateFilter.default() + end + + test "returns nil bounds for join_date" do + assert %{join_date: %{from: nil, to: nil}} = DateFilter.default() + end + + test "contains only :join_date and :exit_date top-level keys" do + defaults = DateFilter.default() + assert Map.keys(defaults) |> Enum.sort() == [:exit_date, :join_date] + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_property_test.exs b/test/mv_web/live/member_live/date_filter_property_test.exs new file mode 100644 index 0000000..693531c --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_property_test.exs @@ -0,0 +1,134 @@ +defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do + @moduledoc """ + Property tests for the pure functions on `DateFilter`: + + * `to_params/1` ∘ `from_params/2` must be the identity for all valid + built-in date filter states (§2.3). + + Custom date field entries are not part of this property because + `from_params/2` needs the caller-supplied `date_custom_fields` list to + validate UUIDs; the standalone property for the in-memory predicate (§2.4) + is covered in S8 after the predicate exists. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias MvWeb.MemberLive.Index.DateFilter + + # Generators ----------------------------------------------------------- + + defp optional_date_gen do + one_of([ + constant(nil), + map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1)) + ]) + end + + defp exit_date_mode_gen do + one_of([ + constant(:active_only), + constant(:all), + constant(:inactive_only), + constant(:custom) + ]) + end + + defp exit_date_state_gen do + gen all( + mode <- exit_date_mode_gen(), + from <- optional_date_gen(), + to <- optional_date_gen() + ) do + %{mode: mode, from: from, to: to} + end + end + + defp join_date_state_gen do + gen all( + from <- optional_date_gen(), + to <- optional_date_gen() + ) do + %{from: from, to: to} + end + end + + # Property ------------------------------------------------------------- + + defp bound_pair_with_at_least_one_set_gen do + gen all( + from <- optional_date_gen(), + to <- optional_date_gen(), + from != nil or to != nil + ) do + {from, to} + end + end + + defp value_date_gen do + map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1)) + end + + # Property ------------------------------------------------------------- + + property "in-memory date filter matches the inclusive range predicate" do + id = "11111111-2222-3333-4444-555555555555" + field = %{id: id, value_type: :date, name: "Property field"} + + check all( + value <- value_date_gen(), + {from, to} <- bound_pair_with_at_least_one_set_gen() + ) do + filters = + DateFilter.default() + |> Map.put(id, %{from: from, to: to}) + + member = %{ + id: "m1", + custom_field_values: [ + %{ + custom_field_id: id, + value: %Ash.Union{value: value, type: :date} + } + ] + } + + result = DateFilter.apply_in_memory([member], filters, [field]) + + from_ok? = is_nil(from) or Date.compare(value, from) != :lt + to_ok? = is_nil(to) or Date.compare(value, to) != :gt + expected_included? = from_ok? and to_ok? + actually_included? = result == [member] + + assert actually_included? == expected_included? + end + end + + property "encoding then decoding built-in date filter state is identity" do + check all( + join_date <- join_date_state_gen(), + exit_date <- exit_date_state_gen() + ) do + filters = %{join_date: join_date, exit_date: exit_date} + + decoded = DateFilter.from_params(DateFilter.to_params(filters), []) + + # join_date round-trips verbatim. + assert decoded.join_date == join_date + + # exit_date semantics: + # * :active_only is the default and discards bounds — the canonical + # URL omits them, so decoding restores nil bounds. + # * :all and :inactive_only also drop bounds in the URL — same reason. + # * :custom preserves bounds. + expected_exit_date = + case exit_date.mode do + :active_only -> %{mode: :active_only, from: nil, to: nil} + :all -> %{mode: :all, from: nil, to: nil} + :inactive_only -> %{mode: :inactive_only, from: nil, to: nil} + :custom -> exit_date + end + + assert decoded.exit_date == expected_exit_date + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_test.exs b/test/mv_web/live/member_live/date_filter_test.exs new file mode 100644 index 0000000..424e6f1 --- /dev/null +++ b/test/mv_web/live/member_live/date_filter_test.exs @@ -0,0 +1,316 @@ +defmodule MvWeb.MemberLive.Index.DateFilterTest do + @moduledoc """ + Unit tests for DateFilter URL codec and pure helpers. + DB-level filtering and in-memory custom field filtering are covered in + separate integration tests (date_filter_default_test, date_filter_test, + date_filter_custom_field_test). + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.DateFilter + + # Synthesize the minimal shape of a date-typed custom field expected by + # from_params/2. Only :id and :value_type are inspected by the decoder. + defp date_custom_field(id) do + %{id: id, value_type: :date, name: "Birthday-#{id}"} + end + + describe "from_params/2 — built-in date fields" do + test "parses jd_from as a Date" do + params = %{"jd_from" => "2024-05-01"} + filters = DateFilter.from_params(params, []) + assert filters.join_date.from == ~D[2024-05-01] + assert filters.join_date.to == nil + end + + test "parses jd_to as a Date" do + params = %{"jd_to" => "2024-08-31"} + filters = DateFilter.from_params(params, []) + assert filters.join_date.to == ~D[2024-08-31] + assert filters.join_date.from == nil + end + + test "ignores malformed jd_from string" do + params = %{"jd_from" => "notadate"} + filters = DateFilter.from_params(params, []) + assert filters.join_date.from == nil + assert filters.join_date.to == nil + end + + test "ignores malformed jd_to string" do + params = %{"jd_to" => "2024-13-45"} + filters = DateFilter.from_params(params, []) + assert filters.join_date.to == nil + end + + test "parses ed_mode=all" do + params = %{"ed_mode" => "all"} + filters = DateFilter.from_params(params, []) + assert filters.exit_date.mode == :all + end + + test "parses ed_mode=inactive_only" do + params = %{"ed_mode" => "inactive_only"} + filters = DateFilter.from_params(params, []) + assert filters.exit_date.mode == :inactive_only + end + + test "parses ed_mode=custom with bounds" do + params = %{"ed_mode" => "custom", "ed_from" => "2024-01-01", "ed_to" => "2024-12-31"} + filters = DateFilter.from_params(params, []) + assert filters.exit_date.mode == :custom + assert filters.exit_date.from == ~D[2024-01-01] + assert filters.exit_date.to == ~D[2024-12-31] + end + + test "returns :active_only mode when ed_mode is absent" do + filters = DateFilter.from_params(%{}, []) + assert filters.exit_date.mode == :active_only + end + + test "treats unknown ed_mode value as :active_only" do + params = %{"ed_mode" => "gibberish"} + filters = DateFilter.from_params(params, []) + assert filters.exit_date.mode == :active_only + end + end + + describe "to_params/1 — built-in date fields" do + test "omits ed_mode when mode is :active_only (default)" do + params = DateFilter.to_params(DateFilter.default()) + refute Map.has_key?(params, "ed_mode") + refute Map.has_key?(params, "ed_from") + refute Map.has_key?(params, "ed_to") + refute Map.has_key?(params, "jd_from") + refute Map.has_key?(params, "jd_to") + end + + test "encodes ed_mode=all" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + params = DateFilter.to_params(filters) + assert params["ed_mode"] == "all" + end + + test "encodes ed_mode=inactive_only" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :inactive_only, from: nil, to: nil} + } + + params = DateFilter.to_params(filters) + assert params["ed_mode"] == "inactive_only" + end + + test "encodes ed_mode=custom with bounds" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]} + } + + params = DateFilter.to_params(filters) + assert params["ed_mode"] == "custom" + assert params["ed_from"] == "2024-01-01" + assert params["ed_to"] == "2024-12-31" + end + + test "encodes jd_from as ISO-8601 string" do + filters = %{ + join_date: %{from: ~D[2024-05-01], to: nil}, + exit_date: %{mode: :active_only, from: nil, to: nil} + } + + params = DateFilter.to_params(filters) + assert params["jd_from"] == "2024-05-01" + refute Map.has_key?(params, "jd_to") + end + + test "encodes jd_to as ISO-8601 string" do + filters = %{ + join_date: %{from: nil, to: ~D[2024-08-31]}, + exit_date: %{mode: :active_only, from: nil, to: nil} + } + + params = DateFilter.to_params(filters) + assert params["jd_to"] == "2024-08-31" + refute Map.has_key?(params, "jd_from") + end + + test "omits exit_date bounds when mode is not :custom" do + # Bounds may linger in state for UX (preserve user's last input) but the + # URL should not advertise them while a non-custom mode is active. + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :all, from: ~D[2024-01-01], to: ~D[2024-12-31]} + } + + params = DateFilter.to_params(filters) + assert params["ed_mode"] == "all" + refute Map.has_key?(params, "ed_from") + refute Map.has_key?(params, "ed_to") + end + end + + describe "to_params/1 — custom date field entries" do + test "encodes from/to bounds with cdf__ prefix" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]}) + + params = DateFilter.to_params(filters) + assert params["cdf_#{id}_from"] == "2024-06-01" + assert params["cdf_#{id}_to"] == "2024-06-30" + end + + test "omits nil bounds for custom date field entries" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: ~D[2024-06-01], to: nil}) + + params = DateFilter.to_params(filters) + assert params["cdf_#{id}_from"] == "2024-06-01" + refute Map.has_key?(params, "cdf_#{id}_to") + end + + test "omits custom date field entry entirely when both bounds are nil" do + id = "11111111-2222-3333-4444-555555555555" + + filters = + DateFilter.default() + |> Map.put(id, %{from: nil, to: nil}) + + params = DateFilter.to_params(filters) + refute Map.has_key?(params, "cdf_#{id}_from") + refute Map.has_key?(params, "cdf_#{id}_to") + end + end + + describe "apply_ash_filter/2 — shape contract" do + # The behavioral correctness of apply_ash_filter/2 is verified in the + # integration tests (date_filter_default_test, date_filter_test). Here we + # only assert the shape contract: which inputs leave the query untouched, + # and which add a filter expression. Inspecting Ash internals is brittle — + # we only check `query.filter` is or is not nil. + + alias Mv.Membership.Member, as: MemberResource + + defp base_query, do: MemberResource + + test ":all mode and empty join_date is a no-op" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + # No filter was applied — Ash leaves filter as nil when nothing is added. + assert is_nil(query.filter) + end + + test "default (:active_only) adds an exit_date filter" do + query = DateFilter.apply_ash_filter(base_query(), DateFilter.default()) + refute is_nil(query.filter) + end + + test ":inactive_only adds an exit_date filter" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :inactive_only, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test ":custom mode with bounds adds an exit_date filter" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test ":custom mode with both bounds nil adds no filter" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + assert is_nil(query.filter) + end + + test "join_date from adds a filter" do + filters = %{ + join_date: %{from: ~D[2024-01-01], to: nil}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test "join_date to adds a filter" do + filters = %{ + join_date: %{from: nil, to: ~D[2024-12-31]}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test "accepts a non-resource Ash.Query as input" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :inactive_only, from: nil, to: nil} + } + + # Should also work when given a pre-built Ash.Query (not just a resource). + query = + MemberResource + |> Ash.Query.new() + |> DateFilter.apply_ash_filter(filters) + + refute is_nil(query.filter) + end + end + + describe "from_params/2 — custom date field entries" do + test "includes entry for known custom date field UUID" do + id = "11111111-2222-3333-4444-555555555555" + params = %{"cdf_#{id}_from" => "2024-06-01", "cdf_#{id}_to" => "2024-06-30"} + filters = DateFilter.from_params(params, [date_custom_field(id)]) + assert filters[id] == %{from: ~D[2024-06-01], to: ~D[2024-06-30]} + end + + test "ignores UUID not in date_custom_fields list" do + id = "11111111-2222-3333-4444-555555555555" + other = "99999999-8888-7777-6666-555555555555" + params = %{"cdf_#{other}_from" => "2024-06-01"} + filters = DateFilter.from_params(params, [date_custom_field(id)]) + refute Map.has_key?(filters, other) + end + + test "ignores malformed custom date field bound" do + id = "11111111-2222-3333-4444-555555555555" + params = %{"cdf_#{id}_from" => "notadate"} + filters = DateFilter.from_params(params, [date_custom_field(id)]) + # Either no entry, or entry with nil bounds — both satisfy "silently ignored" + case Map.get(filters, id) do + nil -> :ok + %{from: nil, to: nil} -> :ok + other -> flunk("expected nil bound for malformed input, got #{inspect(other)}") + end + end + end +end From e3295ab4b5cea20e7107d7d99cb189a53ac2611e Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:28:17 +0200 Subject: [PATCH 25/79] feat(member-live): wire date filters into LiveView lifecycle --- .../components/member_filter_component.ex | 23 +- lib/mv_web/live/member_live/index.ex | 181 ++++++---- .../index/custom_field_value_lookup.ex | 61 ++++ .../live/member_live/index/date_filter.ex | 142 +++++--- .../live/member_live/index/filter_params.ex | 33 +- .../date_filter_custom_field_test.exs | 109 ++++++ .../member_live/date_filter_default_test.exs | 120 +++++++ .../live/member_live/date_filter_test.exs | 334 +++++++++++++++++- .../index/custom_field_value_lookup_test.exs | 89 +++++ .../member_live/index/filter_params_test.exs | 85 +++++ 10 files changed, 1037 insertions(+), 140 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/custom_field_value_lookup.ex create mode 100644 test/mv_web/live/member_live/index/custom_field_value_lookup_test.exs create mode 100644 test/mv_web/live/member_live/index/filter_params_test.exs diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index ddd3538..71227be 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -438,10 +438,18 @@ defmodule MvWeb.Components.MemberFilterComponent do payment_filter = parse_payment_filter(params) group_filters_parsed = - parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1) + FilterParams.parse_prefix_filters( + params, + @group_filter_prefix, + &FilterParams.parse_in_not_in_value/1 + ) fee_type_filters_parsed = - parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1) + FilterParams.parse_prefix_filters( + params, + @fee_type_filter_prefix, + &FilterParams.parse_in_not_in_value/1 + ) custom_boolean_filters_parsed = parse_custom_boolean_filters(params) @@ -486,17 +494,6 @@ defmodule MvWeb.Components.MemberFilterComponent do end end - defp parse_prefix_filters(params, prefix, parse_value_fn) do - prefix_len = String.length(prefix) - - params - |> Enum.filter(fn {key, _} -> String.starts_with?(key, prefix) end) - |> Enum.reduce(%{}, fn {key, value_str}, acc -> - id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) - Map.put(acc, id_str, parse_value_fn.(value_str)) - end) - end - defp parse_custom_boolean_filters(params) do params |> Map.get("custom_boolean", %{}) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c258d5f..cd32513 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -36,6 +36,8 @@ defmodule MvWeb.MemberLive.Index do alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter + alias MvWeb.MemberLive.Index.CustomFieldValueLookup + alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FilterParams @@ -87,6 +89,13 @@ defmodule MvWeb.MemberLive.Index do |> Enum.filter(&(&1.value_type == :boolean)) |> Enum.sort_by(& &1.name, :asc) + # Date-typed custom fields surface in the new "Custom date fields" filter + # section and are needed by DateFilter.from_params/2 to validate UUIDs. + date_custom_fields = + all_custom_fields + |> Enum.filter(&(&1.value_type == :date)) + |> Enum.sort_by(& &1.name, :asc) + # Load groups for filter dropdown (sorted by name) groups = Mv.Membership.Group @@ -143,6 +152,8 @@ defmodule MvWeb.MemberLive.Index do |> assign(:custom_fields_visible, custom_fields_visible) |> assign(:all_custom_fields, all_custom_fields) |> assign(:boolean_custom_fields, boolean_custom_fields) + |> assign(:date_custom_fields, date_custom_fields) + |> assign(:date_filters, DateFilter.default()) |> assign(:all_available_fields, all_available_fields) |> assign(:user_field_selection, initial_selection) |> assign(:fields_in_url?, false) @@ -448,6 +459,25 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: new_path, replace: true)} end + @impl true + def handle_info({:date_filters_changed, new_date_filters}, socket) do + socket = + socket + |> assign(:date_filters, new_date_filters) + |> load_members() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{date_filters: new_date_filters})) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_patch(socket, to: new_path, replace: true)} + end + # Backward compatibility: tuple form delegates to map form def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do handle_info( @@ -502,6 +532,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:group_filters, Map.get(opts, :group_filters, %{})) |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) + |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) |> load_members() |> update_selection_assigns() @@ -632,6 +663,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_group_filters(params) |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) + |> maybe_update_date_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) @@ -683,7 +715,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, - socket.assigns[:visible_custom_field_ids] || [] + socket.assigns[:visible_custom_field_ids] || [], + socket.assigns[:date_filters] } end @@ -783,7 +816,12 @@ defmodule MvWeb.MemberLive.Index do base_params = add_group_filters(base_params, opts.group_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) base_params = add_show_current_cycle(base_params, opts.show_current_cycle) - add_boolean_filters(base_params, opts.boolean_filters || %{}) + base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) + add_date_filters(base_params, opts.date_filters) + end + + defp add_date_filters(params, date_filters) do + Map.merge(params, DateFilter.to_params(date_filters)) end defp opts_for_query_params(socket, overrides \\ %{}) do @@ -795,7 +833,8 @@ defmodule MvWeb.MemberLive.Index do group_filters: socket.assigns[:group_filters] || %{}, show_current_cycle: socket.assigns.show_current_cycle, boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, - fee_type_filters: socket.assigns[:fee_type_filters] || %{} + fee_type_filters: socket.assigns[:fee_type_filters] || %{}, + date_filters: socket.assigns.date_filters } |> Map.merge(overrides) end @@ -941,26 +980,7 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.new() |> Ash.Query.select(@overview_fields) - visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] - - boolean_custom_fields_map = - socket.assigns.boolean_custom_fields - |> Map.new(fn cf -> {to_string(cf.id), cf} end) - - active_boolean_filter_ids = - socket.assigns.boolean_custom_field_filters - |> Map.keys() - |> Enum.filter(fn id_str -> - String.length(id_str) <= @max_uuid_length && - match?({:ok, _}, Ecto.UUID.cast(id_str)) && - Map.has_key?(boolean_custom_fields_map, id_str) - end) - - ids_to_load = - (visible_custom_field_ids ++ active_boolean_filter_ids) - |> Enum.uniq() - - query = load_custom_field_values(query, ids_to_load) + query = load_custom_field_values(query, compute_ids_to_load(socket)) query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) @@ -984,6 +1004,13 @@ defmodule MvWeb.MemberLive.Index do query = apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) + # Built-in date filters (join_date, exit_date) are pushed to the DB so + # excluded rows never reach the BEAM. The active_only default is part of + # this — fresh load returns only members without an exit_date or with an + # exit_date strictly in the future. + query = + DateFilter.apply_ash_filter(query, socket.assigns.date_filters) + # Use ALL custom fields for sorting (not just show_in_overview subset) custom_fields_for_sort = socket.assigns.all_custom_fields @@ -1003,21 +1030,7 @@ defmodule MvWeb.MemberLive.Index do # Custom field values are already filtered at the database level in load_custom_field_values/2 # No need for in-memory filtering anymore - # Apply cycle status filter if set - members = - apply_cycle_status_filter( - members, - socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle - ) - - # Apply boolean custom field filters if set - members = - apply_boolean_custom_field_filters( - members, - socket.assigns.boolean_custom_field_filters, - socket.assigns.all_custom_fields - ) + members = apply_in_memory_filters(members, socket) # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status @@ -1037,6 +1050,55 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :members, members) end + # Collects every custom field UUID whose values must be loaded for a given + # render — visible columns plus any active boolean or date filter. Kept as a + # standalone helper so load_members/1 stays under the credo complexity bar. + defp compute_ids_to_load(socket) do + visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] + + boolean_custom_fields_map = + socket.assigns.boolean_custom_fields + |> Map.new(fn cf -> {to_string(cf.id), cf} end) + + active_boolean_filter_ids = + socket.assigns.boolean_custom_field_filters + |> Map.keys() + |> Enum.filter(fn id_str -> + String.length(id_str) <= @max_uuid_length && + match?({:ok, _}, Ecto.UUID.cast(id_str)) && + Map.has_key?(boolean_custom_fields_map, id_str) + end) + + date_custom_fields = socket.assigns[:date_custom_fields] || [] + + active_date_filter_ids = + DateFilter.active_custom_field_ids( + socket.assigns.date_filters, + date_custom_fields + ) + + (visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids) + |> Enum.uniq() + end + + # Post-DB filtering: cycle status, boolean custom fields, and custom date + # fields. Date custom fields are last so they see the already-narrowed list. + defp apply_in_memory_filters(members, socket) do + members + |> apply_cycle_status_filter( + socket.assigns.cycle_status_filter, + socket.assigns.show_current_cycle + ) + |> apply_boolean_custom_field_filters( + socket.assigns.boolean_custom_field_filters, + socket.assigns.all_custom_fields + ) + |> DateFilter.apply_in_memory( + socket.assigns.date_filters, + socket.assigns[:date_custom_fields] || [] + ) + end + defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, custom_field_ids) do @@ -1649,24 +1711,22 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_show_current_cycle(socket, _params), do: socket + # URL params are the source of truth for filter state on every navigation. + # When no date filter params are present, this falls through to the + # active_only default — exactly the spec behavior for fresh load (§1.1). + defp maybe_update_date_filters(socket, params) when is_map(params) do + date_custom_fields = socket.assigns[:date_custom_fields] || [] + assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields)) + end + + defp maybe_update_date_filters(socket, _params), do: socket + # ------------------------------------------------------------- # Custom Field Value Helpers # ------------------------------------------------------------- def get_custom_field_value(member, custom_field) do - case member.custom_field_values do - nil -> - nil - - values when is_list(values) -> - Enum.find(values, fn cfv -> - cfv.custom_field_id == custom_field.id or - (match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id) - end) - - _ -> - nil - end + CustomFieldValueLookup.find_by_field(member, custom_field) end def get_boolean_custom_field_value(member, custom_field) do @@ -1725,29 +1785,12 @@ defmodule MvWeb.MemberLive.Index do end defp matches_filter?(member, custom_field_id_str, filter_value) do - case find_custom_field_value_by_id(member, custom_field_id_str) do + case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do nil -> false cfv -> extract_boolean_value(cfv.value) == filter_value end end - defp find_custom_field_value_by_id(member, custom_field_id_str) do - case member.custom_field_values do - nil -> - nil - - values when is_list(values) -> - Enum.find(values, fn cfv -> - to_string(cfv.custom_field_id) == custom_field_id_str or - (match?(%{custom_field: %{id: _}}, cfv) && - to_string(cfv.custom_field.id) == custom_field_id_str) - end) - - _ -> - nil - end - end - def format_selected_member_emails(members, selected_members) do members |> Enum.filter(fn member -> diff --git a/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex b/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex new file mode 100644 index 0000000..6d2298c --- /dev/null +++ b/lib/mv_web/live/member_live/index/custom_field_value_lookup.ex @@ -0,0 +1,61 @@ +defmodule MvWeb.MemberLive.Index.CustomFieldValueLookup do + @moduledoc """ + Centralized lookup for a member's `custom_field_values` entry that matches + a given custom field. + + Two callable shapes: + + * `find_by_id/2` — match against a stringified UUID (used by the URL-param + driven date and boolean filter pipelines). + * `find_by_field/2` — match against a loaded `%CustomField{}` struct + (used by the table rendering / display path that already has the + field record at hand). + + Both forms handle the two CFV layouts that appear on a loaded member: + + * the direct foreign key — `%{custom_field_id: id, value: ...}` + * the nested loaded relation — `%{custom_field: %{id: id, ...}, value: ...}` + + All non-loaded or empty containers (`nil`, `%Ash.NotLoaded{}`, empty list) + return `nil`. + """ + + @doc """ + Returns the CFV entry whose custom field id, compared as a string, equals + `custom_field_id_str`. Returns `nil` when no entry matches or the + `custom_field_values` association is not a list. + """ + @spec find_by_id(map(), String.t()) :: map() | nil + def find_by_id(member, custom_field_id_str) when is_binary(custom_field_id_str) do + member + |> Map.get(:custom_field_values) + |> find_in(fn cfv -> cfv_id_string(cfv) == custom_field_id_str end) + end + + @doc """ + Returns the CFV entry whose custom field id matches the given + `custom_field` struct's `:id`. The comparison is identity-based (not + stringified) because both sides are typically `Ash.UUID` binaries; falls + back to string comparison so atom-id callers still work. + """ + @spec find_by_field(map(), map()) :: map() | nil + def find_by_field(member, %{id: field_id}) do + member + |> Map.get(:custom_field_values) + |> find_in(fn cfv -> cfv_id(cfv) == field_id end) + end + + defp find_in(values, predicate) when is_list(values), do: Enum.find(values, predicate) + defp find_in(_other, _predicate), do: nil + + defp cfv_id(%{custom_field_id: id}) when not is_nil(id), do: id + defp cfv_id(%{custom_field: %{id: id}}) when not is_nil(id), do: id + defp cfv_id(_), do: nil + + defp cfv_id_string(cfv) do + case cfv_id(cfv) do + nil -> nil + id -> to_string(id) + end + end +end diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index 2a3e04e..162524a 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -31,12 +31,25 @@ defmodule MvWeb.MemberLive.Index.DateFilter do require Ash.Query import Ash.Expr + alias MvWeb.MemberLive.Index.CustomFieldValueLookup + alias MvWeb.MemberLive.Index.FilterParams + @join_date_from_param Mv.Constants.join_date_from_param() @join_date_to_param Mv.Constants.join_date_to_param() @exit_date_mode_param Mv.Constants.exit_date_mode_param() @exit_date_from_param Mv.Constants.exit_date_from_param() @exit_date_to_param Mv.Constants.exit_date_to_param() @custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix() + @max_uuid_length Mv.Constants.max_uuid_length() + + # An id stripped from a cdf_-prefixed param still has its `_from` / `_to` + # bound suffix attached when we first see it. The longest legal suffix is + # `_from` (5 chars), so the upper bound on a valid suffixed_id is + # @max_uuid_length + 5. Anything longer cannot map to a known custom date + # field and is rejected before further string work — matching the same + # DoS-protection contract enforced by the boolean / group / fee_type + # filter parsers in `MvWeb.MemberLive.Index`. + @max_suffixed_id_length @max_uuid_length + 5 @doc """ Returns the default date filter state used on fresh page load and after @@ -139,15 +152,41 @@ defmodule MvWeb.MemberLive.Index.DateFilter do Today's date is captured via `Date.utc_today/0`; callers needing a frozen clock should wrap the call site, not this function. + + The caller is expected to pass an `%Ash.Query{}` (typically built with + `Ash.Query.new/1` or via earlier filter chaining), matching the convention + used by the sibling `apply_search_filter/2`, `apply_group_filters/3`, and + `apply_fee_type_filters/3` helpers in `MvWeb.MemberLive.Index`. """ - @spec apply_ash_filter(Ash.Query.t() | module(), map()) :: Ash.Query.t() - def apply_ash_filter(query_or_resource, filters) when is_map(filters) do - query_or_resource - |> Ash.Query.new() - |> apply_exit_date_filter(Map.get(filters, :exit_date, %{})) - |> apply_join_date_filter(Map.get(filters, :join_date, %{})) + @spec apply_ash_filter(Ash.Query.t(), map()) :: Ash.Query.t() + def apply_ash_filter(%Ash.Query{} = query, filters) when is_map(filters) do + exit_bounds = normalize_exit_bounds(Map.get(filters, :exit_date, %{})) + join_bounds = normalize_join_bounds(Map.get(filters, :join_date, %{})) + + query + |> apply_exit_date_filter(exit_bounds) + |> apply_join_date_filter(join_bounds) end + # Defensive shape normalization: callers may supply maps where one bound key + # is absent entirely (not just nil). Pattern-match heads require both keys + # present, so we backfill nil here. + defp normalize_exit_bounds(bounds) when is_map(bounds) do + %{ + mode: Map.get(bounds, :mode, :active_only), + from: Map.get(bounds, :from), + to: Map.get(bounds, :to) + } + end + + defp normalize_exit_bounds(_), do: %{mode: :active_only, from: nil, to: nil} + + defp normalize_join_bounds(bounds) when is_map(bounds) do + %{from: Map.get(bounds, :from), to: Map.get(bounds, :to)} + end + + defp normalize_join_bounds(_), do: %{from: nil, to: nil} + defp apply_exit_date_filter(query, %{mode: :all}), do: query defp apply_exit_date_filter(query, %{mode: :active_only}) do @@ -231,6 +270,23 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end end + @doc """ + Returns the UUID string keys of `filters` that name an active (at-least-one- + bound-set) custom date field. The UUID must appear in `date_custom_fields` + (matched by `to_string(field.id)` and `value_type == :date`); other entries + are dropped. + + Use this to compute which custom field values must be loaded so the + in-memory predicate (`apply_in_memory/3`) has the data it needs. + """ + @spec active_custom_field_ids(map(), [map()]) :: [String.t()] + def active_custom_field_ids(filters, date_custom_fields) + when is_map(filters) and is_list(date_custom_fields) do + filters + |> active_custom_date_filters(date_custom_fields) + |> Enum.map(fn {id, _bounds} -> id end) + end + defp matches_all_custom_dates?(member, active_filters) do Enum.all?(active_filters, fn {id, bounds} -> member_matches_custom_date?(member, id, bounds) @@ -238,10 +294,7 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end defp active_custom_date_filters(filters, date_custom_fields) do - valid_ids = - date_custom_fields - |> Enum.filter(&date_field?/1) - |> MapSet.new(&to_string(field_id(&1))) + valid_ids = valid_custom_date_field_ids(date_custom_fields) filters |> Enum.filter(fn @@ -261,25 +314,11 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end defp extract_member_date(member, custom_field_id) do - case Map.get(member, :custom_field_values) do - values when is_list(values) -> - values - |> Enum.find(&cfv_matches_id?(&1, custom_field_id)) - |> extract_date_from_cfv() - - _ -> - nil - end + member + |> CustomFieldValueLookup.find_by_id(custom_field_id) + |> extract_date_from_cfv() end - defp cfv_matches_id?(%{custom_field_id: cfid}, id) when not is_nil(cfid), - do: to_string(cfid) == id - - defp cfv_matches_id?(%{custom_field: %{id: cfid}}, id) when not is_nil(cfid), - do: to_string(cfid) == id - - defp cfv_matches_id?(_, _), do: false - defp extract_date_from_cfv(nil), do: nil defp extract_date_from_cfv(%{value: value}), do: extract_date_value(value) @@ -354,18 +393,16 @@ defmodule MvWeb.MemberLive.Index.DateFilter do defp parse_exit_date_mode(_), do: :active_only defp parse_custom_date_filters(params, date_custom_fields, base) do - valid_ids = - date_custom_fields - |> Enum.filter(&date_field?/1) - |> MapSet.new(&to_string(field_id(&1))) - - prefix = @custom_date_filter_prefix + valid_ids = valid_custom_date_field_ids(date_custom_fields) + # FilterParams.parse_prefix_filters narrows the params map to the + # cdf_-prefixed subset once; the per-entry work below scales with the + # date filter count, not the full form-param map size. params - |> Enum.reduce(base, fn {key, value}, acc -> - with true <- is_binary(key), - true <- String.starts_with?(key, prefix), - {id, bound} <- split_custom_date_key(key, prefix), + |> FilterParams.parse_prefix_filters(@custom_date_filter_prefix, & &1) + |> Enum.reduce(base, fn {suffixed_id, value}, acc -> + with true <- bounded_id?(suffixed_id), + {id, bound} <- split_suffix(suffixed_id), true <- MapSet.member?(valid_ids, id), %Date{} = date <- parse_date(value) do update_custom_date_entry(acc, id, bound, date) @@ -375,20 +412,35 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end) end + # Reject any suffixed_id that could not possibly fit a UUID + bound suffix + # before doing further string work. This is the DoS-protection contract + # used by the boolean / group / fee_type filter parsers in + # `MvWeb.MemberLive.Index` (see `process_boolean_filter_param/5`, + # `add_group_filter_entry/4`, `add_fee_type_filter_entry/4`). + defp bounded_id?(suffixed_id) when is_binary(suffixed_id), + do: String.length(suffixed_id) <= @max_suffixed_id_length + + defp bounded_id?(_), do: false + defp date_field?(%{value_type: :date}), do: true defp date_field?(_), do: false - defp field_id(%{id: id}), do: id - - defp split_custom_date_key(key, prefix) do - rest = String.slice(key, String.length(prefix), String.length(key)) + # Single source of truth for the set of valid custom-date-field UUID strings. + # Used both when parsing URL params (to drop bogus UUIDs) and when computing + # which active filter entries actually correspond to a known date field. + defp valid_custom_date_field_ids(date_custom_fields) do + date_custom_fields + |> Enum.filter(&date_field?/1) + |> MapSet.new(&to_string(&1.id)) + end + defp split_suffix(suffixed_id) do cond do - String.ends_with?(rest, "_from") -> - {String.slice(rest, 0, String.length(rest) - 5), :from} + String.ends_with?(suffixed_id, "_from") -> + {String.replace_suffix(suffixed_id, "_from", ""), :from} - String.ends_with?(rest, "_to") -> - {String.slice(rest, 0, String.length(rest) - 3), :to} + String.ends_with?(suffixed_id, "_to") -> + {String.replace_suffix(suffixed_id, "_to", ""), :to} true -> :error diff --git a/lib/mv_web/live/member_live/index/filter_params.ex b/lib/mv_web/live/member_live/index/filter_params.ex index 9b5e800..790b31f 100644 --- a/lib/mv_web/live/member_live/index/filter_params.ex +++ b/lib/mv_web/live/member_live/index/filter_params.ex @@ -1,8 +1,12 @@ defmodule MvWeb.MemberLive.Index.FilterParams do @moduledoc """ - Shared parsing helpers for member list filter URL/params (in/not_in style). - Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs. + Shared parsing helpers for member list filter URL/params. + + Used by `MvWeb.MemberLive.Index`, `MvWeb.Components.MemberFilterComponent`, + and `MvWeb.MemberLive.Index.DateFilter` to avoid duplication and to keep + param-extraction logic in one place. """ + @doc """ Parses a value for group or fee-type filter params. Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion. @@ -19,4 +23,29 @@ defmodule MvWeb.MemberLive.Index.FilterParams do end def parse_in_not_in_value(_), do: nil + + @doc """ + Selects every `{key, value}` pair in `params` whose `key` is a binary that + starts with `prefix`, strips the prefix from the key, runs `parse_value_fn` + on the value, and accumulates the results into a map. + + Non-binary keys are ignored. Exactly one occurrence of the prefix is + stripped (so a key like `"p_p_abc"` with prefix `"p_"` yields id `"p_abc"`). + + The prefix-match filter is applied before the reduce so unrelated params + (e.g. `query`, `sort_field`, other-prefix filters) do not enter the + per-entry work — keeping the cost proportional to the matched subset on + every `phx-change` keystroke. + """ + @spec parse_prefix_filters(map(), String.t(), (String.t() -> term())) :: + %{optional(String.t()) => term()} + def parse_prefix_filters(params, prefix, parse_value_fn) + when is_map(params) and is_binary(prefix) and is_function(parse_value_fn, 1) do + params + |> Enum.filter(fn {key, _} -> is_binary(key) and String.starts_with?(key, prefix) end) + |> Enum.reduce(%{}, fn {key, value}, acc -> + id = String.replace_prefix(key, prefix, "") + Map.put(acc, id, parse_value_fn.(value)) + end) + end end diff --git a/test/mv_web/live/member_live/date_filter_custom_field_test.exs b/test/mv_web/live/member_live/date_filter_custom_field_test.exs index 2959e77..f8fff15 100644 --- a/test/mv_web/live/member_live/date_filter_custom_field_test.exs +++ b/test/mv_web/live/member_live/date_filter_custom_field_test.exs @@ -3,6 +3,9 @@ defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do Unit tests for `DateFilter.apply_in_memory/3` — the post-`Ash.read!` predicate that filters members by custom date field values stored as JSONB `Ash.Union` types in `custom_field_values`. + + Integration coverage against a real database lives in the second module + in this file (DateFilterCustomFieldIntegrationTest). """ use ExUnit.Case, async: true @@ -208,3 +211,109 @@ defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do end end end + +defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldIntegrationTest do + @moduledoc """ + Integration tests for custom date field filtering on /members (§1.13, §1.14). + + Creates a real `:date`-typed CustomField plus members with corresponding + CustomFieldValue rows, then asserts visibility through the LiveView with + `cdf__from` and `cdf__to` URL params. + """ + # async: false because we mutate global custom_fields and custom_field_values tables. + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + alias Mv.Membership.{CustomField, CustomFieldValue} + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Birthday-#{System.unique_integer([:positive])}", + value_type: :date, + show_in_overview: true + }) + |> Ash.create(actor: system_actor) + + {:ok, alice} = + Mv.Membership.create_member( + %{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"}, + actor: system_actor + ) + + {:ok, bob} = + Mv.Membership.create_member( + %{first_name: "Bob", last_name: "Brown", email: "bob@example.com"}, + actor: system_actor + ) + + {:ok, carla} = + Mv.Membership.create_member( + %{first_name: "Carla", last_name: "Carter", email: "carla@example.com"}, + actor: system_actor + ) + + {:ok, dan_no_value} = + Mv.Membership.create_member( + %{first_name: "Dan", last_name: "Dixon", email: "dan@example.com"}, + actor: system_actor + ) + + create_cfv(system_actor, alice.id, field.id, ~D[2020-05-15]) + create_cfv(system_actor, bob.id, field.id, ~D[2022-08-01]) + create_cfv(system_actor, carla.id, field.id, ~D[2024-02-20]) + + %{ + field: field, + alice: alice, + bob: bob, + carla: carla, + dan_no_value: dan_no_value + } + end + + defp create_cfv(actor, member_id, custom_field_id, %Date{} = date) do + {:ok, cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_id, + custom_field_id: custom_field_id, + value: %{"_union_type" => "date", "_union_value" => Date.to_iso8601(date)} + }) + |> Ash.create(actor: actor, domain: Mv.Membership) + + cfv + end + + describe "custom date field URL filter" do + test "from-only includes members with value >= bound (§1.13)", + %{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2022-01-01") + refute html =~ alice.first_name + assert html =~ bob.first_name + assert html =~ carla.first_name + end + + test "from+to applies inclusive range (§1.14)", + %{conn: conn, field: field, alice: alice, bob: bob, carla: carla} do + conn = conn_with_oidc_user(conn) + + url = "/members?cdf_#{field.id}_from=2022-01-01&cdf_#{field.id}_to=2023-12-31" + {:ok, _view, html} = live(conn, url) + refute html =~ alice.first_name + assert html =~ bob.first_name + refute html =~ carla.first_name + end + + test "excludes member with no value for the active custom date field (§1.13)", + %{conn: conn, field: field, dan_no_value: dan} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?cdf_#{field.id}_from=2000-01-01") + refute html =~ dan.first_name + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_default_test.exs b/test/mv_web/live/member_live/date_filter_default_test.exs index 84400ef..66d69cb 100644 --- a/test/mv_web/live/member_live/date_filter_default_test.exs +++ b/test/mv_web/live/member_live/date_filter_default_test.exs @@ -22,3 +22,123 @@ defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do end end end + +defmodule MvWeb.MemberLive.Index.DateFilterDefaultIntegrationTest do + @moduledoc """ + Integration tests for the default exit_date filter behavior on the member + overview page (§1.1, §1.2, §1.3, §1.4, §1.6 in the issue specs). + + These exercise the full `mount/3` → `handle_params/3` → `load_members/1` + pipeline against a real database, asserting that the active-only default + is applied to a fresh page load and overridden when the URL says so. + """ + # async: false because we mutate the global member table. + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + today = Date.utc_today() + + {:ok, active_no_exit} = + Mv.Membership.create_member( + %{first_name: "Anna", last_name: "Active", email: "anna@example.com"}, + actor: system_actor + ) + + {:ok, future_exit} = + Mv.Membership.create_member( + %{ + first_name: "Felix", + last_name: "Future", + email: "felix@example.com", + join_date: Date.add(today, -365), + exit_date: Date.add(today, 30) + }, + actor: system_actor + ) + + {:ok, exit_today} = + Mv.Membership.create_member( + %{ + first_name: "Tina", + last_name: "Today", + email: "tina@example.com", + join_date: Date.add(today, -365), + exit_date: today + }, + actor: system_actor + ) + + {:ok, past_exit} = + Mv.Membership.create_member( + %{ + first_name: "Paula", + last_name: "Past", + email: "paula@example.com", + join_date: Date.add(today, -365), + exit_date: Date.add(today, -1) + }, + actor: system_actor + ) + + %{ + active_no_exit: active_no_exit, + future_exit: future_exit, + exit_today: exit_today, + past_exit: past_exit + } + end + + describe "fresh load — no URL params" do + test "hides member with exit_date strictly before today (§1.1)", %{ + conn: conn, + past_exit: past + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + refute html =~ past.first_name + end + + test "hides member whose exit_date equals today (§1.4)", %{ + conn: conn, + exit_today: exit_today + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + refute html =~ exit_today.first_name + end + + test "shows member with no exit_date (§1.2)", %{conn: conn, active_no_exit: anna} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ anna.first_name + end + + test "shows member with exit_date strictly in the future (§1.3)", %{ + conn: conn, + future_exit: felix + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ felix.first_name + end + end + + describe "URL with ed_mode=all overrides the default (§1.6)" do + test "shows former members when URL contains ed_mode=all", %{ + conn: conn, + past_exit: past, + exit_today: today, + active_no_exit: anna, + future_exit: felix + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?ed_mode=all") + assert html =~ past.first_name + assert html =~ today.first_name + assert html =~ anna.first_name + assert html =~ felix.first_name + end + end +end diff --git a/test/mv_web/live/member_live/date_filter_test.exs b/test/mv_web/live/member_live/date_filter_test.exs index 424e6f1..7d01c01 100644 --- a/test/mv_web/live/member_live/date_filter_test.exs +++ b/test/mv_web/live/member_live/date_filter_test.exs @@ -1,9 +1,8 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do @moduledoc """ Unit tests for DateFilter URL codec and pure helpers. - DB-level filtering and in-memory custom field filtering are covered in - separate integration tests (date_filter_default_test, date_filter_test, - date_filter_custom_field_test). + DB-level filtering against real members is covered by the integration + module below in this file. """ use ExUnit.Case, async: true @@ -201,7 +200,12 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do alias Mv.Membership.Member, as: MemberResource - defp base_query, do: MemberResource + # The production caller (`MvWeb.MemberLive.Index.load_members/1`) hands + # `apply_ash_filter/2` an already-built `%Ash.Query{}`, matching the + # convention of the sibling `apply_*_filters` helpers. The shape contract + # tests mirror that convention so they exercise the exact call shape used + # in production. + defp base_query, do: Ash.Query.new(MemberResource) test ":all mode and empty join_date is a no-op" do filters = %{ @@ -269,20 +273,102 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do refute is_nil(query.filter) end - test "accepts a non-resource Ash.Query as input" do + test "raises FunctionClauseError when caller passes a bare resource module" do + # The function now requires `%Ash.Query{}` — the production convention + # used by every sibling `apply_*_filters` helper in + # `MvWeb.MemberLive.Index`. A bare resource module is no longer accepted. filters = %{ join_date: %{from: nil, to: nil}, - exit_date: %{mode: :inactive_only, from: nil, to: nil} + exit_date: %{mode: :all, from: nil, to: nil} } - # Should also work when given a pre-built Ash.Query (not just a resource). - query = - MemberResource - |> Ash.Query.new() - |> DateFilter.apply_ash_filter(filters) + # Indirect through a variable so the compiler's static type analysis + # does not flag the deliberately invalid call shape we want to assert on. + bare_resource = Function.identity(MemberResource) + assert_raise FunctionClauseError, fn -> + DateFilter.apply_ash_filter(bare_resource, filters) + end + end + + test "join_date map missing :to key still applies the from bound" do + # A caller-supplied map can omit one bound key entirely (not just set it + # to nil). The Ash filter must still be applied for the bound that is + # present. + filters = %{ + join_date: %{from: ~D[2024-01-01]}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) refute is_nil(query.filter) end + + test "join_date map missing :from key still applies the to bound" do + filters = %{ + join_date: %{to: ~D[2024-12-31]}, + exit_date: %{mode: :all, from: nil, to: nil} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test ":custom exit_date with only :from key still applies the bound" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, from: ~D[2024-01-01]} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + + test ":custom exit_date with only :to key still applies the bound" do + filters = %{ + join_date: %{from: nil, to: nil}, + exit_date: %{mode: :custom, to: ~D[2024-12-31]} + } + + query = DateFilter.apply_ash_filter(base_query(), filters) + refute is_nil(query.filter) + end + end + + describe "active_custom_field_ids/2" do + test "returns UUID keys with at least one bound set whose UUID matches a date field" do + id_a = "11111111-1111-1111-1111-111111111111" + id_b = "22222222-2222-2222-2222-222222222222" + id_unknown = "33333333-3333-3333-3333-333333333333" + + filters = + DateFilter.default() + |> Map.put(id_a, %{from: ~D[2024-01-01], to: nil}) + |> Map.put(id_b, %{from: nil, to: nil}) + |> Map.put(id_unknown, %{from: ~D[2024-06-01], to: nil}) + + ids = + DateFilter.active_custom_field_ids(filters, [ + date_custom_field(id_a), + date_custom_field(id_b) + ]) + + assert ids == [id_a] + end + + test "ignores non-binary keys (built-in atoms)" do + assert DateFilter.active_custom_field_ids(DateFilter.default(), []) == [] + end + + test "returns [] when no date custom fields are supplied" do + id_a = "11111111-1111-1111-1111-111111111111" + + filters = + DateFilter.default() + |> Map.put(id_a, %{from: ~D[2024-01-01], to: nil}) + + assert DateFilter.active_custom_field_ids(filters, []) == [] + end end describe "from_params/2 — custom date field entries" do @@ -312,5 +398,231 @@ defmodule MvWeb.MemberLive.Index.DateFilterTest do other -> flunk("expected nil bound for malformed input, got #{inspect(other)}") end end + + test "strips only the trailing _from / _to suffix, not internal substrings" do + # Construct an id that itself contains "_from" / "_to" — a quirky but + # legal MapSet key. Trailing-only suffix stripping must leave the + # internal substrings intact and recover the original id. + id_with_internal = "abc_from_xyz_to_def" + + params = %{ + "cdf_#{id_with_internal}_from" => "2024-06-01", + "cdf_#{id_with_internal}_to" => "2024-06-30" + } + + filters = + DateFilter.from_params(params, [date_custom_field(id_with_internal)]) + + assert filters[id_with_internal] == + %{from: ~D[2024-06-01], to: ~D[2024-06-30]} + end + + test "drops cdf_-prefixed keys whose id exceeds the UUID length cap" do + # Matches the DoS-protection contract enforced by the sibling boolean, + # group, and fee_type filter parsers: an over-long id-portion (post + # prefix-strip, pre suffix-strip) is rejected without invoking the + # heavier String.replace_suffix / MapSet.member? path. The id we + # construct is well past `Mv.Constants.max_uuid_length()` (36). + known_id = "11111111-2222-3333-4444-555555555555" + over_long_id = String.duplicate("a", 200) + + params = %{ + "cdf_#{over_long_id}_from" => "2024-06-01", + "cdf_#{over_long_id}_to" => "2024-06-30" + } + + filters = DateFilter.from_params(params, [date_custom_field(known_id)]) + + refute Map.has_key?(filters, over_long_id) + refute Map.has_key?(filters, "#{over_long_id}_from") + refute Map.has_key?(filters, "#{over_long_id}_to") + end + end +end + +defmodule MvWeb.MemberLive.Index.DateFilterIntegrationTest do + @moduledoc """ + Integration tests for the date filter URL → query → result-set pipeline. + + Covers §1.7, §1.8, §1.9, §1.10, §1.11, §1.12, §1.15, §1.16, §1.17, §1.18, + §1.19. Custom date field filters are covered in the dedicated custom-field + integration test. + """ + # async: false: mutates the global member table. + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + today = Date.utc_today() + + {:ok, alice} = + Mv.Membership.create_member( + %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com", + join_date: ~D[2020-01-01] + }, + actor: system_actor + ) + + {:ok, bob} = + Mv.Membership.create_member( + %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com", + join_date: ~D[2022-06-15] + }, + actor: system_actor + ) + + {:ok, carla} = + Mv.Membership.create_member( + %{ + first_name: "Carla", + last_name: "Carter", + email: "carla@example.com", + join_date: ~D[2024-03-20] + }, + actor: system_actor + ) + + {:ok, dan} = + Mv.Membership.create_member( + %{ + first_name: "Dan", + last_name: "Dixon", + email: "dan@example.com" + # no join_date — should be excluded by any join_date range filter + }, + actor: system_actor + ) + + {:ok, former} = + Mv.Membership.create_member( + %{ + first_name: "Frida", + last_name: "Former", + email: "frida@example.com", + join_date: Date.add(today, -1000), + exit_date: Date.add(today, -10) + }, + actor: system_actor + ) + + %{alice: alice, bob: bob, carla: carla, dan: dan, former: former, today: today} + end + + describe "join_date filters" do + test "jd_from includes members with join_date >= bound; excludes nil (§1.7, §1.17)", + %{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01") + refute html =~ alice.first_name + assert html =~ bob.first_name + assert html =~ carla.first_name + refute html =~ dan.first_name + end + + test "jd_to excludes members with join_date > bound (§1.8)", + %{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_to=2022-12-31") + assert html =~ alice.first_name + assert html =~ bob.first_name + refute html =~ carla.first_name + refute html =~ dan.first_name + end + + test "jd_from and jd_to combine into an inclusive range (§1.9)", + %{conn: conn, alice: alice, bob: bob, carla: carla} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_from=2022-01-01&jd_to=2023-12-31") + refute html =~ alice.first_name + assert html =~ bob.first_name + refute html =~ carla.first_name + end + + test "no active filter imposes no constraint on the join_date field (§1.18)", + %{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do + # Nil join_date members are still visible when no join_date filter is active. + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ alice.first_name + assert html =~ bob.first_name + assert html =~ carla.first_name + assert html =~ dan.first_name + end + end + + describe "exit_date filters" do + test "ed_mode=inactive_only shows only former members (§1.10)", + %{conn: conn, alice: alice, former: former} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?ed_mode=inactive_only") + refute html =~ alice.first_name + assert html =~ former.first_name + end + + test "ed_mode=all shows all members regardless of exit_date (§1.11)", + %{conn: conn, alice: alice, former: former} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?ed_mode=all") + assert html =~ alice.first_name + assert html =~ former.first_name + end + + test "ed_mode=custom range hides members outside the range (§1.12)", + %{conn: conn, alice: alice, former: former, today: today} do + conn = conn_with_oidc_user(conn) + from = Date.add(today, -30) |> Date.to_iso8601() + to = Date.to_iso8601(today) + + {:ok, _view, html} = live(conn, "/members?ed_mode=custom&ed_from=#{from}&ed_to=#{to}") + refute html =~ alice.first_name + assert html =~ former.first_name + end + end + + describe "filter combination and URL persistence" do + test "join_date filter combined with the default (active-only) shows only active members in range (§1.15)", + %{conn: conn, alice: alice, bob: bob, former: former} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_from=2020-01-01") + # Default active-only hides the former member even though they match join_date range. + refute html =~ former.first_name + assert html =~ alice.first_name + assert html =~ bob.first_name + end + + test "date filter survives reload via URL params (§1.16)", + %{conn: conn, alice: alice, bob: bob, carla: carla} do + conn = conn_with_oidc_user(conn) + url = "/members?jd_from=2022-01-01&jd_to=2023-12-31" + {:ok, _view, html1} = live(conn, url) + {:ok, _view, html2} = live(conn, url) + # Same URL → same visible result set. + for member <- [alice, carla] do + refute html1 =~ member.first_name + refute html2 =~ member.first_name + end + + assert html1 =~ bob.first_name + assert html2 =~ bob.first_name + end + + test "malformed jd_from is silently ignored (§1.19)", + %{conn: conn, alice: alice, bob: bob, carla: carla, dan: dan} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?jd_from=notadate") + # The filter is dropped, so default behavior (no join_date filter) applies + # and every member shows up. + assert html =~ alice.first_name + assert html =~ bob.first_name + assert html =~ carla.first_name + assert html =~ dan.first_name + end end end diff --git a/test/mv_web/live/member_live/index/custom_field_value_lookup_test.exs b/test/mv_web/live/member_live/index/custom_field_value_lookup_test.exs new file mode 100644 index 0000000..1c4bbe4 --- /dev/null +++ b/test/mv_web/live/member_live/index/custom_field_value_lookup_test.exs @@ -0,0 +1,89 @@ +defmodule MvWeb.MemberLive.Index.CustomFieldValueLookupTest do + @moduledoc """ + Unit tests for the shared custom-field-value lookup helper. + + The lookup must handle both shapes a CFV entry can take on a loaded member: + + * `%{custom_field_id: id, value: ...}` — id present directly + * `%{custom_field: %{id: id, ...}, value: ...}` — id nested under loaded relation + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.CustomFieldValueLookup + + defp uuid, do: "11111111-2222-3333-4444-555555555555" + + describe "find_by_id/2" do + test "matches when custom_field_id key is present" do + id = uuid() + cfv = %{custom_field_id: id, value: :anything} + member = %{custom_field_values: [cfv]} + + assert CustomFieldValueLookup.find_by_id(member, id) == cfv + end + + test "matches when nested custom_field relation is loaded" do + id = uuid() + cfv = %{custom_field: %{id: id, value_type: :date}, value: :anything} + member = %{custom_field_values: [cfv]} + + assert CustomFieldValueLookup.find_by_id(member, id) == cfv + end + + test "compares stringified ids — accepts atom or binary ids on the cfv side" do + id = uuid() + cfv = %{custom_field_id: id, value: :v} + member = %{custom_field_values: [cfv]} + + # Same id, passed as binary + assert CustomFieldValueLookup.find_by_id(member, id) == cfv + end + + test "returns nil when no entry has a matching id" do + member = %{ + custom_field_values: [ + %{custom_field_id: "11111111-1111-1111-1111-111111111111", value: 1} + ] + } + + assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil + end + + test "returns nil when custom_field_values is nil" do + assert CustomFieldValueLookup.find_by_id(%{custom_field_values: nil}, uuid()) == nil + end + + test "returns nil when custom_field_values is not loaded (Ash.NotLoaded)" do + member = %{custom_field_values: %Ash.NotLoaded{type: :relationship}} + assert CustomFieldValueLookup.find_by_id(member, uuid()) == nil + end + + test "returns nil when custom_field_values is empty" do + assert CustomFieldValueLookup.find_by_id(%{custom_field_values: []}, uuid()) == nil + end + end + + describe "find_by_field/2" do + test "matches a custom_field struct via its :id" do + id = uuid() + cfv = %{custom_field_id: id, value: :v} + member = %{custom_field_values: [cfv]} + custom_field = %{id: id, value_type: :boolean} + + assert CustomFieldValueLookup.find_by_field(member, custom_field) == cfv + end + + test "matches when only the nested custom_field is present" do + id = uuid() + cfv = %{custom_field: %{id: id}, value: :v} + member = %{custom_field_values: [cfv]} + + assert CustomFieldValueLookup.find_by_field(member, %{id: id}) == cfv + end + + test "returns nil when no entry matches" do + member = %{custom_field_values: [%{custom_field_id: "other", value: :v}]} + assert CustomFieldValueLookup.find_by_field(member, %{id: uuid()}) == nil + end + end +end diff --git a/test/mv_web/live/member_live/index/filter_params_test.exs b/test/mv_web/live/member_live/index/filter_params_test.exs new file mode 100644 index 0000000..a73d17b --- /dev/null +++ b/test/mv_web/live/member_live/index/filter_params_test.exs @@ -0,0 +1,85 @@ +defmodule MvWeb.MemberLive.Index.FilterParamsTest do + @moduledoc """ + Unit tests for the shared filter-param parsers. + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.FilterParams + + describe "parse_prefix_filters/3" do + test "extracts only entries whose key starts with the prefix" do + params = %{ + "group_abc" => "in", + "group_def" => "not_in", + "fee_type_xyz" => "in", + "unrelated" => "in", + "query" => "alice" + } + + result = FilterParams.parse_prefix_filters(params, "group_", & &1) + + assert result == %{"abc" => "in", "def" => "not_in"} + end + + test "strips exactly one occurrence of the prefix, even when the rest starts with the prefix again" do + # Quirky but legal: a key like "p_p_abc" with prefix "p_" must produce id "p_abc". + params = %{"p_p_abc" => "v"} + result = FilterParams.parse_prefix_filters(params, "p_", & &1) + assert result == %{"p_abc" => "v"} + end + + test "applies parse_value_fn to every value" do + params = %{"x_one" => "in", "x_two" => "not_in", "x_three" => "garbage"} + + result = + FilterParams.parse_prefix_filters( + params, + "x_", + &FilterParams.parse_in_not_in_value/1 + ) + + assert result == %{"one" => :in, "two" => :not_in, "three" => nil} + end + + test "returns empty map when no key matches the prefix" do + params = %{"a" => "1", "b" => "2"} + assert FilterParams.parse_prefix_filters(params, "z_", & &1) == %{} + end + + test "ignores non-binary keys" do + params = %{"x_a" => "1", :atom_key => "2", 123 => "3"} + result = FilterParams.parse_prefix_filters(params, "x_", & &1) + assert result == %{"a" => "1"} + end + + test "returns empty map for empty input" do + assert FilterParams.parse_prefix_filters(%{}, "x_", & &1) == %{} + end + end + + describe "parse_in_not_in_value/1" do + test "maps 'in' to :in" do + assert FilterParams.parse_in_not_in_value("in") == :in + end + + test "maps 'not_in' to :not_in" do + assert FilterParams.parse_in_not_in_value("not_in") == :not_in + end + + test "trims whitespace around recognized values" do + assert FilterParams.parse_in_not_in_value(" in ") == :in + assert FilterParams.parse_in_not_in_value("\tnot_in\n") == :not_in + end + + test "returns nil for unrecognized strings" do + assert FilterParams.parse_in_not_in_value("yes") == nil + assert FilterParams.parse_in_not_in_value("") == nil + end + + test "returns nil for non-binary input" do + assert FilterParams.parse_in_not_in_value(nil) == nil + assert FilterParams.parse_in_not_in_value(:in) == nil + assert FilterParams.parse_in_not_in_value(123) == nil + end + end +end From d6671daf1a2dc0026e252de22023960c364d9f49 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 20 May 2026 16:32:29 +0200 Subject: [PATCH 26/79] feat(member-filter): add date filter sections with active-count badge and reset support --- .../components/member_filter_component.ex | 278 +++++++++++++- lib/mv_web/live/member_live/index.html.heex | 2 + priv/gettext/de/LC_MESSAGES/default.po | 75 ++++ priv/gettext/default.pot | 75 ++++ priv/gettext/en/LC_MESSAGES/default.po | 75 ++++ .../member_filter_component_test.exs | 338 ++++++++++++++++++ .../member_live/date_filter_property_test.exs | 9 +- 7 files changed, 834 insertions(+), 18 deletions(-) diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 71227be..99ee2c5 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -23,6 +23,11 @@ defmodule MvWeb.Components.MemberFilterComponent do - `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All). - `:boolean_custom_fields` - List of boolean custom fields to display - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` + - `:date_custom_fields` - List of date-typed custom fields rendered in the + "Custom date fields" section (each with `:id`, `:name`, `:value_type`). + - `:date_filters` - Date filter state map (see `MvWeb.MemberLive.Index.DateFilter`): + built-in `:join_date` / `:exit_date` bounds and mode, plus optional + UUID-keyed custom date field bound entries. - `:id` - Component ID (required) - `:member_count` - Number of filtered members to display in badge (optional, default: 0) @@ -31,13 +36,18 @@ defmodule MvWeb.Components.MemberFilterComponent do - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) - Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in) - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes + - Sends `{:date_filters_changed, new_filters}` to parent when any date + filter input changes (built-in date bounds, exit_date mode, or custom + date field bounds). """ use MvWeb, :live_component + alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.FilterParams @group_filter_prefix Mv.Constants.group_filter_prefix() @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() + @custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix() @impl true def mount(socket) do @@ -50,19 +60,42 @@ defmodule MvWeb.Components.MemberFilterComponent do socket |> assign(:id, assigns.id) |> assign(:cycle_status_filter, assigns[:cycle_status_filter]) - |> assign(:groups, assigns[:groups] || []) - |> assign(:group_filters, assigns[:group_filters] || %{}) - |> assign(:group_filter_prefix, @group_filter_prefix) - |> assign(:fee_types, assigns[:fee_types] || []) - |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) - |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) - |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) - |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) + |> assign_group_assigns(assigns) + |> assign_fee_type_assigns(assigns) + |> assign_boolean_assigns(assigns) + |> assign_date_assigns(assigns) |> assign(:member_count, assigns[:member_count] || 0) {:ok, socket} end + defp assign_group_assigns(socket, assigns) do + socket + |> assign(:groups, assigns[:groups] || []) + |> assign(:group_filters, assigns[:group_filters] || %{}) + |> assign(:group_filter_prefix, @group_filter_prefix) + end + + defp assign_fee_type_assigns(socket, assigns) do + socket + |> assign(:fee_types, assigns[:fee_types] || []) + |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) + |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) + end + + defp assign_boolean_assigns(socket, assigns) do + socket + |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) + |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) + end + + defp assign_date_assigns(socket, assigns) do + socket + |> assign(:date_custom_fields, assigns[:date_custom_fields] || []) + |> assign(:date_filters, assigns[:date_filters] || DateFilter.default()) + |> assign(:custom_date_filter_prefix, @custom_date_filter_prefix) + end + @impl true def render(assigns) do ~H""" @@ -81,7 +114,8 @@ defmodule MvWeb.Components.MemberFilterComponent do "gap-2", (@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0 || - active_boolean_filters_count(@boolean_filters) > 0) && + active_boolean_filters_count(@boolean_filters) > 0 || + date_filters_active?(@date_filters)) && "btn-active" ]} phx-click="toggle_dropdown" @@ -99,7 +133,8 @@ defmodule MvWeb.Components.MemberFilterComponent do @fee_types, @fee_type_filters, @boolean_custom_fields, - @boolean_filters + @boolean_filters, + @date_filters )} <.badge @@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do <.badge :if={ - (@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) && + (@cycle_status_filter || map_size(@group_filters) > 0 || + map_size(@fee_type_filters) > 0 || + date_filters_active?(@date_filters)) && active_boolean_filters_count(@boolean_filters) == 0 } variant="primary" @@ -329,6 +366,163 @@ defmodule MvWeb.Components.MemberFilterComponent do + +
+
+ {gettext("Dates")} +
+
+ + {gettext("Exit date")} + +
+ + + + +
+
+ <.input + type="date" + id="ed-from" + name="ed_from" + label={gettext("From")} + class="input input-sm input-bordered" + aria-label={gettext("Exit date from")} + value={date_value_for_input(@date_filters, :exit_date, :from)} + /> + <.input + type="date" + id="ed-to" + name="ed_to" + label={gettext("To")} + class="input input-sm input-bordered" + aria-label={gettext("Exit date to")} + value={date_value_for_input(@date_filters, :exit_date, :to)} + /> +
+
+
+ + {gettext("Join date")} + +
+ <.input + type="date" + id="jd-from" + name="jd_from" + label={gettext("From")} + class="input input-sm input-bordered" + aria-label={gettext("Join date from")} + value={date_value_for_input(@date_filters, :join_date, :from)} + /> + <.input + type="date" + id="jd-to" + name="jd_to" + label={gettext("To")} + class="input input-sm input-bordered" + aria-label={gettext("Join date to")} + value={date_value_for_input(@date_filters, :join_date, :to)} + /> +
+
+
+ + +
0} class="mb-4"> +
+ {gettext("Custom date fields")} +
+
5, do: "max-h-60 overflow-y-auto pr-2", else: "" + }> +
+ + {field.name} + +
+ <.input + type="date" + id={"cdf-#{field.id}-from"} + name={"#{@custom_date_filter_prefix}#{field.id}_from"} + label={gettext("From")} + class="input input-sm input-bordered" + aria-label={gettext("%{field} from", field: field.name)} + value={custom_date_value_for_input(@date_filters, field.id, :from)} + /> + <.input + type="date" + id={"cdf-#{field.id}-to"} + name={"#{@custom_date_filter_prefix}#{field.id}_to"} + label={gettext("To")} + class="input input-sm input-bordered" + aria-label={gettext("%{field} to", field: field.name)} + value={custom_date_value_for_input(@date_filters, field.id, :to)} + /> +
+
+
+
+
0} class="mb-2">
@@ -452,11 +646,13 @@ defmodule MvWeb.Components.MemberFilterComponent do ) custom_boolean_filters_parsed = parse_custom_boolean_filters(params) + new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields) dispatch_payment_filter_change(socket, payment_filter) dispatch_group_filter_changes(socket, group_filters_parsed) dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) + dispatch_date_filters_change(socket, new_date_filters) {:noreply, socket} end @@ -540,6 +736,12 @@ defmodule MvWeb.Components.MemberFilterComponent do end) end + defp dispatch_date_filters_change(socket, new_date_filters) do + if new_date_filters != socket.assigns.date_filters do + send(self(), {:date_filters_changed, new_date_filters}) + end + end + # Get display label for button defp button_label( cycle_status_filter, @@ -548,14 +750,16 @@ defmodule MvWeb.Components.MemberFilterComponent do fee_types, fee_type_filters, boolean_custom_fields, - boolean_filters + boolean_filters, + date_filters ) do active_count = count_active_filter_categories( cycle_status_filter, group_filters, fee_type_filters, - boolean_filters + boolean_filters, + date_filters ) if active_count >= 2 do @@ -576,6 +780,9 @@ defmodule MvWeb.Components.MemberFilterComponent do map_size(boolean_filters) > 0 -> boolean_filter_label(boolean_custom_fields, boolean_filters) + date_filters_active?(date_filters) -> + gettext("Dates") + true -> gettext("Apply filters") end @@ -586,17 +793,27 @@ defmodule MvWeb.Components.MemberFilterComponent do cycle_status_filter, group_filters, fee_type_filters, - boolean_filters + boolean_filters, + date_filters ) do [ cycle_status_filter, map_size(group_filters) > 0, map_size(fee_type_filters) > 0, - map_size(boolean_filters) > 0 + map_size(boolean_filters) > 0, + date_filters_active?(date_filters) ] |> Enum.count(& &1) end + # Date filter is "active" when its state differs from the default — i.e. the + # user selected something other than active-only with no custom date bounds. + defp date_filters_active?(date_filters) when is_map(date_filters) do + date_filters != DateFilter.default() + end + + defp date_filters_active?(_), do: false + defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, do: gettext("All") @@ -765,4 +982,35 @@ defmodule MvWeb.Components.MemberFilterComponent do "#{base_classes} btn-outline" end end + + # --- Date filter helpers ---------------------------------------------- + + defp exit_mode(%{exit_date: %{mode: mode}}), do: mode + defp exit_mode(_), do: :active_only + + defp exit_mode_label_class(date_filters, expected) do + base_classes = "join-item btn btn-sm" + + if exit_mode(date_filters) == expected do + "#{base_classes} btn-active" + else + "#{base_classes} btn" + end + end + + defp date_value_for_input(date_filters, field, bound) do + case date_filters do + %{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d) + _ -> "" + end + end + + defp custom_date_value_for_input(date_filters, field_id, bound) do + key = to_string(field_id) + + case Map.get(date_filters, key) do + %{^bound => %Date{} = d} -> Date.to_iso8601(d) + _ -> "" + end + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index efc1eb7..13dc89e 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -54,6 +54,8 @@ fee_type_filters={@fee_type_filters} boolean_custom_fields={@boolean_custom_fields} boolean_filters={@boolean_custom_field_filters} + date_custom_fields={@date_custom_fields} + date_filters={@date_filters} member_count={length(@members)} /> <.tooltip diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a85b4cf..f03daf0 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3897,3 +3897,78 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} from" +msgstr "%{field} von" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} to" +msgstr "%{field} bis" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Active only" +msgstr "Nur aktive" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Custom date fields" +msgstr "Benutzerdefinierte Datumsfelder" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Dates" +msgstr "Daten" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date" +msgstr "Austrittsdatum" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date from" +msgstr "Austrittsdatum von" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date to" +msgstr "Austrittsdatum bis" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "From" +msgstr "Von" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Inactive only" +msgstr "Nur ehemalige" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date" +msgstr "Beitrittsdatum" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date from" +msgstr "Beitrittsdatum von" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date to" +msgstr "Beitrittsdatum bis" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Range" +msgstr "Zeitraum" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "To" +msgstr "Bis" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b995b1a..50ceff8 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3897,3 +3897,78 @@ msgstr "" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} to" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Active only" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Custom date fields" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Dates" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Exit date to" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "From" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Inactive only" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Join date to" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Range" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "To" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index f4526d1..9ec230f 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3897,3 +3897,78 @@ msgstr "" #, elixir-autogen, elixir-format msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only." msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{field} to" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Active only" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom date fields" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Dates" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Exit date" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Exit date from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Exit date to" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "From" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Inactive only" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Join date" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Join date from" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Join date to" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Range" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "To" +msgstr "" diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs index d32993c..bb55fa5 100644 --- a/test/mv_web/components/member_filter_component_test.exs +++ b/test/mv_web/components/member_filter_component_test.exs @@ -209,6 +209,57 @@ defmodule MvWeb.Components.MemberFilterComponentTest do # Button should still contain some text (truncated version or indicator) assert String.length(button_html) > 0 end + + test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?ed_mode=all") + + button_html = + view + |> element("#member-filter button[aria-haspopup='true']") + |> render() + + # The idle label must not appear; some non-idle label is shown. This is + # the same observable contract as the other filter categories — the + # button visually communicates "a filter is active". The `btn-active` + # CSS class is set by the parent class= attribute but the `<.button>` + # core component currently composes its own class string and drops the + # caller-supplied one — that is a pre-existing component constraint, not + # specific to date filters. + refute button_html =~ gettext("Apply filters") + end + + test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15") + + button_html = + view + |> element("#member-filter button[aria-haspopup='true']") + |> render() + + refute button_html =~ gettext("Apply filters") + end + + test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do + conn = conn_with_oidc_user(conn) + boolean_field = create_boolean_custom_field(%{name: "Newsletter"}) + + {:ok, view, _html} = + live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true") + + button_html = + view + |> element("#member-filter button[aria-haspopup='true']") + |> render() + + # With two distinct filter categories active, the label switches to the + # pluralized "N filters active" form. Without counting date filters as + # a category, this would show only "1 filter active" or the boolean + # field name. + assert button_html =~ "2" + assert button_html =~ gettext("filters active") + end end describe "badge" do @@ -268,6 +319,293 @@ defmodule MvWeb.Components.MemberFilterComponentTest do refute dropdown_html =~ "String Field" end + test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + view + |> element("#member-filter button[aria-haspopup='true']") + |> render_click() + + dropdown_html = + view + |> element("#member-filter div[role='dialog']") + |> render() + + assert dropdown_html =~ gettext("Dates") + assert dropdown_html =~ gettext("Join date") + assert dropdown_html =~ gettext("Exit date") + # Exit-date segmented control modes. + assert dropdown_html =~ gettext("Active only") + assert dropdown_html =~ gettext("Inactive only") + # Built-in date inputs (always present for join_date and the ed_mode selector). + assert dropdown_html =~ ~s(name="jd_from") + assert dropdown_html =~ ~s(name="jd_to") + assert dropdown_html =~ ~s(name="ed_mode") + end + + test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?ed_mode=custom") + + view + |> element("#member-filter button[aria-haspopup='true']") + |> render_click() + + dropdown_html = + view + |> element("#member-filter div[role='dialog']") + |> render() + + assert dropdown_html =~ ~s(name="ed_from") + assert dropdown_html =~ ~s(name="ed_to") + end + + test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)", + %{conn: conn} do + # DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's + # `<.input>` wrapper rather than emitting raw `` tags carrying + # DaisyUI component classes (e.g. `input input-sm input-bordered`) + # directly in HEEX. `<.input>` is the project's single source of truth + # for input styling; bypassing it splits styling across many call sites. + # + # The recognizable structural fingerprint of `<.input>` is a wrapping + # `
` `
{col[:label]} @@ -1006,7 +1020,13 @@ defmodule MvWeb.CoreComponents do
+ + + + + + + + + + + <%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %> + + + + <%= for sample <- samples do %> + + <% end %> + + <% end %> + +
{gettext("Role")}{gettext("Column")}{gettext("Row 1")}{gettext("Row 2")}{gettext("Row 3")}
+ + {role_label(role)} + + {header}{sample}
+
+ + <%= if @import_state.groups_to_create != [] do %> +
+ <.icon name="hero-information-circle" class="size-5" aria-hidden="true" /> +
+

+ {gettext("These groups will be created automatically: %{names}", + names: Enum.join(@import_state.groups_to_create, ", ") + )} +

+
+
+ <% end %> + + <%= if @import_state.fee_type_warnings != [] do %> + + <% end %> + + <%= if @import_state.has_empty_fee_type_cells? do %> +
+ <.icon name="hero-information-circle" class="size-5" aria-hidden="true" /> +
+

+ {gettext("Rows with an empty fee type will get the default fee type.")} +

+
+
+ <% end %> + + <%= if @import_state.warnings != [] do %> + + <% end %> + +
+ <.button + type="button" + phx-click="confirm_import" + variant="primary" + data-testid="confirm-import-button" + > + {gettext("Confirm and Import")} + + <.button type="button" phx-click="cancel_import" data-testid="cancel-import-button"> + {gettext("Cancel")} + +
+ + """ + end + + # Pairs each CSV header with its resolved role for the preview mapping table. + defp column_roles(state) do + member_indices = MapSet.new(Map.values(state.column_map)) + custom_indices = MapSet.new(Map.values(state.custom_field_map)) + ignored_headers = MapSet.new(state.ignored) + + state.headers + |> Enum.with_index() + |> Enum.map(fn {header, index} -> + {header, role_for(index, header, state, member_indices, custom_indices, ignored_headers)} + end) + end + + defp role_for(index, header, state, member_indices, custom_indices, ignored_headers) do + cond do + index == state.groups_column_index -> :groups + index == state.fee_type_column_index -> :fee_type + MapSet.member?(ignored_headers, header) -> :ignored + MapSet.member?(member_indices, index) -> :member_field + MapSet.member?(custom_indices, index) -> :custom_field + true -> :unknown + end + end + + defp role_label(:member_field), do: gettext("Member field") + defp role_label(:custom_field), do: gettext("Custom field") + defp role_label(:groups), do: gettext("Groups") + defp role_label(:fee_type), do: gettext("Fee type") + defp role_label(:ignored), do: gettext("Ignored (system-computed field)") + defp role_label(:unknown), do: gettext("Unknown (ignored)") + + defp role_badge_class(:member_field), do: "badge-primary" + defp role_badge_class(:custom_field), do: "badge-secondary" + defp role_badge_class(:groups), do: "badge-success" + defp role_badge_class(:fee_type), do: "badge-warning" + defp role_badge_class(:ignored), do: "badge-ghost" + defp role_badge_class(:unknown), do: "badge-error" + + defp role_row_class(:ignored), do: "opacity-50" + defp role_row_class(:unknown), do: "opacity-50" + defp role_row_class(_), do: nil + + defp column_samples([], col_count), do: List.duplicate([], col_count) + + defp column_samples(rows, col_count) do + Enum.map(0..(col_count - 1), fn col_idx -> + rows + |> Enum.map(fn row -> Enum.at(row, col_idx, "") end) + |> pad_to(3, "") + end) + end + + defp pad_to(list, target, fill) do + list ++ List.duplicate(fill, max(0, target - length(list))) + end + @doc """ Renders import progress text and, when done or aborted, the import results section. """ @@ -246,8 +449,10 @@ defmodule MvWeb.ImportLive.Components do @doc """ Returns whether the Start Import button should be disabled. """ - @spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean() + @spec import_button_disabled?(:idle | :preview | :running | :done | :error, [map()]) :: + boolean() def import_button_disabled?(:running, _entries), do: true + def import_button_disabled?(:preview, _entries), do: true def import_button_disabled?(_status, []), do: true def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true def import_button_disabled?(_status, _entries), do: false diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 9fa6cd4..343ede8 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -390,6 +390,7 @@ msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur z #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -1329,6 +1330,7 @@ msgstr "Feb." msgid "Fee Type" msgstr "Beitragsart" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1488,6 +1490,7 @@ msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -2643,6 +2646,7 @@ msgstr "Geprüft von" msgid "Reviewed at" msgstr "Geprüft am" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex @@ -3303,11 +3307,6 @@ msgstr "Aufhebung der Verknüpfung geplant" msgid "Unpaid" msgstr "Unbezahlt" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." -msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und Beitragsstatus können nicht importiert werden." - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3977,3 +3976,103 @@ msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwende #, elixir-autogen, elixir-format msgid "Group assignment failed: %{reason}" msgstr "Gruppenzuordnung fehlgeschlagen: %{reason}" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "Bestätigen und importieren" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "Kein vorbereiteter Import zum Bestätigen. Bitte erneut hochladen." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "Importvorschau" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "Spalte" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "Benutzerdefiniertes Feld" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "Ignoriert (vom System berechnetes Feld)" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Member field" +msgstr "Mitgliedsfeld" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "Zeilen ohne Beitragsart erhalten die Standard-Beitragsart." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "Diese Gruppen werden automatisch erstellt: %{names}" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown (ignored)" +msgstr "Unbekannt (ignoriert)" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "Unbekannte Beitragsarten (Mitglieder erhalten den Standard): %{names}" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "Gruppen-Spalte (erkannte Spaltennamen): Groups, Gruppen, Gruppe. Mehrere durch Komma getrennte Gruppennamen werden unterstützt; fehlende Gruppen werden automatisch erstellt." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart." + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "Datenfeld erstellen" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "Beitragsart erstellen" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "Zeile 1" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "Zeile 2" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "Zeile 3" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index c961420..f14f7a1 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -391,6 +391,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -1330,6 +1331,7 @@ msgstr "" msgid "Fee Type" msgstr "" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1489,6 +1491,7 @@ msgstr "" #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -2644,6 +2647,7 @@ msgstr "" msgid "Reviewed at" msgstr "" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex @@ -3304,11 +3308,6 @@ msgstr "" msgid "Unpaid" msgstr "" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3977,3 +3976,103 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Group assignment failed: %{reason}" msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Member field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown (ignored)" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 58aeead..18d1e30 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -391,6 +391,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -1330,6 +1331,7 @@ msgstr "" msgid "Fee Type" msgstr "Fee Type" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Fee type" @@ -1489,6 +1491,7 @@ msgstr "" #: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -2644,6 +2647,7 @@ msgstr "Review by" msgid "Reviewed at" msgstr "Review date" +#: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex @@ -3304,11 +3308,6 @@ msgstr "" msgid "Unpaid" msgstr "" -#: lib/mv_web/live/import_live/components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import." -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "User" @@ -3977,3 +3976,103 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Group assignment failed: %{reason}" msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Confirm and Import" +msgstr "" + +#: lib/mv_web/live/import_live.ex +#, elixir-autogen, elixir-format +msgid "No prepared import to confirm. Please upload again." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Preview import" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Column" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Ignored (system-computed field)" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Member field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Rows with an empty fee type will get the default fee type." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "These groups will be created automatically: %{names}" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Unknown (ignored)" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Unknown fee types (members get the default): %{names}" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create custom field" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Create fee type" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 1" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 2" +msgstr "" + +#: lib/mv_web/live/import_live/components.ex +#, elixir-autogen, elixir-format +msgid "Row 3" +msgstr "" diff --git a/test/mv_web/live/import_live/components_test.exs b/test/mv_web/live/import_live/components_test.exs new file mode 100644 index 0000000..3870ed6 --- /dev/null +++ b/test/mv_web/live/import_live/components_test.exs @@ -0,0 +1,31 @@ +defmodule MvWeb.ImportLive.ComponentsTest do + use ExUnit.Case, async: true + + alias MvWeb.ImportLive.Components + + describe "import_button_disabled?/2" do + @done_entry %{done?: true} + + test "disables the Start Import button while the preview is displayed" do + # During :preview the upload entry is done, but re-clicking Start Import + # would re-run the upload processing and overwrite the current preview. + assert Components.import_button_disabled?(:preview, [@done_entry]) == true + end + + test "disables the button while an import is running" do + assert Components.import_button_disabled?(:running, [@done_entry]) == true + end + + test "disables the button when there are no upload entries" do + assert Components.import_button_disabled?(:idle, []) == true + end + + test "disables the button while an upload entry is not yet done" do + assert Components.import_button_disabled?(:idle, [%{done?: false}]) == true + end + + test "enables the button at idle with a completed upload" do + assert Components.import_button_disabled?(:idle, [@done_entry]) == false + end + end +end diff --git a/test/mv_web/live/import_live_test.exs b/test/mv_web/live/import_live_test.exs index 7b4dd40..bd5cdec 100644 --- a/test/mv_web/live/import_live_test.exs +++ b/test/mv_web/live/import_live_test.exs @@ -27,6 +27,16 @@ defmodule MvWeb.ImportLiveTest do defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit() + defp confirm_import(view), + do: view |> element("[data-testid='confirm-import-button']") |> render_click() + + # Full flow: upload, enter preview (start), then confirm to begin processing. + defp run_full_import(view, csv_content, filename \\ "test_import.csv") do + upload_csv_file(view, csv_content, filename) + submit_import(view) + confirm_import(view) + end + defp wait_for_import_completion, do: Process.sleep(1000) # ---------- Business logic: Authorization ---------- @@ -56,8 +66,7 @@ defmodule MvWeb.ImportLiveTest do |> File.read!() {:ok, view, _html} = live(conn, ~p"/admin/import") - upload_csv_file(view, csv_content) - submit_import(view) + run_full_import(view, csv_content) wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -121,8 +130,7 @@ defmodule MvWeb.ImportLiveTest do invalid_csv: csv_content } do {:ok, view, _html} = live(conn, ~p"/admin/import") - upload_csv_file(view, csv_content, "invalid_import.csv") - submit_import(view) + run_full_import(view, csv_content, "invalid_import.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -141,8 +149,7 @@ defmodule MvWeb.ImportLiveTest do invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Country#{i};City#{i};Street#{i};12345\n" - upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv") - submit_import(view) + run_full_import(view, header <> Enum.join(invalid_rows), "large_invalid.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -174,8 +181,7 @@ defmodule MvWeb.ImportLiveTest do Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"]) |> File.read!() - upload_csv_file(view, csv_content, "bom_import.csv") - submit_import(view) + run_full_import(view, csv_content, "bom_import.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -193,8 +199,7 @@ defmodule MvWeb.ImportLiveTest do Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"]) |> File.read!() - upload_csv_file(view, csv_content, "empty_lines.csv") - submit_import(view) + run_full_import(view, csv_content, "empty_lines.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-error-list']") @@ -208,8 +213,7 @@ defmodule MvWeb.ImportLiveTest do unknown_custom_field_csv: csv_content } do {:ok, view, _html} = live(conn, ~p"/admin/import") - upload_csv_file(view, csv_content, "unknown_custom.csv") - submit_import(view) + run_full_import(view, csv_content, "unknown_custom.csv") wait_for_import_completion() assert has_element?(view, "[data-testid='import-results-panel']") @@ -254,14 +258,27 @@ defmodule MvWeb.ImportLiveTest do assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']") end + test "custom fields notice lists accepted groups and fee-type column names", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/import") + + # Groups column variants (both EN and DE) + assert html =~ "Groups" + assert html =~ "Gruppen" + # Fee type column variants (both EN and DE) + assert html =~ "Beitragsart" + assert html =~ "Fee Type" + assert html =~ "fee type" + # Fee status is always ignored (named explicitly) + assert html =~ "Bezahlstatus" + end + test "after successful import, progress container has aria-live", %{conn: conn} do csv_content = Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) |> File.read!() {:ok, view, _html} = live(conn, ~p"/admin/import") - upload_csv_file(view, csv_content) - submit_import(view) + run_full_import(view, csv_content) wait_for_import_completion() assert has_element?(view, "[data-testid='import-progress-container']") html = render(view) @@ -280,4 +297,187 @@ defmodule MvWeb.ImportLiveTest do html = render(view) assert html =~ "Failed to prepare" end + + describe "preview state machine" do + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + + conn = + conn + |> MvWeb.ConnCase.conn_with_password_user(admin_user) + |> put_locale_en() + + valid_csv = + Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) + |> File.read!() + + {:ok, conn: conn, valid_csv: valid_csv} + end + + test "start_import transitions to preview without processing", %{ + conn: conn, + valid_csv: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv_content) + submit_import(view) + + # Preview is shown; no results panel yet because nothing was processed. + assert has_element?(view, "[data-testid='import-preview']") + refute has_element?(view, "[data-testid='import-results-panel']") + + # No member was created during preview (read-only step). + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, members} = Membership.list_members(actor: system_actor) + + refute Enum.any?( + members, + &(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"]) + ) + end + + test "confirm_import starts processing and creates members", %{ + conn: conn, + valid_csv: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import") + run_full_import(view, csv_content) + wait_for_import_completion() + + assert has_element?(view, "[data-testid='import-results-panel']") + + system_actor = Mv.Helpers.SystemActor.get_system_actor() + {:ok, members} = Membership.list_members(actor: system_actor) + + imported = + Enum.filter( + members, + &(&1.email in ["alice.smith@example.com", "bob.johnson@example.com"]) + ) + + assert length(imported) == 2 + end + + test "cancel_import returns to idle and hides the preview", %{ + conn: conn, + valid_csv: csv_content + } do + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv_content) + submit_import(view) + assert has_element?(view, "[data-testid='import-preview']") + + view |> element("[data-testid='cancel-import-button']") |> render_click() + + refute has_element?(view, "[data-testid='import-preview']") + refute has_element?(view, "[data-testid='import-results-panel']") + end + end + + describe "preview contents" do + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + + conn = + conn + |> MvWeb.ConnCase.conn_with_password_user(admin_user) + |> put_locale_en() + + {:ok, conn: conn} + end + + test "shows the column mapping table with roles for each column", %{conn: conn} do + csv = "email;Gruppen;Beitragsart;Bezahlstatus;UnknownCol\na@e.com;Chor;Premium;paid;x" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-mapping-table']") + html = render(view) + + assert html =~ "email" + assert html =~ "Gruppen" + assert html =~ "Beitragsart" + assert html =~ "Bezahlstatus" + assert html =~ "UnknownCol" + end + + test "lists every CSV column exactly once in the mapping table", %{conn: conn} do + headers = ["email", "Gruppen", "Beitragsart", "Bezahlstatus", "UnknownCol"] + csv = Enum.join(headers, ";") <> "\na@e.com;Chor;Premium;paid;x" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + # Count the data rows via their stable testid so the assertion is independent + # of how Phoenix renders class attributes or tr tags (§1.15). + html = render(view) + + row_count = + html |> String.split(~s(data-testid="preview-column-row")) |> length() |> Kernel.-(1) + + assert row_count == length(headers) + end + + test "shows up to 3 sample data rows", %{conn: conn} do + csv = "email\nr1@e.com\nr2@e.com\nr3@e.com\nr4@e.com" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + html = render(view) + assert html =~ "r1@e.com" + assert html =~ "r2@e.com" + assert html =~ "r3@e.com" + refute html =~ "r4@e.com" + end + + test "shows an auto-create notice for unknown group names", %{conn: conn} do + csv = "email;Gruppen\na@e.com;Ganz Neue Gruppe" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-groups-notice']") + assert render(view) =~ "Ganz Neue Gruppe" + end + + test "shows a warning and link for unknown fee-type names", %{conn: conn} do + csv = "email;Beitragsart\na@e.com;Phantom Tarif" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-fee-type-warning']") + html = render(view) + assert html =~ "Phantom Tarif" + assert html =~ "/membership_fee_settings" + end + + test "shows an info notice when fee-type cells are empty", %{conn: conn} do + csv = "email;Beitragsart\na@e.com;\nb@e.com;" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-fee-type-info']") + end + + test "shows a warning for unknown custom-field columns", %{conn: conn} do + csv = "email;TotallyUnknown\na@e.com;value" + + {:ok, view, _html} = live(conn, ~p"/admin/import") + upload_csv_file(view, csv) + submit_import(view) + + assert has_element?(view, "[data-testid='preview-unknown-warning']") + assert render(view) =~ "TotallyUnknown" + end + end end From 118b9f8d57bdd09a259e6f1c8513f662d2d4d325 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:32:15 +0200 Subject: [PATCH 58/79] perf(import): reuse auto-created groups across import chunks --- lib/mv/membership/import/import_runner.ex | 14 ++++++ lib/mv/membership/import/member_csv.ex | 11 +++-- lib/mv_web/live/import_live.ex | 3 ++ .../membership/import/import_runner_test.exs | 18 +++++++ test/mv/membership/import/member_csv_test.exs | 47 +++++++++++++++++++ 5 files changed, 90 insertions(+), 3 deletions(-) diff --git a/lib/mv/membership/import/import_runner.ex b/lib/mv/membership/import/import_runner.ex index 5f953d4..b56cae6 100644 --- a/lib/mv/membership/import/import_runner.ex +++ b/lib/mv/membership/import/import_runner.ex @@ -97,6 +97,20 @@ defmodule Mv.Membership.Import.ImportRunner do } end + @doc """ + Carries the in-memory group snapshot grown by a chunk back into `import_state` + so the next chunk reuses groups created earlier instead of re-reading the + Group table. When the chunk result omits `groups_found`, the state is returned + unchanged. + """ + @spec carry_groups_forward(map(), map()) :: map() + def carry_groups_forward(import_state, chunk_result) do + case Map.fetch(chunk_result, :groups_found) do + {:ok, groups_found} -> Map.put(import_state, :groups_found, groups_found) + :error -> import_state + end + end + @doc """ Returns the next action after processing a chunk: send the next chunk index or done. """ diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index d0ab74b..31dea59 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -37,6 +37,9 @@ defmodule Mv.Membership.Import.MemberCSV do - `inserted` - Number of successfully created members - `failed` - Number of failed member creations - `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import) + - `groups_found` - The in-memory group snapshot grown while processing this + chunk; thread it into the next chunk's `:groups_found` opt so groups created + in an earlier chunk are reused without re-reading the Group table ## Examples @@ -94,7 +97,8 @@ defmodule Mv.Membership.Import.MemberCSV do failed: non_neg_integer(), errors: list(Error.t()), errors_truncated?: boolean(), - warnings: list(String.t()) + warnings: list(String.t()), + groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()}) } alias Mv.Membership.Import.ColumnResolver @@ -374,7 +378,7 @@ defmodule Mv.Membership.Import.MemberCSV do actor: actor } - {inserted, failed, errors, _collected_error_count, truncated?, warnings, _groups_acc} = + {inserted, failed, errors, _collected_error_count, truncated?, warnings, groups_acc} = Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false, [], groups_found}, fn {line_number, row_map}, {acc_inserted, @@ -417,7 +421,8 @@ defmodule Mv.Membership.Import.MemberCSV do failed: failed, errors: Enum.reverse(errors), errors_truncated?: truncated?, - warnings: warnings + warnings: warnings, + groups_found: groups_acc }} end diff --git a/lib/mv_web/live/import_live.ex b/lib/mv_web/live/import_live.ex index 2c5aa8a..cd7f6d3 100644 --- a/lib/mv_web/live/import_live.ex +++ b/lib/mv_web/live/import_live.ex @@ -367,8 +367,11 @@ defmodule MvWeb.ImportLive do new_progress = ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors) + new_import_state = ImportRunner.carry_groups_forward(import_state, chunk_result) + socket = socket + |> assign(:import_state, new_import_state) |> assign(:import_progress, new_progress) |> assign(:import_status, new_progress.status) |> maybe_send_next_chunk(idx, length(import_state.chunks)) diff --git a/test/mv/membership/import/import_runner_test.exs b/test/mv/membership/import/import_runner_test.exs index 88d189e..22d21bd 100644 --- a/test/mv/membership/import/import_runner_test.exs +++ b/test/mv/membership/import/import_runner_test.exs @@ -3,6 +3,24 @@ defmodule Mv.Membership.Import.ImportRunnerTest do alias Mv.Membership.Import.ImportRunner + describe "carry_groups_forward/2" do + test "replaces import_state groups_found with the chunk's grown snapshot" do + import_state = %{groups_found: [%{id: "1", name: "A"}]} + chunk_result = %{groups_found: [%{id: "1", name: "A"}, %{id: "2", name: "B"}]} + + assert ImportRunner.carry_groups_forward(import_state, chunk_result) == %{ + groups_found: [%{id: "1", name: "A"}, %{id: "2", name: "B"}] + } + end + + test "leaves import_state unchanged when the chunk result omits groups_found" do + import_state = %{groups_found: [%{id: "1", name: "A"}], other: :kept} + chunk_result = %{inserted: 1} + + assert ImportRunner.carry_groups_forward(import_state, chunk_result) == import_state + end + end + describe "read_file_entry/2" do test "returns {:ok, content} for a readable file" do path = diff --git a/test/mv/membership/import/member_csv_test.exs b/test/mv/membership/import/member_csv_test.exs index 0701a92..91adb91 100644 --- a/test/mv/membership/import/member_csv_test.exs +++ b/test/mv/membership/import/member_csv_test.exs @@ -1116,6 +1116,53 @@ defmodule Mv.Membership.Import.MemberCSVTest do "Expected the group table read at most a few times, got #{reads} reads for 10 rows (N+1)." end + test "returns the grown group snapshot so later chunks skip the table read", + %{actor: actor} do + chunk1 = [ + {2, %{member: %{email: "g-xchunk-1@example.com"}, custom: %{}, groups: "Shared X"}} + ] + + chunk2 = [ + {3, %{member: %{email: "g-xchunk-2@example.com"}, custom: %{}, groups: "Shared X"}} + ] + + assert {:ok, result1} = + MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, actor: actor, groups_found: []) + + # The chunk result must expose the accumulated snapshot, including the group + # auto-created while processing this chunk, so the LiveView can thread it + # into the next chunk's opts. + assert is_list(result1.groups_found) + assert Enum.any?(result1.groups_found, &(&1.name == "Shared X")) + + group_read_count = Agent.start_link(fn -> 0 end) |> elem(1) + test_pid = self() + + handler = fn _event, _measurements, metadata, _config -> + if self() == test_pid and metadata[:source] == "groups" and + is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do + Agent.update(group_read_count, &(&1 + 1)) + end + end + + handler_id = "test-xchunk-group-read-#{System.unique_integer([:positive])}" + :telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil) + + assert {:ok, %{inserted: 1}} = + MemberCSV.process_chunk(chunk2, %{email: 0}, %{}, + actor: actor, + groups_found: result1.groups_found + ) + + reads = Agent.get(group_read_count, & &1) + :telemetry.detach(handler_id) + + # The second chunk receives the snapshot grown by the first, so the shared + # group resolves from memory without any full-table read. + assert reads == 0, + "Expected no group table read in the second chunk, got #{reads} (snapshot not threaded across chunks)." + end + test "empty groups cell leaves the member without group assignment", %{actor: actor} do chunk = [{2, %{member: %{email: "g-empty@example.com"}, custom: %{}, groups: " "}}] opts = [actor: actor, groups_found: []] From 45c9b819838d46461cfd7431c805f86d667ae7bd Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:37:12 +0200 Subject: [PATCH 59/79] fix(import): collapse duplicate fee-type warnings into a bounded list --- lib/mv/membership/import/import_runner.ex | 2 +- .../membership/import/import_runner_test.exs | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/mv/membership/import/import_runner.ex b/lib/mv/membership/import/import_runner.ex index b56cae6..28893a3 100644 --- a/lib/mv/membership/import/import_runner.ex +++ b/lib/mv/membership/import/import_runner.ex @@ -80,7 +80,7 @@ defmodule Mv.Membership.Import.ImportRunner do all_errors = progress.errors ++ chunk_result.errors new_errors = Enum.take(all_errors, max_errors) errors_truncated? = length(all_errors) > max_errors - new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, []) + new_warnings = Enum.uniq(progress.warnings ++ Map.get(chunk_result, :warnings, [])) chunks_processed = current_chunk_idx + 1 new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running diff --git a/test/mv/membership/import/import_runner_test.exs b/test/mv/membership/import/import_runner_test.exs index 22d21bd..b5fe3c2 100644 --- a/test/mv/membership/import/import_runner_test.exs +++ b/test/mv/membership/import/import_runner_test.exs @@ -21,6 +21,65 @@ defmodule Mv.Membership.Import.ImportRunnerTest do end end + describe "merge_progress/4 warning accumulation" do + test "deduplicates identical warnings across chunks instead of growing unbounded" do + progress = %{ + inserted: 0, + failed: 0, + errors: [], + warnings: ["Fee type 'Ghost' not found; using the default fee type."], + status: :running, + current_chunk: 0, + total_chunks: 3 + } + + chunk_result = %{ + inserted: 2, + failed: 0, + errors: [], + errors_truncated?: false, + warnings: [ + "Fee type 'Ghost' not found; using the default fee type.", + "Fee type 'Ghost' not found; using the default fee type." + ] + } + + result = ImportRunner.merge_progress(progress, chunk_result, 0) + + assert result.warnings == ["Fee type 'Ghost' not found; using the default fee type."] + end + + test "preserves distinct warnings while collapsing duplicates" do + progress = %{ + inserted: 0, + failed: 0, + errors: [], + warnings: ["Fee type 'A' not found; using the default fee type."], + status: :running, + current_chunk: 0, + total_chunks: 2 + } + + chunk_result = %{ + inserted: 1, + failed: 0, + errors: [], + errors_truncated?: false, + warnings: [ + "Fee type 'A' not found; using the default fee type.", + "Fee type 'B' not found; using the default fee type." + ] + } + + result = ImportRunner.merge_progress(progress, chunk_result, 0) + + assert result.warnings == [ + "Fee type 'A' not found; using the default fee type.", + "Fee type 'B' not found; using the default fee type." + ] + end + end + describe "read_file_entry/2" do test "returns {:ok, content} for a readable file" do path = From 2bc5fcec5ac655dac6abd06b3ebcddf9dda234bc Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 3 Jun 2026 02:37:46 +0200 Subject: [PATCH 60/79] docs(changelog): record CSV import improvements under Unreleased --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8032c..edb53f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups. +- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it. +- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm. +- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set. + +### Fixed +- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors. +- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists. + ## [1.2.0] - 2026-05-08 ### Changed From b6c2cf58b1c86f782e1b84391d22602aae677dc1 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 3 Jun 2026 12:01:41 +0200 Subject: [PATCH 61/79] feat(custom-field): add join_description attribute for GDPR join-form labels --- lib/membership/custom_field.ex | 24 ++- ..._add_join_description_to_custom_fields.exs | 21 +++ .../repo/custom_fields/20260603000204.json | 145 ++++++++++++++++++ .../custom_field_validation_test.exs | 55 +++++++ 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20260603000204.json diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ef6c79a..5f4dd0e 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -12,6 +12,8 @@ defmodule Mv.Membership.CustomField do - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `description` - Optional human-readable description + - `join_description` - Optional label shown for this field on the public join form + (e.g., a GDPR confirmation text); supports inline external links. Falls back to `name` when nil. - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -61,7 +63,14 @@ defmodule Mv.Membership.CustomField do end actions do - default_accept [:name, :value_type, :description, :required, :show_in_overview] + default_accept [ + :name, + :value_type, + :description, + :join_description, + :required, + :show_in_overview + ] read :read do primary? true @@ -69,13 +78,13 @@ defmodule Mv.Membership.CustomField do end create :create do - accept [:name, :value_type, :description, :required, :show_in_overview] + accept [:name, :value_type, :description, :join_description, :required, :show_in_overview] change Mv.Membership.Changes.GenerateSlug validate string_length(:slug, min: 1) end update :update do - accept [:name, :description, :required, :show_in_overview] + accept [:name, :description, :join_description, :required, :show_in_overview] require_atomic? false validate fn changeset, _context -> @@ -139,6 +148,15 @@ defmodule Mv.Membership.CustomField do trim?: true ] + attribute :join_description, :string, + allow_nil?: true, + public?: true, + description: "Label shown for this field on the public join form; supports external links", + constraints: [ + max_length: 1000, + trim?: true + ] + attribute :required, :boolean, default: false, allow_nil?: false diff --git a/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs b/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs new file mode 100644 index 0000000..b1d9a05 --- /dev/null +++ b/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddJoinDescriptionToCustomFields 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 + alter table(:custom_fields) do + add :join_description, :text + end + end + + def down do + alter table(:custom_fields) do + remove :join_description + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20260603000204.json b/priv/resource_snapshots/repo/custom_fields/20260603000204.json new file mode 100644 index 0000000..aa0b0ed --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20260603000204.json @@ -0,0 +1,145 @@ +{ + "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?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "show_in_overview", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "2600667D140A2A846F9A848ACEFCADA1F1206950B38EF407B0BB13816E508A2A", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index e642d82..9b1faf5 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -159,6 +159,61 @@ defmodule Mv.Membership.CustomFieldValidationTest do end end + describe "join_description" do + test "persists join_description when set", %{actor: actor} do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "dsgvo_field", + value_type: :boolean, + join_description: "hereby I confirm the GDPR" + }) + |> Ash.create(actor: actor) + + assert custom_field.join_description == "hereby I confirm the GDPR" + end + + test "defaults to nil when not given", %{actor: actor} do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "no_join_desc", + value_type: :boolean + }) + |> Ash.create(actor: actor) + + assert custom_field.join_description == nil + end + + test "rejects join_description longer than 1000 characters", %{actor: actor} do + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "too_long_join_desc", + value_type: :boolean, + join_description: String.duplicate("a", 1001) + }) + |> Ash.create(actor: actor) + + assert [%{field: :join_description, message: message}] = changeset.errors + assert message =~ "max" or message =~ "length" or message =~ "1000" + end + + test "is writable via the update action", %{actor: actor} do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{name: "updatable", value_type: :boolean}) + |> Ash.create(actor: actor) + + assert {:ok, updated} = + custom_field + |> Ash.Changeset.for_update(:update, %{join_description: "Accept the GDPR"}) + |> Ash.update(actor: actor) + + assert updated.join_description == "Accept the GDPR" + end + end + describe "name uniqueness" do test "rejects duplicate names", %{actor: actor} do assert {:ok, _} = From cb5cb6848323d8d7bb00ac939fabe27a5d5a12e8 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 3 Jun 2026 12:06:48 +0200 Subject: [PATCH 62/79] feat(join): render join_description with auto-linked URLs and Markdown links --- .../helpers/join_description_renderer.ex | 70 +++++++++++++++ .../join_description_renderer_test.exs | 85 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 lib/mv_web/helpers/join_description_renderer.ex create mode 100644 test/mv_web/helpers/join_description_renderer_test.exs diff --git a/lib/mv_web/helpers/join_description_renderer.ex b/lib/mv_web/helpers/join_description_renderer.ex new file mode 100644 index 0000000..121b02b --- /dev/null +++ b/lib/mv_web/helpers/join_description_renderer.ex @@ -0,0 +1,70 @@ +defmodule MvWeb.Helpers.JoinDescriptionRenderer do + @moduledoc """ + Renders a custom field's `join_description` into Phoenix-safe HTML for the + public join form. + + The renderer auto-links two patterns into `` tags: + + - Markdown links of the form `[text](url)` (processed first) + - bare `http(s)://` URLs in the remaining text + + All other content is HTML-escaped: only `` tags are ever + emitted, so arbitrary HTML in the input is rendered as inert text. This is a + defense-in-depth measure — `join_description` is admin-set content, never + end-user input — but the renderer must not become a vector for injecting + arbitrary markup. + + Markdown links are matched before bare URLs and their matched region is + consumed, so a Markdown link whose URL also looks like a bare URL is linked + exactly once (no nested anchors). + """ + + @markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/ + @bare_url ~r/(https?:\/\/[^\s<]+)/ + @bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/ + + @doc """ + Converts `value` to a Phoenix-safe HTML iolist. + + Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with + links rendered and all other text HTML-escaped. + """ + @spec render(String.t() | nil) :: Phoenix.HTML.safe() + def render(nil), do: {:safe, ""} + + def render(value) when is_binary(value) do + {:safe, render_segments(value)} + end + + # Split on Markdown links first; for each non-Markdown segment, link bare URLs; + # everything that is not a link is HTML-escaped. + defp render_segments(text) do + Regex.split(@markdown_link, text, include_captures: true) + |> Enum.map(&render_markdown_or_plain/1) + end + + defp render_markdown_or_plain(segment) do + case Regex.run(@markdown_link, segment) do + [^segment, label, url] -> anchor(url, label) + _ -> render_plain(segment) + end + end + + # Auto-link bare URLs in a plain-text segment, escaping all surrounding text. + defp render_plain(segment) do + Regex.split(@bare_url, segment, include_captures: true) + |> Enum.map(fn part -> + if Regex.match?(@bare_url_anchored, part) do + anchor(part, part) + else + escape(part) + end + end) + end + + defp anchor(url, label) do + ["", escape(label), ""] + end + + defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string() +end diff --git a/test/mv_web/helpers/join_description_renderer_test.exs b/test/mv_web/helpers/join_description_renderer_test.exs new file mode 100644 index 0000000..62eb91d --- /dev/null +++ b/test/mv_web/helpers/join_description_renderer_test.exs @@ -0,0 +1,85 @@ +defmodule MvWeb.Helpers.JoinDescriptionRendererTest do + @moduledoc """ + Tests for the join-description renderer that auto-links raw URLs and Markdown + links while escaping all other content. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias MvWeb.Helpers.JoinDescriptionRenderer + + defp html(value) do + value + |> JoinDescriptionRenderer.render() + |> Phoenix.HTML.safe_to_string() + end + + describe "render/1" do + test "converts a raw URL to an anchor tag" do + result = html("Akzeptiere https://example.com/dsgvo") + + assert result =~ ~s(" + assert result =~ "Akzeptiere " + end + + test "converts Markdown [text](url) to an anchor tag with the link text" do + result = html("[Datenschutzerklärung](https://example.com/dsgvo)") + + assert result =~ ~s(Datenschutzerklärung" + end + + test "returns an empty safe string for nil input" do + assert JoinDescriptionRenderer.render(nil) == {:safe, ""} + end + + test "escapes arbitrary HTML in non-link text" do + result = html("") + + refute result =~ "