diff --git a/.drone.yml b/.drone.yml index 623114f..427ecfc 100644 --- a/.drone.yml +++ b/.drone.yml @@ -53,6 +53,8 @@ steps: - mix hex.audit # Provide hints for improving code quality - mix credo + # Check that translations are up to date + - mix gettext.extract --check-up-to-date - name: wait_for_postgres image: docker.io/library/postgres:17.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 74df997..71d9147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 " 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 diff --git a/Justfile b/Justfile index b28dbdc..907283f 100644 --- a/Justfile +++ b/Justfile @@ -29,6 +29,7 @@ lint: mix format --check-formatted mix compile --warnings-as-errors mix credo + mix gettext.extract --check-up-to-date audit: mix sobelow --config diff --git a/assets/js/app.js b/assets/js/app.js index e55a06d..883ca30 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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() { diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index d548b82..1644f2a 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -115,7 +115,6 @@ Member (1) → (N) Properties ### Member Constraints - First name and last name required (min 1 char) - Email unique, validated format (5-254 chars) -- Birth date cannot be in future - Join date cannot be in future - Exit date must be after join date - Phone: `+?[0-9\- ]{6,20}` @@ -169,7 +168,7 @@ Member (1) → (N) Properties ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes -- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code +- **Weight C:** phone_number, city, street, house_number, postal_code - **Weight D (lowest):** join_date, exit_date ### Usage Example @@ -381,7 +380,7 @@ Install "DBML Language" extension to view/edit DBML files with: - tokens (jti, purpose, extra_data) **Personal Data (GDPR):** -- All member fields (name, email, birth_date, address) +- All member fields (name, email, address) - User email - Token subject diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 33c0647..b620830 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -122,7 +122,6 @@ Table members { first_name text [not null, note: 'Member first name (min length: 1)'] last_name text [not null, note: 'Member last name (min length: 1)'] email text [not null, unique, note: 'Member email address (5-254 chars, validated)'] - birth_date date [null, note: 'Date of birth (cannot be in future)'] paid boolean [null, note: 'Payment status flag'] phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})'] join_date date [null, note: 'Date when member joined club (cannot be in future)'] @@ -153,7 +152,7 @@ Table members { **Club Member Master Data** Core entity for membership management containing: - - Personal information (name, birth date, email) + - Personal information (name, email) - Contact details (phone, address) - Membership status (join/exit dates, payment status) - Additional notes @@ -183,7 +182,6 @@ Table members { **Validation Rules:** - first_name, last_name: min 1 character - email: 5-254 characters, valid email format - - birth_date: cannot be in future - join_date: cannot be in future - exit_date: must be after join_date (if both present) - phone_number: matches pattern ^\+?[0-9\- ]{6,20}$ diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 5669a19..629987e 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -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 ` 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 " 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) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 2313fd7..609523c 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -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) @@ -99,10 +100,10 @@ **Closed Issues:** - [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) - [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) +- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02 **Open Issues:** - [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks] -- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority) - [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority) **Missing Features:** diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 31a825b..b788dc9 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do - Email format validation (using EctoCommons.EmailValidator) - Phone number format: international format with 6-20 digits - Postal code format: exactly 5 digits (German format) - - Date validations: birth_date and join_date not in future, exit_date after join_date + - Date validations: join_date not in future, exit_date after join_date - Email uniqueness: prevents conflicts with unlinked users ## Full-Text Search @@ -284,11 +284,6 @@ defmodule Mv.Membership.Member do end end - # Birth date not in the future - validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), - where: [present(:birth_date)], - message: "cannot be in the future" - # Join date not in the future validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:join_date)], @@ -351,10 +346,6 @@ defmodule Mv.Membership.Member do constraints min_length: 5, max_length: 254 end - attribute :birth_date, :date do - allow_nil? true - end - attribute :paid, :boolean do allow_nil? true end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 516448c..f5a708b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -134,8 +134,8 @@ defmodule Mv.Membership do ## Parameters - `settings` - The settings record to update - - `visibility_config` - A map of member field names (atoms) to boolean visibility values - (e.g., `%{street: false, house_number: false}`) + - `visibility_config` - A map of member field names (strings) to boolean visibility values + (e.g., `%{"street" => false, "house_number" => false}`) ## Returns @@ -145,9 +145,9 @@ defmodule Mv.Membership do ## Examples iex> {:ok, settings} = Mv.Membership.get_settings() - iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false}) + 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} + %{"street" => false, "house_number" => false} """ def update_member_field_visibility(settings, visibility_config) do diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 3405a3f..52c0328 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -10,7 +10,7 @@ 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`. + (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. @@ -32,7 +32,7 @@ defmodule Mv.Membership.Setting do {: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}) + {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) """ use Ash.Resource, domain: Mv.Membership, @@ -67,43 +67,6 @@ defmodule Mv.Membership.Setting do description "Updates the visibility configuration for member fields in the overview" require_atomic? false accept [:member_field_visibility] - - change fn changeset, _context -> - visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) - - if visibility && is_map(visibility) do - valid_fields = Mv.Constants.member_fields() - # Normalize keys to atoms (JSONB may return string keys) - invalid_keys = - Enum.filter(visibility, fn {key, _value} -> - atom_key = - if is_atom(key) do - key - else - try do - String.to_existing_atom(key) - rescue - ArgumentError -> nil - end - end - - atom_key && atom_key not in valid_fields - end) - |> Enum.map(fn {key, _value} -> key end) - - if Enum.empty?(invalid_keys) do - changeset - else - Ash.Changeset.add_error( - changeset, - field: :member_field_visibility, - message: "Invalid member field keys: #{inspect(invalid_keys)}" - ) - end - else - changeset - end - end end end @@ -111,23 +74,39 @@ defmodule Mv.Membership.Setting do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] - # Validate that member_field_visibility map contains only boolean values - # This allows dynamic fields without hardcoding specific field names + # 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 - invalid_entries = + # Validate all values are booleans + invalid_values = Enum.filter(visibility, fn {_key, value} -> not is_boolean(value) end) - if Enum.empty?(invalid_entries) do - :ok - else - {:error, - field: :member_field_visibility, - message: "All values in member_field_visibility must be booleans"} + # 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 diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index cd8d3a4..334bcc1 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -7,7 +7,6 @@ defmodule Mv.Constants do :first_name, :last_name, :email, - :birth_date, :paid, :phone_number, :join_date, diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 4f6bf37..8fa4495 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -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" @@ -56,16 +60,20 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="toast toast-top toast-end z-50" + class="z-50 toast toast-top toast-end" {@rest} >
<.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" />

{@title}

{msg}

@@ -300,7 +308,7 @@ defmodule MvWeb.CoreComponents do end) ~H""" -
+
<.error :for={msg <- @errors}>{msg} @@ -322,9 +334,15 @@ defmodule MvWeb.CoreComponents do def input(%{type: "select"} = assigns) do ~H""" -
+