Merge pull request 'Enhances accessibiity closes #421' (#450) from feat/421_accessibility into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #450
This commit is contained in:
commit
8fac974b1b
45 changed files with 2517 additions and 1214 deletions
|
|
@ -2775,6 +2775,14 @@ Building accessible applications ensures that all users, including those with di
|
||||||
<div phx-click="action">Click me</div>
|
<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})
|
||||||
socket
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|> assign(:show_delete_modal, false)
|
end
|
||||||
|> assign(:custom_field_to_delete, nil)
|
|
||||||
|> assign(:slug_confirmation, "")}
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
defp close_delete_modal_and_restore_focus(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:custom_field_to_delete, nil)
|
||||||
|
|> assign(:slug_confirmation, "")
|
||||||
|
|> 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
|
||||||
|
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||||
|
id="member-fields-component"
|
||||||
|
settings={@settings}
|
||||||
|
/>
|
||||||
|
</.form_section>
|
||||||
|
|
||||||
|
<.form_section title={gettext("Individual Datafields")}>
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||||
|
id="custom-fields-component"
|
||||||
|
actor={@current_user}
|
||||||
|
/>
|
||||||
|
</.form_section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Edit mode: only the active section, no section title/card wrapper --%>
|
||||||
|
<div :if={@active_editing_section == :member_fields} class="mt-6">
|
||||||
<.live_component
|
<.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>
|
</div>
|
||||||
|
|
||||||
<.form_section title={gettext("Custom fields")}>
|
<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
|
<.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>
|
</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)
|
socket
|
||||||
|
|> assign(assigns)
|
||||||
|
|> assign_new(:settings, fn -> get_settings() end)
|
||||||
|
|> assign_new(:show_form, fn -> false end)
|
||||||
|
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|
||||||
|
|> assign_new(:editing_member_field, fn -> nil end)
|
||||||
|
|
||||||
if previous_show_form and not new_show_form do
|
# 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})
|
send(self(), {:editing_section_changed, nil})
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok, socket}
|
||||||
socket
|
|
||||||
|> assign(assigns)
|
|
||||||
|> assign_new(:settings, fn -> get_settings() end)
|
|
||||||
|> assign_new(:show_form, fn -> false end)
|
|
||||||
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|
|
||||||
|> assign_new(:editing_member_field, fn -> nil end)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -38,248 +38,312 @@ 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">
|
||||||
<.header>
|
<div
|
||||||
<:leading>
|
id="member-form-focus-root"
|
||||||
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
phx-hook="FocusRestore"
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
{gettext("Back")}
|
>
|
||||||
</.button>
|
<.header>
|
||||||
</:leading>
|
<:leading>
|
||||||
<%= if @member do %>
|
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
<% else %>
|
{gettext("Back")}
|
||||||
{gettext("New Member")}
|
</.button>
|
||||||
<% end %>
|
</:leading>
|
||||||
<:actions>
|
<%= if @member do %>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
{gettext("Save")}
|
<% else %>
|
||||||
</.button>
|
{gettext("New Member")}
|
||||||
</:actions>
|
<% end %>
|
||||||
</.header>
|
<:actions>
|
||||||
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
|
{gettext("Save")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.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"
|
||||||
{gettext("Contact Data")}
|
>
|
||||||
</button>
|
<button
|
||||||
</div>
|
id="member-tab-contact"
|
||||||
|
role="tab"
|
||||||
<%!-- Personal Data and Custom Fields Row --%>
|
type="button"
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
tabindex="0"
|
||||||
<%!-- Personal Data Section --%>
|
aria-selected="true"
|
||||||
<div>
|
aria-controls="member-tabpanel-contact"
|
||||||
<.form_section title={gettext("Personal Data")}>
|
class="tab tab-active flex items-center gap-2"
|
||||||
<div class="space-y-4">
|
>
|
||||||
<%!-- Name Row --%>
|
<.icon name="hero-identification" class="size-4 shrink-0" />
|
||||||
<div class="flex gap-4">
|
{gettext("Contact Data")}
|
||||||
<div class="w-48">
|
</button>
|
||||||
<.input
|
|
||||||
field={@form[:first_name]}
|
|
||||||
label={gettext("First Name")}
|
|
||||||
required={@member_field_required_map[:first_name]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-48">
|
|
||||||
<.input
|
|
||||||
field={@form[:last_name]}
|
|
||||||
label={gettext("Last Name")}
|
|
||||||
required={@member_field_required_map[:last_name]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div class="w-48">
|
|
||||||
<.input field={@form[:country]} label={gettext("Country")} />
|
|
||||||
</div>
|
|
||||||
<div class="w-24">
|
|
||||||
<.input
|
|
||||||
field={@form[:postal_code]}
|
|
||||||
label={gettext("Postal Code")}
|
|
||||||
required={@member_field_required_map[:postal_code]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-48">
|
|
||||||
<.input field={@form[:city]} label={gettext("City")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Street and Nr. below --%>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div class="w-64">
|
|
||||||
<.input field={@form[:street]} label={gettext("Street")} />
|
|
||||||
</div>
|
|
||||||
<div class="w-24">
|
|
||||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Email --%>
|
|
||||||
<div class="w-64">
|
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Membership Dates Row --%>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div class="w-36">
|
|
||||||
<.input
|
|
||||||
field={@form[:join_date]}
|
|
||||||
label={gettext("Join Date")}
|
|
||||||
type="date"
|
|
||||||
required={@member_field_required_map[:join_date]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-36">
|
|
||||||
<.input
|
|
||||||
field={@form[:exit_date]}
|
|
||||||
label={gettext("Exit Date")}
|
|
||||||
type="date"
|
|
||||||
required={@member_field_required_map[:exit_date]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Notes --%>
|
|
||||||
<div>
|
|
||||||
<.input
|
|
||||||
field={@form[:notes]}
|
|
||||||
label={gettext("Notes")}
|
|
||||||
type="textarea"
|
|
||||||
required={@member_field_required_map[:notes]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</.form_section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Custom Fields Section --%>
|
<%!-- Contact Data Tab Content (same structure as member show) --%>
|
||||||
<%= if Enum.any?(@custom_fields) do %>
|
<div
|
||||||
<div>
|
id="member-tabpanel-contact"
|
||||||
<.form_section title={gettext("Custom Fields")}>
|
role="tabpanel"
|
||||||
<div class="grid grid-cols-2 gap-4">
|
aria-labelledby="member-tab-contact"
|
||||||
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
>
|
||||||
<%= for cf <- @sorted_custom_fields do %>
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
<%!-- Personal Data Section --%>
|
||||||
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
<div>
|
||||||
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
<.form_section title={gettext("Personal Data")}>
|
||||||
<.input
|
<div class="space-y-4">
|
||||||
field={value_form[:value]}
|
<%!-- Name Row --%>
|
||||||
label={cf.name}
|
<div class="flex gap-4">
|
||||||
type={custom_field_input_type(cf.value_type)}
|
<div class="w-48">
|
||||||
required={cf.required}
|
<.input
|
||||||
/>
|
field={@form[:first_name]}
|
||||||
</.inputs_for>
|
label={gettext("First Name")}
|
||||||
<input
|
required={@member_field_required_map[:first_name]}
|
||||||
type="hidden"
|
/>
|
||||||
name={f_cfv[:custom_field_id].name}
|
</div>
|
||||||
value={f_cfv[:custom_field_id].value}
|
<div class="w-48">
|
||||||
/>
|
<.input
|
||||||
</div>
|
field={@form[:last_name]}
|
||||||
|
label={gettext("Last Name")}
|
||||||
|
required={@member_field_required_map[:last_name]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="w-48">
|
||||||
|
<.input field={@form[:country]} label={gettext("Country")} />
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<.input
|
||||||
|
field={@form[:postal_code]}
|
||||||
|
label={gettext("Postal Code")}
|
||||||
|
required={@member_field_required_map[:postal_code]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-48">
|
||||||
|
<.input field={@form[:city]} label={gettext("City")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Street and Nr. below --%>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="w-64">
|
||||||
|
<.input field={@form[:street]} label={gettext("Street")} />
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Email --%>
|
||||||
|
<div class="w-64">
|
||||||
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Membership Dates Row --%>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="w-36">
|
||||||
|
<.input
|
||||||
|
field={@form[:join_date]}
|
||||||
|
label={gettext("Join Date")}
|
||||||
|
type="date"
|
||||||
|
required={@member_field_required_map[:join_date]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-36">
|
||||||
|
<.input
|
||||||
|
field={@form[:exit_date]}
|
||||||
|
label={gettext("Exit Date")}
|
||||||
|
type="date"
|
||||||
|
required={@member_field_required_map[:exit_date]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Notes --%>
|
||||||
|
<div>
|
||||||
|
<.input
|
||||||
|
field={@form[:notes]}
|
||||||
|
label={gettext("Notes")}
|
||||||
|
type="textarea"
|
||||||
|
required={@member_field_required_map[:notes]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</.form_section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Custom Fields Section --%>
|
||||||
|
<%= if Enum.any?(@custom_fields) do %>
|
||||||
|
<div>
|
||||||
|
<.form_section title={gettext("Custom Fields")}>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
||||||
|
<%= for cf <- @sorted_custom_fields do %>
|
||||||
|
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||||
|
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
||||||
|
<div class={
|
||||||
|
if cf.value_type == :boolean, do: "flex items-end", else: ""
|
||||||
|
}>
|
||||||
|
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||||
|
<.input
|
||||||
|
field={value_form[:value]}
|
||||||
|
label={cf.name}
|
||||||
|
type={custom_field_input_type(cf.value_type)}
|
||||||
|
required={cf.required}
|
||||||
|
/>
|
||||||
|
</.inputs_for>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={f_cfv[:custom_field_id].name}
|
||||||
|
value={f_cfv[:custom_field_id].value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</.inputs_for>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.inputs_for>
|
</div>
|
||||||
<% end %>
|
</.form_section>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Membership Fee Section --%>
|
||||||
|
<div class="max-w-xl">
|
||||||
|
<.form_section title={gettext("Membership Fee")}>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
name={@form[:membership_fee_type_id].name}
|
||||||
|
phx-change="validate"
|
||||||
|
value={@form[:membership_fee_type_id].value || ""}
|
||||||
|
>
|
||||||
|
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||||
|
<option value="">{gettext("Select a membership fee type")}</option>
|
||||||
|
<%= for fee_type <- @available_fee_types do %>
|
||||||
|
<option
|
||||||
|
value={fee_type.id}
|
||||||
|
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
||||||
|
>
|
||||||
|
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
||||||
|
fee_type.interval
|
||||||
|
)})
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||||
|
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||||
|
<p class="text-error text-sm mt-1">{msg}</p>
|
||||||
|
<% end %>
|
||||||
|
<%= if @interval_warning do %>
|
||||||
|
<div class="alert alert-warning mt-2">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
|
<span>{@interval_warning}</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<p class="text-sm text-base-content/60 mt-2">
|
||||||
|
{gettext(
|
||||||
|
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Membership Fee Section --%>
|
<%!-- Bottom Action Buttons --%>
|
||||||
<div class="max-w-xl">
|
<div class="flex justify-end gap-4 mt-6">
|
||||||
<.form_section title={gettext("Membership Fee")}>
|
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
||||||
<div class="space-y-4">
|
{gettext("Cancel")}
|
||||||
<div>
|
</.button>
|
||||||
<label class="label">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
{gettext("Save Member")}
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
class="select select-bordered w-full"
|
|
||||||
name={@form[:membership_fee_type_id].name}
|
|
||||||
phx-change="validate"
|
|
||||||
value={@form[:membership_fee_type_id].value || ""}
|
|
||||||
>
|
|
||||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
|
||||||
<option value="">{gettext("Select a membership fee type")}</option>
|
|
||||||
<%= for fee_type <- @available_fee_types do %>
|
|
||||||
<option
|
|
||||||
value={fee_type.id}
|
|
||||||
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
|
||||||
>
|
|
||||||
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
|
||||||
fee_type.interval
|
|
||||||
)})
|
|
||||||
</option>
|
|
||||||
<% end %>
|
|
||||||
</select>
|
|
||||||
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
|
||||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
|
||||||
<p class="text-error text-sm mt-1">{msg}</p>
|
|
||||||
<% end %>
|
|
||||||
<%= if @interval_warning do %>
|
|
||||||
<div class="alert alert-warning mt-2">
|
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
|
||||||
<span>{@interval_warning}</span>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<p class="text-sm text-base-content/60 mt-2">
|
|
||||||
{gettext(
|
|
||||||
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</.form_section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Bottom Action Buttons --%>
|
|
||||||
<div class="flex justify-end gap-4 mt-6">
|
|
||||||
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</.button>
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
|
||||||
{gettext("Save Member")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
|
||||||
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
|
||||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
|
||||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
|
||||||
{gettext("Danger zone")}
|
|
||||||
</h2>
|
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
|
||||||
<p class="text-base-content/70 mb-4">
|
|
||||||
{gettext(
|
|
||||||
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<.button
|
|
||||||
variant="danger"
|
|
||||||
type="button"
|
|
||||||
phx-click="delete"
|
|
||||||
phx-value-id={@member.id}
|
|
||||||
data-confirm={
|
|
||||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
data-testid="member-delete"
|
|
||||||
aria-label={
|
|
||||||
gettext("Delete member %{name}",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.icon name="hero-trash" class="size-4" />
|
|
||||||
{gettext("Delete member")}
|
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
<% end %>
|
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
||||||
|
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
||||||
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
|
{gettext("Danger zone")}
|
||||||
|
</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
id="delete-member-form-trigger"
|
||||||
|
variant="danger"
|
||||||
|
type="button"
|
||||||
|
phx-click="open_delete_modal"
|
||||||
|
data-testid="member-delete"
|
||||||
|
aria-label={
|
||||||
|
gettext("Delete member %{name}",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete member")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
|
<%= if @member && assigns[:show_delete_modal] do %>
|
||||||
|
<dialog
|
||||||
|
id="delete-member-form-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-member-form-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
|
||||||
|
{gettext("Delete Member")}
|
||||||
|
</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
{gettext(
|
||||||
|
"Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="cancel_delete_modal"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
id="delete-member-form-modal-cancel"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click={JS.push("delete", value: %{id: @member.id})}
|
||||||
|
aria-label={gettext("Delete member")}
|
||||||
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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 @@
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<%= for group <- (member.groups || []) do %>
|
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||||
<span
|
<%= for group <- (member.groups || []) do %>
|
||||||
class="badge badge-outline badge-primary"
|
<.badge
|
||||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
variant="primary"
|
||||||
>
|
style="outline"
|
||||||
{group.name}
|
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||||
</span>
|
>
|
||||||
<% end %>
|
{group.name}
|
||||||
<%= if (member.groups || []) == [] do %>
|
</.badge>
|
||||||
<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">
|
<div
|
||||||
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
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
|
<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,59 +220,103 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.list>
|
<div
|
||||||
<:item title={gettext("Name")}>{@role.name}</:item>
|
id="role-show-focus-root"
|
||||||
<:item title={gettext("Description")}>
|
phx-hook="FocusRestore"
|
||||||
<%= if @role.description do %>
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
{@role.description}
|
>
|
||||||
<% else %>
|
<.list>
|
||||||
<span class="text-base-content/70 italic">{gettext("No description")}</span>
|
<:item title={gettext("Name")}>{@role.name}</:item>
|
||||||
<% end %>
|
<:item title={gettext("Description")}>
|
||||||
</:item>
|
<%= if @role.description do %>
|
||||||
<:item title={gettext("Permission Set")}>
|
{@role.description}
|
||||||
<span class={permission_set_badge_class(@role.permission_set_name)}>
|
<% else %>
|
||||||
{@role.permission_set_name}
|
<span class="text-base-content/70 italic">{gettext("No description")}</span>
|
||||||
</span>
|
<% end %>
|
||||||
</:item>
|
</:item>
|
||||||
<:item title={gettext("System Role")}>
|
<:item title={gettext("Permission Set")}>
|
||||||
<%= if @role.is_system_role do %>
|
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
|
||||||
<span class="badge badge-warning">{gettext("Yes")}</span>
|
{@role.permission_set_name}
|
||||||
<% else %>
|
</.badge>
|
||||||
<span class="badge badge-ghost">{gettext("No")}</span>
|
</:item>
|
||||||
<% end %>
|
<:item title={gettext("System Role")}>
|
||||||
</:item>
|
<.badge :if={@role.is_system_role} variant="warning">
|
||||||
</.list>
|
{gettext("Yes")}
|
||||||
|
</.badge>
|
||||||
|
<.badge :if={!@role.is_system_role} variant="neutral">
|
||||||
|
{gettext("No")}
|
||||||
|
</.badge>
|
||||||
|
</:item>
|
||||||
|
</.list>
|
||||||
|
|
||||||
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
||||||
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
|
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
|
||||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
{gettext("Danger zone")}
|
{gettext("Danger zone")}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<p class="text-base-content/70 mb-4">
|
<p class="text-base-content/70 mb-4">
|
||||||
{gettext(
|
{gettext(
|
||||||
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
variant="danger"
|
id="delete-role-trigger"
|
||||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
variant="danger"
|
||||||
data-confirm={
|
phx-click="open_delete_modal"
|
||||||
gettext(
|
data-testid="role-delete"
|
||||||
|
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete role")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
|
||||||
|
<%= if assigns[:show_delete_modal] do %>
|
||||||
|
<dialog
|
||||||
|
id="delete-role-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-role-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
{gettext(
|
||||||
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
||||||
name: @role.name
|
name: @role.name
|
||||||
)
|
)}
|
||||||
}
|
</p>
|
||||||
data-testid="role-delete"
|
<div class="modal-action">
|
||||||
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
<.button
|
||||||
>
|
type="button"
|
||||||
<.icon name="hero-trash" class="size-4" />
|
variant="neutral"
|
||||||
{gettext("Delete role")}
|
phx-click="cancel_delete_modal"
|
||||||
</.button>
|
phx-mounted={JS.focus()}
|
||||||
</div>
|
id="delete-role-modal-cancel"
|
||||||
</section>
|
aria-label={gettext("Cancel")}
|
||||||
<% end %>
|
>
|
||||||
|
{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,280 +65,323 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
<div
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
id="user-form-focus-root"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
|
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||||
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
|
|
||||||
<%= if @user && @can_assign_role do %>
|
<%= if @user && @can_assign_role do %>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<.input
|
|
||||||
field={@form[:role_id]}
|
|
||||||
type="select"
|
|
||||||
label={gettext("Role")}
|
|
||||||
options={Enum.map(@roles, &{&1.name, &1.id})}
|
|
||||||
prompt={gettext("Select role...")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<!-- Password Section -->
|
|
||||||
<div class="mt-6">
|
|
||||||
<label class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="set_password"
|
|
||||||
phx-click="toggle_password_section"
|
|
||||||
checked={@show_password_fields}
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<%= if @show_password_fields do %>
|
|
||||||
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
|
||||||
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
|
|
||||||
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
|
|
||||||
<p class="text-sm font-semibold text-red-800">
|
|
||||||
{gettext("SSO / OIDC user")}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-sm text-red-700">
|
|
||||||
{gettext(
|
|
||||||
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<.input
|
<.input
|
||||||
field={@form[:password]}
|
field={@form[:role_id]}
|
||||||
label={gettext("Password")}
|
type="select"
|
||||||
type="password"
|
label={gettext("Role")}
|
||||||
required
|
options={Enum.map(@roles, &{&1.name, &1.id})}
|
||||||
autocomplete="new-password"
|
prompt={gettext("Select role...")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Only show password confirmation for new users (register_with_password) -->
|
|
||||||
<%= if !@user do %>
|
|
||||||
<.input
|
|
||||||
field={@form[:password_confirmation]}
|
|
||||||
label={gettext("Confirm Password")}
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
/>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
<p><strong>{gettext("Password requirements")}:</strong></p>
|
|
||||||
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
|
|
||||||
<li>{gettext("At least 8 characters")}</li>
|
|
||||||
<li>{gettext("Include both letters and numbers")}</li>
|
|
||||||
<li>{gettext("Consider using special characters")}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if @user && @can_manage_member_linking do %>
|
|
||||||
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
|
||||||
<p class="text-sm text-orange-800">
|
|
||||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
|
||||||
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
|
||||||
<%= if @user do %>
|
|
||||||
<div class="p-4 mt-4 rounded-lg bg-blue-50">
|
|
||||||
<p class="text-sm text-blue-800">
|
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
|
||||||
"Check 'Change Password' above to set a new password for this user."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
|
||||||
"User will be created without a password. Check 'Set Password' to add one."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
|
||||||
|
<!-- Password Section -->
|
||||||
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
|
|
||||||
<%= if @can_manage_member_linking do %>
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
<label class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="set_password"
|
||||||
|
phx-click="toggle_password_section"
|
||||||
|
checked={@show_password_fields}
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<%= if @user && @user.member && !@unlink_member do %>
|
<%= if @show_password_fields do %>
|
||||||
<!-- Show linked member with unlink button -->
|
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
||||||
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
|
||||||
<div class="flex items-center justify-between">
|
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
|
||||||
<div>
|
<p class="text-sm font-semibold text-red-800">
|
||||||
<p class="font-medium text-green-900">
|
{gettext("SSO / OIDC user")}
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
<p class="mt-1 text-sm text-red-700">
|
||||||
</div>
|
{gettext(
|
||||||
<.button
|
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
||||||
type="button"
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
phx-click="unlink_member"
|
|
||||||
>
|
|
||||||
{gettext("Unlink Member")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= if @unlink_member do %>
|
|
||||||
<!-- Show unlink pending message -->
|
|
||||||
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
|
||||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<!-- Show member search/selection for unlinked users -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="member-search-input"
|
|
||||||
role="combobox"
|
|
||||||
phx-hook="ComboBox"
|
|
||||||
phx-focus="show_member_dropdown"
|
|
||||||
phx-change="search_members"
|
|
||||||
phx-debounce="300"
|
|
||||||
phx-window-keydown="member_dropdown_keydown"
|
|
||||||
value={@member_search_query}
|
|
||||||
placeholder={gettext("Search for a member to link...")}
|
|
||||||
class="w-full input"
|
|
||||||
name="member_search"
|
|
||||||
disabled={@unlink_member}
|
|
||||||
aria-label={gettext("Search for member to link")}
|
|
||||||
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="member-dropdown"
|
|
||||||
aria-expanded={to_string(@show_member_dropdown)}
|
|
||||||
aria-activedescendant={
|
|
||||||
if @focused_member_index,
|
|
||||||
do: "member-option-#{@focused_member_index}",
|
|
||||||
else: nil
|
|
||||||
}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<%= if length(@available_members) > 0 do %>
|
|
||||||
<div
|
|
||||||
id="member-dropdown"
|
|
||||||
role="listbox"
|
|
||||||
aria-label={gettext("Available members")}
|
|
||||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
|
||||||
phx-click-away="hide_member_dropdown"
|
|
||||||
>
|
|
||||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
|
||||||
<div
|
|
||||||
id={"member-option-#{index}"}
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected={to_string(@focused_member_index == index)}
|
|
||||||
phx-click="select_member"
|
|
||||||
phx-value-id={member.id}
|
|
||||||
data-member-id={member.id}
|
|
||||||
class={[
|
|
||||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
|
||||||
if(@focused_member_index == index,
|
|
||||||
do: "bg-base-300",
|
|
||||||
else: "hover:bg-base-200"
|
|
||||||
)
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<p class="font-medium">
|
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
|
||||||
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
|
||||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<.input
|
||||||
|
field={@form[:password]}
|
||||||
|
label={gettext("Password")}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Only show password confirmation for new users (register_with_password) -->
|
||||||
|
<%= if !@user do %>
|
||||||
|
<.input
|
||||||
|
field={@form[:password_confirmation]}
|
||||||
|
label={gettext("Confirm Password")}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= if @selected_member_id && @selected_member_name do %>
|
<div class="text-sm text-gray-600">
|
||||||
<div
|
<p><strong>{gettext("Password requirements")}:</strong></p>
|
||||||
id="member-selected"
|
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
|
||||||
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
<li>{gettext("At least 8 characters")}</li>
|
||||||
>
|
<li>{gettext("Include both letters and numbers")}</li>
|
||||||
<p class="text-sm text-blue-800">
|
<li>{gettext("Consider using special characters")}</li>
|
||||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
</ul>
|
||||||
</p>
|
</div>
|
||||||
<p class="mt-1 text-xs text-blue-600">
|
|
||||||
{gettext("Save to confirm linking.")}
|
<%= if @user && @can_manage_member_linking do %>
|
||||||
|
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
||||||
|
<p class="text-sm text-orange-800">
|
||||||
|
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
||||||
|
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%= if @user do %>
|
||||||
|
<div class="p-4 mt-4 rounded-lg bg-blue-50">
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
|
"Check 'Change Password' above to set a new password for this user."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
|
"User will be created without a password. Check 'Set Password' to add one."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
|
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
|
||||||
|
<%= if @can_manage_member_linking do %>
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
||||||
|
|
||||||
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
<%= if @user && @user.member && !@unlink_member do %>
|
||||||
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
|
<!-- Show linked member with unlink button -->
|
||||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
||||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
<div class="flex items-center justify-between">
|
||||||
{gettext("Danger zone")}
|
<div>
|
||||||
</h2>
|
<p class="font-medium text-green-900">
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||||
<p class="text-base-content/70 mb-4">
|
</p>
|
||||||
{gettext(
|
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||||
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
</div>
|
||||||
)}
|
<.button
|
||||||
</p>
|
type="button"
|
||||||
<.button
|
variant="danger"
|
||||||
type="button"
|
size="sm"
|
||||||
variant="danger"
|
phx-click="unlink_member"
|
||||||
phx-click="delete"
|
>
|
||||||
phx-value-id={@user.id}
|
{gettext("Unlink Member")}
|
||||||
data-confirm={
|
</.button>
|
||||||
gettext(
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%= if @unlink_member do %>
|
||||||
|
<!-- Show unlink pending message -->
|
||||||
|
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||||
|
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<!-- Show member search/selection for unlinked users -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="member-search-input"
|
||||||
|
role="combobox"
|
||||||
|
phx-hook="ComboBox"
|
||||||
|
phx-focus="show_member_dropdown"
|
||||||
|
phx-change="search_members"
|
||||||
|
phx-debounce="300"
|
||||||
|
phx-window-keydown="member_dropdown_keydown"
|
||||||
|
value={@member_search_query}
|
||||||
|
placeholder={gettext("Search for a member to link...")}
|
||||||
|
class="w-full input"
|
||||||
|
name="member_search"
|
||||||
|
disabled={@unlink_member}
|
||||||
|
aria-label={gettext("Search for member to link")}
|
||||||
|
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="member-dropdown"
|
||||||
|
aria-expanded={to_string(@show_member_dropdown)}
|
||||||
|
aria-activedescendant={
|
||||||
|
if @focused_member_index,
|
||||||
|
do: "member-option-#{@focused_member_index}",
|
||||||
|
else: nil
|
||||||
|
}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<%= if length(@available_members) > 0 do %>
|
||||||
|
<div
|
||||||
|
id="member-dropdown"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={gettext("Available members")}
|
||||||
|
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||||
|
phx-click-away="hide_member_dropdown"
|
||||||
|
>
|
||||||
|
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||||
|
<div
|
||||||
|
id={"member-option-#{index}"}
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={to_string(@focused_member_index == index)}
|
||||||
|
phx-click="select_member"
|
||||||
|
phx-value-id={member.id}
|
||||||
|
data-member-id={member.id}
|
||||||
|
class={[
|
||||||
|
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||||
|
if(@focused_member_index == index,
|
||||||
|
do: "bg-base-300",
|
||||||
|
else: "hover:bg-base-200"
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||||
|
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
|
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @selected_member_id && @selected_member_name do %>
|
||||||
|
<div
|
||||||
|
id="member-selected"
|
||||||
|
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-blue-600">
|
||||||
|
{gettext("Save to confirm linking.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
||||||
|
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
|
||||||
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
|
{gettext("Danger zone")}
|
||||||
|
</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
id="delete-user-form-trigger"
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click="open_delete_modal"
|
||||||
|
data-testid="user-delete"
|
||||||
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete user")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
|
<%= if @user && assigns[:show_delete_modal] do %>
|
||||||
|
<dialog
|
||||||
|
id="delete-user-form-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-user-form-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
||||||
|
{gettext("Delete User")}
|
||||||
|
</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
{gettext(
|
||||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||||
email: @user.email
|
email: @user.email
|
||||||
)
|
)}
|
||||||
}
|
</p>
|
||||||
data-testid="user-delete"
|
<div class="modal-action">
|
||||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
<.button
|
||||||
>
|
type="button"
|
||||||
<.icon name="hero-trash" class="size-4" />
|
variant="neutral"
|
||||||
{gettext("Delete user")}
|
phx-click="cancel_delete_modal"
|
||||||
</.button>
|
phx-mounted={JS.focus()}
|
||||||
</div>
|
id="delete-user-form-modal-cancel"
|
||||||
</section>
|
aria-label={gettext("Cancel")}
|
||||||
<% end %>
|
>
|
||||||
|
{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")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save User")}
|
{gettext("Save User")}
|
||||||
</.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,65 +58,106 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.list>
|
<div
|
||||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
id="user-show-focus-root"
|
||||||
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
phx-hook="FocusRestore"
|
||||||
<:item title={gettext("Password Authentication")}>
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
{if MvWeb.Helpers.UserHelpers.has_password?(@user),
|
>
|
||||||
do: gettext("Enabled"),
|
<.list>
|
||||||
else: gettext("Not enabled")}
|
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||||
</:item>
|
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
||||||
<:item title={gettext("OIDC")}>
|
<:item title={gettext("Password Authentication")}>
|
||||||
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
|
{if MvWeb.Helpers.UserHelpers.has_password?(@user),
|
||||||
do: gettext("Linked"),
|
do: gettext("Enabled"),
|
||||||
else: gettext("Not linked")}
|
else: gettext("Not enabled")}
|
||||||
</:item>
|
</:item>
|
||||||
<:item title={gettext("Linked Member")}>
|
<:item title={gettext("OIDC")}>
|
||||||
<%= if @user.member do %>
|
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
|
||||||
<.link
|
do: gettext("Linked"),
|
||||||
navigate={~p"/members/#{@user.member}"}
|
else: gettext("Not linked")}
|
||||||
class="text-blue-600 underline hover:text-blue-800"
|
</:item>
|
||||||
>
|
<:item title={gettext("Linked Member")}>
|
||||||
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
|
<%= if @user.member do %>
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
<.link
|
||||||
</.link>
|
navigate={~p"/members/#{@user.member}"}
|
||||||
<% else %>
|
class="text-blue-600 underline hover:text-blue-800"
|
||||||
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
>
|
||||||
<% end %>
|
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
|
||||||
</:item>
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||||
</.list>
|
</.link>
|
||||||
|
<% else %>
|
||||||
|
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
||||||
|
<% end %>
|
||||||
|
</:item>
|
||||||
|
</.list>
|
||||||
|
|
||||||
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
||||||
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
|
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
|
||||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
{gettext("Danger zone")}
|
{gettext("Danger zone")}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<p class="text-base-content/70 mb-4">
|
<p class="text-base-content/70 mb-4">
|
||||||
{gettext(
|
{gettext(
|
||||||
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
variant="danger"
|
id="delete-user-trigger"
|
||||||
phx-click="delete"
|
variant="danger"
|
||||||
phx-value-id={@user.id}
|
phx-click="open_delete_modal"
|
||||||
data-confirm={
|
data-testid="user-delete"
|
||||||
gettext(
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete user")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
|
<%= if assigns[:show_delete_modal] do %>
|
||||||
|
<dialog
|
||||||
|
id="delete-user-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-user-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
{gettext(
|
||||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||||
email: @user.email
|
email: @user.email
|
||||||
)
|
)}
|
||||||
}
|
</p>
|
||||||
data-testid="user-delete"
|
<div class="modal-action">
|
||||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
<.button
|
||||||
>
|
type="button"
|
||||||
<.icon name="hero-trash" class="size-4" />
|
variant="neutral"
|
||||||
{gettext("Delete user")}
|
phx-click="cancel_delete_modal"
|
||||||
</.button>
|
phx-mounted={JS.focus()}
|
||||||
</div>
|
id="delete-user-modal-cancel"
|
||||||
</section>
|
aria-label={gettext("Cancel")}
|
||||||
<% end %>
|
>
|
||||||
|
{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