diff --git a/.drone.yml b/.drone.yml index 335fe3b..dc2aaae 100644 --- a/.drone.yml +++ b/.drone.yml @@ -273,7 +273,7 @@ environment: steps: - name: renovate - image: renovate/renovate:43.39 + image: renovate/renovate:43.35 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index d4769f3..50c9eca 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -60,9 +60,6 @@ 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 deleted file mode 100644 index fc3acac..0000000 --- a/DESIGN_DUIDELINES.md +++ /dev/null @@ -1,426 +0,0 @@ -# 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 66b46eb..b699560 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -191,11 +191,6 @@ - ❌ 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 85c26c7..21e3546 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="pointer-events-auto" + class="z-50 toast toast-top toast-end" {@rest} >
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> @@ -90,71 +90,33 @@ 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"/"} variant="secondary">Home - <.button variant="ghost" size="sm">Edit + <.button navigate={~p"/"}>Home <.button disabled={true}>Disabled """ - 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 :rest, :global, include: ~w(href navigate patch method data-testid) + attr :variant, :string, values: ~w(primary) attr :disabled, :boolean, default: false, doc: "Whether the button is disabled" slot :inner_block, required: true def button(assigns) do rest = assigns.rest - 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) + variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} + assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) 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", btn_class, "btn-disabled"], - else: ["btn", btn_class] + do: ["btn", assigns.class, "btn-disabled"], + else: ["btn", assigns.class] + # Prevent interaction when disabled + # Remove navigation attributes to prevent "Open in new tab", "Copy link" etc. link_attrs = if assigns[:disabled] do rest @@ -176,49 +138,13 @@ defmodule MvWeb.CoreComponents do """ else ~H""" - """ end end - @doc """ - Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content, - or status badges that need explanation (Design Guidelines §8.2). - - ## Examples - - <.tooltip content={gettext("Edit")}> - <.button variant="icon" size="sm"><.icon name="hero-pencil" /> - - - <.tooltip content={@full_name} position="top"> - {@full_name} - - """ - attr :content, :string, required: true, doc: "Tooltip text (data-tip)" - - attr :position, :string, - values: ~w(top bottom left right), - default: "bottom" - - attr :wrap_class, :string, default: nil, doc: "Additional classes for the wrapper" - slot :inner_block, required: true - - def tooltip(assigns) do - position_class = "tooltip tooltip-#{assigns.position}" - wrap_class = [position_class, assigns.wrap_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ") - - assigns = assign(assigns, :wrap_class, wrap_class) - - ~H""" -
- {render_slot(@inner_block)} -
- """ - end - @doc """ Renders a dropdown menu. @@ -511,7 +437,7 @@ defmodule MvWeb.CoreComponents do {@rest} />{@label}* @@ -530,7 +456,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -559,7 +485,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -588,7 +514,7 @@ defmodule MvWeb.CoreComponents do {@label}* @@ -633,24 +559,17 @@ 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)}

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

-
- {render_slot(@actions)} -
+
{render_slot(@actions)}
""" end @@ -668,51 +585,18 @@ 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" @@ -724,11 +608,6 @@ 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 @@ -746,12 +625,6 @@ 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"""
@@ -759,12 +632,12 @@ defmodule MvWeb.CoreComponents do - - - +
{col[:label]} + <.live_component module={MvWeb.Components.SortHeaderComponent} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} @@ -774,21 +647,15 @@ 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 79983c5..89e3549 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -115,11 +115,7 @@ defmodule MvWeb.Layouts do def flash_group(assigns) do ~H""" -
+
<.flash kind={:success} flash={@flash} /> <.flash kind={:warning} flash={@flash} /> <.flash kind={:info} flash={@flash} /> diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index e107d5b..a47fcc7 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 20a76f5..28f3846 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(:success, message) + |> put_flash(:info, message) |> redirect(to: return_to) end @@ -322,7 +322,7 @@ defmodule MvWeb.AuthController do conn |> clear_session(:mv) - |> put_flash(:success, gettext("You are now signed out")) + |> put_flash(:info, 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 01bd57b..b6c24b1 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( - :success, + :info, 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( - :success, + :info, 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 58777da..a8e8d45 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("Datafield %{id}", id: id) + nil -> gettext("Custom Field %{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 4ee72d3..ef6f32e 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -64,11 +64,11 @@ defmodule MvWeb.Components.MemberFilterComponent do phx-key="Escape" phx-target={@myself} > - <.button +
0} class="mb-2">
- {gettext("Individual datafields")} + {gettext("Custom Fields")}
- <.button + +
@@ -460,7 +458,7 @@ defmodule MvWeb.Components.MemberFilterComponent do boolean_filter_label(boolean_custom_fields, boolean_filters) true -> - gettext("Apply filters") + gettext("All") 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 c4850c4..d548efa 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -19,28 +19,25 @@ 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 aac67dc..f89f767 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -24,13 +24,11 @@ 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="size-4" /> - {gettext("Back")} + <.icon name="hero-arrow-left" class="w-4 h-4" />

{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")} @@ -98,35 +96,8 @@ 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" variant="neutral" phx-click="cancel" phx-target={@myself}> + <.button type="button" phx-click="cancel" phx-target={@myself}> {gettext("Cancel")} <.button phx-disable-with={gettext("Saving...")} variant="primary"> @@ -197,15 +168,6 @@ 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 3b70c3d..a670a3e 100644 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ b/lib/mv_web/live/custom_field_live/index_component.ex @@ -59,7 +59,6 @@ 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} @@ -96,6 +95,22 @@ 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")} + +
@@ -149,17 +164,17 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do

@@ -207,38 +222,16 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do # Get actor from assigns or fall back to socket assigns actor = Map.get(assigns, :actor, socket.assigns[:actor]) - 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} + {: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)} end @impl true diff --git a/lib/mv_web/live/datafields_live.ex b/lib/mv_web/live/datafields_live.ex index 0fc4c3c..f7436ab 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(:success, gettext("Data field %{action} successfully", action: action))} + |> put_flash(:info, gettext("Data field %{action} successfully", action: action))} end @impl true def handle_info({:custom_field_deleted, _custom_field}, socket) do - {:noreply, put_flash(socket, :success, gettext("Data field deleted successfully"))} + {:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))} end @impl true @@ -101,17 +101,6 @@ 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() @@ -126,7 +115,7 @@ defmodule MvWeb.DatafieldsLive do socket |> assign(:settings, updated_settings) |> assign(:active_editing_section, nil) - |> put_flash(:success, gettext("Member field %{action} successfully", action: action))} + |> put_flash(:info, 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 bb8eb32..752c8d6 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 Name")} + {gettext("Save Settings")} @@ -181,18 +181,18 @@ defmodule MvWeb.GlobalSettingsLive do <.button :if={Mv.Config.vereinfacht_configured?()} type="button" - variant="outline" phx-click="test_vereinfacht_connection" phx-disable-with={gettext("Testing...")} + class="btn-outline" > {gettext("Test Integration")} <.button :if={Mv.Config.vereinfacht_configured?()} type="button" - variant="outline" phx-click="sync_vereinfacht_contacts" phx-disable-with={gettext("Syncing...")} + class="btn-outline" > {gettext("Sync all members without Vereinfacht contact")} @@ -357,21 +357,20 @@ 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(flash_kind, flash_message) + |> 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) + ) + ) + ) {:noreply, socket} @@ -410,7 +409,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(:success, gettext("Settings updated successfully")) + |> put_flash(:info, 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 2e79a7f..0ffba09 100644 --- a/lib/mv_web/live/group_live/form.ex +++ b/lib/mv_web/live/group_live/form.ex @@ -78,56 +78,30 @@ defmodule MvWeb.GroupLive.Form do ~H""" <.form for={@form} id="group-form" phx-change="validate" phx-submit="save"> - <.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")} - - - + <%!-- 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")} + -
-
- <.input field={@form[:name]} label={gettext("Name")} required /> - <.input - field={@form[:description]} - type="textarea" - label={gettext("Description")} - rows="4" - /> -
+

+ {@page_title} +

- <%!-- 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 %> + <.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" + />
@@ -155,7 +129,7 @@ defmodule MvWeb.GroupLive.Form do socket = socket - |> put_flash(:success, gettext("Group saved successfully.")) + |> put_flash(:info, 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 ff22b91..deab7e1 100644 --- a/lib/mv_web/live/group_live/index.ex +++ b/lib/mv_web/live/group_live/index.ex @@ -39,47 +39,72 @@ defmodule MvWeb.GroupLive.Index do def render(assigns) do ~H""" - <.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} - - +
+

{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")} + <% 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 dbc0523..0c7e93e 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} = params, _url, socket) do + def handle_params(%{"slug" => slug}, _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, params) + load_group_by_slug(socket, slug, actor) else {:noreply, redirect(socket, to: ~p"/members")} end end - defp load_group_by_slug(socket, slug, actor, params) do + defp load_group_by_slug(socket, slug, actor) do # Load group with members and member_count # Using explicit load ensures efficient preloading of members relationship require Ash.Query @@ -68,16 +68,10 @@ defmodule MvWeb.GroupLive.Show do |> redirect(to: ~p"/groups")} {:ok, 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} + {:noreply, + socket + |> assign(:page_title, group.name) + |> assign(:group, group)} {:error, _error} -> {:noreply, @@ -91,346 +85,318 @@ defmodule MvWeb.GroupLive.Show do def render(assigns) do ~H""" - <.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> + <%!-- 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} +

+ +
<%= if can?(@current_user, :update, @group) do %> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"} data-testid="group-show-edit-btn" > - <.icon name="hero-pencil-square" /> {gettext("Edit group")} + {gettext("Edit")} <% 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 %> -
-
- -
-

{gettext("Members")}

-
-

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

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

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

-

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

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

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

- <% else %> -
- - - - - - <%= if can?(@current_user, :update, @group) do %> - - <% end %> - - - - <%= for member <- @group.members do %> - - - - <%= if can?(@current_user, :update, @group) do %> - - <% end %> - - <% end %> - -
{gettext("Name")}{gettext("Email")}{gettext("Actions")}
- <.link - navigate={~p"/members/#{member.id}"} - class="link link-primary" - > - {MvWeb.Helpers.MemberHelpers.display_name(member)} - - - <%= if member.email do %> - - {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 %> -
+ <%!-- Group Information --%> +
+
+

{gettext("Description")}

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

{@group.description}

+ <% else %> +

{gettext("No description")}

+ <% end %>
- <%!-- Danger zone: canonical pattern (same as member show) --%> - <%= if can?(@current_user, :destroy, @group) do %> -
-

- {gettext("Danger zone")} -

-
-

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

- <.button - variant="danger" - type="button" - phx-click="open_delete_modal" - data-testid="group-show-delete-btn" - aria-label={gettext("Delete group %{name}", name: @group.name)} - > - <.icon name="hero-trash" class="size-4" /> - {gettext("Delete group")} - -
-
- <% end %> +
+

{gettext("Members")}

+
+

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

- <%!-- Delete Confirmation Modal --%> - <%= if assigns[:show_delete_modal] do %> - - - <% end %> + <% end %> +
+
+ + <%!-- Delete Confirmation Modal --%> + <%= if assigns[:show_delete_modal] do %> + + + + <% end %> """ end @@ -934,7 +900,7 @@ defmodule MvWeb.GroupLive.Show do :ok -> {:noreply, socket - |> put_flash(:success, gettext("Group deleted successfully.")) + |> put_flash(:info, 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 17266a8..ae9e239 100644 --- a/lib/mv_web/live/member_field_live/form_component.ex +++ b/lib/mv_web/live/member_field_live/form_component.ex @@ -42,13 +42,11 @@ 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="size-4" /> - {gettext("Back")} + <.icon name="hero-arrow-left" class="w-4 h-4" />

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

- <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}> + <.button type="button" 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 28384b5..db62778 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -52,12 +52,6 @@ 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)} @@ -92,6 +86,16 @@ 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 c98feb9..81db0fe 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -6,6 +6,7 @@ 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 @@ -20,7 +21,6 @@ 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,248 +38,222 @@ defmodule MvWeb.MemberLive.Form do ~H""" <.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> - <.header> - <:leading> - <.button navigate={return_path(@return_to, @member)} variant="neutral"> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - - - <%= if @member do %> - {MvWeb.Helpers.MemberHelpers.display_name(@member)} - <% else %> - {gettext("New Member")} - <% end %> - <:actions> - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("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")} + -
- <%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%> -
- -
+

+ <%= if @member do %> + {MvWeb.Helpers.MemberHelpers.display_name(@member)} + <% else %> + {gettext("New Member")} + <% end %> +

- <%!-- 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]} - /> -
-
+ <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save")} + +
- <%!-- 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")} /> -
-
+ <%!-- Tab Navigation --%> +
+ + +
- <%!-- Street and Nr. below --%> -
-
- <.input field={@form[:street]} label={gettext("Street")} /> -
-
- <.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]} - /> -
-
- <.input - field={@form[:exit_date]} - label={gettext("Exit Date")} - type="date" - required={@member_field_required_map[:exit_date]} - /> -
-
- - <%!-- Notes --%> -
+ <%!-- Personal Data and Custom Fields Row --%> +
+ <%!-- Personal Data Section --%> +
+ <.form_section title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+
<.input - field={@form[:notes]} - label={gettext("Notes")} - type="textarea" - required={@member_field_required_map[:notes]} + 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]} />
- -
- <%!-- 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 %> + <%!-- Address: Country, Postal Code, City in one row --%> +
+
+ <.input field={@form[:country]} label={gettext("Country")} />
- -
- <% end %> -
+
+ <.input + field={@form[:postal_code]} + label={gettext("Postal Code")} + required={@member_field_required_map[:postal_code]} + /> +
+
+ <.input field={@form[:city]} label={gettext("City")} /> +
+
- <%!-- Membership Fee Section --%> -
- <.form_section title={gettext("Membership Fee")}> -
+ <%!-- Street and Nr. below --%> +
+
+ <.input field={@form[:street]} label={gettext("Street")} /> +
+
+ <.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]} + /> +
+
+ <.input + field={@form[:exit_date]} + label={gettext("Exit Date")} + type="date" + required={@member_field_required_map[:exit_date]} + /> +
+
+ + <%!-- 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." - )} -

+ <.input + field={@form[:notes]} + label={gettext("Notes")} + type="textarea" + required={@member_field_required_map[:notes]} + />
- <%!-- 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")} - -
+ <%!-- 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 %> +
- <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> - <%= if @member && can?(@current_user, :destroy, @member) do %> -
-

- {gettext("Danger zone")} -

-
-

+ <%!-- Membership Fee Section --%> +

+ <.form_section title={gettext("Membership Fee")}> +
+
+ + + <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

{msg}

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

{gettext( - "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." + "Select a membership fee type for this member. Members can only switch between types with the same interval." )}

- <.button - variant="danger" - type="button" - phx-click="delete" - phx-value-id={@member.id} - data-confirm={ - gettext("Are you sure you want to delete %{name}? This action cannot be undone.", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) - ) - } - data-testid="member-delete" - aria-label={ - gettext("Delete member %{name}", - name: MvWeb.Helpers.MemberHelpers.display_name(@member) - ) - } - > - <.icon name="hero-trash" class="size-4" /> - {gettext("Delete member")} -
-
- <% end %> +
+ +
+ + <%!-- 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")} +
@@ -400,41 +374,6 @@ 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}) @@ -447,7 +386,7 @@ defmodule MvWeb.MemberLive.Form do socket = socket - |> put_flash(:success, flash_message) + |> put_flash(:info, flash_message) |> maybe_put_vereinfacht_sync_flash(member.id) |> push_navigate(to: return_path(socket.assigns.return_to, member)) @@ -482,19 +421,6 @@ 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 1be35b4..3283b5c 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -15,6 +15,7 @@ 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 @@ -122,7 +123,6 @@ 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,14 +157,48 @@ 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("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}")} + 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 end @impl true @@ -309,6 +343,22 @@ 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 # ----------------------------------------------------------------- @@ -606,7 +656,6 @@ 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) @@ -806,18 +855,6 @@ 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 af2863f..da9c3d5 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 - variant="secondary" + class="secondary" id="copy-emails-btn" phx-hook="CopyToClipboard" phx-click="copy_emails" @@ -20,7 +20,7 @@ {gettext("Copy email addresses")} ({@selected_count}) <.button - variant="secondary" + class="secondary" id="open-email-btn" href={"mailto:?bcc=" <> @mailto_bcc} disabled={not @any_selected?} @@ -54,12 +54,13 @@ boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} /> - <.button + <.live_component module={MvWeb.Components.FieldVisibilityDropdownComponent} id="field-visibility-dropdown" @@ -90,329 +91,334 @@ />
- <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%> -
"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} > - <.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""" - <.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" - /> - """ - } - > + <:col + :let={member} + col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} + label={ + ~H""" <.input type="checkbox" - name={member.id} - checked={MapSet.member?(@selected_members, member.id)} - aria-label={gettext("Select member")} + 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" /> - - <:col - :let={member} - :if={:first_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_first_name} - field={:first_name} - label={gettext("First name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.first_name} - - <:col - :let={member} - :if={:last_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_last_name} - field={:last_name} - label={gettext("Last name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.last_name} - - <:col - :let={member} - :if={:email in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_email} - field={:email} - label={gettext("Email")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.email} - - <:col - :let={member} - :if={:join_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_join_date} - field={:join_date} - label={gettext("Join Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.join_date)} - - <:col - :let={member} - :if={:exit_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_exit_date} - field={:exit_date} - label={gettext("Exit Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.exit_date)} - - <:col - :let={member} - :if={:notes in @member_fields_visible} - label={gettext("Notes")} - > - {member.notes} - - <:col - :let={member} - :if={:country in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_country} - field={:country} - label={gettext("Country")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.country} - - <:col - :let={member} - :if={:city in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_city} - field={:city} - label={gettext("City")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.city} - - <:col - :let={member} - :if={:street in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_street} - field={:street} - label={gettext("Street")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.street} - - <:col - :let={member} - :if={:house_number in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_house_number} - field={:house_number} - label={gettext("House Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.house_number} - - <:col - :let={member} - :if={:postal_code in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_postal_code} - field={:postal_code} - label={gettext("Postal Code")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.postal_code} - - <:col - :let={member} - :if={:membership_fee_start_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_start_date} - field={:membership_fee_start_date} - label={gettext("Membership Fee Start Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} - - <:col - :let={member} - :if={:membership_fee_type in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_type} - field={:membership_fee_type} - label={gettext("Fee Type")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= if member.membership_fee_type do %> - {member.membership_fee_type.name} - <% else %> - - <% end %> - - <:col - :let={member} - :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} - > - <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( + """ + } + > + <.input + type="checkbox" + name={member.id} + checked={MapSet.member?(@selected_members, member.id)} + aria-label={gettext("Select member")} + role="checkbox" + /> + + <:col + :let={member} + :if={:first_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_first_name} + field={:first_name} + label={gettext("First name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.first_name} + + <:col + :let={member} + :if={:last_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_last_name} + field={:last_name} + label={gettext("Last name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.last_name} + + <:col + :let={member} + :if={:email in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_email} + field={:email} + label={gettext("Email")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.email} + + <:col + :let={member} + :if={:join_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_join_date} + field={:join_date} + label={gettext("Join Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.join_date)} + + <:col + :let={member} + :if={:exit_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_exit_date} + field={:exit_date} + label={gettext("Exit Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.exit_date)} + + <:col + :let={member} + :if={:notes in @member_fields_visible} + label={gettext("Notes")} + > + {member.notes} + + <:col + :let={member} + :if={:country in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_country} + field={:country} + label={gettext("Country")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.country} + + <:col + :let={member} + :if={:city in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + + <:col + :let={member} + :if={:street in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + :if={:house_number in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + :if={:membership_fee_start_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_start_date} + field={:membership_fee_start_date} + label={gettext("Membership Fee Start Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} + + <:col + :let={member} + :if={:membership_fee_type in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_type} + field={:membership_fee_type} + label={gettext("Fee Type")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= if member.membership_fee_type do %> + {member.membership_fee_type.name} + <% else %> + + <% end %> + + <:col + :let={member} + :if={:membership_fee_status in @member_fields_visible} + label={gettext("Membership Fee Status")} + > + <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) ) do %> - - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - {gettext("No cycle")} - <% end %> - - <:col - :let={member} - :if={:groups in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_groups} - field={:groups} - label={gettext("Groups")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= for group <- (member.groups || []) do %> - - {group.name} - - <% end %> - <%= if (member.groups || []) == [] do %> - - <% end %> - - <:action :let={member}> -
- <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> - {gettext("Show")} - -
- - -
+ + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <% else %> + {gettext("No cycle")} + <% end %> + + <:col + :let={member} + :if={:groups in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_groups} + field={:groups} + label={gettext("Groups")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= for group <- (member.groups || []) do %> + + {group.name} + + <% end %> + <%= if (member.groups || []) == [] do %> + + <% end %> + + <:action :let={member}> +
+ <.link navigate={~p"/members/#{member}"}>{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 abe8453..c05da63 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -30,311 +30,235 @@ defmodule MvWeb.MemberLive.Show do def render(assigns) do ~H""" - <.header> - <:leading> + <%!-- 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 %> <.button - navigate={~p"/members?highlight=#{@member.id}"} - variant="neutral" - aria-label={gettext("Back to members list")} + variant="primary" + navigate={~p"/members/#{@member}/edit?return_to=show"} + data-testid="member-edit" > - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} + {gettext("Edit Member")} - - {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: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%> -
- - -
- - <%= if @active_tab == :contact do %> - <%!-- Contact Data Tab Content --%> -
- <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.section_box title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
- <.data_field - label={gettext("First Name")} - value={@member.first_name} - class="w-48" - /> - <.data_field - label={gettext("Last Name")} - value={@member.last_name} - class="w-48" - /> -
- - <%!-- Address --%> -
- <.data_field label={gettext("Address")} value={format_address(@member)} /> -
- - <%!-- 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" - /> -
- - <%!-- 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} - - <% 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 %> - <% status = @member.last_cycle_status %> - - {format_status_label(status)} - - <% else %> - {gettext("No cycles")} - <% end %> - - <.data_field label={gettext("Current Cycle")} class="min-w-36"> - <%= if @member.current_cycle_status do %> - <% status = @member.current_cycle_status %> - - {format_status_label(status)} - - <% else %> - {gettext("No cycles")} - <% end %> - -
- <% else %> -
- {gettext("No membership fee type assigned")} -
- <% end %> - -
-
- <% end %> - - <%= if @active_tab == :membership_fees do %> - <%!-- Membership Fees Tab Content --%> -
- <.live_component - module={MvWeb.MemberLive.Show.MembershipFeesComponent} - id={"membership-fees-#{@member.id}"} - member={@member} - current_user={@current_user} - vereinfacht_receipts={@vereinfacht_receipts} - /> -
- <% end %> - - <%!-- 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 %>
+ + <%!-- 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" /> +
+ + <%!-- Address --%> +
+ <.data_field label={gettext("Address")} value={format_address(@member)} /> +
+ + <%!-- 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" + /> +
+ + <%!-- 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} + + <% 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 %> + <.link + navigate={~p"/groups/#{group.slug}"} + class="btn btn-xs btn-outline btn-primary" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
+ <% end %> + +
+ + <%!-- Notes --%> + <%= if @member.notes && String.trim(@member.notes) != "" do %> +
+ <.data_field label={gettext("Notes")}> +

{@member.notes}

+ +
+ <% end %> +
+ +
+ + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.section_box title={gettext("Custom Fields")}> +
+ <%= for custom_field <- @custom_fields do %> + <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %> + <.data_field label={custom_field.name}> + {format_custom_field_value(cfv, custom_field.value_type)} + + <% end %> +
+ +
+ <% end %> +
+ + <%!-- Payment Data Section --%> +
+ <.section_box title={gettext("Payment Data")}> + <%= if @member.membership_fee_type do %> +
+ <.data_field + label={gettext("Type")} + value={@member.membership_fee_type.name} + class="min-w-32" + /> + <.data_field + label={gettext("Membership Fee")} + value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} + class="min-w-24" + /> + <.data_field + label={gettext("Payment Interval")} + value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)} + class="min-w-32" + /> + <.data_field label={gettext("Last Cycle")} class="min-w-32"> + <%= if @member.last_cycle_status do %> + <% status = @member.last_cycle_status %> + + {format_status_label(status)} + + <% else %> + {gettext("No cycles")} + <% end %> + + <.data_field label={gettext("Current Cycle")} class="min-w-36"> + <%= if @member.current_cycle_status do %> + <% status = @member.current_cycle_status %> + + {format_status_label(status)} + + <% else %> + {gettext("No cycles")} + <% end %> + +
+ <% else %> +
+ {gettext("No membership fee type assigned")} +
+ <% end %> + +
+ <% end %> + + <%= if @active_tab == :membership_fees do %> + <%!-- Membership Fees Tab Content --%> + <.live_component + module={MvWeb.MemberLive.Show.MembershipFeesComponent} + id={"membership-fees-#{@member.id}"} + member={@member} + current_user={@current_user} + vereinfacht_receipts={@vereinfacht_receipts} + /> + <% end %> """ end @@ -396,37 +320,6 @@ 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 @@ -457,19 +350,6 @@ 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 # ----------------------------------------------------------------- @@ -523,7 +403,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 23f0dda..b8757a0 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,15 +66,14 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
- <.button +
<%= 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" /> @@ -160,10 +158,9 @@ 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" /> @@ -262,18 +259,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %> <%= if @can_destroy_cycle do %> - <.button + <% end %>
@@ -313,15 +309,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do />
@@ -343,17 +334,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do )} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}

@@ -394,20 +385,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do />
@@ -481,15 +472,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
@@ -562,7 +548,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do get_available_fee_types(updated_member, actor) ) |> assign(:interval_warning, nil) - |> put_flash(:success, gettext("Membership fee type removed"))} + |> put_flash(:info, gettext("Membership fee type removed"))} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} @@ -621,7 +607,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do get_available_fee_types(updated_member, actor) ) |> assign(:interval_warning, nil) - |> put_flash(:success, gettext("Membership fee type updated. Cycles regenerated."))} + |> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} @@ -649,7 +635,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do {:noreply, socket |> assign(:cycles, updated_cycles) - |> put_flash(:success, gettext("Cycle status updated"))} + |> put_flash(:info, gettext("Cycle status updated"))} {:error, %Ash.Error.Invalid{} = error} -> error_msg = @@ -705,7 +691,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:member, updated_member) |> assign(:cycles, cycles) |> assign(:regenerating, false) - |> put_flash(:success, gettext("Cycles regenerated successfully"))} + |> put_flash(:info, gettext("Cycles regenerated successfully"))} {:error, error} -> {:noreply, @@ -755,7 +741,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:editing_cycle, nil) - |> put_flash(:success, gettext("Cycle amount updated"))} + |> put_flash(:info, gettext("Cycle amount updated"))} {:error, error} -> {:noreply, @@ -794,7 +780,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:deleting_cycle, nil) - |> put_flash(:success, gettext("Cycle deleted"))} + |> put_flash(:info, gettext("Cycle deleted"))} {:ok, _destroyed} -> # Handle case where return_destroyed? is true @@ -804,7 +790,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do socket |> assign(:cycles, updated_cycles) |> assign(:deleting_cycle, nil) - |> put_flash(:success, gettext("Cycle deleted"))} + |> put_flash(:info, gettext("Cycle deleted"))} {:error, error} -> {:noreply, @@ -950,7 +936,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:creating_cycle, false) |> assign(:create_cycle_date, nil) |> assign(:create_cycle_error, nil) - |> put_flash(:success, gettext("Cycle created successfully"))} + |> put_flash(:info, gettext("Cycle created successfully"))} {:error, error} -> {:noreply, @@ -1013,7 +999,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:member, updated_member) |> assign(:cycles, updated_cycles) |> reset_modal.() - |> put_flash(:success, gettext("All cycles deleted"))} + |> put_flash(:info, 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 84ce662..bfa20f8 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(:success, gettext("Settings saved successfully.")) + |> put_flash(:info, 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(:success, gettext("Membership fee type deleted"))} + |> put_flash(:info, gettext("Membership fee type deleted"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, @@ -239,10 +239,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
- <.button type="submit" variant="primary" class="w-full"> +
@@ -333,27 +333,24 @@ defmodule MvWeb.MembershipFeeSettingsLive do <:action :let={mft}> - <.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" /> - - + <.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" /> + <:action :let={mft}> - <.tooltip +
0} - content={ + class="tooltip tooltip-left" + data-tip={ gettext("Cannot delete - %{count} member(s) assigned", count: get_member_count(mft, @member_counts) ) } - position="left" > - - <.button +
+ 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 237b4b4..d8569e2 100644 --- a/lib/mv_web/live/membership_fee_type_live/form.ex +++ b/lib/mv_web/live/membership_fee_type_live/form.ex @@ -27,26 +27,10 @@ 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 @@ -192,20 +176,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
@@ -333,7 +317,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do socket = socket - |> put_flash(:success, gettext("Membership fee type saved successfully")) + |> put_flash(:info, 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 ee3b791..f5f760f 100644 --- a/lib/mv_web/live/membership_fee_type_live/index.ex +++ b/lib/mv_web/live/membership_fee_type_live/index.ex @@ -78,27 +78,24 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do <:action :let={mft}> - <.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" /> - - + <.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" /> + <:action :let={mft}> - <.tooltip +
0} - content={ + class="tooltip tooltip-left" + data-tip={ gettext("Cannot delete - %{count} member(s) assigned", count: get_member_count(mft, @member_counts) ) } - position="left" > - - <.button +
+ @@ -149,7 +145,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do socket |> assign(:membership_fee_types, updated_types) |> assign(:member_counts, updated_counts) - |> put_flash(:success, gettext("Membership fee type deleted"))} + |> put_flash(:info, 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 e066555..ea76fe8 100644 --- a/lib/mv_web/live/role_live/form.ex +++ b/lib/mv_web/live/role_live/form.ex @@ -21,70 +21,66 @@ 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"> - <.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[:name]} type="text" label={gettext("Name")} required /> -
- <.input field={@form[:name]} type="text" label={gettext("Name")} required /> + <.input + field={@form[:description]} + type="textarea" + label={gettext("Description")} + rows="3" + /> - <.input - field={@form[:description]} - type="textarea" - label={gettext("Description")} - rows="3" - /> - -
- - - <%= 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 %> +
+ + + <%= 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")} +
@@ -179,7 +175,7 @@ defmodule MvWeb.RoleLive.Form do socket = socket - |> put_flash(:success, gettext("Role saved successfully.")) + |> put_flash(:info, 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 ed64eb7..091b191 100644 --- a/lib/mv_web/live/role_live/index.ex +++ b/lib/mv_web/live/role_live/index.ex @@ -5,8 +5,11 @@ 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 (row click) and edit from details header - - Delete only via Danger zone on role show page + - Navigate to role details and edit forms + - Delete non-system roles + + ## Events + - `delete` - Remove a role from the database (only non-system roles) ## Security Only admins can access this page (enforced by authorization). @@ -18,7 +21,8 @@ defmodule MvWeb.RoleLive.Index do require Ash.Query - import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1] + import MvWeb.RoleLive.Helpers, + only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3] @impl true def mount(_params, _session, socket) do @@ -33,6 +37,83 @@ 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) @@ -73,4 +154,15 @@ 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 43f2fc7..f409944 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -17,7 +17,6 @@ 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,5 +52,46 @@ <:col :let={role} label={gettext("Users")}> {get_user_count(role, @user_counts)} + + <:action :let={role}> +
+ <.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")} +
+ + <%= if can?(@current_user, :update, Mv.Authorization.Role) do %> + <.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm"> + <.icon name="hero-pencil" class="size-4" /> + {gettext("Edit")} + + <% end %> + + + <:action :let={role}> + <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %> + <.link + phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")} + data-confirm={gettext("Are you sure?")} + class="btn btn-ghost btn-sm text-error" + > + <.icon name="hero-trash" class="size-4" /> + {gettext("Delete")} + + <% else %> +
+ +
+ <% end %> + diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex index 4f36eca..0e1c7ca 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(:success, gettext("Role deleted successfully.")) + |> put_flash(:info, gettext("Role deleted successfully.")) |> push_navigate(to: ~p"/admin/roles")} {:error, error} -> @@ -161,29 +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"} - data-testid="role-show-edit-btn" - > - <.icon name="hero-pencil-square" /> {gettext("Edit role")} + <.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" + > + <.icon name="hero-trash" /> {gettext("Delete Role")} + + <% end %> @@ -209,37 +208,6 @@ defmodule MvWeb.RoleLive.Show do <% end %> - - <%!-- Danger zone: canonical pattern (same as member show) --%> - <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> -
-

- {gettext("Danger zone")} -

-
-

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

- <.button - variant="danger" - phx-click={JS.push("delete", value: %{id: @role.id})} - data-confirm={ - gettext( - "Are you sure you want to delete the role %{name}? This action cannot be undone.", - name: @role.name - ) - } - data-testid="role-delete" - aria-label={gettext("Delete role %{name}", name: @role.name)} - > - <.icon name="hero-trash" class="size-4" /> - {gettext("Delete role")} - -
-
- <% end %>
""" end diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index f7c440d..46e23b3 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -39,31 +39,14 @@ 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"> @@ -184,14 +167,13 @@ defmodule MvWeb.UserLive.Form do

{@user.member.email}

- <.button +
<% else %> @@ -298,46 +280,11 @@ 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")}
@@ -454,26 +401,6 @@ 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)} @@ -584,23 +511,6 @@ 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) @@ -643,7 +553,7 @@ defmodule MvWeb.UserLive.Form do socket = socket - |> put_flash(:success, gettext("User %{action} successfully", action: action)) + |> put_flash(:info, 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 4858202..72cc55c 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -5,12 +5,18 @@ defmodule MvWeb.UserLive.Index do ## Features - List all users with email and linked member - Sort users by email (default) - - Navigate to user details (row click) and edit from details header - - Delete only via Danger zone on user show/edit + - Delete users + - Navigate to user details and edit forms + - Bulk selection for future batch operations ## Relationships Displays linked member information when a user is connected to a member account. + ## Events + - `delete` - Remove a user from the database + - `select_user` - Toggle individual user selection + - `select_all` - Toggle selection of all visible users + ## Security User deletion requires admin permissions (enforced by Ash policies). """ @@ -20,6 +26,7 @@ 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 @@ -37,7 +44,63 @@ defmodule MvWeb.UserLive.Index do |> assign(:page_title, gettext("Listing Users")) |> assign(:sort_field, :email) |> assign(:sort_order, :asc) - |> assign(:users, sorted)} + |> 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)} end # Sorts the list of users according to a field, when you click on the column header @@ -64,6 +127,24 @@ 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 86f0ab7..ab13f90 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -15,10 +15,36 @@ 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} @@ -57,5 +83,29 @@ <% 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 d7a12b2..4d803cd 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -27,27 +27,20 @@ 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" @@ -87,38 +80,6 @@ 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 @@ -142,38 +103,4 @@ 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 2f3a1f8..3dc41b2 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -11,13 +11,18 @@ 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?" @@ -35,14 +40,25 @@ 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" @@ -94,6 +110,8 @@ 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" @@ -259,6 +277,7 @@ 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" @@ -472,6 +491,16 @@ 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" @@ -587,6 +616,7 @@ 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 @@ -605,6 +635,11 @@ 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" @@ -638,6 +673,7 @@ 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" @@ -754,21 +790,19 @@ 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 @@ -786,6 +820,7 @@ 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" @@ -799,8 +834,6 @@ 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" @@ -818,6 +851,11 @@ 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 @@ -943,6 +981,11 @@ 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" @@ -1532,7 +1575,6 @@ 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" @@ -1578,11 +1620,22 @@ 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}" @@ -1599,6 +1652,7 @@ 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." @@ -1609,6 +1663,11 @@ 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" @@ -1652,6 +1711,7 @@ 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." @@ -1663,6 +1723,7 @@ 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." @@ -1673,6 +1734,11 @@ 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" @@ -1694,6 +1760,12 @@ 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." @@ -1770,14 +1842,12 @@ msgstr "Mitgliedsbeitragsart nicht gefunden" msgid "User %{action} successfully" msgstr "Benutzer*in wurde erfolgreich %{action}" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.ex #, elixir-autogen, elixir-format msgid "User deleted successfully" msgstr "Benutzer*in erfolgreich gelöscht" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.ex #, elixir-autogen, elixir-format msgid "User not found" msgstr "Benutzer*in nicht gefunden" @@ -1788,14 +1858,18 @@ 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/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.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" @@ -1816,20 +1890,22 @@ msgstr "aktualisiert" msgid "Unknown error" msgstr "Unbekannter Fehler" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "Mitglied wurde erfolgreich gelöscht" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "Mitglied nicht gefunden" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: 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 #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen" @@ -1914,6 +1990,11 @@ 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})" @@ -2080,7 +2161,6 @@ 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" @@ -2161,6 +2241,11 @@ 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" @@ -3035,278 +3120,3 @@ 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 7413a20..41cc407 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -12,13 +12,18 @@ 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 "" @@ -36,14 +41,25 @@ 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 "" @@ -95,6 +111,8 @@ 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 "" @@ -260,6 +278,7 @@ 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" @@ -473,6 +492,16 @@ 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" @@ -588,6 +617,7 @@ 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 @@ -606,6 +636,11 @@ 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" @@ -639,6 +674,7 @@ 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" @@ -755,21 +791,19 @@ 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 @@ -787,6 +821,7 @@ 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 "" @@ -800,8 +835,6 @@ 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 "" @@ -819,6 +852,11 @@ 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 @@ -944,6 +982,11 @@ 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" @@ -1533,7 +1576,6 @@ 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 "" @@ -1579,11 +1621,22 @@ 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}" @@ -1600,6 +1653,7 @@ 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." @@ -1610,6 +1664,11 @@ 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" @@ -1653,6 +1712,7 @@ 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." @@ -1664,6 +1724,7 @@ 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." @@ -1674,6 +1735,11 @@ 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" @@ -1695,6 +1761,12 @@ 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." @@ -1771,14 +1843,12 @@ msgstr "" msgid "User %{action} successfully" msgstr "" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.ex #, elixir-autogen, elixir-format msgid "User deleted successfully" msgstr "" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.ex #, elixir-autogen, elixir-format msgid "User not found" msgstr "" @@ -1789,14 +1859,18 @@ 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/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this user" msgstr "" @@ -1817,20 +1891,22 @@ msgstr "" msgid "Unknown error" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: 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 #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "" @@ -1915,6 +1991,11 @@ 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})" @@ -2081,7 +2162,6 @@ 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" @@ -2162,6 +2242,11 @@ 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" @@ -3030,192 +3115,3 @@ 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 51aab4f..8d466ac 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -12,13 +12,18 @@ 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 "" @@ -36,14 +41,25 @@ 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 "" @@ -95,6 +111,8 @@ 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 "" @@ -260,6 +278,7 @@ 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" @@ -473,6 +492,16 @@ 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" @@ -588,6 +617,7 @@ 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 @@ -606,6 +636,11 @@ 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" @@ -639,6 +674,7 @@ 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" @@ -755,21 +791,19 @@ 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 @@ -787,6 +821,7 @@ 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 "" @@ -800,8 +835,6 @@ 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 "" @@ -819,6 +852,11 @@ 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 @@ -944,6 +982,11 @@ 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" @@ -1533,7 +1576,6 @@ 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 "" @@ -1579,11 +1621,22 @@ 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}" @@ -1600,6 +1653,7 @@ 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." @@ -1610,6 +1664,11 @@ 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" @@ -1653,6 +1712,7 @@ 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." @@ -1664,6 +1724,7 @@ 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." @@ -1674,6 +1735,11 @@ 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" @@ -1695,6 +1761,12 @@ 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." @@ -1771,14 +1843,12 @@ msgstr "" msgid "User %{action} successfully" msgstr "" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "User deleted successfully" msgstr "" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "User not found" msgstr "" @@ -1789,14 +1859,18 @@ 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/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this user" msgstr "" @@ -1817,20 +1891,22 @@ msgstr "" msgid "Unknown error" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "Member deleted successfully" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/index.ex #, elixir-autogen, elixir-format msgid "Member not found" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: 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 #, elixir-autogen, elixir-format msgid "You do not have permission to delete this member" msgstr "" @@ -1915,6 +1991,11 @@ 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})" @@ -2081,7 +2162,6 @@ 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" @@ -2162,6 +2242,11 @@ 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" @@ -3030,278 +3115,3 @@ 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 deleted file mode 100644 index 931b42a..0000000 --- a/test/mv_web/components/core_components_table_test.exs +++ /dev/null @@ -1,154 +0,0 @@ -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 5ec955e..28f98a2 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -46,17 +46,6 @@ 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() @@ -66,7 +55,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member, custom_field, "test") {:ok, view, _html} = live(conn, ~p"/admin/datafields") - open_delete_modal(view, custom_field) + + # Click delete button - find the delete link within the component + view + |> element("#custom-fields-component a", "Delete") + |> render_click() # Modal should be visible assert has_element?(view, "#delete-custom-field-modal") @@ -88,17 +81,23 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member2, custom_field, "test2") {:ok, view, _html} = live(conn, ~p"/admin/datafields") - open_delete_modal(view, custom_field) + + view + |> element("#custom-fields-component a", "Delete") + |> render_click() # 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") - open_delete_modal(view, custom_field) + + view + |> element("#custom-fields-component a", "Delete") + |> render_click() # Should show 0 members assert render(view) =~ "0 members have values assigned for this custom field" @@ -110,7 +109,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - open_delete_modal(view, custom_field) + + view + |> element("#custom-fields-component a", "Delete") + |> render_click() # Type in slug input - use element to find the form with phx-target view @@ -122,10 +124,13 @@ 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") - open_delete_modal(view, custom_field) + + view + |> element("#custom-fields-component a", "Delete") + |> render_click() # Type wrong slug - use element to find the form with phx-target view @@ -144,7 +149,11 @@ 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_delete_modal(view, custom_field) + + # Open modal + view + |> element("#custom-fields-component a", "Delete") + |> render_click() # Enter correct slug - use element to find the form with phx-target view @@ -153,7 +162,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do # Click confirm view - |> element("#delete-custom-field-modal button", "Delete Datafields and All Values") + |> element("#delete-custom-field-modal button", "Delete Custom Field and All Values") |> render_click() # Should show success message @@ -177,7 +186,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - open_delete_modal(view, custom_field) + + view + |> element("#custom-fields-component a", "Delete") + |> render_click() # Enter wrong slug - use element to find the form with phx-target view @@ -198,7 +210,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, view, _html} = live(conn, ~p"/admin/datafields") - open_delete_modal(view, custom_field) + + view + |> element("#custom-fields-component a", "Delete") + |> render_click() # 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 c5db9d6..9a23019 100644 --- a/test/mv_web/live/member_live_authorization_test.exs +++ b/test/mv_web/live/member_live_authorization_test.exs @@ -24,7 +24,6 @@ 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 @@ -32,18 +31,17 @@ defmodule MvWeb.MemberLiveAuthorizationTest do describe "Member Index - Kassenwart (normal_user)" do @tag role: :normal_user - test "sees New Member and Show link in row", %{conn: conn} do + test "sees New Member and Edit buttons", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") assert has_element?(view, "[data-testid=member-new]") - # 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]") + assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]") end @tag role: :normal_user - test "does not see Delete button in table", %{conn: conn} do + test "does not see Delete button", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") @@ -54,14 +52,14 @@ defmodule MvWeb.MemberLiveAuthorizationTest do describe "Member Index - Admin" do @tag role: :admin - test "sees New Member and Show link in row", %{conn: conn} do + test "sees New Member, Edit and Delete buttons", %{conn: conn} do member = Fixtures.member_fixture() {:ok, view, _html} = live(conn, "/members") assert has_element?(view, "[data-testid=member-new]") - # 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]") + assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]") + assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]") end end diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs index 57ce814..cb112f2 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 not shown for system roles", %{conn: conn, actor: actor} do + test "delete button disabled for system roles", %{conn: conn, actor: actor} do system_role = Role |> Ash.Changeset.for_create(:create_role, %{ @@ -148,19 +148,28 @@ defmodule MvWeb.RoleLiveTest do |> Ash.Changeset.force_change_attribute(:is_system_role, true) |> Ash.create!(actor: actor) - {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}") + {:ok, view, _html} = live(conn, "/admin/roles") - # Danger zone (and delete button) is not rendered for system roles - refute has_element?(view, "[data-testid=role-delete]") + 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}']" + ) end test "delete button enabled for non-system roles", %{conn: conn} do role = create_role() - {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}") + {:ok, view, html} = live(conn, "/admin/roles") - # Delete is on show page (Danger zone) - assert has_element?(view, "[data-testid=role-delete]") + # 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']") end test "new role button navigates to form", %{conn: conn} do @@ -384,21 +393,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/#{role.id}") + {:ok, view, html} = live(conn, "/admin/roles") - # Delete from Danger zone on show page - view - |> element("[data-testid=role-delete]") - |> render_click() + # 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 - assert_redirect(view, "/admin/roles") + # Send delete event directly to avoid selector issues with multiple delete buttons + render_click(view, "delete", %{"id" => role.id}) # Verify deletion by checking database assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} = Authorization.get_role(role.id, actor: actor) end - test "system role has no delete button and cannot be deleted", %{conn: conn, actor: actor} do + test "fails to delete system role with error message", %{conn: conn, actor: actor} do system_role = Role |> Ash.Changeset.for_create(:create_role, %{ @@ -408,12 +417,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/#{system_role.id}") + {:ok, view, html} = live(conn, "/admin/roles") - # Danger zone is not rendered for system roles (no delete button) - refute has_element?(view, "[data-testid=role-delete]") + # System role delete button should be disabled + assert html =~ "disabled" || html =~ "cursor-not-allowed" || + html =~ "System roles cannot be deleted" - # Role still exists + # 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 {: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 ee9f2b6..f4b4746 100644 --- a/test/mv_web/live/user_live_authorization_test.exs +++ b/test/mv_web/live/user_live_authorization_test.exs @@ -10,16 +10,14 @@ defmodule MvWeb.UserLiveAuthorizationTest do describe "User Index - Admin" do @tag role: :admin - test "sees New User button; Edit and Delete are on show page", %{conn: conn} do + test "sees New User, Edit and Delete buttons", %{conn: conn} do user = Fixtures.user_with_role_fixture("admin") - {:ok, index_view, _html} = live(conn, "/users") - assert has_element?(index_view, "[data-testid=user-new]") + {:ok, view, _html} = live(conn, "/users") - # 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]") + 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]") 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 0ec142e..d61d3fd 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -3,85 +3,11 @@ 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 add2fba..bbd9159 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) + # Toggle to current cycle (use the button in the header, not the one in the column) view - |> element("[data-testid=toggle-cycle-view]") + |> element("button[phx-click='toggle_cycle_view'].btn.gap-2") |> 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 ec35f4d..5246d80 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -46,35 +46,6 @@ 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 @@ -296,80 +267,36 @@ defmodule MvWeb.MemberLive.IndexTest do assert is_list(state.socket.assigns.members) end - @tag :ui - test "member index does not render Edit or Delete actions", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - {:ok, _member} = - Mv.Membership.create_member( - %{first_name: "Test", last_name: "User", email: "test@example.com"}, - actor: system_actor - ) - - conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members") - - refute has_element?(view, "[data-testid='member-edit']") - refute html =~ ~s(data-testid="member-delete") - end - - @tag :ui - test "row click navigates to member show", %{conn: conn} do + test "can delete a member without error", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() + # Create a test member first {:ok, member} = Mv.Membership.create_member( - %{first_name: "Row", last_name: "Click", email: "rowclick@example.com"}, + %{ + first_name: "Test", + last_name: "User", + email: "test@example.com" + }, actor: system_actor ) conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, index_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)") + # Verify the member is displayed + assert has_element?(index_view, "#members", "Test User") + + # Click the delete link for this member + index_view + |> element("a", "Delete") |> render_click() - assert_redirect(view, ~p"/members/#{member}") - end + # Verify the member is no longer displayed + refute has_element?(index_view, "#members", "Test User") - 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 + # Verify the member was actually deleted from the database + assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?()) 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 54829de..26c3f00 100644 --- a/test/mv_web/member_live/show_test.exs +++ b/test/mv_web/member_live/show_test.exs @@ -134,37 +134,6 @@ 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 f748000..11cd70b 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -16,10 +16,11 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ "alice@example.com" assert html =~ "bob@example.com" - # UI elements: New User button; row click navigates to show (no Edit/Delete on index) + # UI elements: New User button, action links assert html =~ "New User" - # 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) + assert html =~ "Edit" + assert html =~ "Delete" + assert html =~ ~r/href="[^"]*\/users\/#{user1.id}\/edit"/ end @tag :ui @@ -115,28 +116,176 @@ defmodule MvWeb.UserLive.IndexTest do end end - describe "delete functionality" do - # 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"}) + 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, index_view, _html} = live(conn, "/users") - assert render(index_view) =~ "delete-me@example.com" + {:ok, _view, html} = live(conn, "/users") - # Navigate to user show and trigger delete from Danger zone - {:ok, show_view, _html} = live(conn, "/users/#{user.id}") + # Check select all checkbox exists + assert html =~ ~s(name="select_all") + assert html =~ ~s(phx-click="select_all") - show_view - |> element("[data-testid=user-delete]") - |> render_click() + # 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 - # Should redirect to index - assert_redirect(show_view, "/users") + @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") - # Reload index with same session; user should be gone - {:ok, _view_after, html} = live(conn, "/users") - refute html =~ "delete-me@example.com" + # 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"}) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/users") + + # Confirm user is displayed + assert render(view) =~ "delete-me@example.com" + + # Click the delete button (phx-click="delete" event) + view |> element("tbody tr:first-child a[data-confirm]") |> render_click() + + # Verify user was actually deleted (should not appear in HTML anymore) + html = render(view) + 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 @@ -147,14 +296,36 @@ defmodule MvWeb.UserLive.IndexTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/users") - # Row click navigates to show page (edit is on show page) + # Check that user row contains link to 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