Merge pull request 'Enhances accessibiity closes #421' (#450) from feat/421_accessibility into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #450
This commit is contained in:
commit
8fac974b1b
45 changed files with 2517 additions and 1214 deletions
|
|
@ -2775,6 +2775,14 @@ Building accessible applications ensures that all users, including those with di
|
|||
<div phx-click="action">Click me</div>
|
||||
```
|
||||
|
||||
**Tables (Core Component `<.table>` with `row_click`):**
|
||||
|
||||
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
|
||||
|
||||
**Empty table cells (missing values):**
|
||||
|
||||
- Do not use dashes ("-", "—", "–") or "n/a" as placeholders. Use CoreComponents `<.empty_cell sr_text="…">` for a cell with no value, or `<.maybe_value value={…} empty_sr_text="…">` when content is conditional. The cell is visually empty; screen readers get the `sr_text` (e.g. "No cycle", "No group assignment", "Not specified"). See Design Guidelines §8.6.
|
||||
|
||||
**Tab Order:**
|
||||
|
||||
- Ensure logical tab order matches visual order
|
||||
|
|
@ -2784,7 +2792,11 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
### 8.4 Color and Contrast
|
||||
|
||||
**Ensure Sufficient Contrast:**
|
||||
**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):**
|
||||
|
||||
- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button.
|
||||
- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient.
|
||||
- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states.
|
||||
|
||||
```elixir
|
||||
# Tailwind classes with sufficient contrast (4.5:1 minimum)
|
||||
|
|
@ -3003,24 +3015,56 @@ end
|
|||
- [ ] Skip links are available
|
||||
- [ ] Tables have proper structure (th, scope, caption)
|
||||
- [ ] ARIA labels used for icon-only buttons
|
||||
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
|
||||
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
|
||||
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.
|
||||
|
||||
### 8.11 DaisyUI Accessibility
|
||||
### 8.11 Modals and Dialogs
|
||||
|
||||
DaisyUI components are designed with accessibility in mind, but ensure:
|
||||
Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `<dialog>` so focus and semantics are correct (WCAG 2.4.3, 2.1.2).
|
||||
|
||||
**Structure and semantics:**
|
||||
|
||||
- Use `<dialog>` with DaisyUI classes `modal modal-open` when the modal is visible.
|
||||
- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose.
|
||||
- Give the title (e.g. `<h3>`) a unique `id` (e.g. `id="delete-role-modal-title"`).
|
||||
|
||||
**Focus management (WCAG 2.4.3):**
|
||||
|
||||
- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element:
|
||||
- If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group).
|
||||
- If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard.
|
||||
- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse.
|
||||
|
||||
**Layout and consistency:**
|
||||
|
||||
- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action).
|
||||
- Place Cancel (or neutral) first, primary/danger action second.
|
||||
- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons.
|
||||
|
||||
**Closing:**
|
||||
|
||||
- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`).
|
||||
- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
|
||||
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).
|
||||
|
||||
**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button).
|
||||
|
||||
### 8.12 DaisyUI Accessibility
|
||||
|
||||
DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure:
|
||||
|
||||
```heex
|
||||
<!-- Modal accessibility -->
|
||||
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
|
||||
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
|
||||
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
|
||||
<div class="modal-box">
|
||||
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
|
||||
<h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3>
|
||||
<p><%= gettext("Are you sure?") %></p>
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
||||
<.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}>
|
||||
<%= gettext("Cancel") %>
|
||||
</button>
|
||||
<button class="btn btn-error" phx-click="confirm-delete">
|
||||
<%= gettext("Delete") %>
|
||||
</button>
|
||||
</.button>
|
||||
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
|||
|
|
@ -293,6 +293,12 @@ Notes:
|
|||
- 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 `<thead>` 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.
|
||||
|
||||
### 8.6 Empty table cells (missing values)
|
||||
- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a").
|
||||
- **MUST NOT:** Use dashes ("-", "—", "–") or "n/a" as placeholders for empty cells.
|
||||
- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent).
|
||||
- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified".
|
||||
|
||||
---
|
||||
|
||||
## 9) Flash / Toast messages (mandatory UX)
|
||||
|
|
@ -331,14 +337,17 @@ 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)
|
||||
- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible.
|
||||
|
||||
**Recommended copy style:**
|
||||
- Title/confirm text is clear and specific (what will be deleted, consequences).
|
||||
- Buttons: `Cancel` (neutral) + `Delete` (danger).
|
||||
|
||||
### 10.3 Dialogs and modals (mandatory)
|
||||
- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `<dialog>` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element).
|
||||
- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse.
|
||||
- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout).
|
||||
|
||||
---
|
||||
|
||||
## 11) Detail pages (consistent structure)
|
||||
|
|
|
|||
|
|
@ -118,6 +118,138 @@
|
|||
color: oklch(0.45 0.2 25);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
|
||||
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
|
||||
outline badges always have a visible background in both themes. */
|
||||
[data-theme="light"] .badge.badge-outline,
|
||||
[data-theme="dark"] .badge.badge-outline {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
|
||||
Theme tokens *-content are often too light on * backgrounds in light theme, and
|
||||
badge-soft uses variant as text on a light tint (low contrast). We override
|
||||
--badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */
|
||||
|
||||
/* Light theme: use dark text on all colored badges (solid, soft, outline). */
|
||||
[data-theme="light"] .badge.badge-primary {
|
||||
--badge-fg: oklch(0.25 0.08 47);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-primary.badge-soft {
|
||||
color: oklch(0.38 0.14 47);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-success {
|
||||
--badge-fg: oklch(0.26 0.06 165);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-success.badge-soft {
|
||||
color: oklch(0.35 0.10 165);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-error {
|
||||
--badge-fg: oklch(0.22 0.08 25);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-error.badge-soft {
|
||||
color: oklch(0.38 0.14 25);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-warning {
|
||||
--badge-fg: oklch(0.28 0.06 75);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-warning.badge-soft {
|
||||
color: oklch(0.42 0.12 75);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-info {
|
||||
--badge-fg: oklch(0.26 0.08 250);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-info.badge-soft {
|
||||
color: oklch(0.38 0.12 250);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-neutral {
|
||||
--badge-fg: oklch(0.22 0.01 285);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-neutral.badge-soft {
|
||||
color: oklch(0.32 0.02 285);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-outline.badge-primary,
|
||||
[data-theme="light"] .badge.badge-outline.badge-success,
|
||||
[data-theme="light"] .badge.badge-outline.badge-error,
|
||||
[data-theme="light"] .badge.badge-outline.badge-warning,
|
||||
[data-theme="light"] .badge.badge-outline.badge-info,
|
||||
[data-theme="light"] .badge.badge-outline.badge-neutral {
|
||||
--badge-fg: oklch(0.25 0.02 285);
|
||||
}
|
||||
|
||||
/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1).
|
||||
Slightly darken solid variant backgrounds so theme *-content (light) passes. */
|
||||
[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.20 277);
|
||||
--badge-fg: oklch(0.97 0.02 277);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.10 185);
|
||||
--badge-fg: oklch(0.97 0.01 185);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.18 18);
|
||||
--badge-fg: oklch(0.97 0.02 18);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.48 0.14 58);
|
||||
--badge-fg: oklch(0.22 0.02 58);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.45 0.14 242);
|
||||
--badge-fg: oklch(0.97 0.02 242);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.32 0.02 257);
|
||||
--badge-fg: oklch(0.96 0.01 257);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); }
|
||||
[data-theme="dark"] .badge.badge-outline.badge-primary,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-success,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-error,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-warning,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-info,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-neutral {
|
||||
--badge-fg: oklch(0.92 0.02 257);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean).
|
||||
Inactive state uses base-content on a light/dark surface; active state ensures
|
||||
*-content on * background meets 4.5:1. */
|
||||
.member-filter-dropdown .join .btn {
|
||||
/* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */
|
||||
border-color: var(--color-base-300);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) {
|
||||
color: oklch(0.25 0.02 285);
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active {
|
||||
background-color: oklch(0.42 0.12 165);
|
||||
color: oklch(0.98 0.01 165);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active {
|
||||
background-color: oklch(0.42 0.18 18);
|
||||
color: oklch(0.98 0.02 18);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) {
|
||||
color: oklch(0.92 0.02 257);
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active {
|
||||
background-color: oklch(0.42 0.10 165);
|
||||
color: oklch(0.97 0.01 165);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active {
|
||||
background-color: oklch(0.42 0.18 18);
|
||||
color: oklch(0.97 0.02 18);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Sidebar Base Styles
|
||||
============================================ */
|
||||
|
|
@ -389,4 +521,31 @@
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 1.4.3: Primary button contrast (AA)
|
||||
============================================ */
|
||||
|
||||
/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand)
|
||||
meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary
|
||||
and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */
|
||||
|
||||
/* Light theme: primary is orange (brand); primary-content must be dark. */
|
||||
[data-theme="light"] {
|
||||
--color-primary-content: oklch(0.18 0.02 47);
|
||||
--color-error: oklch(55% 0.253 17.585);
|
||||
--color-error-content: oklch(98% 0 0);
|
||||
}
|
||||
|
||||
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
|
||||
[data-theme="dark"] {
|
||||
--color-error: oklch(55% 0.253 17.585);
|
||||
--color-error-content: oklch(98% 0 0);
|
||||
|
||||
--color-primary: oklch(72% 0.17 45);
|
||||
--color-primary-content: oklch(0.18 0.02 47);
|
||||
|
||||
--color-secondary: oklch(48% 0.233 277.117);
|
||||
--color-secondary-content: oklch(98% 0 0);
|
||||
}
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
|
|
|
|||
|
|
@ -73,6 +73,53 @@ Hooks.ComboBox = {
|
|||
}
|
||||
}
|
||||
|
||||
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
|
||||
// Enter and Space trigger a click so row_click tables are keyboard activatable
|
||||
Hooks.TableRowKeydown = {
|
||||
mounted() {
|
||||
this.handleKeydown = (e) => {
|
||||
if (
|
||||
e.target.getAttribute("data-row-clickable") === "true" &&
|
||||
(e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.target.click()
|
||||
}
|
||||
}
|
||||
this.el.addEventListener("keydown", this.handleKeydown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
|
||||
Hooks.FocusRestore = {
|
||||
mounted() {
|
||||
this.handleEvent("focus_restore", ({id}) => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) el.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
|
||||
Hooks.TabListKeydown = {
|
||||
mounted() {
|
||||
this.handleKeydown = (e) => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
this.el.addEventListener('keydown', this.handleKeydown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener('keydown', this.handleKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||
Hooks.SidebarState = {
|
||||
mounted() {
|
||||
|
|
|
|||
88
docs/badge-wcag-phase1-analysis.md
Normal file
88
docs/badge-wcag-phase1-analysis.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Phase 1 — Badge WCAG Analysis & Migration
|
||||
|
||||
## 1) Repo-Analyse (Stand vor Änderungen)
|
||||
|
||||
### Badge-Verwendungen (alle Fundstellen)
|
||||
|
||||
| Datei | Kontext | Markup |
|
||||
|-------|---------|--------|
|
||||
| `lib/mv_web/live/member_field_live/index_component.ex` | Tabelle (show_in_overview) | `<span class="badge badge-success">` / `<span class="badge badge-ghost">` |
|
||||
| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `<span class="badge badge-primary badge-sm">` (2×) |
|
||||
| `lib/mv_web/live/role_live/index.html.heex` | Tabelle (System Role, Permission Set, Custom) | `badge-warning`, `permission_set_badge_class()`, `badge-ghost` (User Count) |
|
||||
| `lib/mv_web/helpers/membership_fee_helpers.ex` | Helper | `status_color/1` → "badge-success" \| "badge-error" \| "badge-ghost" |
|
||||
| `lib/mv_web/live/member_live/show.ex` | Mitgliedsdetail (Beiträge) | `<span class={["badge", status_color(status)]}>`, `badge-ghost` (No cycles) |
|
||||
| `lib/mv_web/live/membership_fee_settings_live.ex` | Settings (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
|
||||
| `lib/mv_web/live/membership_fee_type_live/index.ex` | Index (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
|
||||
| `lib/mv_web/live/role_live/index.ex` | (Helper-Import) | `permission_set_badge_class/1` |
|
||||
| `lib/mv_web/live/member_live/show/membership_fees_component.ex` | Mitgliedsbeiträge | `badge-outline`, `["badge", status_color]` |
|
||||
| `lib/mv_web/live/custom_field_live/index_component.ex` | Tabelle (show_in_overview) | `badge-success`, `badge-ghost` |
|
||||
| `lib/mv_web/member_live/index/membership_fee_status.ex` | Helper | `format_cycle_status_badge/1` → map mit `color`, `icon`, `label` |
|
||||
| `lib/mv_web/live/global_settings_live.ex` | Form (label-text-alt) | `badge badge-ghost` "(set)" (2×) |
|
||||
| `lib/mv_web/live/member_live/index.html.heex` | Tabelle (Status) | `format_cycle_status_badge` + `<span class={["badge", badge.color]}>`, `badge-ghost` (No cycle), `badge-outline badge-primary` (Filter-Chip) |
|
||||
| `lib/mv_web/live/role_live/helpers.ex` | Helper | `permission_set_badge_class/1` → "badge badge-* badge-sm" |
|
||||
| `lib/mv_web/live/group_live/show.ex` | Card | `badge badge-outline badge` |
|
||||
| `lib/mv_web/live/role_live/show.ex` | Detail | `permission_set_badge_class`, `badge-warning` (System), `badge-ghost` (No) |
|
||||
|
||||
### DaisyUI/Tailwind Config
|
||||
|
||||
- **Tailwind:** `assets/tailwind.config.js` — erweitert nur `theme.extend.colors.brand`; kein DaisyUI hier.
|
||||
- **DaisyUI:** wird in `assets/css/app.css` per `@plugin "../vendor/daisyui"` mit `themes: false` geladen.
|
||||
- **Themes:** Zwei Custom-Themes in `app.css`:
|
||||
- `@plugin "../vendor/daisyui-theme"` mit `name: "dark"` (default: false)
|
||||
- `@plugin "../vendor/daisyui-theme"` mit `name: "light"` (default: true)
|
||||
- **Theme-Umschaltung:** `lib/mv_web/components/layouts/root.html.heex` — Inline-Script setzt `document.documentElement.setAttribute("data-theme", "light"|"dark")` aus `localStorage["phx:theme"]` oder `prefers-color-scheme`. Sidebar enthält Theme-Toggle (`<.theme_toggle />`).
|
||||
|
||||
### Core Components
|
||||
|
||||
- **Modul:** `lib/mv_web/components/core_components.ex` (MvWeb.CoreComponents).
|
||||
- **Vorhanden:** flash, button, dropdown_menu, form_section, input, header, table, icon, link, etc.
|
||||
- **Badge:** Bisher keine zentrale `<.badge>`-Komponente.
|
||||
|
||||
### DaisyUI Badge (Vendor)
|
||||
|
||||
- **Default:** `--badge-bg: var(--badge-color, var(--color-base-100))`, `--badge-fg: var(--color-base-content)`.
|
||||
- **badge-outline:** `--badge-bg: "#0000"` (transparent) → Kontrastproblem auf base-200/base-300.
|
||||
- **badge-ghost:** `background-color: var(--color-base-200)`, `color: var(--color-base-content)` → auf base-200-Flächen kaum sichtbar.
|
||||
- **badge-soft:** color-mix 8% Variante mit base-100 → sichtbar; Text ist Variantenfarbe (Kontrast prüfen).
|
||||
|
||||
---
|
||||
|
||||
## 2) Core Component <.badge> API (geplant)
|
||||
|
||||
- **attr :variant** — `:neutral | :primary | :info | :success | :warning | :error`
|
||||
- **attr :style** — `:soft | :solid | :outline` (Default: `:soft`)
|
||||
- **attr :size** — `:sm | :md` (Default: `:md`)
|
||||
- **slot :inner_block** — Badge-Text
|
||||
- **attr :sr_label** — optional, für Icon-only (Screen Reader)
|
||||
- **slot :icon** — optional
|
||||
|
||||
Regeln:
|
||||
|
||||
- `:soft` und `:solid` nutzen sichtbaren Hintergrund (kein transparenter Ghost als Default).
|
||||
- `:outline` setzt immer einen Hintergrund (z. B. `bg-base-100`), damit der Rand auf grauen Flächen sichtbar bleibt.
|
||||
- Ghost nur als explizites Opt-in; dann mit `bg-base-100` für Sichtbarkeit.
|
||||
|
||||
---
|
||||
|
||||
## 3) Theme-Overrides (WCAG)
|
||||
|
||||
- In `app.css` sind bereits Custom-Themes für `light` und `dark` mit eigenen Tokens.
|
||||
- **Badge-Kontrast (WCAG 2.2 AA 4.5:1):** Zusätzliche Overrides in `app.css`:
|
||||
- **Light theme:** Dunkle `--badge-fg` für alle Varianten (primary, success, error, warning, info, neutral); für `badge-soft` dunklere Textfarbe (`color`) auf getöntem Hintergrund; für `badge-outline` einheitlich dunkle Schrift auf base-100.
|
||||
- **Dark theme:** Leicht abgedunkelte Badge-Hintergründe für Solid-Badges, damit die hellen *-content-Farben 4.5:1 erreichen; für `badge-soft` hellere, gut lesbare Variantentöne; für `badge-outline` heller Text (`--badge-fg`) auf base-100.
|
||||
|
||||
---
|
||||
|
||||
## 4) Migration (erledigt)
|
||||
|
||||
- Alle `<span class="badge ...">` durch `<.badge variant="..." style="...">...</.badge>` ersetzt.
|
||||
- Klickbare Chips (z. B. Group Show „Remove“) bleiben als <.badge> mit Button im inner_block (Badge ist nur Container).
|
||||
- **Neue Helper:** `MembershipFeeHelpers.status_variant/1` (→ :success | :error | :warning; suspended = :warning wie Edit-Button), `RoleLive.Helpers.permission_set_badge_variant/1` (→ :neutral | :info | :success | :error).
|
||||
- **Angepasst:** `MembershipFeeStatus.format_cycle_status_badge/1` liefert zusätzlich `:variant` für <.badge>.
|
||||
- **Migrierte Stellen:** member_field_live, member_filter_component, role_live (index + show), member_live (show, index, membership_fees_component), membership_fee_settings_live, membership_fee_type_live, custom_field_live, global_settings_live, group_live/show.
|
||||
|
||||
## 5) Weitere Anpassungen (nach Phase 1)
|
||||
|
||||
- **Filter Join-Buttons (WCAG):** In `app.css` Kontrast-Overrides für `.member-filter-dropdown .join .btn` (inaktiv: base-100/base-200 + dunkle/helle Schrift; aktiv: success/error mit 4.5:1).
|
||||
- **Badge „Pausiert“ (suspended):** `status_variant(:suspended)` → `:warning` (gelb), damit Badge dieselbe Farbe wie der Edit-Button (btn-warning) hat.
|
||||
- **Filter-Dropdown schließen:** `phx-click-away` vom inneren Panel auf den äußeren Wrapper (`member-filter-dropdown`) verschoben; Klick auf den Filter-Button schließt das Dropdown (konsistent mit Spalten/Ausblenden).
|
||||
|
|
@ -31,6 +31,21 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
|
||||
@button_focus_classes [
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-offset-2",
|
||||
"focus-visible:ring-offset-base-100",
|
||||
"focus-visible:ring-base-content/60"
|
||||
]
|
||||
|
||||
@doc """
|
||||
Returns the shared focus ring class list for buttons and dropdown items (WCAG 2.4.7).
|
||||
Use when building custom dropdown item buttons so they match <.button> and dropdown trigger.
|
||||
"""
|
||||
def button_focus_classes, do: @button_focus_classes
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
|
|
@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do
|
|||
size_class = size_classes[size]
|
||||
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
||||
|
||||
assigns = assign(assigns, :btn_class, btn_class)
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:btn_class, btn_class)
|
||||
|> assign(:button_focus_classes, @button_focus_classes)
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
link_class =
|
||||
if assigns[:disabled],
|
||||
do: ["btn", btn_class, "btn-disabled"],
|
||||
else: ["btn", btn_class]
|
||||
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
|
||||
else: ["btn", btn_class] ++ @button_focus_classes
|
||||
|
||||
link_attrs =
|
||||
if assigns[:disabled] do
|
||||
|
|
@ -176,13 +194,187 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
|
||||
<button
|
||||
class={["btn", @btn_class] ++ @button_focus_classes}
|
||||
disabled={@disabled}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a non-interactive badge with WCAG-compliant contrast.
|
||||
|
||||
Use for status labels, counts, or tags. For clickable elements (e.g. filter chips),
|
||||
use a button or link component instead, not this badge.
|
||||
|
||||
## Variants and styles
|
||||
|
||||
- **variant:** `:neutral`, `:primary`, `:info`, `:success`, `:warning`, `:error`
|
||||
- **style:** `:soft` (default, tinted background), `:solid`, `:outline`
|
||||
- **size:** `:sm`, `:md` (default)
|
||||
|
||||
Outline and soft styles always use a visible background so the badge remains
|
||||
readable on base-200/base-300 surfaces (WCAG 2.2 AA). Ghost style is not exposed
|
||||
by default to avoid low-contrast on gray backgrounds.
|
||||
|
||||
## Examples
|
||||
|
||||
<.badge variant="success">Paid</.badge>
|
||||
<.badge variant="error" style="solid">Unpaid</.badge>
|
||||
<.badge variant="neutral" size="sm">Custom</.badge>
|
||||
<.badge variant="primary" style="outline">Label</.badge>
|
||||
<.badge variant="success" sr_label="Paid">
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
</.badge>
|
||||
"""
|
||||
attr :variant, :any,
|
||||
default: "neutral",
|
||||
doc: "Color variant: neutral | primary | info | success | warning | error (string or atom)"
|
||||
|
||||
attr :style, :any,
|
||||
default: "soft",
|
||||
doc: "Visual style: soft | solid | outline; :outline gets bg-base-100 for contrast"
|
||||
|
||||
attr :size, :any,
|
||||
default: "md",
|
||||
doc: "Badge size: sm | md"
|
||||
|
||||
attr :sr_label, :string,
|
||||
default: nil,
|
||||
doc: "Optional screen-reader label for icon-only content"
|
||||
|
||||
attr :rest, :global, doc: "Arbitrary HTML attributes (e.g. id, class, data-testid)"
|
||||
|
||||
slot :inner_block, required: true, doc: "Badge text (and optional icon)"
|
||||
slot :icon, doc: "Optional leading icon slot"
|
||||
|
||||
def badge(assigns) do
|
||||
# Normalize so both HEEx strings (variant="neutral") and helper atoms (variant={:neutral}) work
|
||||
variant = to_string(assigns.variant || "neutral")
|
||||
style = to_string(assigns.style || "soft")
|
||||
size = to_string(assigns.size || "md")
|
||||
|
||||
variant_class = "badge-#{variant}"
|
||||
style_class = badge_style_class(style)
|
||||
size_class = "badge-#{size}"
|
||||
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
||||
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
||||
|
||||
rest = assigns.rest || []
|
||||
rest = if is_list(rest), do: rest, else: Map.to_list(rest)
|
||||
extra_class = Keyword.get(rest, :class)
|
||||
rest = Keyword.drop(rest, [:class])
|
||||
rest = if assigns.sr_label, do: Keyword.put(rest, :"aria-label", assigns.sr_label), else: rest
|
||||
|
||||
class =
|
||||
["badge", variant_class, style_class, size_class, outline_bg, extra_class]
|
||||
|> List.flatten()
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join(" ")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:class, class)
|
||||
|> assign(:rest, rest)
|
||||
|> assign(:has_icon, assigns.icon != [])
|
||||
|
||||
~H"""
|
||||
<span class={@class} {@rest}>
|
||||
<%= if @has_icon do %>
|
||||
{render_slot(@icon)}
|
||||
<% end %>
|
||||
{render_slot(@inner_block)}
|
||||
<%= if @sr_label do %>
|
||||
<span class="sr-only">{@sr_label}</span>
|
||||
<% end %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp badge_style_class("soft"), do: "badge-soft"
|
||||
defp badge_style_class("solid"), do: nil
|
||||
defp badge_style_class("outline"), do: "badge-outline"
|
||||
defp badge_style_class(_), do: nil
|
||||
|
||||
@doc """
|
||||
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
||||
|
||||
Use when a table cell has no value so that:
|
||||
- The cell appears empty (no dash, no "n/a").
|
||||
- Screen readers still get a meaningful label (e.g. "No cycle", "No group assignment").
|
||||
|
||||
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
|
||||
|
||||
## Examples
|
||||
|
||||
<.empty_cell sr_text={gettext("No cycle")} />
|
||||
<.empty_cell sr_text={gettext("No group assignment")} />
|
||||
<.empty_cell sr_text={gettext("Not specified")} />
|
||||
"""
|
||||
attr :sr_text, :string,
|
||||
required: true,
|
||||
doc: "Text read by screen readers when the cell is visually empty"
|
||||
|
||||
def empty_cell(assigns) do
|
||||
~H"""
|
||||
<span class="sr-only">{@sr_text}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders content when value is present, otherwise an accessible empty cell.
|
||||
|
||||
Use in table cells for optional fields: when `value` is blank, only the
|
||||
screen-reader text is shown (visually empty). Otherwise the inner block is rendered.
|
||||
|
||||
Blank check: `nil`, `false`, `[]`, `""`, whitespace-only string, or `%Ash.NotLoaded{}` count as empty.
|
||||
|
||||
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
|
||||
|
||||
## Examples
|
||||
|
||||
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("No fee type")}>
|
||||
{member.membership_fee_type.name}
|
||||
</.maybe_value>
|
||||
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||
<%= for g <- member.groups do %>
|
||||
<.badge variant="primary" style="outline">{g.name}</.badge>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
"""
|
||||
attr :value, :any, doc: "Value to check; if blank, empty_cell is rendered"
|
||||
|
||||
attr :empty_sr_text, :string,
|
||||
default: nil,
|
||||
doc: "Screen-reader text when value is blank (default: gettext \"Not specified\")"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def maybe_value(assigns) do
|
||||
empty_sr = assigns.empty_sr_text || gettext("Not specified")
|
||||
assigns = assign(assigns, :empty_sr_text, empty_sr)
|
||||
assigns = assign(assigns, :blank?, value_blank?(assigns.value))
|
||||
|
||||
~H"""
|
||||
<%= if @blank? do %>
|
||||
<.empty_cell sr_text={@empty_sr_text} />
|
||||
<% else %>
|
||||
{render_slot(@inner_block)}
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp value_blank?(nil), do: true
|
||||
defp value_blank?(false), do: true
|
||||
defp value_blank?([]), do: true
|
||||
defp value_blank?(%Ash.NotLoaded{}), do: true
|
||||
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
|
||||
defp value_blank?(_), do: false
|
||||
|
||||
@doc """
|
||||
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
|
||||
or status badges that need explanation (Design Guidelines §8.2).
|
||||
|
|
@ -265,7 +457,11 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def dropdown_menu(assigns) do
|
||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||
assigns = assign(assigns, :menu_testid, menu_testid)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:menu_testid, menu_testid)
|
||||
|> assign(:button_focus_classes, @button_focus_classes)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
|
|
@ -281,17 +477,10 @@ defmodule MvWeb.CoreComponents do
|
|||
tabindex="0"
|
||||
role="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={@open}
|
||||
aria-expanded={if @open, do: "true", else: "false"}
|
||||
aria-controls={@id}
|
||||
aria-label={@button_label}
|
||||
class={[
|
||||
"btn",
|
||||
"focus:outline-none",
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-offset-2",
|
||||
"focus-visible:ring-base-content/20",
|
||||
@button_class
|
||||
]}
|
||||
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
data-testid={@button_testid}
|
||||
|
|
@ -359,7 +548,12 @@ defmodule MvWeb.CoreComponents do
|
|||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={
|
||||
[
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left",
|
||||
"focus-visible:ring-inset"
|
||||
] ++ @button_focus_classes
|
||||
}
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
|
|
@ -670,6 +864,8 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
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).
|
||||
For keyboard accessibility (WCAG 2.1.1), the first column without `col_click` gets
|
||||
`tabindex="0"` and `role="button"`; the TableRowKeydown hook triggers the row action on Enter/Space.
|
||||
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).
|
||||
|
||||
|
|
@ -752,8 +948,22 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
||||
|
||||
# WCAG 2.1.1: when row_click is set, first column without col_click gets tabindex="0"
|
||||
# so rows are reachable via Tab; TableRowKeydown hook triggers click on Enter/Space
|
||||
first_row_click_col_idx =
|
||||
if assigns[:row_click] do
|
||||
Enum.find_index(assigns[:col] || [], fn c -> !c[:col_click] end)
|
||||
end
|
||||
|
||||
assigns =
|
||||
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
||||
|
||||
~H"""
|
||||
<div class="overflow-auto">
|
||||
<div
|
||||
id={@row_click && "#{@id}-keyboard"}
|
||||
class="overflow-auto"
|
||||
phx-hook={@row_click && "TableRowKeydown"}
|
||||
>
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -789,6 +999,11 @@ defmodule MvWeb.CoreComponents do
|
|||
>
|
||||
<td
|
||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||
tabindex={if @row_click && @first_row_click_col_idx == col_idx, do: 0, else: nil}
|
||||
role={if @row_click && @first_row_click_col_idx == col_idx, do: "button", else: nil}
|
||||
data-row-clickable={
|
||||
if @row_click && @first_row_click_col_idx == col_idx, do: "true", else: nil
|
||||
}
|
||||
phx-click={
|
||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||
(@row_click && @row_click.(row))
|
||||
|
|
@ -812,6 +1027,19 @@ defmodule MvWeb.CoreComponents do
|
|||
classes
|
||||
end
|
||||
|
||||
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
|
||||
classes =
|
||||
if @row_click && @first_row_click_col_idx == col_idx do
|
||||
[
|
||||
"focus:outline-none",
|
||||
"focus-visible:outline-none",
|
||||
"focus:ring-0",
|
||||
"focus-visible:ring-0" | classes
|
||||
]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
classes =
|
||||
if col_class do
|
||||
[col_class | classes]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
|
|
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
|
|
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -219,6 +219,17 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
|||
def status_color(:unpaid), do: "badge-error"
|
||||
def status_color(:suspended), do: "badge-ghost"
|
||||
|
||||
@doc """
|
||||
Returns the Core Components badge variant for a cycle status (WCAG-compliant).
|
||||
|
||||
Use with <.badge variant={MembershipFeeHelpers.status_variant(status)}>.
|
||||
Suspended uses :warning (yellow) to match the edit cycle-status button.
|
||||
"""
|
||||
@spec status_variant(:paid | :unpaid | :suspended) :: :success | :error | :warning
|
||||
def status_variant(:paid), do: :success
|
||||
def status_variant(:unpaid), do: :error
|
||||
def status_variant(:suspended), do: :warning
|
||||
|
||||
@doc """
|
||||
Gets the icon name for a status.
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="relative"
|
||||
class="relative member-filter-dropdown"
|
||||
id={@id}
|
||||
phx-click-away={if @open, do: "close_dropdown", else: nil}
|
||||
phx-window-keydown={@open && "close_dropdown"}
|
||||
phx-key="Escape"
|
||||
phx-target={@myself}
|
||||
|
|
@ -89,21 +90,23 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
@boolean_filters
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
<.badge
|
||||
:if={active_boolean_filters_count(@boolean_filters) > 0}
|
||||
class="badge badge-primary badge-sm"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{active_boolean_filters_count(@boolean_filters)}
|
||||
</span>
|
||||
<span
|
||||
</.badge>
|
||||
<.badge
|
||||
:if={
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0) &&
|
||||
active_boolean_filters_count(@boolean_filters) == 0
|
||||
}
|
||||
class="badge badge-primary badge-sm"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{@member_count}
|
||||
</span>
|
||||
</.badge>
|
||||
</.button>
|
||||
|
||||
<!--
|
||||
|
|
@ -118,8 +121,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
:if={@open}
|
||||
tabindex="0"
|
||||
class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]"
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@myself}
|
||||
role="dialog"
|
||||
aria-label={gettext("Member filter")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-custom-field-trigger"
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click="request_delete"
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||
|
||||
~H"""
|
||||
<div id={@id} class="mt-8">
|
||||
<div class="flex">
|
||||
<div id={@id}>
|
||||
<div :if={!@show_form} class="flex">
|
||||
<p class="text-sm text-base-content/70">
|
||||
{gettext("These will appear in addition to other data when adding new members.")}
|
||||
</p>
|
||||
|
|
@ -54,6 +54,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
<.table
|
||||
id="custom_fields_table"
|
||||
rows={@streams.custom_fields}
|
||||
row_id={fn {_stream_key, cf} -> "custom_fields-#{cf.id}" end}
|
||||
row_click={
|
||||
fn {_id, custom_field} ->
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
|
|
@ -89,20 +90,29 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
label={gettext("Show in overview")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
||||
<.badge :if={custom_field.show_in_overview} variant="success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
||||
</.badge>
|
||||
<.badge :if={!custom_field.show_in_overview} variant="neutral">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<dialog
|
||||
:if={@show_delete_modal}
|
||||
id="delete-custom-field-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-custom-field-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
|
||||
<h3 id="delete-custom-field-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete Data Field")}
|
||||
</h3>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
|
|
@ -110,15 +120,15 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
<div>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
"%{count} member has a value assigned for this datafield.",
|
||||
"%{count} members have values assigned for this datafield.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
"All datafield values will be permanently deleted when you delete this datafield."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -184,8 +194,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# Track previous show_form state to detect when form is closed
|
||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
||||
# Use socket state so send_update(open_delete_for_id: ...) does not trigger false "form closed"
|
||||
previous_show_form = socket.assigns[:show_form] || false
|
||||
|
||||
# If show_form is explicitly provided in assigns, reset editing state
|
||||
socket =
|
||||
|
|
@ -197,13 +207,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
socket
|
||||
end
|
||||
|
||||
# Detect when form is closed (show_form changes from true to false)
|
||||
new_show_form = Map.get(assigns, :show_form, false)
|
||||
|
||||
if previous_show_form and not new_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
# Get actor from assigns or fall back to socket assigns
|
||||
actor = Map.get(assigns, :actor, socket.assigns[:actor])
|
||||
|
||||
|
|
@ -225,6 +228,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
socket
|
||||
|
||||
id ->
|
||||
send(self(), {:custom_field_delete_modal_open, true})
|
||||
|
||||
custom_field =
|
||||
Ash.get!(Mv.Membership.CustomField, id,
|
||||
load: [:assigned_members_count],
|
||||
|
|
@ -238,6 +243,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|> assign(:open_delete_for_id, nil)
|
||||
end
|
||||
|
||||
# Detect form closed only from final socket state (not from assigns alone)
|
||||
current_show_form = socket.assigns[:show_form] || false
|
||||
|
||||
if previous_show_form and not current_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
|
|
@ -282,6 +294,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
actor: actor
|
||||
)
|
||||
|
||||
send(self(), {:custom_field_delete_modal_open, true})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_to_delete, custom_field)
|
||||
|
|
@ -302,6 +316,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||
case Ash.destroy(custom_field, actor: actor) do
|
||||
:ok ->
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
send(self(), {:custom_field_deleted, custom_field})
|
||||
|
||||
{:noreply,
|
||||
|
|
@ -312,6 +327,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|> stream_delete(:custom_fields, custom_field)}
|
||||
|
||||
{:error, error} ->
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
send(self(), {:custom_field_delete_error, error})
|
||||
|
||||
{:noreply,
|
||||
|
|
@ -321,6 +337,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
else
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
send(self(), :custom_field_slug_mismatch)
|
||||
|
||||
{:noreply,
|
||||
|
|
@ -333,10 +350,22 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
|
|||
socket
|
||||
|> assign(:page_title, gettext("Datafields"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)}
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign(:custom_field_delete_modal_open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("window_keydown", %{"key" => key}, socket)
|
||||
when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:custom_field_delete_modal_open] do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
show_delete_modal: false,
|
||||
custom_field_to_delete: nil,
|
||||
slug_confirmation: ""
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_delete_modal_open, false)
|
||||
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -29,31 +52,68 @@ defmodule MvWeb.DatafieldsLive do
|
|||
<.header>
|
||||
{gettext("Datafields")}
|
||||
<:subtitle>
|
||||
{gettext("Configure member fields and custom data fields.")}
|
||||
{gettext(
|
||||
"Configure which data you want to save for your members. Define individual datafields."
|
||||
)}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form_section title={gettext("Member fields")}>
|
||||
<%!-- Overview: both sections with form_section wrappers; FocusRestore for custom field delete modal --%>
|
||||
<div
|
||||
:if={@active_editing_section == nil}
|
||||
id="datafields-focus-root"
|
||||
class="mt-6 space-y-6"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :custom_fields}
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
</.form_section>
|
||||
|
||||
<.form_section title={gettext("Custom fields")}>
|
||||
<.form_section title={gettext("Individual Datafields")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :member_fields}
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Edit mode: only the active section, no section title/card wrapper --%>
|
||||
<div :if={@active_editing_section == :member_fields} class="mt-6">
|
||||
<.live_component
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@active_editing_section == :custom_fields}
|
||||
id="datafields-focus-root"
|
||||
class="mt-6"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_delete_modal_open, open}, socket) do
|
||||
{:noreply, assign(socket, :custom_field_delete_modal_open, open)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
|
|
|
|||
|
|
@ -124,7 +124,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
<label class="label" for={@form[:vereinfacht_api_key].id}>
|
||||
<span class="label-text">{gettext("API Key")}</span>
|
||||
<%= if @vereinfacht_api_key_set do %>
|
||||
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||
<span class="label-text-alt">
|
||||
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
||||
</span>
|
||||
<% end %>
|
||||
</label>
|
||||
<.input
|
||||
|
|
@ -251,7 +253,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
<label class="label" for={@form[:oidc_client_secret].id}>
|
||||
<span class="label-text">{gettext("Client Secret")}</span>
|
||||
<%= if @oidc_client_secret_set do %>
|
||||
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||
<span class="label-text-alt">
|
||||
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
||||
</span>
|
||||
<% end %>
|
||||
</label>
|
||||
<.input
|
||||
|
|
|
|||
|
|
@ -68,11 +68,9 @@ defmodule MvWeb.GroupLive.Index do
|
|||
{group.name}
|
||||
</:col>
|
||||
<:col :let={group} label={gettext("Description")}>
|
||||
<%= if group.description do %>
|
||||
<.maybe_value value={group.description} empty_sr_text={gettext("Not specified")}>
|
||||
{group.description}
|
||||
<% else %>
|
||||
<span class="text-base-content/50 italic">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:col :let={group} label={gettext("Members")} class="text-right">
|
||||
{group.member_count || 0}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,12 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<div
|
||||
id="group-show-focus-root"
|
||||
class="mt-6 space-y-6"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<%!-- Group Information --%>
|
||||
<div class="max-w-2xl space-y-6 mb-6">
|
||||
<div>
|
||||
|
|
@ -150,7 +155,11 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<div class="relative">
|
||||
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
||||
<%= for member <- @selected_members do %>
|
||||
<span class="badge badge-outline badge flex items-center gap-1">
|
||||
<.badge
|
||||
variant="primary"
|
||||
style="outline"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||
<.tooltip content={gettext("Remove")} position="top">
|
||||
<.button
|
||||
|
|
@ -169,7 +178,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<.icon name="hero-x-mark" class="size-3" />
|
||||
</.button>
|
||||
</.tooltip>
|
||||
</span>
|
||||
</.badge>
|
||||
<% end %>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -300,16 +309,14 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</.link>
|
||||
</td>
|
||||
<td>
|
||||
<%= if member.email do %>
|
||||
<.maybe_value value={member.email} empty_sr_text={gettext("No email")}>
|
||||
<a
|
||||
href={"mailto:#{member.email}"}
|
||||
class="link link-primary"
|
||||
>
|
||||
{member.email}
|
||||
</a>
|
||||
<% else %>
|
||||
<span class="text-base-content/50 italic">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</td>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<td>
|
||||
|
|
@ -351,6 +358,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-group-trigger"
|
||||
variant="danger"
|
||||
type="button"
|
||||
phx-click="open_delete_modal"
|
||||
|
|
@ -364,11 +372,19 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
|
||||
<dialog
|
||||
id="delete-group-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-group-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
|
||||
<h3 id="delete-group-modal-title" class="text-lg font-bold mb-4">
|
||||
{gettext("Delete Group")}
|
||||
</h3>
|
||||
<p class="mb-4">
|
||||
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
|
||||
</p>
|
||||
|
|
@ -403,6 +419,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
placeholder={gettext("Enter the group name to confirm")}
|
||||
autocomplete="off"
|
||||
phx-debounce="200"
|
||||
phx-mounted={JS.focus()}
|
||||
class="w-full input input-bordered"
|
||||
/>
|
||||
</form>
|
||||
|
|
@ -443,12 +460,25 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:name_confirmation, "")}
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
|
||||
{:noreply, assign(socket, :name_confirmation, name)}
|
||||
|
|
@ -929,6 +959,13 @@ defmodule MvWeb.GroupLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:name_confirmation, "")
|
||||
|> push_event("focus_restore", %{id: "delete-group-trigger"})
|
||||
end
|
||||
|
||||
defp perform_group_deletion(socket, group, actor) do
|
||||
case Membership.destroy_group(group, actor: actor) do
|
||||
:ok ->
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
<p :if={!@show_form} class="text-sm text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
|
||||
)}
|
||||
|
|
@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
:if={!@show_form}
|
||||
id="member_fields"
|
||||
rows={@member_fields}
|
||||
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
|
||||
row_click={
|
||||
fn {field_name, _field_data} ->
|
||||
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
||||
|
|
@ -85,12 +86,12 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
label={gettext("Show in overview")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span :if={field_data.show_in_overview} class="badge badge-success">
|
||||
<.badge :if={field_data.show_in_overview} variant="success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
|
||||
</.badge>
|
||||
<.badge :if={!field_data.show_in_overview} variant="neutral">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
|
@ -99,8 +100,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# Track previous show_form state to detect when form is closed
|
||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
||||
# Use socket state so send_update(show_form: false) is the only trigger for "form closed"
|
||||
previous_show_form = socket.assigns[:show_form] || false
|
||||
|
||||
# If show_form is explicitly provided in assigns, reset editing state
|
||||
socket =
|
||||
|
|
@ -112,20 +113,22 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
socket
|
||||
end
|
||||
|
||||
# Detect when form is closed (show_form changes from true to false)
|
||||
new_show_form = Map.get(assigns, :show_form, false)
|
||||
|
||||
if previous_show_form and not new_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:settings, fn -> get_settings() end)
|
||||
|> assign_new(:show_form, fn -> false end)
|
||||
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|
||||
|> assign_new(:editing_member_field, fn -> nil end)}
|
||||
|> assign_new(:editing_member_field, fn -> nil end)
|
||||
|
||||
# Detect form closed only from final socket state (not from assigns alone)
|
||||
current_show_form = socket.assigns[:show_form] || false
|
||||
|
||||
if previous_show_form and not current_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ defmodule MvWeb.MemberLive.Form do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
||||
<div
|
||||
id="member-form-focus-root"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.header>
|
||||
<:leading>
|
||||
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
||||
|
|
@ -58,14 +63,31 @@ defmodule MvWeb.MemberLive.Form do
|
|||
</.header>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
|
||||
<div
|
||||
role="tablist"
|
||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||
>
|
||||
<button
|
||||
id="member-tab-contact"
|
||||
role="tab"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-selected="true"
|
||||
aria-controls="member-tabpanel-contact"
|
||||
class="tab tab-active flex items-center gap-2"
|
||||
>
|
||||
<.icon name="hero-identification" class="size-4 shrink-0" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Contact Data Tab Content (same structure as member show) --%>
|
||||
<div
|
||||
id="member-tabpanel-contact"
|
||||
role="tabpanel"
|
||||
aria-labelledby="member-tab-contact"
|
||||
>
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
|
|
@ -164,7 +186,9 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<%= 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 %>
|
||||
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
||||
<div class={
|
||||
if cf.value_type == :boolean, do: "flex items-end", else: ""
|
||||
}>
|
||||
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||
<.input
|
||||
field={value_form[:value]}
|
||||
|
|
@ -258,15 +282,10 @@ defmodule MvWeb.MemberLive.Form do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-member-form-trigger"
|
||||
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)
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
|
|
@ -280,6 +299,51 @@ defmodule MvWeb.MemberLive.Form do
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= if @member && assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-member-form-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-member-form-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete Member")}
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-member-form-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @member.id})}
|
||||
aria-label={gettext("Delete member")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
|
|
@ -329,6 +393,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> assign(:member_field_required_map, member_field_required_map)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -400,6 +465,32 @@ defmodule MvWeb.MemberLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
member = socket.assigns.member
|
||||
|
|
@ -407,10 +498,16 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
cond do
|
||||
is_nil(member) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Member not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
to_string(id) != to_string(member.id) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Member not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
true ->
|
||||
handle_member_delete_destroy(socket, member, actor)
|
||||
|
|
@ -427,14 +524,26 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
|
||||
socket
|
||||
|> put_flash(:error, gettext("You do not have permission to delete this member"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
{:noreply, put_flash(socket, :error, format_destroy_error(error))}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_destroy_error(error))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-member-form-trigger"})
|
||||
end
|
||||
|
||||
defp handle_save_success(socket, member) do
|
||||
notify_parent({:saved, member})
|
||||
|
||||
|
|
|
|||
|
|
@ -1644,11 +1644,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
||||
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
|
||||
|
||||
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
|
||||
mailto_bcc =
|
||||
if any_selected? do
|
||||
format_selected_member_emails(members, selected_members)
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode_www_form()
|
||||
|> String.replace("+", "%20")
|
||||
else
|
||||
""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -356,26 +356,24 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
<%= if member.membership_fee_type do %>
|
||||
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("Not specified")}>
|
||||
{member.membership_fee_type.name}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<: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)
|
||||
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
|
||||
MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
||||
) do %>
|
||||
<span class={["badge", badge.color]}>
|
||||
<.badge variant={badge.variant}>
|
||||
<.icon name={badge.icon} class="size-4" />
|
||||
{badge.label}
|
||||
</span>
|
||||
</.badge>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
||||
<.empty_cell sr_text={gettext("No cycle")} />
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col
|
||||
|
|
@ -394,17 +392,17 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||
<%= for group <- (member.groups || []) do %>
|
||||
<span
|
||||
class="badge badge-outline badge-primary"
|
||||
<.badge
|
||||
variant="primary"
|
||||
style="outline"
|
||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if (member.groups || []) == [] do %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
</.badge>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
|
|
@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
||||
<div
|
||||
id="member-show-focus-root"
|
||||
class="mt-6 space-y-6"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<%!-- Tab Navigation: roving tabindex (only active tab tabindex="0"), ArrowLeft/ArrowRight (WCAG tab pattern) --%>
|
||||
<div
|
||||
id="member-tablist"
|
||||
role="tablist"
|
||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||
phx-hook="TabListKeydown"
|
||||
phx-keydown="tab_keydown"
|
||||
>
|
||||
<button
|
||||
id="member-tab-contact"
|
||||
role="tab"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-selected={@active_tab == :contact}
|
||||
tabindex={if @active_tab == :contact, do: "0", else: "-1"}
|
||||
aria-selected={if @active_tab == :contact, do: "true", else: "false"}
|
||||
aria-controls="member-tabpanel-contact"
|
||||
class={[
|
||||
"tab flex items-center gap-2",
|
||||
|
|
@ -82,8 +90,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
id="member-tab-membership_fees"
|
||||
role="tab"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-selected={@active_tab == :membership_fees}
|
||||
tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"}
|
||||
aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"}
|
||||
aria-controls="member-tabpanel-membership_fees"
|
||||
class={[
|
||||
"tab flex items-center gap-2",
|
||||
|
|
@ -254,22 +262,24 @@ defmodule MvWeb.MemberLive.Show do
|
|||
/>
|
||||
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
||||
<%= if @member.last_cycle_status do %>
|
||||
<% status = @member.last_cycle_status %>
|
||||
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
||||
{format_status_label(status)}
|
||||
</span>
|
||||
<.badge variant={
|
||||
MembershipFeeHelpers.status_variant(@member.last_cycle_status)
|
||||
}>
|
||||
{format_status_label(@member.last_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
||||
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
<.data_field label={gettext("Current Cycle")} class="min-w-36">
|
||||
<%= if @member.current_cycle_status do %>
|
||||
<% status = @member.current_cycle_status %>
|
||||
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
||||
{format_status_label(status)}
|
||||
</span>
|
||||
<.badge variant={
|
||||
MembershipFeeHelpers.status_variant(@member.current_cycle_status)
|
||||
}>
|
||||
{format_status_label(@member.current_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
||||
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
|
@ -313,14 +323,9 @@ defmodule MvWeb.MemberLive.Show do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-member-trigger"
|
||||
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)
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
|
|
@ -334,6 +339,48 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-member-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-member-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-member-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete Member")}
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-member-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @member.id})}
|
||||
aria-label={gettext("Delete member")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -344,7 +391,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:ok,
|
||||
socket
|
||||
|> assign(:active_tab, :contact)
|
||||
|> assign(:vereinfacht_receipts, nil)}
|
||||
|> assign(:vereinfacht_receipts, nil)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -396,13 +444,58 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("tab_keydown", %{"key" => key}, socket)
|
||||
when key in ["ArrowLeft", "ArrowRight"] do
|
||||
new_tab =
|
||||
case {key, socket.assigns.active_tab} do
|
||||
{"ArrowRight", :contact} -> :membership_fees
|
||||
{"ArrowLeft", :membership_fees} -> :contact
|
||||
_ -> socket.assigns.active_tab
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :active_tab, new_tab)}
|
||||
end
|
||||
|
||||
def handle_event("tab_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
# Escape closes modal (WCAG). phx-window-keydown ensures Escape is captured regardless of focus.
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@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"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Member not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
else
|
||||
case Ash.destroy(member, actor: actor) do
|
||||
:ok ->
|
||||
|
|
@ -413,16 +506,21 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("You do not have permission to delete this member")
|
||||
)}
|
||||
)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, error} ->
|
||||
require Logger
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_error(error))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -437,6 +535,13 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
||||
end
|
||||
|
||||
# WCAG 2.4.3: when modal closes, return focus to the trigger (Delete member button)
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-member-trigger"})
|
||||
end
|
||||
|
||||
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||
@impl true
|
||||
def handle_info({:put_flash, type, message}, socket) do
|
||||
|
|
@ -503,7 +608,11 @@ defmodule MvWeb.MemberLive.Show do
|
|||
<%= if @inner_block != [] do %>
|
||||
{render_slot(@inner_block)}
|
||||
<% else %>
|
||||
{display_value(@value)}
|
||||
<%= if value_blank?(@value) do %>
|
||||
<.empty_cell sr_text={gettext("Not set")} />
|
||||
<% else %>
|
||||
{@value}
|
||||
<% end %>
|
||||
<% end %>
|
||||
</dd>
|
||||
</dl>
|
||||
|
|
@ -537,9 +646,9 @@ defmodule MvWeb.MemberLive.Show do
|
|||
# Helper Functions
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
defp display_value(nil), do: render_empty_value()
|
||||
defp display_value(""), do: render_empty_value()
|
||||
defp display_value(value), do: value
|
||||
defp value_blank?(nil), do: true
|
||||
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
|
||||
defp value_blank?(_), do: false
|
||||
|
||||
defp format_status_label(:paid), do: gettext("Paid")
|
||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||
|
|
@ -628,10 +737,10 @@ defmodule MvWeb.MemberLive.Show do
|
|||
if String.trim(value) == "" do
|
||||
render_empty_value()
|
||||
else
|
||||
assigns = %{email: value}
|
||||
assigns = %{email: value, display: value}
|
||||
|
||||
~H"""
|
||||
<.mailto_link email={@email} display={@email} />
|
||||
<.mailto_link email={@email} display={@display} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -646,17 +755,10 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
defp format_custom_field_value(value, _type), do: to_string(value)
|
||||
|
||||
# Renders accessible placeholder for empty values
|
||||
# Uses translated text for screen readers while maintaining visual consistency
|
||||
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
|
||||
# Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines §8.6).
|
||||
# Returns safe HTML so it can be used from helpers without LiveView assigns.
|
||||
defp render_empty_value do
|
||||
assigns = %{text: gettext("Not set")}
|
||||
|
||||
~H"""
|
||||
<span class="text-base-content/50 italic">
|
||||
<span aria-hidden="true">—</span>
|
||||
<span class="sr-only">{@text}</span>
|
||||
</span>
|
||||
"""
|
||||
text = gettext("Not set")
|
||||
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -101,7 +101,13 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<%= for r <- receipts do %>
|
||||
<tr>
|
||||
<%= for {col_key, _header_key} <- cols do %>
|
||||
<td>{format_receipt_cell(col_key, r[col_key])}</td>
|
||||
<td>
|
||||
<%= if (cell_content = format_receipt_cell(col_key, r[col_key])) != nil do %>
|
||||
{cell_content}
|
||||
<% else %>
|
||||
<.empty_cell sr_text={receipt_empty_sr_text(col_key)} />
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
@ -186,9 +192,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
<.badge variant="neutral" style="outline">
|
||||
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Amount")}>
|
||||
|
|
@ -208,12 +214,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Status")}>
|
||||
<% badge = MembershipFeeHelpers.status_color(cycle.status) %>
|
||||
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
|
||||
<span class={["badge", badge]}>
|
||||
<.icon name={icon} class="size-4" />
|
||||
<.badge variant={MembershipFeeHelpers.status_variant(cycle.status)}>
|
||||
<.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-4" />
|
||||
{format_status_label(cycle.status)}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:action :let={cycle}>
|
||||
|
|
@ -227,7 +231,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :paid)}
|
||||
aria-pressed={cycle.status == :paid}
|
||||
aria-pressed={if cycle.status == :paid, do: "true", else: "false"}
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
|
|
@ -240,7 +244,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :suspended)}
|
||||
aria-pressed={cycle.status == :suspended}
|
||||
aria-pressed={if cycle.status == :suspended, do: "true", else: "false"}
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
|
|
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :unpaid)}
|
||||
aria-pressed={cycle.status == :unpaid}
|
||||
aria-pressed={if cycle.status == :unpaid, do: "true", else: "false"}
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
|
|
@ -290,11 +294,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<% end %>
|
||||
</.section_box>
|
||||
|
||||
<%!-- Edit Cycle Amount Modal --%>
|
||||
<%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if @editing_cycle do %>
|
||||
<dialog id="edit-cycle-amount-modal" class="modal modal-open">
|
||||
<dialog
|
||||
id="edit-cycle-amount-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="edit-cycle-amount-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Edit Cycle Amount")}</h3>
|
||||
<h3 id="edit-cycle-amount-modal-title" class="text-lg font-bold">
|
||||
{gettext("Edit Cycle Amount")}
|
||||
</h3>
|
||||
<form phx-submit="save_cycle_amount" phx-target={@myself}>
|
||||
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
|
||||
<div class="form-control w-full mt-4">
|
||||
|
|
@ -310,6 +322,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
|
|
@ -328,11 +341,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Cycle Confirmation Modal --%>
|
||||
<%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if @deleting_cycle do %>
|
||||
<dialog id="delete-cycle-modal" class="modal modal-open">
|
||||
<dialog
|
||||
id="delete-cycle-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-cycle-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
|
||||
<h3 id="delete-cycle-modal-title" class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
|
||||
<p class="py-4">
|
||||
{gettext("Are you sure you want to delete this cycle?")}
|
||||
</p>
|
||||
|
|
@ -343,7 +362,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
|
||||
<.button
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_cycle"
|
||||
phx-target={@myself}
|
||||
phx-mounted={JS.focus()}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
|
|
@ -359,11 +383,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete All Cycles Confirmation Modal --%>
|
||||
<%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if @deleting_all_cycles do %>
|
||||
<dialog id="delete-all-cycles-modal" class="modal modal-open">
|
||||
<dialog
|
||||
id="delete-all-cycles-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-all-cycles-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
|
||||
<h3 id="delete-all-cycles-modal-title" class="text-lg font-bold text-error">
|
||||
{gettext("Delete All Cycles")}
|
||||
</h3>
|
||||
<div class="alert alert-warning mt-4">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<div>
|
||||
|
|
@ -391,6 +423,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
value={@delete_all_confirmation || ""}
|
||||
class="input input-bordered w-full"
|
||||
placeholder={gettext("Yes")}
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
|
|
@ -413,11 +446,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Create Cycle Modal --%>
|
||||
<%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if @creating_cycle do %>
|
||||
<dialog id="create-cycle-modal" class="modal modal-open">
|
||||
<dialog
|
||||
id="create-cycle-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="create-cycle-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
|
||||
<h3 id="create-cycle-modal-title" class="text-lg font-bold">{gettext("Create Cycle")}</h3>
|
||||
<form phx-submit="create_cycle" phx-target={@myself}>
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label" for="create-cycle-date">
|
||||
|
|
@ -433,6 +472,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
class="input input-bordered w-full"
|
||||
required
|
||||
aria-label={gettext("Date")}
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
|
|
@ -881,6 +921,35 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:create_cycle_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => "Escape"}, socket) do
|
||||
socket =
|
||||
cond do
|
||||
socket.assigns[:editing_cycle] ->
|
||||
assign(socket, :editing_cycle, nil)
|
||||
|
||||
socket.assigns[:deleting_cycle] ->
|
||||
assign(socket, :deleting_cycle, nil)
|
||||
|
||||
socket.assigns[:deleting_all_cycles] ->
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|
||||
socket.assigns[:creating_cycle] ->
|
||||
socket
|
||||
|> assign(:creating_cycle, false)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)
|
||||
|
||||
true ->
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
|
||||
date =
|
||||
case Date.from_iso8601(date_str) do
|
||||
|
|
@ -1127,7 +1196,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:amount, nil), do: "—"
|
||||
# Screen-reader text for empty receipt table cells (visually empty, A11y)
|
||||
defp receipt_empty_sr_text(:status), do: gettext("Not set")
|
||||
defp receipt_empty_sr_text(_), do: gettext("Not specified")
|
||||
|
||||
defp format_receipt_cell(:amount, nil), do: nil
|
||||
|
||||
defp format_receipt_cell(:amount, val) when is_number(val) do
|
||||
case Decimal.cast(val) do
|
||||
|
|
@ -1145,7 +1218,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
defp format_receipt_cell(:amount, val), do: to_string(val)
|
||||
|
||||
defp format_receipt_cell(:status, nil), do: "—"
|
||||
defp format_receipt_cell(:status, nil), do: nil
|
||||
|
||||
defp format_receipt_cell(:status, val) when is_binary(val) do
|
||||
translate_receipt_status(val)
|
||||
|
|
@ -1153,7 +1226,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
|
||||
|
||||
defp format_receipt_cell(:receiptType, nil), do: "—"
|
||||
defp format_receipt_cell(:receiptType, nil), do: nil
|
||||
|
||||
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
|
||||
translate_receipt_type(val)
|
||||
|
|
@ -1162,7 +1235,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
|
||||
|
||||
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
|
||||
do: "—"
|
||||
do: nil
|
||||
|
||||
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
|
||||
format_receipt_date(val)
|
||||
|
|
@ -1223,7 +1296,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
defp translate_receipt_status("draft"), do: gettext("Draft")
|
||||
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
|
||||
defp translate_receipt_status("completed"), do: gettext("Completed")
|
||||
defp translate_receipt_status("empty"), do: "—"
|
||||
defp translate_receipt_status("empty"), do: nil
|
||||
defp translate_receipt_status(other), do: other
|
||||
|
||||
# Translate API receipt type values (extend as API returns more values)
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
<.header>
|
||||
{gettext("Membership Fee Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Configure global settings and fee types for membership fees.")}
|
||||
{gettext("Configure fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
|
||||
|
|
@ -177,7 +177,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
id="default_membership_fee_type_id"
|
||||
name="settings[default_membership_fee_type_id]"
|
||||
class={[
|
||||
"select select-bordered w-full",
|
||||
"select select-bordered",
|
||||
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
||||
]}
|
||||
phx-debounce="blur"
|
||||
|
|
@ -323,13 +323,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
<.badge variant="neutral" style="outline">
|
||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
|
||||
<span class="text-sm">{get_member_count(mft, @member_counts)}</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={mft}>
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage membership fee types in your database.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button
|
||||
form="membership-fee-type-form"
|
||||
|
|
|
|||
|
|
@ -68,13 +68,13 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
<.badge variant="neutral" style="outline">
|
||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
|
||||
<.badge variant="neutral">{get_member_count(mft, @member_counts)}</.badge>
|
||||
</:col>
|
||||
|
||||
<:action :let={mft}>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ defmodule MvWeb.RoleLive.Form do
|
|||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ defmodule MvWeb.RoleLive.Helpers do
|
|||
|
||||
@doc """
|
||||
Returns the CSS badge class for a permission set name.
|
||||
|
||||
Deprecated for new code: prefer `permission_set_badge_variant/1` with <.badge>.
|
||||
"""
|
||||
@spec permission_set_badge_class(String.t()) :: String.t()
|
||||
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
|
||||
|
|
@ -26,6 +28,18 @@ defmodule MvWeb.RoleLive.Helpers do
|
|||
def permission_set_badge_class("admin"), do: "badge badge-error badge-sm"
|
||||
def permission_set_badge_class(_), do: "badge badge-ghost badge-sm"
|
||||
|
||||
@doc """
|
||||
Returns the Core Components badge variant for a permission set name (WCAG-compliant).
|
||||
|
||||
Use with <.badge variant={permission_set_badge_variant(permission_set_name)} size="sm">.
|
||||
"""
|
||||
@spec permission_set_badge_variant(String.t()) :: :neutral | :info | :success | :error
|
||||
def permission_set_badge_variant("own_data"), do: :neutral
|
||||
def permission_set_badge_variant("read_only"), do: :info
|
||||
def permission_set_badge_variant("normal_user"), do: :success
|
||||
def permission_set_badge_variant("admin"), do: :error
|
||||
def permission_set_badge_variant(_), do: :neutral
|
||||
|
||||
@doc """
|
||||
Builds Ash options with actor and domain, ensuring actor is never nil in real paths.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.Index do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1]
|
||||
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
|
|||
|
|
@ -16,15 +16,16 @@
|
|||
<.table
|
||||
id="roles"
|
||||
rows={@roles}
|
||||
row_id={fn role -> "role-#{role.id}" end}
|
||||
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
||||
row_tooltip={gettext("Click for role details")}
|
||||
>
|
||||
<:col :let={role} label={gettext("Name")}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{role.name}</span>
|
||||
<%= if role.is_system_role do %>
|
||||
<span class="badge badge-warning badge-sm">{gettext("System Role")}</span>
|
||||
<% end %>
|
||||
<.badge :if={role.is_system_role} variant="warning" size="sm">
|
||||
{gettext("System Role")}
|
||||
</.badge>
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
|
|
@ -37,21 +38,22 @@
|
|||
</:col>
|
||||
|
||||
<:col :let={role} label={gettext("Permission Set")}>
|
||||
<span class={permission_set_badge_class(role.permission_set_name)}>
|
||||
<.badge variant={permission_set_badge_variant(role.permission_set_name)} size="sm">
|
||||
{role.permission_set_name}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={role} label={gettext("Type")}>
|
||||
<%= if role.is_system_role do %>
|
||||
<span class="badge badge-warning badge-sm">{gettext("System")}</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost badge-sm">{gettext("Custom")}</span>
|
||||
<% end %>
|
||||
<.badge :if={role.is_system_role} variant="warning" size="sm">
|
||||
{gettext("System")}
|
||||
</.badge>
|
||||
<.badge :if={!role.is_system_role} variant="neutral" size="sm">
|
||||
{gettext("Custom")}
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={role} label={gettext("Users")}>
|
||||
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
|
||||
<span class="text-sm">{get_user_count(role, @user_counts)}</span>
|
||||
</:col>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.RoleLive.Show do
|
|||
require Ash.Query
|
||||
|
||||
import MvWeb.RoleLive.Helpers,
|
||||
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
|
||||
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
|
|
@ -35,7 +35,8 @@ defmodule MvWeb.RoleLive.Show do
|
|||
socket
|
||||
|> assign(:page_title, gettext("Show Role"))
|
||||
|> assign(:role, role)
|
||||
|> assign(:user_count, user_count)}
|
||||
|> assign(:user_count, user_count)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||
{:ok,
|
||||
|
|
@ -84,35 +85,61 @@ defmodule MvWeb.RoleLive.Show do
|
|||
error_message = format_error(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Failed to delete role: %{error}", error: error_message)
|
||||
)}
|
||||
)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
defp handle_delete_role(role, socket) do
|
||||
if role.is_system_role do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("System roles cannot be deleted.")
|
||||
)}
|
||||
socket
|
||||
|> put_flash(:error, gettext("System roles cannot be deleted."))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
else
|
||||
user_count = recalculate_user_count(role, socket.assigns.current_user)
|
||||
|
||||
if user_count > 0 do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext(
|
||||
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
|
||||
count: user_count
|
||||
)
|
||||
)}
|
||||
)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
else
|
||||
perform_role_deletion(role, socket)
|
||||
end
|
||||
|
|
@ -156,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
|
|||
recalculate_user_count(role, actor)
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-role-trigger"})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -187,6 +220,11 @@ defmodule MvWeb.RoleLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div
|
||||
id="role-show-focus-root"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.list>
|
||||
<:item title={gettext("Name")}>{@role.name}</:item>
|
||||
<:item title={gettext("Description")}>
|
||||
|
|
@ -197,16 +235,17 @@ defmodule MvWeb.RoleLive.Show do
|
|||
<% end %>
|
||||
</:item>
|
||||
<:item title={gettext("Permission Set")}>
|
||||
<span class={permission_set_badge_class(@role.permission_set_name)}>
|
||||
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
|
||||
{@role.permission_set_name}
|
||||
</span>
|
||||
</.badge>
|
||||
</:item>
|
||||
<:item title={gettext("System Role")}>
|
||||
<%= if @role.is_system_role do %>
|
||||
<span class="badge badge-warning">{gettext("Yes")}</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No")}</span>
|
||||
<% end %>
|
||||
<.badge :if={@role.is_system_role} variant="warning">
|
||||
{gettext("Yes")}
|
||||
</.badge>
|
||||
<.badge :if={!@role.is_system_role} variant="neutral">
|
||||
{gettext("No")}
|
||||
</.badge>
|
||||
</:item>
|
||||
</.list>
|
||||
|
||||
|
|
@ -223,14 +262,9 @@ defmodule MvWeb.RoleLive.Show do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-role-trigger"
|
||||
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
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="role-delete"
|
||||
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
||||
>
|
||||
|
|
@ -240,6 +274,49 @@ defmodule MvWeb.RoleLive.Show do
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-role-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-role-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
||||
name: @role.name
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-role-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
||||
data-testid="role-delete-confirm"
|
||||
aria-label={gettext("Delete role")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ defmodule MvWeb.StatisticsLive do
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Statistics")}
|
||||
<:subtitle>{gettext("Overview from first membership to today")}</:subtitle>
|
||||
</.header>
|
||||
|
||||
<section class="mb-8" aria-labelledby="members-heading">
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ defmodule MvWeb.UserLive.Form do
|
|||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
||||
<:actions>
|
||||
<.button
|
||||
form="user-form"
|
||||
|
|
@ -66,6 +65,11 @@ defmodule MvWeb.UserLive.Form do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div
|
||||
id="user-form-focus-root"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
|
||||
|
|
@ -311,16 +315,10 @@ defmodule MvWeb.UserLive.Form do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-user-form-trigger"
|
||||
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
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="user-delete"
|
||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||
>
|
||||
|
|
@ -331,6 +329,49 @@ defmodule MvWeb.UserLive.Form do
|
|||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= if @user && assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-user-form-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-user-form-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete User")}
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||
email: @user.email
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-user-form-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @user.id})}
|
||||
aria-label={gettext("Delete user")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4">
|
||||
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
||||
{gettext("Cancel")}
|
||||
|
|
@ -340,6 +381,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -399,6 +441,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
|> assign(:selected_member_name, nil)
|
||||
|> assign(:unlink_member, false)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> load_initial_members()
|
||||
|> assign_form()}
|
||||
end
|
||||
|
|
@ -454,6 +497,32 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
user = socket.assigns.user
|
||||
|
|
@ -461,13 +530,22 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
cond do
|
||||
is_nil(user) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("User not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
to_string(id) != to_string(user.id) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("User not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
Mv.Helpers.SystemActor.system_user?(user) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("System user cannot be deleted."))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
true ->
|
||||
handle_user_delete_destroy(socket, user, actor)
|
||||
|
|
@ -594,13 +672,24 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
|
||||
socket
|
||||
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_ash_error(error))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-user-form-trigger"})
|
||||
end
|
||||
|
||||
defp handle_member_linking(socket, user, actor) do
|
||||
result = perform_member_link_action(socket, user, actor)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Listing Users")}
|
||||
{gettext("Users")}
|
||||
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
||||
|
|
@ -37,25 +38,25 @@
|
|||
{user.role.name}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("Linked Member")}>
|
||||
<%= if user.member do %>
|
||||
<.maybe_value value={user.member} empty_sr_text={gettext("No member linked")}>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
|
||||
<% else %>
|
||||
<span class="text-base-content/70">{gettext("No member linked")}</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("Password")}>
|
||||
<%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
|
||||
<.maybe_value
|
||||
value={MvWeb.Helpers.UserHelpers.has_password?(user)}
|
||||
empty_sr_text={gettext("Not set")}
|
||||
>
|
||||
<span>{gettext("Enabled")}</span>
|
||||
<% else %>
|
||||
<span class="text-base-content/70">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("OIDC")}>
|
||||
<%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
|
||||
<.maybe_value
|
||||
value={MvWeb.Helpers.UserHelpers.has_oidc?(user)}
|
||||
empty_sr_text={gettext("Not set")}
|
||||
>
|
||||
<span>{gettext("Linked")}</span>
|
||||
<% else %>
|
||||
<span class="text-base-content/70">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ defmodule MvWeb.UserLive.Show do
|
|||
</.button>
|
||||
</:leading>
|
||||
{gettext("User")} {@user.email}
|
||||
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :update, @user) do %>
|
||||
<.button
|
||||
|
|
@ -60,6 +58,11 @@ defmodule MvWeb.UserLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div
|
||||
id="user-show-focus-root"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.list>
|
||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
||||
|
|
@ -101,15 +104,9 @@ defmodule MvWeb.UserLive.Show do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-user-trigger"
|
||||
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
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="user-delete"
|
||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||
>
|
||||
|
|
@ -119,6 +116,48 @@ defmodule MvWeb.UserLive.Show do
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-user-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-user-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||
email: @user.email
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-user-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @user.id})}
|
||||
aria-label={gettext("Delete user")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -139,10 +178,37 @@ defmodule MvWeb.UserLive.Show do
|
|||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Show User"))
|
||||
|> assign(:user, user)}
|
||||
|> assign(:user, user)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
user = socket.assigns.user
|
||||
|
|
@ -150,10 +216,16 @@ defmodule MvWeb.UserLive.Show do
|
|||
|
||||
cond do
|
||||
to_string(id) != to_string(user.id) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("User not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
Mv.Helpers.SystemActor.system_user?(user) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("System user cannot be deleted."))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
true ->
|
||||
handle_user_delete_destroy(socket, user, actor)
|
||||
|
|
@ -170,10 +242,21 @@ defmodule MvWeb.UserLive.Show do
|
|||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
|
||||
socket
|
||||
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_ash_error(error))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-user-trigger"})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -93,22 +93,30 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
|||
|
||||
## Returns
|
||||
|
||||
Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
|
||||
Map with `:variant`, `:icon`, and `:label` keys (and legacy `:color`), or `nil` if status is nil.
|
||||
Use `:variant` with <.badge variant={badge.variant}> for WCAG-compliant rendering.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
|
||||
%{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
|
||||
%{variant: :success, color: "badge-success", icon: "hero-check-circle", label: "Paid"}
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
|
||||
nil
|
||||
"""
|
||||
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
|
||||
%{color: String.t(), icon: String.t(), label: String.t()} | nil
|
||||
%{
|
||||
variant: :success | :error | :warning,
|
||||
color: String.t(),
|
||||
icon: String.t(),
|
||||
label: String.t()
|
||||
}
|
||||
| nil
|
||||
def format_cycle_status_badge(nil), do: nil
|
||||
|
||||
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
|
||||
%{
|
||||
variant: MembershipFeeHelpers.status_variant(status),
|
||||
color: MembershipFeeHelpers.status_color(status),
|
||||
icon: MembershipFeeHelpers.status_icon(status),
|
||||
label: format_status_label(status)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ msgid "City"
|
|||
msgstr "Stadt"
|
||||
|
||||
#: 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/member_live/show/membership_fees_component.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 "Delete"
|
||||
msgstr "Löschen"
|
||||
|
|
@ -257,9 +262,12 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
|
|||
#: 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/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_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 "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
|
@ -294,7 +302,6 @@ msgid "Logout"
|
|||
msgstr "Abmelden"
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Listing Users"
|
||||
msgstr "Benutzer*innen auflisten"
|
||||
|
|
@ -381,16 +388,6 @@ msgstr "Benutzer*in speichern"
|
|||
msgid "Show User"
|
||||
msgstr "Benutzer*in anzeigen"
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This is a user record from your database."
|
||||
msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage user records in your database."
|
||||
msgstr "Verwende dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -533,6 +530,7 @@ msgstr "Suchen..."
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Users"
|
||||
msgstr "Benutzer*innen"
|
||||
|
|
@ -593,18 +591,6 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
|
|||
msgid "Custom Fields"
|
||||
msgstr "Benutzerdefinierte Felder"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} member has a value assigned for this custom field."
|
||||
msgid_plural "%{count} members have values assigned for this custom field."
|
||||
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
|
||||
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
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 "Enter the text above to confirm"
|
||||
|
|
@ -790,6 +776,7 @@ msgstr "Beitragsdaten"
|
|||
msgid "Payments"
|
||||
msgstr "Zahlungen"
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1388,6 +1375,8 @@ msgid "None (no default)"
|
|||
msgstr "Keine (kein Standard)"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not set"
|
||||
msgstr "Nicht gesetzt"
|
||||
|
|
@ -1473,11 +1462,6 @@ msgstr "Art"
|
|||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr "Gib '%{confirmation}' ein, um zu bestätigen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr "Verwende dieses Formular, um Mitgliedsbeitragsarten in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
|
|
@ -1704,11 +1688,6 @@ msgstr "System-Rollen können nicht gelöscht werden."
|
|||
msgid "Toggle sidebar"
|
||||
msgstr "Sidebar umschalten"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage roles in your database."
|
||||
msgstr "Verwende dieses Formular, um Rollen in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User menu"
|
||||
|
|
@ -2428,11 +2407,6 @@ msgstr "Alle Jahre zusammengefasst (Kreis)"
|
|||
msgid "Contributions by year"
|
||||
msgstr "Beiträge nach Jahr"
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Overview from first membership to today"
|
||||
msgstr "Übersicht vom ersten Eintritt bis heute"
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contributions by year as table with stacked bars"
|
||||
|
|
@ -2910,11 +2884,6 @@ msgstr "CSV Datei auswählen"
|
|||
msgid "Import Members"
|
||||
msgstr "Mitglieder importieren (CSV)"
|
||||
|
||||
#~ #: lib/mv_web/live/import_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Datei auswählen"
|
||||
#~ msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Admin group name"
|
||||
|
|
@ -2940,21 +2909,6 @@ msgstr "Client-ID"
|
|||
msgid "Client Secret"
|
||||
msgstr "Client-Geheimnis"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren."
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom fields"
|
||||
msgstr "Benutzerdefinierte Felder"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2996,11 +2950,6 @@ msgstr "Aus OIDC_REDIRECT_URI"
|
|||
msgid "Groups claim"
|
||||
msgstr "Gruppenclaim"
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member fields"
|
||||
msgstr "Mitgliedsfelder"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee settings"
|
||||
|
|
@ -3225,88 +3174,64 @@ msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden.
|
|||
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/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/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/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Role"
|
||||
msgstr "Rolle löschen"
|
||||
|
||||
#~ #: 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/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete User"
|
||||
msgstr "Benutzer*in löschen"
|
||||
|
||||
#~ #: 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/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure fee types for membership fees."
|
||||
msgstr "Verwalte Beitragsarten und Mitgliedsbeiträge."
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Coming soon"
|
||||
#~ msgstr "Demnächst verfügbar"
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure which data you want to save for your members. Define individual datafields."
|
||||
msgstr "Verwalte welche Daten du für eure Mitglieder speichern möchtest. Lege individuelle datenfelder an."
|
||||
|
||||
#~ #: 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/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Manage users and their permissions."
|
||||
msgstr "Verwalte Benutzer*innen und ihre Berechtigungen."
|
||||
|
||||
#~ #: 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/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "%{count} member has a value assigned for this datafield."
|
||||
msgid_plural "%{count} members have values assigned for this datafield."
|
||||
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
|
||||
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
|
||||
|
||||
#~ #: 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/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Individual Datafields"
|
||||
msgstr "Individuelle Datenfelder"
|
||||
|
||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Reset"
|
||||
#~ msgstr "Zurücksetzen"
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No group assignment"
|
||||
msgstr "Keine Gruppenzuordnung"
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Rolle bearbeiten"
|
||||
#~ msgstr "Rolle bearbeiten"
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not specified"
|
||||
msgstr "Nicht angegeben"
|
||||
|
||||
#~ #: 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"
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ msgid "City"
|
|||
msgstr ""
|
||||
|
||||
#: 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/member_live/show/membership_fees_component.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 "Delete"
|
||||
msgstr ""
|
||||
|
|
@ -258,9 +263,12 @@ msgstr ""
|
|||
#: 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/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_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 "Cancel"
|
||||
msgstr ""
|
||||
|
|
@ -295,7 +303,6 @@ msgid "Logout"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Listing Users"
|
||||
msgstr ""
|
||||
|
|
@ -382,16 +389,6 @@ msgstr ""
|
|||
msgid "Show User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This is a user record from your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage user records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -534,6 +531,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
|
|
@ -594,18 +592,6 @@ msgstr ""
|
|||
msgid "Custom Fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} member has a value assigned for this custom field."
|
||||
msgid_plural "%{count} members have values assigned for this custom field."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
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 "Enter the text above to confirm"
|
||||
|
|
@ -791,6 +777,7 @@ msgstr ""
|
|||
msgid "Payments"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1389,6 +1376,8 @@ msgid "None (no default)"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not set"
|
||||
msgstr ""
|
||||
|
|
@ -1474,11 +1463,6 @@ msgstr ""
|
|||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
|
|
@ -1705,11 +1689,6 @@ msgstr ""
|
|||
msgid "Toggle sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage roles in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User menu"
|
||||
|
|
@ -2429,11 +2408,6 @@ msgstr ""
|
|||
msgid "Contributions by year"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Overview from first membership to today"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contributions by year as table with stacked bars"
|
||||
|
|
@ -2935,21 +2909,6 @@ msgstr ""
|
|||
msgid "Client Secret"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2991,11 +2950,6 @@ msgstr ""
|
|||
msgid "Groups claim"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee settings"
|
||||
|
|
@ -3219,3 +3173,65 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Individual datafields"
|
||||
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/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete Role"
|
||||
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/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure which data you want to save for your members. Define individual datafields."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage users and their permissions."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} member has a value assigned for this datafield."
|
||||
msgid_plural "%{count} members have values assigned for this datafield."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Individual Datafields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No group assignment"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not specified"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ msgid "City"
|
|||
msgstr ""
|
||||
|
||||
#: 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/member_live/show/membership_fees_component.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 "Delete"
|
||||
msgstr ""
|
||||
|
|
@ -258,9 +263,12 @@ msgstr ""
|
|||
#: 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/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_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 "Cancel"
|
||||
msgstr ""
|
||||
|
|
@ -295,7 +303,6 @@ msgid "Logout"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Listing Users"
|
||||
msgstr ""
|
||||
|
|
@ -382,16 +389,6 @@ msgstr ""
|
|||
msgid "Show User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "This is a user record from your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage user records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -534,6 +531,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
|
|
@ -594,18 +592,6 @@ msgstr ""
|
|||
msgid "Custom Fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} member has a value assigned for this custom field."
|
||||
msgid_plural "%{count} members have values assigned for this custom field."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
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 "Enter the text above to confirm"
|
||||
|
|
@ -791,6 +777,7 @@ msgstr ""
|
|||
msgid "Payments"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1389,6 +1376,8 @@ msgid "None (no default)"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Not set"
|
||||
msgstr ""
|
||||
|
|
@ -1474,11 +1463,6 @@ msgstr ""
|
|||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
|
|
@ -1705,11 +1689,6 @@ msgstr ""
|
|||
msgid "Toggle sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage roles in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "User menu"
|
||||
|
|
@ -2429,11 +2408,6 @@ msgstr ""
|
|||
msgid "Contributions by year"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Overview from first membership to today"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Contributions by year as table with stacked bars"
|
||||
|
|
@ -2935,21 +2909,6 @@ msgstr ""
|
|||
msgid "Client Secret"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2991,11 +2950,6 @@ msgstr ""
|
|||
msgid "Groups claim"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Member fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee settings"
|
||||
|
|
@ -3220,88 +3174,64 @@ msgstr ""
|
|||
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/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/role_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Cannot delete system role"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete 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/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/member_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Click for datafield details"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Coming soon"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure which data you want to save for your members. Define individual datafields."
|
||||
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/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Manage users and their permissions."
|
||||
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/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "%{count} member has a value assigned for this datafield."
|
||||
msgid_plural "%{count} members have values assigned for this datafield."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Edit user"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Individual Datafields"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Reset"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No group assignment"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Rolle bearbeiten"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not specified"
|
||||
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 ""
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||
msgstr ""
|
||||
|
|
|
|||
91
test/mv_web/components/core_components_badge_test.exs
Normal file
91
test/mv_web/components/core_components_badge_test.exs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule MvWeb.Components.CoreComponentsBadgeTest do
|
||||
@moduledoc """
|
||||
Unit tests for the Core Components badge (WCAG-compliant, non-transparent).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.Component
|
||||
import Phoenix.LiveViewTest
|
||||
import MvWeb.CoreComponents
|
||||
|
||||
describe "badge/1" do
|
||||
test "default variant renders with badge and badge-neutral classes (visible, not ghost)" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="neutral">Label</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge"
|
||||
assert html =~ "badge-neutral"
|
||||
assert html =~ "badge-soft"
|
||||
refute html =~ "badge-ghost"
|
||||
assert html =~ "Label"
|
||||
end
|
||||
|
||||
test "success variant renders badge-success and badge-soft" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="success">Paid</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge-success"
|
||||
assert html =~ "badge-soft"
|
||||
assert html =~ "Paid"
|
||||
end
|
||||
|
||||
test "outline style includes bg-base-100 for contrast" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="primary" style="outline">Outline</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge-outline"
|
||||
assert html =~ "bg-base-100"
|
||||
assert html =~ "Outline"
|
||||
end
|
||||
|
||||
test "solid style has no badge-soft or badge-outline" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="error" style="solid">Error</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge-error"
|
||||
refute html =~ "badge-soft"
|
||||
refute html =~ "badge-outline"
|
||||
assert html =~ "Error"
|
||||
end
|
||||
|
||||
test "size sm adds badge-sm" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="neutral" size="sm">Small</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge-sm"
|
||||
assert html =~ "Small"
|
||||
end
|
||||
|
||||
test "renders as span (non-interactive)" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="info">Info</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ ~r/<span[^>]*class="[^"]*badge[^"]*"/
|
||||
refute html =~ ~r/<button/
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -254,6 +254,14 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "status_variant/1" do
|
||||
test "returns badge variant for <.badge> (suspended uses warning to match edit button)" do
|
||||
assert MembershipFeeHelpers.status_variant(:paid) == :success
|
||||
assert MembershipFeeHelpers.status_variant(:unpaid) == :error
|
||||
assert MembershipFeeHelpers.status_variant(:suspended) == :warning
|
||||
end
|
||||
end
|
||||
|
||||
describe "status_color/1" do
|
||||
test "returns correct color classes for statuses" do
|
||||
assert MembershipFeeHelpers.status_color(:paid) == "badge-success"
|
||||
|
|
|
|||
|
|
@ -46,15 +46,19 @@ 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)
|
||||
# Delete is in the edit form (FormComponent). First row click opens form (overview) or switches
|
||||
# to edit-mode (new component shows table). If delete button is visible, click it; else click row
|
||||
# again to open the form, then click delete.
|
||||
defp open_delete_modal(view, custom_field) do
|
||||
view
|
||||
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|
||||
|> render_click()
|
||||
row_selector = "tr#custom_fields-#{custom_field.id} td"
|
||||
view |> element(row_selector, custom_field.name) |> render_click()
|
||||
|
||||
view
|
||||
|> element("[data-testid=custom-field-delete]")
|
||||
|> render_click()
|
||||
if has_element?(view, "[data-testid=custom-field-delete]") do
|
||||
view |> element("[data-testid=custom-field-delete]") |> render_click()
|
||||
else
|
||||
view |> element(row_selector, custom_field.name) |> render_click()
|
||||
view |> element("[data-testid=custom-field-delete]") |> render_click()
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete button and modal" do
|
||||
|
|
@ -71,8 +75,12 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
# Modal should be visible
|
||||
assert has_element?(view, "#delete-custom-field-modal")
|
||||
|
||||
# Edit mode: section titles must not reappear when modal opens (regression)
|
||||
refute has_element?(view, "h2", "Member fields")
|
||||
refute has_element?(view, "h2", "Custom fields")
|
||||
|
||||
# Should show correct member count (1 member)
|
||||
assert render(view) =~ "1 member has a value assigned for this custom field"
|
||||
assert render(view) =~ "1 member has a value assigned for this datafield"
|
||||
|
||||
# Should show the slug
|
||||
assert render(view) =~ custom_field.slug
|
||||
|
|
@ -91,7 +99,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
open_delete_modal(view, custom_field)
|
||||
|
||||
# Should show plural form
|
||||
assert render(view) =~ "2 members have values assigned for this custom field"
|
||||
assert render(view) =~ "2 members have values assigned for this datafield"
|
||||
end
|
||||
|
||||
test "shows 0 members for custom field without values", %{conn: conn} do
|
||||
|
|
@ -101,7 +109,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
open_delete_modal(view, custom_field)
|
||||
|
||||
# Should show 0 members
|
||||
assert render(view) =~ "0 members have values assigned for this custom field"
|
||||
assert render(view) =~ "0 members have values assigned for this datafield"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,21 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "edit mode visibility" do
|
||||
test "clicking member field row shows only form, no section titles", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||
|
||||
# Row click is on the first td (no col_click); click that cell to open edit form
|
||||
view
|
||||
|> element("tr#member_field-first_name td:first-child")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#member-field-form-first_name")
|
||||
refute has_element?(view, "h2", "Custom fields")
|
||||
refute has_element?(view, "h2", "Member fields")
|
||||
end
|
||||
end
|
||||
|
||||
describe "required fields" do
|
||||
setup do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
|
|
|||
|
|
@ -386,11 +386,16 @@ defmodule MvWeb.RoleLiveTest do
|
|||
|
||||
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
# Delete from Danger zone on show page
|
||||
# Open delete modal from Danger zone
|
||||
view
|
||||
|> element("[data-testid=role-delete]")
|
||||
|> render_click()
|
||||
|
||||
# Confirm deletion in modal
|
||||
view
|
||||
|> element("[data-testid=role-delete-confirm]")
|
||||
|> render_click()
|
||||
|
||||
assert_redirect(view, "/admin/roles")
|
||||
|
||||
# Verify deletion by checking database
|
||||
|
|
|
|||
|
|
@ -29,9 +29,8 @@ defmodule MvWeb.StatisticsLiveTest do
|
|||
test "page shows overview of all relevant years without year selector", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/statistics")
|
||||
|
||||
# No year dropdown: single select for year should not be present as main control
|
||||
assert html =~ "Overview" or html =~ "overview"
|
||||
# table header or legend
|
||||
# Page shows multi-year data (member numbers by year) and year column; no single-year selector as main control
|
||||
assert html =~ "Member numbers by year"
|
||||
assert html =~ "Year"
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,20 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
|
|||
assert html =~ member3.first_name
|
||||
end
|
||||
|
||||
test "empty group cell is visually empty with sr-only text (no dash)", %{
|
||||
conn: conn,
|
||||
member3: member3
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
assert html =~ member3.first_name
|
||||
# Screen reader gets a meaningful label for the empty cell
|
||||
assert html =~ "sr-only"
|
||||
assert html =~ "No group assignment"
|
||||
# No visible dash as placeholder (Design Guidelines §8.6)
|
||||
refute html =~ ~r/<span[^>]*class="[^"]*text-base-content\/50[^"]*"[^>]*>—<\/span>/
|
||||
end
|
||||
|
||||
test "displays group name correctly in badge", %{conn: conn, group1: group1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
|
|
|||
|
|
@ -123,13 +123,17 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
{:ok, index_view, _html} = live(conn, "/users")
|
||||
assert render(index_view) =~ "delete-me@example.com"
|
||||
|
||||
# Navigate to user show and trigger delete from Danger zone
|
||||
# Navigate to user show, open delete modal, then confirm in modal (WCAG modal pattern)
|
||||
{:ok, show_view, _html} = live(conn, "/users/#{user.id}")
|
||||
|
||||
show_view
|
||||
|> element("[data-testid=user-delete]")
|
||||
|> render_click()
|
||||
|
||||
show_view
|
||||
|> element("#delete-user-modal button", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Should redirect to index
|
||||
assert_redirect(show_view, "/users")
|
||||
|
||||
|
|
@ -206,7 +210,9 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "Password column display" do
|
||||
test "user without password shows em dash in Password column", %{conn: conn} do
|
||||
test "user without password shows empty cell with sr-only text in Password column", %{
|
||||
conn: conn
|
||||
} do
|
||||
# User created with hashed_password: nil (no password) - must not get default password
|
||||
user_no_pw =
|
||||
create_test_user(%{
|
||||
|
|
@ -219,9 +225,13 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
assert html =~ "no-password@example.com"
|
||||
|
||||
# Password column must show "—" (em dash) for user without password, not "Enabled"
|
||||
# Password column: visually empty, screen-reader gets "Not set" (Design Guidelines §8.6)
|
||||
row = view |> element("tr#row-#{user_no_pw.id}") |> render()
|
||||
assert row =~ "—", "Password column should show em dash for user without password"
|
||||
assert row =~ "sr-only", "Password column should have sr-only text for accessibility"
|
||||
assert row =~ "Not set", "Screen reader should get 'Not set' for empty password"
|
||||
|
||||
refute row =~ "—",
|
||||
"Password column must not show dash (use empty cell + sr-only per CODE_GUIDELINES §8)"
|
||||
|
||||
refute row =~ "Enabled",
|
||||
"Password column must not show Enabled when user has no password"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue