Enhances accessibiity closes #421 #450
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>
|
<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:**
|
**Tab Order:**
|
||||||
|
|
||||||
- Ensure logical tab order matches visual 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
|
### 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
|
```elixir
|
||||||
# Tailwind classes with sufficient contrast (4.5:1 minimum)
|
# Tailwind classes with sufficient contrast (4.5:1 minimum)
|
||||||
|
|
@ -3003,24 +3015,56 @@ end
|
||||||
- [ ] Skip links are available
|
- [ ] Skip links are available
|
||||||
- [ ] Tables have proper structure (th, scope, caption)
|
- [ ] Tables have proper structure (th, scope, caption)
|
||||||
- [ ] ARIA labels used for icon-only buttons
|
- [ ] 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
|
```heex
|
||||||
<!-- Modal accessibility -->
|
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
|
||||||
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
|
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
|
||||||
<div class="modal-box">
|
<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>
|
<p><%= gettext("Are you sure?") %></p>
|
||||||
<div class="modal-action">
|
<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") %>
|
<%= gettext("Cancel") %>
|
||||||
</button>
|
</.button>
|
||||||
<button class="btn btn-error" phx-click="confirm-delete">
|
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
|
||||||
<%= gettext("Delete") %>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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.
|
- 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.
|
- 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)
|
## 9) Flash / Toast messages (mandatory UX)
|
||||||
|
|
@ -331,14 +337,17 @@ No “silent success”.
|
||||||
|
|
||||||
### 10.2 Destructive actions: one standard confirmation pattern
|
### 10.2 Destructive actions: one standard confirmation pattern
|
||||||
- **MUST:** All destructive actions use the same confirm style and wording conventions.
|
- **MUST:** All destructive actions use the same confirm style and wording conventions.
|
||||||
- Choose one approach and standardize:
|
- **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.
|
||||||
- `JS.confirm("…")` everywhere (simple, consistent)
|
|
||||||
- or a modal component everywhere (more flexible, more work)
|
|
||||||
|
|
||||||
**Recommended copy style:**
|
**Recommended copy style:**
|
||||||
- Title/confirm text is clear and specific (what will be deleted, consequences).
|
- Title/confirm text is clear and specific (what will be deleted, consequences).
|
||||||
- Buttons: `Cancel` (neutral) + `Delete` (danger).
|
- 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)
|
## 11) Detail pages (consistent structure)
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,138 @@
|
||||||
color: oklch(0.45 0.2 25);
|
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
|
Sidebar Base Styles
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
@ -389,4 +521,31 @@
|
||||||
display: none !important;
|
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 */
|
/* 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
|
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||||
Hooks.SidebarState = {
|
Hooks.SidebarState = {
|
||||||
mounted() {
|
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
|
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 """
|
@doc """
|
||||||
Renders flash notices.
|
Renders flash notices.
|
||||||
|
|
||||||
|
|
@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do
|
||||||
size_class = size_classes[size]
|
size_class = size_classes[size]
|
||||||
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
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
|
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||||
link_class =
|
link_class =
|
||||||
if assigns[:disabled],
|
if assigns[:disabled],
|
||||||
do: ["btn", btn_class, "btn-disabled"],
|
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
|
||||||
else: ["btn", btn_class]
|
else: ["btn", btn_class] ++ @button_focus_classes
|
||||||
|
|
||||||
link_attrs =
|
link_attrs =
|
||||||
if assigns[:disabled] do
|
if assigns[:disabled] do
|
||||||
|
|
@ -176,13 +194,187 @@ defmodule MvWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
else
|
else
|
||||||
~H"""
|
~H"""
|
||||||
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
|
<button
|
||||||
|
class={["btn", @btn_class] ++ @button_focus_classes}
|
||||||
|
disabled={@disabled}
|
||||||
|
{@rest}
|
||||||
|
>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</button>
|
</button>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
|
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
|
||||||
or status badges that need explanation (Design Guidelines §8.2).
|
or status badges that need explanation (Design Guidelines §8.2).
|
||||||
|
|
@ -265,7 +457,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
def dropdown_menu(assigns) do
|
def dropdown_menu(assigns) do
|
||||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
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"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
|
|
@ -281,17 +477,10 @@ defmodule MvWeb.CoreComponents do
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={@open}
|
aria-expanded={if @open, do: "true", else: "false"}
|
||||||
aria-controls={@id}
|
aria-controls={@id}
|
||||||
aria-label={@button_label}
|
aria-label={@button_label}
|
||||||
class={[
|
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
|
||||||
"btn",
|
|
||||||
"focus:outline-none",
|
|
||||||
"focus-visible:ring-2",
|
|
||||||
"focus-visible:ring-offset-2",
|
|
||||||
"focus-visible:ring-base-content/20",
|
|
||||||
@button_class
|
|
||||||
]}
|
|
||||||
phx-click="toggle_dropdown"
|
phx-click="toggle_dropdown"
|
||||||
phx-target={@phx_target}
|
phx-target={@phx_target}
|
||||||
data-testid={@button_testid}
|
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
|
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||||
}
|
}
|
||||||
tabindex="0"
|
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-click="select_item"
|
||||||
phx-keydown="select_item"
|
phx-keydown="select_item"
|
||||||
phx-key="Enter"
|
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.
|
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).
|
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`),
|
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).
|
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)
|
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"""
|
~H"""
|
||||||
<div class="overflow-auto">
|
<div
|
||||||
|
id={@row_click && "#{@id}-keyboard"}
|
||||||
|
class="overflow-auto"
|
||||||
|
phx-hook={@row_click && "TableRowKeydown"}
|
||||||
|
>
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -789,6 +999,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
: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={
|
phx-click={
|
||||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||||
(@row_click && @row_click.(row))
|
(@row_click && @row_click.(row))
|
||||||
|
|
@ -812,6 +1027,19 @@ defmodule MvWeb.CoreComponents do
|
||||||
classes
|
classes
|
||||||
end
|
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 =
|
classes =
|
||||||
if col_class do
|
if col_class do
|
||||||
[col_class | classes]
|
[col_class | classes]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
use Gettext, backend: MvWeb.Gettext
|
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
|
@impl true
|
||||||
def mount(socket) do
|
def mount(socket) do
|
||||||
{:ok, assign(socket, :open, false)}
|
{:ok, assign(socket, :open, false)}
|
||||||
|
|
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
role="menuitem"
|
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")}
|
aria-label={gettext("Export members to CSV")}
|
||||||
data-testid="export-csv-link"
|
data-testid="export-csv-link"
|
||||||
>
|
>
|
||||||
|
|
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
role="menuitem"
|
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")}
|
aria-label={gettext("Export members to PDF")}
|
||||||
data-testid="export-pdf-link"
|
data-testid="export-pdf-link"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,17 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
def status_color(:unpaid), do: "badge-error"
|
def status_color(:unpaid), do: "badge-error"
|
||||||
def status_color(:suspended), do: "badge-ghost"
|
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 """
|
@doc """
|
||||||
Gets the icon name for a status.
|
Gets the icon name for a status.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative member-filter-dropdown"
|
||||||
id={@id}
|
id={@id}
|
||||||
|
phx-click-away={if @open, do: "close_dropdown", else: nil}
|
||||||
phx-window-keydown={@open && "close_dropdown"}
|
phx-window-keydown={@open && "close_dropdown"}
|
||||||
phx-key="Escape"
|
phx-key="Escape"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
|
|
@ -89,21 +90,23 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
@boolean_filters
|
@boolean_filters
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<.badge
|
||||||
:if={active_boolean_filters_count(@boolean_filters) > 0}
|
:if={active_boolean_filters_count(@boolean_filters) > 0}
|
||||||
class="badge badge-primary badge-sm"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{active_boolean_filters_count(@boolean_filters)}
|
{active_boolean_filters_count(@boolean_filters)}
|
||||||
</span>
|
</.badge>
|
||||||
<span
|
<.badge
|
||||||
:if={
|
:if={
|
||||||
(@cycle_status_filter || map_size(@group_filters) > 0) &&
|
(@cycle_status_filter || map_size(@group_filters) > 0) &&
|
||||||
active_boolean_filters_count(@boolean_filters) == 0
|
active_boolean_filters_count(@boolean_filters) == 0
|
||||||
}
|
}
|
||||||
class="badge badge-primary badge-sm"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{@member_count}
|
{@member_count}
|
||||||
</span>
|
</.badge>
|
||||||
</.button>
|
</.button>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
@ -118,8 +121,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
:if={@open}
|
:if={@open}
|
||||||
tabindex="0"
|
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]"
|
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"
|
role="dialog"
|
||||||
aria-label={gettext("Member filter")}
|
aria-label={gettext("Member filter")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-custom-field-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="request_delete"
|
phx-click="request_delete"
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div id={@id} class="mt-8">
|
<div id={@id}>
|
||||||
<div class="flex">
|
<div :if={!@show_form} class="flex">
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
{gettext("These will appear in addition to other data when adding new members.")}
|
{gettext("These will appear in addition to other data when adding new members.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -54,6 +54,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
<.table
|
<.table
|
||||||
id="custom_fields_table"
|
id="custom_fields_table"
|
||||||
rows={@streams.custom_fields}
|
rows={@streams.custom_fields}
|
||||||
|
row_id={fn {_stream_key, cf} -> "custom_fields-#{cf.id}" end}
|
||||||
row_click={
|
row_click={
|
||||||
fn {_id, custom_field} ->
|
fn {_id, custom_field} ->
|
||||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
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")}
|
label={gettext("Show in overview")}
|
||||||
class="max-w-[9.375rem] text-center"
|
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")}
|
{gettext("Yes")}
|
||||||
</span>
|
</.badge>
|
||||||
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
<.badge :if={!custom_field.show_in_overview} variant="neutral">
|
||||||
{gettext("No")}
|
{gettext("No")}
|
||||||
</span>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Delete Confirmation Modal --%>
|
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
<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">
|
<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="py-4 space-y-4">
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
|
|
@ -110,15 +120,15 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">
|
<p class="font-semibold">
|
||||||
{ngettext(
|
{ngettext(
|
||||||
"%{count} member has a value assigned for this custom field.",
|
"%{count} member has a value assigned for this datafield.",
|
||||||
"%{count} members have values assigned for this custom field.",
|
"%{count} members have values assigned for this datafield.",
|
||||||
@custom_field_to_delete.assigned_members_count,
|
@custom_field_to_delete.assigned_members_count,
|
||||||
count: @custom_field_to_delete.assigned_members_count
|
count: @custom_field_to_delete.assigned_members_count
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm">
|
<p class="mt-2 text-sm">
|
||||||
{gettext(
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,8 +194,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
# Track previous show_form state to detect when form is closed
|
# Use socket state so send_update(open_delete_for_id: ...) does not trigger false "form closed"
|
||||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
previous_show_form = socket.assigns[:show_form] || false
|
||||||
|
|
||||||
# If show_form is explicitly provided in assigns, reset editing state
|
# If show_form is explicitly provided in assigns, reset editing state
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -197,13 +207,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
socket
|
socket
|
||||||
end
|
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
|
# Get actor from assigns or fall back to socket assigns
|
||||||
actor = Map.get(assigns, :actor, socket.assigns[:actor])
|
actor = Map.get(assigns, :actor, socket.assigns[:actor])
|
||||||
|
|
||||||
|
|
@ -225,6 +228,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
socket
|
socket
|
||||||
|
|
||||||
id ->
|
id ->
|
||||||
|
send(self(), {:custom_field_delete_modal_open, true})
|
||||||
|
|
||||||
custom_field =
|
custom_field =
|
||||||
Ash.get!(Mv.Membership.CustomField, id,
|
Ash.get!(Mv.Membership.CustomField, id,
|
||||||
load: [:assigned_members_count],
|
load: [:assigned_members_count],
|
||||||
|
|
@ -238,6 +243,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|> assign(:open_delete_for_id, nil)
|
|> assign(:open_delete_for_id, nil)
|
||||||
end
|
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}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -282,6 +294,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
send(self(), {:custom_field_delete_modal_open, true})
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:custom_field_to_delete, custom_field)
|
|> 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
|
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||||
case Ash.destroy(custom_field, actor: actor) do
|
case Ash.destroy(custom_field, actor: actor) do
|
||||||
:ok ->
|
:ok ->
|
||||||
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
send(self(), {:custom_field_deleted, custom_field})
|
send(self(), {:custom_field_deleted, custom_field})
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -312,6 +327,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|> stream_delete(:custom_fields, custom_field)}
|
|> stream_delete(:custom_fields, custom_field)}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
send(self(), {:custom_field_delete_error, error})
|
send(self(), {:custom_field_delete_error, error})
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -321,6 +337,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|> assign(:slug_confirmation, "")}
|
|> assign(:slug_confirmation, "")}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
send(self(), :custom_field_slug_mismatch)
|
send(self(), :custom_field_slug_mismatch)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -333,10 +350,22 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete", _params, socket) do
|
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
|
socket
|
||||||
|> assign(:show_delete_modal, false)
|
|> assign(:show_delete_modal, false)
|
||||||
|> assign(:custom_field_to_delete, nil)
|
|> assign(:custom_field_to_delete, nil)
|
||||||
|> assign(:slug_confirmation, "")}
|
|> assign(:slug_confirmation, "")
|
||||||
|
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Datafields"))
|
|> assign(:page_title, gettext("Datafields"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:active_editing_section, nil)}
|
|> assign(:active_editing_section, nil)
|
||||||
|
|> assign(:custom_field_delete_modal_open, false)}
|
||||||
end
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -29,31 +52,68 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Datafields")}
|
{gettext("Datafields")}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Configure member fields and custom data fields.")}
|
{gettext(
|
||||||
|
"Configure which data you want to save for your members. Define individual datafields."
|
||||||
|
)}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
</.header>
|
</.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
|
<.live_component
|
||||||
:if={@active_editing_section != :custom_fields}
|
|
||||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||||
id="member-fields-component"
|
id="member-fields-component"
|
||||||
settings={@settings}
|
settings={@settings}
|
||||||
/>
|
/>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
|
|
||||||
<.form_section title={gettext("Custom fields")}>
|
<.form_section title={gettext("Individual Datafields")}>
|
||||||
<.live_component
|
<.live_component
|
||||||
:if={@active_editing_section != :member_fields}
|
|
||||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||||
id="custom-fields-component"
|
id="custom-fields-component"
|
||||||
actor={@current_user}
|
actor={@current_user}
|
||||||
/>
|
/>
|
||||||
</.form_section>
|
</.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>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<label class="label" for={@form[:vereinfacht_api_key].id}>
|
<label class="label" for={@form[:vereinfacht_api_key].id}>
|
||||||
<span class="label-text">{gettext("API Key")}</span>
|
<span class="label-text">{gettext("API Key")}</span>
|
||||||
<%= if @vereinfacht_api_key_set do %>
|
<%= 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 %>
|
<% end %>
|
||||||
</label>
|
</label>
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -251,7 +253,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<label class="label" for={@form[:oidc_client_secret].id}>
|
<label class="label" for={@form[:oidc_client_secret].id}>
|
||||||
<span class="label-text">{gettext("Client Secret")}</span>
|
<span class="label-text">{gettext("Client Secret")}</span>
|
||||||
<%= if @oidc_client_secret_set do %>
|
<%= 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 %>
|
<% end %>
|
||||||
</label>
|
</label>
|
||||||
<.input
|
<.input
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,9 @@ defmodule MvWeb.GroupLive.Index do
|
||||||
{group.name}
|
{group.name}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={group} label={gettext("Description")}>
|
<:col :let={group} label={gettext("Description")}>
|
||||||
<%= if group.description do %>
|
<.maybe_value value={group.description} empty_sr_text={gettext("Not specified")}>
|
||||||
{group.description}
|
{group.description}
|
||||||
<% else %>
|
</.maybe_value>
|
||||||
<span class="text-base-content/50 italic">—</span>
|
|
||||||
<% end %>
|
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={group} label={gettext("Members")} class="text-right">
|
<:col :let={group} label={gettext("Members")} class="text-right">
|
||||||
{group.member_count || 0}
|
{group.member_count || 0}
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,12 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.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 --%>
|
<%!-- Group Information --%>
|
||||||
<div class="max-w-2xl space-y-6 mb-6">
|
<div class="max-w-2xl space-y-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -150,7 +155,11 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
<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 %>
|
<%= 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)}
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
<.tooltip content={gettext("Remove")} position="top">
|
<.tooltip content={gettext("Remove")} position="top">
|
||||||
<.button
|
<.button
|
||||||
|
|
@ -169,7 +178,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
<.icon name="hero-x-mark" class="size-3" />
|
<.icon name="hero-x-mark" class="size-3" />
|
||||||
</.button>
|
</.button>
|
||||||
</.tooltip>
|
</.tooltip>
|
||||||
</span>
|
</.badge>
|
||||||
<% end %>
|
<% end %>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -300,16 +309,14 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
</.link>
|
</.link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<%= if member.email do %>
|
<.maybe_value value={member.email} empty_sr_text={gettext("No email")}>
|
||||||
<a
|
<a
|
||||||
href={"mailto:#{member.email}"}
|
href={"mailto:#{member.email}"}
|
||||||
class="link link-primary"
|
class="link link-primary"
|
||||||
>
|
>
|
||||||
{member.email}
|
{member.email}
|
||||||
</a>
|
</a>
|
||||||
<% else %>
|
</.maybe_value>
|
||||||
<span class="text-base-content/50 italic">—</span>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
</td>
|
||||||
<%= if can?(@current_user, :update, @group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -351,6 +358,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-group-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_delete_modal"
|
phx-click="open_delete_modal"
|
||||||
|
|
@ -364,11 +372,19 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Delete Confirmation Modal --%>
|
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||||
<%= if assigns[:show_delete_modal] do %>
|
<%= 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">
|
<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">
|
<p class="mb-4">
|
||||||
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
|
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -403,6 +419,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
placeholder={gettext("Enter the group name to confirm")}
|
placeholder={gettext("Enter the group name to confirm")}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
phx-debounce="200"
|
phx-debounce="200"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
class="w-full input input-bordered"
|
class="w-full input input-bordered"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -443,12 +460,25 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete", _params, socket) do
|
def handle_event("cancel_delete", _params, socket) do
|
||||||
{:noreply,
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
socket
|
|
||||||
|> assign(:show_delete_modal, false)
|
|
||||||
|> assign(:name_confirmation, "")}
|
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
|
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
|
||||||
{:noreply, assign(socket, :name_confirmation, name)}
|
{:noreply, assign(socket, :name_confirmation, name)}
|
||||||
|
|
@ -929,6 +959,13 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
end
|
end
|
||||||
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
|
defp perform_group_deletion(socket, group, actor) do
|
||||||
case Membership.destroy_group(group, actor: actor) do
|
case Membership.destroy_group(group, actor: actor) do
|
||||||
:ok ->
|
:ok ->
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div id={@id}>
|
<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(
|
{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."
|
"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}
|
:if={!@show_form}
|
||||||
id="member_fields"
|
id="member_fields"
|
||||||
rows={@member_fields}
|
rows={@member_fields}
|
||||||
|
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
|
||||||
row_click={
|
row_click={
|
||||||
fn {field_name, _field_data} ->
|
fn {field_name, _field_data} ->
|
||||||
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
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")}
|
label={gettext("Show in overview")}
|
||||||
class="max-w-[9.375rem] text-center"
|
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")}
|
{gettext("Yes")}
|
||||||
</span>
|
</.badge>
|
||||||
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
|
<.badge :if={!field_data.show_in_overview} variant="neutral">
|
||||||
{gettext("No")}
|
{gettext("No")}
|
||||||
</span>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,8 +100,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
# Track previous show_form state to detect when form is closed
|
# Use socket state so send_update(show_form: false) is the only trigger for "form closed"
|
||||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
previous_show_form = socket.assigns[:show_form] || false
|
||||||
|
|
||||||
# If show_form is explicitly provided in assigns, reset editing state
|
# If show_form is explicitly provided in assigns, reset editing state
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -112,20 +113,22 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
# Detect when form is closed (show_form changes from true to false)
|
socket =
|
||||||
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(assigns)
|
||||||
|> assign_new(:settings, fn -> get_settings() end)
|
|> assign_new(:settings, fn -> get_settings() end)
|
||||||
|> assign_new(:show_form, fn -> false end)
|
|> assign_new(:show_form, fn -> false end)
|
||||||
|> assign_new(:form_id, fn -> "member-field-form-new" 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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
<.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>
|
<.header>
|
||||||
<:leading>
|
<:leading>
|
||||||
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
||||||
|
|
@ -58,14 +63,31 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
|
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
|
||||||
<div role="tablist" class="tabs tabs-bordered">
|
<div
|
||||||
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
role="tablist"
|
||||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
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")}
|
{gettext("Contact Data")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 --%>
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
<%!-- Personal Data Section --%>
|
<%!-- Personal Data Section --%>
|
||||||
|
|
@ -164,7 +186,9 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
<%= for cf <- @sorted_custom_fields do %>
|
<%= for cf <- @sorted_custom_fields do %>
|
||||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
<%= 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]}>
|
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||||
<.input
|
<.input
|
||||||
field={value_form[:value]}
|
field={value_form[:value]}
|
||||||
|
|
@ -258,15 +282,10 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-member-form-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="delete"
|
phx-click="open_delete_modal"
|
||||||
phx-value-id={@member.id}
|
|
||||||
data-confirm={
|
|
||||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
data-testid="member-delete"
|
data-testid="member-delete"
|
||||||
aria-label={
|
aria-label={
|
||||||
gettext("Delete member %{name}",
|
gettext("Delete member %{name}",
|
||||||
|
|
@ -280,6 +299,51 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% 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>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
@ -329,6 +393,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|> assign(:available_fee_types, available_fee_types)
|
|> assign(:available_fee_types, available_fee_types)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|> assign(:member_field_required_map, member_field_required_map)
|
|> assign(:member_field_required_map, member_field_required_map)
|
||||||
|
|> assign_new(:show_delete_modal, fn -> false end)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -400,6 +465,32 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
member = socket.assigns.member
|
member = socket.assigns.member
|
||||||
|
|
@ -407,10 +498,16 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
is_nil(member) ->
|
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) ->
|
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 ->
|
true ->
|
||||||
handle_member_delete_destroy(socket, member, actor)
|
handle_member_delete_destroy(socket, member, actor)
|
||||||
|
|
@ -427,14 +524,26 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
{:noreply,
|
{: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} ->
|
{:error, error} ->
|
||||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(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
|
||||||
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
|
defp handle_save_success(socket, member) do
|
||||||
notify_parent({:saved, member})
|
notify_parent({:saved, member})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1644,11 +1644,13 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
||||||
any_selected? = Enum.any?(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 =
|
mailto_bcc =
|
||||||
if any_selected? do
|
if any_selected? do
|
||||||
format_selected_member_emails(members, selected_members)
|
format_selected_member_emails(members, selected_members)
|
||||||
|> Enum.join(", ")
|
|> Enum.join(", ")
|
||||||
|> URI.encode_www_form()
|
|> URI.encode_www_form()
|
||||||
|
|> String.replace("+", "%20")
|
||||||
else
|
else
|
||||||
""
|
""
|
||||||
end
|
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}
|
{member.membership_fee_type.name}
|
||||||
<% else %>
|
</.maybe_value>
|
||||||
<span class="text-base-content/50">—</span>
|
|
||||||
<% end %>
|
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_status in @member_fields_visible}
|
:if={:membership_fee_status in @member_fields_visible}
|
||||||
label={gettext("Membership Fee Status")}
|
label={gettext("Membership Fee Status")}
|
||||||
>
|
>
|
||||||
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
|
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
|
||||||
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
||||||
) do %>
|
) do %>
|
||||||
<span class={["badge", badge.color]}>
|
<.badge variant={badge.variant}>
|
||||||
<.icon name={badge.icon} class="size-4" />
|
<.icon name={badge.icon} class="size-4" />
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</.badge>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
<.empty_cell sr_text={gettext("No cycle")} />
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
|
|
@ -394,17 +392,17 @@
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||||
<%= for group <- (member.groups || []) do %>
|
<%= for group <- (member.groups || []) do %>
|
||||||
<span
|
<.badge
|
||||||
class="badge badge-outline badge-primary"
|
variant="primary"
|
||||||
|
style="outline"
|
||||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||||
>
|
>
|
||||||
{group.name}
|
{group.name}
|
||||||
</span>
|
</.badge>
|
||||||
<% end %>
|
|
||||||
<%= if (member.groups || []) == [] do %>
|
|
||||||
<span class="text-base-content/50">—</span>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</.maybe_value>
|
||||||
</:col>
|
</:col>
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<div class="sr-only">
|
<div class="sr-only">
|
||||||
|
|
|
||||||
|
|
@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
|
||||||
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
|
||||||
<div
|
<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"
|
role="tablist"
|
||||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||||
|
phx-hook="TabListKeydown"
|
||||||
|
phx-keydown="tab_keydown"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
id="member-tab-contact"
|
id="member-tab-contact"
|
||||||
role="tab"
|
role="tab"
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
tabindex={if @active_tab == :contact, do: "0", else: "-1"}
|
||||||
aria-selected={@active_tab == :contact}
|
aria-selected={if @active_tab == :contact, do: "true", else: "false"}
|
||||||
aria-controls="member-tabpanel-contact"
|
aria-controls="member-tabpanel-contact"
|
||||||
class={[
|
class={[
|
||||||
"tab flex items-center gap-2",
|
"tab flex items-center gap-2",
|
||||||
|
|
@ -82,8 +90,8 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
id="member-tab-membership_fees"
|
id="member-tab-membership_fees"
|
||||||
role="tab"
|
role="tab"
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"}
|
||||||
aria-selected={@active_tab == :membership_fees}
|
aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"}
|
||||||
aria-controls="member-tabpanel-membership_fees"
|
aria-controls="member-tabpanel-membership_fees"
|
||||||
class={[
|
class={[
|
||||||
"tab flex items-center gap-2",
|
"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">
|
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
||||||
<%= if @member.last_cycle_status do %>
|
<%= if @member.last_cycle_status do %>
|
||||||
<% status = @member.last_cycle_status %>
|
<.badge variant={
|
||||||
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
MembershipFeeHelpers.status_variant(@member.last_cycle_status)
|
||||||
{format_status_label(status)}
|
}>
|
||||||
</span>
|
{format_status_label(@member.last_cycle_status)}
|
||||||
|
</.badge>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.data_field>
|
</.data_field>
|
||||||
<.data_field label={gettext("Current Cycle")} class="min-w-36">
|
<.data_field label={gettext("Current Cycle")} class="min-w-36">
|
||||||
<%= if @member.current_cycle_status do %>
|
<%= if @member.current_cycle_status do %>
|
||||||
<% status = @member.current_cycle_status %>
|
<.badge variant={
|
||||||
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
MembershipFeeHelpers.status_variant(@member.current_cycle_status)
|
||||||
{format_status_label(status)}
|
}>
|
||||||
</span>
|
{format_status_label(@member.current_cycle_status)}
|
||||||
|
</.badge>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.data_field>
|
</.data_field>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -313,14 +323,9 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-member-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="delete"
|
phx-click="open_delete_modal"
|
||||||
phx-value-id={@member.id}
|
|
||||||
data-confirm={
|
|
||||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
data-testid="member-delete"
|
data-testid="member-delete"
|
||||||
aria-label={
|
aria-label={
|
||||||
gettext("Delete member %{name}",
|
gettext("Delete member %{name}",
|
||||||
|
|
@ -334,6 +339,48 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% 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>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -344,7 +391,8 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:active_tab, :contact)
|
|> assign(:active_tab, :contact)
|
||||||
|> assign(:vereinfacht_receipts, nil)}
|
|> assign(:vereinfacht_receipts, nil)
|
||||||
|
|> assign_new(:show_delete_modal, fn -> false end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -396,13 +444,58 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
member = socket.assigns.member
|
member = socket.assigns.member
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
if to_string(id) != to_string(member.id) do
|
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
|
else
|
||||||
case Ash.destroy(member, actor: actor) do
|
case Ash.destroy(member, actor: actor) do
|
||||||
:ok ->
|
:ok ->
|
||||||
|
|
@ -413,16 +506,21 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
put_flash(
|
socket
|
||||||
socket,
|
|> put_flash(
|
||||||
:error,
|
:error,
|
||||||
gettext("You do not have permission to delete this member")
|
gettext("You do not have permission to delete this member")
|
||||||
)}
|
)
|
||||||
|
|> assign(:show_delete_modal, false)}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
require Logger
|
require Logger
|
||||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -437,6 +535,13 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
||||||
end
|
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
|
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:put_flash, type, message}, socket) do
|
def handle_info({:put_flash, type, message}, socket) do
|
||||||
|
|
@ -503,7 +608,11 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
<%= if @inner_block != [] do %>
|
<%= if @inner_block != [] do %>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
<% else %>
|
<% else %>
|
||||||
{display_value(@value)}
|
<%= if value_blank?(@value) do %>
|
||||||
|
<.empty_cell sr_text={gettext("Not set")} />
|
||||||
|
<% else %>
|
||||||
|
{@value}
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
@ -537,9 +646,9 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
defp display_value(nil), do: render_empty_value()
|
defp value_blank?(nil), do: true
|
||||||
defp display_value(""), do: render_empty_value()
|
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
|
||||||
defp display_value(value), do: value
|
defp value_blank?(_), do: false
|
||||||
|
|
||||||
defp format_status_label(:paid), do: gettext("Paid")
|
defp format_status_label(:paid), do: gettext("Paid")
|
||||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||||
|
|
@ -628,10 +737,10 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
if String.trim(value) == "" do
|
if String.trim(value) == "" do
|
||||||
render_empty_value()
|
render_empty_value()
|
||||||
else
|
else
|
||||||
assigns = %{email: value}
|
assigns = %{email: value, display: value}
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<.mailto_link email={@email} display={@email} />
|
<.mailto_link email={@email} display={@display} />
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -646,17 +755,10 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
defp format_custom_field_value(value, _type), do: to_string(value)
|
defp format_custom_field_value(value, _type), do: to_string(value)
|
||||||
|
|
||||||
# Renders accessible placeholder for empty values
|
# Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines §8.6).
|
||||||
# Uses translated text for screen readers while maintaining visual consistency
|
# Returns safe HTML so it can be used from helpers without LiveView assigns.
|
||||||
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
|
|
||||||
defp render_empty_value do
|
defp render_empty_value do
|
||||||
assigns = %{text: gettext("Not set")}
|
text = gettext("Not set")
|
||||||
|
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
|
||||||
~H"""
|
|
||||||
<span class="text-base-content/50 italic">
|
|
||||||
<span aria-hidden="true">—</span>
|
|
||||||
<span class="sr-only">{@text}</span>
|
|
||||||
</span>
|
|
||||||
"""
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,13 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
<%= for r <- receipts do %>
|
<%= for r <- receipts do %>
|
||||||
<tr>
|
<tr>
|
||||||
<%= for {col_key, _header_key} <- cols do %>
|
<%= 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 %>
|
<% end %>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -186,9 +192,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={cycle} label={gettext("Interval")}>
|
<:col :let={cycle} label={gettext("Interval")}>
|
||||||
<span class="badge badge-outline">
|
<.badge variant="neutral" style="outline">
|
||||||
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
|
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
|
||||||
</span>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={cycle} label={gettext("Amount")}>
|
<:col :let={cycle} label={gettext("Amount")}>
|
||||||
|
|
@ -208,12 +214,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={cycle} label={gettext("Status")}>
|
<:col :let={cycle} label={gettext("Status")}>
|
||||||
<% badge = MembershipFeeHelpers.status_color(cycle.status) %>
|
<.badge variant={MembershipFeeHelpers.status_variant(cycle.status)}>
|
||||||
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
|
<.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-4" />
|
||||||
<span class={["badge", badge]}>
|
|
||||||
<.icon name={icon} class="size-4" />
|
|
||||||
{format_status_label(cycle.status)}
|
{format_status_label(cycle.status)}
|
||||||
</span>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:action :let={cycle}>
|
<:action :let={cycle}>
|
||||||
|
|
@ -227,7 +231,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
phx-value-status="paid"
|
phx-value-status="paid"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class={cycle_status_btn_class(cycle.status, :paid)}
|
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")}
|
title={gettext("Mark as paid")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-check-circle" class="size-4" />
|
<.icon name="hero-check-circle" class="size-4" />
|
||||||
|
|
@ -240,7 +244,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
phx-value-status="suspended"
|
phx-value-status="suspended"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class={cycle_status_btn_class(cycle.status, :suspended)}
|
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")}
|
title={gettext("Mark as suspended")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-pause-circle" class="size-4" />
|
<.icon name="hero-pause-circle" class="size-4" />
|
||||||
|
|
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
phx-value-status="unpaid"
|
phx-value-status="unpaid"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class={cycle_status_btn_class(cycle.status, :unpaid)}
|
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")}
|
title={gettext("Mark as unpaid")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-x-circle" class="size-4" />
|
<.icon name="hero-x-circle" class="size-4" />
|
||||||
|
|
@ -290,11 +294,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
<% end %>
|
<% end %>
|
||||||
</.section_box>
|
</.section_box>
|
||||||
|
|
||||||
<%!-- Edit Cycle Amount Modal --%>
|
<%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||||
<%= if @editing_cycle do %>
|
<%= 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">
|
<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}>
|
<form phx-submit="save_cycle_amount" phx-target={@myself}>
|
||||||
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
|
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
|
||||||
<div class="form-control w-full mt-4">
|
<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(".", ",")}
|
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
|
|
@ -328,11 +341,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</dialog>
|
</dialog>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Delete Cycle Confirmation Modal --%>
|
<%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||||
<%= if @deleting_cycle do %>
|
<%= 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">
|
<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">
|
<p class="py-4">
|
||||||
{gettext("Are you sure you want to delete this cycle?")}
|
{gettext("Are you sure you want to delete this cycle?")}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -343,7 +362,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
|
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
|
||||||
</p>
|
</p>
|
||||||
<div class="modal-action">
|
<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")}
|
{gettext("Cancel")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
|
|
@ -359,11 +383,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</dialog>
|
</dialog>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Delete All Cycles Confirmation Modal --%>
|
<%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||||
<%= if @deleting_all_cycles do %>
|
<%= 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">
|
<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">
|
<div class="alert alert-warning mt-4">
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -391,6 +423,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
value={@delete_all_confirmation || ""}
|
value={@delete_all_confirmation || ""}
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
placeholder={gettext("Yes")}
|
placeholder={gettext("Yes")}
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
|
|
@ -413,11 +446,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</dialog>
|
</dialog>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Create Cycle Modal --%>
|
<%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||||
<%= if @creating_cycle do %>
|
<%= 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">
|
<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}>
|
<form phx-submit="create_cycle" phx-target={@myself}>
|
||||||
<div class="form-control w-full mt-4">
|
<div class="form-control w-full mt-4">
|
||||||
<label class="label" for="create-cycle-date">
|
<label class="label" for="create-cycle-date">
|
||||||
|
|
@ -433,6 +472,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
aria-label={gettext("Date")}
|
aria-label={gettext("Date")}
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
/>
|
/>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt">
|
<span class="label-text-alt">
|
||||||
|
|
@ -881,6 +921,35 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign(:create_cycle_error, nil)}
|
|> assign(:create_cycle_error, nil)}
|
||||||
end
|
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
|
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
|
||||||
date =
|
date =
|
||||||
case Date.from_iso8601(date_str) do
|
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)
|
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
|
||||||
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
|
defp format_receipt_cell(:amount, val) when is_number(val) do
|
||||||
case Decimal.cast(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(: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
|
defp format_receipt_cell(:status, val) when is_binary(val) do
|
||||||
translate_receipt_status(val)
|
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(: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
|
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
|
||||||
translate_receipt_type(val)
|
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(:receiptType, val), do: translate_receipt_type(to_string(val))
|
||||||
|
|
||||||
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
|
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
|
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
|
||||||
format_receipt_date(val)
|
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("draft"), do: gettext("Draft")
|
||||||
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
|
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
|
||||||
defp translate_receipt_status("completed"), do: gettext("Completed")
|
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
|
defp translate_receipt_status(other), do: other
|
||||||
|
|
||||||
# Translate API receipt type values (extend as API returns more values)
|
# Translate API receipt type values (extend as API returns more values)
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Membership Fee Settings")}
|
{gettext("Membership Fee Settings")}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Configure global settings and fee types for membership fees.")}
|
{gettext("Configure fee types for membership fees.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
|
<.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"
|
id="default_membership_fee_type_id"
|
||||||
name="settings[default_membership_fee_type_id]"
|
name="settings[default_membership_fee_type_id]"
|
||||||
class={[
|
class={[
|
||||||
"select select-bordered w-full",
|
"select select-bordered",
|
||||||
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
||||||
]}
|
]}
|
||||||
phx-debounce="blur"
|
phx-debounce="blur"
|
||||||
|
|
@ -323,13 +323,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={mft} label={gettext("Interval")}>
|
<:col :let={mft} label={gettext("Interval")}>
|
||||||
<span class="badge badge-outline">
|
<.badge variant="neutral" style="outline">
|
||||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||||
</span>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={mft} label={gettext("Members")}>
|
<: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>
|
</:col>
|
||||||
|
|
||||||
<:action :let={mft}>
|
<:action :let={mft}>
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
<:subtitle>
|
|
||||||
{gettext("Use this form to manage membership fee types in your database.")}
|
|
||||||
</:subtitle>
|
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button
|
<.button
|
||||||
form="membership-fee-type-form"
|
form="membership-fee-type-form"
|
||||||
|
|
|
||||||
|
|
@ -68,13 +68,13 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={mft} label={gettext("Interval")}>
|
<:col :let={mft} label={gettext("Interval")}>
|
||||||
<span class="badge badge-outline">
|
<.badge variant="neutral" style="outline">
|
||||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||||
</span>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={mft} label={gettext("Members")}>
|
<: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>
|
</:col>
|
||||||
|
|
||||||
<:action :let={mft}>
|
<:action :let={mft}>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
|
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
{gettext("Save")}
|
{gettext("Save")}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ defmodule MvWeb.RoleLive.Helpers do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the CSS badge class for a permission set name.
|
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()
|
@spec permission_set_badge_class(String.t()) :: String.t()
|
||||||
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
|
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("admin"), do: "badge badge-error badge-sm"
|
||||||
def permission_set_badge_class(_), do: "badge badge-ghost 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 """
|
@doc """
|
||||||
Builds Ash options with actor and domain, ensuring actor is never nil in real paths.
|
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
|
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
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,16 @@
|
||||||
<.table
|
<.table
|
||||||
id="roles"
|
id="roles"
|
||||||
rows={@roles}
|
rows={@roles}
|
||||||
|
row_id={fn role -> "role-#{role.id}" end}
|
||||||
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
||||||
row_tooltip={gettext("Click for role details")}
|
row_tooltip={gettext("Click for role details")}
|
||||||
>
|
>
|
||||||
<:col :let={role} label={gettext("Name")}>
|
<:col :let={role} label={gettext("Name")}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium">{role.name}</span>
|
<span class="font-medium">{role.name}</span>
|
||||||
<%= if role.is_system_role do %>
|
<.badge :if={role.is_system_role} variant="warning" size="sm">
|
||||||
<span class="badge badge-warning badge-sm">{gettext("System Role")}</span>
|
{gettext("System Role")}
|
||||||
<% end %>
|
</.badge>
|
||||||
</div>
|
</div>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
|
|
@ -37,21 +38,22 @@
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={role} label={gettext("Permission Set")}>
|
<: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}
|
{role.permission_set_name}
|
||||||
</span>
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={role} label={gettext("Type")}>
|
<:col :let={role} label={gettext("Type")}>
|
||||||
<%= if role.is_system_role do %>
|
<.badge :if={role.is_system_role} variant="warning" size="sm">
|
||||||
<span class="badge badge-warning badge-sm">{gettext("System")}</span>
|
{gettext("System")}
|
||||||
<% else %>
|
</.badge>
|
||||||
<span class="badge badge-ghost badge-sm">{gettext("Custom")}</span>
|
<.badge :if={!role.is_system_role} variant="neutral" size="sm">
|
||||||
<% end %>
|
{gettext("Custom")}
|
||||||
|
</.badge>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={role} label={gettext("Users")}>
|
<: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>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
import MvWeb.RoleLive.Helpers,
|
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
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
|
|
@ -35,7 +35,8 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Show Role"))
|
|> assign(:page_title, gettext("Show Role"))
|
||||||
|> assign(:role, 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{} | _]}} ->
|
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
@ -84,35 +85,61 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
error_message = format_error(error)
|
error_message = format_error(error)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
put_flash(
|
socket
|
||||||
socket,
|
|> put_flash(
|
||||||
:error,
|
:error,
|
||||||
gettext("Failed to delete role: %{error}", error: error_message)
|
gettext("Failed to delete role: %{error}", error: error_message)
|
||||||
)}
|
)
|
||||||
|
|> assign(:show_delete_modal, false)}
|
||||||
end
|
end
|
||||||
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
|
defp handle_delete_role(role, socket) do
|
||||||
if role.is_system_role do
|
if role.is_system_role do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
put_flash(
|
socket
|
||||||
socket,
|
|> put_flash(:error, gettext("System roles cannot be deleted."))
|
||||||
:error,
|
|> assign(:show_delete_modal, false)}
|
||||||
gettext("System roles cannot be deleted.")
|
|
||||||
)}
|
|
||||||
else
|
else
|
||||||
user_count = recalculate_user_count(role, socket.assigns.current_user)
|
user_count = recalculate_user_count(role, socket.assigns.current_user)
|
||||||
|
|
||||||
if user_count > 0 do
|
if user_count > 0 do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
put_flash(
|
socket
|
||||||
socket,
|
|> put_flash(
|
||||||
:error,
|
:error,
|
||||||
gettext(
|
gettext(
|
||||||
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
|
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
|
||||||
count: user_count
|
count: user_count
|
||||||
)
|
)
|
||||||
)}
|
)
|
||||||
|
|> assign(:show_delete_modal, false)}
|
||||||
else
|
else
|
||||||
perform_role_deletion(role, socket)
|
perform_role_deletion(role, socket)
|
||||||
end
|
end
|
||||||
|
|
@ -156,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
recalculate_user_count(role, actor)
|
recalculate_user_count(role, actor)
|
||||||
end
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -187,6 +220,11 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="role-show-focus-root"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<.list>
|
<.list>
|
||||||
<:item title={gettext("Name")}>{@role.name}</:item>
|
<:item title={gettext("Name")}>{@role.name}</:item>
|
||||||
<:item title={gettext("Description")}>
|
<:item title={gettext("Description")}>
|
||||||
|
|
@ -197,16 +235,17 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
<% end %>
|
<% end %>
|
||||||
</:item>
|
</:item>
|
||||||
<:item title={gettext("Permission Set")}>
|
<: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}
|
{@role.permission_set_name}
|
||||||
</span>
|
</.badge>
|
||||||
</:item>
|
</:item>
|
||||||
<:item title={gettext("System Role")}>
|
<:item title={gettext("System Role")}>
|
||||||
<%= if @role.is_system_role do %>
|
<.badge :if={@role.is_system_role} variant="warning">
|
||||||
<span class="badge badge-warning">{gettext("Yes")}</span>
|
{gettext("Yes")}
|
||||||
<% else %>
|
</.badge>
|
||||||
<span class="badge badge-ghost">{gettext("No")}</span>
|
<.badge :if={!@role.is_system_role} variant="neutral">
|
||||||
<% end %>
|
{gettext("No")}
|
||||||
|
</.badge>
|
||||||
</:item>
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
|
|
||||||
|
|
@ -223,14 +262,9 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-role-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
phx-click="open_delete_modal"
|
||||||
data-confirm={
|
|
||||||
gettext(
|
|
||||||
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
|
||||||
name: @role.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
data-testid="role-delete"
|
data-testid="role-delete"
|
||||||
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
||||||
>
|
>
|
||||||
|
|
@ -240,6 +274,49 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% 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>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@ defmodule MvWeb.StatisticsLive do
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Statistics")}
|
{gettext("Statistics")}
|
||||||
<:subtitle>{gettext("Overview from first membership to today")}</:subtitle>
|
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<section class="mb-8" aria-labelledby="members-heading">
|
<section class="mb-8" aria-labelledby="members-heading">
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button
|
<.button
|
||||||
form="user-form"
|
form="user-form"
|
||||||
|
|
@ -66,6 +65,11 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.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">
|
<.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" />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
|
|
||||||
|
|
@ -311,16 +315,10 @@ defmodule MvWeb.UserLive.Form do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-user-form-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="delete"
|
phx-click="open_delete_modal"
|
||||||
phx-value-id={@user.id}
|
|
||||||
data-confirm={
|
|
||||||
gettext(
|
|
||||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
|
||||||
email: @user.email
|
|
||||||
)
|
|
||||||
}
|
|
||||||
data-testid="user-delete"
|
data-testid="user-delete"
|
||||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||||
>
|
>
|
||||||
|
|
@ -331,6 +329,49 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% 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">
|
<div class="mt-4">
|
||||||
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
|
|
@ -340,6 +381,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -399,6 +441,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> assign(:selected_member_name, nil)
|
|> assign(:selected_member_name, nil)
|
||||||
|> assign(:unlink_member, false)
|
|> assign(:unlink_member, false)
|
||||||
|> assign(:focused_member_index, nil)
|
|> assign(:focused_member_index, nil)
|
||||||
|
|> assign_new(:show_delete_modal, fn -> false end)
|
||||||
|> load_initial_members()
|
|> load_initial_members()
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
@ -454,6 +497,32 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
user = socket.assigns.user
|
user = socket.assigns.user
|
||||||
|
|
@ -461,13 +530,22 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
is_nil(user) ->
|
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) ->
|
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) ->
|
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 ->
|
true ->
|
||||||
handle_user_delete_destroy(socket, user, actor)
|
handle_user_delete_destroy(socket, user, actor)
|
||||||
|
|
@ -594,13 +672,24 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
{:noreply,
|
{: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} ->
|
{: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
|
||||||
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
|
defp handle_member_linking(socket, user, actor) do
|
||||||
result = perform_member_link_action(socket, user, actor)
|
result = perform_member_link_action(socket, user, actor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Listing Users")}
|
{gettext("Users")}
|
||||||
|
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||||
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
||||||
|
|
@ -37,25 +38,25 @@
|
||||||
{user.role.name}
|
{user.role.name}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={user} label={gettext("Linked Member")}>
|
<: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)}
|
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
|
||||||
<% else %>
|
</.maybe_value>
|
||||||
<span class="text-base-content/70">{gettext("No member linked")}</span>
|
|
||||||
<% end %>
|
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={user} label={gettext("Password")}>
|
<: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>
|
<span>{gettext("Enabled")}</span>
|
||||||
<% else %>
|
</.maybe_value>
|
||||||
<span class="text-base-content/70">—</span>
|
|
||||||
<% end %>
|
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={user} label={gettext("OIDC")}>
|
<: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>
|
<span>{gettext("Linked")}</span>
|
||||||
<% else %>
|
</.maybe_value>
|
||||||
<span class="text-base-content/70">—</span>
|
|
||||||
<% end %>
|
|
||||||
</:col>
|
</:col>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,6 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{gettext("User")} {@user.email}
|
{gettext("User")} {@user.email}
|
||||||
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
|
||||||
|
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :update, @user) do %>
|
<%= if can?(@current_user, :update, @user) do %>
|
||||||
<.button
|
<.button
|
||||||
|
|
@ -60,6 +58,11 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="user-show-focus-root"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<.list>
|
<.list>
|
||||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||||
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
||||||
|
|
@ -101,15 +104,9 @@ defmodule MvWeb.UserLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-user-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="delete"
|
phx-click="open_delete_modal"
|
||||||
phx-value-id={@user.id}
|
|
||||||
data-confirm={
|
|
||||||
gettext(
|
|
||||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
|
||||||
email: @user.email
|
|
||||||
)
|
|
||||||
}
|
|
||||||
data-testid="user-delete"
|
data-testid="user-delete"
|
||||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||||
>
|
>
|
||||||
|
|
@ -119,6 +116,48 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% 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>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -139,10 +178,37 @@ defmodule MvWeb.UserLive.Show do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Show User"))
|
|> assign(:page_title, gettext("Show User"))
|
||||||
|> assign(:user, user)}
|
|> assign(:user, user)
|
||||||
|
|> assign(:show_delete_modal, false)}
|
||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
user = socket.assigns.user
|
user = socket.assigns.user
|
||||||
|
|
@ -150,10 +216,16 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
to_string(id) != to_string(user.id) ->
|
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) ->
|
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 ->
|
true ->
|
||||||
handle_user_delete_destroy(socket, user, actor)
|
handle_user_delete_destroy(socket, user, actor)
|
||||||
|
|
@ -170,10 +242,21 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
{:noreply,
|
{: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} ->
|
{: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
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -93,22 +93,30 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
|
|
||||||
## Returns
|
## 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
|
## Examples
|
||||||
|
|
||||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
|
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)
|
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
|
||||||
nil
|
nil
|
||||||
"""
|
"""
|
||||||
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | 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(nil), do: nil
|
||||||
|
|
||||||
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
|
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
|
||||||
%{
|
%{
|
||||||
|
variant: MembershipFeeHelpers.status_variant(status),
|
||||||
color: MembershipFeeHelpers.status_color(status),
|
color: MembershipFeeHelpers.status_color(status),
|
||||||
icon: MembershipFeeHelpers.status_icon(status),
|
icon: MembershipFeeHelpers.status_icon(status),
|
||||||
label: format_status_label(status)
|
label: format_status_label(status)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,12 @@ msgid "City"
|
||||||
msgstr "Stadt"
|
msgstr "Stadt"
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Löschen"
|
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/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.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/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/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.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/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Abbrechen"
|
msgstr "Abbrechen"
|
||||||
|
|
@ -294,7 +302,6 @@ msgid "Logout"
|
||||||
msgstr "Abmelden"
|
msgstr "Abmelden"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/index.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Listing Users"
|
msgid "Listing Users"
|
||||||
msgstr "Benutzer*innen auflisten"
|
msgstr "Benutzer*innen auflisten"
|
||||||
|
|
@ -381,16 +388,6 @@ msgstr "Benutzer*in speichern"
|
||||||
msgid "Show User"
|
msgid "Show User"
|
||||||
msgstr "Benutzer*in anzeigen"
|
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/form.ex
|
||||||
#: lib/mv_web/live/user_live/show.ex
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -533,6 +530,7 @@ msgstr "Suchen..."
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Benutzer*innen"
|
msgstr "Benutzer*innen"
|
||||||
|
|
@ -593,18 +591,6 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
|
||||||
msgid "Custom Fields"
|
msgid "Custom Fields"
|
||||||
msgstr "Benutzerdefinierte Felder"
|
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
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
|
|
@ -790,6 +776,7 @@ msgstr "Beitragsdaten"
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
msgstr "Zahlungen"
|
msgstr "Zahlungen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -1388,6 +1375,8 @@ msgid "None (no default)"
|
||||||
msgstr "Keine (kein Standard)"
|
msgstr "Keine (kein Standard)"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: 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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Not set"
|
msgid "Not set"
|
||||||
msgstr "Nicht gesetzt"
|
msgstr "Nicht gesetzt"
|
||||||
|
|
@ -1473,11 +1462,6 @@ msgstr "Art"
|
||||||
msgid "Type '%{confirmation}' to confirm"
|
msgid "Type '%{confirmation}' to confirm"
|
||||||
msgstr "Gib '%{confirmation}' ein, um zu bestätigen"
|
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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Warning"
|
msgid "Warning"
|
||||||
|
|
@ -1704,11 +1688,6 @@ msgstr "System-Rollen können nicht gelöscht werden."
|
||||||
msgid "Toggle sidebar"
|
msgid "Toggle sidebar"
|
||||||
msgstr "Sidebar umschalten"
|
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
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
|
|
@ -2428,11 +2407,6 @@ msgstr "Alle Jahre zusammengefasst (Kreis)"
|
||||||
msgid "Contributions by year"
|
msgid "Contributions by year"
|
||||||
msgstr "Beiträge nach Jahr"
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contributions by year as table with stacked bars"
|
msgid "Contributions by year as table with stacked bars"
|
||||||
|
|
@ -2910,11 +2884,6 @@ msgstr "CSV Datei auswählen"
|
||||||
msgid "Import Members"
|
msgid "Import Members"
|
||||||
msgstr "Mitglieder importieren (CSV)"
|
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
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Admin group name"
|
msgid "Admin group name"
|
||||||
|
|
@ -2940,21 +2909,6 @@ msgstr "Client-ID"
|
||||||
msgid "Client Secret"
|
msgid "Client Secret"
|
||||||
msgstr "Client-Geheimnis"
|
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/components/layouts/sidebar.ex
|
||||||
#: lib/mv_web/live/datafields_live.ex
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2996,11 +2950,6 @@ msgstr "Aus OIDC_REDIRECT_URI"
|
||||||
msgid "Groups claim"
|
msgid "Groups claim"
|
||||||
msgstr "Gruppenclaim"
|
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
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Membership fee settings"
|
msgid "Membership fee settings"
|
||||||
|
|
@ -3225,88 +3174,64 @@ msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden.
|
||||||
msgid "Individual datafields"
|
msgid "Individual datafields"
|
||||||
msgstr "Individuelle Datenfelder"
|
msgstr "Individuelle Datenfelder"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_field_live/form_component.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#~ msgid "Back to Settings"
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgstr "Zurück zu den Einstellungen"
|
msgid "Delete Member"
|
||||||
|
msgstr "Mitglied löschen"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Cannot delete system role"
|
msgid "Delete Role"
|
||||||
#~ msgstr "System-Rolle kann nicht gelöscht werden"
|
msgstr "Rolle löschen"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#~ msgid "Click for custom field details"
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgstr "Klicke für Datenfeld-Details"
|
msgid "Delete User"
|
||||||
|
msgstr "Benutzer*in löschen"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Click for datafield details"
|
msgid "Configure fee types for membership fees."
|
||||||
#~ msgstr "Klicke für Datenfeld-Details"
|
msgstr "Verwalte Beitragsarten und Mitgliedsbeiträge."
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Coming soon"
|
msgid "Configure which data you want to save for your members. Define individual datafields."
|
||||||
#~ msgstr "Demnächst verfügbar"
|
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
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Custom Field %{id}"
|
msgid "Manage users and their permissions."
|
||||||
#~ msgstr "Benutzerdefiniertes Feld %{id}"
|
msgstr "Verwalte Benutzer*innen und ihre Berechtigungen."
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
#: 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
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
msgid "%{count} member has a value assigned for this datafield."
|
||||||
#~ msgid "Edit datafield"
|
msgid_plural "%{count} members have values assigned for this datafield."
|
||||||
#~ msgstr "Datenfeld bearbeiten"
|
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
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Edit user"
|
msgid "Individual Datafields"
|
||||||
#~ msgstr "Benutzer*in bearbeiten"
|
msgstr "Individuelle Datenfelder"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Reset"
|
msgid "No group assignment"
|
||||||
#~ msgstr "Zurücksetzen"
|
msgstr "Keine Gruppenzuordnung"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
#~ msgid "Rolle bearbeiten"
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#~ msgstr "Rolle bearbeiten"
|
#: 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
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Save Role"
|
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||||
#~ msgstr "Rolle speichern"
|
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
||||||
|
|
||||||
#~ #: 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"
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,12 @@ msgid "City"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -258,9 +263,12 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.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/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/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.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/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -295,7 +303,6 @@ msgid "Logout"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/index.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Listing Users"
|
msgid "Listing Users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -382,16 +389,6 @@ msgstr ""
|
||||||
msgid "Show User"
|
msgid "Show User"
|
||||||
msgstr ""
|
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/form.ex
|
||||||
#: lib/mv_web/live/user_live/show.ex
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -534,6 +531,7 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -594,18 +592,6 @@ msgstr ""
|
||||||
msgid "Custom Fields"
|
msgid "Custom Fields"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
|
|
@ -791,6 +777,7 @@ msgstr ""
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -1389,6 +1376,8 @@ msgid "None (no default)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: 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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Not set"
|
msgid "Not set"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1474,11 +1463,6 @@ msgstr ""
|
||||||
msgid "Type '%{confirmation}' to confirm"
|
msgid "Type '%{confirmation}' to confirm"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Warning"
|
msgid "Warning"
|
||||||
|
|
@ -1705,11 +1689,6 @@ msgstr ""
|
||||||
msgid "Toggle sidebar"
|
msgid "Toggle sidebar"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
|
|
@ -2429,11 +2408,6 @@ msgstr ""
|
||||||
msgid "Contributions by year"
|
msgid "Contributions by year"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contributions by year as table with stacked bars"
|
msgid "Contributions by year as table with stacked bars"
|
||||||
|
|
@ -2935,21 +2909,6 @@ msgstr ""
|
||||||
msgid "Client Secret"
|
msgid "Client Secret"
|
||||||
msgstr ""
|
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/components/layouts/sidebar.ex
|
||||||
#: lib/mv_web/live/datafields_live.ex
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2991,11 +2950,6 @@ msgstr ""
|
||||||
msgid "Groups claim"
|
msgid "Groups claim"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/datafields_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Member fields"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership fee settings"
|
msgid "Membership fee settings"
|
||||||
|
|
@ -3219,3 +3173,65 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Individual datafields"
|
msgid "Individual datafields"
|
||||||
msgstr ""
|
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 ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -258,9 +263,12 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.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/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/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.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/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -295,7 +303,6 @@ msgid "Logout"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/index.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Listing Users"
|
msgid "Listing Users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -382,16 +389,6 @@ msgstr ""
|
||||||
msgid "Show User"
|
msgid "Show User"
|
||||||
msgstr ""
|
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/form.ex
|
||||||
#: lib/mv_web/live/user_live/show.ex
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -534,6 +531,7 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -594,18 +592,6 @@ msgstr ""
|
||||||
msgid "Custom Fields"
|
msgid "Custom Fields"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
|
|
@ -791,6 +777,7 @@ msgstr ""
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -1389,6 +1376,8 @@ msgid "None (no default)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: 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
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Not set"
|
msgid "Not set"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1474,11 +1463,6 @@ msgstr ""
|
||||||
msgid "Type '%{confirmation}' to confirm"
|
msgid "Type '%{confirmation}' to confirm"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Warning"
|
msgid "Warning"
|
||||||
|
|
@ -1705,11 +1689,6 @@ msgstr ""
|
||||||
msgid "Toggle sidebar"
|
msgid "Toggle sidebar"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
|
|
@ -2429,11 +2408,6 @@ msgstr ""
|
||||||
msgid "Contributions by year"
|
msgid "Contributions by year"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/statistics_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Contributions by year as table with stacked bars"
|
msgid "Contributions by year as table with stacked bars"
|
||||||
|
|
@ -2935,21 +2909,6 @@ msgstr ""
|
||||||
msgid "Client Secret"
|
msgid "Client Secret"
|
||||||
msgstr ""
|
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/components/layouts/sidebar.ex
|
||||||
#: lib/mv_web/live/datafields_live.ex
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2991,11 +2950,6 @@ msgstr ""
|
||||||
msgid "Groups claim"
|
msgid "Groups claim"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/datafields_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Member fields"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Membership fee settings"
|
msgid "Membership fee settings"
|
||||||
|
|
@ -3220,88 +3174,64 @@ msgstr ""
|
||||||
msgid "Individual datafields"
|
msgid "Individual datafields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_field_live/form_component.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#~ msgid "Back to Settings"
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgstr ""
|
msgid "Delete Member"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Cannot delete system role"
|
msgid "Delete Role"
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#~ msgid "Click for custom field details"
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgstr ""
|
msgid "Delete User"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Click for datafield details"
|
msgid "Configure fee types for membership fees."
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Coming soon"
|
msgid "Configure which data you want to save for your members. Define individual datafields."
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Custom Field %{id}"
|
msgid "Manage users and their permissions."
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
#: 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
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
msgid "%{count} member has a value assigned for this datafield."
|
||||||
#~ msgid "Edit datafield"
|
msgid_plural "%{count} members have values assigned for this datafield."
|
||||||
#~ msgstr ""
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
#: lib/mv_web/live/datafields_live.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Edit user"
|
msgid "Individual Datafields"
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Reset"
|
msgid "No group assignment"
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
#~ msgid "Rolle bearbeiten"
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#~ msgstr ""
|
#: 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
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Save Role"
|
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||||
#~ msgstr ""
|
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 ""
|
|
||||||
|
|
|
||||||
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
|
||||||
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
|
describe "status_color/1" do
|
||||||
test "returns correct color classes for statuses" do
|
test "returns correct color classes for statuses" do
|
||||||
assert MembershipFeeHelpers.status_color(:paid) == "badge-success"
|
assert MembershipFeeHelpers.status_color(:paid) == "badge-success"
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,19 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
%{conn: conn, user: user_with_role}
|
%{conn: conn, user: user_with_role}
|
||||||
end
|
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
|
defp open_delete_modal(view, custom_field) do
|
||||||
view
|
row_selector = "tr#custom_fields-#{custom_field.id} td"
|
||||||
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|
view |> element(row_selector, custom_field.name) |> render_click()
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
if has_element?(view, "[data-testid=custom-field-delete]") do
|
||||||
|> element("[data-testid=custom-field-delete]")
|
view |> element("[data-testid=custom-field-delete]") |> render_click()
|
||||||
|> render_click()
|
else
|
||||||
|
view |> element(row_selector, custom_field.name) |> render_click()
|
||||||
|
view |> element("[data-testid=custom-field-delete]") |> render_click()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "delete button and modal" do
|
describe "delete button and modal" do
|
||||||
|
|
@ -71,8 +75,12 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
# Modal should be visible
|
# Modal should be visible
|
||||||
assert has_element?(view, "#delete-custom-field-modal")
|
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)
|
# 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
|
# Should show the slug
|
||||||
assert render(view) =~ custom_field.slug
|
assert render(view) =~ custom_field.slug
|
||||||
|
|
@ -91,7 +99,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
open_delete_modal(view, custom_field)
|
open_delete_modal(view, custom_field)
|
||||||
|
|
||||||
# Should show plural form
|
# 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
|
end
|
||||||
|
|
||||||
test "shows 0 members for custom field without values", %{conn: conn} do
|
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)
|
open_delete_modal(view, custom_field)
|
||||||
|
|
||||||
# Should show 0 members
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,21 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
|
||||||
end
|
end
|
||||||
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
|
describe "required fields" do
|
||||||
setup do
|
setup do
|
||||||
{:ok, settings} = Membership.get_settings()
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
|
|
||||||
|
|
@ -386,11 +386,16 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
|
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
|
||||||
|
|
||||||
# Delete from Danger zone on show page
|
# Open delete modal from Danger zone
|
||||||
view
|
view
|
||||||
|> element("[data-testid=role-delete]")
|
|> element("[data-testid=role-delete]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
|
# Confirm deletion in modal
|
||||||
|
view
|
||||||
|
|> element("[data-testid=role-delete-confirm]")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
assert_redirect(view, "/admin/roles")
|
assert_redirect(view, "/admin/roles")
|
||||||
|
|
||||||
# Verify deletion by checking database
|
# 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
|
test "page shows overview of all relevant years without year selector", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/statistics")
|
{:ok, _view, html} = live(conn, ~p"/statistics")
|
||||||
|
|
||||||
# No year dropdown: single select for year should not be present as main control
|
# Page shows multi-year data (member numbers by year) and year column; no single-year selector as main control
|
||||||
assert html =~ "Overview" or html =~ "overview"
|
assert html =~ "Member numbers by year"
|
||||||
# table header or legend
|
|
||||||
assert html =~ "Year"
|
assert html =~ "Year"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,20 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
|
||||||
assert html =~ member3.first_name
|
assert html =~ member3.first_name
|
||||||
end
|
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
|
test "displays group name correctly in badge", %{conn: conn, group1: group1} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
|
||||||
|
|
@ -123,13 +123,17 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
{:ok, index_view, _html} = live(conn, "/users")
|
{:ok, index_view, _html} = live(conn, "/users")
|
||||||
assert render(index_view) =~ "delete-me@example.com"
|
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}")
|
{:ok, show_view, _html} = live(conn, "/users/#{user.id}")
|
||||||
|
|
||||||
show_view
|
show_view
|
||||||
|> element("[data-testid=user-delete]")
|
|> element("[data-testid=user-delete]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
|
show_view
|
||||||
|
|> element("#delete-user-modal button", "Delete")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
# Should redirect to index
|
# Should redirect to index
|
||||||
assert_redirect(show_view, "/users")
|
assert_redirect(show_view, "/users")
|
||||||
|
|
||||||
|
|
@ -206,7 +210,9 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Password column display" do
|
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 created with hashed_password: nil (no password) - must not get default password
|
||||||
user_no_pw =
|
user_no_pw =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
|
|
@ -219,9 +225,13 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
|
|
||||||
assert html =~ "no-password@example.com"
|
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()
|
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",
|
refute row =~ "Enabled",
|
||||||
"Password column must not show Enabled when user has no password"
|
"Password column must not show Enabled when user has no password"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue