From 856ea4279ccb559f7de38ff6acf40bc8f48217d3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 8 Jun 2026 12:48:22 +0200 Subject: [PATCH] refactor(member): share Ash error formatting across member-show components --- lib/mv_web/helpers/ash_error_helpers.ex | 34 ++++++++++ .../member_live/show/deactivate_component.ex | 14 +---- .../show/membership_fees_component.ex | 12 +--- priv/gettext/de/LC_MESSAGES/default.po | 6 +- priv/gettext/default.pot | 6 +- priv/gettext/en/LC_MESSAGES/default.po | 6 +- .../mv_web/helpers/ash_error_helpers_test.exs | 63 +++++++++++++++++++ 7 files changed, 105 insertions(+), 36 deletions(-) create mode 100644 lib/mv_web/helpers/ash_error_helpers.ex create mode 100644 test/mv_web/helpers/ash_error_helpers_test.exs diff --git a/lib/mv_web/helpers/ash_error_helpers.ex b/lib/mv_web/helpers/ash_error_helpers.ex new file mode 100644 index 0000000..2b9f2ae --- /dev/null +++ b/lib/mv_web/helpers/ash_error_helpers.ex @@ -0,0 +1,34 @@ +defmodule MvWeb.Helpers.AshErrorHelpers do + @moduledoc """ + Shared formatting for Ash errors surfaced as flash messages in the + member show LiveComponents. + + Centralizes the translation of `Ash.Error.Invalid` / `Ash.Error.Forbidden` + (and plain string/unknown errors) into user-facing text so the components do + not each carry their own copy. + """ + use Gettext, backend: MvWeb.Gettext + + @doc """ + Turns an Ash error into a human-readable, localized string. + + - `Ash.Error.Invalid` — joins the individual error messages, falling back to + `inspect/1` for sub-errors that carry no `:message`. + - `Ash.Error.Forbidden` — a localized "not allowed" message. + - a binary — passed through unchanged (already a ready-to-show message). + - anything else — a localized generic error message. + """ + def format_error(%Ash.Error.Invalid{errors: errors}) do + Enum.map_join(errors, ", ", fn + %{message: message} -> message + other -> inspect(other) + end) + end + + def format_error(%Ash.Error.Forbidden{}) do + gettext("You are not allowed to perform this action.") + end + + def format_error(error) when is_binary(error), do: error + def format_error(_error), do: gettext("An error occurred") +end diff --git a/lib/mv_web/live/member_live/show/deactivate_component.ex b/lib/mv_web/live/member_live/show/deactivate_component.ex index d3a1dfe..dad6008 100644 --- a/lib/mv_web/live/member_live/show/deactivate_component.ex +++ b/lib/mv_web/live/member_live/show/deactivate_component.ex @@ -15,6 +15,7 @@ defmodule MvWeb.MemberLive.Show.DeactivateComponent do use MvWeb, :live_component import MvWeb.Authorization, only: [can?: 3] + import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1] alias Mv.Membership alias MvWeb.Helpers.MemberHelpers @@ -187,17 +188,4 @@ defmodule MvWeb.MemberLive.Show.DeactivateComponent do |> assign(:show_modal, false) |> assign(:error, nil) end - - defp format_error(%Ash.Error.Invalid{errors: errors}) do - Enum.map_join(errors, ", ", fn - %{message: message} -> message - other -> inspect(other) - end) - end - - defp format_error(%Ash.Error.Forbidden{}) do - gettext("You are not allowed to perform this action.") - end - - defp format_error(_error), do: gettext("An error occurred") end 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 33b0456..16ee5dc 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 @@ -15,6 +15,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do require Ash.Query import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization, only: [can?: 3] + import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1] alias Mv.Membership alias Mv.MembershipFees @@ -1144,17 +1145,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_status_label(:unpaid), do: gettext("Unpaid") defp format_status_label(:suspended), do: gettext("Suspended") - defp format_error(%Ash.Error.Invalid{} = error) do - Enum.map_join(error.errors, ", ", fn e -> e.message end) - end - - defp format_error(%Ash.Error.Forbidden{}) do - gettext("You are not allowed to perform this action.") - end - - defp format_error(error) when is_binary(error), do: error - defp format_error(_error), do: gettext("An error occurred") - defp validate_cycle_not_exists(cycles, cycle_start) do if Enum.any?(cycles, &(&1.cycle_start == cycle_start)) do {:error, :cycle_exists} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a5ffa81..38ad3e5 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -191,8 +191,7 @@ msgstr "Betrag" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen." -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex @@ -3932,8 +3931,7 @@ msgstr "Du hattest bereits einen offenen Antrag. Hier ist ein neuer Bestätigung msgid "You are about to delete all %{count} cycles for this member." msgstr "Du bist dabei, alle %{count} Zyklen für dieses Mitglied zu löschen." -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." msgstr "Du hast keine Berechtigung, diese Aktion auszuführen." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 91a111d..3c21f67 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -192,8 +192,7 @@ msgstr "" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex @@ -3932,8 +3931,7 @@ msgstr "" msgid "You are about to delete all %{count} cycles for this member." msgstr "" -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index d8a7fe9..cda87b5 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -192,8 +192,7 @@ msgstr "" msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/helpers.ex @@ -3932,8 +3931,7 @@ msgstr "You already had a pending request. Here is a new confirmation link." msgid "You are about to delete all %{count} cycles for this member." msgstr "" -#: lib/mv_web/live/member_live/show/deactivate_component.ex -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/helpers/ash_error_helpers.ex #, elixir-autogen, elixir-format msgid "You are not allowed to perform this action." msgstr "" diff --git a/test/mv_web/helpers/ash_error_helpers_test.exs b/test/mv_web/helpers/ash_error_helpers_test.exs new file mode 100644 index 0000000..921f07d --- /dev/null +++ b/test/mv_web/helpers/ash_error_helpers_test.exs @@ -0,0 +1,63 @@ +defmodule MvWeb.Helpers.AshErrorHelpersTest do + @moduledoc """ + Tests for format_error/1, the shared Ash error formatter used by the + member show LiveComponents. + """ + use Mv.DataCase, async: true + + import MvWeb.Helpers.AshErrorHelpers + + describe "format_error/1" do + test "joins messages of an Ash.Error.Invalid with commas" do + error = %Ash.Error.Invalid{ + errors: [%{message: "exit_date must be after join_date"}, %{message: "another"}] + } + + assert format_error(error) == "exit_date must be after join_date, another" + end + + test "falls back to inspect for invalid sub-errors without a message" do + error = %Ash.Error.Invalid{errors: [:boom]} + + assert format_error(error) == inspect(:boom) + end + + test "returns a localized message for an Ash.Error.Forbidden" do + assert format_error(%Ash.Error.Forbidden{}) =~ "allowed" + end + + test "passes through a plain binary unchanged" do + assert format_error("custom message") == "custom message" + end + + test "returns a generic localized message for anything else" do + assert format_error(:unexpected) == "An error occurred" + end + + test "renders the genuine update_member validation error as a user-facing message" do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{ + first_name: "Exit", + last_name: "Validation", + email: "exit-validation-#{System.unique_integer([:positive])}@example.com", + join_date: ~D[2024-01-01] + }, + actor: system_actor + ) + + # exit_date earlier than join_date triggers the resource validation + {:error, %Ash.Error.Invalid{} = error} = + Mv.Membership.update_member(member, %{exit_date: ~D[2023-12-31]}, actor: system_actor) + + formatted = format_error(error) + + # The real Ash sub-error must surface its localized :message, not an inspect()'d struct. + assert formatted == "cannot be before join date" + refute formatted =~ "InvalidAttribute" + refute formatted =~ "%{" + end + end +end