diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 50c9eca..d4769f3 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`), **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.
+
---
## 1. Setup and Architectural Conventions
diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md
new file mode 100644
index 0000000..fc3acac
--- /dev/null
+++ b/DESIGN_DUIDELINES.md
@@ -0,0 +1,426 @@
+# 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.
+
+### 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>
+ <: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 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)
+
+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).
+- **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.
+
+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.
+
+### 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 `` 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)
+
+### 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.
+- **Status:** Not yet implemented. See [feature-roadmap](docs/feature-roadmap.md) → Flash: Auto-dismiss and consistency.
+
+### 9.3 Variants (unified)
+- Supported semantic variants: `info`, `success`, `warning`, `error`.
+- **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).
+- 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.
+
+---
+
+## 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/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 21e3546..85c26c7 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="pointer-events-auto"
{@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 :rest, :global, include: ~w(href navigate patch method data-testid form)
+
+ 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"""
-
+
{render_slot(@inner_block)}
"""
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}*
@@ -559,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)}
@@ -577,7 +658,9 @@ defmodule MvWeb.CoreComponents do
{render_slot(@subtitle)}
- {render_slot(@actions)}
+
+ {render_slot(@actions)}
+
"""
end
@@ -585,18 +668,51 @@ 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.
+ 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.
+
## Examples
<.table id="users" rows={@users}>
<: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"
@@ -608,6 +724,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
@@ -625,6 +746,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"""
@@ -632,12 +759,12 @@ defmodule MvWeb.CoreComponents do
{col[:label]}
-
+
-
+
-
+
+ <%= if col_idx == 0 && @row_click && @row_tooltip do %>
+ {@row_tooltip}
+ <% end %>
{render_slot(col, @row_item.(row))}
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/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 89e3549..79983c5 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -115,7 +115,11 @@ 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/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/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 ef6f32e..4ee72d3 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}
>
-
0 ||
active_boolean_filters_count(@boolean_filters) > 0) &&
"btn-active"
@@ -104,7 +104,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
>
{@member_count}
-
+
0} class="mb-2">
- {gettext("Custom Fields")}
+ {gettext("Individual datafields")}
-
- {gettext("Reset")}
-
-
+ <.button
type="button"
+ variant="primary"
+ size="sm"
phx-click="close_dropdown"
phx-target={@myself}
- class="btn btn-primary btn-sm"
>
{gettext("Close")}
-
+
@@ -458,7 +460,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
boolean_filter_label(boolean_custom_fields, boolean_filters)
true ->
- gettext("All")
+ gettext("Apply filters")
end
end
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"""
-
@@ -222,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 f7436ab..0fc4c3c 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
@@ -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()
@@ -115,7 +126,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 752c8d6..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")}
@@ -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")}
@@ -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 0ffba09..2e79a7f 100644
--- a/lib/mv_web/live/group_live/form.ex
+++ b/lib/mv_web/live/group_live/form.ex
@@ -78,30 +78,56 @@ 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>
+ <: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")}
+
+
+
-
- {@page_title}
-
+
+
+ <.input field={@form[:name]} label={gettext("Name")} required />
+ <.input
+ field={@form[:description]}
+ type="textarea"
+ label={gettext("Description")}
+ rows="4"
+ />
+
- <.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"
- />
+ <%!-- 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 %>
@@ -129,7 +155,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 deab7e1..ff22b91 100644
--- a/lib/mv_web/live/group_live/index.ex
+++ b/lib/mv_web/live/group_live/index.ex
@@ -39,72 +39,47 @@ 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}
+ row_tooltip={gettext("Click for group details")}
+ >
+ <: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}
+
+
<% end %>
-
- <%= if Enum.empty?(@groups) do %>
-
-
{gettext("No groups")}
-
- <% else %>
-
-
-
-
- {gettext("Name")}
- {gettext("Description")}
- {gettext("Members")}
- {gettext("Actions")}
-
-
-
- <%= for group <- @groups do %>
-
-
- {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 %>
"""
end
diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex
index 0c7e93e..dbc0523 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,
@@ -85,318 +91,346 @@ 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>
+ <:leading>
+ <.button
+ navigate={~p"/groups"}
+ variant="neutral"
+ aria-label={gettext("Back to groups list")}
+ >
+ <.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")}
+ <.icon name="hero-pencil-square" /> {gettext("Edit group")}
<% end %>
- <%= if can?(@current_user, :destroy, @group) do %>
- <.button
- class="btn-error"
- 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 %>
-
-
-
- <.icon name="hero-plus" class="size-5" />
-
-
- {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")}
+
+
{gettext("Members")}
+
+
+ {ngettext(
+ "Total: %{count} member",
+ "Total: %{count} members",
+ @group.member_count || 0,
+ count: @group.member_count || 0
+ )}
- <% else %>
-
-
-
-
- {gettext("Name")}
- {gettext("Email")}
- <%= if can?(@current_user, :update, @group) do %>
- {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 %>
-
+
+ <%= if assigns[:show_add_member_input] 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")}
<%= if can?(@current_user, :update, @group) do %>
-
-
- <.icon name="hero-trash" class="size-4" />
-
-
+ {gettext("Actions")}
<% end %>
- <% end %>
-
-
-
- <% 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 %>
-
-
-
{gettext("Delete Group")}
-
- {gettext("Are you sure you want to delete this group? This action cannot be undone.")}
-
- <%= if @group.member_count && @group.member_count > 0 do %>
-
- <.icon name="hero-exclamation-triangle" class="size-5" />
-
- {ngettext(
- "This group has %{count} member. All member-group associations will be permanently deleted.",
- "This group has %{count} members. All member-group associations will be permanently deleted.",
- @group.member_count,
- count: @group.member_count
- )}
-
-
- <% end %>
-
-
-
- {gettext("To confirm deletion, please enter the group name:")}
-
-
-
- {@group.name}
-
-
-
-
-
+ <%= 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"
- class="btn"
- phx-click="cancel_delete"
- aria-label={gettext("Cancel")}
+ phx-click="open_delete_modal"
+ data-testid="group-show-delete-btn"
+ aria-label={gettext("Delete group %{name}", name: @group.name)}
>
- {gettext("Cancel")}
-
-
- {gettext("Delete")}
-
+ <.icon name="hero-trash" class="size-4" />
+ {gettext("Delete group")}
+
-
-
- <% end %>
+
+ <% end %>
+
+ <%!-- Delete Confirmation Modal --%>
+ <%= if assigns[:show_delete_modal] do %>
+
+
+
{gettext("Delete Group")}
+
+ {gettext("Are you sure you want to delete this group? This action cannot be undone.")}
+
+ <%= if @group.member_count && @group.member_count > 0 do %>
+
+ <.icon name="hero-exclamation-triangle" class="size-5" />
+
+ {ngettext(
+ "This group has %{count} member. All member-group associations will be permanently deleted.",
+ "This group has %{count} members. All member-group associations will be permanently deleted.",
+ @group.member_count,
+ count: @group.member_count
+ )}
+
+
+ <% end %>
+
+
+
+ {gettext("To confirm deletion, please enter the group name:")}
+
+
+
+ {@group.name}
+
+
+
+
+ <.button
+ type="button"
+ variant="neutral"
+ phx-click="cancel_delete"
+ aria-label={gettext("Cancel")}
+ >
+ {gettext("Cancel")}
+
+ <.button
+ type="button"
+ variant="danger"
+ phx-click="confirm_delete"
+ phx-value-slug={@group.slug}
+ disabled={(@name_confirmation || "") != @group.name}
+ aria-label={gettext("Delete group")}
+ >
+ {gettext("Delete")}
+
+
+
+
+ <% end %>
+
"""
end
@@ -900,7 +934,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_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..28384b5 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,12 @@ 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
+ }
+ row_tooltip={gettext("Click to edit datafield")}
>
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)}
@@ -86,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")}
-
-
"""
diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex
index 81db0fe..c98feb9 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
@@ -21,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
@@ -38,222 +38,248 @@ 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>
+ <: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 phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
+ {gettext("Save")}
+
+
+
-
- <%= if @member do %>
- {MvWeb.Helpers.MemberHelpers.display_name(@member)}
- <% else %>
- {gettext("New Member")}
- <% end %>
-
+
+ <%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
+
+
+ <.icon name="hero-identification" class="size-4 mr-2" />
+ {gettext("Contact Data")}
+
+
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
- {gettext("Save")}
-
-
-
- <%!-- Tab Navigation --%>
-
-
- <.icon name="hero-identification" class="size-4 mr-2" />
- {gettext("Contact Data")}
-
-
- <.icon name="hero-credit-card" class="size-4 mr-2" />
- {gettext("Payments")}
-
-
-
- <%!-- 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]}
- />
+ <%!-- 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]}
+ />
+
-
- <.input
- field={@form[:last_name]}
- label={gettext("Last Name")}
- required={@member_field_required_map[:last_name]}
- />
-
-
- <%!-- Address: Country, Postal Code, City in one row --%>
-
-
- <.input field={@form[:country]} label={gettext("Country")} />
+ <%!-- Address: Country, Postal Code, City in one row --%>
+
+
+ <.input field={@form[:country]} label={gettext("Country")} />
+
+
+ <.input
+ field={@form[:postal_code]}
+ label={gettext("Postal Code")}
+ required={@member_field_required_map[:postal_code]}
+ />
+
+
+ <.input field={@form[:city]} label={gettext("City")} />
+
-
- <.input
- field={@form[:postal_code]}
- label={gettext("Postal Code")}
- required={@member_field_required_map[:postal_code]}
- />
-
-
- <.input field={@form[:city]} label={gettext("City")} />
-
-
- <%!-- Street and Nr. below --%>
-
+ <%!-- Street and Nr. below --%>
+
+
+ <.input field={@form[:street]} label={gettext("Street")} />
+
+
+ <.input field={@form[:house_number]} label={gettext("Nr.")} />
+
+
+
+ <%!-- Email --%>
- <.input field={@form[:street]} label={gettext("Street")} />
+ <.input field={@form[:email]} label={gettext("Email")} required type="email" />
-
- <.input field={@form[:house_number]} label={gettext("Nr.")} />
-
-
- <%!-- Email --%>
-
- <.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]}
- />
+ <%!-- 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[:exit_date]}
- label={gettext("Exit Date")}
- type="date"
- required={@member_field_required_map[:exit_date]}
+ field={@form[:notes]}
+ label={gettext("Notes")}
+ type="textarea"
+ required={@member_field_required_map[:notes]}
/>
+
+
- <%!-- 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 %>
+
+
+ <%!-- Membership Fee Section --%>
+
+ <.form_section title={gettext("Membership Fee")}>
+
- <.input
- field={@form[:notes]}
- label={gettext("Notes")}
- type="textarea"
- required={@member_field_required_map[:notes]}
- />
+
+ {gettext("Membership Fee Type")}
+
+
+ <%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
+ {gettext("Select a membership fee type")}
+ <%= for fee_type <- @available_fee_types do %>
+
+ {fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
+ fee_type.interval
+ )})
+
+ <% end %>
+
+ <%= 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 %>
-
+ <%!-- 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")}
+
+
- <%!-- Membership Fee Section --%>
-
- <.form_section title={gettext("Membership Fee")}>
-
-
-
- {gettext("Membership Fee Type")}
-
-
- <%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
- {gettext("Select a membership fee type")}
- <%= for fee_type <- @available_fee_types do %>
-
- {fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
- fee_type.interval
- )})
-
- <% end %>
-
- <%= 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 %>
-
+ <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
+ <%= if @member && can?(@current_user, :destroy, @member) do %>
+
+
+ {gettext("Danger zone")}
+
+
+
{gettext(
- "Select a membership fee type for this member. Members can only switch between types with the same interval."
+ "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")}
+
-
-
-
-
- <%!-- 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")}
-
+
+ <% end %>
@@ -374,6 +400,41 @@ 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)
+
+ cond do
+ is_nil(member) ->
+ {:noreply, put_flash(socket, :error, gettext("Member not found"))}
+
+ to_string(id) != to_string(member.id) ->
+ {:noreply, put_flash(socket, :error, gettext("Member not found"))}
+
+ 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
+
defp handle_save_success(socket, member) do
notify_parent({:saved, member})
@@ -386,7 +447,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))
@@ -421,6 +482,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/index.ex b/lib/mv_web/live/member_live/index.ex
index 3283b5c..1be35b4 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
@@ -123,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)
@@ -157,48 +157,14 @@ 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(:info, 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
+ 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
@@ -343,22 +309,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
# -----------------------------------------------------------------
@@ -656,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)
@@ -855,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 da9c3d5..af2863f 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,12 @@
boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>
-
-
+
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
@@ -91,334 +90,329 @@
/>
- <.table
- id="members"
- rows={@members}
- row_id={fn member -> "row-#{member.id}" end}
- row_click={fn member -> JS.navigate(~p"/members/#{member}") 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={:country in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_country}
- field={:country}
- label={gettext("Country")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.country}
-
- <:col
- :let={member}
- :if={:city in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_city}
- field={:city}
- label={gettext("City")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {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={:country in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_country}
+ field={:country}
+ label={gettext("Country")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.country}
+
+ <:col
+ :let={member}
+ :if={:city in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_city}
+ field={:city}
+ label={gettext("City")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {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}"}>{gettext("Show")}
-
-
- <%= if can?(@current_user, :update, member) do %>
- <.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
- {gettext("Edit")}
-
- <% 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 %>
-
-
+
+ <.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/member_live/show.ex b/lib/mv_web/live/member_live/show.ex
index c05da63..abe8453 100644
--- a/lib/mv_web/live/member_live/show.ex
+++ b/lib/mv_web/live/member_live/show.ex
@@ -30,235 +30,311 @@ 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>
+ <:leading>
<.button
- variant="primary"
- navigate={~p"/members/#{@member}/edit?return_to=show"}
- data-testid="member-edit"
+ navigate={~p"/members?highlight=#{@member.id}"}
+ variant="neutral"
+ aria-label={gettext("Back to members list")}
>
- {gettext("Edit Member")}
+ <.icon name="hero-arrow-left" class="size-4" />
+ {gettext("Back")}
- <% end %>
-
+
+ {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"
+ >
+ <.icon name="hero-pencil-square" /> {gettext("Edit member")}
+
+ <% end %>
+
+
- <%!-- Tab Navigation --%>
-
-
+ <%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
+
- <.icon name="hero-identification" class="size-4 mr-2" />
- {gettext("Contact Data")}
-
-
- <.icon name="hero-credit-card" class="size-4 mr-2" />
- {gettext("Membership Fees")}
-
-
+
+ <.icon name="hero-identification" class="size-4 shrink-0" />
+ {gettext("Contact Data")}
+
+
+ <.icon name="hero-credit-card" class="size-4 shrink-0" />
+ {gettext("Membership Fees")}
+
+
- <%= 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 --%>
+
+ <% 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 %>
- <%= 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 %>
+ <%!-- 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 %>
+
"""
end
@@ -320,6 +396,37 @@ 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} ->
+ require Logger
+ Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(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
@@ -350,6 +457,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
# -----------------------------------------------------------------
@@ -403,7 +523,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..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
@@ -66,14 +66,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
-
{gettext("Show bookings/receipts from Vereinfacht")}
-
+
<%= 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 %>
-
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
-
+
<% end %>
@@ -309,10 +313,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
-
+ <.button
+ type="button"
+ variant="neutral"
+ phx-click="cancel_edit_amount"
+ phx-target={@myself}
+ >
{gettext("Cancel")}
-
- {gettext("Save")}
+
+ <.button type="submit" variant="primary">{gettext("Save")}
@@ -334,17 +343,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
-
+ <.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
{gettext("Cancel")}
-
-
+ <.button
+ variant="danger"
phx-click="confirm_delete_cycle"
phx-value-cycle_id={@deleting_cycle.id}
phx-target={@myself}
- class="btn btn-error"
>
{gettext("Delete")}
-
+
@@ -385,20 +394,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
-
+ <.button variant="neutral" phx-click="cancel_delete_all_cycles" phx-target={@myself}>
{gettext("Cancel")}
-
-
+ <.button
+ variant="danger"
phx-click="confirm_delete_all_cycles"
phx-target={@myself}
- class="btn btn-error"
disabled={
String.trim(String.downcase(@delete_all_confirmation)) !=
String.downcase(gettext("Yes"))
}
>
{gettext("Delete All")}
-
+
@@ -472,10 +481,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
-
+ <.button
+ type="button"
+ variant="neutral"
+ phx-click="cancel_create_cycle"
+ phx-target={@myself}
+ >
{gettext("Cancel")}
-
- {gettext("Create")}
+
+ <.button type="submit" variant="primary">{gettext("Create")}
@@ -548,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))}
@@ -607,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))}
@@ -635,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 =
@@ -691,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,
@@ -741,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,
@@ -780,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
@@ -790,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,
@@ -936,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,
@@ -999,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 bfa20f8..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,
@@ -239,10 +239,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
-
+ <.button type="submit" variant="primary" class="w-full">
<.icon name="hero-check" class="size-5" />
{gettext("Save Settings")}
-
+
@@ -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"
>
<.icon name="hero-trash" class="size-4" />
-
-
+ <.button
:if={get_member_count(mft, @member_counts) == 0}
+ variant="danger"
+ size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
- class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
-
+
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..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
@@ -176,20 +192,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
-
{gettext("Cancel")}
-
-
+ <.button
type="button"
+ variant="primary"
phx-click="confirm_amount_change"
- class="btn btn-primary"
>
{gettext("Confirm Change")}
-
+
@@ -317,7 +333,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 f5f760f..ee3b791 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"
>
<.icon name="hero-trash" class="size-4" />
-
-
+ <.button
:if={get_member_count(mft, @member_counts) == 0}
+ variant="danger"
+ size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
- class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
-
+
@@ -145,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 ea76fe8..e066555 100644
--- a/lib/mv_web/live/role_live/form.ex
+++ b/lib/mv_web/live/role_live/form.ex
@@ -21,66 +21,70 @@ 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>
+ <: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")}
+
+
+
- <.input
- field={@form[:description]}
- type="textarea"
- label={gettext("Description")}
- rows="3"
- />
+
+ <.input field={@form[:name]} type="text" label={gettext("Name")} required />
-
@@ -175,7 +179,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..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(:info, 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 f409944..43f2fc7 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")}>
@@ -52,46 +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 %>
- <.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm">
- <.icon name="hero-pencil" class="size-4" />
- {gettext("Edit")}
-
- <% end %>
-
-
- <:action :let={role}>
- <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
- <.link
- 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 %>
-
-
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete")}
-
-
- <% end %>
-
diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex
index 0e1c7ca..4f36eca 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} ->
@@ -161,27 +161,28 @@ defmodule MvWeb.RoleLive.Show do
~H"""
<.header>
+ <:leading>
+ <.button
+ navigate={~p"/admin/roles"}
+ variant="neutral"
+ aria-label={gettext("Back to roles list")}
+ >
+ <.icon name="hero-arrow-left" class="size-4" />
+ {gettext("Back")}
+
+
{gettext("Role")} {@role.name}
<: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")}
-
<%= 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")}
-
- <% end %>
- <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
- <.link
- phx-click={JS.push("delete", value: %{id: @role.id})}
- data-confirm={gettext("Are you sure?")}
- class="btn btn-error"
+ <.button
+ variant="primary"
+ navigate={~p"/admin/roles/#{@role}/edit"}
+ data-testid="role-show-edit-btn"
>
- <.icon name="hero-trash" /> {gettext("Delete Role")}
-
+ <.icon name="hero-pencil-square" /> {gettext("Edit role")}
+
<% end %>
@@ -208,6 +209,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 46e23b3..f7c440d 100644
--- a/lib/mv_web/live/user_live/form.ex
+++ b/lib/mv_web/live/user_live/form.ex
@@ -39,14 +39,31 @@ 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
~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">
@@ -167,13 +184,14 @@ defmodule MvWeb.UserLive.Form do
{@user.member.email}
-
{gettext("Unlink Member")}
-
+
<% else %>
@@ -280,11 +298,46 @@ 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")}
+
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
- <.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}
@@ -401,6 +454,26 @@ 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)
+
+ cond do
+ is_nil(user) ->
+ {:noreply, put_flash(socket, :error, gettext("User not found"))}
+
+ 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
+
@impl true
def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)}
@@ -511,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)
@@ -553,7 +643,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..4858202 100644
--- a/lib/mv_web/live/user_live/index.ex
+++ b/lib/mv_web/live/user_live/index.ex
@@ -5,18 +5,12 @@ 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
- - Bulk selection for future batch operations
+ - Navigate to user details (row click) and edit from details header
+ - Delete only via Danger zone on user show/edit
## 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
-
## Security
User deletion requires admin permissions (enforced by Ash policies).
"""
@@ -26,7 +20,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
@@ -44,63 +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
-
- @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(:info, 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
- # 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
@@ -127,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 ab13f90..86f0ab7 100644
--- a/lib/mv_web/live/user_live/index.html.heex
+++ b/lib/mv_web/live/user_live/index.html.heex
@@ -15,36 +15,10 @@
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}
>
- <: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}
@@ -83,29 +57,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")}
-
- <% 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 4d803cd..d7a12b2 100644
--- a/lib/mv_web/live/user_live/show.ex
+++ b/lib/mv_web/live/user_live/show.ex
@@ -27,20 +27,27 @@ 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
~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"} 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"
@@ -80,6 +87,38 @@ 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 +142,38 @@ 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)
+
+ 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,
+ 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
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 3dc41b2..2f3a1f8 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -11,18 +11,13 @@ 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
-#: 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?"
@@ -40,25 +35,14 @@ 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/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
#, elixir-autogen, elixir-format
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"
@@ -110,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"
@@ -277,7 +259,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"
@@ -491,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"
@@ -616,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
@@ -635,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"
@@ -673,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"
@@ -790,19 +754,21 @@ 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/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"
-#: 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 +786,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 +799,8 @@ 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"
msgstr "Speichern"
@@ -851,11 +818,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
@@ -981,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"
@@ -1575,6 +1532,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"
@@ -1620,22 +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/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
msgid "Failed to delete role: %{error}"
@@ -1652,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."
@@ -1663,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"
@@ -1711,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."
@@ -1723,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,11 +1673,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"
@@ -1760,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."
@@ -1842,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"
@@ -1858,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"
@@ -1890,22 +1816,20 @@ msgstr "aktualisiert"
msgid "Unknown error"
msgstr "Unbekannter Fehler"
-#: lib/mv_web/live/member_live/index.ex
+#: 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/index.ex
+#: 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/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/form.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 +1914,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})"
@@ -2161,6 +2080,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"
@@ -2241,11 +2161,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"
@@ -3120,3 +3035,278 @@ 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/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/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/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Edit role"
+msgstr "Rolle bearbeiten"
+
+#: 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_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"
+#~ 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"
+#~ 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 41cc407..7413a20 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -12,18 +12,13 @@ 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
-#: 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 ""
@@ -41,25 +36,14 @@ 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/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
#, elixir-autogen, elixir-format
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 ""
@@ -111,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 ""
@@ -278,7 +260,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"
@@ -492,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"
@@ -617,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
@@ -636,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"
@@ -674,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"
@@ -791,19 +755,21 @@ 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/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 ""
-#: 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 +787,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 +800,8 @@ 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"
msgstr ""
@@ -852,11 +819,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
@@ -982,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"
@@ -1576,6 +1533,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 ""
@@ -1621,22 +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/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
msgid "Failed to delete role: %{error}"
@@ -1653,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."
@@ -1664,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"
@@ -1712,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."
@@ -1724,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,11 +1674,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"
@@ -1761,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."
@@ -1843,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 ""
@@ -1859,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 ""
@@ -1891,22 +1817,20 @@ msgstr ""
msgid "Unknown error"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex
+#: 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/index.ex
+#: 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/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/form.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 +1915,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})"
@@ -2162,6 +2081,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"
@@ -2242,11 +2162,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"
@@ -3115,3 +3030,192 @@ 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/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/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/show.ex
+#, elixir-autogen, elixir-format
+msgid "Edit role"
+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_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 8d466ac..51aab4f 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -12,18 +12,13 @@ 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
-#: 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 ""
@@ -41,25 +36,14 @@ 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/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
#, elixir-autogen, elixir-format
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 ""
@@ -111,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 ""
@@ -278,7 +260,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"
@@ -492,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"
@@ -617,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
@@ -636,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"
@@ -674,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"
@@ -791,19 +755,21 @@ 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/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 ""
-#: 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 +787,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 +800,8 @@ 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"
msgstr ""
@@ -852,11 +819,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
@@ -982,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"
@@ -1576,6 +1533,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 ""
@@ -1621,22 +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/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
msgid "Failed to delete role: %{error}"
@@ -1653,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."
@@ -1664,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"
@@ -1712,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."
@@ -1724,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,11 +1674,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"
@@ -1761,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."
@@ -1843,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 ""
@@ -1859,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 ""
@@ -1891,22 +1817,20 @@ msgstr ""
msgid "Unknown error"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex
+#: 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/index.ex
+#: 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/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/form.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 +1915,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})"
@@ -2162,6 +2081,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"
@@ -2242,11 +2162,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"
@@ -3115,3 +3030,278 @@ 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/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/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/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Edit role"
+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_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"
+#~ 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"
+#~ 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/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/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/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/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/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs
index d61d3fd..0ec142e 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,85 @@ 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 "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
+ 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_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 5246d80..ec35f4d 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
@@ -267,36 +296,80 @@ 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")
+ assert_redirect(view, ~p"/members/#{member}")
+ end
- # Verify the member was actually deleted from the database
- assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
+ 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
diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs
index 26c3f00..54829de 100644
--- a/test/mv_web/member_live/show_test.exs
+++ b/test/mv_web/member_live/show_test.exs
@@ -134,6 +134,37 @@ defmodule MvWeb.MemberLive.ShowTest do
end
end
+ describe "delete action" do
+ 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}")
+
+ 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", %{
+ 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} =
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