Compare commits
13 commits
56173d423b
...
a67a91cffa
| Author | SHA1 | Date | |
|---|---|---|---|
| a67a91cffa | |||
| 40835f7a2d | |||
| 13f77b5c0a | |||
| dce2053ce7 | |||
| e81aecce48 | |||
| 397cbde9d6 | |||
| 831149f463 | |||
| 944b868478 | |||
| d10f2ecc90 | |||
| d757d1b9be | |||
| 39d2cb7820 | |||
| ba78a6ac7a | |||
| e2ace3d2a8 |
21 changed files with 1099 additions and 173 deletions
|
|
@ -12,8 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- PostgreSQL trigram-based member search with typo tolerance
|
||||
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
|
||||
- Bilingual UI (German/English) for member linking workflow
|
||||
- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230)
|
||||
- Email format: "First Last <email>" with semicolon separator (compatible with email clients)
|
||||
- CopyToClipboard JavaScript hook with fallback for older browsers
|
||||
- Button shows count of visible selected members (respects search/filter)
|
||||
- German/English translations
|
||||
|
||||
### Fixed
|
||||
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
||||
- Relationship data extraction from Ash manage_relationship during validation
|
||||
- Copy button count now shows only visible selected members when filtering
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,33 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("
|
|||
// Hooks for LiveView components
|
||||
let Hooks = {}
|
||||
|
||||
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
|
||||
Hooks.CopyToClipboard = {
|
||||
mounted() {
|
||||
this.handleEvent("copy_to_clipboard", ({text}) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).catch(err => {
|
||||
console.error("Clipboard write failed:", err)
|
||||
})
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = text
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-999999px"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand("copy")
|
||||
} catch (err) {
|
||||
console.error("Fallback clipboard copy failed:", err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
|
||||
Hooks.ComboBox = {
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -1327,6 +1327,33 @@ end
|
|||
|
||||
---
|
||||
|
||||
## Session: Bulk Email Copy Feature (2025-12-02)
|
||||
|
||||
### Feature Summary
|
||||
Implemented bulk email copy functionality for selected members (#230). Users can select members and copy their email addresses to clipboard.
|
||||
|
||||
**Key Features:**
|
||||
- Copy button appears only when visible members are selected
|
||||
- Email format: `First Last <email>` with semicolon separator (email client compatible)
|
||||
- Button shows count of visible selected members (respects search/filter)
|
||||
- CopyToClipboard JavaScript hook with clipboard API + fallback for older browsers
|
||||
- Bilingual UI (English/German)
|
||||
|
||||
### Key Decisions
|
||||
|
||||
1. **Email Format:** "First Last <email>" with semicolon - standard for all major email clients
|
||||
2. **Visible Member Count:** Button shows only visible selected members, not total selected (better UX when filtering)
|
||||
3. **Server→Client:** Used `push_event/3` - server formats data, client handles clipboard
|
||||
|
||||
### Files Changed
|
||||
- `lib/mv_web/live/member_live/index.ex` - Event handler, helper function
|
||||
- `lib/mv_web/live/member_live/index.html.heex` - Copy button
|
||||
- `assets/js/app.js` - CopyToClipboard hook
|
||||
- `test/mv_web/member_live/index_test.exs` - 9 new tests
|
||||
- `priv/gettext/de/LC_MESSAGES/default.po` - German translations
|
||||
|
||||
---
|
||||
|
||||
## Session: User-Member Linking UI Enhancement (2025-01-13)
|
||||
|
||||
### Feature Summary
|
||||
|
|
@ -1559,8 +1586,8 @@ This project demonstrates a modern Phoenix application built with:
|
|||
|
||||
---
|
||||
|
||||
**Document Version:** 1.2
|
||||
**Last Updated:** 2025-11-27
|
||||
**Document Version:** 1.3
|
||||
**Last Updated:** 2025-12-02
|
||||
**Maintainer:** Development Team
|
||||
**Status:** Living Document (update as project evolves)
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@
|
|||
- ✅ Sorting by basic fields
|
||||
- ✅ User-Member linking (optional 1:1)
|
||||
- ✅ Email synchronization between User and Member
|
||||
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
|
|||
@member_search_limit 10
|
||||
@default_similarity_threshold 0.2
|
||||
|
||||
# Use constants from Mv.Constants for member fields
|
||||
# This ensures consistency across the codebase
|
||||
@member_fields Mv.Constants.member_fields()
|
||||
|
||||
postgres do
|
||||
table "members"
|
||||
repo Mv.Repo
|
||||
|
|
@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do
|
|||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||
argument :user, :map, allow_nil?: true
|
||||
|
||||
accept [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
accept @member_fields
|
||||
|
||||
change manage_relationship(:custom_field_values, type: :create)
|
||||
|
||||
|
|
@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do
|
|||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||
argument :user, :map, allow_nil?: true
|
||||
|
||||
accept [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
accept @member_fields
|
||||
|
||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ defmodule Mv.Membership do
|
|||
# It's only used internally as fallback in get_settings/0
|
||||
# Settings should be created via seed script
|
||||
define :update_settings, action: :update
|
||||
define :update_member_field_visibility, action: :update_member_field_visibility
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -123,4 +124,37 @@ defmodule Mv.Membership do
|
|||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the member field visibility configuration.
|
||||
|
||||
This is a specialized action for updating only the member field visibility settings.
|
||||
It validates that all keys are valid member fields and all values are booleans.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `visibility_config` - A map of member field names (strings) to boolean visibility values
|
||||
(e.g., `%{"street" => false, "house_number" => false}`)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
iex> updated.member_field_visibility
|
||||
%{"street" => false, "house_number" => false}
|
||||
|
||||
"""
|
||||
def update_member_field_visibility(settings, visibility_config) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
## Attributes
|
||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||
|
||||
## Singleton Pattern
|
||||
This resource uses a singleton pattern - there should only be one settings record.
|
||||
|
|
@ -28,6 +30,9 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
# Update club name
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
|
||||
|
||||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
|
|
@ -49,18 +54,65 @@ defmodule Mv.Membership.Setting do
|
|||
# Used only as fallback in get_settings/0 if settings don't exist
|
||||
# Settings should normally be created via seed script
|
||||
create :create do
|
||||
accept [:club_name]
|
||||
accept [:club_name, :member_field_visibility]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
accept [:club_name]
|
||||
require_atomic? false
|
||||
accept [:club_name, :member_field_visibility]
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
description "Updates the visibility configuration for member fields in the overview"
|
||||
require_atomic? false
|
||||
accept [:member_field_visibility]
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:club_name), on: [:create, :update]
|
||||
validate string_length(:club_name, min: 1), on: [:create, :update]
|
||||
|
||||
# Validate member_field_visibility map structure and content
|
||||
validate fn changeset, _context ->
|
||||
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
|
||||
|
||||
if visibility && is_map(visibility) do
|
||||
# Validate all values are booleans
|
||||
invalid_values =
|
||||
Enum.filter(visibility, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
# Validate all keys are valid member fields
|
||||
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
invalid_keys =
|
||||
Enum.filter(visibility, fn {key, _value} ->
|
||||
key not in valid_field_strings
|
||||
end)
|
||||
|> Enum.map(fn {key, _value} -> key end)
|
||||
|
||||
cond do
|
||||
not Enum.empty?(invalid_values) ->
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "All values in member_field_visibility must be booleans"}
|
||||
|
||||
not Enum.empty?(invalid_keys) ->
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
|
@ -75,6 +127,12 @@ defmodule Mv.Membership.Setting do
|
|||
min_length: 1
|
||||
]
|
||||
|
||||
attribute :member_field_visibility, :map,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
23
lib/mv/constants.ex
Normal file
23
lib/mv/constants.ex
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
defmodule Mv.Constants do
|
||||
@moduledoc """
|
||||
Module for defining constants and atoms.
|
||||
"""
|
||||
|
||||
@member_fields [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
end
|
||||
|
|
@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
|
||||
attr :kind, :atom,
|
||||
values: [:info, :error, :success, :warning],
|
||||
doc: "used for styling and flash lookup"
|
||||
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
|
@ -62,16 +66,20 @@ defmodule MvWeb.CoreComponents do
|
|||
<div class={[
|
||||
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
@kind == :info && "alert-info",
|
||||
@kind == :error && "alert-error"
|
||||
@kind == :error && "alert-error",
|
||||
@kind == :success && "bg-green-500 text-white",
|
||||
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
|
||||
]}>
|
||||
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button type="button" class="self-start cursor-pointer group" aria-label={gettext("close")}>
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite">
|
||||
<div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
|
||||
<.flash kind={:success} flash={@flash} />
|
||||
<.flash kind={:warning} flash={@flash} />
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
- `delete` - Remove a member from the database
|
||||
- `select_member` - Toggle individual member selection
|
||||
- `select_all` - Toggle selection of all visible members
|
||||
- `copy_emails` - Copy email addresses of selected members to clipboard
|
||||
|
||||
## Implementation Notes
|
||||
- Search uses PostgreSQL full-text search (plainto_tsquery)
|
||||
|
|
@ -29,11 +30,18 @@ defmodule MvWeb.MemberLive.Index do
|
|||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.MemberLive.Index.Formatter
|
||||
|
||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||
@custom_field_prefix "custom_field_"
|
||||
|
||||
# Member fields that are loaded for the overview
|
||||
# Uses constants from Mv.Constants to ensure consistency
|
||||
# Note: :id is always included for member identification
|
||||
# All member fields are loaded, but visibility is controlled via settings
|
||||
@overview_fields [:id | Mv.Constants.member_fields()]
|
||||
|
||||
@doc """
|
||||
Initializes the LiveView state.
|
||||
|
||||
|
|
@ -52,14 +60,23 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|
||||
# Load settings once to avoid N+1 queries
|
||||
settings =
|
||||
case Membership.get_settings() do
|
||||
{:ok, s} -> s
|
||||
# Fallback if settings can't be loaded
|
||||
{:error, _} -> %{member_field_visibility: %{}}
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Members"))
|
||||
|> assign(:query, "")
|
||||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:member_fields_visible, get_visible_member_fields(settings))
|
||||
|
||||
# We call handle params to use the query from the URL
|
||||
{:ok, socket}
|
||||
|
|
@ -91,10 +108,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
@impl true
|
||||
def handle_event("select_member", %{"id" => id}, socket) do
|
||||
selected =
|
||||
if id in socket.assigns.selected_members do
|
||||
List.delete(socket.assigns.selected_members, id)
|
||||
if MapSet.member?(socket.assigns.selected_members, id) do
|
||||
MapSet.delete(socket.assigns.selected_members, id)
|
||||
else
|
||||
[id | socket.assigns.selected_members]
|
||||
MapSet.put(socket.assigns.selected_members, id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :selected_members, selected)}
|
||||
|
|
@ -102,13 +119,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("select_all", _params, socket) do
|
||||
members = socket.assigns.members
|
||||
|
||||
all_ids = Enum.map(members, & &1.id)
|
||||
all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
|
||||
|
||||
selected =
|
||||
if Enum.sort(socket.assigns.selected_members) == Enum.sort(all_ids) do
|
||||
[]
|
||||
if MapSet.equal?(socket.assigns.selected_members, all_ids) do
|
||||
MapSet.new()
|
||||
else
|
||||
all_ids
|
||||
end
|
||||
|
|
@ -116,6 +131,52 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, assign(socket, :selected_members, selected)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("copy_emails", _params, socket) do
|
||||
selected_ids = socket.assigns.selected_members
|
||||
|
||||
# Filter members that are in the selection and have email addresses
|
||||
formatted_emails =
|
||||
socket.assigns.members
|
||||
|> Enum.filter(fn member ->
|
||||
MapSet.member?(selected_ids, member.id) && member.email && member.email != ""
|
||||
end)
|
||||
|> Enum.map(&format_member_email/1)
|
||||
|
||||
email_count = length(formatted_emails)
|
||||
|
||||
cond do
|
||||
MapSet.size(selected_ids) == 0 ->
|
||||
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
|
||||
|
||||
email_count == 0 ->
|
||||
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
||||
|
||||
true ->
|
||||
# RFC 5322 uses comma as separator for email address lists
|
||||
email_string = Enum.join(formatted_emails, ", ")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> push_event("copy_to_clipboard", %{text: email_string})
|
||||
|> put_flash(
|
||||
:success,
|
||||
ngettext(
|
||||
"Copied %{count} email address to clipboard",
|
||||
"Copied %{count} email addresses to clipboard",
|
||||
email_count,
|
||||
count: email_count
|
||||
)
|
||||
)
|
||||
|> put_flash(
|
||||
:warning,
|
||||
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Handle Infos from Child Components
|
||||
# -----------------------------------------------------------------
|
||||
|
|
@ -313,18 +374,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.select([
|
||||
:id,
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code,
|
||||
:city,
|
||||
:phone_number,
|
||||
:join_date
|
||||
])
|
||||
|> Ash.Query.select(@overview_fields)
|
||||
|
||||
# Load custom field values for visible custom fields
|
||||
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
|
||||
|
|
@ -433,18 +483,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp maybe_sort(query, _, _, _), do: {query, false}
|
||||
|
||||
# Validate that a field is sortable
|
||||
# Uses member fields from constants, but excludes fields that don't make sense to sort
|
||||
# (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
|
||||
defp valid_sort_field?(field) when is_atom(field) do
|
||||
valid_fields = [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code,
|
||||
:city,
|
||||
:phone_number,
|
||||
:join_date
|
||||
]
|
||||
# All member fields are sortable, but we exclude some that don't make sense
|
||||
# :id is not in member_fields, but we don't want to sort by it anyway
|
||||
non_sortable_fields = [:notes, :paid]
|
||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||
|
||||
field in valid_fields or custom_field_sort?(field)
|
||||
end
|
||||
|
|
@ -733,4 +778,50 @@ defmodule MvWeb.MemberLive.Index do
|
|||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Formats a member's email in the format "First Last <email>"
|
||||
# Used for copy_emails feature to create email-client-friendly format.
|
||||
defp format_member_email(member) do
|
||||
first_name = member.first_name || ""
|
||||
last_name = member.last_name || ""
|
||||
|
||||
name =
|
||||
[first_name, last_name]
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
|> Enum.join(" ")
|
||||
|
||||
if name == "" do
|
||||
member.email
|
||||
else
|
||||
"#{name} <#{member.email}>"
|
||||
end
|
||||
end
|
||||
|
||||
# Gets the list of member fields that should be visible in the overview.
|
||||
#
|
||||
# Reads the visibility configuration from Settings and returns only the fields
|
||||
# where show_in_overview is true. Fields not configured in settings default to true.
|
||||
#
|
||||
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
|
||||
# Settings should be loaded once in mount/3 and passed to this function.
|
||||
#
|
||||
# Parameters:
|
||||
# - `settings` - The settings struct loaded from the database
|
||||
#
|
||||
# Returns a list of atoms representing visible member field names.
|
||||
#
|
||||
# Fields are read from the global Constants module.
|
||||
@spec get_visible_member_fields(map()) :: [atom()]
|
||||
defp get_visible_member_fields(settings) do
|
||||
# Get all eligible fields from the global constants
|
||||
all_fields = Mv.Constants.member_fields()
|
||||
|
||||
# JSONB stores keys as strings
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
|
||||
# Filter to only return visible fields
|
||||
Enum.filter(all_fields, fn field ->
|
||||
Map.get(visibility_config, Atom.to_string(field), true)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,6 +2,24 @@
|
|||
<.header>
|
||||
{gettext("Members")}
|
||||
<:actions>
|
||||
<.button
|
||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||
id="copy-emails-btn"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
aria-label={gettext("Copy email addresses of selected members")}
|
||||
>
|
||||
<.icon name="hero-clipboard-document" />
|
||||
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
|
||||
</.button>
|
||||
<.button
|
||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||
href={"mailto:?bcc=#{@members |> Enum.filter(&(MapSet.member?(@selected_members, &1.id) && &1.email)) |> Enum.map(& &1.email) |> Enum.join(",")}"}
|
||||
aria-label={gettext("Open email program with BCC recipients")}
|
||||
>
|
||||
<.icon name="hero-envelope" />
|
||||
{gettext("Open in email program")}
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/members/new"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||
</.button>
|
||||
|
|
@ -33,7 +51,7 @@
|
|||
type="checkbox"
|
||||
name="select_all"
|
||||
phx-click="select_all"
|
||||
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()}
|
||||
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
||||
aria-label={gettext("Select all members")}
|
||||
role="checkbox"
|
||||
/>
|
||||
|
|
@ -45,7 +63,7 @@
|
|||
name={member.id}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
checked={member.id in @selected_members}
|
||||
checked={MapSet.member?(@selected_members, member.id)}
|
||||
phx-capture-click
|
||||
phx-stop-propagation
|
||||
aria-label={gettext("Select member")}
|
||||
|
|
@ -71,6 +89,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:email in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -88,6 +107,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:street in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -105,6 +125,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:house_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -122,6 +143,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:postal_code in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -139,6 +161,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:city in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -156,6 +179,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:phone_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -173,6 +197,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:join_date in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
|
|||
|
|
@ -10,37 +10,37 @@ msgid ""
|
|||
msgstr ""
|
||||
"Language: en\n"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:378
|
||||
#: lib/mv_web/components/core_components.ex:386
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr "Aktionen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||
#: lib/mv_web/live/member_live/index.html.heex:227
|
||||
#: lib/mv_web/live/user_live/index.html.heex:72
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
msgstr "Bist du sicher?"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:80
|
||||
#: lib/mv_web/components/layouts.ex:92
|
||||
#: lib/mv_web/components/layouts.ex:82
|
||||
#: lib/mv_web/components/layouts.ex:94
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr "Verbindung wird wiederhergestellt"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
||||
#: lib/mv_web/live/member_live/index.html.heex:171
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
msgstr "Stadt"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:204
|
||||
#: lib/mv_web/live/member_live/index.html.heex:229
|
||||
#: lib/mv_web/live/user_live/index.html.heex:74
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr "Löschen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:196
|
||||
#: lib/mv_web/live/member_live/index.html.heex:221
|
||||
#: lib/mv_web/live/user_live/form.ex:265
|
||||
#: lib/mv_web/live/user_live/index.html.heex:66
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -54,7 +54,7 @@ msgid "Edit Member"
|
|||
msgstr "Mitglied bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:47
|
||||
#: lib/mv_web/live/member_live/index.html.heex:80
|
||||
#: lib/mv_web/live/member_live/index.html.heex:99
|
||||
#: lib/mv_web/live/member_live/show.ex:50
|
||||
#: lib/mv_web/live/user_live/form.ex:46
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
|
|
@ -70,7 +70,7 @@ msgid "First Name"
|
|||
msgstr "Vorname"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
||||
#: lib/mv_web/live/member_live/index.html.heex:207
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
|
|
@ -82,28 +82,28 @@ msgstr "Beitrittsdatum"
|
|||
msgid "Last Name"
|
||||
msgstr "Nachname"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:6
|
||||
#: lib/mv_web/live/member_live/index.html.heex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New Member"
|
||||
msgstr "Neues Mitglied"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
||||
#: lib/mv_web/live/member_live/index.html.heex:218
|
||||
#: lib/mv_web/live/user_live/index.html.heex:63
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
msgstr "Anzeigen"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:87
|
||||
#: lib/mv_web/components/layouts.ex:89
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr "Etwas ist schiefgelaufen!"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:75
|
||||
#: lib/mv_web/components/layouts.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr "Keine Internetverbindung gefunden"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:74
|
||||
#: lib/mv_web/components/core_components.ex:82
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr "schließen"
|
||||
|
|
@ -121,7 +121,7 @@ msgid "Exit Date"
|
|||
msgstr "Austrittsdatum"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:56
|
||||
#: lib/mv_web/live/member_live/index.html.heex:114
|
||||
#: lib/mv_web/live/member_live/index.html.heex:135
|
||||
#: lib/mv_web/live/member_live/show.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
|
|
@ -140,14 +140,14 @@ msgid "Paid"
|
|||
msgstr "Bezahlt"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
||||
#: lib/mv_web/live/member_live/index.html.heex:189
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr "Telefonnummer"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:57
|
||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
||||
#: lib/mv_web/live/member_live/index.html.heex:153
|
||||
#: lib/mv_web/live/member_live/show.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
|
|
@ -168,7 +168,7 @@ msgid "Saving..."
|
|||
msgstr "Speichern..."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:55
|
||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
||||
#: lib/mv_web/live/member_live/index.html.heex:117
|
||||
#: lib/mv_web/live/member_live/show.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
|
|
@ -306,7 +306,7 @@ msgid "Member"
|
|||
msgstr "Mitglied"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:25
|
||||
#: lib/mv_web/live/member_live/index.ex:57
|
||||
#: lib/mv_web/live/member_live/index.ex:73
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -360,12 +360,12 @@ msgstr "Profil"
|
|||
msgid "Required"
|
||||
msgstr "Erforderlich"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select all members"
|
||||
msgstr "Alle Mitglieder auswählen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:69
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select member"
|
||||
msgstr "Mitglied auswählen"
|
||||
|
|
@ -551,7 +551,7 @@ msgid "Toggle dark mode"
|
|||
msgstr "Dunklen Modus umschalten"
|
||||
|
||||
#: lib/mv_web/live/components/search_bar_component.ex:15
|
||||
#: lib/mv_web/live/member_live/index.html.heex:15
|
||||
#: lib/mv_web/live/member_live/index.html.heex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Search..."
|
||||
msgstr "Suchen..."
|
||||
|
|
@ -567,7 +567,7 @@ msgstr "Benutzer*innen"
|
|||
msgid "Click to sort"
|
||||
msgstr "Klicke um zu sortieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:63
|
||||
#: lib/mv_web/live/member_live/index.html.heex:81
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "First name"
|
||||
msgstr "Vorname"
|
||||
|
|
@ -777,25 +777,57 @@ msgstr "Mitglied entverknüpfen"
|
|||
msgid "Unlinking scheduled"
|
||||
msgstr "Entverknüpfung geplant"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:164
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copied %{count} email address to clipboard"
|
||||
msgid_plural "Copied %{count} email addresses to clipboard"
|
||||
msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert"
|
||||
msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:10
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:13
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy emails"
|
||||
msgstr "E-Mails kopieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:153
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No email addresses found"
|
||||
msgstr "Keine E-Mail-Adressen gefunden"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:150
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members selected"
|
||||
msgstr "Keine Mitglieder ausgewählt"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr "Im E-Mail-Programm öffnen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:173
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
|
||||
msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformität"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fields marked with an asterisk (*) cannot be empty."
|
||||
msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben."
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:198
|
||||
#: lib/mv_web/components/core_components.ex:215
|
||||
#: lib/mv_web/components/core_components.ex:242
|
||||
#: lib/mv_web/components/core_components.ex:269
|
||||
#: lib/mv_web/components/core_components.ex:206
|
||||
#: lib/mv_web/components/core_components.ex:223
|
||||
#: lib/mv_web/components/core_components.ex:250
|
||||
#: lib/mv_web/components/core_components.ex:277
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "This field cannot be empty"
|
||||
msgstr "Dieses Feld darf nicht leer bleiben"
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index.ex:97
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "To confirm deletion, please enter the custom field slug:"
|
||||
#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex:40
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Use this form to manage member records and their properties."
|
||||
#~ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
|
||||
|
|
|
|||
|
|
@ -11,37 +11,37 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:378
|
||||
#: lib/mv_web/components/core_components.ex:386
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||
#: lib/mv_web/live/member_live/index.html.heex:227
|
||||
#: lib/mv_web/live/user_live/index.html.heex:72
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:80
|
||||
#: lib/mv_web/components/layouts.ex:92
|
||||
#: lib/mv_web/components/layouts.ex:82
|
||||
#: lib/mv_web/components/layouts.ex:94
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
||||
#: lib/mv_web/live/member_live/index.html.heex:171
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:204
|
||||
#: lib/mv_web/live/member_live/index.html.heex:229
|
||||
#: lib/mv_web/live/user_live/index.html.heex:74
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:196
|
||||
#: lib/mv_web/live/member_live/index.html.heex:221
|
||||
#: lib/mv_web/live/user_live/form.ex:265
|
||||
#: lib/mv_web/live/user_live/index.html.heex:66
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -55,7 +55,7 @@ msgid "Edit Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:47
|
||||
#: lib/mv_web/live/member_live/index.html.heex:80
|
||||
#: lib/mv_web/live/member_live/index.html.heex:99
|
||||
#: lib/mv_web/live/member_live/show.ex:50
|
||||
#: lib/mv_web/live/user_live/form.ex:46
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
|
|
@ -71,7 +71,7 @@ msgid "First Name"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
||||
#: lib/mv_web/live/member_live/index.html.heex:207
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
|
|
@ -83,28 +83,28 @@ msgstr ""
|
|||
msgid "Last Name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:6
|
||||
#: lib/mv_web/live/member_live/index.html.heex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
||||
#: lib/mv_web/live/member_live/index.html.heex:218
|
||||
#: lib/mv_web/live/user_live/index.html.heex:63
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:87
|
||||
#: lib/mv_web/components/layouts.ex:89
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:75
|
||||
#: lib/mv_web/components/layouts.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:74
|
||||
#: lib/mv_web/components/core_components.ex:82
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
|
@ -122,7 +122,7 @@ msgid "Exit Date"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:56
|
||||
#: lib/mv_web/live/member_live/index.html.heex:114
|
||||
#: lib/mv_web/live/member_live/index.html.heex:135
|
||||
#: lib/mv_web/live/member_live/show.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
|
|
@ -141,14 +141,14 @@ msgid "Paid"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
||||
#: lib/mv_web/live/member_live/index.html.heex:189
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:57
|
||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
||||
#: lib/mv_web/live/member_live/index.html.heex:153
|
||||
#: lib/mv_web/live/member_live/show.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
|
|
@ -169,7 +169,7 @@ msgid "Saving..."
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:55
|
||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
||||
#: lib/mv_web/live/member_live/index.html.heex:117
|
||||
#: lib/mv_web/live/member_live/show.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
|
|
@ -307,7 +307,7 @@ msgid "Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:25
|
||||
#: lib/mv_web/live/member_live/index.ex:57
|
||||
#: lib/mv_web/live/member_live/index.ex:73
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -361,12 +361,12 @@ msgstr ""
|
|||
msgid "Required"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select all members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:69
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select member"
|
||||
msgstr ""
|
||||
|
|
@ -552,7 +552,7 @@ msgid "Toggle dark mode"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/search_bar_component.ex:15
|
||||
#: lib/mv_web/live/member_live/index.html.heex:15
|
||||
#: lib/mv_web/live/member_live/index.html.heex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Search..."
|
||||
msgstr ""
|
||||
|
|
@ -568,7 +568,7 @@ msgstr ""
|
|||
msgid "Click to sort"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:63
|
||||
#: lib/mv_web/live/member_live/index.html.heex:81
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "First name"
|
||||
msgstr ""
|
||||
|
|
@ -778,15 +778,57 @@ msgstr ""
|
|||
msgid "Unlinking scheduled"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:164
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copied %{count} email address to clipboard"
|
||||
msgid_plural "Copied %{count} email addresses to clipboard"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:10
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:13
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy emails"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:153
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No email addresses found"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:150
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members selected"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:173
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fields marked with an asterisk (*) cannot be empty."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:198
|
||||
#: lib/mv_web/components/core_components.ex:215
|
||||
#: lib/mv_web/components/core_components.ex:242
|
||||
#: lib/mv_web/components/core_components.ex:269
|
||||
#: lib/mv_web/components/core_components.ex:206
|
||||
#: lib/mv_web/components/core_components.ex:223
|
||||
#: lib/mv_web/components/core_components.ex:250
|
||||
#: lib/mv_web/components/core_components.ex:277
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This field cannot be empty"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -11,37 +11,37 @@ msgstr ""
|
|||
"Language: en\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:378
|
||||
#: lib/mv_web/components/core_components.ex:386
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||
#: lib/mv_web/live/member_live/index.html.heex:227
|
||||
#: lib/mv_web/live/user_live/index.html.heex:72
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:80
|
||||
#: lib/mv_web/components/layouts.ex:92
|
||||
#: lib/mv_web/components/layouts.ex:82
|
||||
#: lib/mv_web/components/layouts.ex:94
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:54
|
||||
#: lib/mv_web/live/member_live/index.html.heex:148
|
||||
#: lib/mv_web/live/member_live/index.html.heex:171
|
||||
#: lib/mv_web/live/member_live/show.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:204
|
||||
#: lib/mv_web/live/member_live/index.html.heex:229
|
||||
#: lib/mv_web/live/user_live/index.html.heex:74
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:196
|
||||
#: lib/mv_web/live/member_live/index.html.heex:221
|
||||
#: lib/mv_web/live/user_live/form.ex:265
|
||||
#: lib/mv_web/live/user_live/index.html.heex:66
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -55,7 +55,7 @@ msgid "Edit Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:47
|
||||
#: lib/mv_web/live/member_live/index.html.heex:80
|
||||
#: lib/mv_web/live/member_live/index.html.heex:99
|
||||
#: lib/mv_web/live/member_live/show.ex:50
|
||||
#: lib/mv_web/live/user_live/form.ex:46
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
|
|
@ -71,7 +71,7 @@ msgid "First Name"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:182
|
||||
#: lib/mv_web/live/member_live/index.html.heex:207
|
||||
#: lib/mv_web/live/member_live/show.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
|
|
@ -83,28 +83,28 @@ msgstr ""
|
|||
msgid "Last Name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:6
|
||||
#: lib/mv_web/live/member_live/index.html.heex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
||||
#: lib/mv_web/live/member_live/index.html.heex:218
|
||||
#: lib/mv_web/live/user_live/index.html.heex:63
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:87
|
||||
#: lib/mv_web/components/layouts.ex:89
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex:75
|
||||
#: lib/mv_web/components/layouts.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:74
|
||||
#: lib/mv_web/components/core_components.ex:82
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
|
@ -122,7 +122,7 @@ msgid "Exit Date"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:56
|
||||
#: lib/mv_web/live/member_live/index.html.heex:114
|
||||
#: lib/mv_web/live/member_live/index.html.heex:135
|
||||
#: lib/mv_web/live/member_live/show.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
|
|
@ -141,14 +141,14 @@ msgid "Paid"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:50
|
||||
#: lib/mv_web/live/member_live/index.html.heex:165
|
||||
#: lib/mv_web/live/member_live/index.html.heex:189
|
||||
#: lib/mv_web/live/member_live/show.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:57
|
||||
#: lib/mv_web/live/member_live/index.html.heex:131
|
||||
#: lib/mv_web/live/member_live/index.html.heex:153
|
||||
#: lib/mv_web/live/member_live/show.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
|
|
@ -169,7 +169,7 @@ msgid "Saving..."
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:55
|
||||
#: lib/mv_web/live/member_live/index.html.heex:97
|
||||
#: lib/mv_web/live/member_live/index.html.heex:117
|
||||
#: lib/mv_web/live/member_live/show.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
|
|
@ -307,7 +307,7 @@ msgid "Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:25
|
||||
#: lib/mv_web/live/member_live/index.ex:57
|
||||
#: lib/mv_web/live/member_live/index.ex:73
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -361,12 +361,12 @@ msgstr ""
|
|||
msgid "Required"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select all members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:51
|
||||
#: lib/mv_web/live/member_live/index.html.heex:69
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select member"
|
||||
msgstr ""
|
||||
|
|
@ -552,7 +552,7 @@ msgid "Toggle dark mode"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/search_bar_component.ex:15
|
||||
#: lib/mv_web/live/member_live/index.html.heex:15
|
||||
#: lib/mv_web/live/member_live/index.html.heex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Search..."
|
||||
msgstr ""
|
||||
|
|
@ -568,7 +568,7 @@ msgstr ""
|
|||
msgid "Click to sort"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:63
|
||||
#: lib/mv_web/live/member_live/index.html.heex:81
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "First name"
|
||||
msgstr ""
|
||||
|
|
@ -778,25 +778,57 @@ msgstr ""
|
|||
msgid "Unlinking scheduled"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:164
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copied %{count} email address to clipboard"
|
||||
msgid_plural "Copied %{count} email addresses to clipboard"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:10
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:13
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy emails"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:153
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No email addresses found"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:150
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No members selected"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:18
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:21
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex:173
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fields marked with an asterisk (*) cannot be empty."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex:198
|
||||
#: lib/mv_web/components/core_components.ex:215
|
||||
#: lib/mv_web/components/core_components.ex:242
|
||||
#: lib/mv_web/components/core_components.ex:269
|
||||
#: lib/mv_web/components/core_components.ex:206
|
||||
#: lib/mv_web/components/core_components.ex:223
|
||||
#: lib/mv_web/components/core_components.ex:250
|
||||
#: lib/mv_web/components/core_components.ex:277
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "This field cannot be empty"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index.ex:97
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "To confirm deletion, please enter the custom field slug:"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex:40
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Use this form to manage member records and their properties."
|
||||
#~ msgstr ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :member_field_visibility, :map
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :member_field_visibility
|
||||
end
|
||||
end
|
||||
end
|
||||
144
priv/resource_snapshots/repo/custom_fields/20251201115939.json
Normal file
144
priv/resource_snapshots/repo/custom_fields/20251201115939.json
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "slug",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "value_type",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "immutable",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "required",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "show_in_overview",
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_name_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "name"
|
||||
}
|
||||
],
|
||||
"name": "unique_name",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_slug_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "slug"
|
||||
}
|
||||
],
|
||||
"name": "unique_slug",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "custom_fields"
|
||||
}
|
||||
79
priv/resource_snapshots/repo/settings/20251201115939.json
Normal file
79
priv/resource_snapshots/repo/settings/20251201115939.json
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_visibility",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
14
test/membership/member_field_visibility_test.exs
Normal file
14
test/membership/member_field_visibility_test.exs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
defmodule Mv.Membership.MemberFieldVisibilityTest do
|
||||
@moduledoc """
|
||||
Tests for member field visibility configuration.
|
||||
|
||||
Tests cover:
|
||||
- Member fields are visible by default (show_in_overview: true)
|
||||
- Member fields can be hidden (show_in_overview: false)
|
||||
- Checking if a specific field is visible
|
||||
- Configuration is stored in Settings resource
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.Member
|
||||
end
|
||||
64
test/mv_web/member_live/index_member_fields_display_test.exs
Normal file
64
test/mv_web/member_live/index_member_fields_display_test.exs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
setup do
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main Street",
|
||||
house_number: "123",
|
||||
postal_code: "12345",
|
||||
city: "Berlin",
|
||||
phone_number: "+49123456789",
|
||||
join_date: ~D[2020-01-15]
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{
|
||||
member1: member1,
|
||||
member2: member2
|
||||
}
|
||||
end
|
||||
|
||||
test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
||||
assert html =~ field
|
||||
end
|
||||
end
|
||||
|
||||
test "respects show_in_overview config", %{conn: conn, member1: m} do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
fields_to_hide = [:street, :house_number]
|
||||
|
||||
{:ok, _} =
|
||||
Mv.Membership.update_settings(settings, %{
|
||||
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
|
||||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "Email"
|
||||
assert html =~ m.email
|
||||
refute html =~ m.street
|
||||
end
|
||||
end
|
||||
|
|
@ -249,4 +249,224 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
# Verify the member was actually deleted from the database
|
||||
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
|
||||
end
|
||||
|
||||
describe "copy_emails feature" do
|
||||
setup do
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Max",
|
||||
last_name: "Mustermann",
|
||||
email: "max@example.com"
|
||||
})
|
||||
|
||||
{:ok, member2} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Erika",
|
||||
last_name: "Musterfrau",
|
||||
email: "erika@example.com"
|
||||
})
|
||||
|
||||
{:ok, member3} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Hans",
|
||||
last_name: "Müller-Lüdenscheidt",
|
||||
email: "hans@example.com"
|
||||
})
|
||||
|
||||
%{member1: member1, member2: member2, member3: member3}
|
||||
end
|
||||
|
||||
test "copy_emails event formats selected members correctly", %{
|
||||
conn: conn,
|
||||
member1: member1,
|
||||
member2: member2
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select two members
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Trigger copy_emails event
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
|
||||
# Verify flash message shows correct count
|
||||
assert render(view) =~ "2"
|
||||
end
|
||||
|
||||
test "copy_emails event with no selection shows error flash", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Trigger copy_emails event directly (button not visible when no selection)
|
||||
# This tests the edge case where event is triggered without selection
|
||||
result = render_hook(view, "copy_emails", %{})
|
||||
|
||||
# Should show error flash
|
||||
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
|
||||
end
|
||||
|
||||
test "copy_emails event with all members selected formats all emails", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select all members via select_all
|
||||
view |> element("[phx-click='select_all']") |> render_click()
|
||||
|
||||
# Trigger copy_emails event
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
|
||||
# Verify flash message shows correct count (3 members)
|
||||
assert render(view) =~ "3"
|
||||
end
|
||||
|
||||
test "copy_emails handles members with special characters in names", %{
|
||||
conn: conn,
|
||||
member3: member3
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select member with umlauts
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member3.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Trigger copy_emails event - should not crash
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
|
||||
# Verify flash message shows success
|
||||
assert render(view) =~ "1"
|
||||
end
|
||||
|
||||
test "copy_emails handles case where selected member is deleted before copy", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select a member
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Delete the member from the database
|
||||
Ash.destroy!(member1)
|
||||
|
||||
# Trigger copy_emails event directly - selection still contains the deleted ID
|
||||
# but the member is no longer in @members list after reload
|
||||
result = render_hook(view, "copy_emails", %{})
|
||||
|
||||
# Should show error since no visible members match selection
|
||||
assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0"
|
||||
end
|
||||
|
||||
test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{
|
||||
conn: conn,
|
||||
member1: member1,
|
||||
member2: member2
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select two members
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Get the socket state to verify the formatted email string
|
||||
state = :sys.get_state(view.pid)
|
||||
selected_members = state.socket.assigns.selected_members
|
||||
|
||||
# Verify MapSet is used
|
||||
assert %MapSet{} = selected_members
|
||||
assert MapSet.size(selected_members) == 2
|
||||
end
|
||||
|
||||
test "email format is 'First Last <email>' with comma separator", %{
|
||||
conn: conn,
|
||||
member1: _member1
|
||||
} do
|
||||
# Test the format_member_email function indirectly
|
||||
# by checking the push_event payload structure
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Create a member with known data
|
||||
{:ok, test_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Test",
|
||||
last_name: "Format",
|
||||
email: "test.format@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select the test member
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{test_member.id}']")
|
||||
|> render_click()
|
||||
|
||||
# The format should be "Test Format <test.format@example.com>"
|
||||
# We verify this by checking the flash shows 1 email was copied
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
assert render(view) =~ "1"
|
||||
end
|
||||
|
||||
test "copy button is not visible when no members are selected", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Ensure no members are selected (default state)
|
||||
refute has_element?(view, "#copy-emails-btn")
|
||||
end
|
||||
|
||||
test "copy button is visible when members are selected", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select a member
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Button should now be visible
|
||||
assert has_element?(view, "#copy-emails-btn")
|
||||
end
|
||||
|
||||
test "copy button click triggers event and shows flash", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select a member
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Click copy button
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
|
||||
# Flash message should appear
|
||||
assert has_element?(view, "#flash-group")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue