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"} > - +
@@ -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