Merge branch 'main' into feature/concept-groups

This commit is contained in:
simon 2026-01-19 12:00:03 +01:00
commit 68baf71119
13 changed files with 709 additions and 187 deletions

View file

@ -181,6 +181,29 @@
padding-left: 14px;
}
/* ============================================
Menu Groups - Disable hover and active on expanded-menu-group header
============================================ */
/* Disable all interactive effects on expanded-menu-group header (no href, not clickable)
Using [role="group"] to increase specificity and avoid !important */
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a) {
pointer-events: none;
cursor: default;
}
/* Higher specificity selector to override DaisyUI menu hover styles
DaisyUI uses :where() which has 0 specificity, but the compiled CSS might have higher specificity
Using [role="group"] attribute selector increases specificity without !important */
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):hover,
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):active,
.sidebar .menu > li.expanded-menu-group > div[role="group"]:not(a):focus {
background-color: transparent;
box-shadow: none;
cursor: default;
color: inherit;
}
/* ============================================
Elements Only Visible in Expanded State
============================================ */
@ -217,7 +240,9 @@
- Menu has p-2 (8px), so links need 14px additional padding-left */
.sidebar .menu > li > a,
.sidebar .menu > li > button {
.sidebar .menu > li > button,
.sidebar .menu > li.expanded-menu-group > div,
.sidebar .menu > div.collapsed-menu-group > button {
@apply transition-all duration-300;
padding-left: 14px;
}
@ -226,12 +251,17 @@
- Remove gap so label (which is opacity-0 w-0) doesn't create space
- Keep padding-left at 14px so icons stay centered under logo */
[data-sidebar-expanded="false"] .sidebar .menu > li > a,
[data-sidebar-expanded="false"] .sidebar .menu > li > button {
[data-sidebar-expanded="false"] .sidebar .menu > li > button,
[data-sidebar-expanded="false"] .sidebar .menu > li.expanded-menu-group > div,
[data-sidebar-expanded="false"] .sidebar .menu > div.collapsed-menu-group > button {
@apply gap-0;
padding-left: 14px;
padding-right: 14px; /* Center icon horizontally in 64px sidebar */
}
/* ============================================
Footer Button Alignment - Left Aligned in Collapsed State
============================================ */

View file

@ -12,7 +12,7 @@ config :mv, Mv.Repo,
port: System.get_env("TEST_POSTGRES_PORT", "5000"),
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
pool_size: System.schedulers_online() * 4
# We don't run a server during test. If one is required,
# you can enable the server option below.

62
docs/email-validation.md Normal file
View file

@ -0,0 +1,62 @@
# Email Validation Strategy
We use `EctoCommons.EmailValidator` with both `:html_input` and `:pow` checks, defined centrally in `Mv.Constants.email_validator_checks/0`.
## Checks Used
- `:html_input` - Pragmatic validation matching browser `<input type="email">` behavior
- `:pow` - Stricter validation following email spec, supports internationalization (Unicode)
## Rationale
Using both checks ensures:
- **Compatibility with common email providers** (`:html_input`) - Matches what users expect from web forms
- **Compliance with email standards** (`:pow`) - Follows RFC 5322 and related specifications
- **Support for international email addresses** (`:pow`) - Allows Unicode characters in email addresses
This dual approach provides a balance between user experience (accepting common email formats) and technical correctness (validating against email standards).
## Usage
The checks are used consistently across all email validation points:
- `Mv.Membership.Import.MemberCSV.validate_row/3` - CSV import validation
- `Mv.Membership.Member` validations - Member resource validation
- `Mv.Accounts.User` validations - User resource validation
All three locations use `Mv.Constants.email_validator_checks()` to ensure consistency.
## Implementation Details
### CSV Import Validation
The CSV import uses a schemaless changeset for email validation:
```elixir
changeset =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: Map.get(member_attrs, :email)}, [:email])
|> Ecto.Changeset.update_change(:email, &String.trim/1)
|> Ecto.Changeset.validate_required([:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: Mv.Constants.email_validator_checks())
```
This approach:
- Trims whitespace before validation
- Validates email is required
- Validates email format using the centralized checks
- Provides consistent error messages via Gettext
### Resource Validations
Both `Member` and `User` resources use similar schemaless changesets within their Ash validations, ensuring consistent validation behavior across the application.
## Changing the Validation Strategy
To change the email validation checks, update the `@email_validator_checks` constant in `Mv.Constants`. This will automatically apply to all validation points.
**Note:** Changing the validation strategy may affect existing data. Consider:
- Whether existing emails will still be valid
- Migration strategy for invalid emails
- User communication if validation becomes stricter

View file

@ -290,7 +290,9 @@ defmodule Mv.Accounts.User do
changeset2 =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
|> EctoCommons.EmailValidator.validate_email(:email,
checks: Mv.Constants.email_validator_checks()
)
if changeset2.valid? do
:ok

View file

@ -453,7 +453,9 @@ defmodule Mv.Membership.Member do
changeset2 =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
|> EctoCommons.EmailValidator.validate_email(:email,
checks: Mv.Constants.email_validator_checks()
)
if changeset2.valid? do
:ok

View file

@ -19,6 +19,8 @@ defmodule Mv.Constants do
@custom_field_prefix "custom_field_"
@email_validator_checks [:html_input, :pow]
def member_fields, do: @member_fields
@doc """
@ -30,4 +32,23 @@ defmodule Mv.Constants do
"custom_field_"
"""
def custom_field_prefix, do: @custom_field_prefix
@doc """
Returns the email validator checks used for EctoCommons.EmailValidator.
We use both `:html_input` and `:pow` checks:
- `:html_input` - Pragmatic validation matching browser `<input type="email">` behavior
- `:pow` - Stricter validation following email spec, supports internationalization (Unicode)
Using both ensures:
- Compatibility with common email providers (html_input)
- Compliance with email standards (pow)
- Support for international email addresses (pow)
## Examples
iex> Mv.Constants.email_validator_checks()
[:html_input, :pow]
"""
def email_validator_checks, do: @email_validator_checks
end

View file

@ -76,6 +76,8 @@ defmodule Mv.Membership.Import.MemberCSV do
alias Mv.Membership.Import.CsvParser
alias Mv.Membership.Import.HeaderMapper
use Gettext, backend: MvWeb.Gettext
@doc """
Prepares CSV content for import by parsing, mapping headers, and validating limits.
@ -295,38 +297,157 @@ defmodule Mv.Membership.Import.MemberCSV do
{:ok, %{inserted: inserted, failed: failed, errors: Enum.reverse(errors)}}
end
@doc """
Validates a single CSV row before database insertion.
This function:
1. Trims all string values in the member map
2. Validates that email is present and not empty after trimming
3. Validates email format using EctoCommons.EmailValidator
4. Returns structured errors with Gettext-backed messages
## Parameters
- `row_map` - Map with `:member` and `:custom` keys containing field values
- `csv_line_number` - Physical line number in CSV (1-based, header is line 1)
- `opts` - Optional keyword list (for future extensions)
## Returns
- `{:ok, trimmed_row_map}` - Successfully validated row with trimmed values
- `{:error, %Error{}}` - Validation error with structured error information
## Examples
iex> row_map = %{member: %{email: " john@example.com "}, custom: %{}}
iex> MemberCSV.validate_row(row_map, 2, [])
{:ok, %{member: %{email: "john@example.com"}, custom: %{}}}
iex> row_map = %{member: %{}, custom: %{}}
iex> MemberCSV.validate_row(row_map, 3, [])
{:error, %MemberCSV.Error{csv_line_number: 3, field: :email, message: "Email is required."}}
"""
@spec validate_row(map(), pos_integer(), keyword()) ::
{:ok, map()} | {:error, Error.t()}
def validate_row(row_map, csv_line_number, _opts \\ []) do
# Safely get member map (handle missing key)
member_attrs = Map.get(row_map, :member, %{})
custom_attrs = Map.get(row_map, :custom, %{})
# Validate email using schemaless changeset
changeset =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: Map.get(member_attrs, :email)}, [:email])
|> Ecto.Changeset.update_change(:email, &String.trim/1)
|> Ecto.Changeset.validate_required([:email])
|> EctoCommons.EmailValidator.validate_email(:email,
checks: Mv.Constants.email_validator_checks()
)
if changeset.valid? do
# Apply trimmed email back to member_attrs
trimmed_email = Ecto.Changeset.get_change(changeset, :email)
trimmed_member = Map.put(member_attrs, :email, trimmed_email) |> trim_string_values()
{:ok, %{member: trimmed_member, custom: custom_attrs}}
else
# Extract first error
error = extract_changeset_error(changeset, csv_line_number)
{:error, error}
end
end
# Extracts the first error from a changeset and converts it to a MemberCSV.Error struct
defp extract_changeset_error(changeset, csv_line_number) do
case Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end) do
%{email: [message | _]} ->
# Email-specific error
%Error{
csv_line_number: csv_line_number,
field: :email,
message: gettext_error_message(message)
}
errors when map_size(errors) > 0 ->
# Get first error (any field)
{field, [message | _]} = Enum.at(Enum.to_list(errors), 0)
%Error{
csv_line_number: csv_line_number,
field: String.to_existing_atom(to_string(field)),
message: gettext_error_message(message)
}
_ ->
# Fallback
%Error{
csv_line_number: csv_line_number,
field: :email,
message: gettext("Email is invalid.")
}
end
end
# Maps changeset error messages to appropriate Gettext messages
defp gettext_error_message(message) when is_binary(message) do
cond do
String.contains?(String.downcase(message), "required") or
String.contains?(String.downcase(message), "can't be blank") ->
gettext("Email is required.")
String.contains?(String.downcase(message), "invalid") or
String.contains?(String.downcase(message), "not a valid") ->
gettext("Email is invalid.")
true ->
message
end
end
defp gettext_error_message(_), do: gettext("Email is invalid.")
# Processes a single row and creates member with custom field values
defp process_row(
%{member: member_attrs, custom: custom_attrs},
row_map,
line_number,
custom_field_lookup
) do
# Prepare custom field values for Ash
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup)
# Create member with custom field values
member_attrs_with_cf =
member_attrs
|> Map.put(:custom_field_values, custom_field_values)
|> trim_string_values()
# Only include custom_field_values if not empty
final_attrs =
if Enum.empty?(custom_field_values) do
Map.delete(member_attrs_with_cf, :custom_field_values)
else
member_attrs_with_cf
end
case Mv.Membership.create_member(final_attrs) do
{:ok, member} ->
{:ok, member}
{:error, %Ash.Error.Invalid{} = error} ->
{:error, format_ash_error(error, line_number)}
# Validate row before database insertion
case validate_row(row_map, line_number, []) do
{:error, error} ->
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
# Return validation error immediately, no DB insert attempted
{:error, error}
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
# Prepare custom field values for Ash
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup)
# Create member with custom field values
member_attrs_with_cf =
trimmed_member_attrs
|> Map.put(:custom_field_values, custom_field_values)
# Only include custom_field_values if not empty
final_attrs =
if Enum.empty?(custom_field_values) do
Map.delete(member_attrs_with_cf, :custom_field_values)
else
member_attrs_with_cf
end
case Mv.Membership.create_member(final_attrs) do
{:ok, member} ->
{:ok, member}
{:error, %Ash.Error.Invalid{} = error} ->
{:error, format_ash_error(error, line_number)}
{:error, error} ->
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
end
end
rescue
e ->

View file

@ -75,30 +75,23 @@ defmodule MvWeb.Layouts.Sidebar do
icon="hero-users"
label={gettext("Members")}
/>
<.menu_item
href={~p"/users"}
icon="hero-user-circle"
label={gettext("Users")}
/>
<.menu_item
href={~p"/custom_field_values"}
icon="hero-rectangle-group"
label={gettext("Custom Fields")}
/>
<!-- Nested Menu: Contributions -->
<.menu_group
icon="hero-currency-dollar"
label={gettext("Contributions")}
>
<.menu_subitem href="/contribution_types" label={gettext("Contribution Types")} />
<.menu_subitem href="/membership_fee_settings" label={gettext("Settings")} />
</.menu_group>
<.menu_item
href={~p"/settings"}
icon="hero-cog-6-tooth"
label={gettext("Settings")}
href={~p"/membership_fee_types"}
icon="hero-currency-euro"
label={gettext("Fee Types")}
/>
<!-- Nested Admin Menu -->
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
<.menu_subitem
href={~p"/membership_fee_settings"}
label={gettext("Fee Settings")}
/>
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
</.menu_group>
</ul>
"""
end
@ -129,43 +122,41 @@ defmodule MvWeb.Layouts.Sidebar do
defp menu_group(assigns) do
~H"""
<li role="none" class="menu-group">
<!-- Expanded Mode: Details/Summary -->
<details class="expanded-menu-group">
<summary
class="flex items-center gap-3 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
role="menuitem"
aria-haspopup="true"
>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="menu-label">{@label}</span>
</summary>
<ul role="menu" class="ml-4">
{render_slot(@inner_block)}
</ul>
</details>
<!-- Collapsed Mode: Dropdown -->
<div class="collapsed-menu-group dropdown dropdown-right">
<button
type="button"
tabindex="0"
class="flex items-center w-full p-2 rounded-lg hover:bg-base-300 tooltip tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
data-tip={@label}
aria-haspopup="menu"
aria-label={@label}
>
<.icon name={@icon} class="size-5" aria-hidden="true" />
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-50 min-w-48 p-2 focus:outline-none"
role="menu"
>
<li class="menu-title">{@label}</li>
{render_slot(@inner_block)}
</ul>
<!-- Expanded Mode: Always open div structure -->
<li role="none" class="expanded-menu-group">
<div
class="flex items-center gap-3"
role="group"
aria-label={@label}
>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="menu-label">{@label}</span>
</div>
<ul role="menu" class="ml-4">
{render_slot(@inner_block)}
</ul>
</li>
<!-- Collapsed Mode: Dropdown -->
<div class="collapsed-menu-group dropdown dropdown-right">
<button
type="button"
tabindex="0"
class="flex items-center gap-3 px-2 py-1.5 rounded-selector hover:bg-base-300 tooltip tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 cursor-pointer"
role="menuitem"
data-tip={@label}
aria-haspopup="menu"
aria-label={@label}
>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
</button>
<ul
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-50 min-w-48 p-2 focus:outline-none"
role="menu"
>
<li class="menu-title">{@label}</li>
{render_slot(@inner_block)}
</ul>
</div>
"""
end

View file

@ -631,7 +631,6 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -915,7 +914,6 @@ msgstr "Beitragsart ändern"
msgid "Contribution Start"
msgstr "Beitragsbeginn"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Types"
@ -926,11 +924,6 @@ msgstr "Beitragsarten"
msgid "Contribution type"
msgstr "Beitragsart"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions"
msgstr "Beiträge"
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions for %{name}"
@ -2188,3 +2181,38 @@ msgstr "Mitglied wurde erfolgreich erstellt"
#, elixir-autogen, elixir-format
msgid "Member updated successfully"
msgstr "Mitglied wurde erfolgreich aktualisiert"
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Email is invalid."
msgstr "E-Mail ist ungültig."
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr "E-Mail ist erforderlich."
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Roles"
msgstr "Rollen"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Fee Settings"
msgstr "Beitragseinstellungen"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Fee Types"
msgstr "Beitragstypen"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Administration"
msgstr "Administration"
#~ #: lib/mv_web/components/layouts/sidebar.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Contributions"
#~ msgstr "Beiträge"

View file

@ -632,7 +632,6 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -916,7 +915,6 @@ msgstr ""
msgid "Contribution Start"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution Types"
@ -927,11 +925,6 @@ msgstr ""
msgid "Contribution type"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contributions for %{name}"
@ -2189,3 +2182,32 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Member updated successfully"
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Email is invalid."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Roles"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Fee Settings"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Fee Types"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Administration"
msgstr ""

View file

@ -632,7 +632,6 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
@ -916,7 +915,6 @@ msgstr ""
msgid "Contribution Start"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Contribution Types"
@ -927,11 +925,6 @@ msgstr ""
msgid "Contribution type"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Contributions"
msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format
msgid "Contributions for %{name}"
@ -2189,3 +2182,43 @@ msgstr "Member created successfully"
#, elixir-autogen, elixir-format
msgid "Member updated successfully"
msgstr "Member updated successfully"
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Email is invalid."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Roles"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Fee Settings"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Fee Types"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Administration"
msgstr ""
#~ #: lib/mv_web/components/layouts/sidebar.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Admin"
#~ msgstr ""
#~ #: lib/mv_web/components/layouts/sidebar.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Contributions"
#~ msgstr ""

View file

@ -124,7 +124,52 @@ defmodule Mv.Membership.Import.MemberCSVTest do
error = List.first(chunk_result.errors)
assert error.csv_line_number == 2
assert error.field == :email
assert error.message =~ "email"
# Error message should come from validate_row (Gettext-backed)
assert is_binary(error.message)
assert error.message != ""
end
test "returns error for missing email" do
chunk_rows_with_lines = [
{2, %{member: %{}, custom: %{}}}
]
column_map = %{}
custom_field_map = %{}
opts = []
assert {:ok, chunk_result} =
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
assert chunk_result.inserted == 0
assert chunk_result.failed == 1
assert length(chunk_result.errors) == 1
error = List.first(chunk_result.errors)
assert error.csv_line_number == 2
assert error.field == :email
assert is_binary(error.message)
end
test "returns error for whitespace-only email" do
chunk_rows_with_lines = [
{3, %{member: %{email: " "}, custom: %{}}}
]
column_map = %{email: 0}
custom_field_map = %{}
opts = []
assert {:ok, chunk_result} =
MemberCSV.process_chunk(chunk_rows_with_lines, column_map, custom_field_map, opts)
assert chunk_result.inserted == 0
assert chunk_result.failed == 1
assert length(chunk_result.errors) == 1
error = List.first(chunk_result.errors)
assert error.csv_line_number == 3
assert error.field == :email
end
test "returns error for duplicate email" do
@ -218,6 +263,9 @@ defmodule Mv.Membership.Import.MemberCSVTest do
error = List.first(chunk_result.errors)
assert error.csv_line_number == 3
assert error.field == :email
# Error should come from validate_row, not from DB insert
assert is_binary(error.message)
end
test "preserves CSV line numbers in errors" do
@ -279,6 +327,147 @@ defmodule Mv.Membership.Import.MemberCSVTest do
end
end
describe "validate_row/3" do
test "returns error when email is missing" do
row_map = %{member: %{}, custom: %{}}
csv_line_number = 5
opts = []
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert %MemberCSV.Error{} = error
assert error.csv_line_number == 5
assert error.field == :email
assert error.message != nil
assert error.message != ""
end
test "returns error when email is only whitespace" do
row_map = %{member: %{email: " "}, custom: %{}}
csv_line_number = 3
opts = []
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert %MemberCSV.Error{} = error
assert error.csv_line_number == 3
assert error.field == :email
assert error.message != nil
end
test "returns error when email is nil" do
row_map = %{member: %{email: nil}, custom: %{}}
csv_line_number = 7
opts = []
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert %MemberCSV.Error{} = error
assert error.csv_line_number == 7
assert error.field == :email
end
test "returns error when email format is invalid" do
row_map = %{member: %{email: "invalid-email"}, custom: %{}}
csv_line_number = 4
opts = []
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert %MemberCSV.Error{} = error
assert error.csv_line_number == 4
assert error.field == :email
assert error.message != nil
end
test "returns {:ok, trimmed_row_map} when email is valid with whitespace" do
row_map = %{member: %{email: " john@example.com "}, custom: %{}}
csv_line_number = 2
opts = []
assert {:ok, trimmed_row_map} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert trimmed_row_map.member.email == "john@example.com"
end
test "returns {:ok, trimmed_row_map} when email is valid without whitespace" do
row_map = %{member: %{email: "john@example.com"}, custom: %{}}
csv_line_number = 2
opts = []
assert {:ok, trimmed_row_map} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert trimmed_row_map.member.email == "john@example.com"
end
test "trims all string values in member map" do
row_map = %{
member: %{
email: " john@example.com ",
first_name: " John ",
last_name: " Doe "
},
custom: %{}
}
csv_line_number = 2
opts = []
assert {:ok, trimmed_row_map} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert trimmed_row_map.member.email == "john@example.com"
assert trimmed_row_map.member.first_name == "John"
assert trimmed_row_map.member.last_name == "Doe"
end
test "preserves custom map unchanged" do
row_map = %{
member: %{email: "john@example.com"},
custom: %{"field1" => "value1"}
}
csv_line_number = 2
opts = []
assert {:ok, trimmed_row_map} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert trimmed_row_map.custom == %{"field1" => "value1"}
end
test "uses Gettext for error messages" do
row_map = %{member: %{}, custom: %{}}
csv_line_number = 5
opts = []
# Test with default locale (should work)
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert is_binary(error.message)
# Test with German locale
Gettext.put_locale(MvWeb.Gettext, "de")
assert {:error, error_de} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert is_binary(error_de.message)
# Test with English locale
Gettext.put_locale(MvWeb.Gettext, "en")
assert {:error, error_en} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert is_binary(error_en.message)
# Reset to default
Gettext.put_locale(MvWeb.Gettext, "en")
end
test "handles empty opts gracefully" do
row_map = %{member: %{email: "john@example.com"}, custom: %{}}
csv_line_number = 2
opts = []
assert {:ok, _} = MemberCSV.validate_row(row_map, csv_line_number, opts)
end
test "handles missing member key gracefully" do
row_map = %{custom: %{}}
csv_line_number = 3
opts = []
assert {:error, error} = MemberCSV.validate_row(row_map, csv_line_number, opts)
assert %MemberCSV.Error{} = error
assert error.csv_line_number == 3
end
end
describe "module documentation" do
test "module has @moduledoc" do
# Check that the module exists and has documentation

View file

@ -122,35 +122,34 @@ defmodule MvWeb.Layouts.SidebarTest do
test "T2.2: does not render menu items when current_user is nil" do
html = render_sidebar(guest_assigns())
# Navigation links should not be rendered
refute html =~ ~s(href="/members")
refute html =~ ~s(href="/users")
refute html =~ ~s(href="/settings")
refute html =~ ~s(href="/contribution_types")
# Navigation menu should not be rendered
refute html =~ ~s(role="menubar")
refute html =~ ~s(role="menuitem")
# Footer section should not be rendered
refute html =~ "locale-select"
refute html =~ "theme-controller"
refute html =~ "locale-select"
end
test "T2.3: renders menu items when current_user is present" do
html = render_sidebar(authenticated_assigns())
# Check for Members link
assert html =~ ~s(href="/members")
# Check that menu structure exists
assert html =~ ~s(role="menubar")
assert html =~ ~s(role="menuitem")
# Check for Users link
assert html =~ ~s(href="/users")
# Check that top-level menu items exist (at least one)
# Count menu items with tooltips (top-level items have tooltips)
menu_item_count = html |> String.split("data-tip=") |> length() |> Kernel.-(1)
assert menu_item_count > 0, "Should have at least one top-level menu item"
# Check for Custom Fields link
assert html =~ ~s(href="/custom_field_values")
# Check that nested menu groups exist
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~ ~s(role="group")
assert has_class?(html, "expanded-menu-group")
# Check for Contributions section
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
# Check for Settings link (placeholder)
assert html =~ ~s(href="/settings")
# Check that nested menu items exist
assert html =~ ~s(role="menu")
end
test "T2.4: renders sidebar with main-sidebar ID" do
@ -174,51 +173,59 @@ defmodule MvWeb.Layouts.SidebarTest do
test "T3.1: renders flat menu items with icons and labels" do
html = render_sidebar(authenticated_assigns())
# Check for Members link with icon
assert html =~ ~s(href="/members")
assert html =~ "hero-users"
# Check for Users link with icon
assert html =~ ~s(href="/users")
assert html =~ "hero-user-circle"
# Check for Custom Fields link with icon
assert html =~ ~s(href="/custom_field_values")
assert html =~ "hero-rectangle-group"
# Check for Settings link with icon
assert html =~ ~s(href="/settings")
assert html =~ "hero-cog-6-tooth"
# Check for tooltips (data-tip attribute)
# Check that top-level menu items have structure
# Top-level items have tooltips
assert html =~ "data-tip="
assert has_class?(html, "tooltip")
assert has_class?(html, "tooltip-right")
# Check that menu items have icons (hero-* classes)
assert html =~ ~r/hero-\w+/
# Check that menu items have labels
assert has_class?(html, "menu-label")
# Check that menu items have links
assert html =~ ~s(role="menuitem")
end
test "T3.2: renders nested menu with details element for expanded state" do
html = render_sidebar(authenticated_assigns())
# Check for Contributions section structure with details
assert html =~ "<details"
# Check for nested menu structure
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~ ~s(role="group")
assert html =~ ~s(aria-label="Administration")
assert has_class?(html, "expanded-menu-group")
# Check for contribution links
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
# Check that nested menu has subitems
assert html =~ ~s(role="menu")
# Check that subitems exist (at least one link in nested menu)
# Submenu items have role="menuitem" but no data-tip attribute
# (Top-level items have data-tip, nested items don't)
# Count menuitems vs data-tips - nested items don't have data-tip
menuitem_count = html |> String.split(~s(role="menuitem")) |> length() |> Kernel.-(1)
data_tip_count = html |> String.split("data-tip=") |> length() |> Kernel.-(1)
# There should be more menuitems than data-tips (nested items don't have data-tip)
assert menuitem_count > data_tip_count,
"Should have nested menu items (menuitems without data-tip)"
end
test "T3.3: renders nested menu with dropdown for collapsed state" do
html = render_sidebar(authenticated_assigns())
# Check for collapsed dropdown container
# Check for collapsed dropdown structure
assert has_class?(html, "collapsed-menu-group")
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-right")
# Check for dropdown-content
assert has_class?(html, "dropdown-content")
# Check for icon button
assert html =~ "hero-currency-dollar"
# Check that dropdown button has icon (any hero icon)
assert html =~ ~r/hero-\w+/
# Check ARIA attributes
assert html =~ ~s(aria-haspopup="menu")
end
end
@ -346,8 +353,9 @@ defmodule MvWeb.Layouts.SidebarTest do
test "T5.4: nested menu has correct ARIA attributes" do
html = render_sidebar(authenticated_assigns())
# Details summary should have haspopup
assert html =~ ~s(aria-haspopup="true")
# Expanded mode should have role="group" with aria-label
assert html =~ ~s(role="group")
assert html =~ ~s(aria-label="Administration")
# Dropdown button should have haspopup
assert html =~ ~s(aria-haspopup="menu")
@ -414,17 +422,17 @@ defmodule MvWeb.Layouts.SidebarTest do
test "T7.1: renders hero icons for menu items" do
html = render_sidebar(authenticated_assigns())
# Check for hero icons
assert html =~ "hero-users"
assert html =~ "hero-user-circle"
assert html =~ "hero-rectangle-group"
assert html =~ "hero-currency-dollar"
assert html =~ "hero-cog-6-tooth"
# Check that hero icons are present (pattern matching)
assert html =~ ~r/hero-\w+/
# Check that icons have aria-hidden
assert html =~ ~s(aria-hidden="true")
# Check for specific structural icons (toggle, theme) that should always exist
assert html =~ "hero-chevron-left"
assert html =~ "hero-chevron-right"
# Icons should have aria-hidden
assert html =~ ~s(aria-hidden="true")
assert html =~ "hero-sun"
assert html =~ "hero-moon"
end
test "T7.2: renders icons for theme toggle" do
@ -503,26 +511,25 @@ defmodule MvWeb.Layouts.SidebarTest do
# Header section
assert html =~ "Mila Logo"
assert html =~ ~s(src="/images/mila.svg")
# Navigation section
assert html =~ ~s(role="menubar")
assert html =~ ~s(id="main-sidebar")
# Check that menu has items (at least one top-level item)
assert html =~ ~s(role="menuitem")
# Check that nested menus exist
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~ ~s(role="group")
# Footer section
assert html =~ "theme-controller"
assert html =~ ~s(action="/set_locale")
# All expected links
expected_links = [
"/members",
"/users",
"/custom_field_values",
"/contribution_types",
"/membership_fee_settings",
"/sign-out"
]
for link <- expected_links do
assert html =~ ~s(href="#{link}"), "Missing link: #{link}"
end
# Check that critical navigation exists (at least /members)
assert html =~ ~s(href="/members"), "Critical /members route should exist"
end
end
@ -621,9 +628,10 @@ defmodule MvWeb.Layouts.SidebarTest do
test "renders expanded menu group" do
html = render_sidebar(authenticated_assigns())
# details/summary present
assert html =~ "<details"
assert html =~ "<summary"
# expanded-menu-group structure present
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~ ~s(role="group")
assert html =~ ~s(aria-label="Administration")
assert has_class?(html, "expanded-menu-group")
end
@ -639,9 +647,21 @@ defmodule MvWeb.Layouts.SidebarTest do
test "renders submenu items" do
html = render_sidebar(authenticated_assigns())
# Inner_block items rendered
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
# Check that nested menu structure exists
assert html =~ ~s(role="menu")
# Check that subitems are rendered (links within nested menu)
# Submenu items have role="menuitem" but no data-tip attribute
# (Top-level items have data-tip, nested items don't)
# Count menuitems vs data-tips - nested items don't have data-tip
menuitem_count = html |> String.split(~s(role="menuitem")) |> length() |> Kernel.-(1)
data_tip_count = html |> String.split("data-tip=") |> length() |> Kernel.-(1)
# There should be more menuitems than data-tips (nested items don't have data-tip)
assert menuitem_count > data_tip_count,
"Should have nested menu items (menuitems without data-tip)"
# Verify nested menu structure exists
assert html =~ ~s(role="menu")
end
end
@ -821,9 +841,10 @@ defmodule MvWeb.Layouts.SidebarTest do
assert has_class?(html, "expanded-menu-group")
assert has_class?(html, "collapsed-menu-group")
# Details element should not have duplicate hover classes
# (CSS handles this, but we verify structure)
assert html =~ "<details"
# Expanded menu group should have correct structure
# (CSS handles hover effects, but we verify structure)
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
assert html =~ ~s(role="group")
end
test "tooltips only visible when collapsed" do