From f0be98316cc2e2bb57ada5202dc5b66aa207e0cc Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 08:43:54 +0100 Subject: [PATCH 1/8] docs: adds design guidelines --- CODE_GUIDELINES.md | 3 + DESIGN_DUIDELINES.md | 361 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 DESIGN_DUIDELINES.md diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 50c9eca..68e7887 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -60,6 +60,9 @@ We are building a membership management system (Mila) using the following techno 7. [Documentation Standards](#7-documentation-standards) 8. [Accessibility Guidelines](#8-accessibility-guidelines) +**Related documents:** +- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors. + --- ## 1. Setup and Architectural Conventions diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md new file mode 100644 index 0000000..b0372ef --- /dev/null +++ b/DESIGN_DUIDELINES.md @@ -0,0 +1,361 @@ +# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI) + +## Purpose +This document defines Mila’s **UI system** to ensure **UX consistency**, **accessibility**, and **maintainability** across Phoenix LiveView pages: + +- consistent DaisyUI usage +- typography & spacing +- button intent & labeling +- list/search/filter UX +- tables behavior (row click, tooltips, alignment) +- flash/toast UX (position, stacking, auto-dismiss, tones) +- standard page skeletons (index/detail/form) +- microcopy conventions (German “du” tone) + +> Engineering practices (LiveView load budget, testing, security, etc.) are defined in `docs/CODE_GUIDELINES.md`. +> This document focuses on **visual + UX** consistency and references engineering rules where needed. + +--- + +## 1) Principles + +### 1.1 Components first (no raw DaisyUI classes in views) +- **MUST:** Use `MvWeb.CoreComponents` (e.g. `<.button>`, `<.header>`, `<.table>`, `<.input>`, `<.flash_group>`, `<.form_section>`). +- **MUST NOT:** Write DaisyUI component classes directly in LiveViews/HEEX (e.g. `btn`, `alert`, `table`, `input`, `select`, `tooltip`) unless you are implementing them **inside** CoreComponents. +- **MAY:** Use Tailwind for layout only: `flex`, `grid`, `gap-*`, `p-*`, `max-w-*`, `sm:*`, etc. + +### 1.2 DaisyUI for look, Tailwind for layout +- DaisyUI: component visuals + semantic variants (`btn-primary`, `alert-error`, `badge`, `tooltip`). +- Tailwind: spacing, alignment, responsiveness. + +### 1.3 Semantics over hard-coded colors +- **MUST NOT:** Use “status colors” in views (`bg-green-500`, `text-blue-500`, …). +- **MUST:** Express intent via component props / DaisyUI semantic variants. + +--- + +## 2) Page Skeleton & “Chrome” (mandatory) + +### 2.1 Standard page layout +Every authenticated page should follow the same structure: + +1) `<.header>` (title + optional subtitle + actions) +2) content area with consistent vertical rhythm (`mt-6 space-y-6`) +3) optional footer actions for forms + +**MUST:** Use `<.header>` on every page (except login/public pages). +**SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks. + +**Template:** +```heex +<.header> + Title + <:subtitle>Short explanation of what the page is for. + <:actions> + <.button variant="primary" navigate={...}>Primary action + + + +
+ +
+ +## 3) Typography (system) + +Use these standard roles: + +| Role | Use | Class | +|---|---|---| +| Page title (H1) | main page title | `text-xl font-semibold leading-8` | +| Subtitle | helper under title | `text-sm text-base-content/70` | +| Section title (H2) | section headings | `text-lg font-semibold` | +| Helper text | under inputs | `text-sm text-base-content/70` | +| Fine print | small hints | `text-xs text-base-content/60` | +| Empty state | no data | `text-base-content/60 italic` | +| Destructive text | danger | `text-error` | + +**MUST:** Page titles via `<.header>`. +**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later). + +--- + +## 4) States: Loading, Empty, Error (mandatory consistency) + +### 4.1 Loading state +- **MUST:** Show a consistent loading indicator when data is not ready. +- **MUST NOT:** Render empty states while loading (avoid flicker). +- **SHOULD:** Prefer “skeleton rows” for tables or a spinner in content area. + +### 4.2 Empty state pattern +Empty states must be consistent: +- short message +- optional primary CTA (“Create …”) +- optional secondary help link + +**Example:** +```heex +
+

No members yet.

+ <.button variant="primary" navigate={~p"/members/new"}>Create member +
+ +### 4.3 Error state pattern +- **MUST:** Use flash/toast for global errors. +- **SHOULD:** Also show inline error state near the relevant content area if the page cannot proceed. + +--- + +## 5) Buttons (intent, labels, variants) + +### 5.1 Decision rule: action vs status +- **MUST:** Button labels describe **actions** (verb-first): + - ✅ Save, Create member, Send invite, Import CSV + - ❌ Active, Success, Done (status belongs elsewhere) +- **MUST:** Status belongs in badges/labels or read-only text, not in CTAs. + +### 5.2 Standard variants (mandatory set) +Buttons must be rendered via `<.button>` and mapped to DaisyUI internally. + +**Supported variants:** +- `primary` (main CTA) +- `secondary` (supporting) +- `neutral` (cancel/back) +- `ghost` (low emphasis; table/toolbars) +- `outline` (alternative CTA) +- `danger` (destructive) +- `link` (inline; rare) +- `icon` (icon-only) + +**Sizes:** `sm`, `md` (default), `lg` (rare) + +### 5.3 Placement rules +- Header CTA inside `<.header><:actions>`. +- Form footer: primary right; cancel/secondary left. +- Tables: use `ghost`/`icon` for row actions (avoid `primary` inside rows). + +### 5.4 Primary vs Secondary (UX consistency rules) + +#### One primary action per screen +- MUST: Each screen/section has at most one **primary** action (e.g. Save, Create, Start import). +- SHOULD: Additional actions are secondary/neutral/ghost, not additional primary. + +#### Primary vs Secondary meaning +- Primary = the most important/most common action to complete the user task. +- Secondary = supporting actions (Cancel/Back/Edit in tool contexts), lower emphasis. + +#### Order and placement (choose and apply consistently) +We follow these ordering rules: +- MUST: Order buttons by priority: **Primary → Secondary → Tertiary**. +- Forms: Decide once (primary-left OR primary-right) and apply everywhere. +- Dialogs/confirmations: Place the confirmation action consistently (e.g. trailing edge, confirmation closest to edge). + +#### Cancel/Back consistency +- MUST: Cancel/Back is **never** styled as primary. +- MUST: Cancel/Back placement is consistent across the app (same side, same label). + +#### Implementation requirement +- MUST: Use CoreComponents (`<.button>`) with `variant`/`size` props. +- MUST NOT: Use ad-hoc classes like `class="secondary"` on `<.button>`; instead extend CoreComponents to support `secondary`, `neutral`, `ghost`, `danger`, etc. + +#### Ghost buttons (accessibility requirements) + +Ghost buttons are allowed for low-emphasis actions (toolbars, table actions), but: + +- MUST: Focus indicator is clearly visible (do not remove outlines). +- MUST: UI contrast for the control (and meaningful icons) meets WCAG non-text contrast (≥ 3:1). +- MUST: Icon-only ghost buttons provide an accessible name (`aria-label`) and preferably a tooltip. +- SHOULD: Hit target is large enough for touch/motor accessibility (recommend ~44x44px). +If these cannot be met, use `secondary`/`outline` instead of `ghost`. + + +--- + +## 6) Forms (structure + interaction rules) + +### 6.1 Structure +- **MUST:** Forms are grouped into `<.form_section title="…">`. +- **MUST:** All inputs via `<.input>`. + +### 6.2 Validation timing (consistent UX) +- **MUST:** Validate on submit always. +- **SHOULD:** Validate on change only where it helps; use debounce to avoid “error spam”. +- **MUST:** Define a consistent “when errors appear” rule: + - Preferred: show field errors after first submit attempt OR after the field has been touched (pick one and apply everywhere). + +> Engineering note (implementation): follow LiveView load budget in `CODE_GUIDELINES.md` (no DB reads on `phx-change` by default). + +### 6.3 Required fields +- **MUST:** Required fields are marked consistently (UI indicator + accessible text). +- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form. + +--- + +## 7) Lists, Search & Filters (mandatory UX consistency) + +### 7.1 Standard filter/search bar pattern +- **MUST:** All list pages use the same search/filter placement (choose one layout and apply everywhere). + - Recommended: top area above the table, aligned with page actions. +- **MUST:** Always provide “Clear filters” when filters are active. +- **MUST:** Filter state is reflected in URL params (so reload/back/share works consistently). + +### 7.2 URL behavior (UX rule) +- Use `push_patch` for in-page state changes: filters, sorting, pagination, tabs. +- Use `push_navigate` for actual page transitions: details, edit, new. + +--- + +## 8) Tables (mandatory UX) + +### 8.1 Default behavior: row click opens details +- **DEFAULT:** Clicking a row navigates to the details page. +- **EXCEPTIONS:** Highly interactive rows may disable row-click (document why). + +**IMPORTANT (correctness with our `<.table>` CoreComponent):** +Our table implementation attaches the `phx-click` to the **``** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation. + +So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute. + +✅ Correct pattern (one click handler that both stops propagation and triggers an event): +```heex +<.table + id="members" + rows={@members} + row_click={fn m -> JS.navigate(~p"/members/#{m.id}") end} +> + <:col :let={m} label="Name"> + <%= m.last_name %>, <%= m.first_name %> + + + <:col :let={m} label="Newsletter"> + JS.stop_propagation()} + /> + + + <:action :let={m}> + <.button + variant="ghost" + size="sm" + navigate={~p"/members/#{m.id}/edit"} + phx-click={JS.stop_propagation()} + > + Edit + + + + +Notes: +- The checkbox uses `phx-click={JS.push(...) |> JS.stop_propagation()}` so it won’t trigger row navigation. +- The Edit button also stops propagation to avoid accidental row navigation when clicked. + +### 8.2 Tooltips (mandatory where needed) +- **MUST:** Tooltips for: + - icon-only actions + - truncated content + - status badges that require explanation +- **MUST:** Provide tooltips via a shared wrapper (recommended `<.tooltip>` CoreComponent). +- **MUST NOT:** Scatter ad-hoc tooltip markup in views. + +### 8.3 Alignment & density conventions +- **MUST:** Text columns left-aligned. +- **MUST:** Numeric columns right-aligned. +- **MUST:** Action column right-aligned. +- **SHOULD:** Table density is consistent: + - default density for most tables + - a single “dense” option only if needed (via a prop, not per-page random classes) + +### 8.4 Truncation standard +- **MUST:** Truncate long values consistently (same max widths for name/email-like fields). +- **MUST:** Tooltip reveals full value when truncated. + +--- + +## 9) Flash / Toast messages (mandatory UX) + +### 9.1 Location + stacking +- **MUST:** Position flash/toasts at the bottom of the viewport (pick bottom-right or bottom-center; be consistent). +- **MUST:** Stack all flash messages with consistent spacing. +- **SHOULD:** Newest appears on top. + +### 9.2 Auto-dismiss +- **MUST:** Flash messages disappear automatically: + - info/success: 4–6s + - warning: 6–8s + - error: 8–12s (or manual dismiss for critical errors) +- **MUST:** Keep a dismiss button for accessibility and user control. + +### 9.3 Variants + special “email copied” +- Supported semantic variants: `info`, `success`, `warning`, `error`. +- **Special case:** clipboard “Email copied” uses a **soft/light blue** tone distinct from normal info. +- **MUST:** Model this as `tone="soft"` (or similar prop) on the flash component, not hard-coded colors in views. + +### 9.4 Accessibility +- Flash must work with screen readers (live region behavior belongs in the flash component implementation). +- See `CODE_GUIDELINES.md` Accessibility → live regions. + +--- + +## 10) Mutations & feedback patterns (create/update/delete/import) + +### 10.1 Mutation feedback is always two-part +For create/update/delete: +- **MUST:** Show a toast/flash message +- **MUST:** Show a visible UI update (navigate, row removed, values updated) + +No “silent success”. + +### 10.2 Destructive actions: one standard confirmation pattern +- **MUST:** All destructive actions use the same confirm style and wording conventions. +- Choose one approach and standardize: + - `JS.confirm("…")` everywhere (simple, consistent) + - or a modal component everywhere (more flexible, more work) + +**Recommended copy style:** +- Title/confirm text is clear and specific (what will be deleted, consequences). +- Buttons: `Cancel` (neutral) + `Delete` (danger). + +--- + +## 11) Detail pages (consistent structure) + +Detail pages should not drift into random layouts. + +**MUST:** Use consistent structure: +- header with primary action (Edit) +- sections/cards for grouped info +- “Danger zone” section at bottom for destructive actions + +--- + +## 12) Navigation rules (UX consistency) + +- **MUST:** `push_patch` for in-page state: sorting, filtering, pagination, tabs. +- **MUST:** `push_navigate` for page transitions: detail/edit/new. +- **SHOULD:** Back button behavior must feel predictable (URL reflects state). + +--- + +## 13) Microcopy conventions (German “du” tone + glossary) + +### 13.1 Tone +- **MUST:** All German user-facing text uses informal address (“du”). +- **MUST:** Use consistent verbs for common actions: + - Save: “Speichern” + - Cancel: “Abbrechen” + - Delete: “Löschen” + - Edit: “Bearbeiten” + +### 13.2 Preferred terms (starter glossary) +- Member: “Mitglied” +- Fee/Contribution: “Beitrag” +- Settings: “Einstellungen” +- Group: “Gruppe” +- Import/Export: “Import/Export” +- Clear filters: “Filter zurücksetzen” (use when filters are active; button label in list/filter UX) + +Add to this glossary when new terminology appears. + +--- -- 2.47.2 From b7c93f19cb151e58daddb39b176c46fa376685b9 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 09:12:33 +0100 Subject: [PATCH 2/8] refactor: use core components --- lib/mv_web/components/core_components.ex | 115 +++- lib/mv_web/components/layouts.ex | 2 +- lib/mv_web/components/layouts/root.html.heex | 2 +- .../components/member_filter_component.ex | 24 +- .../live/components/sort_header_component.ex | 41 +- .../live/custom_field_live/form_component.ex | 6 +- .../live/custom_field_live/index_component.ex | 12 +- lib/mv_web/live/global_settings_live.ex | 4 +- lib/mv_web/live/group_live/form.ex | 45 +- lib/mv_web/live/group_live/index.ex | 120 ++-- lib/mv_web/live/group_live/show.ex | 571 +++++++++--------- .../live/member_field_live/form_component.ex | 8 +- .../live/member_field_live/index_component.ex | 7 +- lib/mv_web/live/member_live/form.ex | 421 +++++++------ lib/mv_web/live/member_live/index.html.heex | 16 +- lib/mv_web/live/member_live/show.ex | 418 ++++++------- .../show/membership_fees_component.ex | 62 +- .../live/membership_fee_settings_live.ex | 36 +- .../live/membership_fee_type_live/form.ex | 12 +- .../live/membership_fee_type_live/index.ex | 32 +- lib/mv_web/live/role_live/form.ex | 30 +- lib/mv_web/live/role_live/index.html.heex | 21 +- lib/mv_web/live/role_live/show.ex | 14 +- lib/mv_web/live/user_live/form.ex | 11 +- lib/mv_web/live/user_live/index.html.heex | 2 +- lib/mv_web/live/user_live/show.ex | 2 +- 26 files changed, 1080 insertions(+), 954 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 21e3546..4f9d7af 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -60,15 +60,15 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="z-50 toast toast-top toast-end" + class="z-50 toast toast-bottom toast-end" {@rest} >
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> @@ -90,33 +90,71 @@ defmodule MvWeb.CoreComponents do @doc """ Renders a button with navigation support. + ## Variants (Design Guidelines §5.2) + - primary (main CTA) + - secondary (supporting) + - neutral (cancel/back) + - ghost (low emphasis; table/toolbars) + - outline (alternative CTA) + - danger (destructive) + - link (inline; rare) + - icon (icon-only) + + ## Sizes + - sm, md (default), lg + ## Examples <.button>Send! <.button phx-click="go" variant="primary">Send! - <.button navigate={~p"/"}>Home + <.button navigate={~p"/"} variant="secondary">Home + <.button variant="ghost" size="sm">Edit <.button disabled={true}>Disabled """ attr :rest, :global, include: ~w(href navigate patch method data-testid) - attr :variant, :string, values: ~w(primary) + + attr :variant, :string, + values: ~w(primary secondary neutral ghost outline danger link icon), + default: "primary" + + attr :size, :string, values: ~w(sm md lg), default: "md" attr :disabled, :boolean, default: false, doc: "Whether the button is disabled" slot :inner_block, required: true def button(assigns) do rest = assigns.rest - variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} - assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) + variant = assigns[:variant] || "primary" + size = assigns[:size] || "md" + + variant_classes = %{ + "primary" => "btn-primary", + "secondary" => "btn-secondary", + "neutral" => "btn-neutral", + "ghost" => "btn-ghost", + "outline" => "btn-outline", + "danger" => "btn-error", + "link" => "btn-link", + "icon" => "btn-ghost btn-square" + } + + size_classes = %{ + "sm" => "btn-sm", + "md" => "", + "lg" => "btn-lg" + } + + base_class = Map.fetch!(variant_classes, variant) + size_class = size_classes[size] + btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ") + + assigns = assign(assigns, :btn_class, btn_class) if rest[:href] || rest[:navigate] || rest[:patch] do - # For links, we can't use disabled attribute, so we use btn-disabled class - # DaisyUI's btn-disabled provides the same styling as :disabled on buttons link_class = if assigns[:disabled], - do: ["btn", assigns.class, "btn-disabled"], - else: ["btn", assigns.class] + do: ["btn", btn_class, "btn-disabled"], + else: ["btn", btn_class] - # Prevent interaction when disabled - # Remove navigation attributes to prevent "Open in new tab", "Copy link" etc. link_attrs = if assigns[:disabled] do rest @@ -138,13 +176,49 @@ defmodule MvWeb.CoreComponents do """ else ~H""" - """ end end + @doc """ + Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content, + or status badges that need explanation (Design Guidelines §8.2). + + ## Examples + + <.tooltip content={gettext("Edit")}> + <.button variant="icon" size="sm"><.icon name="hero-pencil" /> + + + <.tooltip content={@full_name} position="top"> + {@full_name} + + """ + attr :content, :string, required: true, doc: "Tooltip text (data-tip)" + + attr :position, :string, + values: ~w(top bottom left right), + default: "bottom" + + attr :wrap_class, :string, default: nil, doc: "Additional classes for the wrapper" + slot :inner_block, required: true + + def tooltip(assigns) do + position_class = "tooltip tooltip-#{assigns.position}" + wrap_class = [position_class, assigns.wrap_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ") + + assigns = assign(assigns, :wrap_class, wrap_class) + + ~H""" +
+ {render_slot(@inner_block)} +
+ """ + end + @doc """ Renders a dropdown menu. @@ -437,7 +511,7 @@ defmodule MvWeb.CoreComponents do {@rest} />{@label}* @@ -456,7 +530,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -485,7 +559,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -514,7 +588,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -585,6 +659,11 @@ defmodule MvWeb.CoreComponents do @doc ~S""" Renders a table with generic styling. + When `row_click` is set, clicking a row (or a data cell) triggers the handler. + The action column has no phx-click on its ``, so action buttons do not trigger row navigation. + For interactive elements inside other columns (e.g. checkboxes, buttons), use + `Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired. + ## Examples <.table id="users" rows={@users}> diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 89e3549..765edec 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -115,7 +115,7 @@ defmodule MvWeb.Layouts do def flash_group(assigns) do ~H""" -
+
<.flash kind={:success} flash={@flash} /> <.flash kind={:warning} flash={@flash} /> <.flash kind={:info} flash={@flash} /> diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index a47fcc7..e107d5b 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -72,7 +72,7 @@
<.flash id="flash-success-root" kind={:success} flash={@flash} /> <.flash id="flash-warning-root" kind={:warning} flash={@flash} /> diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index ef6f32e..c020fc1 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -64,11 +64,11 @@ defmodule MvWeb.Components.MemberFilterComponent do phx-key="Escape" phx-target={@myself} > - +
- - +
diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index d548efa..c4850c4 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -19,25 +19,28 @@ defmodule MvWeb.Components.SortHeaderComponent do @impl true def render(assigns) do ~H""" -
- +
+ <.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom"> + <.button + type="button" + variant="ghost" + aria-label={aria_sort(@field, @sort_field, @sort_order)} + class="select-none" + phx-click="sort" + phx-value-field={@field} + data-testid={@field} + > + {@label} + <%= if @sort_field == @field do %> + <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} /> + <% else %> + <.icon + name="hero-chevron-up-down" + class="opacity-40" + /> + <% end %> + +
""" end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index f89f767..8e59ac9 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -24,11 +24,13 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<.button type="button" + variant="neutral" phx-click="cancel" phx-target={@myself} aria-label={gettext("Back to settings")} > - <.icon name="hero-arrow-left" class="w-4 h-4" /> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")}

{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")} @@ -97,7 +99,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do />
- <.button type="button" phx-click="cancel" phx-target={@myself}> + <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}> {gettext("Cancel")} <.button phx-disable-with={gettext("Saving...")} variant="primary"> diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index a670a3e..a944c85 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -100,7 +100,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do <.link phx-click={ JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) }> - {gettext("Edit")} + {gettext("Edit datafield")} @@ -164,17 +164,17 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do

diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 752c8d6..f3a61bc 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -181,18 +181,18 @@ defmodule MvWeb.GlobalSettingsLive do <.button :if={Mv.Config.vereinfacht_configured?()} type="button" + variant="outline" phx-click="test_vereinfacht_connection" phx-disable-with={gettext("Testing...")} - class="btn-outline" > {gettext("Test Integration")} <.button :if={Mv.Config.vereinfacht_configured?()} type="button" + variant="outline" phx-click="sync_vereinfacht_contacts" phx-disable-with={gettext("Syncing...")} - class="btn-outline" > {gettext("Sync all members without Vereinfacht contact")} diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex index 0ffba09..5f781a7 100644 --- a/lib/mv_web/live/group_live/form.ex +++ b/lib/mv_web/live/group_live/form.ex @@ -78,30 +78,29 @@ defmodule MvWeb.GroupLive.Form do ~H""" <.form for={@form} id="group-form" phx-change="validate" phx-submit="save"> - <%!-- Header with Back button, Title, and Save button --%> -
- <.button navigate={return_path(@return_to, @group)} type="button"> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - + <.header> + {@page_title} + <:actions> + <.button navigate={return_path(@return_to, @group)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save")} + + + -

- {@page_title} -

- - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save")} - -
- -
- <.input field={@form[:name]} label={gettext("Name")} required /> - <.input - field={@form[:description]} - type="textarea" - label={gettext("Description")} - rows="4" - /> +
+
+ <.input field={@form[:name]} label={gettext("Name")} required /> + <.input + field={@form[:description]} + type="textarea" + label={gettext("Description")} + rows="4" + /> +
diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex index deab7e1..b6c8277 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -39,72 +39,64 @@ defmodule MvWeb.GroupLive.Index do def render(assigns) do ~H""" -
-

{gettext("Groups")}

- <%= if can?(@current_user, :create, Mv.Membership.Group) do %> - <.button navigate={~p"/groups/new"} variant="primary"> - <.icon name="hero-plus" class="size-4 mr-2" /> - {gettext("Create Group")} - + <.header> + {gettext("Groups")} + <:actions> + <%= if can?(@current_user, :create, Mv.Membership.Group) do %> + <.button navigate={~p"/groups/new"} variant="primary"> + <.icon name="hero-plus" class="size-4 mr-2" /> + {gettext("Create Group")} + + <% end %> + + + +
+ <%= if Enum.empty?(@groups) do %> +
+

{gettext("No groups")}

+
+ <% else %> + <.table + id="groups-table" + rows={@groups} + row_id={fn group -> "group-#{group.id}" end} + row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end} + > + <:col :let={group} label={gettext("Name")}> + {group.name} + + <:col :let={group} label={gettext("Description")}> + <%= if group.description do %> + {group.description} + <% else %> + + <% end %> + + <:col :let={group} label={gettext("Members")} class="text-right"> + {group.member_count || 0} + + <:action :let={group}> + <.button + variant="ghost" + size="sm" + navigate={~p"/groups/#{group.slug}"} + > + {gettext("View")} + + <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <.button + variant="ghost" + size="sm" + navigate={~p"/groups/#{group.slug}/edit"} + > + {gettext("Edit group")} + + <% end %> + + <% end %>
- - <%= if Enum.empty?(@groups) do %> -
-

{gettext("No groups")}

-
- <% else %> -
- - - - - - - - - - - <%= for group <- @groups do %> - - - - - - - <% end %> - -
{gettext("Name")}{gettext("Description")}{gettext("Members")}{gettext("Actions")}
- {group.name} - - <%= if group.description do %> - {group.description} - <% else %> - - <% end %> - - <%= if group.member_count do %> - {group.member_count} - <% else %> - 0 - <% end %> - -
- <.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost"> - {gettext("View")} - - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> - <.link - navigate={~p"/groups/#{group.slug}/edit"} - class="btn btn-sm btn-ghost" - > - {gettext("Edit")} - - <% end %> -
-
-
- <% end %> """ end diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 0c7e93e..46766ef 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -85,318 +85,327 @@ defmodule MvWeb.GroupLive.Show do def render(assigns) do ~H""" - <%!-- Header with Back button, Name, and Edit/Delete buttons --%> -
- <.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - - -

- {@group.name} -

- -
+ <.header> + {@group.name} + <:actions> + <.button + navigate={~p"/groups"} + variant="neutral" + aria-label={gettext("Back to groups list")} + > + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + <%= if can?(@current_user, :update, @group) do %> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"} data-testid="group-show-edit-btn" > - {gettext("Edit")} + {gettext("Edit group")} <% end %> <%= if can?(@current_user, :destroy, @group) do %> <.button - class="btn-error" + variant="danger" phx-click="open_delete_modal" data-testid="group-show-delete-btn" > {gettext("Delete")} <% end %> -
-
+ + - <%!-- Group Information --%> -
-
-

{gettext("Description")}

-
- <%= if @group.description && String.trim(@group.description) != "" do %> -

{@group.description}

- <% else %> -

{gettext("No description")}

- <% end %> +
+ <%!-- Group Information --%> +
+
+

{gettext("Description")}

+
+ <%= if @group.description && String.trim(@group.description) != "" do %> +

{@group.description}

+ <% else %> +

{gettext("No description")}

+ <% end %> +
-
-
-

{gettext("Members")}

-
-

- {ngettext( - "Total: %{count} member", - "Total: %{count} members", - @group.member_count || 0, - count: @group.member_count || 0 - )} -

- - <%= if can?(@current_user, :update, @group) do %> -
- <%= if assigns[:show_add_member_input] do %> -
-
-
-
- <%= for member <- @selected_members do %> - - {MvWeb.Helpers.MemberHelpers.display_name(member)} - - - <% end %> - -
- - <%= if length(@available_members) > 0 do %> -
- <%= for {member, index} <- Enum.with_index(@available_members) do %> -
-

- {MvWeb.Helpers.MemberHelpers.display_name(member)} -

-

- {member.email || gettext("No email")} -

-
- <% end %> -
- <% end %> -
-
- - -
- <% else %> - <.button - variant="primary" - phx-click="show_add_member_input" - aria-label={gettext("Add Member")} - > - {gettext("Add Member")} - - <% end %> -
- <% end %> - - <%= if Enum.empty?(@group.members || []) do %> -

- {gettext("No members in this group")} +

+

{gettext("Members")}

+
+

+ {ngettext( + "Total: %{count} member", + "Total: %{count} members", + @group.member_count || 0, + count: @group.member_count || 0 + )}

- <% else %> -
- - - - - - <%= if can?(@current_user, :update, @group) do %> - - <% end %> - - - - <%= for member <- @group.members do %> - - - + + + <.button + type="button" + variant="primary" + phx-click="add_selected_members" + data-testid="group-show-add-selected-members-btn" + disabled={Enum.empty?(@selected_member_ids)} + aria-label={gettext("Add members")} + class="join-item" + > + <.icon name="hero-plus" class="size-5" /> + + <.button + type="button" + variant="neutral" + phx-click="hide_add_member_input" + aria-label={gettext("Cancel")} + class="join-item" + > + {gettext("Cancel")} + + + <% else %> + <.button + variant="primary" + phx-click="show_add_member_input" + aria-label={gettext("Add Member")} + > + {gettext("Add Member")} + + <% end %> + + <% end %> + + <%= if Enum.empty?(@group.members || []) do %> +

+ {gettext("No members in this group")} +

+ <% else %> +
+
{gettext("Name")}{gettext("Email")}{gettext("Actions")}
- <.link - navigate={~p"/members/#{member.id}"} - class="link link-primary" - > - {MvWeb.Helpers.MemberHelpers.display_name(member)} - - - <%= if member.email do %> - +
+ + + + <%= if can?(@current_user, :update, @group) do %> - + <% end %> - <% end %> - -
{gettext("Name")}{gettext("Email")} - - {gettext("Actions")}
-
- <% end %> + + + <%= for member <- @group.members do %> + + + <.link + navigate={~p"/members/#{member.id}"} + class="link link-primary" + > + {MvWeb.Helpers.MemberHelpers.display_name(member)} + + + + <%= if member.email do %> + + {member.email} + + <% else %> + + <% end %> + + <%= if can?(@current_user, :update, @group) do %> + + <.tooltip content={gettext("Remove")} position="left"> + <.button + type="button" + variant="danger" + size="sm" + phx-click="remove_member" + phx-value-member_id={member.id} + data-testid="group-show-remove-member" + aria-label={gettext("Remove member from group")} + > + <.icon name="hero-trash" class="size-4" /> + + + + <% end %> + + <% end %> + + +
+ <% end %> +
-
- <%!-- Delete Confirmation Modal --%> - <%= if assigns[:show_delete_modal] do %> - - + <% end %> +
""" end diff --git a/lib/mv_web/live/member_field_live/form_component.ex b/lib/mv_web/live/member_field_live/form_component.ex index ae9e239..17266a8 100644 --- a/lib/mv_web/live/member_field_live/form_component.ex +++ b/lib/mv_web/live/member_field_live/form_component.ex @@ -42,11 +42,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
<.button type="button" + variant="neutral" phx-click="cancel" phx-target={@myself} - aria-label={gettext("Back to Settings")} + aria-label={gettext("Back to settings")} > - <.icon name="hero-arrow-left" class="w-4 h-4" /> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")}

{gettext("Edit Field: %{field}", field: @field_label)} @@ -176,7 +178,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do

- <.button type="button" phx-click="cancel" phx-target={@myself}> + <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}> {gettext("Cancel")} <.button phx-disable-with={gettext("Saving...")} variant="primary"> diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index db62778..1e8cf05 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -52,6 +52,11 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do :if={!@show_form} id="member_fields" rows={@member_fields} + row_click={ + fn {field_name, _field_data} -> + JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) + end + } > <:col :let={{_field_name, field_data}} label={gettext("Name")}> {MemberFields.label(field_data.field)} @@ -93,7 +98,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do phx-value-field={Atom.to_string(field_data.field)} phx-target={@myself} > - {gettext("Edit")} + {gettext("Edit datafield")} diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 6b3ce67..625ab2a 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -38,227 +38,226 @@ defmodule MvWeb.MemberLive.Form do ~H""" <.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> - <%!-- Header with Back button, Name display, and Save button --%> -
- <.button navigate={return_path(@return_to, @member)} type="button"> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - + <.header> + <%= if @member do %> + {MvWeb.Helpers.MemberHelpers.display_name(@member)} + <% else %> + {gettext("New Member")} + <% end %> + <:actions> + <.button navigate={return_path(@return_to, @member)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save")} + + + -

- <%= if @member do %> - {MvWeb.Helpers.MemberHelpers.display_name(@member)} - <% else %> - {gettext("New Member")} +
+ <%!-- Tab Navigation --%> +
+ + +
+ + <%!-- Personal Data and Custom Fields Row --%> +
+ <%!-- Personal Data Section --%> +
+ <.form_section title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+
+ <.input + field={@form[:first_name]} + label={gettext("First Name")} + required={@member_field_required_map[:first_name]} + /> +
+
+ <.input + field={@form[:last_name]} + label={gettext("Last Name")} + required={@member_field_required_map[:last_name]} + /> +
+
+ + <%!-- Address Row --%> +
+
+ <.input + field={@form[:street]} + label={gettext("Street")} + required={@member_field_required_map[:street]} + /> +
+
+ <.input + field={@form[:house_number]} + label={gettext("Nr.")} + required={@member_field_required_map[:house_number]} + /> +
+
+ <.input + field={@form[:postal_code]} + label={gettext("Postal Code")} + required={@member_field_required_map[:postal_code]} + /> +
+
+ <.input + field={@form[:city]} + label={gettext("City")} + required={@member_field_required_map[:city]} + /> +
+
+ + <%!-- Email (always required) --%> +
+ <.input field={@form[:email]} label={gettext("Email")} required type="email" /> +
+ + <%!-- Membership Dates Row --%> +
+
+ <.input + field={@form[:join_date]} + label={gettext("Join Date")} + type="date" + required={@member_field_required_map[:join_date]} + /> +
+
+ <.input + field={@form[:exit_date]} + label={gettext("Exit Date")} + type="date" + required={@member_field_required_map[:exit_date]} + /> +
+
+ + <%!-- Notes --%> +
+ <.input + field={@form[:notes]} + label={gettext("Notes")} + type="textarea" + required={@member_field_required_map[:notes]} + /> +
+
+ +
+ + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.form_section title={gettext("Custom Fields")}> +
+ <%!-- Render in sorted order by finding the form for each sorted custom field --%> + <%= for cf <- @sorted_custom_fields do %> + <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> + <%= if f_cfv[:custom_field_id].value == cf.id do %> +
+ <.inputs_for :let={value_form} field={f_cfv[:value]}> + <.input + field={value_form[:value]} + label={cf.name} + type={custom_field_input_type(cf.value_type)} + required={cf.required} + /> + + +
+ <% end %> + + <% end %> +
+ +
<% end %> -

+
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save")} - -
- - <%!-- Tab Navigation --%> -
- - -
- - <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.form_section title={gettext("Personal Data")}> + <%!-- Membership Fee Section --%> +
+ <.form_section title={gettext("Membership Fee")}>
- <%!-- Name Row --%> -
-
- <.input - field={@form[:first_name]} - label={gettext("First Name")} - required={@member_field_required_map[:first_name]} - /> -
-
- <.input - field={@form[:last_name]} - label={gettext("Last Name")} - required={@member_field_required_map[:last_name]} - /> -
-
- - <%!-- Address Row --%> -
-
- <.input - field={@form[:street]} - label={gettext("Street")} - required={@member_field_required_map[:street]} - /> -
-
- <.input - field={@form[:house_number]} - label={gettext("Nr.")} - required={@member_field_required_map[:house_number]} - /> -
-
- <.input - field={@form[:postal_code]} - label={gettext("Postal Code")} - required={@member_field_required_map[:postal_code]} - /> -
-
- <.input - field={@form[:city]} - label={gettext("City")} - required={@member_field_required_map[:city]} - /> -
-
- - <%!-- Email (always required) --%>
- <.input field={@form[:email]} label={gettext("Email")} required type="email" /> -
- - <%!-- Membership Dates Row --%> -
-
- <.input - field={@form[:join_date]} - label={gettext("Join Date")} - type="date" - required={@member_field_required_map[:join_date]} - /> -
-
- <.input - field={@form[:exit_date]} - label={gettext("Exit Date")} - type="date" - required={@member_field_required_map[:exit_date]} - /> -
-
- - <%!-- Notes --%> -
- <.input - field={@form[:notes]} - label={gettext("Notes")} - type="textarea" - required={@member_field_required_map[:notes]} - /> + + + <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

{msg}

+ <% end %> + <%= if @interval_warning do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + {@interval_warning} +
+ <% end %> +

+ {gettext( + "Select a membership fee type for this member. Members can only switch between types with the same interval." + )} +

- <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.form_section title={gettext("Custom Fields")}> -
- <%!-- Render in sorted order by finding the form for each sorted custom field --%> - <%= for cf <- @sorted_custom_fields do %> - <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> - <%= if f_cfv[:custom_field_id].value == cf.id do %> -
- <.inputs_for :let={value_form} field={f_cfv[:value]}> - <.input - field={value_form[:value]} - label={cf.name} - type={custom_field_input_type(cf.value_type)} - required={cf.required} - /> - - -
- <% end %> - - <% end %> -
- -
- <% end %> -
- - <%!-- Membership Fee Section --%> -
- <.form_section title={gettext("Membership Fee")}> -
-
- - - <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> - <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> -

{msg}

- <% end %> - <%= if @interval_warning do %> -
- <.icon name="hero-exclamation-triangle" class="size-5" /> - {@interval_warning} -
- <% end %> -

- {gettext( - "Select a membership fee type for this member. Members can only switch between types with the same interval." - )} -

-
-
- -
- - <%!-- Bottom Action Buttons --%> -
- <.button navigate={return_path(@return_to, @member)} type="button"> - {gettext("Cancel")} - - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save Member")} - + <%!-- Bottom Action Buttons --%> +
+ <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> + {gettext("Cancel")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save Member")} + +
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index fcf06c8..c54ec7c 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -9,7 +9,7 @@ selected_count={@selected_count} /> <.button - class="secondary" + variant="secondary" id="copy-emails-btn" phx-hook="CopyToClipboard" phx-click="copy_emails" @@ -20,7 +20,7 @@ {gettext("Copy email addresses")} ({@selected_count}) <.button - class="secondary" + variant="secondary" id="open-email-btn" href={"mailto:?bcc=" <> @mailto_bcc} disabled={not @any_selected?} @@ -54,13 +54,11 @@ boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} /> - + <.live_component module={MvWeb.Components.FieldVisibilityDropdownComponent} id="field-visibility-dropdown" @@ -386,7 +384,7 @@ <%= if can?(@current_user, :update, member) do %> <.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit"> - {gettext("Edit")} + {gettext("Edit member")} <% end %> diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index a85bf69..3af0ed2 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -30,235 +30,243 @@ defmodule MvWeb.MemberLive.Show do def render(assigns) do ~H""" - <%!-- Header with Back button, Name, and Edit button --%> -
- <.button navigate={~p"/members"} aria-label={gettext("Back to members list")}> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - - -

- {MvWeb.Helpers.MemberHelpers.display_name(@member)} -

- - <%= if can?(@current_user, :update, @member) do %> + <.header> + {MvWeb.Helpers.MemberHelpers.display_name(@member)} + <:actions> <.button - variant="primary" - navigate={~p"/members/#{@member}/edit?return_to=show"} - data-testid="member-edit" + navigate={~p"/members"} + variant="neutral" + aria-label={gettext("Back to members list")} > - {gettext("Edit Member")} + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} - <% end %> -
+ <%= if can?(@current_user, :update, @member) do %> + <.button + variant="primary" + navigate={~p"/members/#{@member}/edit?return_to=show"} + data-testid="member-edit" + > + {gettext("Edit member")} + + <% end %> + + - <%!-- Tab Navigation --%> -
- - -
+
+ <%!-- Tab Navigation --%> +
+ + +
- <%= if @active_tab == :contact do %> - <%!-- Contact Data Tab Content --%> - <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.section_box title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
- <.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" /> - <.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> -
+ <%= if @active_tab == :contact do %> + <%!-- Contact Data Tab Content --%> + <%!-- Personal Data and Custom Fields Row --%> +
+ <%!-- Personal Data Section --%> +
+ <.section_box title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+ <.data_field + label={gettext("First Name")} + value={@member.first_name} + class="w-48" + /> + <.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> +
- <%!-- Address --%> -
- <.data_field label={gettext("Address")} value={format_address(@member)} /> -
+ <%!-- Address --%> +
+ <.data_field label={gettext("Address")} value={format_address(@member)} /> +
- <%!-- Email --%> -
- <.data_field label={gettext("Email")}> - - {@member.email} - - -
+ <%!-- Email --%> +
+ <.data_field label={gettext("Email")}> + + {@member.email} + + +
- <%!-- Membership Dates Row --%> -
- <.data_field - label={gettext("Join Date")} - value={format_date(@member.join_date)} - class="w-28" - /> - <.data_field - label={gettext("Exit Date")} - value={format_date(@member.exit_date)} - class="w-28" - /> -
+ <%!-- Membership Dates Row --%> +
+ <.data_field + label={gettext("Join Date")} + value={format_date(@member.join_date)} + class="w-28" + /> + <.data_field + label={gettext("Exit Date")} + value={format_date(@member.exit_date)} + class="w-28" + /> +
- <%!-- Linked User: only show when current user can see other users (e.g. admin). + <%!-- Linked User: only show when current user can see other users (e.g. admin). read_only cannot see linked user, so hide the section to avoid "No user linked" when a user is linked but not visible. --%> - <%= if can_access_page?(@current_user, "/users") do %> + <%= if can_access_page?(@current_user, "/users") do %> +
+ <.data_field label={gettext("Linked User")}> + <%= if @member.user do %> + <.link + navigate={~p"/users/#{@member.user}"} + class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" + > + <.icon name="hero-user" class="size-4" /> + {@member.user.email} + + <% else %> + {gettext("No user linked")} + <% end %> + +
+ <% end %> + + <%!-- Groups (in Personal Data) --%> + <% groups = @member.groups || [] %>
- <.data_field label={gettext("Linked User")}> - <%= if @member.user do %> - <.link - navigate={~p"/users/#{@member.user}"} - class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" - > - <.icon name="hero-user" class="size-4" /> - {@member.user.email} - + <.data_field label={gettext("Groups")}> + <%= if Enum.empty?(groups) do %> + {gettext("No groups")} <% else %> - {gettext("No user linked")} +
+ <%= for group <- groups do %> + <.button + variant="outline" + size="sm" + navigate={~p"/groups/#{group.slug}"} + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
<% end %>
- <% end %> - <%!-- Groups (in Personal Data) --%> - <% groups = @member.groups || [] %> -
- <.data_field label={gettext("Groups")}> - <%= if Enum.empty?(groups) do %> - {gettext("No groups")} - <% else %> -
- <%= for group <- groups do %> - <.link - navigate={~p"/groups/#{group.slug}"} - class="btn btn-xs btn-outline btn-primary" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> -
- <% end %> - -
- - <%!-- Notes --%> - <%= if @member.notes && String.trim(@member.notes) != "" do %> -
- <.data_field label={gettext("Notes")}> -

{@member.notes}

- -
- <% end %> -
- -
- - <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.section_box title={gettext("Custom Fields")}> -
- <%= for custom_field <- @custom_fields do %> - <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> - <.data_field label={custom_field.name}> - {format_custom_field_value(cfv, custom_field.value_type)} - + <%!-- Notes --%> + <%= if @member.notes && String.trim(@member.notes) != "" do %> +
+ <.data_field label={gettext("Notes")}> +

{@member.notes}

+ +
<% end %>
- <% end %> -
- <%!-- Payment Data Section --%> -
- <.section_box title={gettext("Payment Data")}> - <%= if @member.membership_fee_type do %> -
- <.data_field - label={gettext("Type")} - value={@member.membership_fee_type.name} - class="min-w-32" - /> - <.data_field - label={gettext("Membership Fee")} - value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} - class="min-w-24" - /> - <.data_field - label={gettext("Payment Interval")} - value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)} - class="min-w-32" - /> - <.data_field label={gettext("Last Cycle")} class="min-w-32"> - <%= if @member.last_cycle_status do %> - <% status = @member.last_cycle_status %> - - {format_status_label(status)} - - <% else %> - {gettext("No cycles")} - <% end %> - - <.data_field label={gettext("Current Cycle")} class="min-w-36"> - <%= if @member.current_cycle_status do %> - <% status = @member.current_cycle_status %> - - {format_status_label(status)} - - <% else %> - {gettext("No cycles")} - <% end %> - -
- <% else %> -
- {gettext("No membership fee type assigned")} + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.section_box title={gettext("Custom Fields")}> +
+ <%= for custom_field <- @custom_fields do %> + <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> + <.data_field label={custom_field.name}> + {format_custom_field_value(cfv, custom_field.value_type)} + + <% end %> +
+
<% end %> - -
- <% end %> +
- <%= if @active_tab == :membership_fees do %> - <%!-- Membership Fees Tab Content --%> - <.live_component - module={MvWeb.MemberLive.Show.MembershipFeesComponent} - id={"membership-fees-#{@member.id}"} - member={@member} - current_user={@current_user} - vereinfacht_receipts={@vereinfacht_receipts} - /> - <% end %> + <%!-- Payment Data Section --%> +
+ <.section_box title={gettext("Payment Data")}> + <%= if @member.membership_fee_type do %> +
+ <.data_field + label={gettext("Type")} + value={@member.membership_fee_type.name} + class="min-w-32" + /> + <.data_field + label={gettext("Membership Fee")} + value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} + class="min-w-24" + /> + <.data_field + label={gettext("Payment Interval")} + value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)} + class="min-w-32" + /> + <.data_field label={gettext("Last Cycle")} class="min-w-32"> + <%= if @member.last_cycle_status do %> + <% status = @member.last_cycle_status %> + + {format_status_label(status)} + + <% else %> + {gettext("No cycles")} + <% end %> + + <.data_field label={gettext("Current Cycle")} class="min-w-36"> + <%= if @member.current_cycle_status do %> + <% status = @member.current_cycle_status %> + + {format_status_label(status)} + + <% else %> + {gettext("No cycles")} + <% end %> + +
+ <% else %> +
+ {gettext("No membership fee type assigned")} +
+ <% end %> + +
+ <% end %> + + <%= if @active_tab == :membership_fees do %> + <%!-- Membership Fees Tab Content --%> + <.live_component + module={MvWeb.MemberLive.Show.MembershipFeesComponent} + id={"membership-fees-#{@member.id}"} + member={@member} + current_user={@current_user} + vereinfacht_receipts={@vereinfacht_receipts} + /> + <% end %> +
""" end @@ -403,7 +411,7 @@ defmodule MvWeb.MemberLive.Show do ~H""" {@display} diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index b8757a0..2e75b57 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -66,14 +66,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
- +
<%= if @vereinfacht_receipts do %>
<.button :if={Enum.any?(@cycles) and @can_destroy_cycle} + variant="outline" + size="sm" phx-click="delete_all_cycles" phx-target={@myself} - class="btn btn-sm btn-error btn-outline" title={gettext("Delete all cycles")} > <.icon name="hero-trash" class="size-4" /> @@ -158,9 +160,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <.button :if={@member.membership_fee_type != nil and @can_create_cycle} + variant="primary" + size="sm" phx-click="open_create_cycle_modal" phx-target={@myself} - class="btn btn-sm btn-primary" title={gettext("Create a new cycle manually")} > <.icon name="hero-plus" class="size-4" /> @@ -259,17 +262,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %> <%= if @can_destroy_cycle do %> - + <% end %>
@@ -309,10 +313,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do />
@@ -334,17 +343,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do )} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}

@@ -385,20 +394,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do />
@@ -472,10 +481,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex index bfa20f8..aabb210 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -239,10 +239,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
- +
@@ -333,24 +333,27 @@ defmodule MvWeb.MembershipFeeSettingsLive do <:action :let={mft}> - <.link - navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} - class="btn btn-ghost btn-xs" - aria-label={gettext("Edit membership fee type")} - > - <.icon name="hero-pencil" class="size-4" /> - + <.tooltip content={gettext("Edit membership fee type")} position="left"> + <.button + variant="ghost" + size="sm" + navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} + aria-label={gettext("Edit membership fee type")} + > + <.icon name="hero-pencil" class="size-4" /> + + <:action :let={mft}> -
0} - class="tooltip tooltip-left" - data-tip={ + content={ gettext("Cannot delete - %{count} member(s) assigned", count: get_member_count(mft, @member_counts) ) } + position="left" > -
- + diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index d8569e2..72add11 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -176,20 +176,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex index f5f760f..0a17920 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -78,24 +78,27 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do <:action :let={mft}> - <.link - navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} - class="btn btn-ghost btn-xs" - aria-label={gettext("Edit membership fee type")} - > - <.icon name="hero-pencil" class="size-4" /> - + <.tooltip content={gettext("Edit membership fee type")} position="left"> + <.button + variant="ghost" + size="sm" + navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} + aria-label={gettext("Edit membership fee type")} + > + <.icon name="hero-pencil" class="size-4" /> + + <:action :let={mft}> -
0} - class="tooltip tooltip-left" - data-tip={ + content={ gettext("Cannot delete - %{count} member(s) assigned", count: get_member_count(mft, @member_counts) ) } + position="left" > -
- + diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex index ea76fe8..ccd03cf 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -21,13 +21,23 @@ defmodule MvWeb.RoleLive.Form do def render(assigns) do ~H""" - <.header> - {@page_title} - <:subtitle>{gettext("Use this form to manage roles in your database.")} - - <.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save"> - <.input field={@form[:name]} type="text" label={gettext("Name")} required /> + <.header> + {@page_title} + <:subtitle>{gettext("Use this form to manage roles in your database.")} + <:actions> + <.button navigate={return_path(@return_to, @role)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save")} + + + + +
+ <.input field={@form[:name]} type="text" label={gettext("Name")} required /> <.input field={@form[:description]} @@ -73,14 +83,6 @@ defmodule MvWeb.RoleLive.Form do <% end %> <% end %>
- -
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save Role")} - - <.button navigate={return_path(@return_to, @role)} type="button"> - {gettext("Cancel")} -
diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index f409944..5829bca 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -59,28 +59,29 @@ <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> - <.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm"> + <.button variant="ghost" size="sm" navigate={~p"/admin/roles/#{role}/edit"}> <.icon name="hero-pencil" class="size-4" /> - {gettext("Edit")} - + {gettext("Edit role")} + <% end %> <:action :let={role}> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %> - <.link + <.button + variant="danger" + size="sm" phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")} data-confirm={gettext("Are you sure?")} - class="btn btn-ghost btn-sm text-error" > <.icon name="hero-trash" class="size-4" /> {gettext("Delete")} - + <% else %> -
-
+ <% end %> diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 0e1c7ca..4dbbb1f 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -165,23 +165,23 @@ defmodule MvWeb.RoleLive.Show do <:subtitle>{gettext("Role details and permissions.")} <:actions> - <.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}> - <.icon name="hero-arrow-left" /> - {gettext("Back to roles list")} + <.button navigate={~p"/admin/roles"} variant="neutral" aria-label={gettext("Back to roles list")}> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}> - <.icon name="hero-pencil-square" /> {gettext("Edit Role")} + <.icon name="hero-pencil-square" /> {gettext("Rolle bearbeiten")} <% end %> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> - <.link + <.button + variant="danger" phx-click={JS.push("delete", value: %{id: @role.id})} data-confirm={gettext("Are you sure?")} - class="btn btn-error" > <.icon name="hero-trash" /> {gettext("Delete Role")} - + <% end %> diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 46e23b3..f9f17bb 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -167,13 +167,14 @@ defmodule MvWeb.UserLive.Form do

{@user.member.email}

- + <% else %> @@ -281,10 +282,12 @@ defmodule MvWeb.UserLive.Form do <% end %>
+ <.button navigate={return_path(@return_to, @user)} variant="neutral"> + {gettext("Cancel")} + <.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save User")} - <.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}
diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index ab13f90..364e5a4 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -91,7 +91,7 @@ <%= if can?(@current_user, :update, user) do %> <.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit"> - {gettext("Edit")} + {gettext("Edit user")} <% end %> diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 4d803cd..3530b36 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -37,7 +37,7 @@ defmodule MvWeb.UserLive.Show do <:subtitle>{gettext("This is a user record from your database.")} <:actions> - <.button navigate={~p"/users"} aria-label={gettext("Back to users list")}> + <.button navigate={~p"/users"} variant="neutral" aria-label={gettext("Back to users list")}> <.icon name="hero-arrow-left" /> {gettext("Back to users list")} -- 2.47.2 From ff9f98f8e7b5941fc4ea06fa91d875298cc097b2 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 09:45:10 +0100 Subject: [PATCH 3/8] style: consitent flash messages --- DESIGN_DUIDELINES.md | 6 +- docs/feature-roadmap.md | 5 ++ lib/mv_web/components/core_components.ex | 2 +- lib/mv_web/components/layouts.ex | 6 +- lib/mv_web/controllers/auth_controller.ex | 4 +- .../live/auth/link_oidc_account_live.ex | 4 +- lib/mv_web/live/datafields_live.ex | 6 +- lib/mv_web/live/global_settings_live.ex | 25 +++--- lib/mv_web/live/group_live/form.ex | 2 +- lib/mv_web/live/group_live/index.ex | 26 +++--- lib/mv_web/live/group_live/show.ex | 4 +- lib/mv_web/live/member_live/form.ex | 2 +- lib/mv_web/live/member_live/index.ex | 2 +- .../show/membership_fees_component.ex | 18 ++-- .../live/membership_fee_settings_live.ex | 4 +- .../live/membership_fee_type_live/form.ex | 2 +- .../live/membership_fee_type_live/index.ex | 2 +- lib/mv_web/live/role_live/form.ex | 86 +++++++++---------- lib/mv_web/live/role_live/index.ex | 2 +- lib/mv_web/live/role_live/show.ex | 8 +- lib/mv_web/live/user_live/form.ex | 2 +- lib/mv_web/live/user_live/index.ex | 2 +- 22 files changed, 117 insertions(+), 103 deletions(-) diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index b0372ef..18864b5 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -286,11 +286,11 @@ Notes: - warning: 6–8s - error: 8–12s (or manual dismiss for critical errors) - **MUST:** Keep a dismiss button for accessibility and user control. +- **Status:** Not yet implemented. See [feature-roadmap](docs/feature-roadmap.md) → Flash: Auto-dismiss and consistency. -### 9.3 Variants + special “email copied” +### 9.3 Variants (unified) - Supported semantic variants: `info`, `success`, `warning`, `error`. -- **Special case:** clipboard “Email copied” uses a **soft/light blue** tone distinct from normal info. -- **MUST:** Model this as `tone="soft"` (or similar prop) on the flash component, not hard-coded colors in views. +- **MUST:** Use the same variants for all flash types, : e.g. `success` for copy success, no separate tone or styling. This keeps flash UX consistent across the app. ### 9.4 Accessibility - Flash must work with screen readers (live region behavior belongs in the flash component implementation). diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index b699560..66b46eb 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -191,6 +191,11 @@ - ❌ Mobile navigation - ❌ Context-sensitive help - ❌ Onboarding tooltips +- ❌ **Flash: Auto-dismiss and consistency** (Design Guidelines §9) + - Auto-dismiss: info/success 4–6s, warning 6–8s, error 8–12s; dismiss button kept for accessibility. + - Implement via JS hook (e.g. `FlashAutoDismiss`) + `data-dismiss-ms` (or `data-kind`) on flash component; on timeout push `lv:clear-flash` and hide element. + - LiveView: add shared `handle_event("lv:clear-flash", %{"key" => key}, socket)` (e.g. in `MvWeb` live_view quote) calling `clear_flash(socket, key)`. + - All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_DUIDELINES.md` §9. --- diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 4f9d7af..1e8e7f3 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -60,7 +60,7 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="z-50 toast toast-bottom toast-end" + class="pointer-events-auto" {@rest} >
+
<.flash kind={:success} flash={@flash} /> <.flash kind={:warning} flash={@flash} /> <.flash kind={:info} flash={@flash} /> diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 28f3846..20a76f5 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -31,7 +31,7 @@ defmodule MvWeb.AuthController do |> store_in_session(user) # If your resource has a different name, update the assign name here (i.e :current_admin) |> assign(:current_user, user) - |> put_flash(:info, message) + |> put_flash(:success, message) |> redirect(to: return_to) end @@ -322,7 +322,7 @@ defmodule MvWeb.AuthController do conn |> clear_session(:mv) - |> put_flash(:info, gettext("You are now signed out")) + |> put_flash(:success, gettext("You are now signed out")) |> redirect(to: return_to) end end diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex index b6c24b1..01bd57b 100644 --- a/lib/mv_web/live/auth/link_oidc_account_live.ex +++ b/lib/mv_web/live/auth/link_oidc_account_live.ex @@ -81,7 +81,7 @@ defmodule MvWeb.LinkOidcAccountLive do socket |> put_flash( - :info, + :success, dgettext("auth", "Account activated! Redirecting to complete sign-in...") ) |> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc") @@ -217,7 +217,7 @@ defmodule MvWeb.LinkOidcAccountLive do {:noreply, socket |> put_flash( - :info, + :success, dgettext( "auth", "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." diff --git a/lib/mv_web/live/datafields_live.ex b/lib/mv_web/live/datafields_live.ex index f7436ab..f922d22 100644 --- a/lib/mv_web/live/datafields_live.ex +++ b/lib/mv_web/live/datafields_live.ex @@ -64,12 +64,12 @@ defmodule MvWeb.DatafieldsLive do {:noreply, socket |> assign(:active_editing_section, nil) - |> put_flash(:info, gettext("Data field %{action} successfully", action: action))} + |> put_flash(:success, gettext("Data field %{action} successfully", action: action))} end @impl true def handle_info({:custom_field_deleted, _custom_field}, socket) do - {:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))} + {:noreply, put_flash(socket, :success, gettext("Data field deleted successfully"))} end @impl true @@ -115,7 +115,7 @@ defmodule MvWeb.DatafieldsLive do socket |> assign(:settings, updated_settings) |> assign(:active_editing_section, nil) - |> put_flash(:info, gettext("Member field %{action} successfully", action: action))} + |> put_flash(:success, gettext("Member field %{action} successfully", action: action))} end @impl true diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index f3a61bc..485601a 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -357,20 +357,21 @@ defmodule MvWeb.GlobalSettingsLive do errors_with_names = enrich_sync_errors(errors) result = %{synced: synced, errors: errors_with_names} + {flash_kind, flash_message} = + if(errors_with_names == [], + do: {:success, gettext("Synced %{count} member(s) to Vereinfacht.", count: synced)}, + else: + {:warning, + gettext("Synced %{count} member(s). %{error_count} failed.", + count: synced, + error_count: length(errors_with_names) + )} + ) + socket = socket |> assign(:last_vereinfacht_sync_result, result) - |> put_flash( - :info, - if(errors_with_names == [], - do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced), - else: - gettext("Synced %{count} member(s). %{error_count} failed.", - count: synced, - error_count: length(errors_with_names) - ) - ) - ) + |> put_flash(flash_kind, flash_message) {:noreply, socket} @@ -409,7 +410,7 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret)) |> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:vereinfacht_test_result, test_result) - |> put_flash(:info, gettext("Settings updated successfully")) + |> put_flash(:success, gettext("Settings updated successfully")) |> assign_form() {:noreply, socket} diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex index 5f781a7..d9999d3 100644 --- a/lib/mv_web/live/group_live/form.ex +++ b/lib/mv_web/live/group_live/form.ex @@ -128,7 +128,7 @@ defmodule MvWeb.GroupLive.Form do socket = socket - |> put_flash(:info, gettext("Group saved successfully.")) + |> put_flash(:success, gettext("Group saved successfully.")) |> push_navigate(to: redirect_path) {:noreply, socket} diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex index b6c8277..70358e0 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -76,24 +76,24 @@ defmodule MvWeb.GroupLive.Index do <:col :let={group} label={gettext("Members")} class="text-right"> {group.member_count || 0} - <:action :let={group}> - <.button - variant="ghost" - size="sm" - navigate={~p"/groups/#{group.slug}"} - > - {gettext("View")} - - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <:action :let={group}> <.button variant="ghost" size="sm" - navigate={~p"/groups/#{group.slug}/edit"} + navigate={~p"/groups/#{group.slug}"} > - {gettext("Edit group")} + {gettext("View")} - <% end %> - + <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <.button + variant="ghost" + size="sm" + navigate={~p"/groups/#{group.slug}/edit"} + > + {gettext("Edit group")} + + <% end %> + <% end %>
diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 46766ef..7e2d57f 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -150,7 +150,7 @@ defmodule MvWeb.GroupLive.Show do
- <%= for member <- @selected_members do %> + <%= for member <- @selected_members do %> {MvWeb.Helpers.MemberHelpers.display_name(member)} <.tooltip content={gettext("Remove")} position="top"> @@ -909,7 +909,7 @@ defmodule MvWeb.GroupLive.Show do :ok -> {:noreply, socket - |> put_flash(:info, gettext("Group deleted successfully.")) + |> put_flash(:success, gettext("Group deleted successfully.")) |> redirect(to: ~p"/groups")} {:error, error} -> diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 625ab2a..66260f4 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -390,7 +390,7 @@ defmodule MvWeb.MemberLive.Form do socket = socket - |> put_flash(:info, flash_message) + |> put_flash(:success, flash_message) |> maybe_put_vereinfacht_sync_flash(member.id) |> push_navigate(to: return_path(socket.assigns.return_to, member)) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 3283b5c..a7f6316 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -175,7 +175,7 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:members, updated_members) - |> put_flash(:info, gettext("Member deleted successfully"))} + |> put_flash(:success, gettext("Member deleted successfully"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 2e75b57..23f0dda 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -562,7 +562,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do get_available_fee_types(updated_member, actor) ) |> assign(:interval_warning, nil) - |> put_flash(:info, gettext("Membership fee type removed"))} + |> put_flash(:success, gettext("Membership fee type removed"))} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} @@ -621,7 +621,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do get_available_fee_types(updated_member, actor) ) |> assign(:interval_warning, nil) - |> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))} + |> put_flash(:success, gettext("Membership fee type updated. Cycles regenerated."))} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} @@ -649,7 +649,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do {:noreply, socket |> assign(:cycles, updated_cycles) - |> put_flash(:info, gettext("Cycle status updated"))} + |> put_flash(:success, gettext("Cycle status updated"))} {:error, %Ash.Error.Invalid{} = error} -> error_msg = @@ -705,7 +705,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:member, updated_member) |> assign(:cycles, cycles) |> assign(:regenerating, false) - |> put_flash(:info, gettext("Cycles regenerated successfully"))} + |> put_flash(:success, gettext("Cycles regenerated successfully"))} {:error, error} -> {:noreply, @@ -755,7 +755,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:editing_cycle, nil) - |> put_flash(:info, gettext("Cycle amount updated"))} + |> put_flash(:success, gettext("Cycle amount updated"))} {:error, error} -> {:noreply, @@ -794,7 +794,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:deleting_cycle, nil) - |> put_flash(:info, gettext("Cycle deleted"))} + |> put_flash(:success, gettext("Cycle deleted"))} {:ok, _destroyed} -> # Handle case where return_destroyed? is true @@ -804,7 +804,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:deleting_cycle, nil) - |> put_flash(:info, gettext("Cycle deleted"))} + |> put_flash(:success, gettext("Cycle deleted"))} {:error, error} -> {:noreply, @@ -950,7 +950,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:creating_cycle, false) |> assign(:create_cycle_date, nil) |> assign(:create_cycle_error, nil) - |> put_flash(:info, gettext("Cycle created successfully"))} + |> put_flash(:success, gettext("Cycle created successfully"))} {:error, error} -> {:noreply, @@ -1013,7 +1013,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:member, updated_member) |> assign(:cycles, updated_cycles) |> reset_modal.() - |> put_flash(:info, gettext("All cycles deleted"))} + |> put_flash(:success, gettext("All cycles deleted"))} {:ok, _} -> {:noreply, diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex index aabb210..84ce662 100644 --- a/lib/mv_web/live/membership_fee_settings_live.ex +++ b/lib/mv_web/live/membership_fee_settings_live.ex @@ -82,7 +82,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do {:noreply, socket |> assign(:settings, updated_settings) - |> put_flash(:info, gettext("Settings saved successfully.")) + |> put_flash(:success, gettext("Settings saved successfully.")) |> assign_form()} {:error, form} -> @@ -105,7 +105,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do socket |> assign(:membership_fee_types, updated_types) |> assign(:member_counts, updated_counts) - |> put_flash(:info, gettext("Membership fee type deleted"))} + |> put_flash(:success, gettext("Membership fee type deleted"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index 72add11..ca61e19 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -317,7 +317,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do socket = socket - |> put_flash(:info, gettext("Membership fee type saved successfully")) + |> put_flash(:success, gettext("Membership fee type saved successfully")) |> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type)) {:noreply, socket} diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex index 0a17920..ee3b791 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -149,7 +149,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do socket |> assign(:membership_fee_types, updated_types) |> assign(:member_counts, updated_counts) - |> put_flash(:info, gettext("Membership fee type deleted"))} + |> put_flash(:success, gettext("Membership fee type deleted"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex index ccd03cf..684e695 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -39,50 +39,50 @@ defmodule MvWeb.RoleLive.Form do
<.input field={@form[:name]} type="text" label={gettext("Name")} required /> - <.input - field={@form[:description]} - type="textarea" - label={gettext("Description")} - rows="3" - /> + <.input + field={@form[:description]} + type="textarea" + label={gettext("Description")} + rows="3" + /> -
- - + + <%= for permission_set <- all_permission_sets() do %> + + <% end %> + + <%= if @form.errors[:permission_set_name] do %> + <%= for error <- List.wrap(@form.errors[:permission_set_name]) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

+ <.icon name="hero-exclamation-circle" class="size-5" /> + {msg} +

+ <% end %> <% end %> - - <%= if @form.errors[:permission_set_name] do %> - <%= for error <- List.wrap(@form.errors[:permission_set_name]) do %> - <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> -

- <.icon name="hero-exclamation-circle" class="size-5" /> - {msg} -

- <% end %> - <% end %> -
+
@@ -177,7 +177,7 @@ defmodule MvWeb.RoleLive.Form do socket = socket - |> put_flash(:info, gettext("Role saved successfully.")) + |> put_flash(:success, gettext("Role saved successfully.")) |> push_navigate(to: redirect_path) {:noreply, socket} diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex index 091b191..2169400 100644 --- a/lib/mv_web/live/role_live/index.ex +++ b/lib/mv_web/live/role_live/index.ex @@ -100,7 +100,7 @@ defmodule MvWeb.RoleLive.Index do socket |> assign(:roles, updated_roles) |> assign(:user_counts, updated_counts) - |> put_flash(:info, gettext("Role deleted successfully."))} + |> put_flash(:success, gettext("Role deleted successfully."))} {:error, error} -> error_message = format_error(error) diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 4dbbb1f..8b5b1b2 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -124,7 +124,7 @@ defmodule MvWeb.RoleLive.Show do :ok -> {:noreply, socket - |> put_flash(:info, gettext("Role deleted successfully.")) + |> put_flash(:success, gettext("Role deleted successfully.")) |> push_navigate(to: ~p"/admin/roles")} {:error, error} -> @@ -165,7 +165,11 @@ defmodule MvWeb.RoleLive.Show do <:subtitle>{gettext("Role details and permissions.")} <:actions> - <.button navigate={~p"/admin/roles"} variant="neutral" aria-label={gettext("Back to roles list")}> + <.button + navigate={~p"/admin/roles"} + variant="neutral" + aria-label={gettext("Back to roles list")} + > <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index f9f17bb..8ff5966 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -556,7 +556,7 @@ defmodule MvWeb.UserLive.Form do socket = socket - |> put_flash(:info, gettext("User %{action} successfully", action: action)) + |> put_flash(:success, gettext("User %{action} successfully", action: action)) |> push_navigate(to: return_path(socket.assigns.return_to, updated_user)) {:noreply, socket} diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index 72cc55c..ba36605 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Index do {:noreply, socket |> assign(:users, updated_users) - |> put_flash(:info, gettext("User deleted successfully"))} + |> put_flash(:success, gettext("User deleted successfully"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, -- 2.47.2 From 02af136fd99f8c61e184690cd56d8a8996735f01 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 10:33:30 +0100 Subject: [PATCH 4/8] feat: restyle tabs and move delete to edit view --- .../components/member_filter_component.ex | 2 +- lib/mv_web/live/member_live/form.ex | 14 +- lib/mv_web/live/member_live/index.ex | 57 --- lib/mv_web/live/member_live/index.html.heex | 22 +- lib/mv_web/live/member_live/show.ex | 442 +++++++++++------- .../member_live/form_error_handling_test.exs | 27 ++ test/mv_web/member_live/index_test.exs | 44 +- test/mv_web/member_live/show_test.exs | 29 ++ 8 files changed, 361 insertions(+), 276 deletions(-) diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index c020fc1..7a3517b 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -487,7 +487,7 @@ defmodule MvWeb.Components.MemberFilterComponent do # Get boolean filter label (comma-separated list of active filter names) defp boolean_filter_label(_boolean_custom_fields, boolean_filters) when map_size(boolean_filters) == 0 do - gettext("All") + gettext("Apply filters") end defp boolean_filter_label(boolean_custom_fields, boolean_filters) do diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 66260f4..9138236 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Form do - Create new members with personal information - Edit existing member details - Grouped sections for better organization - - Tab navigation (Payments tab disabled, coming soon) - Manage custom properties (dynamic fields, displayed sorted by name) - Real-time validation with visual feedback @@ -56,23 +55,12 @@ defmodule MvWeb.MemberLive.Form do
- <%!-- Tab Navigation --%> + <%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
-
<%!-- Personal Data and Custom Fields Row --%> diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index a7f6316..4309611 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do - `sort_order` - Sort direction (:asc or :desc) ## Events - - `delete` - Remove a member from the database - `select_member` - Toggle individual member selection - `select_all` - Toggle selection of all visible members - `copy_emails` - Copy email addresses of selected members to clipboard @@ -157,50 +156,10 @@ defmodule MvWeb.MemberLive.Index do Handles member-related UI events. ## Supported events: - - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ - @impl true - def handle_event("delete", %{"id" => id}, socket) do - actor = current_actor(socket) - - case Ash.get(Mv.Membership.Member, id, actor: actor) do - {:ok, member} -> - case Ash.destroy(member, actor: actor) do - :ok -> - updated_members = Enum.reject(socket.assigns.members, &(&1.id == id)) - - {:noreply, - socket - |> assign(:members, updated_members) - |> put_flash(:success, gettext("Member deleted successfully"))} - - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this member") - )} - - {:error, error} -> - {:noreply, put_flash(socket, :error, format_error(error))} - end - - {:error, %Ash.Error.Query.NotFound{}} -> - {:noreply, put_flash(socket, :error, gettext("Member not found"))} - - {:error, %Ash.Error.Forbidden{} = _error} -> - {:noreply, - put_flash(socket, :error, gettext("You do not have permission to access this member"))} - - {:error, error} -> - {:noreply, put_flash(socket, :error, format_error(error))} - end - end - @impl true def handle_event("select_member", %{"id" => id}, socket) do selected = @@ -343,22 +302,6 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} end - # Helper to format errors for display - defp format_error(%Ash.Error.Invalid{errors: errors}) do - error_messages = - Enum.map(errors, fn error -> - case error do - %{field: field, message: message} -> "#{field}: #{message}" - %{message: message} -> message - _ -> inspect(error) - end - end) - - Enum.join(error_messages, ", ") - end - - defp format_error(error), do: inspect(error) - # ----------------------------------------------------------------- # Handle Infos from Child Components # ----------------------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index c54ec7c..a696b00 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -379,26 +379,10 @@ <:action :let={member}>
- <.link navigate={~p"/members/#{member}"}>{gettext("Show")} + <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> + {gettext("Show")} +
- - <%= if can?(@current_user, :update, member) do %> - <.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit"> - {gettext("Edit member")} - - <% end %> - - - <:action :let={member}> - <%= if can?(@current_user, :destroy, member) do %> - <.link - phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")} - data-confirm={gettext("Are you sure?")} - data-testid="member-delete" - > - {gettext("Delete")} - - <% end %> diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 3af0ed2..ae69c30 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -54,217 +54,283 @@ defmodule MvWeb.MemberLive.Show do
- <%!-- Tab Navigation --%> -
+ <%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%> +
<%= if @active_tab == :contact do %> <%!-- Contact Data Tab Content --%> - <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.section_box title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
- <.data_field - label={gettext("First Name")} - value={@member.first_name} - class="w-48" - /> - <.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> -
+
+ <%!-- Personal Data and Custom Fields Row --%> +
+ <%!-- Personal Data Section --%> +
+ <.section_box title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+ <.data_field + label={gettext("First Name")} + value={@member.first_name} + class="w-48" + /> + <.data_field + label={gettext("Last Name")} + value={@member.last_name} + class="w-48" + /> +
- <%!-- Address --%> -
- <.data_field label={gettext("Address")} value={format_address(@member)} /> -
+ <%!-- Address --%> +
+ <.data_field label={gettext("Address")} value={format_address(@member)} /> +
- <%!-- Email --%> -
- <.data_field label={gettext("Email")}> - - {@member.email} - - -
+ <%!-- Email --%> +
+ <.data_field label={gettext("Email")}> + + {@member.email} + + +
- <%!-- Membership Dates Row --%> -
- <.data_field - label={gettext("Join Date")} - value={format_date(@member.join_date)} - class="w-28" - /> - <.data_field - label={gettext("Exit Date")} - value={format_date(@member.exit_date)} - class="w-28" - /> -
+ <%!-- Membership Dates Row --%> +
+ <.data_field + label={gettext("Join Date")} + value={format_date(@member.join_date)} + class="w-28" + /> + <.data_field + label={gettext("Exit Date")} + value={format_date(@member.exit_date)} + class="w-28" + /> +
- <%!-- Linked User: only show when current user can see other users (e.g. admin). + <%!-- Linked User: only show when current user can see other users (e.g. admin). read_only cannot see linked user, so hide the section to avoid "No user linked" when a user is linked but not visible. --%> - <%= if can_access_page?(@current_user, "/users") do %> + <%= if can_access_page?(@current_user, "/users") do %> +
+ <.data_field label={gettext("Linked User")}> + <%= if @member.user do %> + <.link + navigate={~p"/users/#{@member.user}"} + class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" + > + <.icon name="hero-user" class="size-4" /> + {@member.user.email} + + <% else %> + + {gettext("No user linked")} + + <% end %> + +
+ <% end %> + + <%!-- Groups (in Personal Data) --%> + <% groups = @member.groups || [] %>
- <.data_field label={gettext("Linked User")}> - <%= if @member.user do %> - <.link - navigate={~p"/users/#{@member.user}"} - class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" - > - <.icon name="hero-user" class="size-4" /> - {@member.user.email} - + <.data_field label={gettext("Groups")}> + <%= if Enum.empty?(groups) do %> + {gettext("No groups")} <% else %> - {gettext("No user linked")} +
+ <%= for group <- groups do %> + <.button + variant="outline" + size="sm" + navigate={~p"/groups/#{group.slug}"} + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
<% end %>
- <% end %> - <%!-- Groups (in Personal Data) --%> - <% groups = @member.groups || [] %> -
- <.data_field label={gettext("Groups")}> - <%= if Enum.empty?(groups) do %> - {gettext("No groups")} - <% else %> -
- <%= for group <- groups do %> - <.button - variant="outline" - size="sm" - navigate={~p"/groups/#{group.slug}"} - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> -
- <% end %> - -
- - <%!-- Notes --%> - <%= if @member.notes && String.trim(@member.notes) != "" do %> -
- <.data_field label={gettext("Notes")}> -

{@member.notes}

- -
- <% end %> -
- -
- - <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.section_box title={gettext("Custom Fields")}> -
- <%= for custom_field <- @custom_fields do %> - <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> - <.data_field label={custom_field.name}> - {format_custom_field_value(cfv, custom_field.value_type)} - + <%!-- Notes --%> + <%= if @member.notes && String.trim(@member.notes) != "" do %> +
+ <.data_field label={gettext("Notes")}> +

{@member.notes}

+ +
<% end %>
- <% end %> -
- <%!-- Payment Data Section --%> -
- <.section_box title={gettext("Payment Data")}> - <%= if @member.membership_fee_type do %> -
- <.data_field - label={gettext("Type")} - value={@member.membership_fee_type.name} - class="min-w-32" - /> - <.data_field - label={gettext("Membership Fee")} - value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} - class="min-w-24" - /> - <.data_field - label={gettext("Payment Interval")} - value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)} - class="min-w-32" - /> - <.data_field label={gettext("Last Cycle")} class="min-w-32"> - <%= if @member.last_cycle_status do %> - <% status = @member.last_cycle_status %> - - {format_status_label(status)} - - <% else %> - {gettext("No cycles")} - <% end %> - - <.data_field label={gettext("Current Cycle")} class="min-w-36"> - <%= if @member.current_cycle_status do %> - <% status = @member.current_cycle_status %> - - {format_status_label(status)} - - <% else %> - {gettext("No cycles")} - <% end %> - -
- <% else %> -
- {gettext("No membership fee type assigned")} + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.section_box title={gettext("Custom Fields")}> +
+ <%= for custom_field <- @custom_fields do %> + <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> + <.data_field label={custom_field.name}> + {format_custom_field_value(cfv, custom_field.value_type)} + + <% end %> +
+
<% end %> - +
+ + <%!-- Payment Data Section --%> +
+ <.section_box title={gettext("Payment Data")}> + <%= if @member.membership_fee_type do %> +
+ <.data_field + label={gettext("Type")} + value={@member.membership_fee_type.name} + class="min-w-32" + /> + <.data_field + label={gettext("Membership Fee")} + value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} + class="min-w-24" + /> + <.data_field + label={gettext("Payment Interval")} + value={ + MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval) + } + class="min-w-32" + /> + <.data_field label={gettext("Last Cycle")} class="min-w-32"> + <%= if @member.last_cycle_status do %> + <% status = @member.last_cycle_status %> + + {format_status_label(status)} + + <% else %> + {gettext("No cycles")} + <% end %> + + <.data_field label={gettext("Current Cycle")} class="min-w-36"> + <%= if @member.current_cycle_status do %> + <% status = @member.current_cycle_status %> + + {format_status_label(status)} + + <% else %> + {gettext("No cycles")} + <% end %> + +
+ <% else %> +
+ {gettext("No membership fee type assigned")} +
+ <% end %> + +
<% end %> <%= if @active_tab == :membership_fees do %> <%!-- Membership Fees Tab Content --%> - <.live_component - module={MvWeb.MemberLive.Show.MembershipFeesComponent} - id={"membership-fees-#{@member.id}"} - member={@member} - current_user={@current_user} - vereinfacht_receipts={@vereinfacht_receipts} - /> +
+ <.live_component + module={MvWeb.MemberLive.Show.MembershipFeesComponent} + id={"membership-fees-#{@member.id}"} + member={@member} + current_user={@current_user} + vereinfacht_receipts={@vereinfacht_receipts} + /> +
+ <% end %> + + <%!-- Danger zone: same section pattern as section_box (h2 outside border) --%> + <%= if can?(@current_user, :destroy, @member) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." + )} +

+ <.button + variant="danger" + phx-click="delete" + phx-value-id={@member.id} + data-confirm={ + gettext("Are you sure you want to delete %{name}? This action cannot be undone.", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + data-testid="member-delete" + aria-label={ + gettext("Delete member %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete member")} + +
+
<% end %>
@@ -328,6 +394,35 @@ defmodule MvWeb.MemberLive.Show do {:noreply, assign(socket, :active_tab, :membership_fees)} end + @impl true + def handle_event("delete", %{"id" => id}, socket) do + member = socket.assigns.member + actor = current_actor(socket) + + if to_string(id) != to_string(member.id) do + {:noreply, put_flash(socket, :error, gettext("Member not found"))} + else + case Ash.destroy(member, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("Member deleted successfully")) + |> push_navigate(to: ~p"/members")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash( + socket, + :error, + gettext("You do not have permission to delete this member") + )} + + {:error, error} -> + {:noreply, put_flash(socket, :error, format_error(error))} + end + end + end + def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do response = case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do @@ -358,6 +453,19 @@ defmodule MvWeb.MemberLive.Show do defp page_title(:show), do: gettext("Show Member") defp page_title(:edit), do: gettext("Edit Member") + defp format_error(%Ash.Error.Invalid{errors: errors}) do + error_messages = + Enum.map(errors, fn + %{field: field, message: message} -> "#{field}: #{message}" + %{message: message} -> message + _ -> inspect(errors) + end) + + Enum.join(error_messages, ", ") + end + + defp format_error(error), do: inspect(error) + # ----------------------------------------------------------------- # Helper Components # ----------------------------------------------------------------- diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index d61d3fd..fec7df4 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -3,11 +3,38 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do Tests for error handling in the member form, specifically flash message display. """ use MvWeb.ConnCase, async: false + use Gettext, backend: MvWeb.Gettext import Phoenix.LiveViewTest require Ash.Query + describe "tab visibility" do + @tag :ui + test "Payments tab is not visible on new member form", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members/new") + + refute html =~ gettext("Payments") + end + + @tag :ui + test "Payments tab is not visible on edit member form", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Edit", last_name: "Member", email: "edit@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members/#{member}/edit") + + refute html =~ gettext("Payments") + end + end + describe "error handling - flash messages" do setup do {:ok, settings} = Mv.Membership.get_settings() diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 53a2815..d8846ea 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -266,36 +266,42 @@ defmodule MvWeb.MemberLive.IndexTest do assert is_list(state.socket.assigns.members) end - test "can delete a member without error", %{conn: conn} do + @tag :ui + test "member index does not render Edit or Delete actions", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() - # Create a test member first - {:ok, member} = + {:ok, _member} = Mv.Membership.create_member( - %{ - first_name: "Test", - last_name: "User", - email: "test@example.com" - }, + %{first_name: "Test", last_name: "User", email: "test@example.com"}, actor: system_actor ) conn = conn_with_oidc_user(conn) - {:ok, index_view, _html} = live(conn, "/members") + {:ok, view, html} = live(conn, "/members") - # Verify the member is displayed - assert has_element?(index_view, "#members", "Test User") + refute has_element?(view, "[data-testid='member-edit']") + refute html =~ ~s(data-testid="member-delete") + end - # Click the delete link for this member - index_view - |> element("a", "Delete") + @tag :ui + test "row click navigates to member show", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Row", last_name: "Click", email: "rowclick@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Click a data cell (e.g. second column = first name) to trigger row navigation + view + |> element("#row-#{member.id} td:nth-child(2)") |> render_click() - # Verify the member is no longer displayed - refute has_element?(index_view, "#members", "Test User") - - # Verify the member was actually deleted from the database - assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?()) + assert_redirect(view, ~p"/members/#{member}") end describe "copy_emails feature" do diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs index 26c3f00..8c7a23a 100644 --- a/test/mv_web/member_live/show_test.exs +++ b/test/mv_web/member_live/show_test.exs @@ -134,6 +134,35 @@ defmodule MvWeb.MemberLive.ShowTest do end end + describe "delete action" do + test "renders Delete button when user can destroy member", %{ + conn: conn, + member: member + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members/#{member}") + + assert has_element?(view, "[data-testid='member-delete']") + end + + test "delete event removes member and redirects to index", %{ + conn: conn, + member: member + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members/#{member}") + + view + |> render_click("delete", %{"id" => member.id}) + + assert_redirect(view, ~p"/members") + + refute Mv.Membership.Member + |> Ash.Query.filter(id == ^member.id) + |> Ash.exists?() + end + end + describe "custom field value formatting" do test "formats string custom field values", %{conn: conn, member: member, actor: actor} do {:ok, custom_field} = -- 2.47.2 From 49fd2181a7fdee1ebd5f74ccc906b83797754e7f Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 13:16:27 +0100 Subject: [PATCH 5/8] style: highlight selected table and add tooltip --- DESIGN_DUIDELINES.md | 1 + lib/mv_web/components/core_components.ex | 84 ++++++++- .../components/member_filter_component.ex | 4 +- .../live/custom_field_live/index_component.ex | 1 + lib/mv_web/live/group_live/index.ex | 1 + .../live/member_field_live/index_component.ex | 1 + lib/mv_web/live/member_live/index.ex | 20 ++ lib/mv_web/live/member_live/index.html.heex | 5 +- lib/mv_web/live/member_live/show.ex | 2 +- lib/mv_web/live/role_live/index.html.heex | 1 + lib/mv_web/live/role_live/show.ex | 4 +- lib/mv_web/live/user_live/index.html.heex | 1 + priv/gettext/de/LC_MESSAGES/default.po | 176 +++++++++++++----- priv/gettext/default.pot | 151 ++++++++++----- priv/gettext/en/LC_MESSAGES/default.po | 176 +++++++++++++----- .../components/core_components_table_test.exs | 154 +++++++++++++++ .../live/member_live_authorization_test.exs | 14 +- .../index_membership_fee_status_test.exs | 4 +- test/mv_web/member_live/index_test.exs | 38 ++++ 19 files changed, 687 insertions(+), 151 deletions(-) create mode 100644 test/mv_web/components/core_components_table_test.exs diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index 18864b5..98e43db 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -209,6 +209,7 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`. ### 8.1 Default behavior: row click opens details - **DEFAULT:** Clicking a row navigates to the details page. - **EXCEPTIONS:** Highly interactive rows may disable row-click (document why). +- **Row outline (CoreComponents):** When `row_click` is set, rows get a subtle hover and focus-within ring (theme-friendly). Use `selected_row_id` to show a stronger selected outline (e.g. from URL `?highlight=id` or last selection); the Back link from detail can use `?highlight=id` so the row is visually selected when returning to the index. **IMPORTANT (correctness with our `<.table>` CoreComponent):** Our table implementation attaches the `phx-click` to the **``** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation. diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 1e8e7f3..22aeae7 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -660,6 +660,10 @@ defmodule MvWeb.CoreComponents do Renders a table with generic styling. When `row_click` is set, clicking a row (or a data cell) triggers the handler. + Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring). + When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`), + that row gets a stronger selected outline (ring-primary) for accessibility (not color-only). + The action column has no phx-click on its ``, so action buttons do not trigger row navigation. For interactive elements inside other columns (e.g. checkboxes, buttons), use `Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired. @@ -670,12 +674,36 @@ defmodule MvWeb.CoreComponents do <:col :let={user} label="id">{user.id} <:col :let={user} label="username">{user.username} + + <.table id="members" rows={@members} row_click={fn m -> JS.navigate(~p"/members/#{m}") end} selected_row_id={@selected_member_id}> + <:col :let={m} label="Name">{m.name} + """ attr :id, :string, required: true attr :rows, :list, required: true attr :row_id, :any, default: nil, doc: "the function for generating the row id" attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + attr :selected_row_id, :any, + default: nil, + doc: + "when set, the row whose id equals this value gets selected styling (single row, e.g. from URL)" + + attr :row_selected?, :any, + default: nil, + doc: + "optional; function (row_item) -> boolean to mark multiple rows as selected (e.g. checkbox selection); overrides selected_row_id when set" + + attr :row_tooltip, :string, + default: nil, + doc: + "optional; when row_click is set, tooltip text for the row (e.g. gettext(\"Click to view\")). Shown as title on hover and as sr-only for screen readers." + + attr :row_value_id, :any, + default: nil, + doc: + "optional; function (row) -> id for comparing with selected_row_id; defaults to row_item.(row).id" + attr :row_item, :any, default: &Function.identity/1, doc: "the function for mapping each row before calling the :col and :action slots" @@ -704,6 +732,12 @@ defmodule MvWeb.CoreComponents do assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) end + # Function to get the row's value id for selected_row_id comparison (no extra DB reads) + row_value_id_fn = + assigns[:row_value_id] || fn row -> assigns.row_item.(row).id end + + assigns = assign(assigns, :row_value_id_fn, row_value_id_fn) + ~H"""
@@ -732,9 +766,15 @@ defmodule MvWeb.CoreComponents do - + ` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table. + --- ## 9) Flash / Toast messages (mandatory UX) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 22aeae7..83d506a 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -715,6 +715,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 :sticky_header, :boolean, + default: false, + doc: + "when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop" + slot :col, required: true do attr :label, :string attr :class, :string @@ -745,12 +750,12 @@ defmodule MvWeb.CoreComponents do - - @@ -891,6 +896,18 @@ defmodule MvWeb.CoreComponents do end end + # Combines column class with optional sticky header classes (desktop only; theme-friendly bg). + defp table_th_class(col, sticky_header) do + base = Map.get(col, :class) + sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil + [base, sticky] |> Enum.filter(& &1) |> Enum.join(" ") + end + + defp table_th_sticky_class(true), + do: "lg:sticky lg:top-0 bg-base-100 z-10" + + defp table_th_sticky_class(_), do: nil + @doc """ Renders a data list. diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index eec49de..709e084 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -90,302 +90,311 @@ /> - <.table - id="members" - rows={@members} - row_id={fn member -> "row-#{member.id}" end} - row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} - row_tooltip={gettext("Click for member details")} - row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} - dynamic_cols={@dynamic_cols} - sort_field={@sort_field} - sort_order={@sort_order} + <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%> +
- + <.table + id="members" + rows={@members} + sticky_header={true} + row_id={fn member -> "row-#{member.id}" end} + row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} + row_tooltip={gettext("Click for member details")} + row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} + dynamic_cols={@dynamic_cols} + sort_field={@sort_field} + sort_order={@sort_order} + > + - <:col - :let={member} - col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} - label={ - ~H""" + <:col + :let={member} + col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} + label={ + ~H""" + <.input + type="checkbox" + name="select_all" + phx-click="select_all" + checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())} + aria-label={gettext("Select all members")} + role="checkbox" + /> + """ + } + > <.input type="checkbox" - name="select_all" - phx-click="select_all" - checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())} - aria-label={gettext("Select all members")} + name={member.id} + checked={MapSet.member?(@selected_members, member.id)} + aria-label={gettext("Select member")} role="checkbox" /> - """ - } - > - <.input - type="checkbox" - name={member.id} - checked={MapSet.member?(@selected_members, member.id)} - aria-label={gettext("Select member")} - role="checkbox" - /> - - <:col - :let={member} - :if={:first_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_first_name} - field={:first_name} - label={gettext("First name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.first_name} - - <:col - :let={member} - :if={:last_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_last_name} - field={:last_name} - label={gettext("Last name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.last_name} - - <:col - :let={member} - :if={:email in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_email} - field={:email} - label={gettext("Email")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.email} - - <:col - :let={member} - :if={:join_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_join_date} - field={:join_date} - label={gettext("Join Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.join_date)} - - <:col - :let={member} - :if={:exit_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_exit_date} - field={:exit_date} - label={gettext("Exit Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.exit_date)} - - <:col - :let={member} - :if={:notes in @member_fields_visible} - label={gettext("Notes")} - > - {member.notes} - - <: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} - /> - """ - } - > - {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} - /> - """ - } - > - {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} - /> - """ - } - > - {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} - /> - """ - } - > - {member.postal_code} - - <:col - :let={member} - :if={:membership_fee_start_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_start_date} - field={:membership_fee_start_date} - label={gettext("Membership Fee Start Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} - - <:col - :let={member} - :if={:membership_fee_type in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_type} - field={:membership_fee_type} - label={gettext("Fee Type")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= if member.membership_fee_type do %> - {member.membership_fee_type.name} - <% else %> - - <% end %> - - <:col - :let={member} - :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} - > - <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( + + <:col + :let={member} + :if={:first_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_first_name} + field={:first_name} + label={gettext("First name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.first_name} + + <:col + :let={member} + :if={:last_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_last_name} + field={:last_name} + label={gettext("Last name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.last_name} + + <:col + :let={member} + :if={:email in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_email} + field={:email} + label={gettext("Email")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.email} + + <:col + :let={member} + :if={:join_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_join_date} + field={:join_date} + label={gettext("Join Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.join_date)} + + <:col + :let={member} + :if={:exit_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_exit_date} + field={:exit_date} + label={gettext("Exit Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.exit_date)} + + <:col + :let={member} + :if={:notes in @member_fields_visible} + label={gettext("Notes")} + > + {member.notes} + + <: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} + /> + """ + } + > + {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} + /> + """ + } + > + {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} + /> + """ + } + > + {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} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + :if={:membership_fee_start_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_start_date} + field={:membership_fee_start_date} + label={gettext("Membership Fee Start Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} + + <:col + :let={member} + :if={:membership_fee_type in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_type} + field={:membership_fee_type} + label={gettext("Fee Type")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= if member.membership_fee_type do %> + {member.membership_fee_type.name} + <% else %> + + <% end %> + + <:col + :let={member} + :if={:membership_fee_status in @member_fields_visible} + label={gettext("Membership Fee Status")} + > + <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) ) do %> - - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - {gettext("No cycle")} - <% end %> - - <:col - :let={member} - :if={:groups in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_groups} - field={:groups} - label={gettext("Groups")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= for group <- (member.groups || []) do %> - - {group.name} - - <% end %> - <%= if (member.groups || []) == [] do %> - - <% end %> - - <:action :let={member}> -
- <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> - {gettext("Show")} - -
- - + + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <% else %> + {gettext("No cycle")} + <% end %> + + <:col + :let={member} + :if={:groups in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_groups} + field={:groups} + label={gettext("Groups")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= for group <- (member.groups || []) do %> + + {group.name} + + <% end %> + <%= if (member.groups || []) == [] do %> + + <% end %> + + <:action :let={member}> +
+ <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> + {gettext("Show")} + +
+ + +
diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index dd2c4f2..b5a25ce 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -174,7 +174,11 @@ defmodule MvWeb.RoleLive.Show do {gettext("Back")} <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> - <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"} data-testid=role-edit"> + <.button + variant="primary" + navigate={~p"/admin/roles/#{@role}/edit"} + data-testid="role-show-edit-btn" + > {gettext("Edit role")} <% end %> diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 4561f24..4e6c888 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3116,6 +3116,7 @@ msgid "Edit member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/role_live/index.html.heex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit role" msgstr "Rolle bearbeiten" @@ -3125,16 +3126,6 @@ msgstr "Rolle bearbeiten" msgid "Edit user" msgstr "Benutzer*in bearbeiten" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Rolle bearbeiten" -msgstr "Rolle bearbeiten" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for custom field details" -msgstr "Klicke für Datenfeld-Details" - #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Click for datafield details" @@ -3160,11 +3151,26 @@ msgstr "Klicke für Rollen-Details" msgid "Click for user details" msgstr "Klicke für Benutzer*innen-Details" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click for dataield details" +msgstr "Klicke für Datenfeld-Details" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Members table" +msgstr "Mitglieder" + #~ #: lib/mv_web/live/member_field_live/form_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Back to Settings" #~ msgstr "Zurück zu den Einstellungen" +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for custom field details" +#~ msgstr "Klicke für Datenfeld-Details" + #~ #: lib/mv_web/live/member_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Coming soon" @@ -3175,6 +3181,11 @@ msgstr "Klicke für Benutzer*innen-Details" #~ msgid "Reset" #~ msgstr "Zurücksetzen" +#~ #: lib/mv_web/live/role_live/show.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Rolle bearbeiten" +#~ msgstr "Rolle bearbeiten" + #~ #: lib/mv_web/live/role_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Save Role" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index cea7991..ed020a0 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3116,6 +3116,7 @@ msgid "Edit member" msgstr "" #: lib/mv_web/live/role_live/index.html.heex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Edit role" msgstr "" @@ -3125,16 +3126,6 @@ msgstr "" msgid "Edit user" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Rolle bearbeiten" -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for custom field details" -msgstr "" - #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Click for datafield details" @@ -3159,3 +3150,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Click for user details" msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for dataield details" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Members table" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 9f38efe..a44e87c 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3116,6 +3116,7 @@ msgid "Edit member" msgstr "" #: lib/mv_web/live/role_live/index.html.heex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit role" msgstr "" @@ -3125,16 +3126,6 @@ msgstr "" msgid "Edit user" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Rolle bearbeiten" -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for custom field details" -msgstr "" - #: lib/mv_web/live/member_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Click for datafield details" @@ -3160,11 +3151,26 @@ msgstr "" msgid "Click for user details" msgstr "" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click for dataield details" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Members table" +msgstr "" + #~ #: lib/mv_web/live/member_field_live/form_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Back to Settings" #~ msgstr "" +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for custom field details" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Coming soon" @@ -3175,6 +3181,11 @@ msgstr "" #~ msgid "Reset" #~ msgstr "" +#~ #: lib/mv_web/live/role_live/show.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Rolle bearbeiten" +#~ msgstr "" + #~ #: lib/mv_web/live/role_live/form.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Save Role" diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 1c8328f..b75fcd8 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -46,6 +46,35 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!(actor: actor) end + describe "desktop layout: scroll container and sticky table header" do + @describetag :ui + + test "header and filters are outside scroll container; table is in scroll container with lg:max-h and lg:overflow-auto", + %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + assert html =~ ~r/data-testid="members-table-scroll"/ + # Scroll container has lg: overflow and max-height for desktop-only scroll + assert html =~ "lg:overflow-auto" + assert html =~ "lg:max-h-[calc(100vh-14rem)]" + + # Header (page title) is present and not inside the scroll container (scroll container comes after filters) + assert html =~ "Members" + assert html =~ "id=\"members\"" + end + + test "table thead has sticky classes on desktop when sticky_header is set", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + # CoreComponents table with sticky_header adds lg:sticky lg:top-0 bg-base-100 z-10 to th + assert html =~ "lg:sticky" + assert html =~ "lg:top-0" + assert html =~ "bg-base-100" + end + end + describe "translations" do @describetag :ui -- 2.47.2 From 91cf7cca6a7dcd0caf8165ec63079ba8493ceefc Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 15:09:37 +0100 Subject: [PATCH 7/8] feat: conistent danger zone delete flow --- DESIGN_DUIDELINES.md | 43 ++++++++ .../live/custom_field_live/form_component.ex | 36 +++++++ .../live/custom_field_live/index_component.ex | 60 +++++++----- lib/mv_web/live/datafields_live.ex | 11 +++ lib/mv_web/live/group_live/form.ex | 25 +++++ lib/mv_web/live/group_live/index.ex | 18 ---- lib/mv_web/live/group_live/show.ex | 55 ++++++++--- .../live/member_field_live/index_component.ex | 12 +-- lib/mv_web/live/member_live/form.ex | 84 ++++++++++++++++ lib/mv_web/live/member_live/show.ex | 2 + lib/mv_web/live/role_live/index.ex | 98 +------------------ lib/mv_web/live/role_live/index.html.heex | 42 -------- lib/mv_web/live/role_live/show.ex | 40 ++++++-- lib/mv_web/live/user_live/form.ex | 71 ++++++++++++++ lib/mv_web/live/user_live/index.ex | 45 +-------- lib/mv_web/live/user_live/index.html.heex | 24 ----- lib/mv_web/live/user_live/show.ex | 67 +++++++++++++ .../member_live/form_error_handling_test.exs | 47 +++++++++ test/mv_web/member_live/show_test.exs | 6 +- 19 files changed, 499 insertions(+), 287 deletions(-) diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index 37428a3..e3faf50 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -365,3 +365,46 @@ Detail pages should not drift into random layouts. Add to this glossary when new terminology appears. --- + +## 14) Destructive actions: Delete flow (canonical) + +This section defines the canonical delete flow for list/detail/form resources (e.g. members). Use it as the single pattern; do not introduce a second pattern elsewhere. + +### Tables: no row action buttons +- **MUST NOT:** Show Edit or Delete as row action buttons (or dropdown actions) in list/table views. +- **MUST:** Remove any existing edit/delete row actions from tables so that the only way to edit or delete is via the flow below. + +### Navigation: row click → details +- **MUST:** Clicking a table row navigates to the resource details page (e.g. `/members/:id`). +- **MUST NOT:** Use the table for primary edit/delete actions. + +### Edit: from details header, not from table +- **MUST:** Provide a clear primary “Edit” CTA in the details page header (e.g. “Edit member”). +- **MUST:** Edit is reached from the details page (e.g. “Edit member” button in header), not from the list/table. + +### Delete: only via “Danger zone” +- **MUST:** Delete is available only in a dedicated “Danger zone” section at the bottom of the page. +- **MUST:** Use the same “Danger zone” on both the details page and the edit form when the user is authorized to destroy the resource. +- **MUST NOT:** Place delete in the table, in the header next to Edit, or in any other location outside the Danger zone. + +### Danger zone layout and wording (canonical pattern) +- **Heading:** “Danger zone” (H2, `aria-labelledby` for the section, semantic colour e.g. `text-error`). +- **Explanatory text:** One short paragraph stating that the action cannot be undone and mentioning consequences (e.g. related data removed). Use `text-base-content/70` for the text. +- **Layout:** Section with heading outside a bordered box; content inside a single bordered, rounded box (`border border-base-300 rounded-lg p-4 bg-base-100`). +- **Button:** One destructive action only (e.g. “Delete member”). Use CoreComponents `<.button variant="danger">`. No primary or secondary actions mixed inside the Danger zone. + +### Confirmation and button semantics +- **MUST:** Use a single confirmation step (e.g. `data-confirm` / browser confirm or one modal). Do not introduce a second confirmation pattern in this flow. +- **Confirm copy:** Message must include the resource name and state that the action “cannot be undone” (e.g. “Are you sure you want to delete %{name}? This action cannot be undone.”). +- **Button:** Accessible label (visible text + `aria-label` that includes the resource name, e.g. “Delete member %{name}”). Icon (e.g. trash) is optional and must not replace the text label for the primary action. + +### Accessibility +- **MUST:** Button has an accessible name (`aria-label` when icon-only or in addition to visible text as above). +- **MUST:** Focus and keyboard: button is focusable and activatable via keyboard; focus management must not trap the user. +- **MUST:** Contrast and visibility: Danger zone heading and button use semantic danger styling with sufficient contrast (WCAG AA). + +### Authorization visibility +- **MUST:** Show the Danger zone only when the current user is authorized to destroy the resource (e.g. `can?(current_user, :destroy, resource)`). +- **MUST NOT:** Show the Danger zone or the delete button when the user cannot destroy the resource; no “disabled” delete button for unauthorized users. + +--- diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index 8e59ac9..5f09a8d 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -98,6 +98,33 @@ defmodule MvWeb.CustomFieldLive.FormComponent do label={gettext("Show in overview")} /> + <%= if @custom_field do %> + <%!-- Danger zone: canonical pattern (same as member form) --%> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this data field cannot be undone. All custom field values for this field will be permanently removed." + )} +

+ <.button + type="button" + variant="danger" + phx-click="request_delete" + phx-target={@myself} + data-testid="custom-field-delete" + aria-label={gettext("Delete data field %{name}", name: @custom_field.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete data field")} + +
+
+ <% end %> +
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}> {gettext("Cancel")} @@ -170,6 +197,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do {:noreply, socket} end + @impl true + def handle_event("request_delete", _params, socket) do + if custom_field = socket.assigns[:custom_field] do + send(self(), {:open_delete_modal_for, custom_field}) + end + + {:noreply, socket} + end + defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do form = if custom_field do diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index ebc4930..b0e9862 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -59,7 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) end } - row_tooltip={gettext("Click for dataield details")} + row_tooltip={gettext("Click to edit datafield")} > <:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name} @@ -96,22 +96,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do {gettext("No")} - - <:action :let={{_id, custom_field}}> - <.link phx-click={ - JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) - }> - {gettext("Edit datafield")} - - - - <:action :let={{_id, custom_field}}> - <.link phx-click={ - JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself) - }> - {gettext("Delete")} - -
@@ -223,16 +207,38 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do # Get actor from assigns or fall back to socket assigns actor = Map.get(assigns, :actor, socket.assigns[:actor]) - {:ok, - socket - |> assign(assigns) - |> assign_new(:show_form, fn -> false end) - |> assign_new(:form_id, fn -> "custom-field-form-new" end) - |> assign_new(:editing_custom_field, fn -> nil end) - |> assign_new(:show_delete_modal, fn -> false end) - |> assign_new(:custom_field_to_delete, fn -> nil end) - |> assign_new(:slug_confirmation, fn -> "" end) - |> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)} + socket = + socket + |> assign(assigns) + |> assign_new(:show_form, fn -> false end) + |> assign_new(:form_id, fn -> "custom-field-form-new" end) + |> assign_new(:editing_custom_field, fn -> nil end) + |> assign_new(:show_delete_modal, fn -> false end) + |> assign_new(:custom_field_to_delete, fn -> nil end) + |> assign_new(:slug_confirmation, fn -> "" end) + |> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true) + + # Open delete modal when requested from form (e.g. Danger zone in FormComponent) + socket = + case Map.get(assigns, :open_delete_for_id) do + nil -> + socket + + id -> + custom_field = + Ash.get!(Mv.Membership.CustomField, id, + load: [:assigned_members_count], + actor: actor + ) + + socket + |> assign(:show_delete_modal, true) + |> assign(:custom_field_to_delete, custom_field) + |> assign(:slug_confirmation, "") + |> assign(:open_delete_for_id, nil) + end + + {:ok, socket} end @impl true diff --git a/lib/mv_web/live/datafields_live.ex b/lib/mv_web/live/datafields_live.ex index f922d22..0fc4c3c 100644 --- a/lib/mv_web/live/datafields_live.ex +++ b/lib/mv_web/live/datafields_live.ex @@ -101,6 +101,17 @@ defmodule MvWeb.DatafieldsLive do {:noreply, assign(socket, :active_editing_section, section)} end + # Open delete modal for custom field (triggered from Danger zone in FormComponent) + @impl true + def handle_info({:open_delete_modal_for, custom_field}, socket) do + send_update(MvWeb.CustomFieldLive.IndexComponent, + id: "custom-fields-component", + open_delete_for_id: custom_field.id + ) + + {:noreply, socket} + end + @impl true def handle_info({:member_field_saved, _member_field, action}, socket) do {:ok, updated_settings} = Membership.get_settings() diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex index d9999d3..490214f 100644 --- a/lib/mv_web/live/group_live/form.ex +++ b/lib/mv_web/live/group_live/form.ex @@ -101,6 +101,31 @@ defmodule MvWeb.GroupLive.Form do rows="4" /> + + <%!-- Danger zone: canonical pattern (same as member form) --%> + <%= if @group && can?(@current_user, :destroy, @group) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this group cannot be undone. All member-group associations will be permanently removed." + )} +

+ <.button + variant="danger" + navigate={~p"/groups/#{@group.slug}?confirm_delete=1"} + data-testid="group-form-delete-btn" + aria-label={gettext("Delete group %{name}", name: @group.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete group")} + +
+
+ <% end %> diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex index 76663fd..ff22b91 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -77,24 +77,6 @@ defmodule MvWeb.GroupLive.Index do <:col :let={group} label={gettext("Members")} class="text-right"> {group.member_count || 0} - <:action :let={group}> - <.button - variant="ghost" - size="sm" - navigate={~p"/groups/#{group.slug}"} - > - {gettext("View")} - - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> - <.button - variant="ghost" - size="sm" - navigate={~p"/groups/#{group.slug}/edit"} - > - {gettext("Edit group")} - - <% end %> - <% end %> diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 7e2d57f..d970f2a 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -39,18 +39,18 @@ defmodule MvWeb.GroupLive.Show do end @impl true - def handle_params(%{"slug" => slug}, _url, socket) do + def handle_params(%{"slug" => slug} = params, _url, socket) do actor = current_actor(socket) # Check if user can read groups if can?(actor, :read, Mv.Membership.Group) do - load_group_by_slug(socket, slug, actor) + load_group_by_slug(socket, slug, actor, params) else {:noreply, redirect(socket, to: ~p"/members")} end end - defp load_group_by_slug(socket, slug, actor) do + defp load_group_by_slug(socket, slug, actor, params \\ %{}) do # Load group with members and member_count # Using explicit load ensures efficient preloading of members relationship require Ash.Query @@ -68,10 +68,16 @@ defmodule MvWeb.GroupLive.Show do |> redirect(to: ~p"/groups")} {:ok, group} -> - {:noreply, - socket - |> assign(:page_title, group.name) - |> assign(:group, group)} + open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group) + + socket = + socket + |> assign(:page_title, group.name) + |> assign(:group, group) + |> assign(:show_delete_modal, open_delete) + |> assign(:name_confirmation, "") + + {:noreply, socket} {:error, _error} -> {:noreply, @@ -105,15 +111,6 @@ defmodule MvWeb.GroupLive.Show do {gettext("Edit group")} <% end %> - <%= if can?(@current_user, :destroy, @group) do %> - <.button - variant="danger" - phx-click="open_delete_modal" - data-testid="group-show-delete-btn" - > - {gettext("Delete")} - - <% end %> @@ -339,6 +336,32 @@ defmodule MvWeb.GroupLive.Show do + <%!-- Danger zone: canonical pattern (same as member show) --%> + <%= if can?(@current_user, :destroy, @group) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this group cannot be undone. All member-group associations will be permanently removed." + )} +

+ <.button + variant="danger" + type="button" + phx-click="open_delete_modal" + data-testid="group-show-delete-btn" + aria-label={gettext("Delete group %{name}", name: @group.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete group")} + +
+
+ <% end %> + <%!-- Delete Confirmation Modal --%> <%= if assigns[:show_delete_modal] do %> diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index 419b585..28384b5 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -57,7 +57,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) end } - row_tooltip={gettext("Click for datafield details")} + row_tooltip={gettext("Click to edit datafield")} > <:col :let={{_field_name, field_data}} label={gettext("Name")}> {MemberFields.label(field_data.field)} @@ -92,16 +92,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do {gettext("No")} - - <:action :let={{_field_name, field_data}}> - <.link - phx-click="edit_member_field" - phx-value-field={Atom.to_string(field_data.field)} - phx-target={@myself} - > - {gettext("Edit datafield")} - - """ diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 9138236..1875205 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -20,6 +20,7 @@ defmodule MvWeb.MemberLive.Form do """ use MvWeb, :live_view + require Logger import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] alias Mv.Membership @@ -246,6 +247,42 @@ defmodule MvWeb.MemberLive.Form do {gettext("Save Member")} + + <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> + <%= if @member && can?(@current_user, :destroy, @member) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." + )} +

+ <.button + variant="danger" + type="button" + phx-click="delete" + phx-value-id={@member.id} + data-confirm={ + gettext("Are you sure you want to delete %{name}? This action cannot be undone.", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + data-testid="member-delete" + aria-label={ + gettext("Delete member %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(@member) + ) + } + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete member")} + +
+
+ <% end %> @@ -366,6 +403,40 @@ defmodule MvWeb.MemberLive.Form do end end + @impl true + def handle_event("delete", %{"id" => id}, socket) do + member = socket.assigns.member + actor = current_actor(socket) + + if is_nil(member) do + {:noreply, put_flash(socket, :error, gettext("Member not found"))} + else + if to_string(id) != to_string(member.id) do + {:noreply, put_flash(socket, :error, gettext("Member not found"))} + else + case Ash.destroy(member, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("Member deleted successfully")) + |> push_navigate(to: ~p"/members")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash( + socket, + :error, + gettext("You do not have permission to delete this member") + )} + + {:error, error} -> + Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") + {:noreply, put_flash(socket, :error, format_destroy_error(error))} + end + end + end + end + defp handle_save_success(socket, member) do notify_parent({:saved, member}) @@ -413,6 +484,19 @@ defmodule MvWeb.MemberLive.Form do end end + defp format_destroy_error(%Ash.Error.Invalid{errors: errors}) do + error_messages = + Enum.map(errors, fn + %{field: field, message: message} -> "#{field}: #{message}" + %{message: message} -> message + _ -> inspect(errors) + end) + + Enum.join(error_messages, ", ") + end + + defp format_destroy_error(error), do: inspect(error) + defp handle_save_error(socket, form) do # Always show a flash message when save fails # Field-level validation errors are displayed in form fields, but flash provides additional feedback diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 6757646..63349be 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -418,6 +418,8 @@ defmodule MvWeb.MemberLive.Show do )} {:error, error} -> + require Logger + Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") {:noreply, put_flash(socket, :error, format_error(error))} end end diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex index 2169400..ed64eb7 100644 --- a/lib/mv_web/live/role_live/index.ex +++ b/lib/mv_web/live/role_live/index.ex @@ -5,11 +5,8 @@ defmodule MvWeb.RoleLive.Index do ## Features - List all roles with name, description, permission_set_name, is_system_role - Create new roles - - Navigate to role details and edit forms - - Delete non-system roles - - ## Events - - `delete` - Remove a role from the database (only non-system roles) + - Navigate to role details (row click) and edit from details header + - Delete only via Danger zone on role show page ## Security Only admins can access this page (enforced by authorization). @@ -21,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do require Ash.Query - import MvWeb.RoleLive.Helpers, - only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3] + import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1] @impl true def mount(_params, _session, socket) do @@ -37,83 +33,6 @@ defmodule MvWeb.RoleLive.Index do |> assign(:user_counts, user_counts)} end - @impl true - def handle_event("delete", %{"id" => id}, socket) do - case Authorization.get_role(id, actor: socket.assigns.current_user) do - {:ok, role} -> - handle_delete_role(role, id, socket) - - {:error, %Ash.Error.Query.NotFound{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("Role not found.") - )} - - {:error, error} -> - error_message = format_error(error) - - {:noreply, - put_flash( - socket, - :error, - gettext("Failed to delete role: %{error}", error: error_message) - )} - end - end - - defp handle_delete_role(role, id, socket) do - if role.is_system_role do - {:noreply, - put_flash( - socket, - :error, - gettext("System roles cannot be deleted.") - )} - else - user_count = recalculate_user_count(role, socket.assigns.current_user) - - if user_count > 0 do - {:noreply, - put_flash( - socket, - :error, - gettext( - "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.", - count: user_count - ) - )} - else - perform_role_deletion(role, id, socket) - end - end - end - - defp perform_role_deletion(role, id, socket) do - case Authorization.destroy_role(role, actor: socket.assigns.current_user) do - :ok -> - updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id)) - updated_counts = Map.delete(socket.assigns.user_counts, id) - - {:noreply, - socket - |> assign(:roles, updated_roles) - |> assign(:user_counts, updated_counts) - |> put_flash(:success, gettext("Role deleted successfully."))} - - {:error, error} -> - error_message = format_error(error) - - {:noreply, - put_flash( - socket, - :error, - gettext("Failed to delete role: %{error}", error: error_message) - )} - end - end - @spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()] defp load_roles(actor) do opts = MvWeb.LiveHelpers.ash_actor_opts(actor) @@ -154,15 +73,4 @@ defmodule MvWeb.RoleLive.Index do defp get_user_count(role, user_counts) do Map.get(user_counts, role.id, 0) end - - # Recalculates user count for a specific role (used before deletion) - @spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer() - defp recalculate_user_count(role, actor) do - opts = opts_with_actor([], actor, Mv.Accounts) - - case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do - {:ok, count} -> count - _ -> 0 - end - end end diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index 5947472..43f2fc7 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -53,47 +53,5 @@ <:col :let={role} label={gettext("Users")}> {get_user_count(role, @user_counts)} - - <:action :let={role}> -
- <.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")} -
- - <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> - <.button variant="ghost" size="sm" navigate={~p"/admin/roles/#{role}/edit"}> - <.icon name="hero-pencil" class="size-4" /> - {gettext("Edit role")} - - <% end %> - - - <:action :let={role}> - <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %> - <.button - variant="danger" - size="sm" - phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")} - data-confirm={gettext("Are you sure?")} - > - <.icon name="hero-trash" class="size-4" /> - {gettext("Delete")} - - <% else %> - <.tooltip - :if={role.is_system_role} - content={gettext("System roles cannot be deleted")} - position="left" - > - - - <% end %> - diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index b5a25ce..8b615b6 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -182,15 +182,6 @@ defmodule MvWeb.RoleLive.Show do {gettext("Edit role")} <% end %> - <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> - <.button - variant="danger" - phx-click={JS.push("delete", value: %{id: @role.id})} - data-confirm={gettext("Are you sure?")} - > - <.icon name="hero-trash" /> {gettext("Delete Role")} - - <% end %> @@ -216,6 +207,37 @@ defmodule MvWeb.RoleLive.Show do <% end %> + + <%!-- Danger zone: canonical pattern (same as member show) --%> + <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." + )} +

+ <.button + variant="danger" + phx-click={JS.push("delete", value: %{id: @role.id})} + data-confirm={ + gettext( + "Are you sure you want to delete the role %{name}? This action cannot be undone.", + name: @role.name + ) + } + data-testid="role-delete" + aria-label={gettext("Delete role %{name}", name: @role.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete role")} + +
+
+ <% end %> """ end diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 8ff5966..34defe1 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -39,6 +39,7 @@ defmodule MvWeb.UserLive.Form do import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.Authorization, only: [can?: 3] + import MvWeb.ErrorHelpers, only: [format_ash_error: 1] @impl true def render(assigns) do @@ -281,6 +282,38 @@ defmodule MvWeb.UserLive.Form do <% end %> + <%!-- Danger zone: canonical pattern (same as member form) --%> + <%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this user cannot be undone. The user account and any linked member association will be affected." + )} +

+ <.button + type="button" + variant="danger" + phx-click="delete" + phx-value-id={@user.id} + data-confirm={ + gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.", + email: @user.email + ) + } + data-testid="user-delete" + aria-label={gettext("Delete user %{email}", email: @user.email)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete user")} + +
+
+ <% end %> +
<.button navigate={return_path(@return_to, @user)} variant="neutral"> {gettext("Cancel")} @@ -404,6 +437,44 @@ defmodule MvWeb.UserLive.Form do end end + @impl true + def handle_event("delete", %{"id" => id}, socket) do + user = socket.assigns.user + actor = current_actor(socket) + + if is_nil(user) do + {:noreply, put_flash(socket, :error, gettext("User not found"))} + else + if to_string(id) != to_string(user.id) do + {:noreply, put_flash(socket, :error, gettext("User not found"))} + else + if Mv.Helpers.SystemActor.system_user?(user) do + {:noreply, + put_flash(socket, :error, gettext("System user cannot be deleted."))} + else + case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("User deleted successfully")) + |> push_navigate(to: ~p"/users")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash( + socket, + :error, + gettext("You do not have permission to delete this user") + )} + + {:error, error} -> + {:noreply, put_flash(socket, :error, format_ash_error(error))} + end + end + end + end + end + @impl true def handle_event("show_member_dropdown", _params, socket) do {:noreply, assign(socket, show_member_dropdown: true)} diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index ba36605..d72c1fd 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -5,15 +5,14 @@ defmodule MvWeb.UserLive.Index do ## Features - List all users with email and linked member - Sort users by email (default) - - Delete users - - Navigate to user details and edit forms + - Navigate to user details (row click) and edit from details header + - Delete only via Danger zone on user show/edit - Bulk selection for future batch operations ## Relationships Displays linked member information when a user is connected to a member account. ## Events - - `delete` - Remove a user from the database - `select_user` - Toggle individual user selection - `select_all` - Toggle selection of all visible users @@ -26,7 +25,6 @@ defmodule MvWeb.UserLive.Index do import MvWeb.LiveHelpers, only: [current_actor: 1] require Ash.Query - import MvWeb.ErrorHelpers, only: [format_ash_error: 1] @impl true def mount(_params, _session, socket) do @@ -48,45 +46,6 @@ defmodule MvWeb.UserLive.Index do |> assign(:selected_users, [])} end - @impl true - def handle_event("delete", %{"id" => id}, socket) do - actor = current_actor(socket) - - case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do - {:ok, user} -> - case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do - :ok -> - updated_users = Enum.reject(socket.assigns.users, &(&1.id == id)) - - {:noreply, - socket - |> assign(:users, updated_users) - |> put_flash(:success, gettext("User deleted successfully"))} - - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this user") - )} - - {:error, error} -> - {:noreply, put_flash(socket, :error, format_ash_error(error))} - end - - {:error, %Ash.Error.Query.NotFound{}} -> - {:noreply, put_flash(socket, :error, gettext("User not found"))} - - {:error, %Ash.Error.Forbidden{} = _error} -> - {:noreply, - put_flash(socket, :error, gettext("You do not have permission to access this user"))} - - {:error, error} -> - {:noreply, put_flash(socket, :error, format_ash_error(error))} - end - end - # Selects one user in the list of users @impl true def handle_event("select_user", %{"id" => id}, socket) do diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 858e784..7ffa0e3 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -84,29 +84,5 @@ <% end %> - - <:action :let={user}> -
- <.link navigate={~p"/users/#{user}"}>{gettext("Show")} -
- - <%= if can?(@current_user, :update, user) do %> - <.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit"> - {gettext("Edit user")} - - <% end %> - - - <:action :let={user}> - <%= if can?(@current_user, :destroy, user) do %> - <.link - phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")} - data-confirm={gettext("Are you sure?")} - data-testid="user-delete" - > - {gettext("Delete")} - - <% end %> - diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 3530b36..a77a1c4 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -27,6 +27,7 @@ defmodule MvWeb.UserLive.Show do use MvWeb, :live_view import MvWeb.LiveHelpers, only: [current_actor: 1] + import MvWeb.ErrorHelpers, only: [format_ash_error: 1] @impl true def render(assigns) do @@ -80,6 +81,37 @@ defmodule MvWeb.UserLive.Show do <% end %> + + <%!-- Danger zone: canonical pattern (same as member show) --%> + <%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %> +
+

+ {gettext("Danger zone")} +

+
+

+ {gettext( + "Deleting this user cannot be undone. The user account and any linked member association will be affected." + )} +

+ <.button + variant="danger" + phx-click="delete" + phx-value-id={@user.id} + data-confirm={ + gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.", + email: @user.email + ) + } + data-testid="user-delete" + aria-label={gettext("Delete user %{email}", email: @user.email)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete user")} + +
+
+ <% end %> """ end @@ -103,4 +135,39 @@ defmodule MvWeb.UserLive.Show do |> assign(:user, user)} end end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + user = socket.assigns.user + actor = current_actor(socket) + + if to_string(id) != to_string(user.id) do + {:noreply, put_flash(socket, :error, gettext("User not found"))} + else + if Mv.Helpers.SystemActor.system_user?(user) do + {:noreply, + put_flash(socket, :error, gettext("System user cannot be deleted."))} + else + case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("User deleted successfully")) + |> push_navigate(to: ~p"/users")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash( + socket, + :error, + gettext("You do not have permission to delete this user") + )} + + {:error, error} -> + {:noreply, + put_flash(socket, :error, format_ash_error(error))} + end + end + end + end end diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index fec7df4..0ec142e 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -9,6 +9,53 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do require Ash.Query + describe "danger zone on edit" do + @tag :ui + test "edit form shows Danger zone and delete button when user can destroy member", %{ + conn: conn + } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Delete", last_name: "FromEdit", email: "delete.from.edit@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, ~p"/members/#{member}/edit") + + assert html =~ gettext("Danger zone") + assert has_element?(view, "[data-testid='member-delete']") + end + + test "delete event from edit form removes member and redirects to /members", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{ + first_name: "ToDelete", + last_name: "FromForm", + email: "todelete.from.form.#{System.unique_integer([:positive])}@example.com" + }, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members/#{member}/edit") + + view + |> render_click("delete", %{"id" => member.id}) + + assert_redirect(view, ~p"/members") + + refute Mv.Membership.Member + |> Ash.Query.filter(id == ^member.id) + |> Ash.exists?() + end + end + describe "tab visibility" do @tag :ui test "Payments tab is not visible on new member form", %{conn: conn} do diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs index 8c7a23a..54829de 100644 --- a/test/mv_web/member_live/show_test.exs +++ b/test/mv_web/member_live/show_test.exs @@ -135,14 +135,16 @@ defmodule MvWeb.MemberLive.ShowTest do end describe "delete action" do - test "renders Delete button when user can destroy member", %{ + test "renders Danger zone section and Delete button when user can destroy member", %{ conn: conn, member: member } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, ~p"/members/#{member}") + {:ok, view, html} = live(conn, ~p"/members/#{member}") assert has_element?(view, "[data-testid='member-delete']") + assert html =~ gettext("Danger zone") + assert has_element?(view, "section[aria-labelledby='danger-zone-heading']") end test "delete event removes member and redirects to index", %{ -- 2.47.2 From 0f12befd11f3f7b2e2c4d3828e9c6e062ff60546 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 16:25:13 +0100 Subject: [PATCH 8/8] style: consistent back button and some translations --- CODE_GUIDELINES.md | 2 +- DESIGN_DUIDELINES.md | 30 +- lib/mv_web/components/core_components.ex | 17 +- .../field_visibility_dropdown_component.ex | 2 +- .../components/member_filter_component.ex | 2 +- .../live/custom_field_live/form_component.ex | 2 +- .../live/custom_field_live/index_component.ex | 2 +- lib/mv_web/live/global_settings_live.ex | 2 +- lib/mv_web/live/group_live/form.ex | 6 +- lib/mv_web/live/group_live/show.ex | 10 +- lib/mv_web/live/member_live/form.ex | 57 ++-- lib/mv_web/live/member_live/show.ex | 8 +- .../live/membership_fee_type_live/form.ex | 16 ++ lib/mv_web/live/role_live/form.ex | 8 +- lib/mv_web/live/role_live/show.ex | 12 +- lib/mv_web/live/user_live/form.ex | 72 +++-- lib/mv_web/live/user_live/index.ex | 42 +-- lib/mv_web/live/user_live/index.html.heex | 27 -- lib/mv_web/live/user_live/show.ex | 64 +++-- priv/gettext/de/LC_MESSAGES/default.po | 265 ++++++++++++------ priv/gettext/default.pot | 214 ++++++++------ priv/gettext/en/LC_MESSAGES/default.po | 265 ++++++++++++------ .../live/custom_field_live/deletion_test.exs | 59 ++-- test/mv_web/live/role_live_test.exs | 52 ++-- .../live/user_live_authorization_test.exs | 12 +- test/mv_web/user_live/index_test.exs | 209 ++------------ 26 files changed, 747 insertions(+), 710 deletions(-) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 68e7887..d4769f3 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -61,7 +61,7 @@ We are building a membership management system (Mila) using the following techno 8. [Accessibility Guidelines](#8-accessibility-guidelines) **Related documents:** -- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors. +- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors. --- diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index e3faf50..fc3acac 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -46,19 +46,35 @@ Every authenticated page should follow the same structure: **MUST:** Use `<.header>` on every page (except login/public pages). **SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks. -**Template:** +### 2.2 Edit/New form header: Back button left (mandatory) + +For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type): + +- **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next, primary action on the right). +- **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper. +- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right. +- **Rationale:** Users expect a consistent way to leave the form without submitting; Back left matches the data fields edit view and keeps primary actions on the right. + +**Template for form pages:** ```heex <.header> - Title - <:subtitle>Short explanation of what the page is for. + <:leading> + <.button navigate={return_path(@return_to, @resource)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + + Page title (e.g. “Edit Member” or “New User”) + <:subtitle>Short explanation. <:actions> - <.button variant="primary" navigate={...}>Primary action + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save")} + +``` -
- -
+If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`). ## 3) Typography (system) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 83d506a..85c26c7 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -111,7 +111,7 @@ defmodule MvWeb.CoreComponents do <.button variant="ghost" size="sm">Edit <.button disabled={true}>Disabled """ - attr :rest, :global, include: ~w(href navigate patch method data-testid) + attr :rest, :global, include: ~w(href navigate patch method data-testid form) attr :variant, :string, values: ~w(primary secondary neutral ghost outline danger link icon), @@ -633,17 +633,24 @@ defmodule MvWeb.CoreComponents do @doc """ Renders a header with title. + + Use the `:leading` slot for the Back button (left side, consistent with data fields). + Use the `:actions` slot for primary actions (e.g. Save) on the right. """ attr :class, :string, default: nil + slot :leading, doc: "Content on the left (e.g. Back button)" slot :inner_block, required: true slot :subtitle slot :actions def header(assigns) do ~H""" -
-
+
+
+ {render_slot(@leading)} +
+

{render_slot(@inner_block)}

@@ -651,7 +658,9 @@ defmodule MvWeb.CoreComponents do {render_slot(@subtitle)}

-
{render_slot(@actions)}
+
+ {render_slot(@actions)} +
""" end diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex index a8e8d45..58777da 100644 --- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex +++ b/lib/mv_web/live/components/field_visibility_dropdown_component.ex @@ -188,7 +188,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do defp find_custom_field_name(id, _field_string, custom_fields) do case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do - nil -> gettext("Custom Field %{id}", id: id) + nil -> gettext("Datafield %{id}", id: id) custom_field -> custom_field.name end end diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index b837bc0..4ee72d3 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -252,7 +252,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
0} class="mb-2">
- {gettext("Custom Fields")} + {gettext("Individual datafields")}

{gettext( - "Deleting this data field cannot be undone. All custom field values for this field will be permanently removed." + "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." )}

<.button diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index b0e9862..3b70c3d 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -158,7 +158,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do phx-target={@myself} disabled={@slug_confirmation != @custom_field_to_delete.slug} > - {gettext("Delete Custom Field and All Values")} + {gettext("Delete Datafields and All Values")}
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 485601a..bb8eb32 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -95,7 +95,7 @@ defmodule MvWeb.GlobalSettingsLive do
<.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save Settings")} + {gettext("Save Name")} diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex index 490214f..2e79a7f 100644 --- a/lib/mv_web/live/group_live/form.ex +++ b/lib/mv_web/live/group_live/form.ex @@ -79,12 +79,14 @@ defmodule MvWeb.GroupLive.Form do <.form for={@form} id="group-form" phx-change="validate" phx-submit="save"> <.header> - {@page_title} - <:actions> + <:leading> <.button navigate={return_path(@return_to, @group)} variant="neutral"> <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {@page_title} + <:actions> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> {gettext("Save")} diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index d970f2a..dbc0523 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -50,7 +50,7 @@ defmodule MvWeb.GroupLive.Show do end end - defp load_group_by_slug(socket, slug, actor, params \\ %{}) do + defp load_group_by_slug(socket, slug, actor, params) do # Load group with members and member_count # Using explicit load ensures efficient preloading of members relationship require Ash.Query @@ -92,8 +92,7 @@ defmodule MvWeb.GroupLive.Show do ~H""" <.header> - {@group.name} - <:actions> + <:leading> <.button navigate={~p"/groups"} variant="neutral" @@ -102,13 +101,16 @@ defmodule MvWeb.GroupLive.Show do <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {@group.name} + <:actions> <%= if can?(@current_user, :update, @group) do %> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"} data-testid="group-show-edit-btn" > - {gettext("Edit group")} + <.icon name="hero-pencil-square" /> {gettext("Edit group")} <% end %> diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 1875205..d0eaabf 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -39,16 +39,18 @@ defmodule MvWeb.MemberLive.Form do <.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> <.header> + <:leading> + <.button navigate={return_path(@return_to, @member)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + <%= if @member do %> {MvWeb.Helpers.MemberHelpers.display_name(@member)} <% else %> {gettext("New Member")} <% end %> <:actions> - <.button navigate={return_path(@return_to, @member)} variant="neutral"> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> {gettext("Save")} @@ -408,32 +410,33 @@ defmodule MvWeb.MemberLive.Form do member = socket.assigns.member actor = current_actor(socket) - if is_nil(member) do - {:noreply, put_flash(socket, :error, gettext("Member not found"))} - else - if to_string(id) != to_string(member.id) do + cond do + is_nil(member) -> {:noreply, put_flash(socket, :error, gettext("Member not found"))} - else - case Ash.destroy(member, actor: actor) do - :ok -> - {:noreply, - socket - |> put_flash(:success, gettext("Member deleted successfully")) - |> push_navigate(to: ~p"/members")} - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this member") - )} + to_string(id) != to_string(member.id) -> + {:noreply, put_flash(socket, :error, gettext("Member not found"))} - {:error, error} -> - Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") - {:noreply, put_flash(socket, :error, format_destroy_error(error))} - end - end + true -> + handle_member_delete_destroy(socket, member, actor) + end + end + + defp handle_member_delete_destroy(socket, member, actor) do + case Ash.destroy(member, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("Member deleted successfully")) + |> push_navigate(to: ~p"/members")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash(socket, :error, gettext("You do not have permission to delete this member"))} + + {:error, error} -> + Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") + {:noreply, put_flash(socket, :error, format_destroy_error(error))} end end diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 63349be..ecd2d51 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -31,8 +31,7 @@ defmodule MvWeb.MemberLive.Show do ~H""" <.header> - {MvWeb.Helpers.MemberHelpers.display_name(@member)} - <:actions> + <:leading> <.button navigate={~p"/members?highlight=#{@member.id}"} variant="neutral" @@ -41,13 +40,16 @@ defmodule MvWeb.MemberLive.Show do <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {MvWeb.Helpers.MemberHelpers.display_name(@member)} + <:actions> <%= if can?(@current_user, :update, @member) do %> <.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"} data-testid="member-edit" > - {gettext("Edit member")} + <.icon name="hero-pencil-square" /> {gettext("Edit member")} <% end %> diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index ca61e19..237b4b4 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -27,10 +27,26 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do ~H""" <.header> + <:leading> + <.button navigate={return_path(@return_to, @membership_fee_type)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + {@page_title} <:subtitle> {gettext("Use this form to manage membership fee types in your database.")} + <:actions> + <.button + form="membership-fee-type-form" + phx-disable-with={gettext("Saving...")} + variant="primary" + type="submit" + > + {gettext("Save")} + + <.form diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex index 684e695..e066555 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -23,13 +23,15 @@ defmodule MvWeb.RoleLive.Form do <.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save"> <.header> - {@page_title} - <:subtitle>{gettext("Use this form to manage roles in your database.")} - <:actions> + <:leading> <.button navigate={return_path(@return_to, @role)} variant="neutral"> <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {@page_title} + <:subtitle>{gettext("Use this form to manage roles in your database.")} + <:actions> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> {gettext("Save")} diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 8b615b6..4f36eca 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -161,10 +161,7 @@ defmodule MvWeb.RoleLive.Show do ~H""" <.header> - {gettext("Role")} {@role.name} - <:subtitle>{gettext("Role details and permissions.")} - - <:actions> + <:leading> <.button navigate={~p"/admin/roles"} variant="neutral" @@ -173,13 +170,18 @@ defmodule MvWeb.RoleLive.Show do <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")} + + {gettext("Role")} {@role.name} + <:subtitle>{gettext("Role details and permissions.")} + + <:actions> <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"} data-testid="role-show-edit-btn" > - {gettext("Edit role")} + <.icon name="hero-pencil-square" /> {gettext("Edit role")} <% end %> diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 34defe1..f7c440d 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -46,8 +46,24 @@ defmodule MvWeb.UserLive.Form do ~H""" <.header> + <:leading> + <.button navigate={return_path(@return_to, @user)} variant="neutral"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + {@page_title} <:subtitle>{gettext("Use this form to manage user records in your database.")} + <:actions> + <.button + form="user-form" + phx-disable-with={gettext("Saving...")} + variant="primary" + type="submit" + > + {gettext("Save User")} + + <.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save"> @@ -300,7 +316,8 @@ defmodule MvWeb.UserLive.Form do phx-click="delete" phx-value-id={@user.id} data-confirm={ - gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.", + gettext( + "Are you sure you want to delete the user %{email}? This action cannot be undone.", email: @user.email ) } @@ -442,36 +459,18 @@ defmodule MvWeb.UserLive.Form do user = socket.assigns.user actor = current_actor(socket) - if is_nil(user) do - {:noreply, put_flash(socket, :error, gettext("User not found"))} - else - if to_string(id) != to_string(user.id) do + cond do + is_nil(user) -> {:noreply, put_flash(socket, :error, gettext("User not found"))} - else - if Mv.Helpers.SystemActor.system_user?(user) do - {:noreply, - put_flash(socket, :error, gettext("System user cannot be deleted."))} - else - case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do - :ok -> - {:noreply, - socket - |> put_flash(:success, gettext("User deleted successfully")) - |> push_navigate(to: ~p"/users")} - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this user") - )} + to_string(id) != to_string(user.id) -> + {:noreply, put_flash(socket, :error, gettext("User not found"))} - {:error, error} -> - {:noreply, put_flash(socket, :error, format_ash_error(error))} - end - end - end + Mv.Helpers.SystemActor.system_user?(user) -> + {:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))} + + true -> + handle_user_delete_destroy(socket, user, actor) end end @@ -585,6 +584,23 @@ defmodule MvWeb.UserLive.Form do {:noreply, socket} end + defp handle_user_delete_destroy(socket, user, actor) do + case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do + :ok -> + {:noreply, + socket + |> put_flash(:success, gettext("User deleted successfully")) + |> push_navigate(to: ~p"/users")} + + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash(socket, :error, gettext("You do not have permission to delete this user"))} + + {:error, error} -> + {:noreply, put_flash(socket, :error, format_ash_error(error))} + end + end + defp handle_member_linking(socket, user, actor) do result = perform_member_link_action(socket, user, actor) diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index d72c1fd..4858202 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -7,15 +7,10 @@ defmodule MvWeb.UserLive.Index do - Sort users by email (default) - Navigate to user details (row click) and edit from details header - Delete only via Danger zone on user show/edit - - Bulk selection for future batch operations ## Relationships Displays linked member information when a user is connected to a member account. - ## Events - - `select_user` - Toggle individual user selection - - `select_all` - Toggle selection of all visible users - ## Security User deletion requires admin permissions (enforced by Ash policies). """ @@ -42,24 +37,7 @@ defmodule MvWeb.UserLive.Index do |> assign(:page_title, gettext("Listing Users")) |> assign(:sort_field, :email) |> assign(:sort_order, :asc) - |> assign(:users, sorted) - |> assign(:selected_users, [])} - end - - # Selects one user in the list of users - @impl true - def handle_event("select_user", %{"id" => id}, socket) do - # Normalize ID to string for consistent comparison - id_str = to_string(id) - - selected = - if id_str in socket.assigns.selected_users do - List.delete(socket.assigns.selected_users, id_str) - else - [id_str | socket.assigns.selected_users] - end - - {:noreply, assign(socket, :selected_users, selected)} + |> assign(:users, sorted)} end # Sorts the list of users according to a field, when you click on the column header @@ -86,24 +64,6 @@ defmodule MvWeb.UserLive.Index do |> assign(:users, sorted_users)} end - # Selects all users in the list of users - @impl true - def handle_event("select_all", _params, socket) do - users = socket.assigns.users - - # Normalize IDs to strings for consistent comparison - all_ids = Enum.map(users, &to_string(&1.id)) - - selected = - if Enum.sort(socket.assigns.selected_users) == Enum.sort(all_ids) do - [] - else - all_ids - end - - {:noreply, assign(socket, :selected_users, selected)} - end - defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp sort_fun(:asc), do: &<=/2 diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 7ffa0e3..86f0ab7 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -19,33 +19,6 @@ sort_field={@sort_field} sort_order={@sort_order} > - <:col - :let={user} - label={ - ~H""" - <.input - type="checkbox" - name="select_all" - phx-click="select_all" - checked={Enum.sort(@selected_users) == Enum.map(@users, &to_string(&1.id)) |> Enum.sort()} - aria-label={gettext("Select all users")} - role="checkbox" - /> - """ - } - > - <.input - type="checkbox" - name={to_string(user.id)} - phx-click="select_user" - phx-value-id={to_string(user.id)} - checked={to_string(user.id) in @selected_users} - phx-capture-click - phx-stop-propagation - aria-label={gettext("Select user")} - role="checkbox" - /> - <:col :let={user} sort_field={:email} diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index a77a1c4..d7a12b2 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -34,14 +34,20 @@ defmodule MvWeb.UserLive.Show do ~H""" <.header> + <:leading> + <.button + navigate={~p"/users"} + variant="neutral" + aria-label={gettext("Back to users list")} + > + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + {gettext("User")} {@user.email} <:subtitle>{gettext("This is a user record from your database.")} <:actions> - <.button navigate={~p"/users"} variant="neutral" aria-label={gettext("Back to users list")}> - <.icon name="hero-arrow-left" /> - {gettext("Back to users list")} - <%= if can?(@current_user, :update, @user) do %> <.button variant="primary" @@ -99,7 +105,8 @@ defmodule MvWeb.UserLive.Show do phx-click="delete" phx-value-id={@user.id} data-confirm={ - gettext("Are you sure you want to delete the user %{email}? This action cannot be undone.", + gettext( + "Are you sure you want to delete the user %{email}? This action cannot be undone.", email: @user.email ) } @@ -141,33 +148,32 @@ defmodule MvWeb.UserLive.Show do user = socket.assigns.user actor = current_actor(socket) - if to_string(id) != to_string(user.id) do - {:noreply, put_flash(socket, :error, gettext("User not found"))} - else - if Mv.Helpers.SystemActor.system_user?(user) do + cond do + to_string(id) != to_string(user.id) -> + {:noreply, put_flash(socket, :error, gettext("User not found"))} + + Mv.Helpers.SystemActor.system_user?(user) -> + {:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))} + + true -> + handle_user_delete_destroy(socket, user, actor) + end + end + + defp handle_user_delete_destroy(socket, user, actor) do + case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do + :ok -> {:noreply, - put_flash(socket, :error, gettext("System user cannot be deleted."))} - else - case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do - :ok -> - {:noreply, - socket - |> put_flash(:success, gettext("User deleted successfully")) - |> push_navigate(to: ~p"/users")} + socket + |> put_flash(:success, gettext("User deleted successfully")) + |> push_navigate(to: ~p"/users")} - {:error, %Ash.Error.Forbidden{}} -> - {:noreply, - put_flash( - socket, - :error, - gettext("You do not have permission to delete this user") - )} + {:error, %Ash.Error.Forbidden{}} -> + {:noreply, + put_flash(socket, :error, gettext("You do not have permission to delete this user"))} - {:error, error} -> - {:noreply, - put_flash(socket, :error, format_ash_error(error))} - end - end + {:error, error} -> + {:noreply, put_flash(socket, :error, format_ash_error(error))} end end end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 4e6c888..1bd57e1 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -18,9 +18,6 @@ msgstr "Aktionen" #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/role_live/show.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" @@ -38,11 +35,8 @@ msgstr "Verbindung wird wiederhergestellt" msgid "City" msgstr "Stadt" -#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" @@ -100,8 +94,6 @@ msgid "New Member" msgstr "Neues Mitglied" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" @@ -480,16 +472,6 @@ msgstr "Passwort" msgid "Password requirements" msgstr "Passwort-Anforderungen" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Select all users" -msgstr "Alle Benutzer*innen auswählen" - -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Select user" -msgstr "Benutzer*in auswählen" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Set Password" @@ -605,7 +587,6 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden." -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -624,11 +605,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht." -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Delete Custom Field and All Values" -msgstr "Benutzerdefiniertes Feld und alle Werte löschen" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -662,7 +638,6 @@ msgstr "Vereinsdaten" msgid "Manage global settings for the association." msgstr "Passe übergreifende Einstellungen für den Verein an." -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save Settings" @@ -785,8 +760,11 @@ msgstr "Adresse" #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "Zurück" @@ -821,6 +799,7 @@ msgstr "Persönliche Daten" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Save" @@ -964,11 +943,6 @@ msgstr "Unbezahlt" msgid "Yearly" msgstr "jährlich" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Custom Field %{id}" -msgstr "Benutzerdefiniertes Feld %{id}" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Last name" @@ -1604,17 +1578,11 @@ msgstr "Datenfeld speichern" msgid "Back to roles list" msgstr "Zurück zur Rollen-Liste" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Cannot delete system role" -msgstr "System-Rolle kann nicht gelöscht werden" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" msgstr "Benutzerdefiniert" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Failed to delete role: %{error}" @@ -1631,7 +1599,6 @@ msgstr "Rollen auflisten" msgid "Manage user roles and their permission sets." msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze." -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -1642,11 +1609,6 @@ msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser msgid "Close sidebar" msgstr "Sidebar schließen" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Delete Role" -msgstr "Rolle löschen" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Main navigation" @@ -1690,7 +1652,6 @@ msgstr "Profil" msgid "Role" msgstr "Rolle" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role deleted successfully." @@ -1702,7 +1663,6 @@ msgid "Role details and permissions." msgstr "Rollen-Details und Berechtigungen." #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role not found." @@ -1734,12 +1694,6 @@ msgstr "System" msgid "System Role" msgstr "System-Rolle" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "System roles cannot be deleted" -msgstr "System-Rollen können nicht gelöscht werden" - -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "System roles cannot be deleted." @@ -1816,12 +1770,14 @@ msgstr "Mitgliedsbeitragsart nicht gefunden" msgid "User %{action} successfully" msgstr "Benutzer*in wurde erfolgreich %{action}" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "User deleted successfully" msgstr "Benutzer*in erfolgreich gelöscht" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "User not found" msgstr "Benutzer*in nicht gefunden" @@ -1832,18 +1788,14 @@ msgstr "Benutzer*in nicht gefunden" msgid "You do not have permission to access this membership fee type" msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen" -#: lib/mv_web/live/user_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this user" -msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this user" msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen" @@ -1864,16 +1816,19 @@ msgstr "aktualisiert" msgid "Unknown error" msgstr "Unbekannter Fehler" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "Mitglied wurde erfolgreich gelöscht" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "Mitglied nicht gefunden" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" @@ -2130,6 +2085,7 @@ msgstr "Gruppe erstellen" msgid "Delete Group" msgstr "Gruppe löschen" +#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete group" @@ -2210,11 +2166,6 @@ msgstr[1] "Diese Gruppe hat %{count} Mitglieder. Alle Mitglied-Gruppen-Zuordnung msgid "To confirm deletion, please enter the group name:" msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:" -#: lib/mv_web/live/group_live/index.ex -#, elixir-autogen, elixir-format -msgid "View" -msgstr "Anzeigen" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Total: %{count} member" @@ -3073,38 +3024,42 @@ msgstr "Filter zurücksetzen“" msgid "Apply filters" msgstr "Filter auswählen" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Are you sure you want to delete %{name}? This action cannot be undone." msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." +#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Danger zone" msgstr "Gefahrenzone" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete member" msgstr "Mitglied löschen" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Delete member %{name}" msgstr "Mitglied %{name} löschen" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." msgstr "Das Löschen des Mitglieds kann nicht rückgängig gemacht werden. Alle dazugehörigen Daten (z.B. Mitgliedsbeitragszylen) werden gelöscht." -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit datafield" -msgstr "Datenfeld bearbeiten" - -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit group" @@ -3115,22 +3070,11 @@ msgstr "Gruppe bearbeiten" msgid "Edit member" msgstr "Mitglied bearbeiten" -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit role" msgstr "Rolle bearbeiten" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit user" -msgstr "Benutzer*in bearbeiten" - -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for datafield details" -msgstr "Klicke für Datenfeld-Details" - #: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Click for group details" @@ -3151,31 +3095,155 @@ msgstr "Klicke für Rollen-Details" msgid "Click for user details" msgstr "Klicke für Benutzer*innen-Details" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Click for dataield details" -msgstr "Klicke für Datenfeld-Details" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Members table" msgstr "Mitglieder" +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete the role %{name}? This action cannot be undone." +msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete the user %{email}? This action cannot be undone." +msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click to edit datafield" +msgstr "Klicke für Datenfeld-Details" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete data field" +msgstr "Datenfeld löschen" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Delete data field %{name}" +msgstr "Datenfeld %{name} löschen" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete group %{name}" +msgstr "Gruppe %{name} löschen" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete role" +msgstr "Rolle löschen" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete role %{name}" +msgstr "Mitglied %{name} löschen" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete user" +msgstr "Löschen" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete user %{email}" +msgstr "Benutzer*in %{email} löschen" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed." +msgstr "Das Löschen der Gruppe kann nicht rückgängig gemacht werden. Alle Mitglieds-Gruppen Zugehörigkeiten werden gelöscht." + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." +msgstr "Das Löschen dieser Rolle kann nicht rückgängig gemacht werden. Benutzer*inen die dieser Rolle zugewiesen wurden, müssen zuerst einer anderen Rolle zugewiesen werden." + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected." +msgstr "Das Löschen kann nicht rückgängig gemacht werden. Der Account und Verlinkungen zu Mitgliedern werden entfernt." + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "System user cannot be deleted." +msgstr "System-Rollen können nicht gelöscht werden." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Name" +msgstr "Speichern" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Datafield %{id}" +msgstr "Datenfelder" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete Datafields and All Values" +msgstr "Benutzerdefiniertes Feld und alle Werte löschen" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." +msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden. Alle " + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Individual datafields" +msgstr "Individuelle Datenfelder" + #~ #: lib/mv_web/live/member_field_live/form_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Back to Settings" #~ msgstr "Zurück zu den Einstellungen" +#~ #: lib/mv_web/live/role_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Cannot delete system role" +#~ msgstr "System-Rolle kann nicht gelöscht werden" + #~ #: lib/mv_web/live/custom_field_live/index_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Click for custom field details" #~ msgstr "Klicke für Datenfeld-Details" +#~ #: lib/mv_web/live/member_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for datafield details" +#~ msgstr "Klicke für Datenfeld-Details" + #~ #: lib/mv_web/live/member_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Coming soon" #~ msgstr "Demnächst verfügbar" +#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Custom Field %{id}" +#~ msgstr "Benutzerdefiniertes Feld %{id}" + +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #: lib/mv_web/live/member_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Edit datafield" +#~ msgstr "Datenfeld bearbeiten" + +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Edit user" +#~ msgstr "Benutzer*in bearbeiten" + #~ #: lib/mv_web/live/components/member_filter_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Reset" @@ -3191,7 +3259,32 @@ msgstr "Mitglieder" #~ msgid "Save Role" #~ msgstr "Rolle speichern" +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Select all users" +#~ msgstr "Alle Benutzer*innen auswählen" + +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Select user" +#~ msgstr "Benutzer*in auswählen" + +#~ #: lib/mv_web/live/role_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "System roles cannot be deleted" +#~ msgstr "System-Rollen können nicht gelöscht werden" + +#~ #: lib/mv_web/live/group_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "View" +#~ msgstr "Anzeigen" + #~ #: lib/mv_web/live/member_live/index.ex #~ #, elixir-autogen, elixir-format #~ msgid "You do not have permission to access this member" #~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen" + +#~ #: lib/mv_web/live/user_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "You do not have permission to access this user" +#~ msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ed020a0..309b425 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -19,9 +19,6 @@ msgstr "" #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/role_live/show.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -39,11 +36,8 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Delete" msgstr "" @@ -101,8 +95,6 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -481,16 +473,6 @@ msgstr "" msgid "Password requirements" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Select all users" -msgstr "" - -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Select user" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Set Password" @@ -606,7 +588,6 @@ msgstr "" msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -625,11 +606,6 @@ msgstr[1] "" msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Delete Custom Field and All Values" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -663,7 +639,6 @@ msgstr "" msgid "Manage global settings for the association." msgstr "" -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Save Settings" @@ -786,8 +761,11 @@ msgstr "" #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "" @@ -822,6 +800,7 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Save" @@ -965,11 +944,6 @@ msgstr "" msgid "Yearly" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Custom Field %{id}" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Last name" @@ -1605,17 +1579,11 @@ msgstr "" msgid "Back to roles list" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Cannot delete system role" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Failed to delete role: %{error}" @@ -1632,7 +1600,6 @@ msgstr "" msgid "Manage user roles and their permission sets." msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -1643,11 +1610,6 @@ msgstr "" msgid "Close sidebar" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Delete Role" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Main navigation" @@ -1691,7 +1653,6 @@ msgstr "" msgid "Role" msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role deleted successfully." @@ -1703,7 +1664,6 @@ msgid "Role details and permissions." msgstr "" #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role not found." @@ -1735,12 +1695,6 @@ msgstr "" msgid "System Role" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "System roles cannot be deleted" -msgstr "" - -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "System roles cannot be deleted." @@ -1817,12 +1771,14 @@ msgstr "" msgid "User %{action} successfully" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "User deleted successfully" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "User not found" msgstr "" @@ -1833,18 +1789,14 @@ msgstr "" msgid "You do not have permission to access this membership fee type" msgstr "" -#: lib/mv_web/live/user_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this user" -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this user" msgstr "" @@ -1865,16 +1817,19 @@ msgstr "" msgid "Unknown error" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" @@ -2131,6 +2086,7 @@ msgstr "" msgid "Delete Group" msgstr "" +#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Delete group" @@ -2211,11 +2167,6 @@ msgstr[1] "" msgid "To confirm deletion, please enter the group name:" msgstr "" -#: lib/mv_web/live/group_live/index.ex -#, elixir-autogen, elixir-format -msgid "View" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Total: %{count} member" @@ -3073,38 +3024,42 @@ msgstr "" msgid "Apply filters" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Are you sure you want to delete %{name}? This action cannot be undone." msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Danger zone" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Delete member" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Delete member %{name}" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Edit datafield" -msgstr "" - -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Edit group" @@ -3115,22 +3070,11 @@ msgstr "" msgid "Edit member" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Edit role" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Edit user" -msgstr "" - -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for datafield details" -msgstr "" - #: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Click for group details" @@ -3151,12 +3095,110 @@ msgstr "" msgid "Click for user details" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for dataield details" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Members table" msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete the role %{name}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete the user %{email}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click to edit datafield" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Delete data field" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Delete data field %{name}" +msgstr "" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete group %{name}" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete role" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete role %{name}" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete user" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete user %{email}" +msgstr "" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed." +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "System user cannot be deleted." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save Name" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Datafield %{id}" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Delete Datafields and All Values" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Individual datafields" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a44e87c..a42bdbd 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -19,9 +19,6 @@ msgstr "" #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/role_live/show.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -39,11 +36,8 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Delete" msgstr "" @@ -101,8 +95,6 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/role_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -481,16 +473,6 @@ msgstr "" msgid "Password requirements" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select all users" -msgstr "" - -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select user" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Set Password" @@ -606,7 +588,6 @@ msgstr "" msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -625,11 +606,6 @@ msgstr[1] "" msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Delete Custom Field and All Values" -msgstr "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" @@ -663,7 +639,6 @@ msgstr "" msgid "Manage global settings for the association." msgstr "" -#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save Settings" @@ -786,8 +761,11 @@ msgstr "" #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "" @@ -822,6 +800,7 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save" @@ -965,11 +944,6 @@ msgstr "" msgid "Yearly" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom Field %{id}" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Last name" @@ -1605,17 +1579,11 @@ msgstr "" msgid "Back to roles list" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Cannot delete system role" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Custom" msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Failed to delete role: %{error}" @@ -1632,7 +1600,6 @@ msgstr "" msgid "Manage user roles and their permission sets." msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." @@ -1643,11 +1610,6 @@ msgstr "" msgid "Close sidebar" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Delete Role" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Main navigation" @@ -1691,7 +1653,6 @@ msgstr "" msgid "Role" msgstr "" -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Role deleted successfully." @@ -1703,7 +1664,6 @@ msgid "Role details and permissions." msgstr "" #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Role not found." @@ -1735,12 +1695,6 @@ msgstr "" msgid "System Role" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "System roles cannot be deleted" -msgstr "" - -#: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "System roles cannot be deleted." @@ -1817,12 +1771,14 @@ msgstr "" msgid "User %{action} successfully" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "User deleted successfully" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "User not found" msgstr "" @@ -1833,18 +1789,14 @@ msgstr "" msgid "You do not have permission to access this membership fee type" msgstr "" -#: lib/mv_web/live/user_live/index.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "You do not have permission to access this user" -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this membership fee type" msgstr "" -#: lib/mv_web/live/user_live/index.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this user" msgstr "" @@ -1865,16 +1817,19 @@ msgstr "" msgid "Unknown error" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" @@ -2131,6 +2086,7 @@ msgstr "" msgid "Delete Group" msgstr "" +#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete group" @@ -2211,11 +2167,6 @@ msgstr[1] "" msgid "To confirm deletion, please enter the group name:" msgstr "" -#: lib/mv_web/live/group_live/index.ex -#, elixir-autogen, elixir-format -msgid "View" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Total: %{count} member" @@ -3073,38 +3024,42 @@ msgstr "" msgid "Apply filters" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Are you sure you want to delete %{name}? This action cannot be undone." msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex #, elixir-autogen, elixir-format msgid "Danger zone" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Delete member" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Delete member %{name}" msgstr "" +#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit datafield" -msgstr "" - -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit group" @@ -3115,22 +3070,11 @@ msgstr "" msgid "Edit member" msgstr "" -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Edit role" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit user" -msgstr "" - -#: lib/mv_web/live/member_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Click for datafield details" -msgstr "" - #: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Click for group details" @@ -3151,31 +3095,155 @@ msgstr "" msgid "Click for user details" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Click for dataield details" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Members table" msgstr "" +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete the role %{name}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete the user %{email}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Click to edit datafield" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete data field" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Delete data field %{name}" +msgstr "" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete group %{name}" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete role" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete role %{name}" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete user" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete user %{email}" +msgstr "" + +#: lib/mv_web/live/group_live/form.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed." +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "System user cannot be deleted." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Name" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Datafield %{id}" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete Datafields and All Values" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed." +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Individual datafields" +msgstr "" + #~ #: lib/mv_web/live/member_field_live/form_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Back to Settings" #~ msgstr "" +#~ #: lib/mv_web/live/role_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Cannot delete system role" +#~ msgstr "" + #~ #: lib/mv_web/live/custom_field_live/index_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Click for custom field details" #~ msgstr "" +#~ #: lib/mv_web/live/member_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for datafield details" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/form.ex #~ #, elixir-autogen, elixir-format #~ msgid "Coming soon" #~ msgstr "" +#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Custom Field %{id}" +#~ msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/index_component.ex +#~ #: lib/mv_web/live/member_field_live/index_component.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Edit datafield" +#~ msgstr "" + +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Edit user" +#~ msgstr "" + #~ #: lib/mv_web/live/components/member_filter_component.ex #~ #, elixir-autogen, elixir-format #~ msgid "Reset" @@ -3191,7 +3259,32 @@ msgstr "" #~ msgid "Save Role" #~ msgstr "" +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Select all users" +#~ msgstr "" + +#~ #: lib/mv_web/live/user_live/index.html.heex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Select user" +#~ msgstr "" + +#~ #: lib/mv_web/live/role_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "System roles cannot be deleted" +#~ msgstr "" + +#~ #: lib/mv_web/live/group_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "View" +#~ msgstr "" + #~ #: lib/mv_web/live/member_live/index.ex #~ #, elixir-autogen, elixir-format #~ msgid "You do not have permission to access this member" #~ msgstr "" + +#~ #: lib/mv_web/live/user_live/index.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "You do not have permission to access this user" +#~ msgstr "" diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs index 28f98a2..5ec955e 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -46,6 +46,17 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do %{conn: conn, user: user_with_role} end + # Delete is in the edit form (FormComponent); open form by clicking the name cell (unique td with phx-click) + defp open_delete_modal(view, custom_field) do + view + |> element("tr#custom_fields-#{custom_field.id} td", custom_field.name) + |> render_click() + + view + |> element("[data-testid=custom-field-delete]") + |> render_click() + end + describe "delete button and modal" do test "opens modal with correct member count when delete is clicked", %{conn: conn} do {:ok, member} = create_member() @@ -55,11 +66,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member, custom_field, "test") {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - # Click delete button - find the delete link within the component - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Modal should be visible assert has_element?(view, "#delete-custom-field-modal") @@ -81,23 +88,17 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member2, custom_field, "test2") {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Should show plural form assert render(view) =~ "2 members have values assigned for this custom field" end test "shows 0 members for custom field without values", %{conn: conn} do - {:ok, _custom_field} = create_custom_field("test_field", :string) + {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Should show 0 members assert render(view) =~ "0 members have values assigned for this custom field" @@ -109,10 +110,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Type in slug input - use element to find the form with phx-target view @@ -124,13 +122,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do end test "delete button is disabled when slug doesn't match", %{conn: conn} do - {:ok, _custom_field} = create_custom_field("test_field", :string) + {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Type wrong slug - use element to find the form with phx-target view @@ -149,11 +144,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test") {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - # Open modal - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Enter correct slug - use element to find the form with phx-target view @@ -162,7 +153,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do # Click confirm view - |> element("#delete-custom-field-modal button", "Delete Custom Field and All Values") + |> element("#delete-custom-field-modal button", "Delete Datafields and All Values") |> render_click() # Should show success message @@ -186,10 +177,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Enter wrong slug - use element to find the form with phx-target view @@ -210,10 +198,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - - view - |> element("#custom-fields-component a", "Delete") - |> render_click() + open_delete_modal(view, custom_field) # Modal should be visible assert has_element?(view, "#delete-custom-field-modal") diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs index cb112f2..57ce814 100644 --- a/test/mv_web/live/role_live_test.exs +++ b/test/mv_web/live/role_live_test.exs @@ -138,7 +138,7 @@ defmodule MvWeb.RoleLiveTest do assert html =~ "System Role" || html =~ "system" end - test "delete button disabled for system roles", %{conn: conn, actor: actor} do + test "delete button not shown for system roles", %{conn: conn, actor: actor} do system_role = Role |> Ash.Changeset.for_create(:create_role, %{ @@ -148,28 +148,19 @@ defmodule MvWeb.RoleLiveTest do |> Ash.Changeset.force_change_attribute(:is_system_role, true) |> Ash.create!(actor: actor) - {:ok, view, _html} = live(conn, "/admin/roles") + {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}") - assert has_element?( - view, - "button[phx-click='delete'][phx-value-id='#{system_role.id}'][disabled]" - ) || - not has_element?( - view, - "button[phx-click='delete'][phx-value-id='#{system_role.id}']" - ) + # Danger zone (and delete button) is not rendered for system roles + refute has_element?(view, "[data-testid=role-delete]") end test "delete button enabled for non-system roles", %{conn: conn} do role = create_role() - {:ok, view, html} = live(conn, "/admin/roles") + {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}") - # Delete is a link with phx-click containing delete event - # Check if delete link exists in HTML (phx-click contains delete and role id) - assert (html =~ "phx-click" && html =~ "delete" && html =~ role.id) || - has_element?(view, "a[phx-click*='delete'][phx-value-id='#{role.id}']") || - has_element?(view, "a[aria-label='Delete role']") + # Delete is on show page (Danger zone) + assert has_element?(view, "[data-testid=role-delete]") end test "new role button navigates to form", %{conn: conn} do @@ -393,21 +384,21 @@ defmodule MvWeb.RoleLiveTest do test "deletes non-system role", %{conn: conn, actor: actor} do role = create_role() - {:ok, view, html} = live(conn, "/admin/roles") + {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}") - # Delete is a link - JS.push creates phx-click with value containing id - # Verify the role id is in the HTML (in phx-click value) - assert html =~ role.id + # Delete from Danger zone on show page + view + |> element("[data-testid=role-delete]") + |> render_click() - # Send delete event directly to avoid selector issues with multiple delete buttons - render_click(view, "delete", %{"id" => role.id}) + assert_redirect(view, "/admin/roles") # Verify deletion by checking database assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} = Authorization.get_role(role.id, actor: actor) end - test "fails to delete system role with error message", %{conn: conn, actor: actor} do + test "system role has no delete button and cannot be deleted", %{conn: conn, actor: actor} do system_role = Role |> Ash.Changeset.for_create(:create_role, %{ @@ -417,19 +408,12 @@ defmodule MvWeb.RoleLiveTest do |> Ash.Changeset.force_change_attribute(:is_system_role, true) |> Ash.create!(actor: actor) - {:ok, view, html} = live(conn, "/admin/roles") + {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}") - # System role delete button should be disabled - assert html =~ "disabled" || html =~ "cursor-not-allowed" || - html =~ "System roles cannot be deleted" + # Danger zone is not rendered for system roles (no delete button) + refute has_element?(view, "[data-testid=role-delete]") - # Try to delete via event (backend check) - render_click(view, "delete", %{"id" => system_role.id}) - - # Should show error message - assert render(view) =~ "System roles cannot be deleted" - - # Role should still exist + # Role still exists {:ok, _role} = Authorization.get_role(system_role.id, actor: actor) end end diff --git a/test/mv_web/live/user_live_authorization_test.exs b/test/mv_web/live/user_live_authorization_test.exs index f4b4746..ee9f2b6 100644 --- a/test/mv_web/live/user_live_authorization_test.exs +++ b/test/mv_web/live/user_live_authorization_test.exs @@ -10,14 +10,16 @@ defmodule MvWeb.UserLiveAuthorizationTest do describe "User Index - Admin" do @tag role: :admin - test "sees New User, Edit and Delete buttons", %{conn: conn} do + test "sees New User button; Edit and Delete are on show page", %{conn: conn} do user = Fixtures.user_with_role_fixture("admin") - {:ok, view, _html} = live(conn, "/users") + {:ok, index_view, _html} = live(conn, "/users") + assert has_element?(index_view, "[data-testid=user-new]") - assert has_element?(view, "[data-testid=user-new]") - assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]") - assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]") + # Edit and Delete are on user show page (Danger zone), not on index + {:ok, show_view, _html} = live(conn, "/users/#{user.id}") + assert has_element?(show_view, "[data-testid=user-edit]") + assert has_element?(show_view, "[data-testid=user-delete]") end end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 11cd70b..f748000 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -16,11 +16,10 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ "alice@example.com" assert html =~ "bob@example.com" - # UI elements: New User button, action links + # UI elements: New User button; row click navigates to show (no Edit/Delete on index) assert html =~ "New User" - assert html =~ "Edit" - assert html =~ "Delete" - assert html =~ ~r/href="[^"]*\/users\/#{user1.id}\/edit"/ + # Row or navigation contains user id (e.g. row id or phx-click navigate) + assert html =~ "row-#{user1.id}" or html =~ to_string(user1.id) end @tag :ui @@ -116,177 +115,29 @@ defmodule MvWeb.UserLive.IndexTest do end end - describe "checkbox selection functionality" do - setup do - user1 = create_test_user(%{email: "user1@example.com", oidc_id: "user1"}) - user2 = create_test_user(%{email: "user2@example.com", oidc_id: "user2"}) - %{users: [user1, user2]} - end - - @tag :ui - test "shows checkbox UI elements", %{conn: conn, users: [user1, user2]} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/users") - - # Check select all checkbox exists - assert html =~ ~s(name="select_all") - assert html =~ ~s(phx-click="select_all") - - # Check individual user checkboxes exist - assert html =~ ~s(name="#{user1.id}") - assert html =~ ~s(name="#{user2.id}") - assert html =~ ~s(phx-click="select_user") - end - - @tag :ui - test "can select and deselect individual users", %{conn: conn, users: [user1, user2]} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/users") - - # Initially, individual checkboxes should exist but not be checked - assert view |> element("input[type='checkbox'][name='#{user1.id}']") |> has_element?() - assert view |> element("input[type='checkbox'][name='#{user2.id}']") |> has_element?() - - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - # Select first user checkbox - html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click() - assert html =~ "Email" - assert html =~ to_string(user1.email) - - # The select_all checkbox should still not be checked (not all users selected) - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - # Deselect user - html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click() - assert html =~ "Email" - - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - end - - @tag :ui - test "select all and deselect all functionality", %{conn: conn, users: [user1, user2]} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/users") - - # Initially no checkboxes should be checked - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - refute view - |> element("input[type='checkbox'][name='#{user1.id}'][checked]") - |> has_element?() - - refute view - |> element("input[type='checkbox'][name='#{user2.id}'][checked]") - |> has_element?() - - # Click select all - html = view |> element("input[type='checkbox'][name='select_all']") |> render_click() - - # After selecting all, the select_all checkbox should be checked - assert view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - assert html =~ "Email" - assert html =~ to_string(user1.email) - assert html =~ to_string(user2.email) - - # Then deselect all - html = view |> element("input[type='checkbox'][name='select_all']") |> render_click() - - # After deselecting all, no checkboxes should be checked - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - refute view - |> element("input[type='checkbox'][name='#{user1.id}'][checked]") - |> has_element?() - - refute view - |> element("input[type='checkbox'][name='#{user2.id}'][checked]") - |> has_element?() - - assert html =~ "Email" - end - - @tag :slow - test "select all automatically checks when all individual users are selected", %{ - conn: conn, - users: [_user1, _user2] - } do - conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/users") - - # Get all user IDs from the rendered HTML by finding all checkboxes with phx-click="select_user" - # Extract user IDs from the HTML (they appear as name attributes on checkboxes) - user_ids = - html - |> String.split("phx-click=\"select_user\"") - |> Enum.flat_map(fn part -> - case Regex.run(~r/name="([^"]+)"[^>]*phx-value-id/, part) do - [_, user_id] -> [user_id] - _ -> [] - end - end) - |> Enum.uniq() - - # Skip if no users found (shouldn't happen, but be safe) - if user_ids != [] do - # Initially nothing should be checked - refute view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - - # Select all users one by one - Enum.each(user_ids, fn user_id -> - view |> element("input[type='checkbox'][name='#{user_id}']") |> render_click() - end) - - # Now select all should be automatically checked (all individual users are selected) - assert view - |> element("input[type='checkbox'][name='select_all'][checked]") - |> has_element?() - end - end - end - describe "delete functionality" do - test "can delete a user", %{conn: conn} do - _user = create_test_user(%{email: "delete-me@example.com"}) + # Delete is only on user show page (Danger zone), not on index (per CODE_GUIDELINES: at most one UI smoke test for delete) + test "can delete a user from show page", %{conn: conn} do + user = create_test_user(%{email: "delete-me@example.com"}) conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/users") + {:ok, index_view, _html} = live(conn, "/users") + assert render(index_view) =~ "delete-me@example.com" - # Confirm user is displayed - assert render(view) =~ "delete-me@example.com" + # Navigate to user show and trigger delete from Danger zone + {:ok, show_view, _html} = live(conn, "/users/#{user.id}") - # Click the delete button (phx-click="delete" event) - view |> element("tbody tr:first-child a[data-confirm]") |> render_click() + show_view + |> element("[data-testid=user-delete]") + |> render_click() - # Verify user was actually deleted (should not appear in HTML anymore) - html = render(view) + # Should redirect to index + assert_redirect(show_view, "/users") + + # Reload index with same session; user should be gone + {:ok, _view_after, html} = live(conn, "/users") refute html =~ "delete-me@example.com" - # Table header should still be there assert html =~ "Email" end - - test "shows delete confirmation", %{conn: conn} do - _user = create_test_user(%{email: "confirm-delete@example.com"}) - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/users") - - # Check that delete link has confirmation attribute - assert html =~ ~s(data-confirm="Are you sure?") - end end describe "navigation" do @@ -296,36 +147,14 @@ defmodule MvWeb.UserLive.IndexTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/users") - # Check that user row contains link to show page + # Row click navigates to show page (edit is on show page) assert html =~ ~s(/users/#{user.id}) - # Check edit link points to correct edit page - assert html =~ ~s(/users/#{user.id}/edit) - # Check new user button points to correct new page assert html =~ ~s(/users/new) end end - describe "translations" do - @tag :ui - test "shows translations for selection in different locales", %{conn: conn} do - conn = conn_with_oidc_user(conn) - - # Test German translations - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html_de} = live(conn, "/users") - assert html_de =~ "Alle Benutzer*innen auswählen" - assert html_de =~ "Benutzer*in auswählen" - - # Test English translations - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html_en} = live(conn, "/users") - # Check that aria-label attributes exist (structure is there) - assert html_en =~ ~s(aria-label=) - end - end - describe "edge cases" do test "handles empty user list gracefully", %{conn: conn} do # Don't create any users besides the authenticated one -- 2.47.2
+ <%= if col_idx == 0 && @row_click && @row_tooltip do %> + {@row_tooltip} + <% end %> {render_slot(col, @row_item.(row))} - gettext("All") + gettext("Apply filters") end end @@ -487,7 +487,7 @@ defmodule MvWeb.Components.MemberFilterComponent do # Get boolean filter label (comma-separated list of active filter names) defp boolean_filter_label(_boolean_custom_fields, boolean_filters) when map_size(boolean_filters) == 0 do - gettext("Apply filters") + gettext("All") end defp boolean_filter_label(boolean_custom_fields, boolean_filters) do diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index a944c85..ebc4930 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -59,6 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) end } + row_tooltip={gettext("Click for dataield details")} > <:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name} diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex index 70358e0..76663fd 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -62,6 +62,7 @@ defmodule MvWeb.GroupLive.Index do rows={@groups} row_id={fn group -> "group-#{group.id}" end} row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end} + row_tooltip={gettext("Click for group details")} > <:col :let={group} label={gettext("Name")}> {group.name} diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index 1e8cf05..419b585 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -57,6 +57,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) end } + row_tooltip={gettext("Click for datafield details")} > <:col :let={{_field_name, field_data}} label={gettext("Name")}> {MemberFields.label(field_data.field)} diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 4309611..1be35b4 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -122,6 +122,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:groups, groups) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) + |> assign(:selected_member_id, nil) |> assign(:settings, settings) |> assign(:custom_fields_visible, custom_fields_visible) |> assign(:all_custom_fields, all_custom_fields) @@ -160,6 +161,12 @@ defmodule MvWeb.MemberLive.Index do - `"select_all"` - Toggles selection of all visible members - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ + @impl true + def handle_event("select_row_and_navigate", %{"id" => id}, socket) do + # Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected. + {:noreply, push_navigate(socket, to: ~p"/members/#{id}")} + end + @impl true def handle_event("select_member", %{"id" => id}, socket) do selected = @@ -599,6 +606,7 @@ defmodule MvWeb.MemberLive.Index do |> 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(:selected_member_id, parse_highlight_param(params["highlight"])) next_sig = build_signature(socket) @@ -798,6 +806,18 @@ defmodule MvWeb.MemberLive.Index do end 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 + + defp parse_highlight_param(id) when is_binary(id) do + if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)), + do: id, + else: nil + end + + defp parse_highlight_param(_), do: nil + defp merge_fields_param_from_uri(params, nil), do: params defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index a696b00..eec49de 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -59,6 +59,7 @@ variant="secondary" class={["gap-2", @show_current_cycle && "btn-active"]} phx-click="toggle_cycle_view" + data-testid="toggle-cycle-view" aria-label={ if(@show_current_cycle, do: gettext("Current Cycle Payment Status"), @@ -93,7 +94,9 @@ id="members" rows={@members} row_id={fn member -> "row-#{member.id}" end} - row_click={fn member -> JS.navigate(~p"/members/#{member}") end} + row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} + row_tooltip={gettext("Click for member details")} + row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} dynamic_cols={@dynamic_cols} sort_field={@sort_field} sort_order={@sort_order} diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index ae69c30..6757646 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -34,7 +34,7 @@ defmodule MvWeb.MemberLive.Show do {MvWeb.Helpers.MemberHelpers.display_name(@member)} <:actions> <.button - navigate={~p"/members"} + navigate={~p"/members?highlight=#{@member.id}"} variant="neutral" aria-label={gettext("Back to members list")} > diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index 5829bca..5947472 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -17,6 +17,7 @@ id="roles" rows={@roles} row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end} + row_tooltip={gettext("Click for role details")} > <:col :let={role} label={gettext("Name")}>
diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 8b5b1b2..dd2c4f2 100644 --- a/lib/mv_web/live/role_live/show.ex +++ b/lib/mv_web/live/role_live/show.ex @@ -174,8 +174,8 @@ defmodule MvWeb.RoleLive.Show do {gettext("Back")} <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> - <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}> - <.icon name="hero-pencil-square" /> {gettext("Rolle bearbeiten")} + <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"} data-testid=role-edit"> + {gettext("Edit role")} <% end %> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 364e5a4..858e784 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -15,6 +15,7 @@ rows={@users} row_id={fn user -> "row-#{user.id}" end} row_click={fn user -> JS.navigate(~p"/users/#{user}") end} + row_tooltip={gettext("Click for user details")} sort_field={@sort_field} sort_order={@sort_order} > diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 49fbe83..4561f24 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -11,13 +11,11 @@ msgstr "" "Language: de\n" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex @@ -42,7 +40,6 @@ msgstr "Stadt" #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex @@ -50,15 +47,8 @@ msgstr "Stadt" msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/group_live/index.ex -#: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeiten" @@ -277,7 +267,6 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex -#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Cancel" @@ -790,19 +779,18 @@ msgstr "Alle" msgid "Address" msgstr "Adresse" +#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/form.ex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "Zurück" -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "Demnächst verfügbar" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -820,7 +808,6 @@ msgid "Payment Data" msgstr "Beitragsdaten" #: lib/mv_web/live/components/member_filter_component.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "Payments" msgstr "Zahlungen" @@ -834,6 +821,7 @@ msgstr "Persönliche Daten" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Save" msgstr "Speichern" @@ -851,11 +839,6 @@ msgstr "Mitglied erstellen" msgid "Amount" msgstr "Betrag" -#: lib/mv_web/live/member_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "Zurück zu den Einstellungen" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1575,6 +1558,7 @@ msgid "Show/Hide Columns" msgstr "Spalten ein-/ausblenden" #: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/member_field_live/form_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Back to settings" msgstr "Zurück zu den Einstellungen" @@ -1630,11 +1614,6 @@ msgstr "System-Rolle kann nicht gelöscht werden" msgid "Custom" msgstr "Benutzerdefiniert" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Edit Role" -msgstr "Rolle bearbeiten" - #: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format @@ -1734,11 +1713,6 @@ msgstr "Rolle nicht gefunden." msgid "Role saved successfully." msgstr "Rolle erfolgreich gespeichert." -#: lib/mv_web/live/role_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save Role" -msgstr "Rolle speichern" - #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Select permission set" @@ -1890,22 +1864,17 @@ msgstr "aktualisiert" msgid "Unknown error" msgstr "Unbekannter Fehler" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "Mitglied wurde erfolgreich gelöscht" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "Mitglied nicht gefunden" -#: lib/mv_web/live/member_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this member" -msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen" - -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen" @@ -1990,11 +1959,6 @@ msgstr "Mitgliedsfilter" msgid "Payment Status" msgstr "Bezahlstatus" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Reset" -msgstr "Zurücksetzen" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -3098,3 +3062,125 @@ msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)" #, elixir-autogen, elixir-format msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Clear filters" +msgstr "Filter zurücksetzen“" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Apply filters" +msgstr "Filter auswählen" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete %{name}? This action cannot be undone." +msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Danger zone" +msgstr "Gefahrenzone" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete member" +msgstr "Mitglied löschen" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete member %{name}" +msgstr "Mitglied %{name} löschen" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." +msgstr "Das Löschen des Mitglieds kann nicht rückgängig gemacht werden. Alle dazugehörigen Daten (z.B. Mitgliedsbeitragszylen) werden gelöscht." + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit datafield" +msgstr "Datenfeld bearbeiten" + +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit group" +msgstr "Gruppe bearbeiten" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit member" +msgstr "Mitglied bearbeiten" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit role" +msgstr "Rolle bearbeiten" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit user" +msgstr "Benutzer*in bearbeiten" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rolle bearbeiten" +msgstr "Rolle bearbeiten" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for custom field details" +msgstr "Klicke für Datenfeld-Details" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for datafield details" +msgstr "Klicke für Datenfeld-Details" + +#: lib/mv_web/live/group_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for group details" +msgstr "Klicke für Gruppen-Details" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for member details" +msgstr "Klicke für Mitglieds-Details" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for role details" +msgstr "Klicke für Rollen-Details" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for user details" +msgstr "Klicke für Benutzer*innen-Details" + +#~ #: lib/mv_web/live/member_field_live/form_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Back to Settings" +#~ msgstr "Zurück zu den Einstellungen" + +#~ #: lib/mv_web/live/member_live/form.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Coming soon" +#~ msgstr "Demnächst verfügbar" + +#~ #: lib/mv_web/live/components/member_filter_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Reset" +#~ msgstr "Zurücksetzen" + +#~ #: lib/mv_web/live/role_live/form.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Save Role" +#~ msgstr "Rolle speichern" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "You do not have permission to access this member" +#~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ea8e976..cea7991 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -12,13 +12,11 @@ msgid "" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex @@ -43,7 +41,6 @@ msgstr "" #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex @@ -51,15 +48,8 @@ msgstr "" msgid "Delete" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/group_live/index.ex -#: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -278,7 +268,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex -#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Cancel" @@ -791,19 +780,18 @@ msgstr "" msgid "Address" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/form.ex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -821,7 +809,6 @@ msgid "Payment Data" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "Payments" msgstr "" @@ -835,6 +822,7 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Save" msgstr "" @@ -852,11 +840,6 @@ msgstr "" msgid "Amount" msgstr "" -#: lib/mv_web/live/member_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1576,6 +1559,7 @@ msgid "Show/Hide Columns" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/member_field_live/form_component.ex #, elixir-autogen, elixir-format msgid "Back to settings" msgstr "" @@ -1631,11 +1615,6 @@ msgstr "" msgid "Custom" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format -msgid "Edit Role" -msgstr "" - #: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format @@ -1735,11 +1714,6 @@ msgstr "" msgid "Role saved successfully." msgstr "" -#: lib/mv_web/live/role_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save Role" -msgstr "" - #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Select permission set" @@ -1891,22 +1865,17 @@ msgstr "" msgid "Unknown error" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" -#: lib/mv_web/live/member_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this member" -msgstr "" - -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "" @@ -1991,11 +1960,6 @@ msgstr "" msgid "Payment Status" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Reset" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -3098,3 +3062,100 @@ msgstr "" #, elixir-autogen, elixir-format msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Clear filters" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Apply filters" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete %{name}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Danger zone" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete member %{name}" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Edit datafield" +msgstr "" + +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Edit group" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Edit member" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Edit role" +msgstr "" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Edit user" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rolle bearbeiten" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for custom field details" +msgstr "" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for datafield details" +msgstr "" + +#: lib/mv_web/live/group_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for group details" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for member details" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for role details" +msgstr "" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for user details" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 915fc52..9f38efe 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -12,13 +12,11 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/index.html.heex @@ -43,7 +41,6 @@ msgstr "" #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex @@ -51,15 +48,8 @@ msgstr "" msgid "Delete" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/group_live/index.ex -#: lib/mv_web/live/group_live/show.ex -#: lib/mv_web/live/member_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/role_live/form.ex -#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -278,7 +268,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex -#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Cancel" @@ -791,19 +780,18 @@ msgstr "" msgid "Address" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/show.ex +#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/role_live/form.ex +#: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "Back" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format @@ -821,7 +809,6 @@ msgid "Payment Data" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "Payments" msgstr "" @@ -835,6 +822,7 @@ msgstr "" #: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format, fuzzy msgid "Save" msgstr "" @@ -852,11 +840,6 @@ msgstr "" msgid "Amount" msgstr "" -#: lib/mv_web/live/member_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #, elixir-autogen, elixir-format @@ -1576,6 +1559,7 @@ msgid "Show/Hide Columns" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/member_field_live/form_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Back to settings" msgstr "" @@ -1631,11 +1615,6 @@ msgstr "" msgid "Custom" msgstr "" -#: lib/mv_web/live/role_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit Role" -msgstr "" - #: lib/mv_web/live/role_live/index.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -1735,11 +1714,6 @@ msgstr "" msgid "Role saved successfully." msgstr "" -#: lib/mv_web/live/role_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Save Role" -msgstr "" - #: lib/mv_web/live/role_live/form.ex #, elixir-autogen, elixir-format msgid "Select permission set" @@ -1891,22 +1865,17 @@ msgstr "" msgid "Unknown error" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" -#: lib/mv_web/live/member_live/index.ex -#, elixir-autogen, elixir-format -msgid "You do not have permission to access this member" -msgstr "" - -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "" @@ -1991,11 +1960,6 @@ msgstr "" msgid "Payment Status" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Reset" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -3098,3 +3062,125 @@ msgstr "" #, elixir-autogen, elixir-format msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Clear filters" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Apply filters" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Are you sure you want to delete %{name}? This action cannot be undone." +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Danger zone" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Delete member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Delete member %{name}" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format +msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit datafield" +msgstr "" + +#: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit group" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit member" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit role" +msgstr "" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit user" +msgstr "" + +#: lib/mv_web/live/role_live/show.ex +#, elixir-autogen, elixir-format +msgid "Rolle bearbeiten" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for custom field details" +msgstr "" + +#: lib/mv_web/live/member_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Click for datafield details" +msgstr "" + +#: lib/mv_web/live/group_live/index.ex +#, elixir-autogen, elixir-format +msgid "Click for group details" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for member details" +msgstr "" + +#: lib/mv_web/live/role_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for role details" +msgstr "" + +#: lib/mv_web/live/user_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click for user details" +msgstr "" + +#~ #: lib/mv_web/live/member_field_live/form_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Back to Settings" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/form.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Coming soon" +#~ msgstr "" + +#~ #: lib/mv_web/live/components/member_filter_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Reset" +#~ msgstr "" + +#~ #: lib/mv_web/live/role_live/form.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Save Role" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "You do not have permission to access this member" +#~ msgstr "" diff --git a/test/mv_web/components/core_components_table_test.exs b/test/mv_web/components/core_components_table_test.exs new file mode 100644 index 0000000..931b42a --- /dev/null +++ b/test/mv_web/components/core_components_table_test.exs @@ -0,0 +1,154 @@ +defmodule MvWeb.Components.CoreComponentsTableTest do + @moduledoc """ + Tests for the CoreComponents table: row hover/focus and selected styling. + """ + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias MvWeb.CoreComponents + + describe "table row_click styling" do + test "when row_click is set, table rows have hover and focus-within ring classes" do + rows = [%{id: "1", name: "Alice"}, %{id: "2", name: "Bob"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: fn _ -> nil end, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || item["name"] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ "hover:ring-2" + assert html =~ "focus-within:ring-2" + assert html =~ "hover:ring-base-content/10" + end + + test "when row_click is nil, table rows do not have hover ring classes" do + rows = [%{id: "1", name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: nil, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + refute html =~ "hover:ring-2" + refute html =~ "focus-within:ring-2" + end + end + + describe "table selected_row_id styling" do + test "when selected_row_id matches a row id, that row has data-selected and ring-primary" do + rows = [%{id: "one", name: "Alice"}, %{id: "two", name: "Bob"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: fn _ -> nil end, + selected_row_id: "two", + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + assert html =~ ~s(id="row-two") + assert html =~ ~s(data-selected="true") + assert html =~ "ring-primary" + end + + test "when selected_row_id is nil, no row has data-selected" do + rows = [%{id: "1", name: "Alice"}] + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: nil, + selected_row_id: nil, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + refute html =~ ~s(data-selected="true") + end + + test "when row_selected? is set, multiple rows can have data-selected and ring-primary" do + rows = [%{id: "a", name: "Alice"}, %{id: "b", name: "Bob"}, %{id: "c", name: "Claire"}] + selected_ids = MapSet.new(["a", "c"]) + + assigns = %{ + id: "test-table", + rows: rows, + row_id: fn r -> "row-#{r.id}" end, + row_click: fn _ -> nil end, + row_selected?: fn item -> MapSet.member?(selected_ids, item.id) end, + row_item: &Function.identity/1, + col: [ + %{ + __slot__: :col, + label: "Name", + inner_block: fn _socket, item -> [item[:name] || ""] end + } + ], + dynamic_cols: [], + action: [] + } + + html = render_component(&CoreComponents.table/1, assigns) + + # Two rows selected (a and c), one not (b) + assert html =~ ~s(id="row-a") + assert html =~ ~s(id="row-b") + assert html =~ ~s(id="row-c") + # data-selected appears twice (for row a and row c) + assert String.contains?(html, ~s(data-selected="true")) + assert html =~ "ring-primary" + end + end +end diff --git a/test/mv_web/live/member_live_authorization_test.exs b/test/mv_web/live/member_live_authorization_test.exs index 9a23019..c5db9d6 100644 --- a/test/mv_web/live/member_live_authorization_test.exs +++ b/test/mv_web/live/member_live_authorization_test.exs @@ -24,6 +24,7 @@ defmodule MvWeb.MemberLiveAuthorizationTest do {:ok, view, _html} = live(conn, "/members") + # Index table has no Edit/Delete per row (only sr-only Show link); ensure they are not present refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]") refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]") end @@ -31,17 +32,18 @@ defmodule MvWeb.MemberLiveAuthorizationTest do describe "Member Index - Kassenwart (normal_user)" do @tag role: :normal_user - test "sees New Member and Edit buttons", %{conn: conn} do + test "sees New Member and Show link in row", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") assert has_element?(view, "[data-testid=member-new]") - assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]") + # Index table action column has sr-only Show link only (Edit is on member show page) + assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]") end @tag role: :normal_user - test "does not see Delete button", %{conn: conn} do + test "does not see Delete button in table", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") @@ -52,14 +54,14 @@ defmodule MvWeb.MemberLiveAuthorizationTest do describe "Member Index - Admin" do @tag role: :admin - test "sees New Member, Edit and Delete buttons", %{conn: conn} do + test "sees New Member and Show link in row", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") assert has_element?(view, "[data-testid=member-new]") - assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]") - assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]") + # Index table action column has sr-only Show link only (Edit/Delete are on member show page) + assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]") end end diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs index bbd9159..add2fba 100644 --- a/test/mv_web/member_live/index_membership_fee_status_test.exs +++ b/test/mv_web/member_live/index_membership_fee_status_test.exs @@ -107,9 +107,9 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do {:ok, view, _html} = live(conn, "/members") - # Toggle to current cycle (use the button in the header, not the one in the column) + # Toggle to current cycle (use the button in the header) view - |> element("button[phx-click='toggle_cycle_view'].btn.gap-2") + |> element("[data-testid=toggle-cycle-view]") |> render_click() html = render(view) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index d8846ea..1c8328f 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -304,6 +304,44 @@ defmodule MvWeb.MemberLive.IndexTest do assert_redirect(view, ~p"/members/#{member}") end + describe "table row outline (hover and selected)" do + @describetag :ui + + test "clickable rows have hover and focus-within ring classes", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, _member} = + Mv.Membership.create_member( + %{first_name: "Hover", last_name: "Test", email: "hover@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # CoreComponents table adds hover and focus-within ring when row_click is set + assert html =~ "hover:ring-2" + assert html =~ "focus-within:ring-2" + assert html =~ "hover:ring-base-content/10" + end + + test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"}, + actor: system_actor + ) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?highlight=#{member.id}") + + # Outline is only for checkbox selection; highlight param does not set data-selected + refute has_element?(view, "tr#row-#{member.id}[data-selected='true']") + end + end + describe "copy_emails feature" do setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() -- 2.47.2 From e5a6003ace579d3d4154ae044ffe061bcf6832a9 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 25 Feb 2026 14:16:43 +0100 Subject: [PATCH 6/8] feat: sticky memberstable header --- DESIGN_DUIDELINES.md | 5 + lib/mv_web/components/core_components.ex | 23 +- lib/mv_web/live/member_live/index.html.heex | 589 ++++++++++---------- lib/mv_web/live/role_live/show.ex | 6 +- priv/gettext/de/LC_MESSAGES/default.po | 31 +- priv/gettext/default.pot | 21 +- priv/gettext/en/LC_MESSAGES/default.po | 31 +- test/mv_web/member_live/index_test.exs | 29 + 8 files changed, 411 insertions(+), 324 deletions(-) diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md index 98e43db..37428a3 100644 --- a/DESIGN_DUIDELINES.md +++ b/DESIGN_DUIDELINES.md @@ -272,6 +272,11 @@ Notes: - **MUST:** Truncate long values consistently (same max widths for name/email-like fields). - **MUST:** Tooltip reveals full value when truncated. +### 8.5 Loading/Lists/Tables: keep filters visible on desktop +- On **desktop (lg: breakpoint)** only: list pages with large datasets (e.g. Members overview) keep the page header and filter/search bar visible while the user scrolls. Only the table body scrolls inside a constrained area (`lg:max-h-[calc(100vh-)] lg:overflow-auto`). This preserves context and avoids losing filters when scrolling. +- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space. +- When the table is inside such a scroll container, use the CoreComponents table’s `sticky_header={true}` so the table’s `
{col[:label]} + <.live_component module={MvWeb.Components.SortHeaderComponent} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} @@ -760,7 +765,7 @@ defmodule MvWeb.CoreComponents do sort_order={@sort_order} /> + {gettext("Actions")}