diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 4d303c3..2f2516b 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 3ee5ede..24fe879 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,7 +176,7 @@ defmodule MvWeb.CoreComponents do """ else ~H""" - """ @@ -240,6 +278,42 @@ defmodule MvWeb.CoreComponents do defp badge_style_class("outline"), do: "badge-outline" defp badge_style_class(_), do: nil + @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. @@ -532,7 +606,7 @@ defmodule MvWeb.CoreComponents do {@rest} />{@label}* @@ -551,7 +625,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -580,7 +654,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -609,7 +683,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -654,17 +728,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)}

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

-
{render_slot(@actions)}
+
+ {render_slot(@actions)} +
""" end @@ -680,18 +763,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" @@ -703,6 +819,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 @@ -720,6 +841,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"""
@@ -727,12 +854,12 @@ defmodule MvWeb.CoreComponents do - - - + + <%= for member <- @group.members do %> + + + + <%= if can?(@current_user, :update, @group) do %> + + <% end %> + + <% end %> + +
{col[:label]} + <.live_component module={MvWeb.Components.SortHeaderComponent} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} @@ -742,15 +869,21 @@ defmodule MvWeb.CoreComponents do sort_order={@sort_order} /> + {gettext("Actions")}
+ <%= 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 95a3954..d3b42ed 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -65,11 +65,11 @@ defmodule MvWeb.Components.MemberFilterComponent do phx-key="Escape" phx-target={@myself} > - - + +
0} class="mb-4">
@@ -249,11 +249,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
- +
0} class="mb-2">
- {gettext("Custom Fields")} + {gettext("Individual datafields")}
- +
- - +
@@ -459,7 +461,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""" -
- +
+ <.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom"> + <.button + type="button" + variant="ghost" + aria-label={aria_sort(@field, @sort_field, @sort_order)} + class="select-none" + phx-click="sort" + phx-value-field={@field} + data-testid={@field} + > + {@label} + <%= if @sort_field == @field do %> + <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} /> + <% else %> + <.icon + name="hero-chevron-up-down" + class="opacity-40" + /> + <% end %> + +
""" end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index f89f767..aac67dc 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -24,11 +24,13 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<.button type="button" + variant="neutral" phx-click="cancel" phx-target={@myself} aria-label={gettext("Back to settings")} > - <.icon name="hero-arrow-left" class="w-4 h-4" /> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")}

{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")} @@ -96,8 +98,35 @@ defmodule MvWeb.CustomFieldLive.FormComponent do label={gettext("Show in overview")} /> + <%= if @custom_field do %> + <%!-- Danger zone: canonical pattern (same as member form) --%> +
+

+ {gettext("Danger zone")} +

+
+

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

+ <.button + type="button" + variant="danger" + phx-click="request_delete" + phx-target={@myself} + data-testid="custom-field-delete" + aria-label={gettext("Delete data field %{name}", name: @custom_field.name)} + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete data field")} + +
+
+ <% end %> +
- <.button type="button" 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"> @@ -168,6 +197,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do {:noreply, socket} end + @impl true + def handle_event("request_delete", _params, socket) do + if custom_field = socket.assigns[:custom_field] do + send(self(), {:open_delete_modal_for, custom_field}) + end + + {:noreply, socket} + end + defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do form = if custom_field do diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex index 500e92a..a9f921d 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -59,6 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) end } + row_tooltip={gettext("Click to edit datafield")} > <:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name} @@ -95,22 +96,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do {gettext("No")} - - <:action :let={{_id, custom_field}}> - <.link phx-click={ - JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) - }> - {gettext("Edit")} - - - - <:action :let={{_id, custom_field}}> - <.link phx-click={ - JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself) - }> - {gettext("Delete")} - -
@@ -164,17 +149,17 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do

@@ -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 f263b37..289d721 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")} @@ -183,18 +183,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")} @@ -361,20 +361,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} @@ -413,7 +414,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 %> -
- - - - - - - - - - - <%= for group <- @groups do %> - - - - - - - <% end %> - -
{gettext("Name")}{gettext("Description")}{gettext("Members")}{gettext("Actions")}
- {group.name} - - <%= if group.description do %> - {group.description} - <% else %> - - <% end %> - - <%= if group.member_count do %> - {group.member_count} - <% else %> - 0 - <% end %> - -
- <.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost"> - {gettext("View")} - - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> - <.link - navigate={~p"/groups/#{group.slug}/edit"} - class="btn btn-sm btn-ghost" - > - {gettext("Edit")} - - <% end %> -
-
-
- <% end %> """ end diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 0039a7b..7e802b8 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,51 +91,44 @@ 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")}

@@ -150,22 +149,26 @@ defmodule MvWeb.GroupLive.Show do
- <%= for member <- @selected_members do %> + <%= for member <- @selected_members do %> <.badge variant="primary" style="outline" class="flex items-center gap-1"> {MvWeb.Helpers.MemberHelpers.display_name(member)} - + <.tooltip content={gettext("Remove")} position="top"> + <.button + type="button" + variant="icon" + size="sm" + phx-click="remove_selected_member" + phx-value-member_id={member.id} + aria-label={ + gettext("Remove %{name}", + name: MvWeb.Helpers.MemberHelpers.display_name(member) + ) + } + class="p-0 h-4 w-4 min-h-0" + > + <.icon name="hero-x-mark" class="size-3" /> + + <% end %>
- - +
<% else %> <.button @@ -268,135 +273,164 @@ defmodule MvWeb.GroupLive.Show do <% end %> <%= if Enum.empty?(@group.members || []) do %> -

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

- <% else %> -
- - - - - - <%= if can?(@current_user, :update, @group) do %> - - <% end %> - - - - <%= for member <- @group.members do %> +

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

+ <% else %> +
+
{gettext("Name")}{gettext("Email")}{gettext("Actions")}
+ - - + + <%= if can?(@current_user, :update, @group) do %> - + <% end %> - <% end %> - -
- <.link - navigate={~p"/members/#{member.id}"} - class="link link-primary" - > - {MvWeb.Helpers.MemberHelpers.display_name(member)} - - - <%= if member.email do %> - - {member.email} - - <% else %> - - <% end %> - {gettext("Name")}{gettext("Email")} - - {gettext("Actions")}
-
- <% end %> + +
+ <.link + navigate={~p"/members/#{member.id}"} + class="link link-primary" + > + {MvWeb.Helpers.MemberHelpers.display_name(member)} + + + <%= if member.email do %> + + {member.email} + + <% else %> + + <% end %> + + <.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 %> +
- - <%!-- Delete Confirmation Modal --%> - <%= if assigns[:show_delete_modal] do %> - - - - <% end %> + + <% end %> + + <%!-- Delete Confirmation Modal --%> + <%= if assigns[:show_delete_modal] do %> + + + + <% 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 6f8a06d..d6f87b1 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) --%> +
+ +
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save")} - -
- - <%!-- Tab Navigation --%> -
- - -
- - <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.form_section title={gettext("Personal Data")}> -
- <%!-- 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]} - /> + + + <%= 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")}> -
-
- - - <%= 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 7ca2560..b5b8c6a 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,40 +90,53 @@ />
- <.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} @@ -400,26 +412,11 @@ <:action :let={member}>
- <.link navigate={~p"/members/#{member}"}>{gettext("Show")} + <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> + {gettext("Show")} +
- - <%= if can?(@current_user, :update, member) do %> - <.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit"> - {gettext("Edit")} - - <% end %> - - - <:action :let={member}> - <%= if can?(@current_user, :destroy, member) do %> - <.link - phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")} - data-confirm={gettext("Are you sure?")} - data-testid="member-delete" - > - {gettext("Delete")} - - <% end %> +
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 80ed353..c63ced5 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 --%> -
- - -
+ + +
- <%= if @active_tab == :contact do %> - <%!-- Contact Data Tab Content --%> - <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.section_box title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
- <.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" /> - <.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> -
+ <%= if @active_tab == :contact do %> + <%!-- Contact Data Tab Content --%> +
+ <%!-- Personal Data and Custom Fields Row --%> +
+ <%!-- Personal Data Section --%> +
+ <.section_box title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+ <.data_field + label={gettext("First Name")} + value={@member.first_name} + class="w-48" + /> + <.data_field + label={gettext("Last Name")} + value={@member.last_name} + class="w-48" + /> +
- <%!-- Address --%> -
- <.data_field label={gettext("Address")} value={format_address(@member)} /> -
+ <%!-- Address --%> +
+ <.data_field label={gettext("Address")} value={format_address(@member)} /> +
- <%!-- Email --%> -
- <.data_field label={gettext("Email")}> - - {@member.email} - - -
+ <%!-- Email --%> +
+ <.data_field label={gettext("Email")}> + + {@member.email} + + +
- <%!-- Membership Dates Row --%> -
- <.data_field - label={gettext("Join Date")} - value={format_date(@member.join_date)} - class="w-28" - /> - <.data_field - label={gettext("Exit Date")} - value={format_date(@member.exit_date)} - class="w-28" - /> -
+ <%!-- Membership Dates Row --%> +
+ <.data_field + label={gettext("Join Date")} + value={format_date(@member.join_date)} + class="w-28" + /> + <.data_field + label={gettext("Exit Date")} + value={format_date(@member.exit_date)} + class="w-28" + /> +
- <%!-- Linked User: only show when current user can see other users (e.g. admin). + <%!-- Linked User: only show when current user can see other users (e.g. admin). read_only cannot see linked user, so hide the section to avoid "No user linked" when a user is linked but not visible. --%> - <%= if can_access_page?(@current_user, "/users") do %> -
- <.data_field label={gettext("Linked User")}> - <%= if @member.user do %> - <.link - navigate={~p"/users/#{@member.user}"} - class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" - > - <.icon name="hero-user" class="size-4" /> - {@member.user.email} - + <%= if can_access_page?(@current_user, "/users") do %> +
+ <.data_field label={gettext("Linked User")}> + <%= if @member.user do %> + <.link + navigate={~p"/users/#{@member.user}"} + class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" + > + <.icon name="hero-user" class="size-4" /> + {@member.user.email} + + <% else %> + + {gettext("No user linked")} + + <% end %> + +
+ <% end %> + + <%!-- Groups (in Personal Data) --%> + <% groups = @member.groups || [] %> +
+ <.data_field label={gettext("Groups")}> + <%= if Enum.empty?(groups) do %> + {gettext("No groups")} + <% else %> +
+ <%= for group <- groups do %> + <.button + variant="outline" + size="sm" + navigate={~p"/groups/#{group.slug}"} + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
+ <% end %> + +
+ + <%!-- Notes --%> + <%= if @member.notes && String.trim(@member.notes) != "" do %> +
+ <.data_field label={gettext("Notes")}> +

{@member.notes}

+ +
+ <% end %> +
+ +
+ + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.section_box title={gettext("Custom Fields")}> +
+ <%= for custom_field <- @custom_fields do %> + <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> + <.data_field label={custom_field.name}> + {format_custom_field_value(cfv, custom_field.value_type)} + + <% end %> +
+ +
+ <% end %> +
+ + <%!-- Payment Data Section --%> +
+ <.section_box title={gettext("Payment Data")}> + <%= if @member.membership_fee_type do %> +
+ <.data_field + label={gettext("Type")} + value={@member.membership_fee_type.name} + class="min-w-32" + /> + <.data_field + label={gettext("Membership Fee")} + value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} + class="min-w-24" + /> + <.data_field + label={gettext("Payment Interval")} + value={ + MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval) + } + class="min-w-32" + /> + <.data_field label={gettext("Last Cycle")} class="min-w-32"> + <%= if @member.last_cycle_status do %> + <.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}> + {format_status_label(@member.last_cycle_status)} + <% else %> - {gettext("No user linked")} + <.badge variant="neutral">{gettext("No cycles")} + <% end %> + + <.data_field label={gettext("Current Cycle")} class="min-w-36"> + <%= if @member.current_cycle_status do %> + <.badge variant={ + MembershipFeeHelpers.status_variant(@member.current_cycle_status) + }> + {format_status_label(@member.current_cycle_status)} + + <% else %> + <.badge variant="neutral">{gettext("No cycles")} <% end %>
- <% end %> - - <%!-- Groups (in Personal Data) --%> - <% groups = @member.groups || [] %> -
- <.data_field label={gettext("Groups")}> - <%= if Enum.empty?(groups) do %> - {gettext("No groups")} - <% else %> -
- <%= for group <- groups do %> - <.link - navigate={~p"/groups/#{group.slug}"} - class="btn btn-xs btn-outline btn-primary" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> -
- <% end %> - -
- - <%!-- Notes --%> - <%= if @member.notes && String.trim(@member.notes) != "" do %> -
- <.data_field label={gettext("Notes")}> -

{@member.notes}

- + <% else %> +
+ {gettext("No membership fee type assigned")}
<% end %> -
- -
- - <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.section_box title={gettext("Custom Fields")}> -
- <%= for custom_field <- @custom_fields do %> - <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> - <.data_field label={custom_field.name}> - {format_custom_field_value(cfv, custom_field.value_type)} - - <% end %> -
- <% end %> -
+
+ <% end %> - <%!-- Payment Data Section --%> -
- <.section_box title={gettext("Payment Data")}> - <%= if @member.membership_fee_type do %> -
- <.data_field - label={gettext("Type")} - value={@member.membership_fee_type.name} - class="min-w-32" - /> - <.data_field - label={gettext("Membership Fee")} - value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} - class="min-w-24" - /> - <.data_field - label={gettext("Payment Interval")} - value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)} - class="min-w-32" - /> - <.data_field label={gettext("Last Cycle")} class="min-w-32"> - <%= if @member.last_cycle_status do %> - <.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}> - {format_status_label(@member.last_cycle_status)} - - <% else %> - <.badge variant="neutral">{gettext("No cycles")} - <% end %> - - <.data_field label={gettext("Current Cycle")} class="min-w-36"> - <%= if @member.current_cycle_status do %> - <.badge variant={ - MembershipFeeHelpers.status_variant(@member.current_cycle_status) - }> - {format_status_label(@member.current_cycle_status)} - - <% else %> - <.badge variant="neutral">{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 fa23e46..09a9ee1 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -66,14 +66,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
- +
<%= if @vereinfacht_receipts do %>
<.button :if={Enum.any?(@cycles) and @can_destroy_cycle} + variant="outline" + size="sm" phx-click="delete_all_cycles" phx-target={@myself} - class="btn btn-sm btn-error btn-outline" title={gettext("Delete all cycles")} > <.icon name="hero-trash" class="size-4" /> @@ -158,9 +160,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <.button :if={@member.membership_fee_type != nil and @can_create_cycle} + variant="primary" + size="sm" phx-click="open_create_cycle_modal" phx-target={@myself} - class="btn btn-sm btn-primary" title={gettext("Create a new cycle manually")} > <.icon name="hero-plus" class="size-4" /> @@ -257,17 +260,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %> <%= if @can_destroy_cycle do %> - + <% end %>
@@ -307,10 +311,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do />
@@ -332,17 +341,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do )} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}

@@ -383,20 +392,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do />
@@ -470,10 +479,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
@@ -546,7 +560,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))} @@ -605,7 +619,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))} @@ -633,7 +647,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 = @@ -689,7 +703,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, @@ -739,7 +753,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, @@ -778,7 +792,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 @@ -788,7 +802,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, @@ -934,7 +948,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, @@ -997,7 +1011,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 2298e58..8ced57d 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
- +
@@ -333,24 +333,27 @@ defmodule MvWeb.MembershipFeeSettingsLive do <:action :let={mft}> - <.link - navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} - class="btn btn-ghost btn-xs" - aria-label={gettext("Edit membership fee type")} - > - <.icon name="hero-pencil" class="size-4" /> - + <.tooltip content={gettext("Edit membership fee type")} position="left"> + <.button + variant="ghost" + size="sm" + navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} + aria-label={gettext("Edit membership fee type")} + > + <.icon name="hero-pencil" class="size-4" /> + + <:action :let={mft}> -
0} - class="tooltip tooltip-left" - data-tip={ + content={ gettext("Cannot delete - %{count} member(s) assigned", count: get_member_count(mft, @member_counts) ) } + position="left" > -
- + diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex index d8569e2..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 @@ -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 59828ca..5831841 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" > -
- + @@ -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 /> -
- - + + <%= for permission_set <- all_permission_sets() do %> + + <% end %> + + <%= if @form.errors[:permission_set_name] do %> + <%= for error <- List.wrap(@form.errors[:permission_set_name]) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

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

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

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

- <% end %> - <% end %> -
- -
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save Role")} - - <.button navigate={return_path(@return_to, @role)} type="button"> - {gettext("Cancel")} - +
@@ -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 2340655..0bdc226 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). @@ -37,83 +34,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 +74,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 d4925f6..1dc41c8 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")}>
@@ -53,46 +54,5 @@ <:col :let={role} label={gettext("Users")}> <.badge variant="neutral">{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 %> -
- -
- <% end %> - diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 5c8618a..6fe6c73 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 %> @@ -209,6 +210,37 @@ defmodule MvWeb.RoleLive.Show do + + <%!-- 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}

- + <% 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