Merge branch 'main' into feature/concept-groups
This commit is contained in:
commit
68baf71119
13 changed files with 709 additions and 187 deletions
|
|
@ -181,6 +181,29 @@
|
||||||
padding-left: 14px;
|
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
|
Elements Only Visible in Expanded State
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
@ -217,7 +240,9 @@
|
||||||
- Menu has p-2 (8px), so links need 14px additional padding-left */
|
- Menu has p-2 (8px), so links need 14px additional padding-left */
|
||||||
|
|
||||||
.sidebar .menu > li > a,
|
.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;
|
@apply transition-all duration-300;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
@ -226,12 +251,17 @@
|
||||||
- Remove gap so label (which is opacity-0 w-0) doesn't create space
|
- 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 */
|
- 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 > 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;
|
@apply gap-0;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
padding-right: 14px; /* Center icon horizontally in 64px sidebar */
|
padding-right: 14px; /* Center icon horizontally in 64px sidebar */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Footer Button Alignment - Left Aligned in Collapsed State
|
Footer Button Alignment - Left Aligned in Collapsed State
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ config :mv, Mv.Repo,
|
||||||
port: System.get_env("TEST_POSTGRES_PORT", "5000"),
|
port: System.get_env("TEST_POSTGRES_PORT", "5000"),
|
||||||
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
|
database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||||
pool: Ecto.Adapters.SQL.Sandbox,
|
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,
|
# We don't run a server during test. If one is required,
|
||||||
# you can enable the server option below.
|
# you can enable the server option below.
|
||||||
|
|
|
||||||
62
docs/email-validation.md
Normal file
62
docs/email-validation.md
Normal 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
|
||||||
|
|
||||||
|
|
@ -290,7 +290,9 @@ defmodule Mv.Accounts.User do
|
||||||
changeset2 =
|
changeset2 =
|
||||||
{%{}, %{email: :string}}
|
{%{}, %{email: :string}}
|
||||||
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|
|> 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
|
if changeset2.valid? do
|
||||||
:ok
|
:ok
|
||||||
|
|
|
||||||
|
|
@ -453,7 +453,9 @@ defmodule Mv.Membership.Member do
|
||||||
changeset2 =
|
changeset2 =
|
||||||
{%{}, %{email: :string}}
|
{%{}, %{email: :string}}
|
||||||
|> Ecto.Changeset.cast(%{email: email}, [:email])
|
|> 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
|
if changeset2.valid? do
|
||||||
:ok
|
:ok
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@custom_field_prefix "custom_field_"
|
@custom_field_prefix "custom_field_"
|
||||||
|
|
||||||
|
@email_validator_checks [:html_input, :pow]
|
||||||
|
|
||||||
def member_fields, do: @member_fields
|
def member_fields, do: @member_fields
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -30,4 +32,23 @@ defmodule Mv.Constants do
|
||||||
"custom_field_"
|
"custom_field_"
|
||||||
"""
|
"""
|
||||||
def custom_field_prefix, do: @custom_field_prefix
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
alias Mv.Membership.Import.CsvParser
|
alias Mv.Membership.Import.CsvParser
|
||||||
alias Mv.Membership.Import.HeaderMapper
|
alias Mv.Membership.Import.HeaderMapper
|
||||||
|
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Prepares CSV content for import by parsing, mapping headers, and validating limits.
|
Prepares CSV content for import by parsing, mapping headers, and validating limits.
|
||||||
|
|
||||||
|
|
@ -295,20 +297,138 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
{:ok, %{inserted: inserted, failed: failed, errors: Enum.reverse(errors)}}
|
{:ok, %{inserted: inserted, failed: failed, errors: Enum.reverse(errors)}}
|
||||||
end
|
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
|
# Processes a single row and creates member with custom field values
|
||||||
defp process_row(
|
defp process_row(
|
||||||
%{member: member_attrs, custom: custom_attrs},
|
row_map,
|
||||||
line_number,
|
line_number,
|
||||||
custom_field_lookup
|
custom_field_lookup
|
||||||
) do
|
) do
|
||||||
|
# Validate row before database insertion
|
||||||
|
case validate_row(row_map, line_number, []) do
|
||||||
|
{:error, 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
|
# Prepare custom field values for Ash
|
||||||
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup)
|
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup)
|
||||||
|
|
||||||
# Create member with custom field values
|
# Create member with custom field values
|
||||||
member_attrs_with_cf =
|
member_attrs_with_cf =
|
||||||
member_attrs
|
trimmed_member_attrs
|
||||||
|> Map.put(:custom_field_values, custom_field_values)
|
|> Map.put(:custom_field_values, custom_field_values)
|
||||||
|> trim_string_values()
|
|
||||||
|
|
||||||
# Only include custom_field_values if not empty
|
# Only include custom_field_values if not empty
|
||||||
final_attrs =
|
final_attrs =
|
||||||
|
|
@ -328,6 +448,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
|
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
rescue
|
rescue
|
||||||
e ->
|
e ->
|
||||||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
||||||
|
|
|
||||||
|
|
@ -75,30 +75,23 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
icon="hero-users"
|
icon="hero-users"
|
||||||
label={gettext("Members")}
|
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
|
<.menu_item
|
||||||
href={~p"/settings"}
|
href={~p"/membership_fee_types"}
|
||||||
icon="hero-cog-6-tooth"
|
icon="hero-currency-euro"
|
||||||
label={gettext("Settings")}
|
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>
|
</ul>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -129,35 +122,34 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
|
|
||||||
defp menu_group(assigns) do
|
defp menu_group(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<li role="none" class="menu-group">
|
<!-- Expanded Mode: Always open div structure -->
|
||||||
<!-- Expanded Mode: Details/Summary -->
|
<li role="none" class="expanded-menu-group">
|
||||||
<details class="expanded-menu-group">
|
<div
|
||||||
<summary
|
class="flex items-center gap-3"
|
||||||
class="flex items-center gap-3 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
role="group"
|
||||||
role="menuitem"
|
aria-label={@label}
|
||||||
aria-haspopup="true"
|
|
||||||
>
|
>
|
||||||
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
|
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
|
||||||
<span class="menu-label">{@label}</span>
|
<span class="menu-label">{@label}</span>
|
||||||
</summary>
|
</div>
|
||||||
<ul role="menu" class="ml-4">
|
<ul role="menu" class="ml-4">
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</li>
|
||||||
<!-- Collapsed Mode: Dropdown -->
|
<!-- Collapsed Mode: Dropdown -->
|
||||||
<div class="collapsed-menu-group dropdown dropdown-right">
|
<div class="collapsed-menu-group dropdown dropdown-right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
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"
|
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}
|
data-tip={@label}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-label={@label}
|
aria-label={@label}
|
||||||
>
|
>
|
||||||
<.icon name={@icon} class="size-5" aria-hidden="true" />
|
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
|
||||||
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-50 min-w-48 p-2 focus:outline-none"
|
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-50 min-w-48 p-2 focus:outline-none"
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
|
|
@ -165,7 +157,6 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -631,7 +631,6 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
|
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/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -915,7 +914,6 @@ msgstr "Beitragsart ändern"
|
||||||
msgid "Contribution Start"
|
msgid "Contribution Start"
|
||||||
msgstr "Beitragsbeginn"
|
msgstr "Beitragsbeginn"
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Contribution Types"
|
msgid "Contribution Types"
|
||||||
|
|
@ -926,11 +924,6 @@ msgstr "Beitragsarten"
|
||||||
msgid "Contribution type"
|
msgid "Contribution type"
|
||||||
msgstr "Beitragsart"
|
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
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Contributions for %{name}"
|
msgid "Contributions for %{name}"
|
||||||
|
|
@ -2188,3 +2181,38 @@ msgstr "Mitglied wurde erfolgreich erstellt"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member updated successfully"
|
msgid "Member updated successfully"
|
||||||
msgstr "Mitglied wurde erfolgreich aktualisiert"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -632,7 +632,6 @@ msgstr ""
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -916,7 +915,6 @@ msgstr ""
|
||||||
msgid "Contribution Start"
|
msgid "Contribution Start"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contribution Types"
|
msgid "Contribution Types"
|
||||||
|
|
@ -927,11 +925,6 @@ msgstr ""
|
||||||
msgid "Contribution type"
|
msgid "Contribution type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Contributions"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contributions for %{name}"
|
msgid "Contributions for %{name}"
|
||||||
|
|
@ -2189,3 +2182,32 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member updated successfully"
|
msgid "Member updated successfully"
|
||||||
msgstr ""
|
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 ""
|
||||||
|
|
|
||||||
|
|
@ -632,7 +632,6 @@ msgstr ""
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -916,7 +915,6 @@ msgstr ""
|
||||||
msgid "Contribution Start"
|
msgid "Contribution Start"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contribution Types"
|
msgid "Contribution Types"
|
||||||
|
|
@ -927,11 +925,6 @@ msgstr ""
|
||||||
msgid "Contribution type"
|
msgid "Contribution type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Contributions"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contributions for %{name}"
|
msgid "Contributions for %{name}"
|
||||||
|
|
@ -2189,3 +2182,43 @@ msgstr "Member created successfully"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member updated successfully"
|
msgid "Member updated successfully"
|
||||||
msgstr "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 ""
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,52 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
error = List.first(chunk_result.errors)
|
error = List.first(chunk_result.errors)
|
||||||
assert error.csv_line_number == 2
|
assert error.csv_line_number == 2
|
||||||
assert error.field == :email
|
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
|
end
|
||||||
|
|
||||||
test "returns error for duplicate email" do
|
test "returns error for duplicate email" do
|
||||||
|
|
@ -218,6 +263,9 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
|
|
||||||
error = List.first(chunk_result.errors)
|
error = List.first(chunk_result.errors)
|
||||||
assert error.csv_line_number == 3
|
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
|
end
|
||||||
|
|
||||||
test "preserves CSV line numbers in errors" do
|
test "preserves CSV line numbers in errors" do
|
||||||
|
|
@ -279,6 +327,147 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
||||||
end
|
end
|
||||||
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
|
describe "module documentation" do
|
||||||
test "module has @moduledoc" do
|
test "module has @moduledoc" do
|
||||||
# Check that the module exists and has documentation
|
# Check that the module exists and has documentation
|
||||||
|
|
|
||||||
|
|
@ -122,35 +122,34 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
test "T2.2: does not render menu items when current_user is nil" do
|
test "T2.2: does not render menu items when current_user is nil" do
|
||||||
html = render_sidebar(guest_assigns())
|
html = render_sidebar(guest_assigns())
|
||||||
|
|
||||||
# Navigation links should not be rendered
|
# Navigation menu should not be rendered
|
||||||
refute html =~ ~s(href="/members")
|
refute html =~ ~s(role="menubar")
|
||||||
refute html =~ ~s(href="/users")
|
refute html =~ ~s(role="menuitem")
|
||||||
refute html =~ ~s(href="/settings")
|
|
||||||
refute html =~ ~s(href="/contribution_types")
|
|
||||||
|
|
||||||
# Footer section should not be rendered
|
# Footer section should not be rendered
|
||||||
refute html =~ "locale-select"
|
|
||||||
refute html =~ "theme-controller"
|
refute html =~ "theme-controller"
|
||||||
|
refute html =~ "locale-select"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "T2.3: renders menu items when current_user is present" do
|
test "T2.3: renders menu items when current_user is present" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Check for Members link
|
# Check that menu structure exists
|
||||||
assert html =~ ~s(href="/members")
|
assert html =~ ~s(role="menubar")
|
||||||
|
assert html =~ ~s(role="menuitem")
|
||||||
|
|
||||||
# Check for Users link
|
# Check that top-level menu items exist (at least one)
|
||||||
assert html =~ ~s(href="/users")
|
# 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
|
# Check that nested menu groups exist
|
||||||
assert html =~ ~s(href="/custom_field_values")
|
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
|
# Check that nested menu items exist
|
||||||
assert html =~ ~s(href="/contribution_types")
|
assert html =~ ~s(role="menu")
|
||||||
assert html =~ ~s(href="/membership_fee_settings")
|
|
||||||
|
|
||||||
# Check for Settings link (placeholder)
|
|
||||||
assert html =~ ~s(href="/settings")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "T2.4: renders sidebar with main-sidebar ID" do
|
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
|
test "T3.1: renders flat menu items with icons and labels" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Check for Members link with icon
|
# Check that top-level menu items have structure
|
||||||
assert html =~ ~s(href="/members")
|
# Top-level items have tooltips
|
||||||
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)
|
|
||||||
assert html =~ "data-tip="
|
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
|
end
|
||||||
|
|
||||||
test "T3.2: renders nested menu with details element for expanded state" do
|
test "T3.2: renders nested menu with details element for expanded state" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Check for Contributions section structure with details
|
# Check for nested menu structure
|
||||||
assert html =~ "<details"
|
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")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
||||||
# Check for contribution links
|
# Check that nested menu has subitems
|
||||||
assert html =~ ~s(href="/contribution_types")
|
assert html =~ ~s(role="menu")
|
||||||
assert html =~ ~s(href="/membership_fee_settings")
|
|
||||||
|
# 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
|
end
|
||||||
|
|
||||||
test "T3.3: renders nested menu with dropdown for collapsed state" do
|
test "T3.3: renders nested menu with dropdown for collapsed state" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
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, "collapsed-menu-group")
|
||||||
assert has_class?(html, "dropdown")
|
assert has_class?(html, "dropdown")
|
||||||
assert has_class?(html, "dropdown-right")
|
assert has_class?(html, "dropdown-right")
|
||||||
|
|
||||||
# Check for dropdown-content
|
|
||||||
assert has_class?(html, "dropdown-content")
|
assert has_class?(html, "dropdown-content")
|
||||||
|
|
||||||
# Check for icon button
|
# Check that dropdown button has icon (any hero icon)
|
||||||
assert html =~ "hero-currency-dollar"
|
assert html =~ ~r/hero-\w+/
|
||||||
|
|
||||||
|
# Check ARIA attributes
|
||||||
assert html =~ ~s(aria-haspopup="menu")
|
assert html =~ ~s(aria-haspopup="menu")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -346,8 +353,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
test "T5.4: nested menu has correct ARIA attributes" do
|
test "T5.4: nested menu has correct ARIA attributes" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Details summary should have haspopup
|
# Expanded mode should have role="group" with aria-label
|
||||||
assert html =~ ~s(aria-haspopup="true")
|
assert html =~ ~s(role="group")
|
||||||
|
assert html =~ ~s(aria-label="Administration")
|
||||||
|
|
||||||
# Dropdown button should have haspopup
|
# Dropdown button should have haspopup
|
||||||
assert html =~ ~s(aria-haspopup="menu")
|
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
|
test "T7.1: renders hero icons for menu items" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Check for hero icons
|
# Check that hero icons are present (pattern matching)
|
||||||
assert html =~ "hero-users"
|
assert html =~ ~r/hero-\w+/
|
||||||
assert html =~ "hero-user-circle"
|
|
||||||
assert html =~ "hero-rectangle-group"
|
# Check that icons have aria-hidden
|
||||||
assert html =~ "hero-currency-dollar"
|
assert html =~ ~s(aria-hidden="true")
|
||||||
assert html =~ "hero-cog-6-tooth"
|
|
||||||
|
# Check for specific structural icons (toggle, theme) that should always exist
|
||||||
assert html =~ "hero-chevron-left"
|
assert html =~ "hero-chevron-left"
|
||||||
assert html =~ "hero-chevron-right"
|
assert html =~ "hero-chevron-right"
|
||||||
|
assert html =~ "hero-sun"
|
||||||
# Icons should have aria-hidden
|
assert html =~ "hero-moon"
|
||||||
assert html =~ ~s(aria-hidden="true")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "T7.2: renders icons for theme toggle" do
|
test "T7.2: renders icons for theme toggle" do
|
||||||
|
|
@ -503,26 +511,25 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
|
|
||||||
# Header section
|
# Header section
|
||||||
assert html =~ "Mila Logo"
|
assert html =~ "Mila Logo"
|
||||||
|
assert html =~ ~s(src="/images/mila.svg")
|
||||||
|
|
||||||
# Navigation section
|
# Navigation section
|
||||||
assert html =~ ~s(role="menubar")
|
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
|
# Footer section
|
||||||
assert html =~ "theme-controller"
|
assert html =~ "theme-controller"
|
||||||
|
assert html =~ ~s(action="/set_locale")
|
||||||
|
|
||||||
# All expected links
|
# Check that critical navigation exists (at least /members)
|
||||||
expected_links = [
|
assert html =~ ~s(href="/members"), "Critical /members route should exist"
|
||||||
"/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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -621,9 +628,10 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
test "renders expanded menu group" do
|
test "renders expanded menu group" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# details/summary present
|
# expanded-menu-group structure present
|
||||||
assert html =~ "<details"
|
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||||
assert html =~ "<summary"
|
assert html =~ ~s(role="group")
|
||||||
|
assert html =~ ~s(aria-label="Administration")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -639,9 +647,21 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
test "renders submenu items" do
|
test "renders submenu items" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Inner_block items rendered
|
# Check that nested menu structure exists
|
||||||
assert html =~ ~s(href="/contribution_types")
|
assert html =~ ~s(role="menu")
|
||||||
assert html =~ ~s(href="/membership_fee_settings")
|
|
||||||
|
# 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")
|
assert html =~ ~s(role="menu")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -821,9 +841,10 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
assert has_class?(html, "collapsed-menu-group")
|
assert has_class?(html, "collapsed-menu-group")
|
||||||
|
|
||||||
# Details element should not have duplicate hover classes
|
# Expanded menu group should have correct structure
|
||||||
# (CSS handles this, but we verify structure)
|
# (CSS handles hover effects, but we verify structure)
|
||||||
assert html =~ "<details"
|
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||||
|
assert html =~ ~s(role="group")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "tooltips only visible when collapsed" do
|
test "tooltips only visible when collapsed" do
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue