Compare commits
22 commits
docs/341_u
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 70d574813c | |||
|
|
30b61718a7 | ||
| a37c2f5d13 | |||
|
|
844f5a18d1 | ||
| f3be6ee198 | |||
| 3187d408c5 | |||
| 8fac974b1b | |||
| 7f15909cc6 | |||
| e0484a0533 | |||
| c71c7d6ed6 | |||
| 5516c7fe62 | |||
| 4ac56958b4 | |||
| 9751525a0c | |||
| faf80bfb4b | |||
| 88831685fc | |||
| 2c49018ab7 | |||
| e422e5f4ef | |||
| 2922a4d1ee | |||
| 615b4b866b | |||
| cde6a68591 | |||
| 73382c2c3f | |||
| d0b8cb672a |
50 changed files with 2562 additions and 1239 deletions
|
|
@ -273,7 +273,7 @@ environment:
|
|||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:42.97
|
||||
image: renovate/renovate:42.99
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
|
|
|
|||
|
|
@ -2775,6 +2775,14 @@ Building accessible applications ensures that all users, including those with di
|
|||
<div phx-click="action">Click me</div>
|
||||
```
|
||||
|
||||
**Tables (Core Component `<.table>` with `row_click`):**
|
||||
|
||||
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
|
||||
|
||||
**Empty table cells (missing values):**
|
||||
|
||||
- Do not use dashes ("-", "—", "–") or "n/a" as placeholders. Use CoreComponents `<.empty_cell sr_text="…">` for a cell with no value, or `<.maybe_value value={…} empty_sr_text="…">` when content is conditional. The cell is visually empty; screen readers get the `sr_text` (e.g. "No cycle", "No group assignment", "Not specified"). See Design Guidelines §8.6.
|
||||
|
||||
**Tab Order:**
|
||||
|
||||
- Ensure logical tab order matches visual order
|
||||
|
|
@ -2784,7 +2792,11 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
### 8.4 Color and Contrast
|
||||
|
||||
**Ensure Sufficient Contrast:**
|
||||
**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):**
|
||||
|
||||
- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button.
|
||||
- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient.
|
||||
- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states.
|
||||
|
||||
```elixir
|
||||
# Tailwind classes with sufficient contrast (4.5:1 minimum)
|
||||
|
|
@ -3003,24 +3015,56 @@ end
|
|||
- [ ] Skip links are available
|
||||
- [ ] Tables have proper structure (th, scope, caption)
|
||||
- [ ] ARIA labels used for icon-only buttons
|
||||
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
|
||||
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
|
||||
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.
|
||||
|
||||
### 8.11 DaisyUI Accessibility
|
||||
### 8.11 Modals and Dialogs
|
||||
|
||||
DaisyUI components are designed with accessibility in mind, but ensure:
|
||||
Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `<dialog>` so focus and semantics are correct (WCAG 2.4.3, 2.1.2).
|
||||
|
||||
**Structure and semantics:**
|
||||
|
||||
- Use `<dialog>` with DaisyUI classes `modal modal-open` when the modal is visible.
|
||||
- Add `role="dialog"` and `aria-labelledby` pointing to the modal title’s `id` so screen readers announce the dialog and its purpose.
|
||||
- Give the title (e.g. `<h3>`) a unique `id` (e.g. `id="delete-role-modal-title"`).
|
||||
|
||||
**Focus management (WCAG 2.4.3):**
|
||||
|
||||
- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element:
|
||||
- If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group).
|
||||
- If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard.
|
||||
- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse.
|
||||
|
||||
**Layout and consistency:**
|
||||
|
||||
- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action).
|
||||
- Place Cancel (or neutral) first, primary/danger action second.
|
||||
- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons.
|
||||
|
||||
**Closing:**
|
||||
|
||||
- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`).
|
||||
- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
|
||||
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).
|
||||
|
||||
**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button).
|
||||
|
||||
### 8.12 DaisyUI Accessibility
|
||||
|
||||
DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure:
|
||||
|
||||
```heex
|
||||
<!-- Modal accessibility -->
|
||||
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
|
||||
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
|
||||
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
|
||||
<div class="modal-box">
|
||||
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
|
||||
<h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3>
|
||||
<p><%= gettext("Are you sure?") %></p>
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick="document.getElementById('my-modal').close()">
|
||||
<.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}>
|
||||
<%= gettext("Cancel") %>
|
||||
</button>
|
||||
<button class="btn btn-error" phx-click="confirm-delete">
|
||||
<%= gettext("Delete") %>
|
||||
</button>
|
||||
</.button>
|
||||
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
|||
|
|
@ -293,6 +293,12 @@ Notes:
|
|||
- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space.
|
||||
- When the table is inside such a scroll container, use the CoreComponents table’s `sticky_header={true}` so the table’s `<thead>` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table.
|
||||
|
||||
### 8.6 Empty table cells (missing values)
|
||||
- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a").
|
||||
- **MUST NOT:** Use dashes ("-", "—", "–") or "n/a" as placeholders for empty cells.
|
||||
- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent).
|
||||
- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified".
|
||||
|
||||
---
|
||||
|
||||
## 9) Flash / Toast messages (mandatory UX)
|
||||
|
|
@ -331,14 +337,17 @@ No “silent success”.
|
|||
|
||||
### 10.2 Destructive actions: one standard confirmation pattern
|
||||
- **MUST:** All destructive actions use the same confirm style and wording conventions.
|
||||
- Choose one approach and standardize:
|
||||
- `JS.confirm("…")` everywhere (simple, consistent)
|
||||
- or a modal component everywhere (more flexible, more work)
|
||||
- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible.
|
||||
|
||||
**Recommended copy style:**
|
||||
- Title/confirm text is clear and specific (what will be deleted, consequences).
|
||||
- Buttons: `Cancel` (neutral) + `Delete` (danger).
|
||||
|
||||
### 10.3 Dialogs and modals (mandatory)
|
||||
- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `<dialog>` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element).
|
||||
- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse.
|
||||
- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout).
|
||||
|
||||
---
|
||||
|
||||
## 11) Detail pages (consistent structure)
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ Most membership tools for clubs are either:
|
|||
|
||||
Our philosophy: **software should help people spend less time on administration and more time on their community.**
|
||||
|
||||
## User Documentation (German)
|
||||
|
||||
You can find our documentation for users here: https://wiki.local-it.org/s/mila-user-dokumentation
|
||||
## 📸 Screenshots
|
||||
|
||||

|
||||
*This is how Mila might look in action.*
|
||||
|
||||
## 🔑 Features
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,138 @@
|
|||
color: oklch(0.45 0.2 25);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
|
||||
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
|
||||
outline badges always have a visible background in both themes. */
|
||||
[data-theme="light"] .badge.badge-outline,
|
||||
[data-theme="dark"] .badge.badge-outline {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
|
||||
Theme tokens *-content are often too light on * backgrounds in light theme, and
|
||||
badge-soft uses variant as text on a light tint (low contrast). We override
|
||||
--badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */
|
||||
|
||||
/* Light theme: use dark text on all colored badges (solid, soft, outline). */
|
||||
[data-theme="light"] .badge.badge-primary {
|
||||
--badge-fg: oklch(0.25 0.08 47);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-primary.badge-soft {
|
||||
color: oklch(0.38 0.14 47);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-success {
|
||||
--badge-fg: oklch(0.26 0.06 165);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-success.badge-soft {
|
||||
color: oklch(0.35 0.10 165);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-error {
|
||||
--badge-fg: oklch(0.22 0.08 25);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-error.badge-soft {
|
||||
color: oklch(0.38 0.14 25);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-warning {
|
||||
--badge-fg: oklch(0.28 0.06 75);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-warning.badge-soft {
|
||||
color: oklch(0.42 0.12 75);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-info {
|
||||
--badge-fg: oklch(0.26 0.08 250);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-info.badge-soft {
|
||||
color: oklch(0.38 0.12 250);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-neutral {
|
||||
--badge-fg: oklch(0.22 0.01 285);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-neutral.badge-soft {
|
||||
color: oklch(0.32 0.02 285);
|
||||
}
|
||||
[data-theme="light"] .badge.badge-outline.badge-primary,
|
||||
[data-theme="light"] .badge.badge-outline.badge-success,
|
||||
[data-theme="light"] .badge.badge-outline.badge-error,
|
||||
[data-theme="light"] .badge.badge-outline.badge-warning,
|
||||
[data-theme="light"] .badge.badge-outline.badge-info,
|
||||
[data-theme="light"] .badge.badge-outline.badge-neutral {
|
||||
--badge-fg: oklch(0.25 0.02 285);
|
||||
}
|
||||
|
||||
/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1).
|
||||
Slightly darken solid variant backgrounds so theme *-content (light) passes. */
|
||||
[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.20 277);
|
||||
--badge-fg: oklch(0.97 0.02 277);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.10 185);
|
||||
--badge-fg: oklch(0.97 0.01 185);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.42 0.18 18);
|
||||
--badge-fg: oklch(0.97 0.02 18);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.48 0.14 58);
|
||||
--badge-fg: oklch(0.22 0.02 58);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.45 0.14 242);
|
||||
--badge-fg: oklch(0.97 0.02 242);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) {
|
||||
--badge-bg: oklch(0.32 0.02 257);
|
||||
--badge-fg: oklch(0.96 0.01 257);
|
||||
}
|
||||
[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); }
|
||||
[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); }
|
||||
[data-theme="dark"] .badge.badge-outline.badge-primary,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-success,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-error,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-warning,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-info,
|
||||
[data-theme="dark"] .badge.badge-outline.badge-neutral {
|
||||
--badge-fg: oklch(0.92 0.02 257);
|
||||
}
|
||||
|
||||
/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean).
|
||||
Inactive state uses base-content on a light/dark surface; active state ensures
|
||||
*-content on * background meets 4.5:1. */
|
||||
.member-filter-dropdown .join .btn {
|
||||
/* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */
|
||||
border-color: var(--color-base-300);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) {
|
||||
color: oklch(0.25 0.02 285);
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active {
|
||||
background-color: oklch(0.42 0.12 165);
|
||||
color: oklch(0.98 0.01 165);
|
||||
}
|
||||
[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active {
|
||||
background-color: oklch(0.42 0.18 18);
|
||||
color: oklch(0.98 0.02 18);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) {
|
||||
color: oklch(0.92 0.02 257);
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active {
|
||||
background-color: oklch(0.42 0.10 165);
|
||||
color: oklch(0.97 0.01 165);
|
||||
}
|
||||
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active {
|
||||
background-color: oklch(0.42 0.18 18);
|
||||
color: oklch(0.97 0.02 18);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Sidebar Base Styles
|
||||
============================================ */
|
||||
|
|
@ -389,4 +521,31 @@
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WCAG 1.4.3: Primary button contrast (AA)
|
||||
============================================ */
|
||||
|
||||
/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand)
|
||||
meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary
|
||||
and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */
|
||||
|
||||
/* Light theme: primary is orange (brand); primary-content must be dark. */
|
||||
[data-theme="light"] {
|
||||
--color-primary-content: oklch(0.18 0.02 47);
|
||||
--color-error: oklch(55% 0.253 17.585);
|
||||
--color-error-content: oklch(98% 0 0);
|
||||
}
|
||||
|
||||
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
|
||||
[data-theme="dark"] {
|
||||
--color-error: oklch(55% 0.253 17.585);
|
||||
--color-error-content: oklch(98% 0 0);
|
||||
|
||||
--color-primary: oklch(72% 0.17 45);
|
||||
--color-primary-content: oklch(0.18 0.02 47);
|
||||
|
||||
--color-secondary: oklch(48% 0.233 277.117);
|
||||
--color-secondary-content: oklch(98% 0 0);
|
||||
}
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
|
|
|
|||
|
|
@ -73,6 +73,53 @@ Hooks.ComboBox = {
|
|||
}
|
||||
}
|
||||
|
||||
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
|
||||
// Enter and Space trigger a click so row_click tables are keyboard activatable
|
||||
Hooks.TableRowKeydown = {
|
||||
mounted() {
|
||||
this.handleKeydown = (e) => {
|
||||
if (
|
||||
e.target.getAttribute("data-row-clickable") === "true" &&
|
||||
(e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.target.click()
|
||||
}
|
||||
}
|
||||
this.el.addEventListener("keydown", this.handleKeydown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
|
||||
Hooks.FocusRestore = {
|
||||
mounted() {
|
||||
this.handleEvent("focus_restore", ({id}) => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) el.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
|
||||
Hooks.TabListKeydown = {
|
||||
mounted() {
|
||||
this.handleKeydown = (e) => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
this.el.addEventListener('keydown', this.handleKeydown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener('keydown', this.handleKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||
Hooks.SidebarState = {
|
||||
mounted() {
|
||||
|
|
|
|||
88
docs/badge-wcag-phase1-analysis.md
Normal file
88
docs/badge-wcag-phase1-analysis.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Phase 1 — Badge WCAG Analysis & Migration
|
||||
|
||||
## 1) Repo-Analyse (Stand vor Änderungen)
|
||||
|
||||
### Badge-Verwendungen (alle Fundstellen)
|
||||
|
||||
| Datei | Kontext | Markup |
|
||||
|-------|---------|--------|
|
||||
| `lib/mv_web/live/member_field_live/index_component.ex` | Tabelle (show_in_overview) | `<span class="badge badge-success">` / `<span class="badge badge-ghost">` |
|
||||
| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `<span class="badge badge-primary badge-sm">` (2×) |
|
||||
| `lib/mv_web/live/role_live/index.html.heex` | Tabelle (System Role, Permission Set, Custom) | `badge-warning`, `permission_set_badge_class()`, `badge-ghost` (User Count) |
|
||||
| `lib/mv_web/helpers/membership_fee_helpers.ex` | Helper | `status_color/1` → "badge-success" \| "badge-error" \| "badge-ghost" |
|
||||
| `lib/mv_web/live/member_live/show.ex` | Mitgliedsdetail (Beiträge) | `<span class={["badge", status_color(status)]}>`, `badge-ghost` (No cycles) |
|
||||
| `lib/mv_web/live/membership_fee_settings_live.ex` | Settings (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
|
||||
| `lib/mv_web/live/membership_fee_type_live/index.ex` | Index (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
|
||||
| `lib/mv_web/live/role_live/index.ex` | (Helper-Import) | `permission_set_badge_class/1` |
|
||||
| `lib/mv_web/live/member_live/show/membership_fees_component.ex` | Mitgliedsbeiträge | `badge-outline`, `["badge", status_color]` |
|
||||
| `lib/mv_web/live/custom_field_live/index_component.ex` | Tabelle (show_in_overview) | `badge-success`, `badge-ghost` |
|
||||
| `lib/mv_web/member_live/index/membership_fee_status.ex` | Helper | `format_cycle_status_badge/1` → map mit `color`, `icon`, `label` |
|
||||
| `lib/mv_web/live/global_settings_live.ex` | Form (label-text-alt) | `badge badge-ghost` "(set)" (2×) |
|
||||
| `lib/mv_web/live/member_live/index.html.heex` | Tabelle (Status) | `format_cycle_status_badge` + `<span class={["badge", badge.color]}>`, `badge-ghost` (No cycle), `badge-outline badge-primary` (Filter-Chip) |
|
||||
| `lib/mv_web/live/role_live/helpers.ex` | Helper | `permission_set_badge_class/1` → "badge badge-* badge-sm" |
|
||||
| `lib/mv_web/live/group_live/show.ex` | Card | `badge badge-outline badge` |
|
||||
| `lib/mv_web/live/role_live/show.ex` | Detail | `permission_set_badge_class`, `badge-warning` (System), `badge-ghost` (No) |
|
||||
|
||||
### DaisyUI/Tailwind Config
|
||||
|
||||
- **Tailwind:** `assets/tailwind.config.js` — erweitert nur `theme.extend.colors.brand`; kein DaisyUI hier.
|
||||
- **DaisyUI:** wird in `assets/css/app.css` per `@plugin "../vendor/daisyui"` mit `themes: false` geladen.
|
||||
- **Themes:** Zwei Custom-Themes in `app.css`:
|
||||
- `@plugin "../vendor/daisyui-theme"` mit `name: "dark"` (default: false)
|
||||
- `@plugin "../vendor/daisyui-theme"` mit `name: "light"` (default: true)
|
||||
- **Theme-Umschaltung:** `lib/mv_web/components/layouts/root.html.heex` — Inline-Script setzt `document.documentElement.setAttribute("data-theme", "light"|"dark")` aus `localStorage["phx:theme"]` oder `prefers-color-scheme`. Sidebar enthält Theme-Toggle (`<.theme_toggle />`).
|
||||
|
||||
### Core Components
|
||||
|
||||
- **Modul:** `lib/mv_web/components/core_components.ex` (MvWeb.CoreComponents).
|
||||
- **Vorhanden:** flash, button, dropdown_menu, form_section, input, header, table, icon, link, etc.
|
||||
- **Badge:** Bisher keine zentrale `<.badge>`-Komponente.
|
||||
|
||||
### DaisyUI Badge (Vendor)
|
||||
|
||||
- **Default:** `--badge-bg: var(--badge-color, var(--color-base-100))`, `--badge-fg: var(--color-base-content)`.
|
||||
- **badge-outline:** `--badge-bg: "#0000"` (transparent) → Kontrastproblem auf base-200/base-300.
|
||||
- **badge-ghost:** `background-color: var(--color-base-200)`, `color: var(--color-base-content)` → auf base-200-Flächen kaum sichtbar.
|
||||
- **badge-soft:** color-mix 8% Variante mit base-100 → sichtbar; Text ist Variantenfarbe (Kontrast prüfen).
|
||||
|
||||
---
|
||||
|
||||
## 2) Core Component <.badge> API (geplant)
|
||||
|
||||
- **attr :variant** — `:neutral | :primary | :info | :success | :warning | :error`
|
||||
- **attr :style** — `:soft | :solid | :outline` (Default: `:soft`)
|
||||
- **attr :size** — `:sm | :md` (Default: `:md`)
|
||||
- **slot :inner_block** — Badge-Text
|
||||
- **attr :sr_label** — optional, für Icon-only (Screen Reader)
|
||||
- **slot :icon** — optional
|
||||
|
||||
Regeln:
|
||||
|
||||
- `:soft` und `:solid` nutzen sichtbaren Hintergrund (kein transparenter Ghost als Default).
|
||||
- `:outline` setzt immer einen Hintergrund (z. B. `bg-base-100`), damit der Rand auf grauen Flächen sichtbar bleibt.
|
||||
- Ghost nur als explizites Opt-in; dann mit `bg-base-100` für Sichtbarkeit.
|
||||
|
||||
---
|
||||
|
||||
## 3) Theme-Overrides (WCAG)
|
||||
|
||||
- In `app.css` sind bereits Custom-Themes für `light` und `dark` mit eigenen Tokens.
|
||||
- **Badge-Kontrast (WCAG 2.2 AA 4.5:1):** Zusätzliche Overrides in `app.css`:
|
||||
- **Light theme:** Dunkle `--badge-fg` für alle Varianten (primary, success, error, warning, info, neutral); für `badge-soft` dunklere Textfarbe (`color`) auf getöntem Hintergrund; für `badge-outline` einheitlich dunkle Schrift auf base-100.
|
||||
- **Dark theme:** Leicht abgedunkelte Badge-Hintergründe für Solid-Badges, damit die hellen *-content-Farben 4.5:1 erreichen; für `badge-soft` hellere, gut lesbare Variantentöne; für `badge-outline` heller Text (`--badge-fg`) auf base-100.
|
||||
|
||||
---
|
||||
|
||||
## 4) Migration (erledigt)
|
||||
|
||||
- Alle `<span class="badge ...">` durch `<.badge variant="..." style="...">...</.badge>` ersetzt.
|
||||
- Klickbare Chips (z. B. Group Show „Remove“) bleiben als <.badge> mit Button im inner_block (Badge ist nur Container).
|
||||
- **Neue Helper:** `MembershipFeeHelpers.status_variant/1` (→ :success | :error | :warning; suspended = :warning wie Edit-Button), `RoleLive.Helpers.permission_set_badge_variant/1` (→ :neutral | :info | :success | :error).
|
||||
- **Angepasst:** `MembershipFeeStatus.format_cycle_status_badge/1` liefert zusätzlich `:variant` für <.badge>.
|
||||
- **Migrierte Stellen:** member_field_live, member_filter_component, role_live (index + show), member_live (show, index, membership_fees_component), membership_fee_settings_live, membership_fee_type_live, custom_field_live, global_settings_live, group_live/show.
|
||||
|
||||
## 5) Weitere Anpassungen (nach Phase 1)
|
||||
|
||||
- **Filter Join-Buttons (WCAG):** In `app.css` Kontrast-Overrides für `.member-filter-dropdown .join .btn` (inaktiv: base-100/base-200 + dunkle/helle Schrift; aktiv: success/error mit 4.5:1).
|
||||
- **Badge „Pausiert“ (suspended):** `status_variant(:suspended)` → `:warning` (gelb), damit Badge dieselbe Farbe wie der Edit-Button (btn-warning) hat.
|
||||
- **Filter-Dropdown schließen:** `phx-click-away` vom inneren Panel auf den äußeren Wrapper (`member-filter-dropdown`) verschoben; Klick auf den Filter-Button schließt das Dropdown (konsistent mit Spalten/Ausblenden).
|
||||
|
|
@ -360,12 +360,28 @@ defmodule Mv.Config do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC client secret. ENV first, then Settings.
|
||||
Returns the OIDC client secret.
|
||||
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
|
||||
Otherwise ENV OIDC_CLIENT_SECRET, then Settings.
|
||||
"""
|
||||
@spec oidc_client_secret() :: String.t() | nil
|
||||
def oidc_client_secret do
|
||||
env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
case Application.get_env(:mv, :oidc) do
|
||||
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
|
||||
_ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
end
|
||||
end
|
||||
|
||||
defp oidc_client_secret_from_config(nil),
|
||||
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
|
||||
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
|
||||
s = String.trim(secret)
|
||||
if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
end
|
||||
|
||||
defp oidc_client_secret_from_config(_),
|
||||
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
|
||||
@doc """
|
||||
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
|
||||
|
|
@ -426,7 +442,10 @@ defmodule Mv.Config do
|
|||
def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID")
|
||||
def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL")
|
||||
def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI")
|
||||
def oidc_client_secret_env_set?, do: env_set?("OIDC_CLIENT_SECRET")
|
||||
|
||||
def oidc_client_secret_env_set?,
|
||||
do: env_set?("OIDC_CLIENT_SECRET") or env_set?("OIDC_CLIENT_SECRET_FILE")
|
||||
|
||||
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
|
||||
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
|
||||
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
|
||||
|
|
|
|||
|
|
@ -31,6 +31,21 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
|
||||
@button_focus_classes [
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-offset-2",
|
||||
"focus-visible:ring-offset-base-100",
|
||||
"focus-visible:ring-base-content/60"
|
||||
]
|
||||
|
||||
@doc """
|
||||
Returns the shared focus ring class list for buttons and dropdown items (WCAG 2.4.7).
|
||||
Use when building custom dropdown item buttons so they match <.button> and dropdown trigger.
|
||||
"""
|
||||
def button_focus_classes, do: @button_focus_classes
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
|
|
@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do
|
|||
size_class = size_classes[size]
|
||||
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
||||
|
||||
assigns = assign(assigns, :btn_class, btn_class)
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:btn_class, btn_class)
|
||||
|> assign(:button_focus_classes, @button_focus_classes)
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
link_class =
|
||||
if assigns[:disabled],
|
||||
do: ["btn", btn_class, "btn-disabled"],
|
||||
else: ["btn", btn_class]
|
||||
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
|
||||
else: ["btn", btn_class] ++ @button_focus_classes
|
||||
|
||||
link_attrs =
|
||||
if assigns[:disabled] do
|
||||
|
|
@ -176,13 +194,187 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
|
||||
<button
|
||||
class={["btn", @btn_class] ++ @button_focus_classes}
|
||||
disabled={@disabled}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a non-interactive badge with WCAG-compliant contrast.
|
||||
|
||||
Use for status labels, counts, or tags. For clickable elements (e.g. filter chips),
|
||||
use a button or link component instead, not this badge.
|
||||
|
||||
## Variants and styles
|
||||
|
||||
- **variant:** `:neutral`, `:primary`, `:info`, `:success`, `:warning`, `:error`
|
||||
- **style:** `:soft` (default, tinted background), `:solid`, `:outline`
|
||||
- **size:** `:sm`, `:md` (default)
|
||||
|
||||
Outline and soft styles always use a visible background so the badge remains
|
||||
readable on base-200/base-300 surfaces (WCAG 2.2 AA). Ghost style is not exposed
|
||||
by default to avoid low-contrast on gray backgrounds.
|
||||
|
||||
## Examples
|
||||
|
||||
<.badge variant="success">Paid</.badge>
|
||||
<.badge variant="error" style="solid">Unpaid</.badge>
|
||||
<.badge variant="neutral" size="sm">Custom</.badge>
|
||||
<.badge variant="primary" style="outline">Label</.badge>
|
||||
<.badge variant="success" sr_label="Paid">
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
</.badge>
|
||||
"""
|
||||
attr :variant, :any,
|
||||
default: "neutral",
|
||||
doc: "Color variant: neutral | primary | info | success | warning | error (string or atom)"
|
||||
|
||||
attr :style, :any,
|
||||
default: "soft",
|
||||
doc: "Visual style: soft | solid | outline; :outline gets bg-base-100 for contrast"
|
||||
|
||||
attr :size, :any,
|
||||
default: "md",
|
||||
doc: "Badge size: sm | md"
|
||||
|
||||
attr :sr_label, :string,
|
||||
default: nil,
|
||||
doc: "Optional screen-reader label for icon-only content"
|
||||
|
||||
attr :rest, :global, doc: "Arbitrary HTML attributes (e.g. id, class, data-testid)"
|
||||
|
||||
slot :inner_block, required: true, doc: "Badge text (and optional icon)"
|
||||
slot :icon, doc: "Optional leading icon slot"
|
||||
|
||||
def badge(assigns) do
|
||||
# Normalize so both HEEx strings (variant="neutral") and helper atoms (variant={:neutral}) work
|
||||
variant = to_string(assigns.variant || "neutral")
|
||||
style = to_string(assigns.style || "soft")
|
||||
size = to_string(assigns.size || "md")
|
||||
|
||||
variant_class = "badge-#{variant}"
|
||||
style_class = badge_style_class(style)
|
||||
size_class = "badge-#{size}"
|
||||
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
||||
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
||||
|
||||
rest = assigns.rest || []
|
||||
rest = if is_list(rest), do: rest, else: Map.to_list(rest)
|
||||
extra_class = Keyword.get(rest, :class)
|
||||
rest = Keyword.drop(rest, [:class])
|
||||
rest = if assigns.sr_label, do: Keyword.put(rest, :"aria-label", assigns.sr_label), else: rest
|
||||
|
||||
class =
|
||||
["badge", variant_class, style_class, size_class, outline_bg, extra_class]
|
||||
|> List.flatten()
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join(" ")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:class, class)
|
||||
|> assign(:rest, rest)
|
||||
|> assign(:has_icon, assigns.icon != [])
|
||||
|
||||
~H"""
|
||||
<span class={@class} {@rest}>
|
||||
<%= if @has_icon do %>
|
||||
{render_slot(@icon)}
|
||||
<% end %>
|
||||
{render_slot(@inner_block)}
|
||||
<%= if @sr_label do %>
|
||||
<span class="sr-only">{@sr_label}</span>
|
||||
<% end %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp badge_style_class("soft"), do: "badge-soft"
|
||||
defp badge_style_class("solid"), do: nil
|
||||
defp badge_style_class("outline"), do: "badge-outline"
|
||||
defp badge_style_class(_), do: nil
|
||||
|
||||
@doc """
|
||||
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
||||
|
||||
Use when a table cell has no value so that:
|
||||
- The cell appears empty (no dash, no "n/a").
|
||||
- Screen readers still get a meaningful label (e.g. "No cycle", "No group assignment").
|
||||
|
||||
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
|
||||
|
||||
## Examples
|
||||
|
||||
<.empty_cell sr_text={gettext("No cycle")} />
|
||||
<.empty_cell sr_text={gettext("No group assignment")} />
|
||||
<.empty_cell sr_text={gettext("Not specified")} />
|
||||
"""
|
||||
attr :sr_text, :string,
|
||||
required: true,
|
||||
doc: "Text read by screen readers when the cell is visually empty"
|
||||
|
||||
def empty_cell(assigns) do
|
||||
~H"""
|
||||
<span class="sr-only">{@sr_text}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders content when value is present, otherwise an accessible empty cell.
|
||||
|
||||
Use in table cells for optional fields: when `value` is blank, only the
|
||||
screen-reader text is shown (visually empty). Otherwise the inner block is rendered.
|
||||
|
||||
Blank check: `nil`, `false`, `[]`, `""`, whitespace-only string, or `%Ash.NotLoaded{}` count as empty.
|
||||
|
||||
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
|
||||
|
||||
## Examples
|
||||
|
||||
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("No fee type")}>
|
||||
{member.membership_fee_type.name}
|
||||
</.maybe_value>
|
||||
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||
<%= for g <- member.groups do %>
|
||||
<.badge variant="primary" style="outline">{g.name}</.badge>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
"""
|
||||
attr :value, :any, doc: "Value to check; if blank, empty_cell is rendered"
|
||||
|
||||
attr :empty_sr_text, :string,
|
||||
default: nil,
|
||||
doc: "Screen-reader text when value is blank (default: gettext \"Not specified\")"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def maybe_value(assigns) do
|
||||
empty_sr = assigns.empty_sr_text || gettext("Not specified")
|
||||
assigns = assign(assigns, :empty_sr_text, empty_sr)
|
||||
assigns = assign(assigns, :blank?, value_blank?(assigns.value))
|
||||
|
||||
~H"""
|
||||
<%= if @blank? do %>
|
||||
<.empty_cell sr_text={@empty_sr_text} />
|
||||
<% else %>
|
||||
{render_slot(@inner_block)}
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp value_blank?(nil), do: true
|
||||
defp value_blank?(false), do: true
|
||||
defp value_blank?([]), do: true
|
||||
defp value_blank?(%Ash.NotLoaded{}), do: true
|
||||
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
|
||||
defp value_blank?(_), do: false
|
||||
|
||||
@doc """
|
||||
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
|
||||
or status badges that need explanation (Design Guidelines §8.2).
|
||||
|
|
@ -265,7 +457,11 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def dropdown_menu(assigns) do
|
||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||
assigns = assign(assigns, :menu_testid, menu_testid)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:menu_testid, menu_testid)
|
||||
|> assign(:button_focus_classes, @button_focus_classes)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
|
|
@ -281,17 +477,10 @@ defmodule MvWeb.CoreComponents do
|
|||
tabindex="0"
|
||||
role="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={@open}
|
||||
aria-expanded={if @open, do: "true", else: "false"}
|
||||
aria-controls={@id}
|
||||
aria-label={@button_label}
|
||||
class={[
|
||||
"btn",
|
||||
"focus:outline-none",
|
||||
"focus-visible:ring-2",
|
||||
"focus-visible:ring-offset-2",
|
||||
"focus-visible:ring-base-content/20",
|
||||
@button_class
|
||||
]}
|
||||
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
data-testid={@button_testid}
|
||||
|
|
@ -359,7 +548,12 @@ defmodule MvWeb.CoreComponents do
|
|||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={
|
||||
[
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left",
|
||||
"focus-visible:ring-inset"
|
||||
] ++ @button_focus_classes
|
||||
}
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
|
|
@ -670,6 +864,8 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
|
||||
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
|
||||
For keyboard accessibility (WCAG 2.1.1), the first column without `col_click` gets
|
||||
`tabindex="0"` and `role="button"`; the TableRowKeydown hook triggers the row action on Enter/Space.
|
||||
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
|
||||
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
|
||||
|
||||
|
|
@ -752,8 +948,22 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
||||
|
||||
# WCAG 2.1.1: when row_click is set, first column without col_click gets tabindex="0"
|
||||
# so rows are reachable via Tab; TableRowKeydown hook triggers click on Enter/Space
|
||||
first_row_click_col_idx =
|
||||
if assigns[:row_click] do
|
||||
Enum.find_index(assigns[:col] || [], fn c -> !c[:col_click] end)
|
||||
end
|
||||
|
||||
assigns =
|
||||
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
||||
|
||||
~H"""
|
||||
<div class="overflow-auto">
|
||||
<div
|
||||
id={@row_click && "#{@id}-keyboard"}
|
||||
class="overflow-auto"
|
||||
phx-hook={@row_click && "TableRowKeydown"}
|
||||
>
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -789,6 +999,11 @@ defmodule MvWeb.CoreComponents do
|
|||
>
|
||||
<td
|
||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||
tabindex={if @row_click && @first_row_click_col_idx == col_idx, do: 0, else: nil}
|
||||
role={if @row_click && @first_row_click_col_idx == col_idx, do: "button", else: nil}
|
||||
data-row-clickable={
|
||||
if @row_click && @first_row_click_col_idx == col_idx, do: "true", else: nil
|
||||
}
|
||||
phx-click={
|
||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||
(@row_click && @row_click.(row))
|
||||
|
|
@ -812,6 +1027,19 @@ defmodule MvWeb.CoreComponents do
|
|||
classes
|
||||
end
|
||||
|
||||
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
|
||||
classes =
|
||||
if @row_click && @first_row_click_col_idx == col_idx do
|
||||
[
|
||||
"focus:outline-none",
|
||||
"focus-visible:outline-none",
|
||||
"focus:ring-0",
|
||||
"focus-visible:ring-0" | classes
|
||||
]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
classes =
|
||||
if col_class do
|
||||
[col_class | classes]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
|
|
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
|
|
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
|
|||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -219,6 +219,17 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
|||
def status_color(:unpaid), do: "badge-error"
|
||||
def status_color(:suspended), do: "badge-ghost"
|
||||
|
||||
@doc """
|
||||
Returns the Core Components badge variant for a cycle status (WCAG-compliant).
|
||||
|
||||
Use with <.badge variant={MembershipFeeHelpers.status_variant(status)}>.
|
||||
Suspended uses :warning (yellow) to match the edit cycle-status button.
|
||||
"""
|
||||
@spec status_variant(:paid | :unpaid | :suspended) :: :success | :error | :warning
|
||||
def status_variant(:paid), do: :success
|
||||
def status_variant(:unpaid), do: :error
|
||||
def status_variant(:suspended), do: :warning
|
||||
|
||||
@doc """
|
||||
Gets the icon name for a status.
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="relative"
|
||||
class="relative member-filter-dropdown"
|
||||
id={@id}
|
||||
phx-click-away={if @open, do: "close_dropdown", else: nil}
|
||||
phx-window-keydown={@open && "close_dropdown"}
|
||||
phx-key="Escape"
|
||||
phx-target={@myself}
|
||||
|
|
@ -89,21 +90,23 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
@boolean_filters
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
<.badge
|
||||
:if={active_boolean_filters_count(@boolean_filters) > 0}
|
||||
class="badge badge-primary badge-sm"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{active_boolean_filters_count(@boolean_filters)}
|
||||
</span>
|
||||
<span
|
||||
</.badge>
|
||||
<.badge
|
||||
:if={
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0) &&
|
||||
active_boolean_filters_count(@boolean_filters) == 0
|
||||
}
|
||||
class="badge badge-primary badge-sm"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{@member_count}
|
||||
</span>
|
||||
</.badge>
|
||||
</.button>
|
||||
|
||||
<!--
|
||||
|
|
@ -118,8 +121,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
:if={@open}
|
||||
tabindex="0"
|
||||
class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]"
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@myself}
|
||||
role="dialog"
|
||||
aria-label={gettext("Member filter")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-custom-field-trigger"
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click="request_delete"
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||
|
||||
~H"""
|
||||
<div id={@id} class="mt-8">
|
||||
<div class="flex">
|
||||
<div id={@id}>
|
||||
<div :if={!@show_form} class="flex">
|
||||
<p class="text-sm text-base-content/70">
|
||||
{gettext("These will appear in addition to other data when adding new members.")}
|
||||
</p>
|
||||
|
|
@ -54,6 +54,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
<.table
|
||||
id="custom_fields_table"
|
||||
rows={@streams.custom_fields}
|
||||
row_id={fn {_stream_key, cf} -> "custom_fields-#{cf.id}" end}
|
||||
row_click={
|
||||
fn {_id, custom_field} ->
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
|
|
@ -89,20 +90,29 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
label={gettext("Show in overview")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
||||
<.badge :if={custom_field.show_in_overview} variant="success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
||||
</.badge>
|
||||
<.badge :if={!custom_field.show_in_overview} variant="neutral">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<dialog
|
||||
:if={@show_delete_modal}
|
||||
id="delete-custom-field-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-custom-field-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
|
||||
<h3 id="delete-custom-field-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete Data Field")}
|
||||
</h3>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
|
|
@ -110,15 +120,15 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
<div>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
"%{count} member has a value assigned for this datafield.",
|
||||
"%{count} members have values assigned for this datafield.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
"All datafield values will be permanently deleted when you delete this datafield."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -184,8 +194,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# Track previous show_form state to detect when form is closed
|
||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
||||
# Use socket state so send_update(open_delete_for_id: ...) does not trigger false "form closed"
|
||||
previous_show_form = socket.assigns[:show_form] || false
|
||||
|
||||
# If show_form is explicitly provided in assigns, reset editing state
|
||||
socket =
|
||||
|
|
@ -197,13 +207,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
socket
|
||||
end
|
||||
|
||||
# Detect when form is closed (show_form changes from true to false)
|
||||
new_show_form = Map.get(assigns, :show_form, false)
|
||||
|
||||
if previous_show_form and not new_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
# Get actor from assigns or fall back to socket assigns
|
||||
actor = Map.get(assigns, :actor, socket.assigns[:actor])
|
||||
|
||||
|
|
@ -225,6 +228,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
socket
|
||||
|
||||
id ->
|
||||
send(self(), {:custom_field_delete_modal_open, true})
|
||||
|
||||
custom_field =
|
||||
Ash.get!(Mv.Membership.CustomField, id,
|
||||
load: [:assigned_members_count],
|
||||
|
|
@ -238,6 +243,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|> assign(:open_delete_for_id, nil)
|
||||
end
|
||||
|
||||
# Detect form closed only from final socket state (not from assigns alone)
|
||||
current_show_form = socket.assigns[:show_form] || false
|
||||
|
||||
if previous_show_form and not current_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
|
|
@ -282,6 +294,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
actor: actor
|
||||
)
|
||||
|
||||
send(self(), {:custom_field_delete_modal_open, true})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_to_delete, custom_field)
|
||||
|
|
@ -302,6 +316,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||
case Ash.destroy(custom_field, actor: actor) do
|
||||
:ok ->
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
send(self(), {:custom_field_deleted, custom_field})
|
||||
|
||||
{:noreply,
|
||||
|
|
@ -312,6 +327,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|> stream_delete(:custom_fields, custom_field)}
|
||||
|
||||
{:error, error} ->
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
send(self(), {:custom_field_delete_error, error})
|
||||
|
||||
{:noreply,
|
||||
|
|
@ -321,6 +337,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
else
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
send(self(), :custom_field_slug_mismatch)
|
||||
|
||||
{:noreply,
|
||||
|
|
@ -333,10 +350,22 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
send(self(), {:custom_field_delete_modal_open, false})
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
|
|||
socket
|
||||
|> assign(:page_title, gettext("Datafields"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)}
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign(:custom_field_delete_modal_open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("window_keydown", %{"key" => key}, socket)
|
||||
when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:custom_field_delete_modal_open] do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
show_delete_modal: false,
|
||||
custom_field_to_delete: nil,
|
||||
slug_confirmation: ""
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_delete_modal_open, false)
|
||||
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -29,31 +52,68 @@ defmodule MvWeb.DatafieldsLive do
|
|||
<.header>
|
||||
{gettext("Datafields")}
|
||||
<:subtitle>
|
||||
{gettext("Configure member fields and custom data fields.")}
|
||||
{gettext(
|
||||
"Configure which data you want to save for your members. Define individual datafields."
|
||||
)}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form_section title={gettext("Member fields")}>
|
||||
<%!-- Overview: both sections with form_section wrappers; FocusRestore for custom field delete modal --%>
|
||||
<div
|
||||
:if={@active_editing_section == nil}
|
||||
id="datafields-focus-root"
|
||||
class="mt-6 space-y-6"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :custom_fields}
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
</.form_section>
|
||||
|
||||
<.form_section title={gettext("Custom fields")}>
|
||||
<.form_section title={gettext("Individual Datafields")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :member_fields}
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Edit mode: only the active section, no section title/card wrapper --%>
|
||||
<div :if={@active_editing_section == :member_fields} class="mt-6">
|
||||
<.live_component
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@active_editing_section == :custom_fields}
|
||||
id="datafields-focus-root"
|
||||
class="mt-6"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_delete_modal_open, open}, socket) do
|
||||
{:noreply, assign(socket, :custom_field_delete_modal_open, open)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
|
|
|
|||
|
|
@ -124,7 +124,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
<label class="label" for={@form[:vereinfacht_api_key].id}>
|
||||
<span class="label-text">{gettext("API Key")}</span>
|
||||
<%= if @vereinfacht_api_key_set do %>
|
||||
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||
<span class="label-text-alt">
|
||||
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
||||
</span>
|
||||
<% end %>
|
||||
</label>
|
||||
<.input
|
||||
|
|
@ -251,7 +253,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
<label class="label" for={@form[:oidc_client_secret].id}>
|
||||
<span class="label-text">{gettext("Client Secret")}</span>
|
||||
<%= if @oidc_client_secret_set do %>
|
||||
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||
<span class="label-text-alt">
|
||||
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
||||
</span>
|
||||
<% end %>
|
||||
</label>
|
||||
<.input
|
||||
|
|
|
|||
|
|
@ -68,11 +68,9 @@ defmodule MvWeb.GroupLive.Index do
|
|||
{group.name}
|
||||
</:col>
|
||||
<:col :let={group} label={gettext("Description")}>
|
||||
<%= if group.description do %>
|
||||
<.maybe_value value={group.description} empty_sr_text={gettext("Not specified")}>
|
||||
{group.description}
|
||||
<% else %>
|
||||
<span class="text-base-content/50 italic">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:col :let={group} label={gettext("Members")} class="text-right">
|
||||
{group.member_count || 0}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,12 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<div
|
||||
id="group-show-focus-root"
|
||||
class="mt-6 space-y-6"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<%!-- Group Information --%>
|
||||
<div class="max-w-2xl space-y-6 mb-6">
|
||||
<div>
|
||||
|
|
@ -150,7 +155,11 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<div class="relative">
|
||||
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
||||
<%= for member <- @selected_members do %>
|
||||
<span class="badge badge-outline badge flex items-center gap-1">
|
||||
<.badge
|
||||
variant="primary"
|
||||
style="outline"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||
<.tooltip content={gettext("Remove")} position="top">
|
||||
<.button
|
||||
|
|
@ -169,7 +178,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<.icon name="hero-x-mark" class="size-3" />
|
||||
</.button>
|
||||
</.tooltip>
|
||||
</span>
|
||||
</.badge>
|
||||
<% end %>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -300,16 +309,14 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</.link>
|
||||
</td>
|
||||
<td>
|
||||
<%= if member.email do %>
|
||||
<.maybe_value value={member.email} empty_sr_text={gettext("No email")}>
|
||||
<a
|
||||
href={"mailto:#{member.email}"}
|
||||
class="link link-primary"
|
||||
>
|
||||
{member.email}
|
||||
</a>
|
||||
<% else %>
|
||||
<span class="text-base-content/50 italic">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</td>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<td>
|
||||
|
|
@ -351,6 +358,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-group-trigger"
|
||||
variant="danger"
|
||||
type="button"
|
||||
phx-click="open_delete_modal"
|
||||
|
|
@ -364,11 +372,19 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
|
||||
<dialog
|
||||
id="delete-group-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-group-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
|
||||
<h3 id="delete-group-modal-title" class="text-lg font-bold mb-4">
|
||||
{gettext("Delete Group")}
|
||||
</h3>
|
||||
<p class="mb-4">
|
||||
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
|
||||
</p>
|
||||
|
|
@ -403,6 +419,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
placeholder={gettext("Enter the group name to confirm")}
|
||||
autocomplete="off"
|
||||
phx-debounce="200"
|
||||
phx-mounted={JS.focus()}
|
||||
class="w-full input input-bordered"
|
||||
/>
|
||||
</form>
|
||||
|
|
@ -443,12 +460,25 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:name_confirmation, "")}
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
|
||||
{:noreply, assign(socket, :name_confirmation, name)}
|
||||
|
|
@ -929,6 +959,13 @@ defmodule MvWeb.GroupLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:name_confirmation, "")
|
||||
|> push_event("focus_restore", %{id: "delete-group-trigger"})
|
||||
end
|
||||
|
||||
defp perform_group_deletion(socket, group, actor) do
|
||||
case Membership.destroy_group(group, actor: actor) do
|
||||
:ok ->
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
<p :if={!@show_form} class="text-sm text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
|
||||
)}
|
||||
|
|
@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
:if={!@show_form}
|
||||
id="member_fields"
|
||||
rows={@member_fields}
|
||||
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
|
||||
row_click={
|
||||
fn {field_name, _field_data} ->
|
||||
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
||||
|
|
@ -85,12 +86,12 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
label={gettext("Show in overview")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span :if={field_data.show_in_overview} class="badge badge-success">
|
||||
<.badge :if={field_data.show_in_overview} variant="success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
|
||||
</.badge>
|
||||
<.badge :if={!field_data.show_in_overview} variant="neutral">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
|
@ -99,8 +100,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# Track previous show_form state to detect when form is closed
|
||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
||||
# Use socket state so send_update(show_form: false) is the only trigger for "form closed"
|
||||
previous_show_form = socket.assigns[:show_form] || false
|
||||
|
||||
# If show_form is explicitly provided in assigns, reset editing state
|
||||
socket =
|
||||
|
|
@ -112,20 +113,22 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
socket
|
||||
end
|
||||
|
||||
# Detect when form is closed (show_form changes from true to false)
|
||||
new_show_form = Map.get(assigns, :show_form, false)
|
||||
|
||||
if previous_show_form and not new_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:settings, fn -> get_settings() end)
|
||||
|> assign_new(:show_form, fn -> false end)
|
||||
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|
||||
|> assign_new(:editing_member_field, fn -> nil end)}
|
||||
|> assign_new(:editing_member_field, fn -> nil end)
|
||||
|
||||
# Detect form closed only from final socket state (not from assigns alone)
|
||||
current_show_form = socket.assigns[:show_form] || false
|
||||
|
||||
if previous_show_form and not current_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ defmodule MvWeb.MemberLive.Form do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
||||
<div
|
||||
id="member-form-focus-root"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.header>
|
||||
<:leading>
|
||||
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
||||
|
|
@ -58,14 +63,31 @@ defmodule MvWeb.MemberLive.Form do
|
|||
</.header>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
|
||||
<div
|
||||
role="tablist"
|
||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||
>
|
||||
<button
|
||||
id="member-tab-contact"
|
||||
role="tab"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-selected="true"
|
||||
aria-controls="member-tabpanel-contact"
|
||||
class="tab tab-active flex items-center gap-2"
|
||||
>
|
||||
<.icon name="hero-identification" class="size-4 shrink-0" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Contact Data Tab Content (same structure as member show) --%>
|
||||
<div
|
||||
id="member-tabpanel-contact"
|
||||
role="tabpanel"
|
||||
aria-labelledby="member-tab-contact"
|
||||
>
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
|
|
@ -164,7 +186,9 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<%= for cf <- @sorted_custom_fields do %>
|
||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
||||
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
||||
<div class={
|
||||
if cf.value_type == :boolean, do: "flex items-end", else: ""
|
||||
}>
|
||||
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||
<.input
|
||||
field={value_form[:value]}
|
||||
|
|
@ -258,15 +282,10 @@ defmodule MvWeb.MemberLive.Form do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-member-form-trigger"
|
||||
variant="danger"
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={@member.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
|
|
@ -280,6 +299,51 @@ defmodule MvWeb.MemberLive.Form do
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= if @member && assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-member-form-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-member-form-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete Member")}
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-member-form-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @member.id})}
|
||||
aria-label={gettext("Delete member")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
|
|
@ -329,6 +393,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> assign(:member_field_required_map, member_field_required_map)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -400,6 +465,32 @@ defmodule MvWeb.MemberLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
member = socket.assigns.member
|
||||
|
|
@ -407,10 +498,16 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
cond do
|
||||
is_nil(member) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Member not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
to_string(id) != to_string(member.id) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Member not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
true ->
|
||||
handle_member_delete_destroy(socket, member, actor)
|
||||
|
|
@ -427,14 +524,26 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
|
||||
socket
|
||||
|> put_flash(:error, gettext("You do not have permission to delete this member"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
{:noreply, put_flash(socket, :error, format_destroy_error(error))}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_destroy_error(error))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-member-form-trigger"})
|
||||
end
|
||||
|
||||
defp handle_save_success(socket, member) do
|
||||
notify_parent({:saved, member})
|
||||
|
||||
|
|
|
|||
|
|
@ -1644,11 +1644,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
||||
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
|
||||
|
||||
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
|
||||
mailto_bcc =
|
||||
if any_selected? do
|
||||
format_selected_member_emails(members, selected_members)
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode_www_form()
|
||||
|> String.replace("+", "%20")
|
||||
else
|
||||
""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -356,26 +356,24 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
<%= if member.membership_fee_type do %>
|
||||
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("Not specified")}>
|
||||
{member.membership_fee_type.name}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_status in @member_fields_visible}
|
||||
label={gettext("Membership Fee Status")}
|
||||
>
|
||||
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
|
||||
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
||||
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
|
||||
MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
||||
) do %>
|
||||
<span class={["badge", badge.color]}>
|
||||
<.badge variant={badge.variant}>
|
||||
<.icon name={badge.icon} class="size-4" />
|
||||
{badge.label}
|
||||
</span>
|
||||
</.badge>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
||||
<.empty_cell sr_text={gettext("No cycle")} />
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col
|
||||
|
|
@ -394,17 +392,17 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
|
||||
<%= for group <- (member.groups || []) do %>
|
||||
<span
|
||||
class="badge badge-outline badge-primary"
|
||||
<.badge
|
||||
variant="primary"
|
||||
style="outline"
|
||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if (member.groups || []) == [] do %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
</.badge>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
|
|
@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
||||
<div
|
||||
id="member-show-focus-root"
|
||||
class="mt-6 space-y-6"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<%!-- Tab Navigation: roving tabindex (only active tab tabindex="0"), ArrowLeft/ArrowRight (WCAG tab pattern) --%>
|
||||
<div
|
||||
id="member-tablist"
|
||||
role="tablist"
|
||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||
phx-hook="TabListKeydown"
|
||||
phx-keydown="tab_keydown"
|
||||
>
|
||||
<button
|
||||
id="member-tab-contact"
|
||||
role="tab"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-selected={@active_tab == :contact}
|
||||
tabindex={if @active_tab == :contact, do: "0", else: "-1"}
|
||||
aria-selected={if @active_tab == :contact, do: "true", else: "false"}
|
||||
aria-controls="member-tabpanel-contact"
|
||||
class={[
|
||||
"tab flex items-center gap-2",
|
||||
|
|
@ -82,8 +90,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
id="member-tab-membership_fees"
|
||||
role="tab"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-selected={@active_tab == :membership_fees}
|
||||
tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"}
|
||||
aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"}
|
||||
aria-controls="member-tabpanel-membership_fees"
|
||||
class={[
|
||||
"tab flex items-center gap-2",
|
||||
|
|
@ -254,22 +262,24 @@ defmodule MvWeb.MemberLive.Show do
|
|||
/>
|
||||
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
||||
<%= if @member.last_cycle_status do %>
|
||||
<% status = @member.last_cycle_status %>
|
||||
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
||||
{format_status_label(status)}
|
||||
</span>
|
||||
<.badge variant={
|
||||
MembershipFeeHelpers.status_variant(@member.last_cycle_status)
|
||||
}>
|
||||
{format_status_label(@member.last_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
||||
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
<.data_field label={gettext("Current Cycle")} class="min-w-36">
|
||||
<%= if @member.current_cycle_status do %>
|
||||
<% status = @member.current_cycle_status %>
|
||||
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
||||
{format_status_label(status)}
|
||||
</span>
|
||||
<.badge variant={
|
||||
MembershipFeeHelpers.status_variant(@member.current_cycle_status)
|
||||
}>
|
||||
{format_status_label(@member.current_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
||||
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
|
@ -313,14 +323,9 @@ defmodule MvWeb.MemberLive.Show do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-member-trigger"
|
||||
variant="danger"
|
||||
phx-click="delete"
|
||||
phx-value-id={@member.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
|
|
@ -334,6 +339,48 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-member-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-member-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-member-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete Member")}
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-member-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @member.id})}
|
||||
aria-label={gettext("Delete member")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -344,7 +391,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:ok,
|
||||
socket
|
||||
|> assign(:active_tab, :contact)
|
||||
|> assign(:vereinfacht_receipts, nil)}
|
||||
|> assign(:vereinfacht_receipts, nil)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -396,13 +444,58 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("tab_keydown", %{"key" => key}, socket)
|
||||
when key in ["ArrowLeft", "ArrowRight"] do
|
||||
new_tab =
|
||||
case {key, socket.assigns.active_tab} do
|
||||
{"ArrowRight", :contact} -> :membership_fees
|
||||
{"ArrowLeft", :membership_fees} -> :contact
|
||||
_ -> socket.assigns.active_tab
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :active_tab, new_tab)}
|
||||
end
|
||||
|
||||
def handle_event("tab_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
# Escape closes modal (WCAG). phx-window-keydown ensures Escape is captured regardless of focus.
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
member = socket.assigns.member
|
||||
actor = current_actor(socket)
|
||||
|
||||
if to_string(id) != to_string(member.id) do
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("Member not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
else
|
||||
case Ash.destroy(member, actor: actor) do
|
||||
:ok ->
|
||||
|
|
@ -413,16 +506,21 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("You do not have permission to delete this member")
|
||||
)}
|
||||
)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, error} ->
|
||||
require Logger
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_error(error))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -437,6 +535,13 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
||||
end
|
||||
|
||||
# WCAG 2.4.3: when modal closes, return focus to the trigger (Delete member button)
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-member-trigger"})
|
||||
end
|
||||
|
||||
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||
@impl true
|
||||
def handle_info({:put_flash, type, message}, socket) do
|
||||
|
|
@ -503,7 +608,11 @@ defmodule MvWeb.MemberLive.Show do
|
|||
<%= if @inner_block != [] do %>
|
||||
{render_slot(@inner_block)}
|
||||
<% else %>
|
||||
{display_value(@value)}
|
||||
<%= if value_blank?(@value) do %>
|
||||
<.empty_cell sr_text={gettext("Not set")} />
|
||||
<% else %>
|
||||
{@value}
|
||||
<% end %>
|
||||
<% end %>
|
||||
</dd>
|
||||
</dl>
|
||||
|
|
@ -537,9 +646,9 @@ defmodule MvWeb.MemberLive.Show do
|
|||
# Helper Functions
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
defp display_value(nil), do: render_empty_value()
|
||||
defp display_value(""), do: render_empty_value()
|
||||
defp display_value(value), do: value
|
||||
defp value_blank?(nil), do: true
|
||||
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
|
||||
defp value_blank?(_), do: false
|
||||
|
||||
defp format_status_label(:paid), do: gettext("Paid")
|
||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||
|
|
@ -628,10 +737,10 @@ defmodule MvWeb.MemberLive.Show do
|
|||
if String.trim(value) == "" do
|
||||
render_empty_value()
|
||||
else
|
||||
assigns = %{email: value}
|
||||
assigns = %{email: value, display: value}
|
||||
|
||||
~H"""
|
||||
<.mailto_link email={@email} display={@email} />
|
||||
<.mailto_link email={@email} display={@display} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -646,17 +755,10 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
defp format_custom_field_value(value, _type), do: to_string(value)
|
||||
|
||||
# Renders accessible placeholder for empty values
|
||||
# Uses translated text for screen readers while maintaining visual consistency
|
||||
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
|
||||
# Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines §8.6).
|
||||
# Returns safe HTML so it can be used from helpers without LiveView assigns.
|
||||
defp render_empty_value do
|
||||
assigns = %{text: gettext("Not set")}
|
||||
|
||||
~H"""
|
||||
<span class="text-base-content/50 italic">
|
||||
<span aria-hidden="true">—</span>
|
||||
<span class="sr-only">{@text}</span>
|
||||
</span>
|
||||
"""
|
||||
text = gettext("Not set")
|
||||
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -101,7 +101,13 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<%= for r <- receipts do %>
|
||||
<tr>
|
||||
<%= for {col_key, _header_key} <- cols do %>
|
||||
<td>{format_receipt_cell(col_key, r[col_key])}</td>
|
||||
<td>
|
||||
<%= if (cell_content = format_receipt_cell(col_key, r[col_key])) != nil do %>
|
||||
{cell_content}
|
||||
<% else %>
|
||||
<.empty_cell sr_text={receipt_empty_sr_text(col_key)} />
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
@ -186,9 +192,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
<.badge variant="neutral" style="outline">
|
||||
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Amount")}>
|
||||
|
|
@ -208,12 +214,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Status")}>
|
||||
<% badge = MembershipFeeHelpers.status_color(cycle.status) %>
|
||||
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
|
||||
<span class={["badge", badge]}>
|
||||
<.icon name={icon} class="size-4" />
|
||||
<.badge variant={MembershipFeeHelpers.status_variant(cycle.status)}>
|
||||
<.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-4" />
|
||||
{format_status_label(cycle.status)}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:action :let={cycle}>
|
||||
|
|
@ -227,7 +231,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :paid)}
|
||||
aria-pressed={cycle.status == :paid}
|
||||
aria-pressed={if cycle.status == :paid, do: "true", else: "false"}
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
|
|
@ -240,7 +244,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :suspended)}
|
||||
aria-pressed={cycle.status == :suspended}
|
||||
aria-pressed={if cycle.status == :suspended, do: "true", else: "false"}
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
|
|
@ -253,7 +257,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :unpaid)}
|
||||
aria-pressed={cycle.status == :unpaid}
|
||||
aria-pressed={if cycle.status == :unpaid, do: "true", else: "false"}
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
|
|
@ -290,11 +294,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<% end %>
|
||||
</.section_box>
|
||||
|
||||
<%!-- Edit Cycle Amount Modal --%>
|
||||
<%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if @editing_cycle do %>
|
||||
<dialog id="edit-cycle-amount-modal" class="modal modal-open">
|
||||
<dialog
|
||||
id="edit-cycle-amount-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="edit-cycle-amount-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Edit Cycle Amount")}</h3>
|
||||
<h3 id="edit-cycle-amount-modal-title" class="text-lg font-bold">
|
||||
{gettext("Edit Cycle Amount")}
|
||||
</h3>
|
||||
<form phx-submit="save_cycle_amount" phx-target={@myself}>
|
||||
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
|
||||
<div class="form-control w-full mt-4">
|
||||
|
|
@ -310,6 +322,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
|
|
@ -328,11 +341,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Cycle Confirmation Modal --%>
|
||||
<%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if @deleting_cycle do %>
|
||||
<dialog id="delete-cycle-modal" class="modal modal-open">
|
||||
<dialog
|
||||
id="delete-cycle-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-cycle-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
|
||||
<h3 id="delete-cycle-modal-title" class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
|
||||
<p class="py-4">
|
||||
{gettext("Are you sure you want to delete this cycle?")}
|
||||
</p>
|
||||
|
|
@ -343,7 +362,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
|
||||
<.button
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_cycle"
|
||||
phx-target={@myself}
|
||||
phx-mounted={JS.focus()}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
|
|
@ -359,11 +383,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete All Cycles Confirmation Modal --%>
|
||||
<%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if @deleting_all_cycles do %>
|
||||
<dialog id="delete-all-cycles-modal" class="modal modal-open">
|
||||
<dialog
|
||||
id="delete-all-cycles-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-all-cycles-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
|
||||
<h3 id="delete-all-cycles-modal-title" class="text-lg font-bold text-error">
|
||||
{gettext("Delete All Cycles")}
|
||||
</h3>
|
||||
<div class="alert alert-warning mt-4">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<div>
|
||||
|
|
@ -391,6 +423,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
value={@delete_all_confirmation || ""}
|
||||
class="input input-bordered w-full"
|
||||
placeholder={gettext("Yes")}
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
|
|
@ -413,11 +446,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Create Cycle Modal --%>
|
||||
<%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%>
|
||||
<%= if @creating_cycle do %>
|
||||
<dialog id="create-cycle-modal" class="modal modal-open">
|
||||
<dialog
|
||||
id="create-cycle-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="create-cycle-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
|
||||
<h3 id="create-cycle-modal-title" class="text-lg font-bold">{gettext("Create Cycle")}</h3>
|
||||
<form phx-submit="create_cycle" phx-target={@myself}>
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label" for="create-cycle-date">
|
||||
|
|
@ -433,6 +472,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
class="input input-bordered w-full"
|
||||
required
|
||||
aria-label={gettext("Date")}
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
|
|
@ -881,6 +921,35 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:create_cycle_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => "Escape"}, socket) do
|
||||
socket =
|
||||
cond do
|
||||
socket.assigns[:editing_cycle] ->
|
||||
assign(socket, :editing_cycle, nil)
|
||||
|
||||
socket.assigns[:deleting_cycle] ->
|
||||
assign(socket, :deleting_cycle, nil)
|
||||
|
||||
socket.assigns[:deleting_all_cycles] ->
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|
||||
socket.assigns[:creating_cycle] ->
|
||||
socket
|
||||
|> assign(:creating_cycle, false)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)
|
||||
|
||||
true ->
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
|
||||
date =
|
||||
case Date.from_iso8601(date_str) do
|
||||
|
|
@ -1127,7 +1196,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:amount, nil), do: "—"
|
||||
# Screen-reader text for empty receipt table cells (visually empty, A11y)
|
||||
defp receipt_empty_sr_text(:status), do: gettext("Not set")
|
||||
defp receipt_empty_sr_text(_), do: gettext("Not specified")
|
||||
|
||||
defp format_receipt_cell(:amount, nil), do: nil
|
||||
|
||||
defp format_receipt_cell(:amount, val) when is_number(val) do
|
||||
case Decimal.cast(val) do
|
||||
|
|
@ -1145,7 +1218,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
defp format_receipt_cell(:amount, val), do: to_string(val)
|
||||
|
||||
defp format_receipt_cell(:status, nil), do: "—"
|
||||
defp format_receipt_cell(:status, nil), do: nil
|
||||
|
||||
defp format_receipt_cell(:status, val) when is_binary(val) do
|
||||
translate_receipt_status(val)
|
||||
|
|
@ -1153,7 +1226,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
|
||||
|
||||
defp format_receipt_cell(:receiptType, nil), do: "—"
|
||||
defp format_receipt_cell(:receiptType, nil), do: nil
|
||||
|
||||
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
|
||||
translate_receipt_type(val)
|
||||
|
|
@ -1162,7 +1235,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
|
||||
|
||||
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
|
||||
do: "—"
|
||||
do: nil
|
||||
|
||||
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
|
||||
format_receipt_date(val)
|
||||
|
|
@ -1223,7 +1296,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
defp translate_receipt_status("draft"), do: gettext("Draft")
|
||||
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
|
||||
defp translate_receipt_status("completed"), do: gettext("Completed")
|
||||
defp translate_receipt_status("empty"), do: "—"
|
||||
defp translate_receipt_status("empty"), do: nil
|
||||
defp translate_receipt_status(other), do: other
|
||||
|
||||
# Translate API receipt type values (extend as API returns more values)
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
<.header>
|
||||
{gettext("Membership Fee Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Configure global settings and fee types for membership fees.")}
|
||||
{gettext("Configure fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
|
||||
|
|
@ -177,7 +177,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
id="default_membership_fee_type_id"
|
||||
name="settings[default_membership_fee_type_id]"
|
||||
class={[
|
||||
"select select-bordered w-full",
|
||||
"select select-bordered",
|
||||
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
||||
]}
|
||||
phx-debounce="blur"
|
||||
|
|
@ -323,13 +323,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
<.badge variant="neutral" style="outline">
|
||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
|
||||
<span class="text-sm">{get_member_count(mft, @member_counts)}</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={mft}>
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage membership fee types in your database.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button
|
||||
form="membership-fee-type-form"
|
||||
|
|
|
|||
|
|
@ -68,13 +68,13 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
<.badge variant="neutral" style="outline">
|
||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
|
||||
<.badge variant="neutral">{get_member_count(mft, @member_counts)}</.badge>
|
||||
</:col>
|
||||
|
||||
<:action :let={mft}>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ defmodule MvWeb.RoleLive.Form do
|
|||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ defmodule MvWeb.RoleLive.Helpers do
|
|||
|
||||
@doc """
|
||||
Returns the CSS badge class for a permission set name.
|
||||
|
||||
Deprecated for new code: prefer `permission_set_badge_variant/1` with <.badge>.
|
||||
"""
|
||||
@spec permission_set_badge_class(String.t()) :: String.t()
|
||||
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
|
||||
|
|
@ -26,6 +28,18 @@ defmodule MvWeb.RoleLive.Helpers do
|
|||
def permission_set_badge_class("admin"), do: "badge badge-error badge-sm"
|
||||
def permission_set_badge_class(_), do: "badge badge-ghost badge-sm"
|
||||
|
||||
@doc """
|
||||
Returns the Core Components badge variant for a permission set name (WCAG-compliant).
|
||||
|
||||
Use with <.badge variant={permission_set_badge_variant(permission_set_name)} size="sm">.
|
||||
"""
|
||||
@spec permission_set_badge_variant(String.t()) :: :neutral | :info | :success | :error
|
||||
def permission_set_badge_variant("own_data"), do: :neutral
|
||||
def permission_set_badge_variant("read_only"), do: :info
|
||||
def permission_set_badge_variant("normal_user"), do: :success
|
||||
def permission_set_badge_variant("admin"), do: :error
|
||||
def permission_set_badge_variant(_), do: :neutral
|
||||
|
||||
@doc """
|
||||
Builds Ash options with actor and domain, ensuring actor is never nil in real paths.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.Index do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1]
|
||||
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
|
|||
|
|
@ -16,15 +16,16 @@
|
|||
<.table
|
||||
id="roles"
|
||||
rows={@roles}
|
||||
row_id={fn role -> "role-#{role.id}" end}
|
||||
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
||||
row_tooltip={gettext("Click for role details")}
|
||||
>
|
||||
<:col :let={role} label={gettext("Name")}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{role.name}</span>
|
||||
<%= if role.is_system_role do %>
|
||||
<span class="badge badge-warning badge-sm">{gettext("System Role")}</span>
|
||||
<% end %>
|
||||
<.badge :if={role.is_system_role} variant="warning" size="sm">
|
||||
{gettext("System Role")}
|
||||
</.badge>
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
|
|
@ -37,21 +38,22 @@
|
|||
</:col>
|
||||
|
||||
<:col :let={role} label={gettext("Permission Set")}>
|
||||
<span class={permission_set_badge_class(role.permission_set_name)}>
|
||||
<.badge variant={permission_set_badge_variant(role.permission_set_name)} size="sm">
|
||||
{role.permission_set_name}
|
||||
</span>
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={role} label={gettext("Type")}>
|
||||
<%= if role.is_system_role do %>
|
||||
<span class="badge badge-warning badge-sm">{gettext("System")}</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost badge-sm">{gettext("Custom")}</span>
|
||||
<% end %>
|
||||
<.badge :if={role.is_system_role} variant="warning" size="sm">
|
||||
{gettext("System")}
|
||||
</.badge>
|
||||
<.badge :if={!role.is_system_role} variant="neutral" size="sm">
|
||||
{gettext("Custom")}
|
||||
</.badge>
|
||||
</:col>
|
||||
|
||||
<:col :let={role} label={gettext("Users")}>
|
||||
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
|
||||
<span class="text-sm">{get_user_count(role, @user_counts)}</span>
|
||||
</:col>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.RoleLive.Show do
|
|||
require Ash.Query
|
||||
|
||||
import MvWeb.RoleLive.Helpers,
|
||||
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
|
||||
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
|
|
@ -35,7 +35,8 @@ defmodule MvWeb.RoleLive.Show do
|
|||
socket
|
||||
|> assign(:page_title, gettext("Show Role"))
|
||||
|> assign(:role, role)
|
||||
|> assign(:user_count, user_count)}
|
||||
|> assign(:user_count, user_count)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||
{:ok,
|
||||
|
|
@ -84,35 +85,61 @@ defmodule MvWeb.RoleLive.Show do
|
|||
error_message = format_error(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Failed to delete role: %{error}", error: error_message)
|
||||
)}
|
||||
)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
defp handle_delete_role(role, socket) do
|
||||
if role.is_system_role do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("System roles cannot be deleted.")
|
||||
)}
|
||||
socket
|
||||
|> put_flash(:error, gettext("System roles cannot be deleted."))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
else
|
||||
user_count = recalculate_user_count(role, socket.assigns.current_user)
|
||||
|
||||
if user_count > 0 do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext(
|
||||
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
|
||||
count: user_count
|
||||
)
|
||||
)}
|
||||
)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
else
|
||||
perform_role_deletion(role, socket)
|
||||
end
|
||||
|
|
@ -156,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
|
|||
recalculate_user_count(role, actor)
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-role-trigger"})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -187,6 +220,11 @@ defmodule MvWeb.RoleLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div
|
||||
id="role-show-focus-root"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.list>
|
||||
<:item title={gettext("Name")}>{@role.name}</:item>
|
||||
<:item title={gettext("Description")}>
|
||||
|
|
@ -197,16 +235,17 @@ defmodule MvWeb.RoleLive.Show do
|
|||
<% end %>
|
||||
</:item>
|
||||
<:item title={gettext("Permission Set")}>
|
||||
<span class={permission_set_badge_class(@role.permission_set_name)}>
|
||||
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
|
||||
{@role.permission_set_name}
|
||||
</span>
|
||||
</.badge>
|
||||
</:item>
|
||||
<:item title={gettext("System Role")}>
|
||||
<%= if @role.is_system_role do %>
|
||||
<span class="badge badge-warning">{gettext("Yes")}</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No")}</span>
|
||||
<% end %>
|
||||
<.badge :if={@role.is_system_role} variant="warning">
|
||||
{gettext("Yes")}
|
||||
</.badge>
|
||||
<.badge :if={!@role.is_system_role} variant="neutral">
|
||||
{gettext("No")}
|
||||
</.badge>
|
||||
</:item>
|
||||
</.list>
|
||||
|
||||
|
|
@ -223,14 +262,9 @@ defmodule MvWeb.RoleLive.Show do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-role-trigger"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
||||
data-confirm={
|
||||
gettext(
|
||||
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
||||
name: @role.name
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="role-delete"
|
||||
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
||||
>
|
||||
|
|
@ -240,6 +274,49 @@ defmodule MvWeb.RoleLive.Show do
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-role-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-role-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
||||
name: @role.name
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-role-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
||||
data-testid="role-delete-confirm"
|
||||
aria-label={gettext("Delete role")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ defmodule MvWeb.StatisticsLive do
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Statistics")}
|
||||
<:subtitle>{gettext("Overview from first membership to today")}</:subtitle>
|
||||
</.header>
|
||||
|
||||
<section class="mb-8" aria-labelledby="members-heading">
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ defmodule MvWeb.UserLive.Form do
|
|||
</.button>
|
||||
</:leading>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
||||
<:actions>
|
||||
<.button
|
||||
form="user-form"
|
||||
|
|
@ -66,6 +65,11 @@ defmodule MvWeb.UserLive.Form do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div
|
||||
id="user-form-focus-root"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
|
||||
|
|
@ -311,16 +315,10 @@ defmodule MvWeb.UserLive.Form do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-user-form-trigger"
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click="delete"
|
||||
phx-value-id={@user.id}
|
||||
data-confirm={
|
||||
gettext(
|
||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||
email: @user.email
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="user-delete"
|
||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||
>
|
||||
|
|
@ -331,6 +329,49 @@ defmodule MvWeb.UserLive.Form do
|
|||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= if @user && assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-user-form-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-user-form-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
||||
{gettext("Delete User")}
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||
email: @user.email
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-user-form-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @user.id})}
|
||||
aria-label={gettext("Delete user")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4">
|
||||
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
||||
{gettext("Cancel")}
|
||||
|
|
@ -340,6 +381,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -399,6 +441,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
|> assign(:selected_member_name, nil)
|
||||
|> assign(:unlink_member, false)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> load_initial_members()
|
||||
|> assign_form()}
|
||||
end
|
||||
|
|
@ -454,6 +497,32 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
user = socket.assigns.user
|
||||
|
|
@ -461,13 +530,22 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
cond do
|
||||
is_nil(user) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("User not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
to_string(id) != to_string(user.id) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("User not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
Mv.Helpers.SystemActor.system_user?(user) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("System user cannot be deleted."))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
true ->
|
||||
handle_user_delete_destroy(socket, user, actor)
|
||||
|
|
@ -594,13 +672,24 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
|
||||
socket
|
||||
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_ash_error(error))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-user-form-trigger"})
|
||||
end
|
||||
|
||||
defp handle_member_linking(socket, user, actor) do
|
||||
result = perform_member_link_action(socket, user, actor)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Listing Users")}
|
||||
{gettext("Users")}
|
||||
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
||||
|
|
@ -37,25 +38,25 @@
|
|||
{user.role.name}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("Linked Member")}>
|
||||
<%= if user.member do %>
|
||||
<.maybe_value value={user.member} empty_sr_text={gettext("No member linked")}>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
|
||||
<% else %>
|
||||
<span class="text-base-content/70">{gettext("No member linked")}</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("Password")}>
|
||||
<%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
|
||||
<.maybe_value
|
||||
value={MvWeb.Helpers.UserHelpers.has_password?(user)}
|
||||
empty_sr_text={gettext("Not set")}
|
||||
>
|
||||
<span>{gettext("Enabled")}</span>
|
||||
<% else %>
|
||||
<span class="text-base-content/70">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("OIDC")}>
|
||||
<%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
|
||||
<.maybe_value
|
||||
value={MvWeb.Helpers.UserHelpers.has_oidc?(user)}
|
||||
empty_sr_text={gettext("Not set")}
|
||||
>
|
||||
<span>{gettext("Linked")}</span>
|
||||
<% else %>
|
||||
<span class="text-base-content/70">—</span>
|
||||
<% end %>
|
||||
</.maybe_value>
|
||||
</:col>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ defmodule MvWeb.UserLive.Show do
|
|||
</.button>
|
||||
</:leading>
|
||||
{gettext("User")} {@user.email}
|
||||
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :update, @user) do %>
|
||||
<.button
|
||||
|
|
@ -60,6 +58,11 @@ defmodule MvWeb.UserLive.Show do
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div
|
||||
id="user-show-focus-root"
|
||||
phx-hook="FocusRestore"
|
||||
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||
>
|
||||
<.list>
|
||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
||||
|
|
@ -101,15 +104,9 @@ defmodule MvWeb.UserLive.Show do
|
|||
)}
|
||||
</p>
|
||||
<.button
|
||||
id="delete-user-trigger"
|
||||
variant="danger"
|
||||
phx-click="delete"
|
||||
phx-value-id={@user.id}
|
||||
data-confirm={
|
||||
gettext(
|
||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||
email: @user.email
|
||||
)
|
||||
}
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="user-delete"
|
||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||
>
|
||||
|
|
@ -119,6 +116,48 @@ defmodule MvWeb.UserLive.Show do
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||
<%= if assigns[:show_delete_modal] do %>
|
||||
<dialog
|
||||
id="delete-user-modal"
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-user-modal-title"
|
||||
phx-keydown="dialog_keydown"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
||||
<p class="py-4">
|
||||
{gettext(
|
||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||
email: @user.email
|
||||
)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_delete_modal"
|
||||
phx-mounted={JS.focus()}
|
||||
id="delete-user-modal-cancel"
|
||||
aria-label={gettext("Cancel")}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="danger"
|
||||
phx-click={JS.push("delete", value: %{id: @user.id})}
|
||||
aria-label={gettext("Delete user")}
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -139,10 +178,37 @@ defmodule MvWeb.UserLive.Show do
|
|||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Show User"))
|
||||
|> assign(:user, user)}
|
||||
|> assign(:user, user)
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_delete_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete_modal", _params, socket) do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
if socket.assigns[:show_delete_modal] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||
end
|
||||
|
||||
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
user = socket.assigns.user
|
||||
|
|
@ -150,10 +216,16 @@ defmodule MvWeb.UserLive.Show do
|
|||
|
||||
cond do
|
||||
to_string(id) != to_string(user.id) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("User not found"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
Mv.Helpers.SystemActor.system_user?(user) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("System user cannot be deleted."))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
true ->
|
||||
handle_user_delete_destroy(socket, user, actor)
|
||||
|
|
@ -170,10 +242,21 @@ defmodule MvWeb.UserLive.Show do
|
|||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
|
||||
socket
|
||||
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_ash_error(error))
|
||||
|> assign(:show_delete_modal, false)}
|
||||
end
|
||||
end
|
||||
|
||||
defp close_delete_modal_and_restore_focus(socket) do
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> push_event("focus_restore", %{id: "delete-user-trigger"})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -93,22 +93,30 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
|||
|
||||
## Returns
|
||||
|
||||
Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
|
||||
Map with `:variant`, `:icon`, and `:label` keys (and legacy `:color`), or `nil` if status is nil.
|
||||
Use `:variant` with <.badge variant={badge.variant}> for WCAG-compliant rendering.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
|
||||
%{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
|
||||
%{variant: :success, color: "badge-success", icon: "hero-check-circle", label: "Paid"}
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
|
||||
nil
|
||||
"""
|
||||
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
|
||||
%{color: String.t(), icon: String.t(), label: String.t()} | nil
|
||||
%{
|
||||
variant: :success | :error | :warning,
|
||||
color: String.t(),
|
||||
icon: String.t(),
|
||||
label: String.t()
|
||||
}
|
||||
| nil
|
||||
def format_cycle_status_badge(nil), do: nil
|
||||
|
||||
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
|
||||
%{
|
||||
variant: MembershipFeeHelpers.status_variant(status),
|
||||
color: MembershipFeeHelpers.status_color(status),
|
||||
icon: MembershipFeeHelpers.status_icon(status),
|
||||
label: format_status_label(status)
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -39,7 +39,7 @@ defmodule Mv.MixProject do
|
|||
{:tidewave, "~> 0.5", only: [:dev]},
|
||||
{:sourceror, "~> 1.8", only: [:dev, :test]},
|
||||
{:live_debugger, "~> 0.6", only: [:dev]},
|
||||
{:ash_admin, "~> 0.13"},
|
||||
{:ash_admin, "~> 0.14"},
|
||||
{:ash_postgres, "~> 2.0"},
|
||||
{:ash_phoenix, "~> 2.0"},
|
||||
{:ash, "~> 3.0"},
|
||||
|
|
|
|||
35
mix.lock
35
mix.lock
|
|
@ -1,17 +1,18 @@
|
|||
%{
|
||||
"ash": {:hex, :ash, "3.16.0", "6389927b322ca7fa7990a75730133db44fcff6368adb63f41cf9eec7a5d38862", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1ea69d932ea2ae6cc2971b92576d8ac2721218a8f2f3599e0e25305edb56949b"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
|
||||
"ash": {:hex, :ash, "3.19.1", "b5e933547d948e44d27adaed5737195488292fc2066e7fe60dd3ac83a0c4e54f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "697ac3e4fc6080cb03b1e4ee9088cb8a313a5299686ba1aa91efc86ec4028b6e"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.6.31", "2fde375f7ff5b0a4d1ec54d64089e65c4460ff08be222119e7587b820ebd782b", [:mix], [{:ash, "~> 3.15", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0f045d905fe63eb6d43313309dded5db294e437fb8e9ddcf769d4f838b9c5274"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.4.4", "7e8943b984ad416ba46d297fea6b4d2bcea25c8dfe5666e22d14c42182907798", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "19859ba3f111f1e6e4b0b9ab2f7d849e17b6b0ea5dc54811b3e2b54a7ddff5c0"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.20", "022682396892046f48dc35a137bbea9c1e4c6a6d58e71d795defd2f071c3b138", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0655a90b042a5e8873b32ba2f0b52c7c9b8da0fd415518bef41ac03a7b07e02e"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.6.32", "4bdb281bdffd69c08337396d00ffa0ee429a83b5ac3c843e3982ecfb0aae342b", [:mix], [{:ash, "~> 3.15", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "d1df73f9425bd8fbff325a21e06b4ae64a1eebdec38ed524121f2ebbbd62c971"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.4.5", "30030675ce995570fcedccd3c0671d85beff03cc0c480e7da5002842dccf0277", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "131e06e13ebcf06fc8d050267a5b29f6cc8ef6a781712e61a456f17726a64ea5"},
|
||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
|
||||
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
|
||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
|
||||
|
|
@ -20,12 +21,12 @@
|
|||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
|
||||
"ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
|
|
@ -43,7 +44,7 @@
|
|||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
|
||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||
"live_debugger": {:hex, :live_debugger, "0.6.0", "77fcbb11b1909ff6edc29a755aa5f14cb176d188b24593526b3e482be7519990", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "ad2458f4acd8b86e15b1cf7aef3304907e858f4ac35644986e5c958ea993ffb3"},
|
||||
"live_debugger": {:hex, :live_debugger, "0.6.1", "da9c9813105380c92d3318a40d47c7514b078d85b2502238788dc22aa6e2f5c2", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e0893569b427abc5e94fd43d24ddd9cbbe68e57d99364ae1efb8a6c1d597c9db"},
|
||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
|
|
@ -52,13 +53,13 @@
|
|||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.22", "9b3c985bfe38e82668594a8ce90008548f30b9f23b718ebaea4701710ce9006f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e1395d5622d8bf02113cb58183589b3da6f1751af235768816e90cc3ec5f1188"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.25", "abc1bdf7f148d7f9a003f149834cc858b24290c433b10ef6d1cbb1d6e9a211ca", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8946e474799da1f874eab7e9ce107502c96ca318ed46d19f811f847df270865"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||
|
|
@ -72,24 +73,24 @@
|
|||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
|
||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
|
||||
"spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [:mix], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"},
|
||||
"sourceror": {:hex, :sourceror, "1.11.0", "df2cdaffdc323e804009ff50b50bb31e6f2d6e116d936ccf22981f592594d624", [:mix], [], "hexpm", "6e26f572bdfc21d7ad397f596b4cfbbf31d7112126fe3e902c120947073231a8"},
|
||||
"spark": {:hex, :spark, "2.4.1", "d6807291e74b51f6efb6dd4e0d58216ae3729d45c35c456e049556e7e946e364", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "8b065733de9840cac584515f82182ac5ba66a973a47bc5036348dc740662b46b"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.7", "d6051f94f554d33d038ab3c1d7e017293ae30429cc6b267b08cb6ad69e35e9a3", [], [], "hexpm", "798ff97db02477b05fa3db8e2810cebda6ed5d90c6de6b21aa65abd577599744"},
|
||||
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
|
||||
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
|
||||
"swoosh": {:hex, :swoosh, "1.22.1", "8450ac62d0a7cb82f0765592037cab2d30cbc7801acd879f77b8f672a9b49f58", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13795cd69e137c7a6b99850b938177fa3713bd6b95e92b3bdcdb29b70e88868e"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
|
||||
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ msgid "City"
|
|||
msgstr "Stadt"
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr "Löschen"
|
||||
|
|
@ -257,9 +262,12 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
|
@ -294,7 +302,6 @@ msgid "Logout"
|
|||
msgstr "Abmelden"
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Listing Users"
|
||||
msgstr "Benutzer*innen auflisten"
|
||||
|
|
@ -381,16 +388,6 @@ msgstr "Benutzer*in speichern"
|
|||
msgid "Show User"
|
||||
msgstr "Benutzer*in anzeigen"
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This is a user record from your database."
|
||||
msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage user records in your database."
|
||||
msgstr "Verwende dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -533,6 +530,7 @@ msgstr "Suchen..."
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Users"
|
||||
msgstr "Benutzer*innen"
|
||||
|
|
@ -593,18 +591,6 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
|
|||
msgid "Custom Fields"
|
||||
msgstr "Benutzerdefinierte Felder"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} member has a value assigned for this custom field."
|
||||
msgid_plural "%{count} members have values assigned for this custom field."
|
||||
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
|
||||
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enter the text above to confirm"
|
||||
|
|
@ -790,6 +776,7 @@ msgstr "Beitragsdaten"
|
|||
msgid "Payments"
|
||||
msgstr "Zahlungen"
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1388,6 +1375,8 @@ msgid "None (no default)"
|
|||
msgstr "Keine (kein Standard)"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not set"
|
||||
msgstr "Nicht gesetzt"
|
||||
|
|
@ -1473,11 +1462,6 @@ msgstr "Art"
|
|||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr "Gib '%{confirmation}' ein, um zu bestätigen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr "Verwende dieses Formular, um Mitgliedsbeitragsarten in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
|
|
@ -1704,11 +1688,6 @@ msgstr "System-Rollen können nicht gelöscht werden."
|
|||
msgid "Toggle sidebar"
|
||||
msgstr "Sidebar umschalten"
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage roles in your database."
|
||||
msgstr "Verwende dieses Formular, um Rollen in deiner Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User menu"
|
||||
|
|
@ -2428,11 +2407,6 @@ msgstr "Alle Jahre zusammengefasst (Kreis)"
|
|||
msgid "Contributions by year"
|
||||
msgstr "Beiträge nach Jahr"
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Overview from first membership to today"
|
||||
msgstr "Übersicht vom ersten Eintritt bis heute"
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contributions by year as table with stacked bars"
|
||||
|
|
@ -2910,11 +2884,6 @@ msgstr "CSV Datei auswählen"
|
|||
msgid "Import Members"
|
||||
msgstr "Mitglieder importieren (CSV)"
|
||||
|
||||
#~ #: lib/mv_web/live/import_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Datei auswählen"
|
||||
#~ msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Admin group name"
|
||||
|
|
@ -2940,21 +2909,6 @@ msgstr "Client-ID"
|
|||
msgid "Client Secret"
|
||||
msgstr "Client-Geheimnis"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren."
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom fields"
|
||||
msgstr "Benutzerdefinierte Felder"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2996,11 +2950,6 @@ msgstr "Aus OIDC_REDIRECT_URI"
|
|||
msgid "Groups claim"
|
||||
msgstr "Gruppenclaim"
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member fields"
|
||||
msgstr "Mitgliedsfelder"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee settings"
|
||||
|
|
@ -3225,88 +3174,64 @@ msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden.
|
|||
msgid "Individual datafields"
|
||||
msgstr "Individuelle Datenfelder"
|
||||
|
||||
#~ #: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Back to Settings"
|
||||
#~ msgstr "Zurück zu den Einstellungen"
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Member"
|
||||
msgstr "Mitglied löschen"
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Cannot delete system role"
|
||||
#~ msgstr "System-Rolle kann nicht gelöscht werden"
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Role"
|
||||
msgstr "Rolle löschen"
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Click for custom field details"
|
||||
#~ msgstr "Klicke für Datenfeld-Details"
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete User"
|
||||
msgstr "Benutzer*in löschen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Click for datafield details"
|
||||
#~ msgstr "Klicke für Datenfeld-Details"
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure fee types for membership fees."
|
||||
msgstr "Verwalte Beitragsarten und Mitgliedsbeiträge."
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Coming soon"
|
||||
#~ msgstr "Demnächst verfügbar"
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure which data you want to save for your members. Define individual datafields."
|
||||
msgstr "Verwalte welche Daten du für eure Mitglieder speichern möchtest. Lege individuelle datenfelder an."
|
||||
|
||||
#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Custom Field %{id}"
|
||||
#~ msgstr "Benutzerdefiniertes Feld %{id}"
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Manage users and their permissions."
|
||||
msgstr "Verwalte Benutzer*innen und ihre Berechtigungen."
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Edit datafield"
|
||||
#~ msgstr "Datenfeld bearbeiten"
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "%{count} member has a value assigned for this datafield."
|
||||
msgid_plural "%{count} members have values assigned for this datafield."
|
||||
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
|
||||
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Edit user"
|
||||
#~ msgstr "Benutzer*in bearbeiten"
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Individual Datafields"
|
||||
msgstr "Individuelle Datenfelder"
|
||||
|
||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Reset"
|
||||
#~ msgstr "Zurücksetzen"
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No group assignment"
|
||||
msgstr "Keine Gruppenzuordnung"
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Rolle bearbeiten"
|
||||
#~ msgstr "Rolle bearbeiten"
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not specified"
|
||||
msgstr "Nicht angegeben"
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Save Role"
|
||||
#~ msgstr "Rolle speichern"
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Select all users"
|
||||
#~ msgstr "Alle Benutzer*innen auswählen"
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Select user"
|
||||
#~ msgstr "Benutzer*in auswählen"
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "System roles cannot be deleted"
|
||||
#~ msgstr "System-Rollen können nicht gelöscht werden"
|
||||
|
||||
#~ #: lib/mv_web/live/group_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "View"
|
||||
#~ msgstr "Anzeigen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "You do not have permission to access this member"
|
||||
#~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "You do not have permission to access this user"
|
||||
#~ msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ msgid "City"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
|
@ -258,9 +263,12 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
|
@ -295,7 +303,6 @@ msgid "Logout"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Listing Users"
|
||||
msgstr ""
|
||||
|
|
@ -382,16 +389,6 @@ msgstr ""
|
|||
msgid "Show User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This is a user record from your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage user records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -534,6 +531,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
|
|
@ -594,18 +592,6 @@ msgstr ""
|
|||
msgid "Custom Fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} member has a value assigned for this custom field."
|
||||
msgid_plural "%{count} members have values assigned for this custom field."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enter the text above to confirm"
|
||||
|
|
@ -791,6 +777,7 @@ msgstr ""
|
|||
msgid "Payments"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1389,6 +1376,8 @@ msgid "None (no default)"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not set"
|
||||
msgstr ""
|
||||
|
|
@ -1474,11 +1463,6 @@ msgstr ""
|
|||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
|
|
@ -1705,11 +1689,6 @@ msgstr ""
|
|||
msgid "Toggle sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage roles in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "User menu"
|
||||
|
|
@ -2429,11 +2408,6 @@ msgstr ""
|
|||
msgid "Contributions by year"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Overview from first membership to today"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contributions by year as table with stacked bars"
|
||||
|
|
@ -2935,21 +2909,6 @@ msgstr ""
|
|||
msgid "Client Secret"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2991,11 +2950,6 @@ msgstr ""
|
|||
msgid "Groups claim"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee settings"
|
||||
|
|
@ -3219,3 +3173,65 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Individual datafields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete Role"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure which data you want to save for your members. Define individual datafields."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage users and their permissions."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} member has a value assigned for this datafield."
|
||||
msgid_plural "%{count} members have values assigned for this datafield."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Individual Datafields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No group assignment"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not specified"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ msgid "City"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
|
@ -258,9 +263,12 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
|
@ -295,7 +303,6 @@ msgid "Logout"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Listing Users"
|
||||
msgstr ""
|
||||
|
|
@ -382,16 +389,6 @@ msgstr ""
|
|||
msgid "Show User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "This is a user record from your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage user records in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -534,6 +531,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
|
|
@ -594,18 +592,6 @@ msgstr ""
|
|||
msgid "Custom Fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} member has a value assigned for this custom field."
|
||||
msgid_plural "%{count} members have values assigned for this custom field."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Enter the text above to confirm"
|
||||
|
|
@ -791,6 +777,7 @@ msgstr ""
|
|||
msgid "Payments"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1389,6 +1376,8 @@ msgid "None (no default)"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Not set"
|
||||
msgstr ""
|
||||
|
|
@ -1474,11 +1463,6 @@ msgstr ""
|
|||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
|
|
@ -1705,11 +1689,6 @@ msgstr ""
|
|||
msgid "Toggle sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage roles in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "User menu"
|
||||
|
|
@ -2429,11 +2408,6 @@ msgstr ""
|
|||
msgid "Contributions by year"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Overview from first membership to today"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Contributions by year as table with stacked bars"
|
||||
|
|
@ -2935,21 +2909,6 @@ msgstr ""
|
|||
msgid "Client Secret"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2991,11 +2950,6 @@ msgstr ""
|
|||
msgid "Groups claim"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Member fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee settings"
|
||||
|
|
@ -3220,88 +3174,64 @@ msgstr ""
|
|||
msgid "Individual datafields"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Back to Settings"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Member"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Cannot delete system role"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Role"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Click for custom field details"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete User"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Click for datafield details"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Coming soon"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure which data you want to save for your members. Define individual datafields."
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Custom Field %{id}"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Manage users and their permissions."
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Edit datafield"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "%{count} member has a value assigned for this datafield."
|
||||
msgid_plural "%{count} members have values assigned for this datafield."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Edit user"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Individual Datafields"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Reset"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No group assignment"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Rolle bearbeiten"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not specified"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Save Role"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Select all users"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Select user"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/role_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "System roles cannot be deleted"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/group_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "View"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "You do not have permission to access this member"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "You do not have permission to access this user"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||
msgstr ""
|
||||
|
|
|
|||
91
test/mv_web/components/core_components_badge_test.exs
Normal file
91
test/mv_web/components/core_components_badge_test.exs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule MvWeb.Components.CoreComponentsBadgeTest do
|
||||
@moduledoc """
|
||||
Unit tests for the Core Components badge (WCAG-compliant, non-transparent).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.Component
|
||||
import Phoenix.LiveViewTest
|
||||
import MvWeb.CoreComponents
|
||||
|
||||
describe "badge/1" do
|
||||
test "default variant renders with badge and badge-neutral classes (visible, not ghost)" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="neutral">Label</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge"
|
||||
assert html =~ "badge-neutral"
|
||||
assert html =~ "badge-soft"
|
||||
refute html =~ "badge-ghost"
|
||||
assert html =~ "Label"
|
||||
end
|
||||
|
||||
test "success variant renders badge-success and badge-soft" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="success">Paid</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge-success"
|
||||
assert html =~ "badge-soft"
|
||||
assert html =~ "Paid"
|
||||
end
|
||||
|
||||
test "outline style includes bg-base-100 for contrast" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="primary" style="outline">Outline</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge-outline"
|
||||
assert html =~ "bg-base-100"
|
||||
assert html =~ "Outline"
|
||||
end
|
||||
|
||||
test "solid style has no badge-soft or badge-outline" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="error" style="solid">Error</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge-error"
|
||||
refute html =~ "badge-soft"
|
||||
refute html =~ "badge-outline"
|
||||
assert html =~ "Error"
|
||||
end
|
||||
|
||||
test "size sm adds badge-sm" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="neutral" size="sm">Small</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ "badge-sm"
|
||||
assert html =~ "Small"
|
||||
end
|
||||
|
||||
test "renders as span (non-interactive)" do
|
||||
assigns = %{}
|
||||
|
||||
html =
|
||||
rendered_to_string(~H"""
|
||||
<.badge variant="info">Info</.badge>
|
||||
""")
|
||||
|
||||
assert html =~ ~r/<span[^>]*class="[^"]*badge[^"]*"/
|
||||
refute html =~ ~r/<button/
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -254,6 +254,14 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "status_variant/1" do
|
||||
test "returns badge variant for <.badge> (suspended uses warning to match edit button)" do
|
||||
assert MembershipFeeHelpers.status_variant(:paid) == :success
|
||||
assert MembershipFeeHelpers.status_variant(:unpaid) == :error
|
||||
assert MembershipFeeHelpers.status_variant(:suspended) == :warning
|
||||
end
|
||||
end
|
||||
|
||||
describe "status_color/1" do
|
||||
test "returns correct color classes for statuses" do
|
||||
assert MembershipFeeHelpers.status_color(:paid) == "badge-success"
|
||||
|
|
|
|||
|
|
@ -46,15 +46,19 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
%{conn: conn, user: user_with_role}
|
||||
end
|
||||
|
||||
# Delete is in the edit form (FormComponent); open form by clicking the name cell (unique td with phx-click)
|
||||
# Delete is in the edit form (FormComponent). First row click opens form (overview) or switches
|
||||
# to edit-mode (new component shows table). If delete button is visible, click it; else click row
|
||||
# again to open the form, then click delete.
|
||||
defp open_delete_modal(view, custom_field) do
|
||||
view
|
||||
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|
||||
|> render_click()
|
||||
row_selector = "tr#custom_fields-#{custom_field.id} td"
|
||||
view |> element(row_selector, custom_field.name) |> render_click()
|
||||
|
||||
view
|
||||
|> element("[data-testid=custom-field-delete]")
|
||||
|> render_click()
|
||||
if has_element?(view, "[data-testid=custom-field-delete]") do
|
||||
view |> element("[data-testid=custom-field-delete]") |> render_click()
|
||||
else
|
||||
view |> element(row_selector, custom_field.name) |> render_click()
|
||||
view |> element("[data-testid=custom-field-delete]") |> render_click()
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete button and modal" do
|
||||
|
|
@ -71,8 +75,12 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
# Modal should be visible
|
||||
assert has_element?(view, "#delete-custom-field-modal")
|
||||
|
||||
# Edit mode: section titles must not reappear when modal opens (regression)
|
||||
refute has_element?(view, "h2", "Member fields")
|
||||
refute has_element?(view, "h2", "Custom fields")
|
||||
|
||||
# Should show correct member count (1 member)
|
||||
assert render(view) =~ "1 member has a value assigned for this custom field"
|
||||
assert render(view) =~ "1 member has a value assigned for this datafield"
|
||||
|
||||
# Should show the slug
|
||||
assert render(view) =~ custom_field.slug
|
||||
|
|
@ -91,7 +99,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
open_delete_modal(view, custom_field)
|
||||
|
||||
# Should show plural form
|
||||
assert render(view) =~ "2 members have values assigned for this custom field"
|
||||
assert render(view) =~ "2 members have values assigned for this datafield"
|
||||
end
|
||||
|
||||
test "shows 0 members for custom field without values", %{conn: conn} do
|
||||
|
|
@ -101,7 +109,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
open_delete_modal(view, custom_field)
|
||||
|
||||
# Should show 0 members
|
||||
assert render(view) =~ "0 members have values assigned for this custom field"
|
||||
assert render(view) =~ "0 members have values assigned for this datafield"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,21 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "edit mode visibility" do
|
||||
test "clicking member field row shows only form, no section titles", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||
|
||||
# Row click is on the first td (no col_click); click that cell to open edit form
|
||||
view
|
||||
|> element("tr#member_field-first_name td:first-child")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#member-field-form-first_name")
|
||||
refute has_element?(view, "h2", "Custom fields")
|
||||
refute has_element?(view, "h2", "Member fields")
|
||||
end
|
||||
end
|
||||
|
||||
describe "required fields" do
|
||||
setup do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
|
|
|||
|
|
@ -386,11 +386,16 @@ defmodule MvWeb.RoleLiveTest do
|
|||
|
||||
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
|
||||
|
||||
# Delete from Danger zone on show page
|
||||
# Open delete modal from Danger zone
|
||||
view
|
||||
|> element("[data-testid=role-delete]")
|
||||
|> render_click()
|
||||
|
||||
# Confirm deletion in modal
|
||||
view
|
||||
|> element("[data-testid=role-delete-confirm]")
|
||||
|> render_click()
|
||||
|
||||
assert_redirect(view, "/admin/roles")
|
||||
|
||||
# Verify deletion by checking database
|
||||
|
|
|
|||
|
|
@ -29,9 +29,8 @@ defmodule MvWeb.StatisticsLiveTest do
|
|||
test "page shows overview of all relevant years without year selector", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/statistics")
|
||||
|
||||
# No year dropdown: single select for year should not be present as main control
|
||||
assert html =~ "Overview" or html =~ "overview"
|
||||
# table header or legend
|
||||
# Page shows multi-year data (member numbers by year) and year column; no single-year selector as main control
|
||||
assert html =~ "Member numbers by year"
|
||||
assert html =~ "Year"
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,20 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
|
|||
assert html =~ member3.first_name
|
||||
end
|
||||
|
||||
test "empty group cell is visually empty with sr-only text (no dash)", %{
|
||||
conn: conn,
|
||||
member3: member3
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
assert html =~ member3.first_name
|
||||
# Screen reader gets a meaningful label for the empty cell
|
||||
assert html =~ "sr-only"
|
||||
assert html =~ "No group assignment"
|
||||
# No visible dash as placeholder (Design Guidelines §8.6)
|
||||
refute html =~ ~r/<span[^>]*class="[^"]*text-base-content\/50[^"]*"[^>]*>—<\/span>/
|
||||
end
|
||||
|
||||
test "displays group name correctly in badge", %{conn: conn, group1: group1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
|
|
|||
|
|
@ -123,13 +123,17 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
{:ok, index_view, _html} = live(conn, "/users")
|
||||
assert render(index_view) =~ "delete-me@example.com"
|
||||
|
||||
# Navigate to user show and trigger delete from Danger zone
|
||||
# Navigate to user show, open delete modal, then confirm in modal (WCAG modal pattern)
|
||||
{:ok, show_view, _html} = live(conn, "/users/#{user.id}")
|
||||
|
||||
show_view
|
||||
|> element("[data-testid=user-delete]")
|
||||
|> render_click()
|
||||
|
||||
show_view
|
||||
|> element("#delete-user-modal button", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Should redirect to index
|
||||
assert_redirect(show_view, "/users")
|
||||
|
||||
|
|
@ -206,7 +210,9 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "Password column display" do
|
||||
test "user without password shows em dash in Password column", %{conn: conn} do
|
||||
test "user without password shows empty cell with sr-only text in Password column", %{
|
||||
conn: conn
|
||||
} do
|
||||
# User created with hashed_password: nil (no password) - must not get default password
|
||||
user_no_pw =
|
||||
create_test_user(%{
|
||||
|
|
@ -219,9 +225,13 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
assert html =~ "no-password@example.com"
|
||||
|
||||
# Password column must show "—" (em dash) for user without password, not "Enabled"
|
||||
# Password column: visually empty, screen-reader gets "Not set" (Design Guidelines §8.6)
|
||||
row = view |> element("tr#row-#{user_no_pw.id}") |> render()
|
||||
assert row =~ "—", "Password column should show em dash for user without password"
|
||||
assert row =~ "sr-only", "Password column should have sr-only text for accessibility"
|
||||
assert row =~ "Not set", "Screen reader should get 'Not set' for empty password"
|
||||
|
||||
refute row =~ "—",
|
||||
"Password column must not show dash (use empty cell + sr-only per CODE_GUIDELINES §8)"
|
||||
|
||||
refute row =~ "Enabled",
|
||||
"Password column must not show Enabled when user has no password"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue