From b745b13ca56d6ea84c44f948a447546fc1de720b Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:04:43 +0200 Subject: [PATCH 01/26] feat(membership-fees): denormalize cycle_end so fee status filters run in the database Store the cycle's end date alongside its start so the payment-status filter can be a plain date comparison in SQL instead of loading every cycle and classifying it in memory. The value is fixed when the cycle is created (the fee type's interval is immutable and the update action never changes the start date), so the denormalized column can never drift from the computed end and needs no write-time reconciliation. --- lib/membership_fees/cycle_end_backfill.ex | 53 ++++++ lib/membership_fees/membership_fee_cycle.ex | 19 +- .../changes/set_cycle_end.ex | 68 +++++++ lib/mv/membership_fees/cycle_generator.ex | 9 +- ...add_cycle_end_to_membership_fee_cycles.exs | 32 ++++ .../membership_fee_cycles/20260630163240.json | 173 ++++++++++++++++++ test/membership_fees/cycle_end_test.exs | 98 ++++++++++ 7 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 lib/membership_fees/cycle_end_backfill.ex create mode 100644 lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex create mode 100644 priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs create mode 100644 priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json create mode 100644 test/membership_fees/cycle_end_test.exs diff --git a/lib/membership_fees/cycle_end_backfill.ex b/lib/membership_fees/cycle_end_backfill.ex new file mode 100644 index 00000000..1d739dad --- /dev/null +++ b/lib/membership_fees/cycle_end_backfill.ex @@ -0,0 +1,53 @@ +defmodule Mv.MembershipFees.CycleEndBackfill do + @moduledoc """ + One-time backfill for the denormalized `cycle_end` column on + `membership_fee_cycles`. + + For every cycle whose `cycle_end` is still `NULL`, sets it to + `CalendarCycles.calculate_cycle_end(cycle_start, interval)` using the cycle's + own fee-type interval. Idempotent: rows that already have a `cycle_end` are + left untouched, so it is safe to re-run. + + Invoked from the `cycle_end` migration. Kept as a module function (rather than + inline migration SQL) so the exact calendar arithmetic is reused from + `CalendarCycles` and can be tested directly. + """ + + import Ecto.Query + + alias Mv.MembershipFees.CalendarCycles + alias Mv.Repo + + @doc """ + Backfills `cycle_end` for all cycles missing it. Returns `{:ok, updated_count}`. + """ + @spec run() :: {:ok, non_neg_integer()} + def run do + rows = + from(c in "membership_fee_cycles", + join: t in "membership_fee_types", + on: c.membership_fee_type_id == t.id, + where: is_nil(c.cycle_end), + select: {c.id, c.cycle_start, t.interval} + ) + |> Repo.all() + + count = + Enum.reduce(rows, 0, fn {id, cycle_start, interval}, acc -> + cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, normalize_interval(interval)) + + from(c in "membership_fee_cycles", where: c.id == ^id) + |> Repo.update_all(set: [cycle_end: cycle_end]) + + acc + 1 + end) + + {:ok, count} + end + + # The raw query returns the enum column as a string. + defp normalize_interval(interval) when is_atom(interval), do: interval + + defp normalize_interval(interval) when is_binary(interval), + do: String.to_existing_atom(interval) +end diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index cd628871..cb881938 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -13,7 +13,9 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do - `notes` - Optional notes for this cycle ## Design Decisions - - **No cycle_end field**: Calculated from cycle_start + interval (from fee type) + - **Denormalized cycle_end**: Set at create from cycle_start + interval (fee type). + Because the fee type interval and the cycle_start are immutable after create, + cycle_end never drifts, so it can be filtered/sorted in SQL. - **Amount stored per cycle**: Preserves historical amounts when fee type changes - **Calendar-aligned cycles**: All cycles start on calendar boundaries @@ -46,6 +48,14 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do create :create do primary? true accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id] + + argument :interval, :atom do + allow_nil? true + + description "Optional pre-resolved fee-type interval; when given, SetCycleEnd skips the fee-type lookup" + end + + change Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd end update :update do @@ -106,6 +116,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do description "Start date of the billing cycle" end + attribute :cycle_end, :date do + allow_nil? false + public? true + + description "Denormalized end date of the cycle (cycle_start + interval), set at create" + end + attribute :amount, :decimal do allow_nil? false public? true diff --git a/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex b/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex new file mode 100644 index 00000000..296f952c --- /dev/null +++ b/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex @@ -0,0 +1,68 @@ +defmodule Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd do + @moduledoc """ + Ash change that denormalizes `cycle_end` on a membership fee cycle at create time. + + `cycle_end` is computed as `CalendarCycles.calculate_cycle_end(cycle_start, interval)`, + where `interval` is read from the cycle's `membership_fee_type`. Because a fee type's + `interval` is immutable and the cycle's `cycle_start` is never updated, the stored + `cycle_end` is fixed at create time and never drifts. + + When the caller already knows the interval (e.g. the bulk cycle generator, which + resolves the fee type once for a whole batch), it may pass it via the `:interval` + create argument; the change then uses that value and issues no fee-type query, + avoiding an N+1 lookup across a batch of cycles. + + Otherwise the fee-type interval is read with `authorize?: false`: it is an internal + lookup of immutable reference data needed to populate a derived column, not a + user-facing read, and cycle creation is itself a systemic operation. Reading it under + the caller's actor is unreliable here (the actor is not always present in the change + context, e.g. when a changeset is built with `for_create/3` and the actor is only + supplied to `Ash.create/2`). + + When `cycle_start` or `membership_fee_type_id` are missing the changeset is returned + unchanged so the normal `allow_nil?` validations surface the error. + """ + use Ash.Resource.Change + + alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.MembershipFeeType + + @impl true + def change(changeset, _opts, _context) do + with {:ok, cycle_start} <- fetch(changeset, :cycle_start), + {:ok, fee_type_id} <- fetch(changeset, :membership_fee_type_id), + {:ok, interval} <- resolve_interval(changeset, fee_type_id) do + cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval) + Ash.Changeset.force_change_attribute(changeset, :cycle_end, cycle_end) + else + _ -> changeset + end + end + + defp fetch(changeset, field) do + case Ash.Changeset.fetch_change(changeset, field) do + {:ok, value} when not is_nil(value) -> + {:ok, value} + + _ -> + case Map.get(changeset.data, field) do + nil -> :error + value -> {:ok, value} + end + end + end + + defp resolve_interval(changeset, fee_type_id) do + case Ash.Changeset.get_argument(changeset, :interval) do + nil -> fetch_interval(fee_type_id) + interval -> {:ok, interval} + end + end + + defp fetch_interval(fee_type_id) do + case Ash.get(MembershipFeeType, fee_type_id, authorize?: false) do + {:ok, %{interval: interval}} -> {:ok, interval} + _ -> :error + end + end +end diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index a6c2d300..8bd76d5e 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -305,7 +305,7 @@ defmodule Mv.MembershipFees.CycleGenerator do # Only generate if start_date <= end_date if start_date && Date.compare(start_date, end_date) != :gt do cycle_starts = generate_cycle_starts(start_date, end_date, interval) - create_cycles(cycle_starts, member.id, fee_type.id, amount, opts) + create_cycles(cycle_starts, member.id, fee_type.id, amount, interval, opts) else {:ok, [], []} end @@ -400,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do end end - defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do + defp create_cycles(cycle_starts, member_id, fee_type_id, amount, interval, opts) do actor = get_actor(opts) create_opts = Helpers.ash_actor_opts(actor) @@ -414,7 +414,10 @@ defmodule Mv.MembershipFees.CycleGenerator do member_id: member_id, membership_fee_type_id: fee_type_id, amount: amount, - status: :unpaid + status: :unpaid, + # Pass the already-resolved interval so SetCycleEnd does not re-fetch the + # fee type for every cycle in the batch (avoids an N+1 lookup). + interval: interval } handle_cycle_creation_result( diff --git a/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs b/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs new file mode 100644 index 00000000..5cabc9a5 --- /dev/null +++ b/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs @@ -0,0 +1,32 @@ +defmodule Mv.Repo.Migrations.AddCycleEndToMembershipFeeCycles do + @moduledoc """ + Adds the denormalized `cycle_end` column to `membership_fee_cycles`. + + The column is added nullable, backfilled from `cycle_start + interval` for every + existing row (via `Mv.MembershipFees.CycleEndBackfill`), and only then made + `NOT NULL`. Going forward the column is populated at create time by the + `SetCycleEnd` change. + """ + + use Ecto.Migration + + def up do + alter table(:membership_fee_cycles) do + add :cycle_end, :date + end + + flush() + + {:ok, _count} = Mv.MembershipFees.CycleEndBackfill.run() + + alter table(:membership_fee_cycles) do + modify :cycle_end, :date, null: false + end + end + + def down do + alter table(:membership_fee_cycles) do + remove :cycle_end + end + end +end diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json b/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json new file mode 100644 index 00000000..e2646f6e --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json @@ -0,0 +1,173 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "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": "cycle_start", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_end", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": 2, + "size": null, + "source": "amount", + "type": "decimal" + }, + { + "allow_nil?": false, + "default": "\"unpaid\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "455B6D49C804301DF13F950318644BF28B491535A94F4ABF7805AA1A53287401", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "membership_fee_cycles_unique_cycle_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "cycle_start" + } + ], + "name": "unique_cycle_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "membership_fee_cycles" +} \ No newline at end of file diff --git a/test/membership_fees/cycle_end_test.exs b/test/membership_fees/cycle_end_test.exs new file mode 100644 index 00000000..23e6ed59 --- /dev/null +++ b/test/membership_fees/cycle_end_test.exs @@ -0,0 +1,98 @@ +defmodule Mv.MembershipFees.CycleEndTest do + @moduledoc """ + Tests for the denormalized `cycle_end` column on membership fee cycles: + the create-time change that sets it, and the one-time backfill helper. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2] + + alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.CycleEndBackfill + alias Mv.MembershipFees.MembershipFeeCycle + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp create_member(fee_type, actor) do + member_fixture_with_actor(%{membership_fee_type_id: fee_type.id}, actor) + end + + describe "create sets cycle_end" do + for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do + test "create sets cycle_end from cycle_start + #{interval} interval", %{actor: actor} do + interval = unquote(interval) + fee_type = create_fee_type(%{interval: interval}, actor) + member = create_member(fee_type, actor) + cycle_start = ~D[2024-02-01] + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: cycle_start, + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id + }) + |> Ash.create!(actor: actor) + + assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, interval) + end + end + end + + describe "create with interval argument" do + test "uses the passed interval and skips the fee-type lookup", %{actor: actor} do + # Fee type carries :yearly, but the caller passes :monthly explicitly. + # If the interval argument is honored (no per-row fee-type lookup), the + # stored cycle_end reflects the argument, not the fee type's interval. + fee_type = create_fee_type(%{interval: :yearly}, actor) + member = create_member(fee_type, actor) + cycle_start = ~D[2024-02-01] + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: cycle_start, + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id, + interval: :monthly + }) + |> Ash.create!(actor: actor) + + assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, :monthly) + end + end + + describe "backfill" do + test "fills cycle_end for pre-existing rows matching calculate_cycle_end/2", %{actor: actor} do + fee_type = create_fee_type(%{interval: :quarterly}, actor) + member = create_member(fee_type, actor) + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: ~D[2024-04-01], + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id + }) + |> Ash.create!(actor: actor) + + # Simulate a pre-existing row that predates the cycle_end column. + Repo.query!("ALTER TABLE membership_fee_cycles ALTER COLUMN cycle_end DROP NOT NULL") + + Repo.query!("UPDATE membership_fee_cycles SET cycle_end = NULL WHERE id = $1", [ + Ecto.UUID.dump!(cycle.id) + ]) + + assert {:ok, count} = CycleEndBackfill.run() + assert count >= 1 + + reloaded = Ash.get!(MembershipFeeCycle, cycle.id, actor: actor) + assert reloaded.cycle_end == CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) + end + end +end From e64f55c36a68fc0bdead9c67d184d7efdc8e0833 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:12:38 +0200 Subject: [PATCH 02/26] feat(member): back the overview with a keyset-paginated :overview read action --- lib/membership/member.ex | 75 +++-- lib/membership/membership.ex | 1 + .../live/member_live/index/overview_query.ex | 261 ++++++++++++++++++ .../member_live/index/overview_query_test.exs | 135 +++++++++ .../index_cycle_status_filter_test.exs | 78 ++++++ .../index_keyset_property_test.exs | 86 ++++++ 6 files changed, 615 insertions(+), 21 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/overview_query.ex create mode 100644 test/mv_web/live/member_live/index/overview_query_test.exs create mode 100644 test/mv_web/member_live/index_cycle_status_filter_test.exs create mode 100644 test/mv_web/member_live/index_keyset_property_test.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 0f67a00d..f57c32a5 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -266,6 +266,15 @@ defmodule Mv.Membership.Member do accept [:vereinfacht_contact_id] end + # Keyset-paginated read for the member overview. Filters and sort are + # composed onto the query by `MvWeb.MemberLive.Index.OverviewQuery`; this + # action only enables keyset pagination with a unique `id` tie-breaker so + # pages never skip or duplicate rows. + read :overview do + description "Keyset-paginated member overview read" + pagination keyset?: true, default_limit: 50, required?: false + end + # Action to handle fuzzy search on specific fields read :search do argument :query, :string, allow_nil?: true @@ -279,27 +288,7 @@ defmodule Mv.Membership.Member do threshold = Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold - if is_binary(q) and String.trim(q) != "" do - q2 = String.trim(q) - # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars - q2_sanitized = sanitize_search_query(q2) - pat = "%" <> q2_sanitized <> "%" - - # Build search filters grouped by search type for maintainability - # Priority: FTS > Substring > Custom Fields > Fuzzy Matching - # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized) - fts_match = build_fts_filter(q2) - substring_match = build_substring_filter(q2_sanitized, pat) - custom_field_match = build_custom_field_filter(pat) - fuzzy_match = build_fuzzy_filter(q2, threshold) - - query - |> Ash.Query.filter( - expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) - ) - else - query - end + apply_fuzzy_search_filter(query, q, threshold) end end @@ -738,6 +727,13 @@ defmodule Mv.Membership.Member do end end + aggregates do + # Alphabetically-first group name, used to sort the overview by group DB-side. + # Mirrors the previous in-memory "first group name" sort key; members with no + # groups yield nil (sorted last ascending via NULLS LAST). + min :first_group_name, :groups, :name + end + # Define identities for upsert operations identities do identity :unique_email, [:email] @@ -1212,6 +1208,43 @@ defmodule Mv.Membership.Member do end end + @doc """ + Applies the same fuzzy/full-text search filter as the `:search` action as a + plain filter on the given query, without switching the query's read action. + + Used by the overview query builder so search composes with the `:overview` + keyset-paginated read action instead of replacing it. + """ + @spec apply_overview_search(Ash.Query.t(), String.t() | nil) :: Ash.Query.t() + def apply_overview_search(query, q) do + apply_fuzzy_search_filter(query, q || "", @default_similarity_threshold) + end + + # Shared by the `:search` action and `apply_overview_search/2`. + defp apply_fuzzy_search_filter(query, q, threshold) do + if is_binary(q) and String.trim(q) != "" do + q2 = String.trim(q) + # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars + q2_sanitized = sanitize_search_query(q2) + pat = "%" <> q2_sanitized <> "%" + + # Build search filters grouped by search type for maintainability + # Priority: FTS > Substring > Custom Fields > Fuzzy Matching + # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized) + fts_match = build_fts_filter(q2) + substring_match = build_substring_filter(q2_sanitized, pat) + custom_field_match = build_custom_field_filter(pat) + fuzzy_match = build_fuzzy_filter(q2, threshold) + + Ash.Query.filter( + query, + expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) + ) + else + query + end + end + # ============================================================================ # Search Input Sanitization # ============================================================================ diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2a5b17fb..b7c5974d 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -45,6 +45,7 @@ defmodule Mv.Membership do resource Mv.Membership.Member do define :create_member, action: :create_member define :list_members, action: :read + define :overview_members, action: :overview define :update_member, action: :update_member define :destroy_member, action: :destroy end diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex new file mode 100644 index 00000000..93882d4d --- /dev/null +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -0,0 +1,261 @@ +defmodule MvWeb.MemberLive.Index.OverviewQuery do + @moduledoc """ + Builds the Ash query for the member overview from the LiveView's filter/sort + state, so every filter and sort resolves in PostgreSQL via the `:overview` + keyset-paginated read action. + + All previously in-memory passes (cycle status, boolean/date custom fields, + group sort) are expressed here as DB filters/sorts. A unique `id` tie-breaker + is always appended to the sort so keyset pages never skip or duplicate rows. + + `build/1` returns an unread `Ash.Query`; the caller supplies `page:`/`actor:` + options to `Ash.read/2`. + """ + + import Ash.Expr + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.DateFilter + + require Ash.Query + + @type opts :: %{optional(atom()) => term()} + + @doc """ + Builds the `:overview` query from the given filter/sort options. + + Recognised keys (all optional): + + * `:search` — full-text search string + * `:group_filters` / `:groups` — `%{group_id => :in | :not_in}` and the valid groups + * `:fee_type_filters` / `:fee_types` — `%{fee_type_id => :in | :not_in}` and valid fee types + * `:date_filters` — built-in join/exit date filter map (see `DateFilter`) + * `:sort_field` / `:sort_order` — sort key and direction + * `:today` — reference date for cycle math (defaults to `Date.utc_today/0`) + """ + @spec build(opts()) :: Ash.Query.t() + def build(opts \\ %{}) do + Member + |> Ash.Query.for_read(:overview) + |> apply_search(opts[:search]) + |> apply_group_filters(opts[:group_filters], opts[:groups]) + |> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types]) + |> apply_date_filters(opts[:date_filters]) + |> apply_cycle_status_filter( + opts[:cycle_status_filter], + opts[:show_current_cycle], + today(opts) + ) + |> apply_sort(opts[:sort_field], opts[:sort_order]) + end + + defp today(opts), do: opts[:today] || Date.utc_today() + + # --------------------------------------------------------------------------- + # Search + # --------------------------------------------------------------------------- + + defp apply_search(query, search) when is_binary(search), + do: Member.apply_overview_search(query, search) + + defp apply_search(query, _), do: query + + # --------------------------------------------------------------------------- + # Group filters (AND across selected groups) + # --------------------------------------------------------------------------- + + defp apply_group_filters(query, group_filters, _groups) + when group_filters in [nil, %{}], + do: query + + defp apply_group_filters(query, group_filters, groups) do + valid_ids = valid_id_set(groups) + + Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> + if MapSet.member?(valid_ids, group_id_str) do + apply_one_group_filter(q, group_id_str, value) + else + q + end + end) + end + + defp apply_one_group_filter(query, group_id_str, :in) do + case Ecto.UUID.cast(group_id_str) do + {:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid))) + _ -> query + end + end + + defp apply_one_group_filter(query, group_id_str, :not_in) do + case Ecto.UUID.cast(group_id_str) do + {:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid))) + _ -> query + end + end + + defp apply_one_group_filter(query, _id, _value), do: query + + # --------------------------------------------------------------------------- + # Fee-type filters (:in OR; :not_in AND) + # --------------------------------------------------------------------------- + + defp apply_fee_type_filters(query, fee_type_filters, _fee_types) + when fee_type_filters in [nil, %{}], + do: query + + defp apply_fee_type_filters(query, fee_type_filters, fee_types) do + valid_ids = valid_id_set(fee_types) + + {in_filters, not_in_filters} = + fee_type_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) + + in_uuids = cast_uuids(Enum.map(in_filters, fn {id_str, _} -> id_str end)) + + query = + if in_uuids == [] do + query + else + Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) + end + + Enum.reduce(not_in_filters, query, fn {id_str, _}, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> + Ash.Query.filter( + q, + expr(membership_fee_type_id != ^uuid or is_nil(membership_fee_type_id)) + ) + + _ -> + q + end + end) + end + + # --------------------------------------------------------------------------- + # Cycle-status filter (paid/unpaid, current or last-completed cycle) + # + # Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member + # matches only when its selected-cycle status equals the requested status; + # members with no matching cycle (nil aggregate) are excluded — exactly as the + # previous in-memory classifier behaved. + # --------------------------------------------------------------------------- + + defp apply_cycle_status_filter(query, status, _show_current, _today) + when status not in [:paid, :unpaid], + do: query + + defp apply_cycle_status_filter(query, status, true = _show_current, today) do + # Current cycle: contains today; when several would, the one with the latest + # cycle_start wins. Keep members whose winning current cycle has `status`. + Ash.Query.filter( + query, + expr( + exists( + membership_fee_cycles, + cycle_start <= ^today and cycle_end >= ^today and status == ^status and + not exists( + member.membership_fee_cycles, + cycle_start <= ^today and cycle_end >= ^today and + cycle_start > parent(cycle_start) + ) + ) + ) + ) + end + + defp apply_cycle_status_filter(query, status, _show_current, today) do + # Last completed cycle: most recent cycle that has ended (cycle_end < today). + Ash.Query.filter( + query, + expr( + exists( + membership_fee_cycles, + cycle_end < ^today and status == ^status and + not exists( + member.membership_fee_cycles, + cycle_end < ^today and cycle_start > parent(cycle_start) + ) + ) + ) + ) + end + + # --------------------------------------------------------------------------- + # Built-in date filters (join/exit) + # --------------------------------------------------------------------------- + + defp apply_date_filters(query, nil), do: query + + defp apply_date_filters(query, filters) when is_map(filters), + do: DateFilter.apply_ash_filter(query, filters) + + # --------------------------------------------------------------------------- + # Sort (always append the unique id tie-breaker for keyset stability) + # --------------------------------------------------------------------------- + + defp apply_sort(query, nil, _order), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, _field, nil), do: Ash.Query.sort(query, id: :asc) + + defp apply_sort(query, field, order) do + case sort_key(field) do + nil -> Ash.Query.sort(query, id: :asc) + key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) + end + end + + # Resolves a sort field (atom or string) to a DB sort key, or nil if it is a + # computed field handled elsewhere / not sortable. + defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"], + do: "membership_fee_type.name" + + defp sort_key(field) when field in [:groups, "groups"], do: :first_group_name + + defp sort_key(field) when is_atom(field) do + if field in member_sort_fields(), do: field, else: nil + end + + defp sort_key(field) when is_binary(field) do + allowed = MapSet.new(member_sort_fields(), &Atom.to_string/1) + if MapSet.member?(allowed, field), do: String.to_existing_atom(field), else: nil + end + + defp sort_key(_), do: nil + + defp member_sort_fields do + Mv.Constants.member_fields() -- [:notes] + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp valid_id_set(records) when is_list(records) do + records + |> Enum.map(&to_string(&1.id)) + |> Enum.map(&normalize_uuid/1) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + end + + defp valid_id_set(_), do: MapSet.new() + + defp normalize_uuid(raw) when is_binary(raw) do + case Ecto.UUID.cast(String.trim(raw)) do + {:ok, uuid} -> to_string(uuid) + _ -> nil + end + end + + defp normalize_uuid(_), do: nil + + defp cast_uuids(id_strs) do + id_strs + |> Enum.map(&Ecto.UUID.cast/1) + |> Enum.filter(&match?({:ok, _}, &1)) + |> Enum.map(fn {:ok, uuid} -> uuid end) + end +end diff --git a/test/mv_web/live/member_live/index/overview_query_test.exs b/test/mv_web/live/member_live/index/overview_query_test.exs new file mode 100644 index 00000000..c8ec3fdc --- /dev/null +++ b/test/mv_web/live/member_live/index/overview_query_test.exs @@ -0,0 +1,135 @@ +defmodule MvWeb.MemberLive.Index.OverviewQueryTest do + @moduledoc """ + Resource-level tests for the DB-backed overview query builder: keyset + pagination and the standard DB filters/sort pushed down from the LiveView. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, + only: [member_fixture_with_actor: 2, create_fee_type: 2, group_fixture: 1] + + alias Mv.Membership + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp ids(results), do: Enum.map(results, & &1.id) |> MapSet.new() + + defp read_all_pages(query, actor, limit) do + page = Ash.read!(query, page: [limit: limit], actor: actor) + collect_pages(page, [page.results]) + end + + defp collect_pages(%{more?: true} = page, acc) do + next = Ash.page!(page, :next) + collect_pages(next, [next.results | acc]) + end + + defp collect_pages(_page, acc), do: acc |> Enum.reverse() |> List.flatten() + + describe "keyset pagination" do + test "concatenated pages cover the full set once with no duplicates", %{actor: actor} do + members = + for _ <- 1..5, do: member_fixture_with_actor(%{}, actor) + + expected = ids(members) + + query = OverviewQuery.build(%{sort_field: :inserted_at, sort_order: :asc}) + all = read_all_pages(query, actor, 2) + + assert ids(all) == expected + assert length(all) == length(Enum.uniq_by(all, & &1.id)) + end + end + + describe "DB filters" do + test "search matches name DB-side", %{actor: actor} do + hit = member_fixture_with_actor(%{first_name: "Zorblax", last_name: "Quux"}, actor) + _miss = member_fixture_with_actor(%{first_name: "Otto", last_name: "Normal"}, actor) + + results = + OverviewQuery.build(%{search: "Zorblax"}) + |> Ash.read!(actor: actor) + + assert hit.id in Enum.map(results, & &1.id) + refute Enum.any?(results, &(&1.first_name == "Otto")) + end + + test "group :in and :not_in resolve DB-side", %{actor: actor} do + group = group_fixture(%{name: "Board #{System.unique_integer([:positive])}"}) + in_member = member_fixture_with_actor(%{}, actor) + out_member = member_fixture_with_actor(%{}, actor) + + {:ok, _} = + Membership.create_member_group(%{member_id: in_member.id, group_id: group.id}, + actor: actor + ) + + opts = %{group_filters: %{to_string(group.id) => :in}, groups: [group]} + in_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id) + assert in_member.id in in_ids + refute out_member.id in in_ids + + not_opts = %{group_filters: %{to_string(group.id) => :not_in}, groups: [group]} + out_ids = OverviewQuery.build(not_opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id) + refute in_member.id in out_ids + assert out_member.id in out_ids + end + + test "fee-type :in resolves DB-side", %{actor: actor} do + ft = create_fee_type(%{}, actor) + with_ft = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor) + without_ft = member_fixture_with_actor(%{}, actor) + + opts = %{fee_type_filters: %{to_string(ft.id) => :in}, fee_types: [ft]} + result_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id) + + assert with_ft.id in result_ids + refute without_ft.id in result_ids + end + end + + describe "group sort (DB aggregate)" do + test "sorts by first group name DB-side, members with groups before those without", %{ + actor: actor + } do + g_a = group_fixture(%{name: "AAA #{System.unique_integer([:positive])}"}) + g_z = group_fixture(%{name: "ZZZ #{System.unique_integer([:positive])}"}) + + m_a = member_fixture_with_actor(%{}, actor) + m_z = member_fixture_with_actor(%{}, actor) + m_none = member_fixture_with_actor(%{}, actor) + + {:ok, _} = + Membership.create_member_group(%{member_id: m_a.id, group_id: g_a.id}, actor: actor) + + {:ok, _} = + Membership.create_member_group(%{member_id: m_z.id, group_id: g_z.id}, actor: actor) + + asc = + OverviewQuery.build(%{sort_field: :groups, sort_order: :asc}) + |> Ash.read!(actor: actor) + |> Enum.map(& &1.id) + |> Enum.filter(&(&1 in [m_a.id, m_z.id, m_none.id])) + + # group members first (AAA before ZZZ), member without groups last + assert asc == [m_a.id, m_z.id, m_none.id] + end + end + + describe "sort" do + test "sort by last_name is deterministic with id tie-breaker", %{actor: actor} do + a = member_fixture_with_actor(%{last_name: "Same"}, actor) + b = member_fixture_with_actor(%{last_name: "Same"}, actor) + + results = + OverviewQuery.build(%{sort_field: :last_name, sort_order: :asc}) + |> Ash.read!(actor: actor) + + ordered = results |> Enum.filter(&(&1.id in [a.id, b.id])) |> Enum.map(& &1.id) + assert ordered == Enum.sort([a.id, b.id]) + end + end +end diff --git a/test/mv_web/member_live/index_cycle_status_filter_test.exs b/test/mv_web/member_live/index_cycle_status_filter_test.exs new file mode 100644 index 00000000..f6bb4467 --- /dev/null +++ b/test/mv_web/member_live/index_cycle_status_filter_test.exs @@ -0,0 +1,78 @@ +defmodule MvWeb.MemberLive.IndexCycleStatusFilterTest do + @moduledoc """ + §1.13 — the paid/unpaid cycle-status filter, under the current or last-completed + cycle view, returns the same members as the previous in-memory classifier, + computed DB-side via the `cycle_end`-backed aggregates. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.MemberLive.Index.OverviewQuery + + @today ~D[2024-07-15] + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft = create_fee_type(%{interval: :yearly}, actor) + + # current 2024 paid, last-completed 2023 unpaid + paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor) + create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor) + create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor) + + # current 2024 unpaid, last-completed 2023 paid + unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor) + create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor) + create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor) + + # no cycles + _no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor) + + %{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now} + end + + defp db_ids(status, show_current, actor) do + OverviewQuery.build(%{ + cycle_status_filter: status, + show_current_cycle: show_current, + today: @today + }) + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + defp oracle_ids(status, show_current, actor) do + Member + |> Ash.Query.load(membership_fee_cycles: [:membership_fee_type]) + |> Ash.Query.load(:membership_fee_type) + |> Ash.read!(actor: actor) + |> Enum.filter(fn m -> + MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status + end) + |> MapSet.new(& &1.id) + end + + # {status, show_current, member-key expected in the result} + @cases [ + {:paid, true, :paid_now}, + {:unpaid, true, :unpaid_now}, + {:paid, false, :unpaid_now}, + {:unpaid, false, :paid_now} + ] + + for {status, show_current, expected_key} <- @cases do + test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle", + %{actor: actor} = ctx do + status = unquote(status) + show_current = unquote(show_current) + expected = Map.fetch!(ctx, unquote(expected_key)) + + db = db_ids(status, show_current, actor) + assert db == oracle_ids(status, show_current, actor) + assert expected.id in db + end + end +end diff --git a/test/mv_web/member_live/index_keyset_property_test.exs b/test/mv_web/member_live/index_keyset_property_test.exs new file mode 100644 index 00000000..aaaa43b8 --- /dev/null +++ b/test/mv_web/member_live/index_keyset_property_test.exs @@ -0,0 +1,86 @@ +defmodule MvWeb.MemberLive.IndexKeysetPropertyTest do + @moduledoc """ + §2.2 — Keyset pagination completeness: concatenating every keyset page yields + exactly the full filtered set once each (no duplicates, no gaps) in a stable + total order. + + §2.3 — Sort determinism with the unique `id` tie-breaker: members equal on the + sort key are ordered by `id`, and the result set equals the unsorted set. + """ + use Mv.DataCase, async: false + use ExUnitProperties + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp clear_members(actor) do + Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + end + + defp read_all_pages(query, actor, limit) do + page = Ash.read!(query, page: [limit: limit], actor: actor) + collect(page, page.results) + end + + defp collect(%{more?: true} = page, acc) do + next = Ash.page!(page, :next) + collect(next, acc ++ next.results) + end + + defp collect(_page, acc), do: acc + + property "keyset pages cover the full set once, no dupes or gaps", %{actor: actor} do + check all( + n <- integer(1..12), + limit <- integer(1..5), + sort <- member_of([:last_name, :join_date, :inserted_at]), + order <- member_of([:asc, :desc]), + max_runs: 25 + ) do + clear_members(actor) + # Deliberate ties on last_name so the tie-breaker is exercised. + members = + for i <- 1..n do + member_fixture_with_actor(%{last_name: "Tie#{rem(i, 3)}"}, actor) + end + + expected = MapSet.new(members, & &1.id) + + query = OverviewQuery.build(%{sort_field: sort, sort_order: order}) + paged = read_all_pages(query, actor, limit) + paged_ids = Enum.map(paged, & &1.id) + + assert MapSet.new(paged_ids) == expected + assert length(paged_ids) == MapSet.size(expected) + end + end + + property "sort with ties is a deterministic total order broken by id", %{actor: actor} do + check all( + n <- integer(2..10), + order <- member_of([:asc, :desc]), + max_runs: 25 + ) do + clear_members(actor) + + members = + for _ <- 1..n do + member_fixture_with_actor(%{last_name: "Same"}, actor) + end + + ordered = + OverviewQuery.build(%{sort_field: :last_name, sort_order: order}) + |> Ash.read!(actor: actor) + |> Enum.map(& &1.id) + + # All last_names equal -> order is fully determined by the id tie-breaker (asc). + assert ordered == Enum.sort(Enum.map(members, & &1.id)) + end + end +end From b09cdf7f3ad859c491d8875a1bb211210c895e40 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:19:59 +0200 Subject: [PATCH 03/26] feat(member): resolve custom-field filters and sorting in PostgreSQL Push the boolean and date custom-field predicates and custom-field sorting down to JSONB expressions so the overview stops classifying custom-field values in memory. A GIN index on custom_field_values.value keeps the boolean membership predicates index-served. --- lib/membership/custom_field_value.ex | 17 ++ .../live/member_live/index/date_filter.ex | 48 ++++ .../live/member_live/index/overview_query.ex | 137 +++++++++- ...dd_custom_field_values_value_gin_index.exs | 21 ++ .../custom_field_values/20260630170638.json | 148 +++++++++++ ...index_boolean_custom_field_filter_test.exs | 83 ++++++ .../index_date_custom_field_filter_test.exs | 91 +++++++ .../index_filter_parity_property_test.exs | 245 ++++++++++++++++++ .../index_overview_query_sort_test.exs | 98 +++++++ 9 files changed, 883 insertions(+), 5 deletions(-) create mode 100644 priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs create mode 100644 priv/resource_snapshots/repo/custom_field_values/20260630170638.json create mode 100644 test/mv_web/member_live/index_boolean_custom_field_filter_test.exs create mode 100644 test/mv_web/member_live/index_date_custom_field_filter_test.exs create mode 100644 test/mv_web/member_live/index_filter_parity_property_test.exs create mode 100644 test/mv_web/member_live/index_overview_query_sort_test.exs diff --git a/lib/membership/custom_field_value.ex b/lib/membership/custom_field_value.ex index 623455d6..91404aad 100644 --- a/lib/membership/custom_field_value.ex +++ b/lib/membership/custom_field_value.ex @@ -48,6 +48,13 @@ defmodule Mv.Membership.CustomFieldValue do table "custom_field_values" repo Mv.Repo + custom_indexes do + # GIN index on the JSONB value to serve boolean custom-field containment + # predicates (`value @> '{"value": }'`) used by the member overview. + # Default jsonb_ops supports the `@>` operator we rely on. + index ["value"], name: "custom_field_values_value_gin_index", using: "gin" + end + references do reference :member, on_delete: :delete reference :custom_field, on_delete: :delete @@ -133,6 +140,16 @@ defmodule Mv.Membership.CustomFieldValue do calculations do calculate :value_to_string, :string, expr(value[:value] <> "") + + # Typed sort keys for DB-side ordering of the member overview by a custom + # field. The stored JSONB value (`{"type": ..., "value": ...}`) is cast per + # the field's value type so integers sort numerically and dates + # chronologically. Empty strings collapse to NULL so they sort last, matching + # the previous in-memory `CustomFieldSort` behaviour. + calculate :sort_text, :string, expr(fragment("nullif(?->>'value','')", value)) + calculate :sort_numeric, :decimal, expr(fragment("nullif(?->>'value','')::numeric", value)) + calculate :sort_date, :date, expr(fragment("nullif(?->>'value','')::date", value)) + calculate :sort_boolean, :boolean, expr(fragment("nullif(?->>'value','')::boolean", value)) end # Ensure a member can only have one custom field value per custom field diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index 14d1aef0..b24c2f49 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -271,6 +271,54 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end end + @doc """ + DB-side equivalent of the custom-date portion of `apply_in_memory/3`. + + For each active custom-date filter, adds a predicate keeping members that have + a stored date value for the field within the inclusive `[from, to]` bounds. + The value is read from the JSONB union (`value->>'type' = 'date'`, then + `(value->>'value')::date`). Members without a date value row for the field are + excluded, mirroring the in-memory behaviour exactly. + """ + @spec apply_custom_date_ash_filter(Ash.Query.t(), map(), [map()]) :: Ash.Query.t() + def apply_custom_date_ash_filter(%Ash.Query{} = query, filters, date_custom_fields) + when is_map(filters) and is_list(date_custom_fields) do + filters + |> active_custom_date_filters(date_custom_fields) + |> Enum.reduce(query, fn {id, bounds}, q -> apply_one_custom_date_filter(q, id, bounds) end) + end + + defp apply_one_custom_date_filter(query, id, %{from: from, to: to}) do + case Ecto.UUID.cast(id) do + {:ok, uuid} -> filter_custom_date(query, uuid, from, to) + _ -> query + end + end + + defp filter_custom_date(query, uuid, from, to) do + Ash.Query.filter( + query, + expr( + exists( + custom_field_values, + custom_field_id == ^uuid and + fragment("?->>'type' = 'date'", value) and + ^custom_date_bounds(from, to) + ) + ) + ) + end + + defp custom_date_bounds(nil, to), do: expr(fragment("(?->>'value')::date <= ?", value, ^to)) + defp custom_date_bounds(from, nil), do: expr(fragment("(?->>'value')::date >= ?", value, ^from)) + + defp custom_date_bounds(from, to) do + expr( + fragment("(?->>'value')::date >= ?", value, ^from) and + fragment("(?->>'value')::date <= ?", value, ^to) + ) + end + @doc """ Returns the UUID string keys of `filters` that name an active (at-least-one- bound-set) custom date field. The UUID must appear in `date_custom_fields` diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index 93882d4d..bf4ef85c 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do alias MvWeb.MemberLive.Index.DateFilter require Ash.Query + require Ash.Sort @type opts :: %{optional(atom()) => term()} @@ -40,13 +41,18 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do |> apply_search(opts[:search]) |> apply_group_filters(opts[:group_filters], opts[:groups]) |> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types]) + |> apply_boolean_custom_field_filters( + opts[:boolean_custom_field_filters], + opts[:boolean_custom_fields] + ) |> apply_date_filters(opts[:date_filters]) + |> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields]) |> apply_cycle_status_filter( opts[:cycle_status_filter], opts[:show_current_cycle], today(opts) ) - |> apply_sort(opts[:sort_field], opts[:sort_order]) + |> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || []) end defp today(opts), do: opts[:today] || Date.utc_today() @@ -135,6 +141,48 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do end) end + # --------------------------------------------------------------------------- + # Boolean custom-field filters (AND across selected fields) + # + # Mirrors the previous in-memory behaviour: a member matches a `{field => bool}` + # filter only if it has a stored value row for that field whose boolean value + # equals `bool`. A member with no value row is excluded. Uses a JSONB + # containment predicate (`value @> '{"value": }'`), which the GIN index on + # `custom_field_values.value` can serve. + # --------------------------------------------------------------------------- + + defp apply_boolean_custom_field_filters(query, filters, _boolean_fields) + when filters in [nil, %{}], + do: query + + defp apply_boolean_custom_field_filters(query, filters, boolean_fields) do + valid_ids = valid_id_set(boolean_fields) + + filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.reduce(query, fn {id_str, bool}, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> apply_one_boolean_filter(q, uuid, bool) + _ -> q + end + end) + end + + defp apply_one_boolean_filter(query, uuid, bool) when is_boolean(bool) do + Ash.Query.filter( + query, + expr( + exists( + custom_field_values, + custom_field_id == ^uuid and + fragment("? @> jsonb_build_object('value', ?::boolean)", value, ^bool) + ) + ) + ) + end + + defp apply_one_boolean_filter(query, _uuid, _bool), do: query + # --------------------------------------------------------------------------- # Cycle-status filter (paid/unpaid, current or last-completed cycle) # @@ -193,20 +241,99 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_date_filters(query, filters) when is_map(filters), do: DateFilter.apply_ash_filter(query, filters) + defp apply_custom_date_filters(query, filters, custom_fields) + when is_map(filters) and is_list(custom_fields), + do: DateFilter.apply_custom_date_ash_filter(query, filters, custom_fields) + + defp apply_custom_date_filters(query, _filters, _custom_fields), do: query + # --------------------------------------------------------------------------- # Sort (always append the unique id tie-breaker for keyset stability) # --------------------------------------------------------------------------- - defp apply_sort(query, nil, _order), do: Ash.Query.sort(query, id: :asc) - defp apply_sort(query, _field, nil), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) - defp apply_sort(query, field, order) do + defp apply_sort(query, field, order, custom_fields) do case sort_key(field) do - nil -> Ash.Query.sort(query, id: :asc) + nil -> apply_custom_field_sort(query, field, order, custom_fields) key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) end end + # Sorts by a custom field's JSONB value DB-side. The value is read from the + # first matching `custom_field_values` row for the field, cast per the field's + # value type so integers sort numerically and dates chronologically. Empty + # strings and members without a value row sort last in both directions + # (NULLS LAST), mirroring the previous in-memory `CustomFieldSort` behaviour. + defp apply_custom_field_sort(query, field, order, custom_fields) do + with id_str when is_binary(id_str) <- custom_field_id(field), + %{} = cf <- Enum.find(custom_fields, &(to_string(&1.id) == id_str)) do + nils_order = if order == :desc, do: :desc_nils_last, else: :asc_nils_last + + Ash.Query.sort(query, [ + {custom_field_sort_calc(cf.value_type, cf.id), nils_order}, + {:id, :asc} + ]) + else + _ -> Ash.Query.sort(query, id: :asc) + end + end + + defp custom_field_id(field) when is_atom(field), do: custom_field_id(Atom.to_string(field)) + + defp custom_field_id(field) when is_binary(field) do + case String.split(field, Mv.Constants.custom_field_prefix()) do + ["", id_str] -> id_str + _ -> nil + end + end + + defp custom_field_id(_), do: nil + + # Maps the field's value type to the first matching custom-field value's typed + # sort key (scoped to the field), backed by the `sort_*` calculations on + # `CustomFieldValue` (a raw fragment is not allowed as an aggregate `field:`). + defp custom_field_sort_calc(:integer, id), + do: + Ash.Sort.expr_sort( + first(custom_field_values, + field: :sort_numeric, + query: [filter: expr(custom_field_id == ^id)] + ), + :decimal + ) + + defp custom_field_sort_calc(:date, id), + do: + Ash.Sort.expr_sort( + first(custom_field_values, + field: :sort_date, + query: [filter: expr(custom_field_id == ^id)] + ), + :date + ) + + defp custom_field_sort_calc(:boolean, id), + do: + Ash.Sort.expr_sort( + first(custom_field_values, + field: :sort_boolean, + query: [filter: expr(custom_field_id == ^id)] + ), + :boolean + ) + + defp custom_field_sort_calc(_type, id), + do: + Ash.Sort.expr_sort( + first(custom_field_values, + field: :sort_text, + query: [filter: expr(custom_field_id == ^id)] + ), + :string + ) + # Resolves a sort field (atom or string) to a DB sort key, or nil if it is a # computed field handled elsewhere / not sortable. defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"], diff --git a/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs b/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs new file mode 100644 index 00000000..00ba06c5 --- /dev/null +++ b/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddCustomFieldValuesValueGinIndex do + @moduledoc """ + Adds a GIN index on `custom_field_values.value` (JSONB) to serve the boolean + custom-field containment predicates used by the member overview. + """ + + use Ecto.Migration + + def up do + create index(:custom_field_values, ["value"], + name: "custom_field_values_value_gin_index", + using: "gin" + ) + end + + def down do + drop_if_exists index(:custom_field_values, ["value"], + name: "custom_field_values_value_gin_index" + ) + end +end diff --git a/priv/resource_snapshots/repo/custom_field_values/20260630170638.json b/priv/resource_snapshots/repo/custom_field_values/20260630170638.json new file mode 100644 index 00000000..654d4231 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_field_values/20260630170638.json @@ -0,0 +1,148 @@ +{ + "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?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "custom_field_values_member_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "custom_field_values_custom_field_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "custom_fields" + }, + "scale": null, + "size": null, + "source": "custom_field_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "value" + ], + "fields": [ + { + "type": "string", + "value": "value" + } + ], + "include": null, + "message": null, + "name": "custom_field_values_value_gin_index", + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": "gin", + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "6B19604F0AA26F503094E6AF5086FDAE0D054096B24099376EFF4BBCFF7E3CB5", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_field_values_unique_custom_field_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "custom_field_id" + } + ], + "name": "unique_custom_field_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_field_values" +} \ No newline at end of file diff --git a/test/mv_web/member_live/index_boolean_custom_field_filter_test.exs b/test/mv_web/member_live/index_boolean_custom_field_filter_test.exs new file mode 100644 index 00000000..46eaf64b --- /dev/null +++ b/test/mv_web/member_live/index_boolean_custom_field_filter_test.exs @@ -0,0 +1,83 @@ +defmodule MvWeb.MemberLive.IndexBooleanCustomFieldFilterTest do + @moduledoc """ + §1.15 — DB-backed boolean custom-field filter (in/not_in) must match the + previous in-memory behaviour exactly, including a member with no stored value + row for the field. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldValue + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter_#{System.unique_integer([:positive])}", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + yes = member_fixture_with_actor(%{}, actor) + no = member_fixture_with_actor(%{}, actor) + none = member_fixture_with_actor(%{}, actor) + + set_bool(yes, field, true, actor) + set_bool(no, field, false, actor) + + %{actor: actor, field: field, yes: yes, no: no, none: none} + end + + defp set_bool(member, field, bool, actor) do + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "boolean", "_union_value" => bool} + }) + |> Ash.create!(actor: actor) + end + + defp db_ids(filters, field, actor) do + OverviewQuery.build(%{ + boolean_custom_field_filters: filters, + boolean_custom_fields: [field] + }) + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + defp oracle_ids(filters, field, actor) do + members = + Member + |> Ash.Query.load(custom_field_values: [:custom_field]) + |> Ash.read!(actor: actor) + + Index.apply_boolean_custom_field_filters(members, filters, [field]) + |> MapSet.new(& &1.id) + end + + test "filter true matches only members with stored true value", ctx do + filters = %{to_string(ctx.field.id) => true} + assert db_ids(filters, ctx.field, ctx.actor) == oracle_ids(filters, ctx.field, ctx.actor) + assert ctx.yes.id in db_ids(filters, ctx.field, ctx.actor) + refute ctx.no.id in db_ids(filters, ctx.field, ctx.actor) + refute ctx.none.id in db_ids(filters, ctx.field, ctx.actor) + end + + test "filter false matches only members with stored false value (not missing rows)", ctx do + filters = %{to_string(ctx.field.id) => false} + assert db_ids(filters, ctx.field, ctx.actor) == oracle_ids(filters, ctx.field, ctx.actor) + assert ctx.no.id in db_ids(filters, ctx.field, ctx.actor) + refute ctx.yes.id in db_ids(filters, ctx.field, ctx.actor) + refute ctx.none.id in db_ids(filters, ctx.field, ctx.actor) + end +end diff --git a/test/mv_web/member_live/index_date_custom_field_filter_test.exs b/test/mv_web/member_live/index_date_custom_field_filter_test.exs new file mode 100644 index 00000000..3e8ad7df --- /dev/null +++ b/test/mv_web/member_live/index_date_custom_field_filter_test.exs @@ -0,0 +1,91 @@ +defmodule MvWeb.MemberLive.IndexDateCustomFieldFilterTest do + @moduledoc """ + §1.16 — DB-backed date custom-field range filter must match the previous + in-memory behaviour exactly, including inclusive boundaries and members with + no stored value row (excluded). + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldValue + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday_#{System.unique_integer([:positive])}", + value_type: :date, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + on_lower = member_with_date(field, ~D[2024-01-01], actor) + inside = member_with_date(field, ~D[2024-06-15], actor) + on_upper = member_with_date(field, ~D[2024-12-31], actor) + below = member_with_date(field, ~D[2023-12-31], actor) + above = member_with_date(field, ~D[2025-01-01], actor) + none = member_fixture_with_actor(%{}, actor) + + %{ + actor: actor, + field: field, + on_lower: on_lower, + inside: inside, + on_upper: on_upper, + below: below, + above: above, + none: none + } + end + + defp member_with_date(field, date, actor) do + member = member_fixture_with_actor(%{}, actor) + + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "date", "_union_value" => date} + }) + |> Ash.create!(actor: actor) + + member + end + + defp filters(field), do: %{to_string(field.id) => %{from: ~D[2024-01-01], to: ~D[2024-12-31]}} + + defp db_ids(field, actor) do + OverviewQuery.build(%{date_filters: filters(field), date_custom_fields: [field]}) + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + defp oracle_ids(field, actor) do + members = + Member + |> Ash.Query.load(custom_field_values: [:custom_field]) + |> Ash.read!(actor: actor) + + DateFilter.apply_in_memory(members, filters(field), [field]) + |> MapSet.new(& &1.id) + end + + test "inclusive range matches in-memory oracle, boundaries included", ctx do + db = db_ids(ctx.field, ctx.actor) + assert db == oracle_ids(ctx.field, ctx.actor) + + assert ctx.on_lower.id in db + assert ctx.inside.id in db + assert ctx.on_upper.id in db + refute ctx.below.id in db + refute ctx.above.id in db + refute ctx.none.id in db + end +end diff --git a/test/mv_web/member_live/index_filter_parity_property_test.exs b/test/mv_web/member_live/index_filter_parity_property_test.exs new file mode 100644 index 00000000..8cd17ce8 --- /dev/null +++ b/test/mv_web/member_live/index_filter_parity_property_test.exs @@ -0,0 +1,245 @@ +defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do + @moduledoc """ + §2.1 — Filter result-set parity. + + For generated member populations × filter-parameter combinations, the set of + member ids returned by the DB-backed `:overview` query equals the set returned + by the previous in-memory implementation (the reference oracle). + + The unchanged DB filters (group, fee-type) are applied identically in both + arms; the property isolates the filters that moved from memory to the DB + (boolean custom fields, date custom fields, paid/unpaid cycle status) by + applying them DB-side in one arm and in-memory in the other. + """ + use Mv.DataCase, async: false + use ExUnitProperties + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldValue + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index + alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.MemberLive.Index.OverviewQuery + + @today ~D[2024-07-15] + @cycle_starts [~D[2023-01-01], ~D[2024-01-01], ~D[2025-01-01]] + @date_values [~D[2023-06-01], ~D[2024-06-15], ~D[2025-06-01]] + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft1 = create_fee_type(%{interval: :yearly}, actor) + ft2 = create_fee_type(%{interval: :yearly}, actor) + + {:ok, bool_cf} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "flag_#{System.unique_integer([:positive])}", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + {:ok, date_cf} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "dt_#{System.unique_integer([:positive])}", + value_type: :date, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + %{actor: actor, ft1: ft1, ft2: ft2, bool_cf: bool_cf, date_cf: date_cf} + end + + # ----- generators --------------------------------------------------------- + + defp status_gen, do: StreamData.member_of([:unpaid, :paid, :suspended]) + + defp member_spec_gen do + StreamData.fixed_map(%{ + fee_type: StreamData.member_of([:ft1, :ft2]), + cycles: + StreamData.list_of( + StreamData.tuple({StreamData.member_of(@cycle_starts), status_gen()}), + max_length: 3 + ), + bool: StreamData.member_of([:none, true, false]), + date: StreamData.member_of([:none | @date_values]), + in_group: StreamData.boolean() + }) + end + + defp filter_params_gen do + StreamData.fixed_map(%{ + cycle_status: StreamData.member_of([nil, :paid, :unpaid]), + show_current: StreamData.boolean(), + bool: StreamData.member_of([nil, true, false]), + date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]), + group: StreamData.member_of([nil, :in, :not_in]), + fee_type: StreamData.member_of([nil, :in, :not_in]) + }) + end + + # ----- helpers ------------------------------------------------------------ + + defp clear_members(actor) do + Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + end + + defp create_population(specs, ctx) do + Enum.map(specs, fn spec -> + ft = if spec.fee_type == :ft1, do: ctx.ft1, else: ctx.ft2 + member = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, ctx.actor) + + spec.cycles + |> Enum.uniq_by(fn {start, _} -> start end) + |> Enum.each(fn {start, status} -> + create_cycle(member, ft, %{cycle_start: start, status: status}, ctx.actor) + end) + + maybe_set_value(member, ctx.bool_cf, spec.bool, "boolean", ctx.actor) + maybe_set_value(member, ctx.date_cf, spec.date, "date", ctx.actor) + + if spec.in_group, do: add_to_group(member, ctx, ctx.actor) + {member.id, spec} + end) + end + + defp maybe_set_value(_member, _cf, :none, _type, _actor), do: :ok + + defp maybe_set_value(member, cf, value, type, actor) do + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: cf.id, + value: %{"_union_type" => type, "_union_value" => value} + }) + |> Ash.create!(actor: actor) + end + + defp add_to_group(member, ctx, actor) do + Mv.Membership.create_member_group(%{member_id: member.id, group_id: ctx.group.id}, + actor: actor + ) + end + + defp base_opts(params, ctx) do + %{} + |> put_group(params.group, ctx) + |> put_fee_type(params.fee_type, ctx) + end + + defp put_group(opts, nil, _ctx), do: opts + + defp put_group(opts, dir, ctx), + do: Map.merge(opts, %{group_filters: %{to_string(ctx.group.id) => dir}, groups: [ctx.group]}) + + defp put_fee_type(opts, nil, _ctx), do: opts + + defp put_fee_type(opts, dir, ctx), + do: + Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]}) + + defp moved_opts(params, ctx) do + %{today: @today} + |> put_cycle(params.cycle_status, params.show_current) + |> put_bool(params.bool, ctx) + |> put_date(params.date_range, ctx) + end + + defp put_cycle(opts, nil, _show), do: opts + + defp put_cycle(opts, status, show), + do: Map.merge(opts, %{cycle_status_filter: status, show_current_cycle: show}) + + defp put_bool(opts, nil, _ctx), do: opts + + defp put_bool(opts, bool, ctx), + do: + Map.merge(opts, %{ + boolean_custom_field_filters: %{to_string(ctx.bool_cf.id) => bool}, + boolean_custom_fields: [ctx.bool_cf] + }) + + defp put_date(opts, nil, _ctx), do: opts + + defp put_date(opts, {from, to}, ctx), + do: + Map.merge(opts, %{ + date_filters: %{to_string(ctx.date_cf.id) => %{from: from, to: to}}, + date_custom_fields: [ctx.date_cf] + }) + + defp db_ids(params, ctx) do + base_opts(params, ctx) + |> Map.merge(moved_opts(params, ctx)) + |> OverviewQuery.build() + |> Ash.read!(actor: ctx.actor) + |> MapSet.new(& &1.id) + end + + defp oracle_ids(params, ctx) do + base_members = + base_opts(params, ctx) + |> OverviewQuery.build() + |> Ash.Query.load([ + :membership_fee_type, + {:custom_field_values, [:custom_field]}, + {:membership_fee_cycles, [:membership_fee_type]} + ]) + |> Ash.read!(actor: ctx.actor) + + base_members + |> apply_oracle_bool(params, ctx) + |> apply_oracle_date(params, ctx) + |> apply_oracle_cycle(params) + |> MapSet.new(& &1.id) + end + + defp apply_oracle_bool(members, %{bool: nil}, _ctx), do: members + + defp apply_oracle_bool(members, %{bool: bool}, ctx) do + Index.apply_boolean_custom_field_filters( + members, + %{to_string(ctx.bool_cf.id) => bool}, + [ctx.bool_cf] + ) + end + + defp apply_oracle_date(members, %{date_range: nil}, _ctx), do: members + + defp apply_oracle_date(members, %{date_range: {from, to}}, ctx) do + DateFilter.apply_in_memory( + members, + %{to_string(ctx.date_cf.id) => %{from: from, to: to}}, + [ctx.date_cf] + ) + end + + defp apply_oracle_cycle(members, %{cycle_status: nil}), do: members + + defp apply_oracle_cycle(members, %{cycle_status: status, show_current: show}) do + Enum.filter(members, fn m -> + MembershipFeeStatus.get_cycle_status_for_member(m, show, @today) == status + end) + end + + property "DB-backed filter result set equals the in-memory oracle", ctx do + check all( + specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5), + params <- filter_params_gen(), + max_runs: 30 + ) do + clear_members(ctx.actor) + group = Mv.Fixtures.group_fixture(%{name: "G#{System.unique_integer([:positive])}"}) + ctx = Map.put(ctx, :group, group) + + _population = create_population(specs, ctx) + + assert db_ids(params, ctx) == oracle_ids(params, ctx) + end + end +end diff --git a/test/mv_web/member_live/index_overview_query_sort_test.exs b/test/mv_web/member_live/index_overview_query_sort_test.exs new file mode 100644 index 00000000..ddf0320b --- /dev/null +++ b/test/mv_web/member_live/index_overview_query_sort_test.exs @@ -0,0 +1,98 @@ +defmodule MvWeb.MemberLive.IndexOverviewQuerySortTest do + @moduledoc """ + §2.3 — Sort determinism for custom-field sorts pushed to the DB. + + The keyset property test covers standard fields; these examples pin the + type-aware custom-field ordering (numeric vs lexical, chronological dates) and + the NULLS-LAST behaviour for missing/empty values in both directions, which + the previous in-memory `CustomFieldSort` guaranteed. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldValue + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp clear_members(actor) do + Mv.Membership.Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + end + + defp create_field(value_type, actor) do + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "sort_#{value_type}_#{System.unique_integer([:positive])}", + value_type: value_type, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + field + end + + defp set_value(member, field, union_type, union_value, actor) do + {:ok, _} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => union_type, "_union_value" => union_value} + }) + |> Ash.create(actor: actor) + end + + defp sorted_ids(field, order, actor) do + OverviewQuery.build(%{ + sort_field: "custom_field_#{field.id}", + sort_order: order, + custom_fields: [field] + }) + |> Ash.read!(actor: actor, page: [limit: 100]) + |> then(& &1.results) + |> Enum.map(& &1.id) + end + + test "integer custom field sorts numerically, missing value last in both directions", %{ + actor: actor + } do + clear_members(actor) + field = create_field(:integer, actor) + a = member_fixture_with_actor(%{first_name: "A"}, actor) + b = member_fixture_with_actor(%{first_name: "B"}, actor) + none = member_fixture_with_actor(%{first_name: "N"}, actor) + # 9 vs 10: lexical order would place "10" before "9"; numeric must not. + set_value(a, field, "integer", 10, actor) + set_value(b, field, "integer", 9, actor) + + assert sorted_ids(field, :asc, actor) == [b.id, a.id, none.id] + assert sorted_ids(field, :desc, actor) == [a.id, b.id, none.id] + end + + test "date custom field sorts chronologically", %{actor: actor} do + clear_members(actor) + field = create_field(:date, actor) + older = member_fixture_with_actor(%{first_name: "Older"}, actor) + newer = member_fixture_with_actor(%{first_name: "Newer"}, actor) + set_value(older, field, "date", "1981-01-29", actor) + set_value(newer, field, "date", "1986-07-02", actor) + + assert sorted_ids(field, :asc, actor) == [older.id, newer.id] + end + + test "empty string value sorts last like a missing value", %{actor: actor} do + clear_members(actor) + field = create_field(:string, actor) + filled = member_fixture_with_actor(%{first_name: "Filled"}, actor) + empty = member_fixture_with_actor(%{first_name: "Empty"}, actor) + set_value(filled, field, "string", "AAA", actor) + set_value(empty, field, "string", "", actor) + + assert sorted_ids(field, :asc, actor) == [filled.id, empty.id] + end +end From b79d7ac9eac468fd2ee12fd1c5f09c2c3d89b8a4 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:31:44 +0200 Subject: [PATCH 04/26] feat(member): stream the overview with keyset infinite scroll instead of loading every member --- assets/js/app.js | 63 ++ .../components/bulk_actions_dropdown.ex | 32 +- lib/mv_web/components/core_components.ex | 38 +- lib/mv_web/live/member_live/index.ex | 649 ++++++++---------- lib/mv_web/live/member_live/index.html.heex | 55 +- priv/gettext/de/LC_MESSAGES/default.po | 104 ++- priv/gettext/default.pot | 89 ++- priv/gettext/en/LC_MESSAGES/default.po | 104 ++- .../member_live/index_bulk_scope_test.exs | 71 ++ .../member_live/index_filter_panel_test.exs | 79 +++ .../member_live/index_live_region_test.exs | 68 ++ .../member_live/index_pagination_test.exs | 96 +++ test/mv_web/member_live/index_test.exs | 70 +- 13 files changed, 1067 insertions(+), 451 deletions(-) create mode 100644 test/mv_web/member_live/index_bulk_scope_test.exs create mode 100644 test/mv_web/member_live/index_filter_panel_test.exs create mode 100644 test/mv_web/member_live/index_live_region_test.exs create mode 100644 test/mv_web/member_live/index_pagination_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index a003e279..a6a7d694 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -126,6 +126,29 @@ Hooks.RowSelectionGuard = { } } +// StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width +// (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar +// lives in a full-width table row that is wider than the viewport when the table scrolls +// horizontally; matching the sticky element to the container's visible width keeps its +// centered content pinned at the bottom-center of the visible area instead of drifting off +// with the scrolled table. The data-scroll-container attribute names the container element id. +Hooks.StickyViewportWidth = { + mounted() { + this.container = document.getElementById(this.el.dataset.scrollContainer) + this.sync = () => { + if (this.container) this.el.style.width = this.container.clientWidth + "px" + } + this.sync() + window.addEventListener("resize", this.sync) + }, + updated() { + this.sync() + }, + destroyed() { + window.removeEventListener("resize", this.sync) + } +} + // FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) Hooks.FocusRestore = { mounted() { @@ -360,6 +383,37 @@ Hooks.SidebarState = { } } +// LoadMorePrefetch: fires an infinite-scroll load event as the sentinel +// approaches the viewport, rather than only once it is fully scrolled into view. +// An IntersectionObserver rooted at the scroll container with a positive bottom +// rootMargin treats the sentinel as visible while it is still that many pixels +// below the fold, so the next page loads ahead of time. It complements the +// built-in phx-viewport-bottom binding (which stays as a reliable backstop); +// duplicate loads are harmless because the server guards on "more?" and the +// stream de-duplicates rows by id. +Hooks.LoadMorePrefetch = { + mounted() { + const container = document.getElementById(this.el.dataset.scrollContainer) + const rootMargin = `0px 0px ${this.el.dataset.margin || "600px"} 0px` + const event = this.el.dataset.event || "load_more" + let lastPush = 0 + + this.observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + const now = Date.now() + if (now - lastPush < 400) return + lastPush = now + this.pushEvent(event) + }, {root: container || null, rootMargin, threshold: 0}) + + this.observer.observe(this.el) + }, + + destroyed() { + if (this.observer) this.observer.disconnect() + } +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { @@ -378,6 +432,15 @@ window.addEventListener("phx:set-input-value", (e) => { } }) +// Return the members list to the top after a sort or filter change. The server +// resets the keyset stream to page 1 on those changes and pushes this event; a +// user scrolled down would otherwise be stranded past the shorter content with +// infinite scroll not re-arming. No-op if the scroll container is absent. +window.addEventListener("phx:members:scroll-top", () => { + const el = document.getElementById("members-table-guard") + if (el) el.scrollTop = 0 +}) + // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) diff --git a/lib/mv_web/components/bulk_actions_dropdown.ex b/lib/mv_web/components/bulk_actions_dropdown.ex index d0b6172a..c6db9145 100644 --- a/lib/mv_web/components/bulk_actions_dropdown.ex +++ b/lib/mv_web/components/bulk_actions_dropdown.ex @@ -36,11 +36,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do ## Event routing - `dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the - component owns its own `:open` state. The copy item carries an *un-targeted* - `phx-click="copy_emails"`, which therefore reaches the parent LiveView's - `handle_event/3` (which keeps access to `@members`), plus the - `CopyToClipboard` hook. + `dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` *un-targeted*, so the + parent LiveView owns the `:open` state (passed back in via the `open` assign). + This lets the parent fetch the mailto recipients lazily when the dropdown opens + rather than on every selection/filter change. The copy item likewise carries an + un-targeted `phx-click="copy_emails"`, which reaches the parent's + `handle_event/3` (which keeps access to `@members`), plus the `CopyToClipboard` + hook. """ use MvWeb, :live_component use Gettext, backend: MvWeb.Gettext @@ -72,13 +74,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do |> assign(:recipient_count, assigns[:recipient_count] || 0) |> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false) - # The parent never sets :open (the component owns it via toggle/close). - # Honouring an explicit :open assign keeps the component renderable in - # isolation (render_component/2) for structural tests. + # The parent owns :open and passes it in. Defaulting to false keeps the + # component renderable in isolation (render_component/2) for structural tests + # that omit it. socket = case Map.fetch(assigns, :open) do {:ok, open} -> assign(socket, :open, open) - :error -> socket + :error -> assign_new(socket, :open, fn -> false end) end {:ok, socket} @@ -98,7 +100,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do button_label={gettext("Actions")} icon="hero-bolt" open={@open} - phx_target={@myself} + phx_target={nil} menu_width="w-70" menu_align="left" button_class="btn-secondary gap-2" @@ -232,12 +234,6 @@ defmodule MvWeb.Components.BulkActionsDropdown do end end - @impl true - def handle_event("toggle_dropdown", _params, socket) do - {:noreply, assign(socket, :open, !socket.assigns.open)} - end - - def handle_event("close_dropdown", _params, socket) do - {:noreply, assign(socket, :open, false)} - end + # Open/close are handled by the parent LiveView (see "Event routing"); the + # component no longer owns dropdown state. end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 2ed22fd1..d01b3d63 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -953,6 +953,11 @@ defmodule MvWeb.CoreComponents do doc: "when true, first header/body column gets sticky left positioning to keep selection controls visible" + attr :viewport_bottom, :any, + default: nil, + doc: + "optional phx-viewport-bottom event (string) rendered on the streamed tbody so the built-in InfiniteScroll hook appends the next page without shifting the scroll position; nil omits the binding" + slot :col, required: true do attr :label, :string attr :class, :string @@ -964,6 +969,10 @@ defmodule MvWeb.CoreComponents do slot :action, doc: "the slot for showing user actions in the last table column" + slot :footer, + doc: + "optional after-rows content rendered as a full-width row in a separate, non-streamed tbody. Combined with viewport_bottom it acts as the infinite-scroll sentinel (e.g. a loading indicator) that fires the load event and disappears once no more pages remain." + def table(assigns) do assigns = with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do @@ -986,6 +995,15 @@ defmodule MvWeb.CoreComponents do assigns = assign(assigns, :first_row_click_col_idx, first_row_click_col_idx) + # Total column span for full-width footer rows (e.g. the infinite-scroll + # loading indicator): fixed columns + dynamic custom-field columns + the + # optional action column. + col_count = + length(assigns.col) + length(assigns[:dynamic_cols] || []) + + if assigns.action != [], do: 1, else: 0 + + assigns = assign(assigns, :col_count, col_count) + ~H"""
- + + <%!-- After-rows footer in its own, non-streamed tbody so it stays put + outside the phx-update="stream" container. When viewport_bottom is set it + doubles as the infinite-scroll sentinel: rendered only while more pages + exist, so once the last page loads it disappears and stops firing. --%> + + + + {render_slot(@footer)} + + +
""" diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c991f7fe..f246f5ee 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -30,8 +30,6 @@ defmodule MvWeb.MemberLive.Index do import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Membership - alias Mv.Membership.CustomFieldSort - alias Mv.Membership.Member, as: MemberResource alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter @@ -42,6 +40,7 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.MemberLive.Index.OverviewQuery require Ash.Query require Logger @@ -173,11 +172,29 @@ defmodule MvWeb.MemberLive.Index do ) |> assign(:show_current_cycle, false) |> assign(:membership_fee_status_filter, nil) + |> assign(:page, nil) + |> assign(:after_cursor, nil) + |> assign(:more?, false) + |> assign(:total_count, 0) + |> assign(:loading?, false) + # The bulk-actions dropdown's open state lives here so the mailto recipient + # list can be fetched lazily on open (never on a mere selection change). + |> assign(:bulk_actions_open, false) + |> assign(:mailto_bcc, "") + |> assign(:recipient_count, 0) + |> assign(:mailto_disabled?, false) + |> stream_configure(:members, dom_id: &"row-#{&1.id}") + |> stream(:members, []) |> assign_export_payload() {:ok, socket} end + # Number of members fetched per keyset page (matches the :overview action's + # default_limit). Mount loads one page; further pages arrive via infinite + # scroll (phx-viewport-bottom) so the socket never holds the full table. + @page_limit 50 + # ----------------------------------------------------------------- # Handle Events # ----------------------------------------------------------------- @@ -208,12 +225,15 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:selected_members, selected) + |> restream_member(id) |> update_selection_assigns()} end @impl true def handle_event("select_all", _params, socket) do - all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new() + # Select-all spans the whole filtered set, not just the loaded page (§1.17): + # re-query every matching id. Toggling off when all are already selected. + all_ids = all_filtered_member_ids(socket) selected = if MapSet.equal?(socket.assigns.selected_members, all_ids) do @@ -225,6 +245,33 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:selected_members, selected) + |> restream_all_members() + |> update_selection_assigns()} + end + + # The bulk-actions dropdown routes its open/close here (un-targeted) so the + # mailto recipient list is fetched only when the menu actually opens, not on + # every selection or filter change. + @impl true + def handle_event("toggle_dropdown", _params, socket) do + open? = !socket.assigns.bulk_actions_open + + socket = assign(socket, :bulk_actions_open, open?) + socket = if open?, do: assign_mailto_recipients(socket), else: socket + + {:noreply, socket} + end + + @impl true + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :bulk_actions_open, false)} + end + + @impl true + def handle_event("load_more", _params, socket) do + {:noreply, + socket + |> load_more() |> update_selection_assigns()} end @@ -236,6 +283,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:show_current_cycle, new_show_current) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -247,21 +295,16 @@ defmodule MvWeb.MemberLive.Index do new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true def handle_event("copy_emails", _params, socket) do - members = socket.assigns.members - selected_ids = socket.assigns.selected_members - any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id)) - - # Recipients follow the current scope: the selection when present, otherwise - # every member in the (filtered) list. Members without an email are excluded - # in both cases (unchanged missing-email handling). With no selection we no - # longer hard-stop with "No members selected" — we act on the scope; the - # empty-recipient feedback below is preserved. - formatted_emails = scope_member_emails(members, selected_ids, any_selected?) + # Recipients follow the current scope, re-queried from the DB so a no-selection + # (all/filtered) copy or a select-all copy spans the whole filtered set rather + # than only the loaded page (§1.17). Members without an email are excluded; + # the empty-recipient feedback below is preserved. + formatted_emails = scope_emails(socket) email_count = length(formatted_emails) if email_count == 0 do @@ -310,6 +353,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:sort_order, new_order) |> update_sort_components(old_field, new_field, new_order) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() # URL sync - push_patch happens synchronously in the event handler @@ -325,7 +369,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns[:fields_in_url?] || false ) - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + {:noreply, push_reload(socket, ~p"/members?#{query_params}")} end # ----------------------------------------------------------------- @@ -347,6 +391,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:query, q) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -358,7 +403,7 @@ defmodule MvWeb.MemberLive.Index do new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -367,6 +412,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:cycle_status_filter, filter) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -377,7 +423,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -393,6 +439,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:boolean_custom_field_filters, updated_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -403,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -421,6 +468,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:group_filters, group_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -431,7 +479,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -449,6 +497,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:fee_type_filters, fee_type_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -459,7 +508,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -468,6 +517,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:date_filters, new_date_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -478,7 +528,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end # Backward compatibility: tuple form delegates to map form @@ -537,6 +587,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -547,7 +598,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -691,6 +742,9 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() end + # The (re)load is complete: clear the busy flag set by `push_reload/2` (§1.10). + socket = assign(socket, :loading?, false) + # Update sort components after rendering socket = if socket.assigns[:sort_needs_update] do @@ -806,7 +860,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) new_path = ~p"/members?#{query_params}" - push_patch(socket, to: new_path, replace: true) + push_reload(socket, new_path) end defp update_session_field_selection(socket, selection) do @@ -975,81 +1029,145 @@ defmodule MvWeb.MemberLive.Index do # Loading members # ------------------------------------------------------------- + # Loads the first keyset page of the overview and resets the stream. All + # filtering and sorting run in PostgreSQL via the `:overview` read action + # (see OverviewQuery); the socket holds only the loaded window, never the + # whole table. defp load_members(socket) do - search_query = socket.assigns.query + # `count: true` runs one COUNT for the active filter set so the live region + # can announce the exact total matching count (§1.9), not just the loaded + # page size. + page = read_overview_page(socket, limit: @page_limit, count: true) - query = - Mv.Membership.Member - |> Ash.Query.new() - |> Ash.Query.select(@overview_fields) + socket + |> assign(:members, index_by_id(page.results)) + |> assign(:after_cursor, next_cursor(page)) + |> assign(:more?, page.more?) + |> assign(:total_count, page.count || length(page.results)) + |> AshPhoenix.LiveView.assign_page_and_stream_result(page, + results_key: :members, + page_key: :page, + stream_opts: [reset: true] + ) + end - query = load_custom_field_values(query, compute_ids_to_load(socket)) + # Returns the members scroll container to the top after a sort/filter reset. + # A reset re-streams from page 1, so a user who was scrolled down would be left + # past the (now shorter) content with infinite scroll not re-arming; scrolling + # back to the top shows the new ordering from #1 and re-arms the loader. Only + # used on genuine resets, never on load_more (which must preserve position). + # The client handler (app.js) is a no-op if the container is not found. + defp scroll_list_to_top(socket), do: push_event(socket, "members:scroll-top", %{}) - query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) + # Marks the table region busy (aria-busy, §1.10) for the reload triggered by a + # filter/sort/search patch, then hands off to the patch. `handle_params` clears + # the flag once the page has been (re)loaded. + defp push_reload(socket, path) do + socket + |> assign(:loading?, true) + |> push_patch(to: path, replace: true) + end - # Load groups for each member (id, name, slug only) - query = - Ash.Query.load(query, groups: [:id, :name, :slug]) + # Fetches the next keyset page (if any) and appends it to the stream and the + # loaded window. Triggered by phx-viewport-bottom; a no-op once the last page + # has been reached so the bottom sentinel stops fetching. + defp load_more(%{assigns: %{more?: false}} = socket), do: socket - # Load membership_fee_type when the column is visible or when sorting by it - query = - if :membership_fee_type in socket.assigns.member_fields_visible or - socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do - Ash.Query.load(query, membership_fee_type: [:id, :name]) - else - query - end + defp load_more(socket) do + page = read_overview_page(socket, limit: @page_limit, after: socket.assigns.after_cursor) - query = apply_search_filter(query, search_query) + socket = + Enum.reduce(page.results, socket, fn member, acc -> + stream_insert(acc, :members, member) + end) - query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) + socket + |> assign(:members, Map.merge(socket.assigns.members, index_by_id(page.results))) + |> assign(:after_cursor, next_cursor(page)) + |> assign(:more?, page.more?) + |> assign(:page, %{page | results: nil}) + end - query = - apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) + # Loaded members are held as a `%{id => member}` map (not a list): appends on + # load_more stay O(page) via Map.merge instead of copying an ever-growing list + # with `++`, and a per-row selection re-render is an O(1) lookup by id. + defp index_by_id(members), do: Map.new(members, &{&1.id, &1}) - # Built-in date filters (join_date, exit_date) are pushed to the DB so - # excluded rows never reach the BEAM. The active_only default is part of - # this — fresh load returns only members without an exit_date or with an - # exit_date strictly in the future. - query = - DateFilter.apply_ash_filter(query, socket.assigns.date_filters) + # Re-inserts a single loaded member into the stream so its row re-renders with + # the current selection state (checkbox + selected highlight). Stream rows are + # not re-rendered on assign changes, so a selection toggle must re-stream the + # affected row. + defp restream_member(socket, id) do + case Map.get(socket.assigns[:members] || %{}, id) do + nil -> socket + member -> stream_insert(socket, :members, member) + end + end - # Use ALL custom fields for sorting (not just show_in_overview subset) - custom_fields_for_sort = socket.assigns.all_custom_fields + # Re-inserts every loaded member so all visible rows reflect a bulk selection + # change (select-all / deselect-all). + defp restream_all_members(socket) do + Enum.reduce(Map.values(socket.assigns[:members] || %{}), socket, fn member, acc -> + stream_insert(acc, :members, member) + end) + end - {query, sort_after_load} = - maybe_sort( - query, - socket.assigns.sort_field, - socket.assigns.sort_order, - custom_fields_for_sort - ) + # Reads one keyset page of the overview with display loads applied. Reads run + # as the real user actor so Ash policies are enforced. + defp read_overview_page(socket, page_opts) do + socket + |> overview_query() + |> Ash.read!(actor: current_actor(socket), page: page_opts) + end - # Errors in handle_params are handled by Phoenix LiveView - actor = current_actor(socket) - members = Ash.read!(query, actor: actor) + # The cursor of the last loaded row, used as the `after` bound for the next + # page; nil once there are no further pages. + defp next_cursor(%{more?: true, results: results}) when results != [], + do: List.last(results).__metadata__.keyset - # Custom field values are already filtered at the database level in load_custom_field_values/2 - # No need for in-memory filtering anymore + defp next_cursor(_page), do: nil - members = apply_in_memory_filters(members, socket) + # Builds the `:overview` query (filter/sort via OverviewQuery) and layers on + # the display-only loads (custom field values, cycles, groups, fee type). + defp overview_query(socket) do + socket + |> overview_query_opts() + |> OverviewQuery.build() + |> Ash.Query.select(@overview_fields) + |> load_custom_field_values(compute_ids_to_load(socket)) + |> MembershipFeeStatus.load_cycles_for_members(socket.assigns.show_current_cycle) + |> Ash.Query.load(groups: [:id, :name, :slug]) + |> maybe_load_fee_type(socket) + end - # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) - # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status - members = - if sort_after_load and - socket.assigns.sort_field != :membership_fee_status do - sort_members_in_memory( - members, - socket.assigns.sort_field, - socket.assigns.sort_order, - custom_fields_for_sort - ) - else - members - end + defp maybe_load_fee_type(query, socket) do + if :membership_fee_type in socket.assigns.member_fields_visible or + socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + else + query + end + end - assign(socket, :members, members) + # Assembles the filter/sort options OverviewQuery understands from the current + # LiveView assigns. + defp overview_query_opts(socket) do + %{ + search: socket.assigns.query, + group_filters: socket.assigns[:group_filters], + groups: socket.assigns[:groups], + fee_type_filters: socket.assigns[:fee_type_filters], + fee_types: socket.assigns[:fee_types], + boolean_custom_field_filters: socket.assigns.boolean_custom_field_filters, + boolean_custom_fields: socket.assigns.boolean_custom_fields, + date_filters: socket.assigns.date_filters, + date_custom_fields: socket.assigns[:date_custom_fields], + cycle_status_filter: socket.assigns.cycle_status_filter, + show_current_cycle: socket.assigns.show_current_cycle, + sort_field: socket.assigns.sort_field, + sort_order: socket.assigns.sort_order, + custom_fields: socket.assigns.all_custom_fields + } end # Collects every custom field UUID whose values must be loaded for a given @@ -1083,24 +1201,6 @@ defmodule MvWeb.MemberLive.Index do |> Enum.uniq() end - # Post-DB filtering: cycle status, boolean custom fields, and custom date - # fields. Date custom fields are last so they see the already-narrowed list. - defp apply_in_memory_filters(members, socket) do - members - |> apply_cycle_status_filter( - socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle - ) - |> apply_boolean_custom_field_filters( - socket.assigns.boolean_custom_field_filters, - socket.assigns.all_custom_fields - ) - |> DateFilter.apply_in_memory( - socket.assigns.date_filters, - socket.assigns[:date_custom_fields] || [] - ) - end - defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, custom_field_ids) do @@ -1117,166 +1217,10 @@ defmodule MvWeb.MemberLive.Index do # Helper Functions # ------------------------------------------------------------- - defp apply_search_filter(query, search_query) do - if search_query && String.trim(search_query) != "" do - query - |> MemberResource.fuzzy_search(%{query: search_query}) - else - query - end - end - - # Multiple group filters combine with AND: member must match all selected group conditions. - defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query - - defp apply_group_filters(query, group_filters, groups) do - valid_ids = - groups - |> Enum.map(&normalize_uuid_string(to_string(&1.id))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> - member? = MapSet.member?(valid_ids, group_id_str) - - if member? do - apply_one_group_filter(q, group_id_str, value) - else - q - end - end) - end - - defp apply_one_group_filter(query, _group_id_str, nil), do: query - - defp apply_one_group_filter(query, group_id_str, :in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, group_uuid} -> - Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid))) - - _ -> - query - end - end - - defp apply_one_group_filter(query, group_id_str, :not_in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, group_uuid} -> - Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid))) - - _ -> - query - end - end - - defp apply_one_group_filter(query, _, _), do: query - - # Fee type filters: :in selections combine with OR (member has any of the selected types); - # :not_in selections combine with AND (member must not have type A and not have type B). - defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{}, - do: query - - defp apply_fee_type_filters(query, fee_type_filters, fee_types) do - valid_ids = - fee_types - |> Enum.map(&normalize_uuid_string(to_string(&1.id))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - {in_id_strs, not_in_filters} = - fee_type_filters - |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) - |> Enum.split_with(fn {_, value} -> value == :in end) - - in_uuids = - in_id_strs - |> Enum.map(fn {id_str, _} -> id_str end) - |> Enum.map(&Ecto.UUID.cast/1) - |> Enum.filter(&match?({:ok, _}, &1)) - |> Enum.map(fn {:ok, uuid} -> uuid end) - - query = - if in_uuids == [] do - query - else - Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) - end - - Enum.reduce(not_in_filters, query, fn {fee_type_id_str, _}, q -> - apply_one_fee_type_filter(q, fee_type_id_str, :not_in) - end) - end - - defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do - case Ecto.UUID.cast(fee_type_id_str) do - {:ok, fee_type_uuid} -> - Ash.Query.filter( - query, - expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id)) - ) - - _ -> - query - end - end - - defp apply_cycle_status_filter(members, nil, _show_current), do: members - - defp apply_cycle_status_filter(members, status, show_current) - when status in [:paid, :unpaid] do - MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current) - end - defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp toggle_order(nil), do: :asc - # Function to sort the column if needed. - # Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash. - # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory. - defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false} - defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} - - defp maybe_sort(query, field, order, _custom_fields) do - # :groups is in computed_member_fields() but can be sorted in-memory - # Only :membership_fee_status should be blocked from sorting - if field == :membership_fee_status or field == "membership_fee_status" do - {query, false} - else - apply_sort_to_query(query, field, order) - end - end - - defp apply_sort_to_query(query, field, order) do - cond do - # Groups sort -> after load (in memory) - field in [:groups, "groups"] -> - {query, true} - - # Membership fee type sort -> by related name at DB - field in [:membership_fee_type, "membership_fee_type"] -> - {Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false} - - # Custom field sort -> after load - custom_field_sort?(field) -> - {query, true} - - # DB field sort (atom) - is_atom(field) -> - {Ash.Query.sort(query, [{field, order}]), false} - - # DB field sort (string) -> convert only if allowed - is_binary(field) -> - case safe_member_field_atom_only(field) do - nil -> {query, false} - atom -> {Ash.Query.sort(query, [{atom, order}]), false} - end - - true -> - {query, false} - end - end - defp valid_sort_field?(field) when is_atom(field) do # :groups is in computed_member_fields() but can be sorted # Only :membership_fee_status should be blocked @@ -1331,19 +1275,6 @@ defmodule MvWeb.MemberLive.Index do defp custom_field_sort?(_), do: false - defp extract_custom_field_id(field) when is_atom(field) do - field |> Atom.to_string() |> extract_custom_field_id() - end - - defp extract_custom_field_id(field) when is_binary(field) do - case String.split(field, @custom_field_prefix) do - ["", id_str] -> id_str - _ -> nil - end - end - - defp extract_custom_field_id(_), do: nil - defp extract_custom_field_ids(visible_custom_fields) do Enum.map(visible_custom_fields, fn field_string -> case String.split(field_string, @custom_field_prefix) do @@ -1354,91 +1285,6 @@ defmodule MvWeb.MemberLive.Index do |> Enum.filter(&(&1 != nil)) end - defp sort_members_in_memory(members, field, order, custom_fields) do - if field in [:groups, "groups"] do - sort_members_by_groups(members, order) - else - custom_field_id_str = extract_custom_field_id(field) - - case custom_field_id_str do - nil -> members - id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) - end - end - end - - defp sort_members_by_groups(members, order) do - # Members with groups first, then by first group name alphabetically (min = first by sort order) - first_group_name = fn member -> - (member.groups || []) - |> Enum.map(& &1.name) - |> Enum.min(fn -> nil end) - end - - members - |> Enum.sort_by(fn member -> - name = first_group_name.(member) - # Nil (no groups) sorts last in asc, first in desc - {name == nil, name || ""} - end) - |> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end) - end - - defp sort_members_by_custom_field(members, id_str, order, custom_fields) do - custom_field = find_custom_field_by_id(custom_fields, id_str) - - case custom_field do - nil -> members - cf -> sort_members_with_custom_field(members, cf, order) - end - end - - defp find_custom_field_by_id(custom_fields, id_str) do - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - end - - defp sort_members_with_custom_field(members, custom_field, order) do - {members_with_values, members_without_values} = - split_members_by_value_presence(members, custom_field) - - sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) - sorted_with_values ++ members_without_values - end - - defp split_members_by_value_presence(members, custom_field) do - Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end) - end - - defp has_non_empty_value?(member, custom_field) do - case get_custom_field_value(member, custom_field) do - nil -> - false - - cfv -> - not empty_value?(cfv.value, custom_field.value_type) - end - end - - defp sort_members_with_values(members_with_values, custom_field, order) do - sorted = - Enum.sort_by(members_with_values, fn member -> - cfv = get_custom_field_value(member, custom_field) - CustomFieldSort.sort_key(cfv.value, custom_field.value_type) - end) - - if order == :desc, do: Enum.reverse(sorted), else: sorted - end - - defp empty_value?(%Ash.Union{value: value, type: type}, _expected_type), - do: empty_value?(value, type) - - defp empty_value?(nil, _type), do: true - - defp empty_value?(value, type) when type in [:string, :email] and is_binary(value), - do: String.trim(value) == "" - - defp empty_value?(_value, _type), do: false - defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) @@ -1800,11 +1646,11 @@ defmodule MvWeb.MemberLive.Index do def format_date(date), do: DateFormatter.format_date(date) defp update_selection_assigns(socket) do - members = socket.assigns[:members] || [] selected_members = socket.assigns.selected_members - - selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id)) - any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id)) + # The selection may span members beyond the loaded page (after select-all), + # so its size is the MapSet size, not a count over the loaded window. + selected_count = MapSet.size(selected_members) + any_selected? = selected_count > 0 # Scope drives the trigger label: the selection when present, otherwise the # whole list (filtered, when a search term or any filter is active). @@ -1815,13 +1661,31 @@ defmodule MvWeb.MemberLive.Index do true -> :all end - # Copy/Mailto recipients: the members in scope that have a usable email. - # With a selection that is the selected subset (existing behaviour); without - # a selection it is every member in scope (deliberate behaviour change). In - # both cases members without an email are excluded, exactly as today's - # format_selected_member_emails does for the selection case. - recipient_emails = scope_member_emails(members, selected_members, any_selected?) - recipient_count = length(recipient_emails) + # No DB work here: the mailto recipient list is computed lazily when the + # bulk-actions dropdown opens (assign_mailto_recipients/1), so a selection or + # filter change never reads members for a link the user may never open. + socket + |> assign(:selected_count, selected_count) + |> assign(:scope, scope) + |> assign_export_payload() + end + + # Fetches the mailto BCC recipients for the current bulk scope. Called only when + # the bulk-actions dropdown opens (the mailto item, a native anchor, is rendered + # only then), keeping selection toggles free of DB reads. + defp assign_mailto_recipients(socket) do + selected_count = MapSet.size(socket.assigns.selected_members) + + # Size of the whole bulk scope (§1.17): the selection, or the full filtered + # total — never just the loaded page. + scope_size = if selected_count > 0, do: selected_count, else: socket.assigns.total_count + cap = Mv.Constants.max_mailto_bulk_recipients() + mailto_disabled? = scope_size >= cap + + # Mailto recipients are bounded by the cap; only re-query the (small) scope + # when it is within the cap, otherwise the link is disabled anyway. + recipient_emails = if mailto_disabled?, do: [], else: scope_emails(socket, limit: cap) + recipient_count = if mailto_disabled?, do: scope_size, else: length(recipient_emails) # RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +) mailto_bcc = @@ -1830,29 +1694,60 @@ defmodule MvWeb.MemberLive.Index do |> URI.encode_www_form() |> String.replace("+", "%20") - mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients() - socket - |> assign(:selected_count, selected_count) - |> assign(:scope, scope) |> assign(:recipient_count, recipient_count) |> assign(:mailto_disabled?, mailto_disabled?) |> assign(:mailto_bcc, mailto_bcc) - |> assign_export_payload() end - # Returns the formatted "Name " recipient list for the current scope: - # the selected members when any are selected, otherwise every member in the - # (filtered) list. Members without an email are excluded in both cases. - defp scope_member_emails(members, selected_members, true = _any_selected?), - do: format_selected_member_emails(members, selected_members) - - defp scope_member_emails(members, _selected_members, false = _any_selected?) do - members + # Formatted "Name " recipients for the current bulk scope, re-queried + # from the DB so the set spans the whole selection / filtered total rather than + # only the loaded page (§1.17). Members without an email are excluded. + defp scope_emails(socket, opts \\ []) do + socket + |> scope_members(opts) |> Enum.filter(fn member -> member.email && member.email != "" end) |> Enum.map(&format_member_email/1) end + # MapSet of every member id matching the active filters (no pagination), used + # by select-all so the selection spans the full filtered set (§1.17). + defp all_filtered_member_ids(socket) do + socket + |> overview_query_opts() + |> Map.put(:sort_field, nil) + |> OverviewQuery.build() + |> Ash.Query.select([:id]) + |> Ash.read!(actor: current_actor(socket)) + |> MapSet.new(& &1.id) + end + + # Members matching the current bulk scope: the selected members when any are + # selected, otherwise every member matching the active filters. Re-queried as + # the real user actor so policies are enforced. + defp scope_members(socket, opts) do + selected = socket.assigns.selected_members + + query = + if MapSet.size(selected) > 0 do + ids = MapSet.to_list(selected) + + Mv.Membership.Member + |> Ash.Query.for_read(:overview) + |> Ash.Query.filter(expr(id in ^ids)) + else + socket + |> overview_query_opts() + |> Map.put(:sort_field, nil) + |> OverviewQuery.build() + end + |> Ash.Query.select([:id, :first_name, :last_name, :email]) + + query = if opts[:limit], do: Ash.Query.limit(query, opts[:limit]), else: query + + Ash.read!(query, actor: current_actor(socket)) + end + @doc """ Returns true when the member list is restricted by a non-empty search term or any active filter (cycle status, group, fee type, boolean custom field, or a diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index eb7085dc..d9049146 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -5,6 +5,7 @@ <.live_component module={MvWeb.Components.BulkActionsDropdown} id="bulk-actions-dropdown" + open={@bulk_actions_open} export_payload_json={@export_payload_json} selected_count={@selected_count} scope={@scope} @@ -39,7 +40,7 @@ boolean_filters={@boolean_custom_field_filters} date_custom_fields={@date_custom_fields} date_filters={@date_filters} - member_count={length(@members)} + member_count={@total_count} /> <.tooltip content={ @@ -80,6 +81,19 @@ /> + <%!-- Polite live region: present on first render (before it is filled) so + screen readers announce the exact total matching count on every filter change + (WCAG 4.1.3). --%> +
+ {ngettext("%{count} member", "%{count} members", @total_count, count: @total_count)} +
+ <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
<.table id="members" - rows={@members} + rows={@streams.members} + row_item={fn {_dom_id, member} -> member end} wrapper_overflow_class="overflow-visible" sticky_header={true} sticky_first_col={true} - row_id={fn member -> "row-#{member.id}" end} - row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} + viewport_bottom={@more? && "load_more"} + row_click={ + fn {_dom_id, member} -> + JS.push("select_row_and_navigate", value: %{id: member.id}) + end + } row_tooltip={gettext("Click for member details")} row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} dynamic_cols={@dynamic_cols} @@ -114,7 +134,7 @@ type="checkbox" name="select_all" phx-click="select_all" - checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())} + checked={@total_count > 0 and @selected_count == @total_count} aria-label={gettext("Select all members")} role="checkbox" /> @@ -405,6 +425,31 @@
+ <:footer> + <%!-- Invisible sentinel: starts the next-page load as it approaches the + viewport (see the LoadMorePrefetch hook), so rows load ahead of reaching + the very bottom. Only rendered while more pages exist (with the footer). --%> + +
+ + {gettext("Loading more members …")} +
+ diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 38ad3e54..db07d05f 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -126,7 +126,9 @@ msgstr "Feld hinzufügen" msgid "Add members" msgstr "Mitglieder hinzufügen" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "Adresse" @@ -192,8 +194,7 @@ msgid "An account with this email already exists. Please verify your password to msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen." #: lib/mv_web/helpers/ash_error_helpers.ex -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format msgid "An error occurred" @@ -2288,14 +2289,12 @@ msgstr "Beitragseinstellungen" msgid "Membership fee start" msgstr "Beitragsbeginn" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "Membership fee type deleted" msgstr "Mitgliedsbeitragsart gelöscht" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "Membership fee type not found" msgstr "Mitgliedsbeitragsart nicht gefunden" @@ -2350,12 +2349,14 @@ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -2406,6 +2407,7 @@ msgstr "Neue*r Benutzer*in" msgid "New amount" msgstr "Neuer Betrag" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2693,10 +2695,10 @@ msgstr "Optionen" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "Bezahlt" @@ -3293,11 +3295,6 @@ msgstr "Buchungen/Belege aus Vereinfacht anzeigen" msgid "Show in overview" msgstr "In der Übersicht anzeigen" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "Spalten ein-/ausblenden" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3391,10 +3388,10 @@ msgid "Summary" msgstr "Zusammenfassung" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "Pausiert" @@ -3700,10 +3697,10 @@ msgstr "Aufhebung der Verknüpfung geplant" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" msgstr "Unbezahlt" @@ -3904,6 +3901,7 @@ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen" msgid "Yearly Interval - Joining Cycle Included" msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -3956,8 +3954,7 @@ msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linkte msgid "You do not have permission to %{action} members." msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}." -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "You do not have permission to access this membership fee type" msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen." @@ -3973,8 +3970,7 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen." msgid "You do not have permission to delete this member" msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen." -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen." @@ -4145,3 +4141,75 @@ msgstr "Öffnen" msgctxt "status" msgid "Open" msgstr "Offen" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "%{count} Mitglied" +msgstr[1] "%{count} Mitglieder" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "Suche zurücksetzen" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "Kompaktes Adressfeld" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "Kompakter Modus" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "Ansichtseinstellungen" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "inklusive E-Mail" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "Auf Standard zurücksetzen" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "Spalten" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "Weitere Mitglieder werden geladen …" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "Ansicht" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact Member field" +msgstr "Kompaktes Mitglied-Feld" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "Klicke, um nach Ort zu sortieren" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "Klicke, um nach Nachname zu sortieren" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "Keine Adresse" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 4c9a10a0..c3b21727 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -127,7 +127,9 @@ msgstr "" msgid "Add members" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "" @@ -2348,12 +2350,14 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -2404,6 +2408,7 @@ msgstr "" msgid "New amount" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2691,10 +2696,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "" @@ -3291,11 +3296,6 @@ msgstr "" msgid "Show in overview" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3389,10 +3389,10 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "" @@ -3698,10 +3698,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" msgstr "" @@ -3901,6 +3901,7 @@ msgstr "" msgid "Yearly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -4140,3 +4141,75 @@ msgstr "" msgctxt "status" msgid "Open" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact Member field" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index cda87b5a..fe871774 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -127,7 +127,9 @@ msgstr "" msgid "Add members" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "" @@ -193,8 +195,7 @@ msgid "An account with this email already exists. Please verify your password to msgstr "" #: lib/mv_web/helpers/ash_error_helpers.ex -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format msgid "An error occurred" @@ -2289,14 +2290,12 @@ msgstr "" msgid "Membership fee start" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type deleted" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type not found" msgstr "" @@ -2351,12 +2350,14 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -2407,6 +2408,7 @@ msgstr "" msgid "New amount" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2694,10 +2696,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "" @@ -3294,11 +3296,6 @@ msgstr "" msgid "Show in overview" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3392,10 +3389,10 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "" @@ -3701,10 +3698,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" msgstr "" @@ -3904,6 +3901,7 @@ msgstr "" msgid "Yearly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -3956,8 +3954,7 @@ msgstr "" msgid "You do not have permission to %{action} members." msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to access this membership fee type" msgstr "" @@ -3973,8 +3970,7 @@ msgstr "" msgid "You do not have permission to delete this member" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this membership fee type" msgstr "" @@ -4145,3 +4141,75 @@ msgstr "Open" msgctxt "status" msgid "Open" msgstr "Open" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Compact Member field" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "" diff --git a/test/mv_web/member_live/index_bulk_scope_test.exs b/test/mv_web/member_live/index_bulk_scope_test.exs new file mode 100644 index 00000000..66626539 --- /dev/null +++ b/test/mv_web/member_live/index_bulk_scope_test.exs @@ -0,0 +1,71 @@ +defmodule MvWeb.MemberLive.IndexBulkScopeTest do + @moduledoc """ + §1.17 — Bulk "all" scope spans the whole filtered set (re-queried from the DB), + not only the loaded/visible page, and the count reflects the full filtered + total. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + # More than one keyset page so "loaded" and "all matching" differ. + @total 60 + + defp seed(n) do + actor = SystemActor.get_system_actor() + + Enum.each(1..n, fn i -> + idx = String.pad_leading(Integer.to_string(i), 3, "0") + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Bulk#{idx}", last_name: "Scope", email: "bulk#{idx}@example.com"}, + actor: actor + ) + end) + end + + defp scope_badge_text(view) do + view + |> render() + |> LazyHTML.from_fragment() + |> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"])) + |> LazyHTML.text() + |> String.trim() + end + + test "select all selects the whole filtered set, not just the loaded page", %{conn: conn} do + seed(@total) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("[phx-click='select_all']") |> render_click() + + # The selection count badge reflects all matching members, not the 50 loaded. + assert scope_badge_text(view) == "#{@total}" + end + + test "copy-emails with no selection copies every matching member's email", %{conn: conn} do + seed(@total) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + result = render_hook(view, "copy_emails", %{}) + + # All 60 matching members are copied, not only the 50 loaded rows. + assert result =~ "#{@total}" + end + + test "select-all then copy copies the whole filtered set", %{conn: conn} do + seed(@total) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("[phx-click='select_all']") |> render_click() + result = render_hook(view, "copy_emails", %{}) + + assert result =~ "#{@total}" + end +end diff --git a/test/mv_web/member_live/index_filter_panel_test.exs b/test/mv_web/member_live/index_filter_panel_test.exs new file mode 100644 index 00000000..770c0944 --- /dev/null +++ b/test/mv_web/member_live/index_filter_panel_test.exs @@ -0,0 +1,79 @@ +defmodule MvWeb.MemberLive.IndexFilterPanelTest do + @moduledoc """ + §1.18 — The existing "Apply filters" panel is unchanged: same fieldset/legend + form structure and payment-status controls, no chip/add-filter-builder + paradigm (that is #548). Its filters now resolve DB-side via the `:overview` + read action. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Panel", last_name: "Member", email: "panel@example.com"}, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + defp open_filter(view) do + view + |> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"])) + |> render_click() + end + + test "the filter panel keeps its fieldset form structure and payment controls", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + open_filter(view) + + # Semantic form structure (fieldset/legend, radio controls) is preserved. + assert has_element?(view, ~s(form[data-testid="member-filter-form"])) + assert has_element?(view, "#payment-filter-all") + assert has_element?(view, "#payment-filter-paid") + assert has_element?(view, "#payment-filter-unpaid") + end + + test "no chip / add-filter-builder paradigm is present (#548 is out of scope)", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/members") + open_filter(view) + html = html <> render(view) + + refute html =~ ~s(data-testid="add-filter") + refute html =~ ~s(data-testid="filter-chip") + refute html =~ "filter-builder" + end + + test "the panel's payment filter resolves DB-side via :overview", %{conn: conn} do + system_actor = SystemActor.get_system_actor() + fee_type = Mv.Fixtures.create_fee_type(%{interval: :yearly}, system_actor) + last_year_start = Date.new!(Date.utc_today().year - 1, 1, 1) + + {:ok, paid} = + Mv.Membership.create_member( + %{ + first_name: "PaidPanel", + last_name: "X", + email: "paidpanel@example.com", + membership_fee_type_id: fee_type.id + }, + actor: system_actor + ) + + Mv.Fixtures.create_cycle( + paid, + fee_type, + %{cycle_start: last_year_start, status: :paid, replace_existing: true}, + system_actor + ) + + {:ok, _view, html} = live(conn, ~p"/members?cycle_status_filter=paid") + + assert html =~ "PaidPanel" + refute html =~ "Panel Member" + end +end diff --git a/test/mv_web/member_live/index_live_region_test.exs b/test/mv_web/member_live/index_live_region_test.exs new file mode 100644 index 00000000..152e5eed --- /dev/null +++ b/test/mv_web/member_live/index_live_region_test.exs @@ -0,0 +1,68 @@ +defmodule MvWeb.MemberLive.IndexLiveRegionTest do + @moduledoc """ + §1.9 — The polite live region announces the exact total matching count, + computed via a count query for the active filter set. + §1.10 — The live region element exists in the DOM before it is filled, and the + table region carries `aria-busy`. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + defp seed(n) do + actor = SystemActor.get_system_actor() + + Enum.each(1..n, fn i -> + idx = String.pad_leading(Integer.to_string(i), 3, "0") + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Lr#{idx}", last_name: "Region", email: "lr#{idx}@example.com"}, + actor: actor + ) + end) + end + + test "polite live region exists on first render and announces the exact total", %{conn: conn} do + seed(60) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + # Present-before-fill: the live region element is in the initial DOM. + assert has_element?(view, "#members-result-count[aria-live='polite']") + # Exact total, not the loaded page size (60 matched, only 50 loaded). + region = view |> element("#members-result-count") |> render() + assert region =~ "60 members" + end + + test "table region binds aria-busy to the loading flag (settled after load)", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + # aria-busy is bound to @loading? (`to_string(@loading?)`), not hardcoded: once + # the page has loaded the region is not busy. It is set true across a + # filter/sort/search reload patch (push_reload/2, §1.10). + assert has_element?(view, "[data-testid='members-table-scroll'][aria-busy='false']") + end + + test "count reflects the active filter set, not the whole table", %{conn: conn} do + actor = SystemActor.get_system_actor() + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Findme", last_name: "Unique", email: "findme@example.com"}, + actor: actor + ) + + seed(5) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members?query=Findme") + + # Only the single matching member is counted (1 of 6 total). + region = view |> element("#members-result-count") |> render() + assert region =~ "1 member" + refute region =~ "6 member" + end +end diff --git a/test/mv_web/member_live/index_pagination_test.exs b/test/mv_web/member_live/index_pagination_test.exs new file mode 100644 index 00000000..e21a5531 --- /dev/null +++ b/test/mv_web/member_live/index_pagination_test.exs @@ -0,0 +1,96 @@ +defmodule MvWeb.MemberLive.IndexPaginationTest do + @moduledoc """ + §1.7 — Mount loads only one keyset page, not the whole table. + §1.8 — phx-viewport-bottom fetches and appends the next page; once the last + page is reached no further fetch is issued. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + require Ash.Query + + alias Mv.Helpers.SystemActor + + # Matches the :overview default_limit / @page_limit in the LiveView. + @page_limit 50 + + defp seed_members(n) do + actor = SystemActor.get_system_actor() + + Enum.each(1..n, fn i -> + # Zero-padded names keep first_name-ascending order deterministic. + idx = String.pad_leading(Integer.to_string(i), 4, "0") + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Page#{idx}", last_name: "Member", email: "page#{idx}@example.com"}, + actor: actor + ) + end) + end + + defp row_count(html) do + ~r/]*id="row-/ |> Regex.scan(html) |> length() + end + + test "mount loads exactly one page when more members exist", %{conn: conn} do + seed_members(@page_limit + 10) + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + assert row_count(html) == @page_limit + # More rows remain, so the infinite-scroll sentinel is armed. + assert html =~ ~s(phx-viewport-bottom="load_more") + end + + test "viewport-bottom appends the next page and stops at the last page", %{conn: conn} do + seed_members(@page_limit + 10) + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, ~p"/members") + + assert row_count(html) == @page_limit + + # Fetch the next page: the remaining rows are appended to the stream. + html_after = render_hook(view, "load_more", %{}) + assert row_count(html_after) == @page_limit + 10 + + # Last page reached: the sentinel is disarmed and a further fetch is a no-op. + refute html_after =~ ~s(phx-viewport-bottom="load_more") + html_again = render_hook(view, "load_more", %{}) + assert row_count(html_again) == @page_limit + 10 + end + + test "selecting a row from a later page re-renders it as checked", %{conn: conn} do + seed_members(@page_limit + 10) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + # Load the second page so its rows are in the loaded window. + render_hook(view, "load_more", %{}) + + # The highest-index member sorts last and lives on the second page. + actor = SystemActor.get_system_actor() + + last_member = + Mv.Membership.Member + |> Ash.Query.sort(first_name: :asc) + |> Ash.read!(actor: actor) + |> List.last() + + html = render_click(view, "select_member", %{"id" => last_member.id}) + + # The re-streamed row for a later-page member reflects the selection. + assert html =~ ~s(id="row-#{last_member.id}") + assert has_element?(view, ~s(tr#row-#{last_member.id} input[type="checkbox"][checked])) + end + + test "single page does not arm the infinite-scroll sentinel", %{conn: conn} do + seed_members(3) + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + assert row_count(html) == 3 + refute html =~ ~s(phx-viewport-bottom="load_more") + end +end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 19855349..8f31beb1 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -306,7 +306,8 @@ defmodule MvWeb.MemberLive.IndexTest do # asserted on internal state to preserve the original coverage of the callback. assigns = :sys.get_state(view.pid).socket.assigns assert assigns.query == "Friedrich" - assert is_list(assigns.members) + # Loaded members are held as a `%{id => member}` map (the restream lookup window). + assert is_map(assigns.members) end @tag :ui @@ -1069,6 +1070,63 @@ defmodule MvWeb.MemberLive.IndexTest do assert bcc =~ "scope1%40example.com" refute bcc =~ "scope2%40example.com" end + + test "selecting a member does not read the DB for mailto recipients (deferred to open)", + %{conn: conn, member1: member1} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # A checkbox toggle is a non-search interaction; it must not issue a member + # read just to precompute a mailto link the user may never open. The + # recipient list is fetched lazily when the bulk-actions dropdown opens. + member_reads = + capture_member_select_queries(fn -> + render_click(view, "select_member", %{"id" => member1.id}) + end) + + assert member_reads == [], + "select_member must not read members for the mailto recipients; got: #{inspect(member_reads)}" + + # Opening the dropdown still surfaces the correct recipients for the selection. + bcc = mailto_bcc(view) + assert bcc =~ "scope1%40example.com" + refute bcc =~ "scope2%40example.com" + end + + # Captures every SELECT against the members table emitted while `fun` runs. + defp capture_member_select_queries(fun) do + test_pid = self() + handler_id = "test-member-select-#{System.unique_integer([:positive])}" + + :telemetry.attach( + handler_id, + [:mv, :repo, :query], + fn _event, _measurements, metadata, _config -> + sql = metadata[:query] || "" + + if String.contains?(sql, "SELECT") and String.contains?(sql, "\"members\"") do + send(test_pid, {:member_query, sql}) + end + end, + nil + ) + + try do + fun.() + after + :telemetry.detach(handler_id) + end + + collect_member_queries([]) + end + + defp collect_member_queries(acc) do + receive do + {:member_query, sql} -> collect_member_queries([sql | acc]) + after + 0 -> Enum.reverse(acc) + end + end end describe "cycle status filter" do @@ -2372,12 +2430,12 @@ defmodule MvWeb.MemberLive.IndexTest do # Should complete in less than 1 second (1000ms) assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms" - # Verify filtering worked correctly - should show all true members - Enum.each(1..75, fn i -> - assert html =~ "TrueMember#{i}" - end) + # The overview now keyset-paginates: mount loads only the first page, not + # the whole filtered set (§1.7). The filter still resolves DB-side, so the + # loaded page contains only matching (true) members and never a non-matching + # (false) one. + assert html =~ "TrueMember" - # Should not show false members Enum.each(1..75, fn i -> refute html =~ "FalseMember#{i}" end) From af2cc2e0d492447d3d9d81901cb1575cf552faf1 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:35:46 +0200 Subject: [PATCH 05/26] feat(member): condense the overview into composite name and address cells --- assets/css/app.css | 184 ++++++++++++++++++ lib/mv_web/components/core_components.ex | 16 +- .../live/components/search_bar_component.ex | 17 ++ lib/mv_web/live/member_live/index.html.heex | 78 ++++++-- .../member_live/index/field_visibility.ex | 27 ++- lib/mv_web/translations/member_fields.ex | 2 + .../components/sort_header_component_test.exs | 75 ++++--- .../index/field_visibility_test.exs | 26 +-- .../member_live/index_address_cell_test.exs | 87 +++++++++ .../index_custom_fields_sorting_test.exs | 10 +- .../index_default_columns_test.exs | 58 ++++++ .../index_field_visibility_test.exs | 20 +- .../index_groups_url_params_test.exs | 5 +- .../index_member_fields_display_test.exs | 15 +- .../member_live/index_search_clear_test.exs | 56 ++++++ .../member_live/index_sticky_pinned_test.exs | 56 ++++++ test/mv_web/member_live/index_test.exs | 77 ++++++-- 17 files changed, 716 insertions(+), 93 deletions(-) create mode 100644 test/mv_web/member_live/index_address_cell_test.exs create mode 100644 test/mv_web/member_live/index_default_columns_test.exs create mode 100644 test/mv_web/member_live/index_search_clear_test.exs create mode 100644 test/mv_web/member_live/index_sticky_pinned_test.exs diff --git a/assets/css/app.css b/assets/css/app.css index 611e9ad1..37091909 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -678,6 +678,78 @@ /* This file is for your main application CSS */ +/* ============================================ + Table header density scaling + ============================================ */ + +/* + * DaisyUI table size modifiers (table-xs, table-sm, etc.) scope their font-size + * rule to ":not(thead, tfoot) tr", so the header row stays at the base 0.875 rem + * regardless of the chosen density. We extend each modifier to thead so the + * header scales together with the body. + */ +.table.table-xs thead tr { font-size: 0.6875rem; } +.table.table-sm thead tr { font-size: 0.75rem; } +.table.table-lg thead tr { font-size: 1.125rem; } +.table.table-xl thead tr { font-size: 1.375rem; } + +/* + * Density-tracking badges and sort icons in the member overview. + * + * The member table renders its rows via LiveView streams, so toggling density + * only swaps the size class (table-xs <-> table-md); it does NOT + * re-render the streamed rows. A per-row `size=` conditional therefore bakes a + * stale badge size into the streamed DOM. To make badges (and, for visual + * consistency, the header sort glyphs) follow the density toggle without any + * row re-render, their size is driven purely from the table size class here. + * + * Badge values mirror daisyUI's own .badge-xs / .badge-md (font-size, --size + * which the base .badge uses for height, and padding-inline). Scoped to the + * member overview via #members-keyboard so unrelated badges are unaffected. + */ +#members-keyboard .table.table-xs .badge { + --size: calc(var(--size-selector, 0.25rem) * 4); + font-size: 0.625rem; + padding-inline: calc(0.25rem * 2 - var(--border)); +} +#members-keyboard .table.table-md .badge { + --size: calc(var(--size-selector, 0.25rem) * 6); + font-size: 0.875rem; + padding-inline: calc(0.25rem * 3 - var(--border)); +} + +/* Sort glyphs scale with the header text (11px compact / 14px comfortable). */ +#members-keyboard .table.table-md thead .sort-icon { + width: 1.5rem; + height: 1.5rem; +} +#members-keyboard .table.table-xs thead .sort-icon { + width: 1.125rem; + height: 1.125rem; +} + +/* + * Infinite-scroll loading row. + * + * The loading indicator must read like a full, accented data row rather than a + * narrow pinned bar. The accent (base-200 background + a top separator) and the + * padding live on the full-width footer cell, which spans every column via + * colspan, so the accent is visible edge-to-edge even while the table is scrolled + * horizontally. The spinner + label inside stay pinned to the visible viewport + * width (StickyViewportWidth hook) and transparent, so they remain centered on + * horizontal scroll while the cell accent shows through behind them. + * + * The row height tracks the active density (table-xs compact / table-md + * comfortable) so it matches a normal data row in each mode. + */ +#members-footer > tr > td { + background-color: var(--color-base-200); + border-top: 1px solid var(--color-base-300); + padding: 0; +} +.table.table-xs #members-loading-bar { min-height: 3.375rem; } +.table.table-md #members-loading-bar { min-height: 4.1875rem; } + /* ============================================ SortableList: drag-and-drop table rows ============================================ */ @@ -773,3 +845,115 @@ [data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) { outline: none; } + +/* + * Sticky checkbox column: border-collapse: separate avoids the Chromium artifact + * where a collapsed row border is repainted (a spurious 1px line) under a + * position: sticky cell. The row separators are moved onto the cells so they + * still render in the separate model (matching DaisyUI's faint divider color). + */ +[data-sticky-first-col-rows="true"] .table.table-zebra { + border-collapse: separate; + border-spacing: 0; +} +[data-sticky-first-col-rows="true"] + .table.table-zebra + :where(thead tr, tbody tr:not(:last-child)) + > :where(th, td) { + border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000); +} + +/* + * Horizontal-scroll fade for the pinned checkbox column: instead of hiding the + * scrolled-away content behind a hard opaque block, a gradient on the right edge + * of the sticky cell fades the incoming content, signalling there is more behind + * it. overflow: visible lets the ::after gradient extend past the cell; the cell + * keeps its own (zebra) background so the checkbox stays legible. + */ +[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell { + overflow: visible; +} +[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell::after, +[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 100%; + width: 1.25rem; + pointer-events: none; +} +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr:nth-child(odd) + > td.sticky-first-col-cell::after { + background: linear-gradient(to right, var(--color-base-100), transparent); +} +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr:nth-child(even) + > td.sticky-first-col-cell::after { + background: linear-gradient(to right, var(--color-base-200), transparent); +} +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)) + > td.sticky-first-col-cell::after { + background: linear-gradient(to right, var(--color-base-300), transparent); +} +[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after { + background: linear-gradient(to right, var(--color-base-100), transparent); +} +/* Compact density: narrower fade so the gradient does not visually reach into the + checkbox column's own content area in the tighter table-xs cell. */ +[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs td.sticky-first-col-cell::after, +[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs thead th.sticky-first-col-th::after { + width: 0.5rem; +} + +/* + * Sort-header tooltip. Rendered via the native Popover API (see the SortTooltip + * JS hook), so it lives in the browser TOP LAYER: it is not clipped by the + * members table's overflow (overflow-x:auto forces overflow-y:auto, which used + * to clip a CSS pseudo-tooltip below the header) and paints above the sticky + * header, neighbor cells, and the pinned checkbox column's fade — without any + * z-index hacks. This block only resets the popover UA defaults and gives it a + * small daisyUI-flavoured tooltip look; the JS hook sets top/left each show. + */ +.sort-tooltip { + position: fixed; + inset: auto; + margin: 0; + border: 0; + padding: 0.25rem 0.5rem; + width: max-content; + max-width: 16rem; + overflow: visible; + background: var(--color-neutral, oklch(0.2 0 0)); + color: var(--color-neutral-content, oklch(0.98 0 0)); + font-size: 0.75rem; + line-height: 1rem; + border-radius: 0.25rem; + box-shadow: 0 1px 3px rgb(0 0 0 / 0.3); + pointer-events: none; +} + +/* + * Vertically center the row checkbox in the sticky first column using the + * table-native vertical-align: middle. This keeps display: table-cell intact so + * the cell background fills the full row height (no white gap at the bottom). + * The fieldset margin-bottom reset removes the mb-2 from the shared input + * component so no residual margin shifts the checkbox off-center. + */ +[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell, +[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th { + vertical-align: middle; +} +[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell fieldset, +[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th fieldset { + margin-bottom: 0; +} + diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index d01b3d63..7af829a8 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -953,6 +953,11 @@ defmodule MvWeb.CoreComponents do doc: "when true, first header/body column gets sticky left positioning to keep selection controls visible" + attr :sticky_second_col, :boolean, + default: false, + doc: + "when true, the second column is also pinned left (e.g. an identifier column kept visible while scrolling horizontally)" + attr :viewport_bottom, :any, default: nil, doc: @@ -1018,7 +1023,9 @@ defmodule MvWeb.CoreComponents do :for={{col, col_idx} <- Enum.with_index(@col)} class={[ table_th_class(col, @sticky_header), - @sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100" + @sticky_first_col && col_idx == 0 && + "sticky-first-col-th sticky left-0 z-30 bg-base-100", + @sticky_second_col && col_idx == 1 && "sticky left-12 z-30 bg-base-100" ]} aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)} > @@ -1079,6 +1086,13 @@ defmodule MvWeb.CoreComponents do classes end + classes = + if @sticky_second_col && col_idx == 1 do + ["sticky left-12 z-20 bg-base-100" | classes] + else + classes + end + classes = if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do ["truncate" | classes] diff --git a/lib/mv_web/live/components/search_bar_component.ex b/lib/mv_web/live/components/search_bar_component.ex index ac03a637..d31c563e 100644 --- a/lib/mv_web/live/components/search_bar_component.ex +++ b/lib/mv_web/live/components/search_bar_component.ex @@ -49,6 +49,17 @@ defmodule MvWeb.Components.SearchBarComponent do phx-target={@myself} phx-debounce="300" /> + """ @@ -61,4 +72,10 @@ defmodule MvWeb.Components.SearchBarComponent do send(self(), {:search_changed, q}) {:noreply, assign(socket, :query, q)} end + + # Clears the query and resets the result set to the unfiltered list (§1.3). + def handle_event("clear_search", _params, socket) do + send(self(), {:search_changed, ""}) + {:noreply, assign(socket, :query, "")} + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index d9049146..55bf93f5 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -149,6 +149,48 @@ role="checkbox" /> + <:col + :let={member} + :if={:name in @member_fields_visible} + label={gettext("Name")} + > + <% full_name = + [member.first_name, member.last_name] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> +
+
+ {full_name} +
+
+ {member.email} +
+
+ + <:col + :let={member} + :if={:address in @member_fields_visible} + label={gettext("Address")} + > + <% line1 = + [member.street, member.house_number] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> + <% line2 = + [member.postal_code, member.city] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> + <.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}> +
+
{line1}
+
{line2}
+
+ + <:col :let={member} :if={:first_name in @member_fields_visible} @@ -280,7 +322,9 @@ """ } > - {member.city} + <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}> + {member.city} + <:col :let={member} @@ -298,7 +342,9 @@ """ } > - {member.street} + <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}> + {member.street} + <:col :let={member} @@ -316,7 +362,9 @@ """ } > - {member.house_number} + <.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}> + {member.house_number} + <:col :let={member} @@ -334,7 +382,9 @@ """ } > - {member.postal_code} + <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}> + {member.postal_code} + <:col :let={member} @@ -407,15 +457,17 @@ } > <.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}> - <%= for group <- (member.groups || []) do %> - <.badge - variant="primary" - style="outline" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> +
+ <%= for group <- (member.groups || []) do %> + <.badge + variant="primary" + style="outline" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
<:action :let={member}> diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 52ebe86f..8db026d5 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -29,7 +29,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do # Single UI key for "Membership Fee Status"; only this appears in the dropdown. # Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table). - @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups] + # :name and :address are composite display-only columns (Name = name + email, + # Address = street/house number over postal code/city). + @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address] + + # Curated default-visible column set (§1.2): the columns shown on first visit + # when there is no persisted selection and no global override. Everything else + # (the individual name/address sub-fields, notes, dates other than join date) + # defaults to hidden so the table fits common desktop widths. + @default_visible_fields MapSet.new([ + :name, + :address, + :membership_fee_type, + :membership_fee_status, + :groups, + :join_date + ]) + + @doc """ + The curated default-visible columns (member-field atoms) for a first visit. + """ + @spec default_visible_fields() :: [atom()] + def default_visible_fields, do: MapSet.to_list(@default_visible_fields) # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status @@ -276,13 +297,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do domain_map = Enum.reduce(domain_fields, %{}, fn field, acc -> field_string = Atom.to_string(field) - default_visibility = if field == :exit_date, do: false, else: true + default_visibility = MapSet.member?(@default_visible_fields, field) show_in_overview = Map.get(visibility_config, field, default_visibility) Map.put(acc, field_string, show_in_overview) end) Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc -> - Map.put(acc, Atom.to_string(field), true) + Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field)) end) end diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index fa7abff8..9fd620c3 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -32,6 +32,8 @@ defmodule MvWeb.Translations.MemberFields do def label(:membership_fee_status), do: gettext("Membership Fee Status") def label(:membership_fee_type), do: gettext("Fee Type") def label(:groups), do: gettext("Groups") + def label(:name), do: gettext("Name") + def label(:address), do: gettext("Address") # Fallback for unknown fields def label(field) do diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 0a7bf806..955c3c3f 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -2,10 +2,14 @@ defmodule MvWeb.Components.SortHeaderComponentTest do use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest + # The curated default columns hide the individual name/address sort headers, + # so these component tests make every sortable field visible via ?fields=. + @cols "first_name,email,street,house_number,postal_code,city,country,join_date" + describe "rendering" do test "renders with correct attributes", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test that the component renders with correct attributes assert has_element?(view, "[data-testid='first_name']") @@ -15,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "renders all sortable headers", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") sortable_fields = [ :first_name, @@ -35,7 +39,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "renders correct labels", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test specific labels assert has_element?(view, "button[phx-value-field='first_name']", "First name") @@ -47,7 +51,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "sort icons" do test "shows neutral icon for specific field when not sorted", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # The neutral icon has the opcity class we can test for # Test that EMAIL field specifically shows neutral icon @@ -59,7 +63,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc") + + {:ok, view, html} = + live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") # Test that FIRST_NAME field specifically shows ascending icon # Test CSS classes - no opacity for active state @@ -80,7 +86,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "shows descending icon for specific field when sorted descending", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + {:ok, _view, html} = + live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc") # Count occurrences to ensure only one descending sort icon. Dropdown # triggers carry their own trailing "hero-chevron-down size-4" chevron, so @@ -92,7 +100,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "multiple fields can have different icon states", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") # CITY field should be active (ascending) refute has_element?(view, "[data-testid='city'] .opacity-40") @@ -109,7 +119,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "icon state changes correctly when clicking different fields", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Start: all fields neutral except first name as default assert has_element?(view, "[data-testid='city'] .opacity-40") @@ -139,15 +149,19 @@ defmodule MvWeb.Components.SortHeaderComponentTest do conn = conn_with_oidc_user(conn) # Test EMAIL field specifically - {:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc") + {:ok, view, html_asc} = + live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=asc") + assert html_asc =~ "hero-chevron-up" refute has_element?(view, "[data-testid='email'] .opacity-40") - {:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc") + {:ok, view, html_desc} = + live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") + assert html_desc =~ "hero-chevron-down" refute has_element?(view, "[data-testid='email'] .opacity-40") - {:ok, view, html_neutral} = live(conn, "/members") + {:ok, view, html_neutral} = live(conn, "/members?fields=#{@cols}") assert html_neutral =~ "hero-chevron-up-down" assert has_element?(view, "[data-testid='email'] .opacity-40") end @@ -156,7 +170,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do conn = conn_with_oidc_user(conn) # Test neutral state - only one field should have active sort icon - {:ok, _view, html_neutral} = live(conn, "/members") + {:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}") # Count active icons (should be exactly 1 - ascending for default sort field) up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) @@ -166,7 +180,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do assert down_count == 0, "Expected 0 descending icons, got #{down_count}" # Test descending state - {:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc") + {:ok, _view, html_desc} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc") up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) down_count = active_sort_down_count(html_desc) @@ -190,7 +205,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "accessibility" do test "sets aria-label correctly for unsorted state", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Check aria-label for unsorted state assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']") @@ -198,7 +213,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "sets aria-label correctly for ascending sort", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") # Check aria-label for ascending sort assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']") @@ -206,7 +223,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "sets aria-label correctly for descending sort", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc") # Check aria-label for descending sort assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']") @@ -214,7 +233,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "includes tooltip with correct aria-label", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") # Check that tooltip div exists with correct data-tip assert has_element?(view, "[data-testid='first_name']") @@ -223,7 +244,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "aria-labels work for all sortable fields", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") # Test aria-labels for different fields assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']") @@ -240,7 +261,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "component behavior" do test "clicking triggers sort event on parent LiveView", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Click on the first name sort header view @@ -253,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "component handles different field types correctly", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test that different field types render correctly assert has_element?(view, "button[phx-value-field='first_name']") @@ -265,7 +286,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "edge cases" do test "handles invalid sort field gracefully", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc") + + {:ok, view, html} = + live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc") # Should not crash and should default sorting for first name assert html =~ "hero-chevron-up-down" @@ -274,7 +297,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "handles invalid sort order gracefully", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid") + + {:ok, view, html} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid") # Should default to ascending assert html =~ "hero-chevron-up" @@ -283,7 +308,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "handles empty sort parameters", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?sort_field=&sort_order=") + {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=") # Should show neutral icons assert html =~ "hero-chevron-up-down" @@ -294,7 +319,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "icon state transitions" do test "icon changes when sorting state changes", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Start with neutral state assert has_element?(view, "[data-testid='city'] .opacity-40") @@ -310,7 +335,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "multiple fields can be tested for icon states", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc") + {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") # Email should be active (descending) assert html =~ "hero-chevron-down" diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs index d86893d9..427cbead 100644 --- a/test/mv_web/live/member_live/index/field_visibility_test.exs +++ b/test/mv_web/live/member_live/index/field_visibility_test.exs @@ -89,17 +89,19 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do assert result["email"] == true end - test "defaults to true when field not in settings" do + test "defaults to the curated set when field not in settings" do user_selection = %{} - settings = %{member_field_visibility: %{first_name: false}} + settings = %{member_field_visibility: %{join_date: false}} custom_fields = [] result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - # first_name from settings - assert result["first_name"] == false - # email defaults to true (not in settings) - assert result["email"] == true + # join_date from settings overrides its curated default + assert result["join_date"] == false + # name is in the curated default set -> visible + assert result["name"] == true + # email is not in the curated default set -> hidden + assert result["email"] == false end test "handles custom fields visibility" do @@ -160,9 +162,9 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - # Should default all fields to true - assert result["first_name"] == true - assert result["email"] == true + # Falls back to the curated default set (no crash on nil) + assert result["join_date"] == true + assert result["email"] == false end test "handles missing member_field_visibility key" do @@ -172,9 +174,9 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - # Should default all fields to true - assert result["first_name"] == true - assert result["email"] == true + # Falls back to the curated default set + assert result["join_date"] == true + assert result["email"] == false end test "includes all fields in result" do diff --git a/test/mv_web/member_live/index_address_cell_test.exs b/test/mv_web/member_live/index_address_cell_test.exs new file mode 100644 index 00000000..53d8dc04 --- /dev/null +++ b/test/mv_web/member_live/index_address_cell_test.exs @@ -0,0 +1,87 @@ +defmodule MvWeb.MemberLive.IndexAddressCellTest do + @moduledoc """ + §1.1 — The address renders as one composite cell: line 1 = street + house + number, line 2 = postal code + city. + §1.12 — City and postal code stay findable via search despite the composite + cell. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + defp create_member(attrs) do + {:ok, member} = + Mv.Membership.create_member( + Map.merge( + %{first_name: "Addr", last_name: "Tester", email: "addr@example.com"}, + attrs + ), + actor: SystemActor.get_system_actor() + ) + + member + end + + test "address shows as one cell with street/house on line 1 and postal/city on line 2", %{ + conn: conn + } do + member = + create_member(%{ + street: "Hauptstraße", + house_number: "12a", + postal_code: "10115", + city: "Berlin" + }) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + cell = + view + |> element("#row-#{member.id} [data-testid='member-address']") + |> render() + + assert cell =~ "Hauptstraße" + assert cell =~ "12a" + assert cell =~ "10115" + assert cell =~ "Berlin" + + # Composite, not four separate sortable address columns. + refute has_element?(view, "[data-testid='street']") + refute has_element?(view, "[data-testid='city']") + end + + test "member with no address parts renders a screen-reader label, not a blank cell", %{ + conn: conn + } do + member = create_member(%{email: "no-address@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + row = view |> element("#row-#{member.id}") |> render() + + assert row =~ "No address" + refute has_element?(view, "#row-#{member.id} [data-testid='member-address']") + end + + test "members stay findable by city via search", %{conn: conn} do + member = create_member(%{city: "Hamburg", email: "city-search@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members?query=Hamburg") + + assert has_element?(view, "#row-#{member.id}") + end + + test "members stay findable by postal code via search", %{conn: conn} do + member = create_member(%{postal_code: "99999", email: "postal-search@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members?query=99999") + + assert has_element?(view, "#row-#{member.id}") + end +end diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index 4119205c..0355766b 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -206,14 +206,20 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = - live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + live( + conn, + "/members?fields=email&query=&sort_field=custom_field_#{field.id}&sort_order=desc" + ) # Click on email column view |> element("[data-testid='email']") |> render_click() - assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + # The fields param rides along on the patch, so assert the sort outcome. + path = assert_patch(view) + assert path =~ "sort_field=email" + assert path =~ "sort_order=asc" end test "clicking custom field column after regular column works", %{ diff --git a/test/mv_web/member_live/index_default_columns_test.exs b/test/mv_web/member_live/index_default_columns_test.exs new file mode 100644 index 00000000..04d3c993 --- /dev/null +++ b/test/mv_web/member_live/index_default_columns_test.exs @@ -0,0 +1,58 @@ +defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do + @moduledoc """ + §1.2 — With no persisted column selection, exactly the curated default columns + are visible: selection checkbox, Name (name + email), Address (composite), + fee type, fee status, groups, join date. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Col", + last_name: "Default", + email: "col@example.com", + street: "Musterweg", + house_number: "1", + postal_code: "10115", + city: "Berlin" + }, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + test "curated columns are visible by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + # Composite Name + Address cells. + assert has_element?(view, "[data-testid='member-name']") + assert has_element?(view, "[data-testid='member-address']") + # Fee type, fee status, groups, join date headers. + assert has_element?(view, "[data-testid='membership_fee_type']") + assert has_element?(view, "[data-testid='join_date']") + assert has_element?(view, "th", "Membership Fee Status") + assert has_element?(view, "[data-testid='groups']") + end + + test "the individual name/address sub-fields are hidden by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + for field <- ~w(first_name last_name email city street house_number postal_code country) do + refute has_element?(view, "[data-testid='#{field}']") + end + end + + test "an explicit field selection still overrides the curated default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members?fields=email") + + assert has_element?(view, "[data-testid='email']") + refute has_element?(view, "[data-testid='member-name']") + end +end diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs index 63dff1c4..71744f83 100644 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -148,20 +148,20 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Verify email is visible initially + # The curated Name column carries the email, so it is visible initially. html = render(view) assert html =~ "alice@example.com" - # Open dropdown and hide email + # Open dropdown and hide the Name column view |> element("button[aria-controls='field-visibility-menu']") |> render_click() view - |> element("button[phx-click='select_item'][phx-value-item='email']") + |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_click() - # Email should no longer be visible + # The Name column (and the email it carries) is no longer visible html = render(view) refute html =~ "alice@example.com" refute html =~ "bob@example.com" @@ -309,13 +309,13 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Hide a field via dropdown + # Hide the curated Name column (which carries the email) via dropdown view |> element("button[aria-controls='field-visibility-menu']") |> render_click() view - |> element("button[phx-click='select_item'][phx-value-item='email']") + |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_click() html = render(view) @@ -429,7 +429,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Verify email is visible initially + # The curated Name column carries the email, so it is visible initially. html = render(view) assert html =~ "alice@example.com" @@ -438,12 +438,12 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do |> element("button[aria-controls='field-visibility-menu']") |> render_click() - # Simulate Enter key press on email field button + # Simulate Enter key press on the Name field button view - |> element("button[phx-click='select_item'][phx-value-item='email']") + |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_keydown(%{key: "Enter"}) - # Email should no longer be visible + # The Name column (and the email it carries) is no longer visible html = render(view) refute html =~ "alice@example.com" end diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs index 8ce82aaa..8b92cb90 100644 --- a/test/mv_web/member_live/index_groups_url_params_test.exs +++ b/test/mv_web/member_live/index_groups_url_params_test.exs @@ -125,7 +125,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do conn = conn_with_oidc_user(conn) {:ok, view, html} = - live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in") + live( + conn, + "/members?fields=first_name&sort_field=first_name&sort_order=desc&group_#{group1.id}=in" + ) assert html =~ member1.first_name assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']") diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index f3dadac5..6c222276 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -43,22 +43,23 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do end end - test "respects show_in_overview config", %{conn: conn, member1: m} do + test "respects show_in_overview config", %{conn: conn} do + # Global settings still drive column visibility: hiding a curated default + # column via settings removes it, while unaffected columns stay visible. {: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}) + member_field_visibility: %{"join_date" => false} }) conn = conn_with_oidc_user(conn) # Use search query to filter to only the expected member (Alice) # This significantly improves test performance by avoiding loading all members from other tests - {:ok, _view, html} = live(conn, "/members?query=Alice") + {:ok, view, _html} = live(conn, "/members?query=Alice") - assert html =~ "Email" - assert html =~ m.email - refute html =~ m.street + refute has_element?(view, "[data-testid='join_date']") + # The composite Name column (and its email content) is unaffected. + assert has_element?(view, "[data-testid='member-name']") end end diff --git a/test/mv_web/member_live/index_search_clear_test.exs b/test/mv_web/member_live/index_search_clear_test.exs new file mode 100644 index 00000000..14a8aa25 --- /dev/null +++ b/test/mv_web/member_live/index_search_clear_test.exs @@ -0,0 +1,56 @@ +defmodule MvWeb.MemberLive.IndexSearchClearTest do + @moduledoc """ + §1.3 — Activating the search clear (×) control clears the query, removes the + URL search param, and resets the result set to the unfiltered list. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + actor = SystemActor.get_system_actor() + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Findable", last_name: "One", email: "findable@example.com"}, + actor: actor + ) + + {:ok, other} = + Mv.Membership.create_member( + %{first_name: "Other", last_name: "Two", email: "other@example.com"}, + actor: actor + ) + + %{conn: conn_with_oidc_user(conn), other: other} + end + + test "clear control only appears when a query is present", %{conn: conn} do + {:ok, no_query, _} = live(conn, ~p"/members") + refute has_element?(no_query, "[data-testid='search-clear']") + + {:ok, with_query, _} = live(conn, ~p"/members?query=Findable") + assert has_element?(with_query, "[data-testid='search-clear']") + end + + test "clear resets query, URL param and the result set", %{conn: conn, other: other} do + {:ok, view, _html} = live(conn, ~p"/members?query=Findable") + + # The filtered list excludes the non-matching member. + refute has_element?(view, "#row-#{other.id}") + + view |> element("[data-testid='search-clear']") |> render_click() + + # Query cleared in the URL (search param reset to empty). + path = assert_patch(view) + assert path =~ "query=" + refute path =~ "query=Findable" + + # Result set resets to the unfiltered list (the previously excluded member returns). + assert has_element?(view, "#row-#{other.id}") + # The input no longer carries the query. + refute has_element?(view, "[data-testid='search-input'][value='Findable']") + end +end diff --git a/test/mv_web/member_live/index_sticky_pinned_test.exs b/test/mv_web/member_live/index_sticky_pinned_test.exs new file mode 100644 index 00000000..95eac861 --- /dev/null +++ b/test/mv_web/member_live/index_sticky_pinned_test.exs @@ -0,0 +1,56 @@ +defmodule MvWeb.MemberLive.IndexStickyPinnedTest do + @moduledoc """ + §1.19 — When the table scrolls vertically and horizontally, the header stays + sticky and the identifier (Name) column stays pinned; overflowing cell content + truncates with ellipsis and exposes a hover/focus tooltip. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + @moduletag :ui + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Reginald", + last_name: "Worthington-Smythe", + email: "reginald@example.com", + street: "A Very Long Street Name That Overflows", + house_number: "123", + postal_code: "10115", + city: "Berlin" + }, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + test "header is sticky and the identifier column is pinned", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/members") + + # Sticky header (desktop). + assert html =~ "lg:sticky" + assert html =~ "lg:top-0" + + # Checkbox column pinned at left-0, identifier (Name) column pinned beside it. + assert html =~ "left-0" + assert html =~ "left-12" + end + + test "composite cells truncate and expose a tooltip via title", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + name_cell = view |> element("[data-testid='member-name']") |> render() + assert name_cell =~ "truncate" + assert name_cell =~ ~s(title="Reginald Worthington-Smythe") + + address_cell = view |> element("[data-testid='member-address']") |> render() + assert address_cell =~ "truncate" + assert address_cell =~ ~s(title="A Very Long Street Name That Overflows 123") + end +end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 8f31beb1..e1de77a8 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -154,9 +154,13 @@ defmodule MvWeb.MemberLive.IndexTest do describe "sorting integration" do @describetag :ui + # The curated default columns no longer expose the individual name/address + # sort headers, so these tests make the relevant column visible via ?fields=. + # That puts a `fields` param on every push_patch, hence the relaxed + # substring assertions instead of exact patch strings. test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=email") # The component data test ids are built with the name of the field # First click – should sort ASC @@ -164,32 +168,44 @@ defmodule MvWeb.MemberLive.IndexTest do |> element("[data-testid='email']") |> render_click() - # The LiveView pushes a patch with the new query params - assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + path_asc = assert_patch(view) + assert path_asc =~ "sort_field=email" + assert path_asc =~ "sort_order=asc" # Second click – toggles to DESC view |> element("[data-testid='email']") |> render_click() - assert_patch(view, "/members?query=&sort_field=email&sort_order=desc") + path_desc = assert_patch(view) + assert path_desc =~ "sort_field=email" + assert path_desc =~ "sort_order=desc" end test "clicking different column header resets order to ascending", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc") # Click on a different column view |> element("[data-testid='first_name']") |> render_click() - assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc") + path = assert_patch(view) + assert path =~ "sort_field=first_name" + assert path =~ "sort_order=asc" end test "all sortable columns work correctly", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + + {:ok, view, _html} = + live( + conn, + "/members?fields=first_name,email,street,house_number,postal_code,city,country,join_date" + ) # default ascending sorting with first name assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']") @@ -209,30 +225,40 @@ defmodule MvWeb.MemberLive.IndexTest do |> element("[data-testid='#{field}']") |> render_click() - assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc") + path = assert_patch(view) + assert path =~ "sort_field=#{field}" + assert path =~ "sort_order=asc" end end test "sorting works with search query", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=test") + {:ok, view, _html} = live(conn, "/members?fields=email&query=test") view |> element("[data-testid='email']") |> render_click() - assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc") + path = assert_patch(view) + assert path =~ "query=test" + assert path =~ "sort_field=email" + assert path =~ "sort_order=asc" end test "sorting maintains search query when toggling order", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc") view |> element("[data-testid='email']") |> render_click() - assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc") + path = assert_patch(view) + assert path =~ "query=test" + assert path =~ "sort_field=email" + assert path =~ "sort_order=desc" end end @@ -240,7 +266,9 @@ defmodule MvWeb.MemberLive.IndexTest do @describetag :ui test "handle_params reads sort query and applies it", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc") # Check that the sort state is correctly applied assert has_element?(view, "[data-testid='email'][aria-label='descending']") @@ -248,7 +276,9 @@ defmodule MvWeb.MemberLive.IndexTest do test "handle_params handles invalid sort field gracefully", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc") # Should not crash and should show default first name order assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']") @@ -256,7 +286,9 @@ defmodule MvWeb.MemberLive.IndexTest do test "handle_params preserves search query with sort params", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=desc") # Both search and sort should be preserved assert has_element?(view, "[data-testid='email'][aria-label='descending']") @@ -267,7 +299,9 @@ defmodule MvWeb.MemberLive.IndexTest do @describetag :ui test "search maintains sort state", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc") # Perform search view @@ -280,7 +314,9 @@ defmodule MvWeb.MemberLive.IndexTest do test "sort maintains search state", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc") # Perform sort view @@ -288,7 +324,10 @@ defmodule MvWeb.MemberLive.IndexTest do |> render_click() # Search state should be maintained - assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc") + path = assert_patch(view) + assert path =~ "query=test" + assert path =~ "sort_field=email" + assert path =~ "sort_order=desc" end end @@ -1578,7 +1617,7 @@ defmodule MvWeb.MemberLive.IndexTest do boolean_field = create_boolean_custom_field() {:ok, view, _html} = - live(conn, "/members?bf_#{boolean_field.id}=true") + live(conn, "/members?fields=email&bf_#{boolean_field.id}=true") # Test sort toggle preserves filter view From dfc616257d453428fe0f872c697b90714aa437d9 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 10:45:53 +0200 Subject: [PATCH 06/26] feat(member): let members tailor overview density and visible columns The View dropdown drives row density and whether the Member and Address fields render as composite cells or split into their underlying columns; the column manager toggles visibility and resets to the curated default. All choices persist per browser through the URL/session/cookie/global chain, so a saved layout survives reloads without a per-account store. --- assets/js/app.js | 42 ++- lib/mv_web/components/core_components.ex | 38 +- .../field_visibility_dropdown_component.ex | 10 +- .../live/components/sort_header_component.ex | 7 +- .../view_settings_dropdown_component.ex | 129 +++++++ lib/mv_web/live/member_live/index.ex | 348 ++++++++++++++---- lib/mv_web/live/member_live/index.html.heex | 281 ++++++++------ .../member_live/index/field_visibility.ex | 217 ++++++++++- .../live/member_live/index/view_settings.ex | 186 ++++++++++ .../components/sort_header_component_test.exs | 58 +-- .../index/field_visibility_test.exs | 50 +++ .../member_live/index/view_settings_test.exs | 109 ++++++ .../member_live/index_a11y_hardening_test.exs | 78 ++++ .../member_live/index_column_manager_test.exs | 76 ++++ .../index_default_columns_test.exs | 11 +- .../mv_web/member_live/index_density_test.exs | 73 ++++ .../index_field_visibility_test.exs | 49 ++- .../index_groups_url_params_test.exs | 12 +- .../index_member_fields_display_test.exs | 3 + test/mv_web/member_live/index_test.exs | 27 +- .../member_live/index_view_settings_test.exs | 102 +++++ 21 files changed, 1643 insertions(+), 263 deletions(-) create mode 100644 lib/mv_web/live/components/view_settings_dropdown_component.ex create mode 100644 lib/mv_web/live/member_live/index/view_settings.ex create mode 100644 test/mv_web/live/member_live/index/view_settings_test.exs create mode 100644 test/mv_web/member_live/index_a11y_hardening_test.exs create mode 100644 test/mv_web/member_live/index_column_manager_test.exs create mode 100644 test/mv_web/member_live/index_density_test.exs create mode 100644 test/mv_web/member_live/index_view_settings_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index a6a7d694..85bbc6ba 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -414,15 +414,46 @@ Hooks.LoadMorePrefetch = { } } +// Reads a browser cookie value by name (used to echo the persisted member +// view-settings to the server on connect, since the live socket's connect-info +// does not expose cookies). +function getCookie(name) { + const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")) + return match ? decodeURIComponent(match[1]) : null +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, - timezone: getBrowserTimezone() + timezone: getBrowserTimezone(), + view_settings: getCookie("member_view_settings") }, hooks: Hooks }) +// Persist the member-overview view settings per browser/device (§1.5/§1.20). The +// LiveView pushes "store-view-settings" (density + compact field toggles) on +// change; we write a long-lived cookie that the LiveView reads back on the next +// full page load (via the request cookie on the dead render and via connect +// params on the connected mount). +// Return the members list to the top after a sort or filter change. The server +// resets the keyset stream to page 1 on those changes and pushes this event; a +// user scrolled down would otherwise be stranded past the shorter content with +// infinite scroll not re-arming. No-op if the scroll container is absent. +window.addEventListener("phx:members:scroll-top", () => { + const el = document.getElementById("members-table-guard") + if (el) el.scrollTop = 0 +}) + +window.addEventListener("phx:store-view-settings", (e) => { + const json = e.detail && e.detail.view_settings + if (typeof json === "string" && json.length > 0) { + const maxAge = 365 * 24 * 60 * 60 + document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax` + } +}) + // Listen for custom events from LiveView window.addEventListener("phx:set-input-value", (e) => { const {id, value} = e.detail @@ -432,15 +463,6 @@ window.addEventListener("phx:set-input-value", (e) => { } }) -// Return the members list to the top after a sort or filter change. The server -// resets the keyset stream to page 1 on those changes and pushes this event; a -// user scrolled down would otherwise be stranded past the shorter content with -// infinite scroll not re-arming. No-op if the scroll container is absent. -window.addEventListener("phx:members:scroll-top", () => { - const el = document.getElementById("members-table-guard") - if (el) el.scrollTop = 0 -}) - // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 7af829a8..2b4dcde5 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -250,7 +250,7 @@ defmodule MvWeb.CoreComponents do attr :size, :any, default: "md", - doc: "Badge size: sm | md" + doc: "Badge size: xs | sm | md" attr :sr_label, :string, default: nil, @@ -269,7 +269,7 @@ defmodule MvWeb.CoreComponents do variant_class = "badge-#{variant}" style_class = badge_style_class(style) - size_class = "badge-#{size}" + size_class = badge_size_class(size) # Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300 outline_bg = if style == "outline", do: "bg-base-100", else: nil @@ -309,6 +309,13 @@ defmodule MvWeb.CoreComponents do defp badge_style_class("outline"), do: "badge-outline" defp badge_style_class(_), do: nil + # Literal strings so Tailwind's content scanner sees badge-xs / badge-sm / badge-md / badge-lg + # and does not purge them from the CSS bundle (string interpolation "badge-#{size}" would be invisible). + defp badge_size_class("xs"), do: "badge-xs" + defp badge_size_class("sm"), do: "badge-sm" + defp badge_size_class("lg"), do: "badge-lg" + defp badge_size_class(_), do: "badge-md" + @doc """ Renders a visually empty table cell with screen-reader-only text (WCAG). @@ -443,6 +450,11 @@ defmodule MvWeb.CoreComponents do attr :selected, :map, default: %{} attr :open, :boolean, default: false, doc: "Whether the dropdown is open" attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" + + attr :show_reset_button, :boolean, + default: false, + doc: "Show a reset-to-default icon button next to All/None (emits reset_fields)" + attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events" attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu" attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)" @@ -546,6 +558,19 @@ defmodule MvWeb.CoreComponents do > {gettext("None")} + @@ -938,6 +963,11 @@ defmodule MvWeb.CoreComponents do attr :sort_field, :any, default: nil, doc: "current sort field" attr :sort_order, :atom, default: nil, doc: "current sort order" + attr :size_class, :string, + default: "", + doc: + "optional DaisyUI table-size class controlling row density (e.g. table-xs, table-md); driven by the view-settings density value" + attr :sticky_header, :boolean, default: false, doc: @@ -1016,7 +1046,7 @@ defmodule MvWeb.CoreComponents do data-sticky-first-col-rows={@sticky_first_col && "true"} phx-hook={@row_click && "TableRowKeydown"} > -
+
- - {if dyn_col[:render] do - rendered = dyn_col[:render].(@row_item.(row)) + <%= if dyn_col[:custom_field].value_type == :boolean do %> + <% val = dyn_col[:render] && dyn_col[:render].(@row_item.(row)) %> + <%= cond do %> + <% val == true -> %> +
+ <.icon + name="hero-check-circle" + class="size-4 text-success" + aria-hidden="true" + /> + {gettext("Yes")} +
+ <% val == false -> %> +
+ <.icon + name="hero-x-circle" + class="size-4 text-error" + aria-hidden="true" + /> + {gettext("No")} +
+ <% true -> %> + <.empty_cell sr_text={gettext("Not specified")} /> + <% end %> + <% else %> + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) - if rendered == "" do - "" + if rendered == "" do + "" + else + rendered + end else - rendered - end - else - "" - end} + "" + end} + <% end %>
@@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do {:noreply, assign(socket, :selected_fields, all)} end + # reset to the curated default column set (the parent owns the settings needed + # to compute the default, so it recomputes and re-applies the selection) + def handle_event("reset_fields", _params, socket) do + send(self(), {:fields_reset}) + {:noreply, socket} + end + # select none def handle_event("select_none", _params, socket) do none = diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index c4850c41..d3ab1e94 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -32,11 +32,14 @@ defmodule MvWeb.Components.SortHeaderComponent do > {@label} <%= if @sort_field == @field do %> - <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} /> + <.icon + name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} + class="sort-icon" + /> <% else %> <.icon name="hero-chevron-up-down" - class="opacity-40" + class="sort-icon opacity-40" /> <% end %> diff --git a/lib/mv_web/live/components/view_settings_dropdown_component.ex b/lib/mv_web/live/components/view_settings_dropdown_component.ex new file mode 100644 index 00000000..b6cbec7a --- /dev/null +++ b/lib/mv_web/live/components/view_settings_dropdown_component.ex @@ -0,0 +1,129 @@ +defmodule MvWeb.Components.ViewSettingsDropdownComponent do + @moduledoc """ + LiveComponent for the member-overview view settings (§1.20). + + Renders an icon button with a tooltip that opens a dropdown of toggles: + + * Compact mode — table row density (compact ↔ comfortable) + * Compact Member field — composite last+first name cell, with a sub-toggle + "include email" + * Compact address field — composite street / postal+city cell + + Turning a composite field off surfaces the underlying separate columns in the + table. The component is display-only: it emits `{:view_setting_toggled, key}` + to the parent LiveView, which owns and persists the settings. + + ## Props + - `:density` — `:compact` or `:comfortable` + - `:compact_member` — boolean + - `:member_include_email` — boolean + - `:compact_address` — boolean + - `:id` — component id + """ + + use MvWeb, :live_component + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:open, fn -> false end)} + end + + @impl true + def render(assigns) do + ~H""" +
+ <.dropdown_menu + id="view-settings-menu" + icon="hero-cog-6-tooth" + button_label={gettext("View")} + open={@open} + phx_target={@myself} + menu_width="w-72" + testid="view-settings" + button_testid="view-settings-button" + > +
  • + {gettext("View settings")} +
  • + + <.toggle_item + setting="density" + label={gettext("Compact mode")} + checked={@density == :compact} + target={@myself} + /> + + <.toggle_item + setting="compact_member" + label={gettext("Compact Member field")} + checked={@compact_member} + target={@myself} + /> + + <%!-- The email line lives inside the composite Member cell, so this + sub-toggle is only meaningful while that cell is compact. When the + composite is off, email is its own column and this toggle is omitted. --%> +
  • + <.toggle_item + setting="member_include_email" + label={gettext("include email")} + checked={@member_include_email} + target={@myself} + /> +
  • + + <.toggle_item + setting="compact_address" + label={gettext("Compact address field")} + checked={@compact_address} + target={@myself} + /> + +
    + """ + end + + attr :setting, :string, required: true + attr :label, :string, required: true + attr :checked, :boolean, required: true + attr :target, :any, required: true + + defp toggle_item(assigns) do + ~H""" + + """ + end + + @impl true + def handle_event("toggle_dropdown", _params, socket) do + {:noreply, assign(socket, :open, !socket.assigns.open)} + end + + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :open, false)} + end + + def handle_event("toggle", %{"setting" => setting}, socket) do + send(self(), {:view_setting_toggled, String.to_existing_atom(setting)}) + {:noreply, socket} + end +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index f246f5ee..714efe9a 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.OverviewQuery + alias MvWeb.MemberLive.Index.ViewSettings require Ash.Query require Logger @@ -123,16 +124,31 @@ defmodule MvWeb.MemberLive.Index do # Load user field selection from session session_selection = FieldSelection.get_from_session(session) - # FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice) + # Resolve the per-browser view settings first: the columns the manager offers + # (composite vs. constituent Name/Address columns) follow these settings (§7b). + view_settings = ViewSettings.resolve(session, connect_conn(socket), connect_params(socket)) + compact_member = view_settings.compact_member + member_include_email = view_settings.member_include_email + compact_address = view_settings.compact_address + + # The dropdown offers exactly the columns applicable to the current view + # settings, so composite and constituent columns are never both listed. all_available_fields = - all_custom_fields - |> FieldVisibility.get_all_available_fields() + FieldVisibility.get_offered_fields( + all_custom_fields, + compact_member, + member_include_email, + compact_address + ) initial_selection = FieldVisibility.merge_with_global_settings( session_selection, settings, - all_custom_fields + all_custom_fields, + compact_member: compact_member, + member_include_email: member_include_email, + compact_address: compact_address ) socket = @@ -183,6 +199,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:mailto_bcc, "") |> assign(:recipient_count, 0) |> assign(:mailto_disabled?, false) + |> assign_view_settings(view_settings) |> stream_configure(:members, dom_id: &"row-#{&1.id}") |> stream(:members, []) |> assign_export_payload() @@ -385,6 +402,11 @@ defmodule MvWeb.MemberLive.Index do - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ + @impl true + def handle_info({:view_setting_toggled, key}, socket) do + {:noreply, toggle_view_setting(socket, key)} + end + @impl true def handle_info({:search_changed, q}, socket) do socket = @@ -604,34 +626,10 @@ defmodule MvWeb.MemberLive.Index do @impl true def handle_info({:field_toggled, field_string, visible}, socket) do new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) - socket = update_session_field_selection(socket, new_selection) - - final_selection = - FieldVisibility.merge_with_global_settings( - new_selection, - socket.assigns.settings, - socket.assigns.all_custom_fields - ) - - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_field_visibility(new_selection) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() @@ -641,35 +639,29 @@ defmodule MvWeb.MemberLive.Index do end @impl true - def handle_info({:fields_selected, selection}, socket) do - socket = update_session_field_selection(socket, selection) + def handle_info({:fields_reset}, socket) do + # Reset restores the curated default column set (§1.6): recompute the default + # visibility from the global settings and re-apply it as the selection. + {cm, ie, ca} = view_flags(socket) - final_selection = + default_selection = FieldVisibility.merge_with_global_settings( - selection, + %{}, socket.assigns.settings, - socket.assigns.all_custom_fields + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca ) - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + handle_info({:fields_selected, default_selection}, socket) + end + @impl true + def handle_info({:fields_selected, selection}, socket) do socket = socket - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_field_visibility(selection) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() @@ -697,18 +689,6 @@ defmodule MvWeb.MemberLive.Index do url_selection = FieldSelection.parse_from_url(params) final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket) - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) - socket = socket |> maybe_update_search(params) @@ -721,11 +701,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_visibility_derivations(final_selection) |> assign(:selected_member_id, parse_highlight_param(params["highlight"])) next_sig = build_signature(socket) @@ -863,8 +839,52 @@ defmodule MvWeb.MemberLive.Index do push_reload(socket, new_path) end - defp update_session_field_selection(socket, selection) do - assign(socket, :user_field_selection, selection) + # The current composite view flags; the offered column set is scoped to these. + defp view_flags(socket) do + {socket.assigns.compact_member, socket.assigns.member_include_email, + socket.assigns.compact_address} + end + + # Merges a raw selection with the global settings scoped to the current view + # settings, then assigns it together with the visibility-derived assigns. + defp assign_field_visibility(socket, selection) do + {cm, ie, ca} = view_flags(socket) + + final = + FieldVisibility.merge_with_global_settings( + selection, + socket.assigns.settings, + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca + ) + + assign_visibility_derivations(socket, final) + end + + # Assigns user_field_selection and every visibility-derived assign the table and + # export payload read from an already merged (offered-scoped) selection. + defp assign_visibility_derivations(socket, final_selection) do + visible_member_fields = + final_selection + |> FieldVisibility.get_visible_member_fields() + |> Enum.uniq() + + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + + socket + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign( + :member_fields_visible_db, + FieldVisibility.get_visible_member_fields_db(final_selection) + ) + |> assign( + :member_fields_visible_computed, + FieldVisibility.get_visible_member_fields_computed(final_selection) + ) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) end defp build_query_params(opts) when is_map(opts) do @@ -904,8 +924,14 @@ defmodule MvWeb.MemberLive.Index do end defp compute_final_field_selection(true, url_selection, socket) do + {cm, ie, ca} = view_flags(socket) + only_url = - FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) + FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca + ) visible_members = FieldVisibility.get_visible_member_fields(only_url) visible_custom = FieldVisibility.get_visible_custom_fields(only_url) @@ -919,6 +945,8 @@ defmodule MvWeb.MemberLive.Index do end defp compute_final_field_selection(false, url_selection, socket) do + {cm, ie, ca} = view_flags(socket) + merged = FieldSelection.merge_sources( url_selection, @@ -929,7 +957,10 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.merge_with_global_settings( merged, socket.assigns.settings, - socket.assigns.all_custom_fields + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca ) end @@ -941,6 +972,181 @@ defmodule MvWeb.MemberLive.Index do end end + # The connect-info Plug.Conn (present on the initial dead render), used to read + # per-browser cookies such as the persisted view settings. + defp connect_conn(socket) do + case socket.private[:connect_info] do + %Plug.Conn{} = conn -> conn + _ -> nil + end + end + + # The LiveView connect params (present only on the connected mount). The client + # echoes the persisted view-settings cookie here because the connect-info map + # of a live socket does not expose cookies. + defp connect_params(socket) do + if connected?(socket), do: get_connect_params(socket), else: nil + end + + # Assigns the full view-settings map plus the derived per-setting assigns the + # template renders from. + defp assign_view_settings(socket, settings) do + socket + |> assign(:view_settings, settings) + |> assign(:density, settings.density) + |> assign(:compact_member, settings.compact_member) + |> assign(:member_include_email, settings.member_include_email) + |> assign(:compact_address, settings.compact_address) + end + + # Updates a single view setting, re-derives the template assigns and persists + # the whole settings map per browser (client writes the cookie; the connected + # mount reads it back via connect params). + defp update_view_setting(socket, key, value) do + settings = Map.put(socket.assigns.view_settings, key, value) + + socket + |> assign_view_settings(settings) + |> push_event("store-view-settings", %{view_settings: ViewSettings.to_json(settings)}) + end + + # Flips the density setting. This only re-renders the table wrapper attribute + # (row spacing token), so the streamed rows do not need to be re-rendered. + defp toggle_view_setting(socket, :density) do + new = if socket.assigns.density == :compact, do: :comfortable, else: :compact + update_view_setting(socket, :density, new) + end + + # Flips the "include email" sub-toggle. Only meaningful while the composite + # Member cell is on: it moves the email between an in-cell line (on) and a + # separate E-Mail column (off), so besides re-rendering the rows it recomputes + # which columns the manager offers and their visibility. + defp toggle_view_setting(socket, :member_include_email) do + new_value = not socket.assigns.view_settings.member_include_email + + socket + |> update_view_setting(:member_include_email, new_value) + |> apply_include_email_toggle(new_value) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Flips the compact Member field. Besides re-rendering the rows, this changes + # which columns the manager offers (composite "Name" vs. Vorname/Nachname/E-Mail, + # §7b). The email visibility carries over between the two representations, + # analogously to the "include email" sub-toggle: composite + include-email ⇄ the + # E-Mail column being visible. + defp toggle_view_setting(socket, :compact_member) do + new_value = not socket.assigns.view_settings.compact_member + email_visible? = email_currently_visible?(socket) + + socket + |> update_view_setting(:compact_member, new_value) + |> sync_include_email(new_value, email_visible?) + |> apply_member_toggle(new_value, email_visible?) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Flips the compact Address field: offers the composite "Adresse" vs. the + # separate Straße/Hausnummer/PLZ/Ort columns, making the newly relevant columns + # visible and recomputing the offered set + visibility for the new mode. + defp toggle_view_setting(socket, :compact_address) do + new_value = not socket.assigns.view_settings.compact_address + + fields = if new_value, do: [:address], else: [:street, :house_number, :postal_code, :city] + + socket + |> update_view_setting(:compact_address, new_value) + |> apply_composite_toggle(fields) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Whether the member email is currently surfaced: while the composite is on, + # either folded into the cell (include_email) or as a separate E-Mail column; + # while it is off, as its own column. Read before the flip, so `compact_member` + # still holds the previous mode. + defp email_currently_visible?(socket) do + if socket.assigns.compact_member do + socket.assigns.member_include_email or :email in socket.assigns.member_fields_visible + else + :email in socket.assigns.member_fields_visible + end + end + + # Turning include_email OFF surfaces the email as a separate column; make it + # visible. Turning it ON folds the email into the cell, so the column is no + # longer offered (the recompute drops it). + defp apply_include_email_toggle(socket, false = _include_email) do + selection = Map.put(socket.assigns.user_field_selection, "email", true) + recompute_offered(socket, selection) + end + + defp apply_include_email_toggle(socket, true = _include_email), + do: recompute_offered(socket, socket.assigns.user_field_selection) + + # When switching to the composite, mirror the email state onto the include-email + # sub-toggle so the email keeps being surfaced; when switching away, the + # sub-toggle is not applicable. + defp sync_include_email(socket, true = _new_compact, email_visible?), + do: update_view_setting(socket, :member_include_email, email_visible?) + + defp sync_include_email(socket, false = _new_compact, _email_visible?), do: socket + + # Makes the now-relevant Member columns visible for the new mode. Turning the + # composite on surfaces "Name" (with the email either folded via include_email + # or kept as a separate column); turning it off surfaces Vorname/Nachname and + # carries the prior email visibility onto the E-Mail column. + defp apply_member_toggle(socket, true = _new_compact, email_visible?) do + selection = + socket.assigns.user_field_selection + |> Map.put("name", true) + |> Map.put("email", email_visible?) + + recompute_offered(socket, selection) + end + + defp apply_member_toggle(socket, false = _new_compact, email_visible?) do + selection = + socket.assigns.user_field_selection + |> Map.put("first_name", true) + |> Map.put("last_name", true) + |> Map.put("email", email_visible?) + + recompute_offered(socket, selection) + end + + # Sets the given columns visible, then recomputes the offered set + visibility. + defp apply_composite_toggle(socket, fields) do + selection = + Enum.reduce(fields, socket.assigns.user_field_selection, fn field, acc -> + Map.put(acc, Atom.to_string(field), true) + end) + + recompute_offered(socket, selection) + end + + # Recomputes the offered column list and the derived visibility for the current + # mode. `update_view_setting` has already updated the compact_* assigns, so + # `view_flags/1` reflects the new mode here. + defp recompute_offered(socket, selection) do + {cm, ie, ca} = view_flags(socket) + + socket + |> assign( + :all_available_fields, + FieldVisibility.get_offered_fields(socket.assigns.all_custom_fields, cm, ie, ca) + ) + |> assign_field_visibility(selection) + end + # Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid. defp parse_highlight_param(nil), do: nil defp parse_highlight_param(""), do: nil diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 55bf93f5..9c0db729 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -5,7 +5,6 @@ <.live_component module={MvWeb.Components.BulkActionsDropdown} id="bulk-actions-dropdown" - open={@bulk_actions_open} export_payload_json={@export_payload_json} selected_count={@selected_count} scope={@scope} @@ -72,13 +71,23 @@ - <.live_component - module={MvWeb.Components.FieldVisibilityDropdownComponent} - id="field-visibility-dropdown" - all_fields={@all_available_fields} - custom_fields={@all_custom_fields} - selected_fields={@user_field_selection} - /> +
    + <.live_component + module={MvWeb.Components.FieldVisibilityDropdownComponent} + id="field-visibility-dropdown" + all_fields={@all_available_fields} + custom_fields={@all_custom_fields} + selected_fields={@user_field_selection} + /> + <.live_component + module={MvWeb.Components.ViewSettingsDropdownComponent} + id="view-settings-dropdown" + density={@density} + compact_member={@compact_member} + member_include_email={@member_include_email} + compact_address={@compact_address} + /> +
    <%!-- Polite live region: present on first render (before it is filled) so @@ -103,6 +112,8 @@ role="region" aria-label={gettext("Members table")} aria-busy={to_string(@loading?)} + data-density={@density} + tabindex="0" > <.table id="members" @@ -122,6 +133,7 @@ dynamic_cols={@dynamic_cols} sort_field={@sort_field} sort_order={@sort_order} + size_class={if @density == :compact, do: "table-xs", else: "table-md"} > @@ -130,10 +142,14 @@ col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} label={ ~H""" - <.input + 0 and @selected_count < @total_count)} checked={@total_count > 0 and @selected_count == @total_count} aria-label={gettext("Select all members")} role="checkbox" @@ -141,9 +157,10 @@ """ } > - <.input + <:col :let={member} - :if={:name in @member_fields_visible} - label={gettext("Name")} + :if={:name in @member_fields_visible and @compact_member} + sort_field={:name} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_name} + field={:name} + label={gettext("Name")} + sort_field={@sort_field} + sort_order={@sort_order} + sort_hint={gettext("Click to sort by last name")} + /> + """ + } > <% full_name = [member.first_name, member.last_name] @@ -163,7 +193,8 @@ {full_name}
    @@ -171,29 +202,10 @@
    - <:col - :let={member} - :if={:address in @member_fields_visible} - label={gettext("Address")} - > - <% line1 = - [member.street, member.house_number] - |> Enum.reject(&(&1 in [nil, ""])) - |> Enum.join(" ") %> - <% line2 = - [member.postal_code, member.city] - |> Enum.reject(&(&1 in [nil, ""])) - |> Enum.join(" ") %> - <.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}> -
    -
    {line1}
    -
    {line2}
    -
    - - <:col :let={member} :if={:first_name in @member_fields_visible} + sort_field={:first_name} label={ ~H""" <.live_component @@ -212,6 +224,7 @@ <:col :let={member} :if={:last_name in @member_fields_visible} + sort_field={:last_name} label={ ~H""" <.live_component @@ -230,6 +243,7 @@ <:col :let={member} :if={:email in @member_fields_visible} + sort_field={:email} label={ ~H""" <.live_component @@ -245,9 +259,117 @@ > {member.email} + <:col + :let={member} + :if={:address in @member_fields_visible and @compact_address} + sort_field={:address} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_address} + field={:address} + label={gettext("Address")} + sort_field={@sort_field} + sort_order={@sort_order} + sort_hint={gettext("Click to sort by city")} + /> + """ + } + > + <% line1 = + [member.street, member.house_number] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> + <% line2 = + [member.postal_code, member.city] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> +
    +
    {line1}
    +
    {line2}
    +
    + + <:col + :let={member} + :if={:street in @member_fields_visible} + sort_field={:street} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + :if={:house_number in @member_fields_visible} + sort_field={:house_number} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + sort_field={:postal_code} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + :if={:city in @member_fields_visible} + sort_field={:city} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + <:col :let={member} :if={:join_date in @member_fields_visible} + sort_field={:join_date} label={ ~H""" <.live_component @@ -266,6 +388,7 @@ <:col :let={member} :if={:exit_date in @member_fields_visible} + sort_field={:exit_date} label={ ~H""" <.live_component @@ -291,6 +414,7 @@ <:col :let={member} :if={:country in @member_fields_visible} + sort_field={:country} label={ ~H""" <.live_component @@ -306,89 +430,10 @@ > {member.country} - <:col - :let={member} - :if={:city in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_city} - field={:city} - label={gettext("City")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}> - {member.city} - - - <:col - :let={member} - :if={:street in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_street} - field={:street} - label={gettext("Street")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}> - {member.street} - - - <:col - :let={member} - :if={:house_number in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_house_number} - field={:house_number} - label={gettext("House Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}> - {member.house_number} - - - <:col - :let={member} - :if={:postal_code in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_postal_code} - field={:postal_code} - label={gettext("Postal Code")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}> - {member.postal_code} - - <:col :let={member} :if={:membership_fee_start_date in @member_fields_visible} + sort_field={:membership_fee_start_date} label={ ~H""" <.live_component @@ -407,6 +452,7 @@ <:col :let={member} :if={:membership_fee_type in @member_fields_visible} + sort_field={:membership_fee_type} label={ ~H""" <.live_component @@ -443,6 +489,7 @@ <:col :let={member} :if={:groups in @member_fields_visible} + sort_field={:groups} label={ ~H""" <.live_component @@ -491,11 +538,17 @@ class="h-0" > + <%!-- The accent (background + top separator) and row height live on the + full-width footer cell (see #members-footer in app.css) so they span the + whole table edge-to-edge and match the active density. This inner element + stays pinned to the visible viewport width via StickyViewportWidth and is + transparent, so the cell accent shows through while the spinner/label stay + centered during horizontal scroll. --%>
    diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 8db026d5..682b7b9f 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -31,7 +31,21 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do # Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table). # :name and :address are composite display-only columns (Name = name + email, # Address = street/house number over postal code/city). - @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address] + # Order mirrors the overview table / export column order (fee type before fee + # status), so the Columns dropdown and the table agree. + @pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address] + + # The composite display columns and the constituent columns they replace. The + # column manager offers exactly one variant per group, selected by the view + # settings: the composite when the matching "compact" setting is on, otherwise + # the constituents (see `offered_member_fields/2`). + @name_composite :name + @name_constituents [:first_name, :last_name, :email] + @address_composite :address + @address_constituents [:street, :house_number, :postal_code, :city] + + # Export/API may accept this as alias; must not appear in the UI options list. + @export_only_alias :payment_status # Curated default-visible column set (§1.2): the columns shown on first visit # when there is no persisted selection and no global override. Everything else @@ -52,8 +66,139 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do @spec default_visible_fields() :: [atom()] def default_visible_fields, do: MapSet.to_list(@default_visible_fields) - # Export/API may accept this as alias; must not appear in the UI options list. - @export_only_alias :payment_status + @doc """ + The curated default-visible columns for the given view settings. + + In compact mode the composite `:name` / `:address` columns are visible by + default; when a composite is switched off, its constituent columns take over + as the defaults instead (§7b). + + While the Member composite is compact, `include_email` decides where the email + lives: folded into the Member cell (`true`, no separate column), or as a + default-visible `:email` column (`false`). + """ + @spec default_visible_fields(boolean(), boolean(), boolean()) :: [atom()] + def default_visible_fields(compact_member, include_email, compact_address) do + compact_member + |> default_visible_set(include_email, compact_address) + |> MapSet.to_list() + end + + defp default_visible_set(compact_member, include_email, compact_address) do + @default_visible_fields + |> apply_name_default(compact_member, include_email) + |> apply_group_default(compact_address, @address_composite, @address_constituents) + end + + # Compact Member cell with email folded in: only the composite is default. + defp apply_name_default(set, true = _compact, true = _include_email), do: set + + # Compact Member cell without folded email: the composite plus a separate + # default-visible E-Mail column. + defp apply_name_default(set, true = _compact, false = _include_email), + do: MapSet.put(set, :email) + + # Non-compact: drop the composite and make first/last name and email default. + defp apply_name_default(set, false = _compact, _include_email) do + set + |> MapSet.delete(@name_composite) + |> MapSet.union(MapSet.new(@name_constituents)) + end + + # Compact: the composite stays the default. Non-compact: drop the composite and + # make the constituents default-visible. + defp apply_group_default(set, true, _composite, _constituents), do: set + + defp apply_group_default(set, false, composite, constituents) do + set + |> MapSet.delete(composite) + |> MapSet.union(MapSet.new(constituents)) + end + + @doc """ + Member-field atoms the column manager offers for the given view settings, in + dropdown/table order. + + The identity block always leads, regardless of whether each group is compact: + + 1. the name group — the composite `:name` when compact, otherwise its + constituents `:first_name`, `:last_name`; + 2. the `:email` column, when offered as a separate column (it is folded into + the Member cell only while the composite is compact and `include_email` is + on); + 3. the address group — the composite `:address` when compact, otherwise its + constituents `:street`, `:house_number`, `:postal_code`, `:city`. + + Everything else follows in the configured Datenfelder order. This keeps the + name group ahead of the address group even when the name group is expanded + into `:first_name`/`:last_name` while the address stays compact. + """ + @spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()] + def offered_member_fields(compact_member, include_email, compact_address) do + name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name] + email_group = if email_offered?(compact_member, include_email), do: [:email], else: [] + + address_group = + if compact_address, do: [@address_composite], else: @address_constituents + + leading = name_group ++ email_group ++ address_group + + rest = + Enum.reject(overview_member_fields(), fn field -> + field == @export_only_alias or + hidden_variant?(field, compact_member, include_email, compact_address) or + field in leading + end) + + leading ++ rest + end + + # The email is a separate offered column except when it is folded into the + # compact Member cell (compact + include_email). + defp email_offered?(compact_member, include_email), do: not (compact_member and include_email) + + # The variant of a composite group that is not offered in the current mode. + defp hidden_variant?(field, compact_member, include_email, compact_address) do + name_variant_hidden?(field, compact_member, include_email) or + address_variant_hidden?(field, compact_address) + end + + # Compact hides Vorname/Nachname (folded into the composite). The email is + # hidden only when it is folded into the cell (compact + include_email); with + # include_email off it stays offered as a separate column. Non-compact hides the + # composite in favour of the constituents. + defp name_variant_hidden?(@name_composite, compact_member, _include_email), + do: not compact_member + + defp name_variant_hidden?(:email, compact_member, include_email), + do: compact_member and include_email + + defp name_variant_hidden?(field, compact_member, _include_email) + when field in [:first_name, :last_name], + do: compact_member + + defp name_variant_hidden?(_field, _compact_member, _include_email), do: false + + defp address_variant_hidden?(@address_composite, compact_address), + do: not compact_address + + defp address_variant_hidden?(field, compact_address) + when field in [:street, :house_number, :postal_code, :city], + do: compact_address + + defp address_variant_hidden?(_field, _compact_address), do: false + + @doc """ + All fields the column manager offers for the given view settings: the offered + member fields (see `offered_member_fields/3`) followed by the custom fields. + """ + @spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [ + atom() | String.t() + ] + def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do + custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}") + offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names + end defp overview_member_fields do Mv.Constants.member_fields() ++ @pseudo_member_fields @@ -93,8 +238,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do String.t() => boolean() } def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do - all_fields = get_all_available_fields(custom_fields) + do_selection_from_url_only(url_selection, get_all_available_fields(custom_fields)) + end + def selection_from_url_only(_, _), do: %{} + + @doc """ + Like `selection_from_url_only/2`, but scoped to the columns offered for the + given view settings (`:compact_member` / `:member_include_email` / + `:compact_address` in `opts`). + """ + @spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{ + String.t() => boolean() + } + def selection_from_url_only(url_selection, custom_fields, opts) + when is_map(url_selection) and is_list(opts) do + {cm, ie, ca} = view_opts(opts) + do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca)) + end + + def selection_from_url_only(_, _, _), do: %{} + + defp do_selection_from_url_only(url_selection, all_fields) do Enum.reduce(all_fields, %{}, fn field, acc -> field_string = field_to_string(field) visible = Map.get(url_selection, field_string, false) @@ -102,7 +267,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do end) end - def selection_from_url_only(_, _), do: %{} + defp view_opts(opts) do + { + Keyword.get(opts, :compact_member, true), + Keyword.get(opts, :member_include_email, false), + Keyword.get(opts, :compact_address, true) + } + end @doc """ Merges user field selection with global settings. @@ -135,7 +306,31 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do ) :: %{String.t() => boolean()} def merge_with_global_settings(user_selection, global_settings, custom_fields) do all_fields = get_all_available_fields(custom_fields) - global_visibility = get_global_visibility_map(global_settings, custom_fields) + do_merge(user_selection, global_settings, custom_fields, all_fields, @default_visible_fields) + end + + @doc """ + Like `merge_with_global_settings/3`, but scoped to the columns offered for the + given view settings (`:compact_member` / `:member_include_email` / + `:compact_address` in `opts`). + + Only offered columns appear in the result, so a column that is not applicable + in the current mode (e.g. `:first_name` while the composite "Name" is on) never + leaks into the visible set. Defaults follow the mode via + `default_visible_fields/2`. + """ + @spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) :: + %{String.t() => boolean()} + def merge_with_global_settings(user_selection, global_settings, custom_fields, opts) + when is_list(opts) do + {cm, ie, ca} = view_opts(opts) + all_fields = get_offered_fields(custom_fields, cm, ie, ca) + default_set = default_visible_set(cm, ie, ca) + do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) + end + + defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do + global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set) Enum.reduce(all_fields, %{}, fn field, acc -> field_string = field_to_string(field) @@ -280,15 +475,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do def get_visible_custom_fields(_), do: [] # Gets global visibility map from settings - defp get_global_visibility_map(settings, custom_fields) do - member_visibility = get_member_field_visibility_from_settings(settings) + defp get_global_visibility_map(settings, custom_fields, default_set) do + member_visibility = get_member_field_visibility_from_settings(settings, default_set) custom_field_visibility = get_custom_field_visibility(custom_fields) Map.merge(member_visibility, custom_field_visibility) end # Gets member field visibility from settings (domain fields from settings, pseudo fields default true) - defp get_member_field_visibility_from_settings(settings) do + defp get_member_field_visibility_from_settings(settings, default_set) do visibility_config = VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{})) @@ -297,13 +492,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do domain_map = Enum.reduce(domain_fields, %{}, fn field, acc -> field_string = Atom.to_string(field) - default_visibility = MapSet.member?(@default_visible_fields, field) + default_visibility = MapSet.member?(default_set, field) show_in_overview = Map.get(visibility_config, field, default_visibility) Map.put(acc, field_string, show_in_overview) end) Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc -> - Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field)) + Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field)) end) end diff --git a/lib/mv_web/live/member_live/index/view_settings.ex b/lib/mv_web/live/member_live/index/view_settings.ex new file mode 100644 index 00000000..d80a076e --- /dev/null +++ b/lib/mv_web/live/member_live/index/view_settings.ex @@ -0,0 +1,186 @@ +defmodule MvWeb.MemberLive.Index.ViewSettings do + @moduledoc """ + Resolves and persists the member-overview view settings per browser/device. + + A view-settings value is a map of four keys: + + * `:density` — `:compact` or `:comfortable` (the row-spacing / compact mode) + * `:compact_member` — composite "Member" cell (last+first name) vs separate + Vorname/Nachname columns + * `:member_include_email` — whether the email is surfaced (email line in the + composite cell, or a separate Email column when the member field is split) + * `:compact_address` — composite Address cell (street / postal+city) vs + separate Straße/PLZ/Ort columns + + Persistence is per browser (not per account), resolved in priority order from + the LiveView connect params, the session, the request cookie, and finally the + global defaults (`defaults/0`). The chosen value is written to a long-lived + cookie by a small client-side listener; on the next connected mount the client + echoes the cookie back through the socket connect params (the connect-info map + on a live socket does not expose cookies), and on the disconnected render the + cookie is read directly from the request conn. + """ + + @cookie_name "member_view_settings" + @session_key "member_view_settings" + @connect_param "view_settings" + @cookie_max_age 365 * 24 * 60 * 60 + + @defaults %{ + density: :compact, + compact_member: true, + member_include_email: false, + compact_address: true + } + + @densities [:compact, :comfortable] + @bool_keys [:compact_member, :member_include_email, :compact_address] + + @type t :: %{ + density: :compact | :comfortable, + compact_member: boolean(), + member_include_email: boolean(), + compact_address: boolean() + } + + @doc "The global default view settings used when nothing is stored." + @spec defaults() :: %{ + density: :compact, + compact_member: true, + member_include_email: false, + compact_address: true + } + def defaults, do: @defaults + + @doc "The cookie name the settings are persisted under (used by the client listener)." + @spec cookie_name() :: String.t() + def cookie_name, do: @cookie_name + + @doc "The cookie max-age in seconds (365 days)." + @spec cookie_max_age() :: 31_536_000 + def cookie_max_age, do: @cookie_max_age + + @doc """ + Parses a raw map (string or atom keys, string/boolean values) into a validated + partial settings map with atom keys. Unknown keys and invalid values are + dropped, so a caller can safely `Map.merge/2` the result over another source. + """ + @spec parse(term()) :: %{optional(atom()) => term()} + def parse(raw) when is_map(raw) do + Enum.reduce(raw, %{}, fn {key, value}, acc -> + case parse_pair(to_string(key), value) do + {k, v} -> Map.put(acc, k, v) + :error -> acc + end + end) + end + + def parse(_), do: %{} + + defp parse_pair("density", value) do + case parse_density(value) do + nil -> :error + density -> {:density, density} + end + end + + defp parse_pair(key, value) + when key in ~w(compact_member member_include_email compact_address) do + case parse_bool(value) do + nil -> :error + bool -> {String.to_existing_atom(key), bool} + end + end + + defp parse_pair(_key, _value), do: :error + + defp parse_density(value) when value in @densities, do: value + defp parse_density("compact"), do: :compact + defp parse_density("comfortable"), do: :comfortable + defp parse_density(_), do: nil + + defp parse_bool(value) when is_boolean(value), do: value + defp parse_bool("true"), do: true + defp parse_bool("false"), do: false + defp parse_bool(_), do: nil + + @doc "Reads partial settings from the LiveView session map." + @spec get_from_session(map()) :: %{optional(atom()) => term()} + def get_from_session(session) when is_map(session), + do: parse_json(Map.get(session, @session_key)) + + def get_from_session(_), do: %{} + + @doc "Reads partial settings from the request cookie header of a connect-info conn." + @spec get_from_cookie(Plug.Conn.t() | nil) :: %{optional(atom()) => term()} + def get_from_cookie(%Plug.Conn{} = conn) do + case Plug.Conn.get_req_header(conn, "cookie") do + [cookie_header | _rest] -> + cookie_header + |> parse_cookie_header() + |> Map.get(@cookie_name) + |> parse_json() + + _ -> + %{} + end + end + + def get_from_cookie(_), do: %{} + + @doc "Reads partial settings from the LiveView connect params (raw JSON string)." + @spec get_from_connect_params(map() | nil) :: %{optional(atom()) => term()} + def get_from_connect_params(params) when is_map(params), + do: parse_json(Map.get(params, @connect_param)) + + def get_from_connect_params(_), do: %{} + + @doc """ + Resolves the effective view settings for a mount, merging the sources by + priority (connect params > session > cookie), falling back per key to the + global defaults. + """ + @spec resolve(map(), Plug.Conn.t() | nil, map() | nil) :: t() + def resolve(session, conn, connect_params \\ nil) do + @defaults + |> Map.merge(get_from_cookie(conn)) + |> Map.merge(get_from_session(session)) + |> Map.merge(get_from_connect_params(connect_params)) + end + + @doc "Serializes settings to a JSON string for the client-side cookie writer." + @spec to_json(map()) :: String.t() + def to_json(settings) when is_map(settings) do + settings + |> Map.take([:density | @bool_keys]) + |> Map.new(fn + {:density, density} -> {:density, to_string(density)} + {key, value} -> {key, value} + end) + |> Jason.encode!() + end + + defp parse_json(nil), do: %{} + + defp parse_json(json) when is_binary(json) do + case Jason.decode(json) do + {:ok, decoded} -> parse(decoded) + _ -> %{} + end + end + + defp parse_json(_), do: %{} + + # Parses a cookie header string into a name => value map. + defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do + cookie_header + |> String.split(";") + |> Enum.map(&String.trim/1) + |> Enum.map(&String.split(&1, "=", parts: 2)) + |> Enum.reduce(%{}, fn + [key, value], acc -> Map.put(acc, key, URI.decode(value)) + [key], acc -> Map.put(acc, key, "") + _, acc -> acc + end) + end +end diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 955c3c3f..2ae3ce01 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -4,11 +4,25 @@ defmodule MvWeb.Components.SortHeaderComponentTest do # The curated default columns hide the individual name/address sort headers, # so these component tests make every sortable field visible via ?fields=. + # The individual name/address constituents are only *offered* when the compact + # Member / Address view settings are off, so the session opts them out of the + # composite columns before selecting the fields via ?fields=. @cols "first_name,email,street,house_number,postal_code,city,country,join_date" + # Disables the compact Member/Address composites so first_name/last_name/email + # and street/house_number/postal_code/city are offered as individual sortable + # columns (persisted per browser via the view-settings session key). + defp non_compact(conn) do + Plug.Conn.put_session( + conn, + "member_view_settings", + ~s({"compact_member":false,"compact_address":false}) + ) + end + describe "rendering" do test "renders with correct attributes", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test that the component renders with correct attributes @@ -18,7 +32,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "renders all sortable headers", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") sortable_fields = [ @@ -38,7 +52,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "renders correct labels", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test specific labels @@ -50,7 +64,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "sort icons" do test "shows neutral icon for specific field when not sorted", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # The neutral icon has the opcity class we can test for @@ -62,7 +76,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") @@ -85,7 +99,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "shows descending icon for specific field when sorted descending", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, _view, html} = live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc") @@ -99,7 +113,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "multiple fields can have different icon states", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") @@ -118,7 +132,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "icon state changes correctly when clicking different fields", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Start: all fields neutral except first name as default @@ -146,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "specific field shows correct icon for each sort state", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() # Test EMAIL field specifically {:ok, view, html_asc} = @@ -167,7 +181,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "icon distribution shows exactly one active sort icon", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() # Test neutral state - only one field should have active sort icon {:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}") @@ -204,7 +218,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "accessibility" do test "sets aria-label correctly for unsorted state", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Check aria-label for unsorted state @@ -212,7 +226,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "sets aria-label correctly for ascending sort", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") @@ -222,7 +236,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "sets aria-label correctly for descending sort", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc") @@ -232,7 +246,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "includes tooltip with correct aria-label", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") @@ -243,7 +257,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "aria-labels work for all sortable fields", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") # Test aria-labels for different fields @@ -260,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "component behavior" do test "clicking triggers sort event on parent LiveView", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Click on the first name sort header @@ -273,7 +287,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "component handles different field types correctly", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test that different field types render correctly @@ -285,7 +299,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "edge cases" do test "handles invalid sort field gracefully", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc") @@ -296,7 +310,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "handles invalid sort order gracefully", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid") @@ -307,7 +321,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "handles empty sort parameters", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=") # Should show neutral icons @@ -318,7 +332,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "icon state transitions" do test "icon changes when sorting state changes", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Start with neutral state @@ -334,7 +348,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "multiple fields can be tested for icon states", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") # Email should be active (descending) diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs index 427cbead..728c6cf5 100644 --- a/test/mv_web/live/member_live/index/field_visibility_test.exs +++ b/test/mv_web/live/member_live/index/field_visibility_test.exs @@ -201,6 +201,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do end end + describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do + test "compact member + compact address: Name, E-Mail, Adresse lead" do + result = FieldVisibility.offered_member_fields(true, false, true) + + assert Enum.take(result, 3) == [:name, :email, :address] + # the composite constituents are not offered while their composite is on + refute :first_name in result + refute :street in result + # the Datenfelder-ordered rest trails the identity block + assert index_of(result, :address) < index_of(result, :join_date) + end + + test "compact member with email folded in: no separate E-Mail column" do + result = FieldVisibility.offered_member_fields(true, true, true) + + assert Enum.take(result, 2) == [:name, :address] + refute :email in result + end + + test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do + result = FieldVisibility.offered_member_fields(false, false, true) + + # the reported bug: the composite address must not jump ahead of the + # expanded name constituents + assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address] + assert index_of(result, :address) < index_of(result, :join_date) + end + + test "non-compact member + non-compact address: name, email, then the address block, then rest" do + result = FieldVisibility.offered_member_fields(false, false, false) + + assert Enum.take(result, 7) == [ + :first_name, + :last_name, + :email, + :street, + :house_number, + :postal_code, + :city + ] + + # join_date and later Datenfelder columns never precede the address block + assert index_of(result, :city) < index_of(result, :join_date) + refute :name in result + refute :address in result + end + end + + defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem)) + describe "get_visible_fields/1" do test "returns only fields with true visibility" do selection = %{ diff --git a/test/mv_web/live/member_live/index/view_settings_test.exs b/test/mv_web/live/member_live/index/view_settings_test.exs new file mode 100644 index 00000000..44a86f96 --- /dev/null +++ b/test/mv_web/live/member_live/index/view_settings_test.exs @@ -0,0 +1,109 @@ +defmodule MvWeb.MemberLive.Index.ViewSettingsTest do + @moduledoc """ + §1.20 / §1.5 — the member-overview view settings (density + the compact + member/address field toggles) and their per-browser persistence chain + (connect params > session > cookie > global default). + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.ViewSettings + + test "defaults are all-compact with the email line off" do + assert ViewSettings.defaults() == %{ + density: :compact, + compact_member: true, + member_include_email: false, + compact_address: true + } + end + + describe "parse/1" do + test "reads a partial map with string keys and string/bool values" do + assert ViewSettings.parse(%{ + "density" => "comfortable", + "compact_member" => false, + "member_include_email" => true + }) == %{ + density: :comfortable, + compact_member: false, + member_include_email: true + } + end + + test "ignores unknown keys and invalid values" do + assert ViewSettings.parse(%{"density" => "huge", "bogus" => true}) == %{} + end + + test "returns empty map for non-maps" do + assert ViewSettings.parse(nil) == %{} + assert ViewSettings.parse("x") == %{} + end + end + + describe "get_from_* sources" do + test "session JSON string" do + session = %{"member_view_settings" => ~s({"density":"comfortable"})} + assert ViewSettings.get_from_session(session) == %{density: :comfortable} + assert ViewSettings.get_from_session(%{}) == %{} + end + + test "cookie header" do + json = ~s({"compact_address":false}) + conn = %Plug.Conn{req_headers: [{"cookie", "member_view_settings=#{URI.encode(json)}"}]} + assert ViewSettings.get_from_cookie(conn) == %{compact_address: false} + assert ViewSettings.get_from_cookie(%Plug.Conn{req_headers: []}) == %{} + assert ViewSettings.get_from_cookie(nil) == %{} + end + + test "connect params (raw JSON string under view_settings)" do + params = %{"view_settings" => ~s({"density":"comfortable","compact_member":false})} + + assert ViewSettings.get_from_connect_params(params) == %{ + density: :comfortable, + compact_member: false + } + + assert ViewSettings.get_from_connect_params(nil) == %{} + end + end + + describe "resolve/3 priority connect_params > session > cookie > default" do + test "falls back to defaults when nothing is stored" do + assert ViewSettings.resolve(%{}, nil, nil) == ViewSettings.defaults() + end + + test "cookie overrides default, session overrides cookie, connect params win" do + cookie_json = ~s({"density":"comfortable","compact_member":false,"compact_address":false}) + + conn = %Plug.Conn{ + req_headers: [{"cookie", "member_view_settings=#{URI.encode(cookie_json)}"}] + } + + session = %{"member_view_settings" => ~s({"compact_member":true})} + connect_params = %{"view_settings" => ~s({"density":"compact"})} + + assert ViewSettings.resolve(session, conn, connect_params) == %{ + # from connect params (highest) + density: :compact, + # from session (overrides cookie) + compact_member: true, + # from cookie + compact_address: false, + # default + member_include_email: false + } + end + end + + test "to_json/1 round-trips through parse/1" do + settings = %{ + density: :comfortable, + compact_member: false, + member_include_email: true, + compact_address: false + } + + assert settings |> ViewSettings.to_json() |> Jason.decode!() |> ViewSettings.parse() == + settings + end +end diff --git a/test/mv_web/member_live/index_a11y_hardening_test.exs b/test/mv_web/member_live/index_a11y_hardening_test.exs new file mode 100644 index 00000000..4b6434fc --- /dev/null +++ b/test/mv_web/member_live/index_a11y_hardening_test.exs @@ -0,0 +1,78 @@ +defmodule MvWeb.MemberLive.IndexA11yHardeningTest do + @moduledoc """ + §3.5 — WCAG 2.2 AA posture: ≥24px interactive targets in both densities, + scroll-margin on rows so a focused row is not obscured by the sticky header, + select-all `indeterminate` for partial selection, and reflow markup keeping the + table in its own focusable scroll region. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + @moduletag :ui + + setup %{conn: conn} do + actor = SystemActor.get_system_actor() + + members = + for i <- 1..3 do + {:ok, m} = + Mv.Membership.create_member( + %{first_name: "Wcag#{i}", last_name: "Row", email: "wcag#{i}@example.com"}, + actor: actor + ) + + m + end + + %{conn: conn_with_oidc_user(conn), members: members} + end + + test "rows reserve scroll-margin under the sticky header", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/members") + assert html =~ "scroll-mt-16" + end + + test "the table lives in its own focusable scroll region that reflows", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/members") + + # Focusable region (1.4.10: table scrolls within its own focusable region). + assert html =~ ~r/data-testid="members-table-scroll"[^>]*tabindex="0"/ + # The toolbar reflows to one column on narrow viewports. + assert html =~ "flex-wrap" + end + + test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + # Compact (default) and comfortable both keep the >=24px checkbox target. + assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6") + + # Switch to comfortable via the view-settings dropdown. + view |> element("[data-testid='view-settings-button']") |> render_click() + view |> element("[data-testid='view-setting-density']") |> render_click() + assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6") + end + + test "select-all reflects indeterminate for a partial selection", %{ + conn: conn, + members: members + } do + {:ok, view, _html} = live(conn, ~p"/members") + + # No selection: not indeterminate. + assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']") + + # Select one of several -> partial -> indeterminate. + [m | _] = members + render_click(view, "select_member", %{"id" => m.id}) + assert has_element?(view, "#select-all-checkbox[data-indeterminate='true']") + + # Select all -> fully checked, not indeterminate. + view |> element("[phx-click='select_all']") |> render_click() + assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']") + assert has_element?(view, "#select-all-checkbox[checked]") + end +end diff --git a/test/mv_web/member_live/index_column_manager_test.exs b/test/mv_web/member_live/index_column_manager_test.exs new file mode 100644 index 00000000..723b8f2d --- /dev/null +++ b/test/mv_web/member_live/index_column_manager_test.exs @@ -0,0 +1,76 @@ +defmodule MvWeb.MemberLive.IndexColumnManagerTest do + @moduledoc """ + §1.6 — The column manager sits in the filter toolbar, offers a per-column + visibility toggle plus All and None controls styled as buttons (not links), + and applies changes while preserving the existing persistence chain. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Manager", last_name: "Columns", email: "manager@example.com"}, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + test "column manager trigger sits alongside the filter controls", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + # Both the filter panel trigger and the column-manager trigger render in the + # same toolbar. + assert has_element?(view, ~s(button[aria-label="Filter members"])) + assert has_element?(view, "button[aria-controls='field-visibility-menu']") + end + + test "All and None are buttons, not links, and per-column toggles are checkboxes", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[aria-controls='field-visibility-menu']") |> render_click() + + # All / None render as buttons with button styling. + assert has_element?(view, "button.btn[phx-click='select_all']", "All") + assert has_element?(view, "button.btn[phx-click='select_none']", "None") + refute has_element?(view, "a[phx-click='select_all']") + refute has_element?(view, "a[phx-click='select_none']") + + # Per-column visibility toggles are accessible checkbox menu items. + assert has_element?(view, "button[role='menuitemcheckbox'][phx-value-item='name']") + end + + test "toggling a column applies and is reflected in the URL (persistence)", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[aria-controls='field-visibility-menu']") |> render_click() + view |> element("button[phx-value-item='join_date']") |> render_click() + + # The selection is pushed to the URL fields param so it survives reloads. + path = assert_patch(view) + assert path =~ "fields=" + refute has_element?(view, "[data-testid='join_date']") + end + + test "a reset button restores the curated default column set", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[aria-controls='field-visibility-menu']") |> render_click() + + # Reset is a button styled like All/None, sitting next to them, with a tooltip. + assert has_element?(view, "button.btn[phx-click='reset_fields']") + + # Hide a curated-default column, then reset restores it. + view |> element("button[phx-value-item='join_date']") |> render_click() + assert_patch(view) + refute has_element?(view, "[data-testid='join_date']") + + view |> element("button[phx-click='reset_fields']") |> render_click() + assert_patch(view) + assert has_element?(view, "[data-testid='join_date']") + end +end diff --git a/test/mv_web/member_live/index_default_columns_test.exs b/test/mv_web/member_live/index_default_columns_test.exs index 04d3c993..5f5545d6 100644 --- a/test/mv_web/member_live/index_default_columns_test.exs +++ b/test/mv_web/member_live/index_default_columns_test.exs @@ -1,8 +1,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do @moduledoc """ §1.2 — With no persisted column selection, exactly the curated default columns - are visible: selection checkbox, Name (name + email), Address (composite), - fee type, fee status, groups, join date. + are visible: selection checkbox, Name (composite, no in-cell email line), + E-Mail (its own column while the compact Member field keeps the email line off), + Address (composite), fee type, fee status, groups, join date. """ use MvWeb.ConnCase, async: false @@ -34,6 +35,8 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do # Composite Name + Address cells. assert has_element?(view, "[data-testid='member-name']") assert has_element?(view, "[data-testid='member-address']") + # E-Mail is its own column by default (compact Member field, email line off). + assert has_element?(view, "[data-testid='email']") # Fee type, fee status, groups, join date headers. assert has_element?(view, "[data-testid='membership_fee_type']") assert has_element?(view, "[data-testid='join_date']") @@ -44,7 +47,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do test "the individual name/address sub-fields are hidden by default", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/members") - for field <- ~w(first_name last_name email city street house_number postal_code country) do + # The composite Name/Address cells replace their constituents by default. + # E-Mail is excluded here: it is a default-visible column of its own. + for field <- ~w(first_name last_name city street house_number postal_code country) do refute has_element?(view, "[data-testid='#{field}']") end end diff --git a/test/mv_web/member_live/index_density_test.exs b/test/mv_web/member_live/index_density_test.exs new file mode 100644 index 00000000..dac7c3a6 --- /dev/null +++ b/test/mv_web/member_live/index_density_test.exs @@ -0,0 +1,73 @@ +defmodule MvWeb.MemberLive.IndexDensityTest do + @moduledoc """ + §1.4 — The density toggle switches the table row-spacing token and reflects the + active density in the control state. + §1.5 — Density persists across reload per browser (session/cookie), with the + global default applying when nothing is stored. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Dense", last_name: "Row", email: "dense@example.com"}, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + defp open_view_settings(view) do + view |> element("[data-testid='view-settings-button']") |> render_click() + view + end + + test "defaults to compact when nothing is stored", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']") + # The compact-mode toggle reflects the active (compact) state. + open_view_settings(view) + assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='true']") + end + + test "toggle switches the density token and the control state", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + view |> element("[data-testid='view-setting-density']") |> render_click() + + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']") + assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='false']") + + # Toggling again returns to compact. + view |> element("[data-testid='view-setting-density']") |> render_click() + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']") + end + + test "restores a persisted density from the session on reload", %{conn: conn} do + conn = + Plug.Test.init_test_session(conn, %{ + "member_view_settings" => ~s({"density":"comfortable"}) + }) + + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']") + end + + test "restores a persisted density on the connected mount via connect params", %{conn: conn} do + # Faithful reload round-trip: the connected mount cannot read the cookie + # (the live socket's connect-info map has no cookies), so the client echoes + # the persisted value through connect params. This is the path that was + # broken in the R1 accept (cookie written but not restored on mount). + conn = put_connect_params(conn, %{"view_settings" => ~s({"density":"comfortable"})}) + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']") + end +end diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs index 71744f83..fc1e6fa4 100644 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -22,6 +22,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do require Ash.Query + # Disables the compact Member/Address composites (per-browser view setting) so + # the individual constituent columns (first_name/last_name/email, street/…) are + # offered by the column manager and available via ?fields=. + defp non_compact(conn) do + Plug.Conn.put_session( + conn, + "member_view_settings", + ~s({"compact_member":false,"compact_address":false}) + ) + end + setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() @@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do end test "displays all member fields in dropdown", %{conn: conn} do - conn = conn_with_oidc_user(conn) + # The individual name/address constituents are only offered when the + # compact Member/Address composites are off, so opt out of them first. + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members") # Open dropdown @@ -148,9 +161,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # The curated Name column carries the email, so it is visible initially. + # The curated Name column carries the member names, so they are visible. html = render(view) - assert html =~ "alice@example.com" + assert html =~ "Anderson" # Open dropdown and hide the Name column view @@ -161,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_click() - # The Name column (and the email it carries) is no longer visible + # The Name column (and the names it carries) is no longer visible html = render(view) - refute html =~ "alice@example.com" - refute html =~ "bob@example.com" + refute html =~ "Anderson" + refute html =~ "Brown" end test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do @@ -298,8 +311,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") - # All fields should be visible by default - assert html =~ "alice@example.com" + # The curated Name and Address columns are visible by default. + assert html =~ "Anderson" assert html =~ "Main St" end @@ -309,7 +322,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Hide the curated Name column (which carries the email) via dropdown + # Hide the curated Name column (which carries the names) via dropdown view |> element("button[aria-controls='field-visibility-menu']") |> render_click() @@ -319,7 +332,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do |> render_click() html = render(view) - refute html =~ "alice@example.com" + refute html =~ "Anderson" end end @@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?fields=") - # Should fall back to global settings - assert html =~ "alice@example.com" + # Should fall back to global settings (curated Name column visible) + assert html =~ "Anderson" end test "handles invalid field names in URL", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid") - # Should ignore invalid fields and use defaults - assert html =~ "alice@example.com" + # Should ignore invalid fields and use defaults (curated Name column visible) + assert html =~ "Anderson" end test "handles custom field that doesn't exist", %{conn: conn} do @@ -429,9 +442,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # The curated Name column carries the email, so it is visible initially. + # The curated Name column carries the names, so they are visible initially. html = render(view) - assert html =~ "alice@example.com" + assert html =~ "Anderson" # Open dropdown view @@ -443,9 +456,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_keydown(%{key: "Enter"}) - # The Name column (and the email it carries) is no longer visible + # The Name column (and the names it carries) is no longer visible html = render(view) - refute html =~ "alice@example.com" + refute html =~ "Anderson" end end end diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs index 8b92cb90..3de60923 100644 --- a/test/mv_web/member_live/index_groups_url_params_test.exs +++ b/test/mv_web/member_live/index_groups_url_params_test.exs @@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do require Ash.Query + # Disables the compact Member composite (per-browser view setting) so the + # individual first_name column is offered and selectable via ?fields=. + defp non_compact(conn) do + Plug.Conn.put_session( + conn, + "member_view_settings", + ~s({"compact_member":false,"compact_address":false}) + ) + end + setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() @@ -122,7 +132,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do member1: member1, group1: group1 } do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live( diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index 6c222276..7b1785bf 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -38,6 +38,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") + # The composite Member cell carries first + last name; the in-cell email + # line stays off by default, but the email is surfaced in its own column + # (compact Member field, include-email off). for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do assert html =~ field end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index e1de77a8..14d76a82 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do alias Mv.Membership.CustomFieldValue alias MvWeb.MemberLive.Index, as: MemberIndex + # Disables the compact Member/Address composites (per-browser view setting) so + # the individual constituent columns (first_name/last_name, street/…) are + # offered by the column manager and selectable via ?fields=. The composite + # E-Mail column is offered even in compact mode, so email-only tests do not + # need this. + defp non_compact(conn) do + Plug.Conn.put_session( + conn, + "member_view_settings", + ~s({"compact_member":false,"compact_address":false}) + ) + end + describe "desktop layout: scroll container and sticky table header" do @describetag :ui @@ -155,9 +168,11 @@ defmodule MvWeb.MemberLive.IndexTest do describe "sorting integration" do @describetag :ui # The curated default columns no longer expose the individual name/address - # sort headers, so these tests make the relevant column visible via ?fields=. - # That puts a `fields` param on every push_patch, hence the relaxed - # substring assertions instead of exact patch strings. + # sort headers. The composite E-Mail column is offered even in compact mode + # (so email-only tests just add it via ?fields=), but the name/address + # constituents are only offered once their composite view setting is off — + # those tests additionally opt out via `non_compact/1`. Either way a `fields` + # param rides on every push_patch, hence the relaxed substring assertions. test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?fields=email") @@ -183,7 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do end test "clicking different column header resets order to ascending", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc") @@ -199,7 +214,7 @@ defmodule MvWeb.MemberLive.IndexTest do end test "all sortable columns work correctly", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live( @@ -275,7 +290,7 @@ defmodule MvWeb.MemberLive.IndexTest do end test "handle_params handles invalid sort field gracefully", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc") diff --git a/test/mv_web/member_live/index_view_settings_test.exs b/test/mv_web/member_live/index_view_settings_test.exs new file mode 100644 index 00000000..483a8214 --- /dev/null +++ b/test/mv_web/member_live/index_view_settings_test.exs @@ -0,0 +1,102 @@ +defmodule MvWeb.MemberLive.IndexViewSettingsTest do + @moduledoc """ + §1.20 — the view-settings dropdown toggles: the composite "Member" and address + fields, the "include email" sub-toggle and their effect on the rendered + columns. Turning a composite off surfaces the underlying separate columns. + §1.2 — email is not in the Member cell by default. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + street: "Baker Street", + house_number: "221", + postal_code: "10115", + city: "Berlin" + }, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + defp open_view_settings(view) do + view |> element("[data-testid='view-settings-button']") |> render_click() + view + end + + test "member cell hides the email line by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='member-name']") + refute has_element?(view, "[data-testid='member-name-email']") + end + + test "the include-email sub-toggle surfaces the email line in the member cell", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + view |> element("[data-testid='view-setting-member-include-email']") |> render_click() + + assert has_element?(view, "[data-testid='member-name-email']", "ada@example.com") + end + + test "turning off the compact member field shows separate name columns", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + view |> element("[data-testid='view-setting-compact-member']") |> render_click() + + refute has_element?(view, "[data-testid='member-name']") + assert has_element?(view, "[data-testid='first_name']") + assert has_element?(view, "[data-testid='last_name']") + # With the composite off, the email is surfaced as its own column. + assert has_element?(view, "[data-testid='email']") + end + + test "with the compact member field off, the include-email sub-toggle is hidden", + %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + # While the composite Member field is on, the sub-toggle is available. + assert has_element?(view, "[data-testid='view-setting-member-include-email']") + + view |> element("[data-testid='view-setting-compact-member']") |> render_click() + + # Once the composite is off, the email is a separate column, so the + # in-cell "include email" sub-toggle no longer applies and is hidden. + refute has_element?(view, "[data-testid='view-setting-member-include-email']") + assert has_element?(view, "[data-testid='email']") + end + + test "the compact address field renders a composite address cell by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='member-address']") + refute has_element?(view, "[data-testid='street']") + refute has_element?(view, "[data-testid='postal_code']") + refute has_element?(view, "[data-testid='city']") + end + + test "turning off the compact address field shows separate address columns", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + view |> element("[data-testid='view-setting-compact-address']") |> render_click() + + refute has_element?(view, "[data-testid='member-address']") + assert has_element?(view, "[data-testid='street']") + assert has_element?(view, "[data-testid='postal_code']") + assert has_element?(view, "[data-testid='city']") + end +end From 0c375234c87c7a7433c6182f3d31bdda913f2b6d Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 10:48:28 +0200 Subject: [PATCH 07/26] feat(member): make overview columns sortable through accessible sort headers --- assets/css/app.css | 13 +++ assets/js/app.js | 66 +++++++++++ lib/mv_web/components/core_components.ex | 18 ++- .../live/components/sort_header_component.ex | 69 +++++++----- lib/mv_web/live/member_live/index.ex | 23 ++-- lib/mv_web/live/member_live/index.html.heex | 35 ++++-- .../live/member_live/index/overview_query.ex | 16 +++ .../member_live/index_sort_a11y_test.exs | 55 +++++++++ .../index_sort_name_address_test.exs | 106 ++++++++++++++++++ 9 files changed, 350 insertions(+), 51 deletions(-) create mode 100644 test/mv_web/member_live/index_sort_a11y_test.exs create mode 100644 test/mv_web/member_live/index_sort_name_address_test.exs diff --git a/assets/css/app.css b/assets/css/app.css index 37091909..7a828ff3 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -957,3 +957,16 @@ margin-bottom: 0; } +/* + * Sort-header tooltips must render on top of the pinned checkbox column's + * horizontal-scroll fade (::after on .sticky-first-col-th). The hovered/focused + * header th is already lifted above the checkbox th (hover/focus-within:z-50), + * so the whole tooltip wins the stacking; this rule additionally keeps the + * daisyUI tooltip bubble's own pseudo-elements above the fade overlay. Scoped + * to the member overview header so no unrelated tooltip is affected. + */ +#members-keyboard thead .tooltip::before, +#members-keyboard thead .tooltip::after { + z-index: 60; +} + diff --git a/assets/js/app.js b/assets/js/app.js index 85bbc6ba..91172948 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -336,6 +336,72 @@ Hooks.SortableList = { } } +// SortTooltip hook: shows the sort-header hint in the browser TOP LAYER via the +// native Popover API, so it escapes the members table's overflow clipping +// (overflow-x:auto forces overflow-y:auto, which clips a CSS tooltip below the +// header) and paints above the sticky header, neighbor cells, and the pinned +// checkbox column's fade — no z-index fight. The popover is shown on hover AND +// keyboard focus and positioned next to the header with getBoundingClientRect +// (portable; CSS anchor-positioning is not yet supported in Safari/Firefox). +Hooks.SortTooltip = { + mounted() { + this.tip = document.getElementById(this.el.dataset.tooltipId) + // Feature-detect: without Popover API support fall back to no visual tooltip + // (the button's aria-label still conveys the hint to assistive tech). + if (!this.tip || typeof this.tip.showPopover !== "function") return + + this.position = () => { + const btn = this.el.getBoundingClientRect() + const tip = this.tip.getBoundingClientRect() + const gap = 6 + // Prefer just below the header; flip above when there is no room below. + let top = btn.bottom + gap + if (top + tip.height > window.innerHeight && btn.top - gap - tip.height >= 0) { + top = btn.top - gap - tip.height + } + // Left-align to the header, but keep the whole bubble on-screen. + let left = btn.left + const maxLeft = window.innerWidth - tip.width - gap + if (left > maxLeft) left = maxLeft + if (left < gap) left = gap + this.tip.style.top = `${Math.round(top)}px` + this.tip.style.left = `${Math.round(left)}px` + } + + this.show = () => { + try { + if (!this.tip.matches(":popover-open")) this.tip.showPopover() + // showPopover() makes it measurable; positioning in the same tick avoids + // a visible flash (JS runs before the browser paints). + this.position() + } catch (_e) {} + } + + this.hide = () => { + try { + if (this.tip.matches(":popover-open")) this.tip.hidePopover() + } catch (_e) {} + } + + this.onKey = (e) => { if (e.key === "Escape") this.hide() } + + this.el.addEventListener("mouseenter", this.show) + this.el.addEventListener("focus", this.show) + this.el.addEventListener("mouseleave", this.hide) + this.el.addEventListener("blur", this.hide) + this.el.addEventListener("keydown", this.onKey) + }, + + destroyed() { + if (this.hide) this.hide() + this.el.removeEventListener("mouseenter", this.show) + this.el.removeEventListener("focus", this.show) + this.el.removeEventListener("mouseleave", this.hide) + this.el.removeEventListener("blur", this.hide) + this.el.removeEventListener("keydown", this.onKey) + } +} + // SidebarState hook: Manages sidebar expanded/collapsed state Hooks.SidebarState = { mounted() { diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 2b4dcde5..76c44b1c 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -1061,7 +1061,17 @@ defmodule MvWeb.CoreComponents do > {col[:label]}
    + <.live_component module={MvWeb.Components.SortHeaderComponent} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} @@ -1240,8 +1250,8 @@ defmodule MvWeb.CoreComponents do end # Combines column class with optional sticky header classes (desktop only; theme-friendly bg). - # hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10) - # so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background. + # bg-base-100 + z-10 keep the sticky header opaque and above body rows on vertical scroll. + # Sort tooltips use the Popover API (top layer) and need no header z-lift. defp table_th_class(col, sticky_header) do base = Map.get(col, :class) sticky = if sticky_header, do: sticky_th_classes(), else: nil @@ -1253,7 +1263,7 @@ defmodule MvWeb.CoreComponents do defp table_th_sticky_class(_), do: nil defp sticky_th_classes, - do: "lg:sticky lg:top-0 bg-base-100 hover:bg-base-100 z-10 hover:z-20 focus-within:z-20" + do: "lg:sticky lg:top-0 bg-base-100 z-10" @doc """ Renders a reorderable table (sortable list) with drag handle and keyboard support. diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index d3ab1e94..a3470a75 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -6,44 +6,57 @@ defmodule MvWeb.Components.SortHeaderComponent do - label: string() # Column Heading (can be an heex template) - sort_field: atom() | nil # current sort field from parent liveview - sort_order: :asc | :desc | nil # current sorting order + - sort_hint: string() | nil # optional; overrides the generic "click + # to sort" tooltip/aria-label for columns + # whose sort key is non-obvious (e.g. the + # Name column sorts by last name) """ use MvWeb, :live_component @impl true def update(assigns, socket) do - {:ok, assign(socket, assigns)} + {:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)} end # Check if we can add the aria-sort label directly to the daisyUI header # aria-sort={aria_sort(@field, @sort_field, @sort_order)} @impl true def render(assigns) do + assigns = + assigns + |> assign(:tooltip_id, "sort-tooltip-#{assigns.field}") + |> assign(:hint, assigns.sort_hint || gettext("Click to sort")) + ~H"""
    - <.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom"> - <.button - type="button" - variant="ghost" - aria-label={aria_sort(@field, @sort_field, @sort_order)} - class="select-none" - phx-click="sort" - phx-value-field={@field} - data-testid={@field} - > - {@label} - <%= if @sort_field == @field do %> - <.icon - name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} - class="sort-icon" - /> - <% else %> - <.icon - name="hero-chevron-up-down" - class="sort-icon opacity-40" - /> - <% end %> - - + +
    """ end @@ -51,13 +64,13 @@ defmodule MvWeb.Components.SortHeaderComponent do # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- - defp aria_sort(field, sort_field, dir) when field == sort_field do + defp aria_sort(field, sort_field, dir, hint) when field == sort_field do case dir do :asc -> gettext("ascending") :desc -> gettext("descending") - nil -> gettext("Click to sort") + nil -> hint end end - defp aria_sort(_, _, _), do: gettext("Click to sort") + defp aria_sort(_, _, _, hint), do: hint end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 714efe9a..deea80df 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1450,7 +1450,9 @@ defmodule MvWeb.MemberLive.Index do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields - field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type] + + field in valid_fields or custom_field_sort?(field) or + field in [:groups, :membership_fee_type, :name, :address] end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do @@ -1458,6 +1460,8 @@ defmodule MvWeb.MemberLive.Index do cond do field == "groups" -> :groups field == "membership_fee_type" -> :membership_fee_type + field == "name" -> :name + field == "address" -> :address true -> safe_member_field_atom_only(field) end @@ -1508,10 +1512,16 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, ""), do: default defp determine_field(default, nil), do: default + # Computed/pseudo fields that are nonetheless sortable (they resolve to DB + # sort keys in OverviewQuery): groups aggregate and the composite Name/Address + # columns. Other computed fields (e.g. membership_fee_status) are not sortable. + @sortable_computed_fields [:groups, :name, :address] + defp determine_field(default, sf) when is_binary(sf) do - # Handle "groups" specially - it's in computed_member_fields() but can be sorted - if sf == "groups" do - :groups + sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1) + + if sf in sortable_strings do + String.to_existing_atom(sf) else computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) @@ -1522,9 +1532,8 @@ defmodule MvWeb.MemberLive.Index do end defp determine_field(default, sf) when is_atom(sf) do - # Handle :groups specially - it's in computed_member_fields() but can be sorted - if sf == :groups do - :groups + if sf in @sortable_computed_fields do + sf else if sf in FieldVisibility.computed_member_fields(), do: default, diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 9c0db729..7709c2cb 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -5,6 +5,7 @@ <.live_component module={MvWeb.Components.BulkActionsDropdown} id="bulk-actions-dropdown" + open={@bulk_actions_open} export_payload_json={@export_payload_json} selected_count={@selected_count} scope={@scope} @@ -52,7 +53,9 @@ <.button type="button" variant="secondary" - class={["gap-2", @show_current_cycle && "btn-active"]} + class="gap-2" + active={@show_current_cycle} + aria-pressed={to_string(@show_current_cycle)} phx-click="toggle_cycle_view" data-testid="toggle-cycle-view" aria-label={ @@ -135,8 +138,6 @@ sort_order={@sort_order} size_class={if @density == :compact, do: "table-xs", else: "table-md"} > - - <:col :let={member} col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} @@ -285,10 +286,12 @@ [member.postal_code, member.city] |> Enum.reject(&(&1 in [nil, ""])) |> Enum.join(" ") %> -
    -
    {line1}
    -
    {line2}
    -
    + <.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}> +
    +
    {line1}
    +
    {line2}
    +
    + <:col :let={member} @@ -307,7 +310,9 @@ """ } > - {member.street} + <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}> + {member.street} + <:col :let={member} @@ -326,7 +331,9 @@ """ } > - {member.house_number} + <.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}> + {member.house_number} + <:col :let={member} @@ -345,7 +352,9 @@ """ } > - {member.postal_code} + <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}> + {member.postal_code} + <:col :let={member} @@ -364,7 +373,9 @@ """ } > - {member.city} + <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}> + {member.city} + <:col :let={member} @@ -551,7 +562,7 @@ class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70" data-testid="members-loading-row" > - + <.spinner size="sm" aria-hidden="true" /> {gettext("Loading more members …")} diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index bf4ef85c..ac3051f0 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -254,6 +254,22 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) + # Composite "Member" column: sort by last name then first name. The email that + # shares the cell is display-only and does not affect the sort key (§1.21). + defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do + Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}]) + end + + # Composite "Address" column: sort by city, then postal code, then street. + defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do + Ash.Query.sort(query, [ + {:city, order}, + {:postal_code, order}, + {:street, order}, + {:id, :asc} + ]) + end + defp apply_sort(query, field, order, custom_fields) do case sort_key(field) do nil -> apply_custom_field_sort(query, field, order, custom_fields) diff --git a/test/mv_web/member_live/index_sort_a11y_test.exs b/test/mv_web/member_live/index_sort_a11y_test.exs new file mode 100644 index 00000000..fa032ee3 --- /dev/null +++ b/test/mv_web/member_live/index_sort_a11y_test.exs @@ -0,0 +1,55 @@ +defmodule MvWeb.MemberLive.IndexSortA11yTest do + @moduledoc """ + §1.11 — When a column is sorted, exactly one `th` carries `aria-sort` with the + correct direction, a non-color sort glyph is present, and the sort is reflected + in the URL. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + @moduletag :ui + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Sortable", last_name: "Member", email: "sortable@example.com"}, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + defp aria_sort_count(html) do + ~r/aria-sort="/ |> Regex.scan(html) |> length() + end + + test "exactly one th carries aria-sort ascending with a non-color glyph", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=asc") + + assert aria_sort_count(html) == 1 + assert has_element?(view, "th[aria-sort='ascending']") + # Non-color shape glyph for the active ascending sort. + assert html =~ "hero-chevron-up" + end + + test "aria-sort reflects descending and stays unique", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=desc") + + assert aria_sort_count(html) == 1 + assert has_element?(view, "th[aria-sort='descending']") + assert html =~ "hero-chevron-down" + end + + test "clicking a header reflects the sort in the URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("[data-testid='join_date']") |> render_click() + + path = assert_patch(view) + assert path =~ "sort_field=join_date" + assert path =~ "sort_order=asc" + end +end diff --git a/test/mv_web/member_live/index_sort_name_address_test.exs b/test/mv_web/member_live/index_sort_name_address_test.exs new file mode 100644 index 00000000..519cebc6 --- /dev/null +++ b/test/mv_web/member_live/index_sort_name_address_test.exs @@ -0,0 +1,106 @@ +defmodule MvWeb.MemberLive.IndexSortNameAddressTest do + @moduledoc """ + §1.21 — the composite Member (Name) and Address columns are sortable DB-side: + Name by last name → first name (email in the cell is display-only), Address by + city → postal code → street. Ascending/descending, reflected in the URL and in + `aria-sort` (§1.11). + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + alias MvWeb.MemberLive.Index.OverviewQuery + + setup %{conn: conn} do + actor = SystemActor.get_system_actor() + + # Same last name, different first name — proves the first-name tie-breaker. + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Bruno", + last_name: "Meyer", + email: "z-bruno@example.com", + city: "Aachen", + postal_code: "52062", + street: "Zebra" + }, + actor: actor + ) + + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Anna", + last_name: "Meyer", + email: "a-anna@example.com", + city: "Zwickau", + postal_code: "08056", + street: "Alpha" + }, + actor: actor + ) + + %{conn: conn_with_oidc_user(conn), actor: actor} + end + + defp read_names(query, actor) do + query + |> Ash.read!(actor: actor) + |> Enum.map(&{&1.last_name, &1.first_name}) + end + + test "name sort orders by last name then first name, ignoring email", %{actor: actor} do + asc = OverviewQuery.build(%{sort_field: :name, sort_order: :asc}) |> read_names(actor) + + # Both share last name "Meyer"; the first-name tie-breaker orders Anna before + # Bruno, even though Bruno's email sorts first alphabetically. + assert asc == [{"Meyer", "Anna"}, {"Meyer", "Bruno"}] + + desc = OverviewQuery.build(%{sort_field: :name, sort_order: :desc}) |> read_names(actor) + assert desc == [{"Meyer", "Bruno"}, {"Meyer", "Anna"}] + end + + test "address sort orders by city, then postal code, then street", %{actor: actor} do + asc = + OverviewQuery.build(%{sort_field: :address, sort_order: :asc}) + |> Ash.read!(actor: actor) + |> Enum.map(& &1.city) + + assert asc == ["Aachen", "Zwickau"] + + desc = + OverviewQuery.build(%{sort_field: :address, sort_order: :desc}) + |> Ash.read!(actor: actor) + |> Enum.map(& &1.city) + + assert desc == ["Zwickau", "Aachen"] + end + + test "clicking the Name header sorts and reflects state in URL and aria-sort", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[phx-click='sort'][data-testid='name']") |> render_click() + + path = assert_patch(view) + assert path =~ "sort_field=name" + # Exactly one column carries aria-sort (§1.11). + assert has_element?(view, "th[aria-sort='ascending']") + assert render(view) |> aria_sort_count() == 1 + end + + test "clicking the Address header sorts and reflects state in URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[phx-click='sort'][data-testid='address']") |> render_click() + + path = assert_patch(view) + assert path =~ "sort_field=address" + assert has_element?(view, "th[aria-sort='ascending']") + end + + defp aria_sort_count(html) do + html |> String.split("aria-sort=") |> length() |> Kernel.-(1) + end +end From c2cb3edab85e39a172addbe155ec41e6ccc624a0 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 10:49:10 +0200 Subject: [PATCH 08/26] feat(member): bring the overview table up to WCAG 2.2 AA --- assets/css/app.css | 13 ---- assets/js/app.js | 16 +++++ lib/mv_web/components/core_components.ex | 67 ++++++++++++++----- lib/mv_web/live/member_live/index.ex | 11 ++- .../member_live/index_a11y_hardening_test.exs | 9 ++- .../member_live/index_sticky_pinned_test.exs | 14 ++-- 6 files changed, 91 insertions(+), 39 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 7a828ff3..37091909 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -957,16 +957,3 @@ margin-bottom: 0; } -/* - * Sort-header tooltips must render on top of the pinned checkbox column's - * horizontal-scroll fade (::after on .sticky-first-col-th). The hovered/focused - * header th is already lifted above the checkbox th (hover/focus-within:z-50), - * so the whole tooltip wins the stacking; this rule additionally keeps the - * daisyUI tooltip bubble's own pseudo-elements above the fade overlay. Scoped - * to the member overview header so no unrelated tooltip is affected. - */ -#members-keyboard thead .tooltip::before, -#members-keyboard thead .tooltip::after { - z-index: 60; -} - diff --git a/assets/js/app.js b/assets/js/app.js index 91172948..90e36102 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -36,6 +36,22 @@ function getBrowserTimezone() { // Hooks for LiveView components let Hooks = {} +// IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS +// property, not an HTML attribute, so it cannot be set from the server render. +// Mirror it from the data-indeterminate attribute on mount and every update +// (e.g. select-all reflecting a partial selection — WCAG select-all semantics). +Hooks.IndeterminateCheckbox = { + updateIndeterminate() { + this.el.indeterminate = this.el.dataset.indeterminate === "true" + }, + mounted() { + this.updateIndeterminate() + }, + updated() { + this.updateIndeterminate() + } +} + // CopyToClipboard hook: Copies text to clipboard when triggered by server event Hooks.CopyToClipboard = { mounted() { diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 76c44b1c..f8b95f93 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -1093,12 +1093,17 @@ defmodule MvWeb.CoreComponents do