Compare commits

..

31 commits

Author SHA1 Message Date
7cff550749
i18n: Update POT and English translations
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-13 14:59:18 +01:00
f18980e800
refactor: Reduce nesting depth in UserLive.Form.load_members_for_linking 2026-01-13 14:59:12 +01:00
a6fd5e1c1e
fix: Replace Ash.read! with error handling in CustomFieldValueLive.Index
- Replace Ash.read! with Ash.read and proper error handling in mount/3
2026-01-13 14:58:59 +01:00
c1b0336f01
fix: Correct Language headers in German .po files 2026-01-13 14:58:52 +01:00
4aa429e3cb ci: Add check for empty German translations in lint task
All checks were successful
continuous-integration/drone/push Build is passing
- Check that all German msgstr entries are filled (excluding header)
- Use awk to filter out header msgstr "" entries
- Fail lint if any empty translations are found
2026-01-13 14:05:53 +01:00
4c55ced536 i18n: Add German and English translations for UI strings
- Fill in empty msgstr entries in German translations
- Add translations for user actions, error messages, and form labels
- Ensure all UI strings are consistently translated
2026-01-13 14:05:51 +01:00
51e7c457ae refactor: Simplify UserLive.Form handle_event and improve error handling
- Extract handle_member_linking, perform_member_link_action helpers
- Extract handle_save_success, get_action_name, handle_member_link_error
- Replace hardcoded strings with gettext translations
- Use submit_form wrapper for consistent actor handling
- Group all handle_event/3 clauses together
- Add early return in load_members_for_linking if actor is nil
2026-01-13 14:05:49 +01:00
6d10b9799d refactor: Replace bang calls with error handling in Index LiveViews
- Replace Ash.get!/Ash.destroy! with Ash.get/Ash.destroy
- Add case statements for Forbidden, NotFound, and generic errors
- Display user-friendly flash messages for all error cases
- Use Enum.map_join/3 for efficient error formatting
2026-01-13 14:05:46 +01:00
6c94146aca refactor: Use submit_form wrapper in all LiveView forms
- Replace AshPhoenix.Form.submit with submit_form/3 wrapper
- Import current_actor and submit_form from LiveHelpers
- Consistent actor handling in all form submissions
2026-01-13 14:05:44 +01:00
7b28b03cd4 refactor: Replace actor option patterns with ash_actor_opts helper
- Replace if actor, do: [actor: actor], else: [] with Mv.Helpers.ash_actor_opts/1
- Update email_sync/loader.ex, member validations, member.ex, cycle_generator.ex
- Consistent actor handling across non-LiveView modules
2026-01-13 14:05:41 +01:00
fdbe673a65 feat: Add shared helper functions for actor handling
- Add Mv.Helpers module with ash_actor_opts/1 helper
- Add current_actor/1 with @spec to LiveHelpers
- Add ash_actor_opts/1 delegate and submit_form/3 wrapper to LiveHelpers
- Standardize actor access pattern across LiveViews
2026-01-13 14:05:39 +01:00
295d711768 test: Add role tag support to ConnCase and fix test issues
- Add role tag support (@tag role: :admin/:member/:unauthenticated) to ConnCase
- Fix Keyword.get -> Map.get for tags Map
- Remove duplicate test file index_display_name_test.exs
- Fix CustomField creation in tests (remove slug, use :string instead of :text)
- Fix CustomFieldValue value format to use _union_type/_union_value
2026-01-13 14:05:36 +01:00
7c821193fc Fix error handling and actor access in MemberLive.Index
All checks were successful
continuous-integration/drone/push Build is passing
Replace bang calls with proper error handling and use current_actor/1
helper for consistent actor access.
2026-01-09 05:26:11 +01:00
b0edd25da5 Pass actor parameter in seeds and update test setup
Ensure cycle generation in seeds uses admin actor and update test
to use global admin_user from ConnCase setup.
2026-01-09 05:26:09 +01:00
b409107350 Add missing Gettext translations for member deletion errors
Add German and English translations for member deletion success and
error messages.
2026-01-09 05:26:08 +01:00
5b9830a6cc Use current_actor/1 helper in all LiveViews
Replace inconsistent actor access patterns with current_actor/1 helper
and ensure actor is passed to all Ash operations for proper authorization.
2026-01-09 05:26:06 +01:00
3778c5259c Pass actor parameter to member email validation
Extract actor from changeset context and pass it to Ash.read and
Ash.load calls in email uniqueness validation.
2026-01-09 05:26:04 +01:00
e27bfcb851 Pass actor parameter through email sync operations
Extract actor from changeset context and pass it to all email sync
loader functions to ensure proper authorization when loading linked
users and members.
2026-01-09 05:26:02 +01:00
c65124c8f4 Pass actor parameter through cycle generation
Extract actor from changeset context in Member hooks and pass it
through all cycle generation functions to ensure proper authorization.
2026-01-09 05:26:01 +01:00
57bcef17ca Add current_actor/1 helper for consistent actor access
Provides a single function to access current_user from socket assigns
across all LiveViews, ensuring consistent access pattern.
2026-01-09 05:25:59 +01:00
23f00d2cba
Refactor test setup: use global setup and fix MembershipFees domain alias
All checks were successful
continuous-integration/drone/push Build is passing
- Remove redundant setup blocks from member_live tests
- Add build_unauthenticated_conn helper for AuthController tests
- Add global setup in conn_case.ex
2026-01-09 02:25:30 +01:00
c544cdc07c
Integrate Member policies in LiveViews
- Add on_mount hook to ensure user role is loaded in all Member LiveViews
- Pass actor parameter to all Ash operations (read, get, create, update, destroy, load)
2026-01-09 00:03:58 +01:00
a42fc8a6eb Fix: Update comment in auto_filter to reflect expr(false) usage
Update comment from 'id IN [] = never matches' to 'expr(false) = match none'
to match the actual implementation of deny_filter().
2026-01-08 23:34:04 +01:00
dd4b88f0b7
Improve: Make deny_filter robust and add regression test
All checks were successful
continuous-integration/drone/push Build is passing
- Change deny_filter from [id: {:in, []}] to expr(false)
- Add regression test to ensure deny-filter matches 0 records
2026-01-08 23:30:06 +01:00
05cbd833bc
Security: Fix critical deny-filter bug and improve authorization
All checks were successful
continuous-integration/drone/push Build is passing
CRITICAL FIX: Deny-filter was allowing all records instead of denying
Fix: User validation in Member now uses actor from changeset.context
2026-01-08 23:15:48 +01:00
e5eb3b7e89
Docs: Correct :linked scope documentation
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-08 22:56:34 +01:00
e8293457a3
Fix: Seeds use admin actor instead of NoActor bypass
This ensures seeds work correctly with the new fail-closed NoActor
policy in production, using proper authorization instead of bypass.
2026-01-08 22:56:21 +01:00
236eb53a24
Refactor: NoActor to SimpleCheck with compile-time environment check
This prevents security issues where :create/:read without actor would
be allowed in production. Now all operations require an actor in production.
2026-01-08 22:55:45 +01:00
6cd18545bd
Fix: HasPermission auto_filter and strict_check implementation
Fixes security issue where auto_filter returned nil instead of proper
filter expressions, which could lead to incorrect authorization behavior.
2026-01-08 22:55:24 +01:00
9d58c9d1ef
feat: implement authorization policies for Member resource
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-08 21:22:45 +01:00
f7cda66598
test: add Member resource policy tests 2026-01-08 21:22:15 +01:00
52 changed files with 1505 additions and 9028 deletions

View file

@ -99,213 +99,4 @@
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session] { display: contents }
/* ============================================
Sidebar Base Styles
============================================ */
/* Desktop Sidebar Base */
.sidebar {
@apply flex flex-col bg-base-200 min-h-screen;
@apply transition-[width] duration-300 ease-in-out;
@apply relative;
width: 16rem; /* Expanded: w-64 */
z-index: 40;
}
/* Collapsed State */
[data-sidebar-expanded="false"] .sidebar {
width: 4rem; /* Collapsed: w-16 */
}
/* ============================================
Header - Logo Centering
============================================ */
/* Header container with smooth transition for gap */
.sidebar > div:first-child {
@apply transition-all duration-300;
}
/* ============================================
Text Labels - Hide in Collapsed State
============================================ */
.menu-label {
@apply transition-all duration-200 whitespace-nowrap;
transition-delay: 0ms; /* Expanded: sofort sichtbar */
}
[data-sidebar-expanded="false"] .sidebar .menu-label {
@apply opacity-0 w-0 overflow-hidden pointer-events-none;
transition-delay: 300ms; /* Warte bis Sidebar eingeklappt ist (300ms = duration der Sidebar width transition) */
}
/* ============================================
Toggle Button Icon Swap
============================================ */
.sidebar-collapsed-icon {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .sidebar-expanded-icon {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .sidebar-collapsed-icon {
@apply block;
}
/* ============================================
Menu Groups - Show/Hide Based on State
============================================ */
.expanded-menu-group {
@apply block;
}
.collapsed-menu-group {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .expanded-menu-group {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .collapsed-menu-group {
@apply block;
}
/* Collapsed menu group button: center icon under logo */
.sidebar .collapsed-menu-group button {
padding-left: 14px;
}
/* ============================================
Elements Only Visible in Expanded State
============================================ */
.expanded-only {
@apply block transition-opacity duration-200;
}
[data-sidebar-expanded="false"] .sidebar .expanded-only {
@apply hidden;
}
/* ============================================
Tooltip - Only Show in Collapsed State
============================================ */
.sidebar .tooltip::before,
.sidebar .tooltip::after {
@apply opacity-0 pointer-events-none;
}
[data-sidebar-expanded="false"] .sidebar .tooltip:hover::before,
[data-sidebar-expanded="false"] .sidebar .tooltip:hover::after {
@apply opacity-100;
}
/* ============================================
Menu Item Alignment - Icons Centered Under Logo
============================================ */
/* Base alignment: Icons centered under logo (32px from left edge)
- Logo center: 16px padding + 16px (half of 32px) = 32px
- Icon center should be at 32px: 22px start + 10px (half of 20px) = 32px
- Menu has p-2 (8px), so links need 14px additional padding-left */
.sidebar .menu > li > a,
.sidebar .menu > li > button {
@apply transition-all duration-300;
padding-left: 14px;
}
/* Collapsed state: same padding to keep icons at same position
- Remove gap so label (which is opacity-0 w-0) doesn't create space
- Keep padding-left at 14px so icons stay centered under logo */
[data-sidebar-expanded="false"] .sidebar .menu > li > a,
[data-sidebar-expanded="false"] .sidebar .menu > li > button {
@apply gap-0;
padding-left: 14px;
padding-right: 14px; /* Center icon horizontally in 64px sidebar */
}
/* ============================================
Footer Button Alignment - Left Aligned in Collapsed State
============================================ */
[data-sidebar-expanded="false"] .sidebar .dropdown > button {
@apply px-0;
/* Buttons stay at left position, only label disappears */
}
/* ============================================
User Menu Button - Focus Ring on Avatar
============================================ */
/* Focus ring appears on the avatar when button is focused */
.user-menu-button:focus .avatar > div {
@apply ring-2 ring-primary ring-offset-2 ring-offset-base-200;
}
/* ============================================
User Menu Button - Smooth Centering Transition
============================================ */
/* User menu button transitions smoothly to center */
.user-menu-button {
@apply transition-all duration-300;
}
/* In collapsed state, center avatar under logo
- Avatar is 32px (w-8), center it in 64px sidebar
- (64px - 32px) / 2 = 16px padding avatar center at 32px (same as logo center) */
[data-sidebar-expanded="false"] .sidebar .user-menu-button {
@apply gap-0;
padding-left: 16px;
padding-right: 16px;
justify-content: center;
}
/* ============================================
User Menu Button - Hover Ring on Avatar
============================================ */
/* Smooth transition for avatar ring effects */
.user-menu-button .avatar > div {
@apply transition-all duration-200;
}
/* Hover ring appears on the avatar when button is hovered */
.user-menu-button:hover .avatar > div {
@apply ring-1 ring-neutral ring-offset-1 ring-offset-base-200;
}
/* ============================================
Mobile Drawer Width
============================================ */
/* Auf Mobile (< 1024px) ist die Sidebar immer w-64 (16rem) wenn geöffnet */
@media (max-width: 1023px) {
.drawer-side .sidebar {
width: 16rem; /* w-64 auch auf Mobile */
}
}
/* ============================================
Drawer Side Overflow Fix für Desktop
============================================ */
/* Im Desktop-Modus (lg:drawer-open) overflow auf visible setzen
damit Dropdowns und Tooltips über Main Content erscheinen können */
@media (min-width: 1024px) {
.drawer.lg\:drawer-open .drawer-side {
overflow: visible !important;
overflow-x: visible !important;
overflow-y: visible !important;
}
}
/* This file is for your main application CSS */

View file

@ -73,43 +73,6 @@ Hooks.ComboBox = {
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = {
mounted() {
// Restore state from localStorage
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
this.setSidebarState(expanded)
// Expose toggle function globally
window.toggleSidebar = () => {
const current = this.el.dataset.sidebarExpanded === 'true'
this.setSidebarState(!current)
}
},
setSidebarState(expanded) {
// Convert boolean to string for consistency
const expandedStr = expanded ? 'true' : 'false'
// Update data-attribute (CSS reacts to this)
this.el.dataset.sidebarExpanded = expandedStr
// Persist to localStorage
localStorage.setItem('sidebar-expanded', expandedStr)
// Update ARIA for accessibility
const toggleBtn = document.getElementById('sidebar-toggle')
if (toggleBtn) {
toggleBtn.setAttribute('aria-expanded', expandedStr)
}
},
destroyed() {
// Cleanup
delete window.toggleSidebar
}
}
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
@ -139,170 +102,3 @@ liveSocket.connect()
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// Sidebar accessibility improvements
document.addEventListener("DOMContentLoaded", () => {
const drawerToggle = document.getElementById("mobile-drawer")
const sidebarToggle = document.getElementById("sidebar-toggle")
const sidebar = document.getElementById("main-sidebar")
if (!drawerToggle || !sidebarToggle || !sidebar) return
// Manage tabindex for sidebar elements based on open/closed state
const updateSidebarTabIndex = (isOpen) => {
// Find all potentially focusable elements (including those with tabindex="-1")
const allFocusableElements = sidebar.querySelectorAll(
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
)
allFocusableElements.forEach(el => {
// Skip the overlay button
if (el.closest('.drawer-overlay')) return
if (isOpen) {
// Remove tabindex="-1" to make focusable when open
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') === '-1') {
el.removeAttribute('tabindex')
}
} else {
// Set tabindex="-1" to remove from tab order when closed
if (!el.hasAttribute('tabindex')) {
el.setAttribute('tabindex', '-1')
} else if (el.getAttribute('tabindex') !== '-1') {
// Store original tabindex in data attribute before setting to -1
if (!el.hasAttribute('data-original-tabindex')) {
el.setAttribute('data-original-tabindex', el.getAttribute('tabindex'))
}
el.setAttribute('tabindex', '-1')
}
}
})
}
// Find first focusable element in sidebar
// Priority: first navigation link (menuitem) > other links > other focusable elements
const getFirstFocusableElement = () => {
// First, try to find the first navigation link (menuitem)
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]:not([tabindex="-1"])')
if (firstNavLink && !firstNavLink.closest('.drawer-overlay')) {
return firstNavLink
}
// Fallback: any navigation link
const firstLink = sidebar.querySelector('a[href]:not([tabindex="-1"])')
if (firstLink && !firstLink.closest('.drawer-overlay')) {
return firstLink
}
// Last resort: any other focusable element
const focusableSelectors = [
'button:not([tabindex="-1"]):not([disabled])',
'select:not([tabindex="-1"]):not([disabled])',
'input:not([tabindex="-1"]):not([disabled]):not([type="hidden"])',
'[tabindex]:not([tabindex="-1"])'
]
for (const selector of focusableSelectors) {
const element = sidebar.querySelector(selector)
if (element && !element.closest('.drawer-overlay')) {
return element
}
}
return null
}
// Update aria-expanded when drawer state changes
const updateAriaExpanded = () => {
const isOpen = drawerToggle.checked
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
// Update dropdown aria-expanded if present
const userMenuButton = sidebar.querySelector('button[aria-haspopup="true"]')
if (userMenuButton) {
const dropdown = userMenuButton.closest('.dropdown')
const isDropdownOpen = dropdown?.classList.contains('dropdown-open')
if (userMenuButton) {
userMenuButton.setAttribute("aria-expanded", (isDropdownOpen || false).toString())
}
}
}
// Listen for changes to the drawer checkbox
drawerToggle.addEventListener("change", () => {
const isOpen = drawerToggle.checked
updateAriaExpanded()
updateSidebarTabIndex(isOpen)
if (!isOpen) {
// When closing, return focus to toggle button
sidebarToggle.focus()
}
})
// Update on initial load
updateAriaExpanded()
updateSidebarTabIndex(drawerToggle.checked)
// Close sidebar with ESC key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerToggle.checked) {
drawerToggle.checked = false
updateAriaExpanded()
updateSidebarTabIndex(false)
// Return focus to toggle button
sidebarToggle.focus()
}
})
// Improve keyboard navigation for sidebar toggle
sidebarToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
const wasOpen = drawerToggle.checked
drawerToggle.checked = !drawerToggle.checked
updateAriaExpanded()
// If opening, move focus to first element in sidebar
if (!wasOpen && drawerToggle.checked) {
updateSidebarTabIndex(true)
// Use setTimeout to ensure DOM is updated
setTimeout(() => {
const firstElement = getFirstFocusableElement()
if (firstElement) {
firstElement.focus()
}
}, 50)
} else if (wasOpen && !drawerToggle.checked) {
updateSidebarTabIndex(false)
}
}
})
// Also handle click events to update tabindex and focus
sidebarToggle.addEventListener("click", () => {
setTimeout(() => {
const isOpen = drawerToggle.checked
updateSidebarTabIndex(isOpen)
if (isOpen) {
const firstElement = getFirstFocusableElement()
if (firstElement) {
firstElement.focus()
}
}
}, 50)
})
// Handle dropdown keyboard navigation
const userMenuButton = sidebar?.querySelector('button[aria-haspopup="true"]')
if (userMenuButton) {
userMenuButton.addEventListener("click", () => {
setTimeout(updateAriaExpanded, 0)
})
userMenuButton.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
userMenuButton.click()
}
})
}
})

View file

@ -10,7 +10,11 @@ services:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: mv_dev
volumes:
- postgres-data:/var/lib/postgresql/data
- type: volume
source: postgres-data
target: /var/lib/postgresql/data
volume:
nocopy: true
ports:
- "5000:5432"
networks:
@ -45,7 +49,9 @@ services:
- rauthy-dev
- local
volumes:
- rauthy-data:/app/data
- type: volume
source: rauthy-data
target: /app/data
volumes:
postgres-data:

View file

@ -191,26 +191,6 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
- `/templates/member_import_de.csv`
- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version).
**Example Usage in LiveView Templates:**
```heex
<!-- Using ~p sigil (Phoenix 1.7+) -->
<.link href={~p"/templates/member_import_en.csv"} download>
<%= gettext("Download English Template") %>
</.link>
<.link href={~p"/templates/member_import_de.csv"} download>
<%= gettext("Download German Template") %>
</.link>
<!-- Alternative: Using Routes.static_path/2 -->
<.link href={Routes.static_path(MvWeb.Endpoint, "/templates/member_import_en.csv")} download>
<%= gettext("Download English Template") %>
</.link>
```
**Note:** The `templates` directory must be included in `MvWeb.static_paths()` (configured in `lib/mv_web.ex`) for the files to be served.
### File Limits
- **Max file size:** 10 MB

View file

@ -1,533 +0,0 @@
# DaisyUI Drawer Pattern - Standard Implementation
This document describes the standard DaisyUI drawer pattern for implementing responsive sidebars. It covers mobile overlay drawers, desktop persistent sidebars, and their combination.
## Core Concept
DaisyUI's drawer component uses a **checkbox-based toggle mechanism** combined with CSS to create accessible, responsive sidebars without custom JavaScript.
### Key Components
1. **`drawer`** - Container element
2. **`drawer-toggle`** - Hidden checkbox that controls open/close state
3. **`drawer-content`** - Main content area
4. **`drawer-side`** - Sidebar content (menu, navigation)
5. **`drawer-overlay`** - Optional overlay for mobile (closes drawer on click)
## HTML Structure
```html
<div class="drawer">
<!-- Hidden checkbox controls the drawer state -->
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<!-- Main content area -->
<div class="drawer-content">
<!-- Page content goes here -->
<label for="my-drawer" class="btn btn-primary">Open drawer</label>
</div>
<!-- Sidebar content -->
<div class="drawer-side">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-4 w-80 min-h-full bg-base-200 text-base-content">
<!-- Sidebar content goes here -->
<li><a>Sidebar Item 1</a></li>
<li><a>Sidebar Item 2</a></li>
</ul>
</div>
</div>
```
## How drawer-toggle Works
### Mechanism
The `drawer-toggle` is a **hidden checkbox** that serves as the state controller:
```html
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
```
### Toggle Behavior
1. **Label Connection**: Any `<label for="my-drawer">` element can toggle the drawer
2. **Checkbox State**:
- `checked` → drawer is open
- `unchecked` → drawer is closed
3. **CSS Targeting**: DaisyUI uses CSS sibling selectors to show/hide the drawer based on checkbox state
4. **Accessibility**: Native checkbox provides keyboard accessibility (Space/Enter to toggle)
### Toggle Examples
```html
<!-- Button to open drawer -->
<label for="my-drawer" class="btn btn-primary drawer-button">
Open Menu
</label>
<!-- Close button inside drawer -->
<label for="my-drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></label>
<!-- Overlay to close (click outside) -->
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
```
## Mobile Drawer (Overlay)
### Characteristics
- Drawer slides in from the side (usually left)
- Overlays the main content
- Dark overlay (drawer-overlay) behind drawer
- Clicking overlay closes the drawer
- Typically used on mobile/tablet screens
### Implementation
```html
<div class="drawer">
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Toggle button in header -->
<div class="navbar bg-base-100">
<div class="flex-none">
<label for="mobile-drawer" class="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">My App</a>
</div>
</div>
<!-- Main content -->
<div class="p-4">
<h1>Main Content</h1>
</div>
</div>
<div class="drawer-side">
<!-- Overlay - clicking it closes the drawer -->
<label for="mobile-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar menu -->
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Home</a></li>
<li><a>About</a></li>
<li><a>Contact</a></li>
</ul>
</div>
</div>
```
### Styling Notes
- **Width**: Default `w-80` (320px), adjust with Tailwind width utilities
- **Background**: Use DaisyUI color classes like `bg-base-200`
- **Height**: Always use `min-h-full` to ensure full height
- **Padding**: Add `p-4` or similar for inner spacing
## Desktop Sidebar (Persistent)
### Characteristics
- Always visible (no overlay)
- Does not overlay main content
- Main content adjusts to sidebar width
- No toggle button needed
- Used on desktop screens
### Implementation with drawer-open
```html
<div class="drawer lg:drawer-open">
<input id="desktop-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Main content -->
<div class="p-4">
<h1>Main Content</h1>
<p>The sidebar is always visible on desktop (lg and above)</p>
</div>
</div>
<div class="drawer-side">
<!-- No overlay needed for persistent sidebar -->
<label for="desktop-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar menu -->
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Dashboard</a></li>
<li><a>Settings</a></li>
<li><a>Profile</a></li>
</ul>
</div>
</div>
```
### How drawer-open Works
The `drawer-open` class forces the drawer to be **permanently open**:
```html
<div class="drawer drawer-open">
```
- Drawer is always visible
- Cannot be toggled closed
- `drawer-toggle` checkbox is ignored
- `drawer-overlay` is not shown
- Main content automatically shifts to accommodate sidebar width
### Responsive Usage
Use Tailwind breakpoint modifiers for responsive behavior:
```html
<!-- Open on large screens and above -->
<div class="drawer lg:drawer-open">
<!-- Open on medium screens and above -->
<div class="drawer md:drawer-open">
<!-- Open on extra-large screens and above -->
<div class="drawer xl:drawer-open">
```
## Combined Mobile + Desktop Pattern (Recommended)
This is the **most common pattern** for responsive applications: mobile overlay + desktop persistent.
### Complete Implementation
```html
<div class="drawer lg:drawer-open">
<!-- Checkbox for mobile toggle -->
<input id="app-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<!-- Navbar with mobile menu button -->
<div class="navbar bg-base-100 lg:hidden">
<div class="flex-none">
<label for="app-drawer" class="btn btn-square btn-ghost">
<!-- Hamburger icon -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">My App</a>
</div>
</div>
<!-- Main content -->
<div class="flex-1 p-6">
<h1 class="text-3xl font-bold mb-4">Welcome</h1>
<p>This is the main content area.</p>
<p>On mobile (< lg): sidebar is hidden, hamburger menu visible</p>
<p>On desktop (≥ lg): sidebar is persistent, hamburger menu hidden</p>
</div>
</div>
<div class="drawer-side">
<!-- Overlay only shows on mobile -->
<label for="app-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar navigation -->
<aside class="bg-base-200 w-80 min-h-full">
<!-- Logo/Header area -->
<div class="p-4 font-bold text-xl border-b border-base-300">
My App Logo
</div>
<!-- Navigation menu -->
<ul class="menu p-4">
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Documents
</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a></li>
</ul>
</aside>
</div>
</div>
```
### Behavior Breakdown
#### On Mobile (< 1024px / < lg)
1. Sidebar is hidden by default
2. Hamburger button visible in navbar
3. Clicking hamburger opens sidebar as overlay
4. Clicking overlay or close button closes sidebar
5. Sidebar slides in from left with animation
#### On Desktop (≥ 1024px / ≥ lg)
1. `lg:drawer-open` keeps sidebar permanently visible
2. Hamburger button hidden via `lg:hidden`
3. Sidebar takes up fixed width (320px)
4. Main content area adjusts automatically
5. No overlay, no toggle needed
## Tailwind Breakpoints Reference
```css
/* Default (mobile-first) */
/* < 640px */
sm: /* ≥ 640px */
md: /* ≥ 768px */
lg: /* ≥ 1024px */ ← Common desktop breakpoint
xl: /* ≥ 1280px */
2xl: /* ≥ 1536px */
```
## Key Classes Summary
| Class | Purpose |
|-------|---------|
| `drawer` | Main container |
| `drawer-toggle` | Hidden checkbox for state control |
| `drawer-content` | Main content area |
| `drawer-side` | Sidebar container |
| `drawer-overlay` | Clickable overlay (closes drawer) |
| `drawer-open` | Forces drawer to stay open |
| `drawer-end` | Positions drawer on the right side |
| `lg:drawer-open` | Opens drawer on large screens only |
## Positioning Variants
### Left Side Drawer (Default)
```html
<div class="drawer">
<!-- Drawer appears on the left -->
</div>
```
### Right Side Drawer
```html
<div class="drawer drawer-end">
<!-- Drawer appears on the right -->
</div>
```
## Best Practices
### 1. Accessibility
- Always include `aria-label` on overlay: `<label for="drawer" aria-label="close sidebar" class="drawer-overlay"></label>`
- Use semantic HTML (`<nav>`, `<aside>`)
- Ensure keyboard navigation works (native checkbox provides this)
### 2. Responsive Design
- Use `lg:drawer-open` for desktop persistence
- Hide mobile toggle button on desktop: `lg:hidden`
- Adjust sidebar width for mobile if needed: `w-64 md:w-80`
### 3. Performance
- DaisyUI drawer is pure CSS (no JavaScript needed)
- Animations are handled by CSS transitions
- No performance overhead
### 4. Styling
- Use DaisyUI theme colors: `bg-base-200`, `text-base-content`
- Maintain consistent spacing: `p-4`, `gap-2`
- Use DaisyUI menu component for navigation: `<ul class="menu">`
### 5. Content Structure
```html
<div class="drawer-content flex flex-col">
<!-- Navbar (if needed) -->
<div class="navbar">...</div>
<!-- Main content with flex-1 to fill space -->
<div class="flex-1 p-6">
<!-- Your content -->
</div>
<!-- Footer (if needed) -->
<footer>...</footer>
</div>
```
## Common Patterns
### Pattern 1: Drawer with Close Button
```html
<div class="drawer-side">
<label for="drawer" class="drawer-overlay"></label>
<aside class="bg-base-200 w-80 min-h-full relative">
<!-- Close button (mobile only) -->
<label for="drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 lg:hidden"></label>
<!-- Sidebar content -->
<ul class="menu p-4 pt-12">
<li><a>Item 1</a></li>
</ul>
</aside>
</div>
```
### Pattern 2: Drawer with User Profile
```html
<aside class="bg-base-200 w-80 min-h-full flex flex-col">
<!-- Logo -->
<div class="p-4 font-bold text-xl">My App</div>
<!-- Navigation (flex-1 to push footer down) -->
<ul class="menu flex-1 p-4">
<li><a>Dashboard</a></li>
<li><a>Settings</a></li>
</ul>
<!-- User profile footer -->
<div class="p-4 border-t border-base-300">
<div class="flex items-center gap-2">
<div class="avatar">
<div class="w-10 rounded-full">
<img src="/avatar.jpg" alt="User" />
</div>
</div>
<div>
<div class="font-semibold">John Doe</div>
<div class="text-sm opacity-70">john@example.com</div>
</div>
</div>
</div>
</aside>
```
### Pattern 3: Nested Menu with Submenu
```html
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Dashboard</a></li>
<!-- Submenu -->
<li>
<details>
<summary>Products</summary>
<ul>
<li><a>Electronics</a></li>
<li><a>Clothing</a></li>
<li><a>Books</a></li>
</ul>
</details>
</li>
<li><a>Settings</a></li>
</ul>
```
## Troubleshooting
### Issue: Drawer doesn't open on mobile
**Solution**: Check that:
1. Checkbox `id` matches label `for` attribute
2. Checkbox has class `drawer-toggle`
3. You're not using `drawer-open` on mobile breakpoints
### Issue: Drawer overlaps content on desktop
**Solution**:
- Remove `drawer-open` or use responsive variant `lg:drawer-open`
- Ensure you want overlay behavior, not persistent sidebar
### Issue: Overlay not clickable
**Solution**:
- Ensure overlay label has correct `for` attribute
- Check that overlay is not behind other elements (z-index)
### Issue: Content jumps when drawer opens
**Solution**:
- Add `flex flex-col` to `drawer-content`
- Ensure drawer-side width is fixed (e.g., `w-80`)
## Migration from Custom Solutions
If migrating from a custom sidebar implementation:
### Replace custom JavaScript
❌ Before:
```javascript
function toggleDrawer() {
document.getElementById('sidebar').classList.toggle('open');
}
```
✅ After:
```html
<input id="drawer" type="checkbox" class="drawer-toggle" />
<label for="drawer">Toggle</label>
```
### Replace custom CSS
❌ Before:
```css
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
.sidebar.open {
transform: translateX(0);
}
```
✅ After:
```html
<div class="drawer">
<!-- DaisyUI handles all transitions -->
</div>
```
### Replace media query logic
❌ Before:
```css
@media (min-width: 1024px) {
.sidebar { display: block; }
.toggle-button { display: none; }
}
```
✅ After:
```html
<div class="drawer lg:drawer-open">
<label for="drawer" class="lg:hidden">Toggle</label>
</div>
```
## Summary
The DaisyUI drawer pattern provides:
**Zero JavaScript** - Pure CSS solution
**Accessible** - Built-in keyboard support via checkbox
**Responsive** - Easy mobile/desktop variants with Tailwind
**Themeable** - Uses DaisyUI theme colors
**Flexible** - Supports left/right positioning
**Standard** - No custom CSS needed
**Recommended approach**: Use `lg:drawer-open` for desktop with hidden mobile toggle for best responsive experience.

View file

@ -1,747 +0,0 @@
# Sidebar Analysis - Current State
**Erstellt:** 2025-12-16
**Status:** Analyse für Neuimplementierung
**Autor:** Cursor AI Assistant
---
## Executive Summary
Die aktuelle Sidebar-Implementierung verwendet **nicht existierende Custom-CSS-Variants** (`is-drawer-close:` und `is-drawer-open:`), was zu einer defekten Implementierung führt. Die Sidebar ist strukturell basierend auf DaisyUI's Drawer-Komponente, aber die responsive und state-basierte Funktionalität ist nicht funktionsfähig.
**Kritisches Problem:** Die im Code verwendeten Variants `is-drawer-close:*` und `is-drawer-open:*` sind **nicht in Tailwind konfiguriert**, was bedeutet, dass diese Klassen beim Build ignoriert werden.
---
## 1. Dateien-Übersicht
### 1.1 Hauptdateien
| Datei | Zweck | Zeilen | Status |
|-------|-------|--------|--------|
| `lib/mv_web/components/layouts/sidebar.ex` | Sidebar-Komponente (Elixir) | 198 | ⚠️ Verwendet nicht existierende Variants |
| `lib/mv_web/components/layouts/navbar.ex` | Navbar mit Sidebar-Toggle | 48 | ✅ Funktional |
| `lib/mv_web/components/layouts.ex` | Layout-Wrapper mit Drawer | 121 | ✅ Funktional |
| `assets/js/app.js` | JavaScript für Sidebar-Interaktivität | 272 | ✅ Umfangreiche Accessibility-Logik |
| `assets/css/app.css` | CSS-Konfiguration | 103 | ⚠️ Keine Drawer-Variants definiert |
| `assets/tailwind.config.js` | Tailwind-Konfiguration | 75 | ⚠️ Keine Drawer-Variants definiert |
### 1.2 Verwandte Dateien
- `lib/mv_web/components/layouts/root.html.heex` - Root-Layout (minimal, keine Sidebar-Logik)
- `priv/static/images/logo.svg` - Logo (wird vermutlich für Sidebar benötigt)
---
## 2. Aktuelle Struktur
### 2.1 HTML-Struktur (DaisyUI Drawer Pattern)
```html
<!-- In layouts.ex -->
<div class="drawer">
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Navbar mit Toggle-Button -->
<navbar with sidebar-toggle button />
<!-- Hauptinhalt -->
<main>...</main>
</div>
<!-- Sidebar -->
<div class="drawer-side">
<button class="drawer-overlay" onclick="close drawer"></button>
<nav id="main-sidebar">
<!-- Navigation Items -->
</nav>
</div>
</div>
```
**Bewertung:** ✅ Korrekte DaisyUI Drawer-Struktur
### 2.2 Sidebar-Komponente (`sidebar.ex`)
**Struktur:**
```elixir
defmodule MvWeb.Layouts.Sidebar do
attr :current_user, :map
attr :club_name, :string
def sidebar(assigns) do
# Rendert Sidebar mit Navigation, Locale-Selector, Theme-Toggle, User-Menu
end
end
```
**Hauptelemente:**
1. **Drawer Overlay** - Button zum Schließen (Mobile)
2. **Navigation Container** (`<nav id="main-sidebar">`)
3. **Menü-Items** - Members, Users, Contributions (nested), Settings
4. **Footer-Bereich** - Locale-Selector, Theme-Toggle, User-Menu
---
## 3. Custom CSS Variants - KRITISCHES PROBLEM
### 3.1 Verwendete Variants im Code
Die Sidebar verwendet folgende Custom-Variants **extensiv**:
```elixir
# Beispiele aus sidebar.ex
"is-drawer-close:overflow-visible"
"is-drawer-close:w-14 is-drawer-open:w-64"
"is-drawer-close:hidden"
"is-drawer-close:tooltip is-drawer-close:tooltip-right"
"is-drawer-close:w-auto"
"is-drawer-close:justify-center"
"is-drawer-close:dropdown-end"
```
**Gefundene Verwendungen:**
- `is-drawer-close:` - 13 Instanzen in sidebar.ex
- `is-drawer-open:` - 1 Instanz in sidebar.ex
### 3.2 Definition der Variants
**❌ NICHT GEFUNDEN in:**
- `assets/css/app.css` - Enthält nur `phx-*-loading` Variants
- `assets/tailwind.config.js` - Enthält nur `phx-*-loading` Variants
**Fazit:** Diese Variants existieren **nicht** und werden beim Tailwind-Build **ignoriert**!
### 3.3 Vorhandene Variants
Nur folgende Custom-Variants sind tatsächlich definiert:
```css
/* In app.css (Tailwind CSS 4.x Syntax) */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
```
```javascript
/* In tailwind.config.js (Tailwind 3.x Kompatibilität) */
plugin(({addVariant}) => addVariant("phx-click-loading", [...])),
plugin(({addVariant}) => addVariant("phx-submit-loading", [...])),
plugin(({addVariant}) => addVariant("phx-change-loading", [...])),
```
---
## 4. JavaScript-Implementierung
### 4.1 Übersicht
Die JavaScript-Implementierung ist **sehr umfangreich** und fokussiert auf Accessibility:
**Datei:** `assets/js/app.js` (Zeilen 106-270)
**Hauptfunktionalitäten:**
1. ✅ Tabindex-Management für fokussierbare Elemente
2. ✅ ARIA-Attribut-Management (`aria-expanded`)
3. ✅ Keyboard-Navigation (Enter, Space, Escape)
4. ✅ Focus-Management beim Öffnen/Schließen
5. ✅ Dropdown-Integration
### 4.2 Wichtige JavaScript-Funktionen
#### 4.2.1 Tabindex-Management
```javascript
const updateSidebarTabIndex = (isOpen) => {
const allFocusableElements = sidebar.querySelectorAll(
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
)
allFocusableElements.forEach(el => {
if (isOpen) {
// Make focusable when open
el.removeAttribute('tabindex')
} else {
// Remove from tab order when closed
el.setAttribute('tabindex', '-1')
}
})
}
```
**Zweck:** Verhindert, dass Nutzer mit Tab zu unsichtbaren Sidebar-Elementen springen können.
#### 4.2.2 ARIA-Expanded Management
```javascript
const updateAriaExpanded = () => {
const isOpen = drawerToggle.checked
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
}
```
**Zweck:** Informiert Screen-Reader über den Sidebar-Status.
#### 4.2.3 Focus-Management
```javascript
const getFirstFocusableElement = () => {
// Priority: navigation link > other links > other focusable
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]')
// ... fallback logic
}
// On open: focus first element
// On close: focus toggle button
```
**Zweck:** Logische Fokus-Reihenfolge für Keyboard-Navigation.
#### 4.2.4 Keyboard-Shortcuts
```javascript
// ESC to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerToggle.checked) {
drawerToggle.checked = false
sidebarToggle.focus()
}
})
// Enter/Space on toggle button
sidebarToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
// Toggle drawer and manage focus
}
})
```
### 4.3 LiveView Hooks
**Definierte Hooks:**
```javascript
Hooks.CopyToClipboard = { ... } // Clipboard-Funktionalität
Hooks.ComboBox = { ... } // Dropdown-Prävention bei Enter
```
**Sidebar-spezifisch:** Keine Hooks, nur native DOM-Events.
---
## 5. DaisyUI Dependencies
### 5.1 Verwendete DaisyUI-Komponenten
| Komponente | Verwendung | Klassen |
|------------|-----------|---------|
| **Drawer** | Basis-Layout | `drawer`, `drawer-toggle`, `drawer-side`, `drawer-content`, `drawer-overlay` |
| **Menu** | Navigation | `menu`, `menu-title`, `w-64` |
| **Button** | Toggle, User-Menu | `btn`, `btn-ghost`, `btn-square`, `btn-circle` |
| **Avatar** | User-Menu | `avatar`, `avatar-placeholder` |
| **Dropdown** | User-Menu | `dropdown`, `dropdown-top`, `dropdown-end`, `dropdown-content` |
| **Tooltip** | Icon-Tooltips | `tooltip`, `tooltip-right` (via `data-tip`) |
| **Select** | Locale-Selector | `select`, `select-sm` |
| **Toggle** | Theme-Switch | `toggle`, `theme-controller` |
### 5.2 Standard Tailwind-Klassen
**Layout:**
- `flex`, `flex-col`, `items-start`, `justify-center`
- `gap-2`, `gap-4`, `p-4`, `mt-auto`, `w-full`, `w-64`, `min-h-full`
**Sizing:**
- `size-4`, `size-5`, `w-12`, `w-52`
**Colors:**
- `bg-base-100`, `bg-base-200`, `text-neutral-content`
**Typography:**
- `text-lg`, `text-sm`, `font-bold`
**Accessibility:**
- `sr-only`, `focus:outline-none`, `focus:ring-2`, `focus:ring-primary`
---
## 6. Toggle-Button (Navbar)
### 6.1 Implementierung
**Datei:** `lib/mv_web/components/layouts/navbar.ex`
```elixir
<button
type="button"
onclick="document.getElementById('main-drawer').checked = !document.getElementById('main-drawer').checked"
aria-label={gettext("Toggle navigation menu")}
aria-expanded="false"
aria-controls="main-sidebar"
id="sidebar-toggle"
class="mr-2 btn btn-square btn-ghost"
>
<svg><!-- Layout-Panel-Left Icon --></svg>
</button>
```
**Funktionalität:**
- ✅ Togglet Drawer-Checkbox
- ✅ ARIA-Labels vorhanden
- ✅ Keyboard-accessible
- ⚠️ `aria-expanded` wird durch JavaScript aktualisiert
**Icon:** Custom SVG (Layout-Panel-Left mit Chevron-Right)
---
## 7. Responsive Verhalten
### 7.1 Aktuelles Konzept (nicht funktional)
**Versuchte Implementierung:**
- **Desktop (collapsed):** Sidebar mit 14px Breite (`is-drawer-close:w-14`)
- **Desktop (expanded):** Sidebar mit 64px Breite (`is-drawer-open:w-64`)
- **Mobile:** Overlay-Drawer (DaisyUI Standard)
### 7.2 Problem
Da die `is-drawer-*` Variants nicht existieren, gibt es **kein responsives Verhalten**:
- Die Sidebar hat immer eine feste Breite von `w-64`
- Die conditional hiding (`:hidden`, etc.) funktioniert nicht
- Tooltips werden nicht conditional angezeigt
---
## 8. Accessibility-Features
### 8.1 Implementierte Features
| Feature | Status | Implementierung |
|---------|--------|-----------------|
| **ARIA Labels** | ✅ | Alle interaktiven Elemente haben Labels |
| **ARIA Roles** | ✅ | `menubar`, `menuitem`, `menu`, `button` |
| **ARIA Expanded** | ✅ | Wird durch JS dynamisch gesetzt |
| **ARIA Controls** | ✅ | Toggle → Sidebar verknüpft |
| **Keyboard Navigation** | ✅ | Enter, Space, Escape, Tab |
| **Focus Management** | ✅ | Logische Focus-Reihenfolge |
| **Tabindex Management** | ✅ | Verhindert Focus auf hidden Elements |
| **Screen Reader Only** | ✅ | `.sr-only` für visuelle Labels |
| **Focus Indicators** | ✅ | `focus:ring-2 focus:ring-primary` |
| **Skip Links** | ❌ | Nicht vorhanden |
### 8.2 Accessibility-Score
**Geschätzt:** 90/100 (WCAG 2.1 Level AA konform)
**Verbesserungspotenzial:**
- Skip-Link zur Hauptnavigation hinzufügen
- High-Contrast-Mode testen
---
## 9. Menü-Struktur
### 9.1 Navigation Items
```
📋 Main Menu
├── 👥 Members (/members)
├── 👤 Users (/users)
├── 💰 Contributions (collapsed submenu)
│ ├── Plans (/contribution_types)
│ └── Settings (/contribution_settings)
└── ⚙️ Settings (/settings)
🔽 Footer Area (logged in only)
├── 🌐 Locale Selector (DE/EN)
├── 🌓 Theme Toggle (Light/Dark)
└── 👤 User Menu (Dropdown)
├── Profile (/users/:id)
└── Logout (/sign-out)
```
### 9.2 Conditional Rendering
**Nicht eingeloggt:**
- Sidebar ist leer (nur Struktur)
- Keine Menü-Items
**Eingeloggt:**
- Vollständige Navigation
- Footer-Bereich mit User-Menu
### 9.3 Nested Menu (Contributions)
**Problem:** Das Contributions-Submenu ist **immer versteckt** im collapsed State:
```elixir
<li class="is-drawer-close:hidden" role="none">
<h2 class="flex items-center gap-2 menu-title">
<.icon name="hero-currency-dollar" />
{gettext("Contributions")}
</h2>
<ul role="menu">
<li class="is-drawer-close:hidden">...</li>
<li class="is-drawer-close:hidden">...</li>
</ul>
</li>
```
Da `:hidden` nicht funktioniert, wird das Submenu immer angezeigt.
---
## 10. Theme-Funktionalität
### 10.1 Theme-Toggle
```elixir
<input
type="checkbox"
value="dark"
class="toggle theme-controller"
aria-label={gettext("Toggle dark mode")}
/>
```
**Funktionalität:**
- ✅ DaisyUI `theme-controller` - automatische Theme-Umschaltung
- ✅ Persistence durch `localStorage` (siehe root.html.heex Script)
- ✅ Icon-Wechsel (Sun ↔ Moon)
### 10.2 Definierte Themes
**Datei:** `assets/css/app.css`
1. **Light Theme** (default)
- Base: `oklch(98% 0 0)`
- Primary: `oklch(70% 0.213 47.604)` (Orange/Phoenix-inspiriert)
2. **Dark Theme**
- Base: `oklch(30.33% 0.016 252.42)`
- Primary: `oklch(58% 0.233 277.117)` (Purple/Elixir-inspiriert)
---
## 11. Locale-Funktionalität
### 11.1 Locale-Selector
```elixir
<form method="post" action="/set_locale">
<select
id="locale-select-sidebar"
name="locale"
onchange="this.form.submit()"
class="select select-sm w-full is-drawer-close:w-auto"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</form>
```
**Funktionalität:**
- ✅ POST zu `/set_locale` Endpoint
- ✅ CSRF-Token included
- ✅ Auto-Submit on change
- ✅ Accessible Label (`.sr-only`)
---
## 12. Probleme und Defekte
### 12.1 Kritische Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Nicht existierende CSS-Variants** | 🔴 Kritisch | `is-drawer-close:*` und `is-drawer-open:*` sind nicht definiert |
| **Keine responsive Funktionalität** | 🔴 Kritisch | Sidebar verhält sich nicht wie geplant |
| **Conditional Styles funktionieren nicht** | 🔴 Kritisch | Hidden/Tooltip/Width-Changes werden ignoriert |
### 12.2 Mittlere Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Kein Logo** | 🟡 Mittel | Logo-Element fehlt komplett in der Sidebar |
| **Submenu immer sichtbar** | 🟡 Mittel | Contributions-Submenu sollte in collapsed State versteckt sein |
| **Toggle-Icon statisch** | 🟡 Mittel | Icon ändert sich nicht zwischen expanded/collapsed |
### 12.3 Kleinere Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Code-Redundanz** | 🟢 Klein | Variants in beiden Tailwind-Configs (3.x und 4.x) |
| **Inline-onclick Handler** | 🟢 Klein | Sollten durch JS-Events ersetzt werden |
| **Keine Skip-Links** | 🟢 Klein | Accessibility-Verbesserung |
---
## 13. Abhängigkeiten
### 13.1 Externe Abhängigkeiten
| Dependency | Version | Verwendung |
|------------|---------|------------|
| **DaisyUI** | Latest (vendor) | Drawer, Menu, Button, etc. |
| **Tailwind CSS** | 4.0.9 | Utility-Klassen |
| **Heroicons** | v2.2.0 | Icons in Navigation |
| **Phoenix LiveView** | ~> 1.1.0 | Backend-Integration |
### 13.2 Interne Abhängigkeiten
| Modul | Verwendung |
|-------|-----------|
| `MvWeb.Gettext` | Internationalisierung |
| `Mv.Membership.get_settings()` | Club-Name abrufen |
| `MvWeb.CoreComponents` | Icons, Links |
---
## 14. Code-Qualität
### 14.1 Positives
- ✅ **Sehr gute Accessibility-Implementierung**
- ✅ **Saubere Modulstruktur** (Separation of Concerns)
- ✅ **Gute Dokumentation** (Moduledocs, Attribute docs)
- ✅ **Internationalisierung** vollständig implementiert
- ✅ **ARIA-Best-Practices** befolgt
- ✅ **Keyboard-Navigation** umfassend
### 14.2 Verbesserungsbedarf
- ❌ **Broken CSS-Variants** (Hauptproblem)
- ❌ **Fehlende Tests** (keine Component-Tests gefunden)
- ⚠️ **Inline-JavaScript** in onclick-Attributen
- ⚠️ **Magic-IDs** (`main-drawer`, `sidebar-toggle`) hardcoded
- ⚠️ **Komplexe JavaScript-Logik** ohne Dokumentation
---
## 15. Empfehlungen für Neuimplementierung
### 15.1 Sofort-Maßnahmen
1. **CSS-Variants entfernen**
- Alle `is-drawer-close:*` und `is-drawer-open:*` entfernen
- Durch Standard-Tailwind oder DaisyUI-Mechanismen ersetzen
2. **Logo hinzufügen**
- Logo-Element als erstes Element in Sidebar
- Konsistente Größe (32px / size-8)
3. **Toggle-Icon implementieren**
- Icon-Swap zwischen Chevron-Left und Chevron-Right
- Nur auf Desktop sichtbar
### 15.2 Architektur-Entscheidungen
1. **Responsive Strategie:**
- **Mobile:** Standard DaisyUI Drawer (Overlay)
- **Desktop:** Persistent Sidebar mit fester Breite
- **Kein collapsing auf Desktop** (einfacher, wartbarer)
2. **State-Management:**
- Drawer-Checkbox für Mobile
- Keine zusätzlichen Custom-Variants
- Standard DaisyUI-Mechanismen verwenden
3. **JavaScript-Refactoring:**
- Hooks statt inline-onclick
- Dokumentierte Funktionen
- Unit-Tests für kritische Logik
### 15.3 Prioritäten
**High Priority:**
1. CSS-Variants-Problem lösen
2. Logo implementieren
3. Basic responsive Funktionalität
**Medium Priority:**
4. Toggle-Icon implementieren
5. Tests schreiben
6. JavaScript refactoren
**Low Priority:**
7. Skip-Links hinzufügen
8. Code-Optimierung
9. Performance-Tuning
---
## 16. Checkliste für Neuimplementierung
### 16.1 Vorbereitung
- [ ] Alle `is-drawer-*` Klassen aus Code entfernen
- [ ] Keine Custom-Variants in CSS/Tailwind definieren
- [ ] DaisyUI-Dokumentation für Drawer studieren
### 16.2 Implementation
- [ ] Logo-Element hinzufügen (size-8, persistent)
- [ ] Toggle-Button mit Icon-Swap (nur Desktop)
- [ ] Mobile: Overlay-Drawer (DaisyUI Standard)
- [ ] Desktop: Persistent Sidebar (w-64)
- [ ] Menü-Items mit korrekten Klassen
- [ ] Submenu-Handling (nested `<ul>`)
### 16.3 Funktionalität
- [ ] Toggle-Funktionalität auf Mobile
- [ ] Accessibility: ARIA, Focus, Keyboard
- [ ] Theme-Toggle funktional
- [ ] Locale-Selector funktional
- [ ] User-Menu-Dropdown funktional
### 16.4 Testing
- [ ] Component-Tests schreiben
- [ ] Accessibility-Tests (axe-core)
- [ ] Keyboard-Navigation testen
- [ ] Screen-Reader testen
- [ ] Responsive Breakpoints testen
### 16.5 Dokumentation
- [ ] Code-Kommentare aktualisieren
- [ ] Component-Docs schreiben
- [ ] README aktualisieren
---
## 17. Technische Details
### 17.1 CSS-Selektoren
**Verwendete IDs:**
- `#main-drawer` - Drawer-Toggle-Checkbox
- `#main-sidebar` - Sidebar-Navigation-Container
- `#sidebar-toggle` - Toggle-Button in Navbar
- `#locale-select-sidebar` - Locale-Dropdown
**Verwendete Klassen:**
- `.drawer-side` - DaisyUI Sidebar-Container
- `.drawer-overlay` - DaisyUI Overlay-Button
- `.drawer-content` - DaisyUI Content-Container
- `.menu` - DaisyUI Menu-Container
- `.is-drawer-close:*` - ❌ NICHT DEFINIERT
- `.is-drawer-open:*` - ❌ NICHT DEFINIERT
### 17.2 Event-Handler
**JavaScript:**
```javascript
drawerToggle.addEventListener("change", ...)
sidebarToggle.addEventListener("click", ...)
sidebarToggle.addEventListener("keydown", ...)
document.addEventListener("keydown", ...) // ESC handler
```
**Inline (zu migrieren):**
```elixir
onclick="document.getElementById('main-drawer').checked = false"
onclick="document.getElementById('main-drawer').checked = !..."
onchange="this.form.submit()"
```
---
## 18. Metriken
### 18.1 Code-Metriken
| Metrik | Wert |
|--------|------|
| **Zeilen Code (Sidebar)** | 198 |
| **Zeilen JavaScript** | 165 (Sidebar-spezifisch) |
| **Zeilen CSS** | 0 (nur Tailwind-Klassen) |
| **Anzahl Komponenten** | 1 (Sidebar) + 1 (Navbar) |
| **Anzahl Menü-Items** | 6 (inkl. Submenu) |
| **Anzahl Footer-Controls** | 3 (Locale, Theme, User) |
### 18.2 Abhängigkeits-Metriken
| Kategorie | Anzahl |
|-----------|--------|
| **DaisyUI-Komponenten** | 7 |
| **Tailwind-Utility-Klassen** | ~50 |
| **Custom-Variants (broken)** | 2 (`is-drawer-close`, `is-drawer-open`) |
| **JavaScript-Event-Listener** | 6 |
| **ARIA-Attribute** | 12 |
---
## 19. Zusammenfassung
### 19.1 Was funktioniert
✅ **Sehr gute Grundlage:**
- DaisyUI Drawer-Pattern korrekt implementiert
- Exzellente Accessibility (ARIA, Keyboard, Focus)
- Saubere Modulstruktur
- Internationalisierung
- Theme-Switching
- JavaScript-Logik ist robust
### 19.2 Was nicht funktioniert
❌ **Kritische Defekte:**
- CSS-Variants existieren nicht → keine responsive Funktionalität
- Kein Logo
- Kein Toggle-Icon-Swap
- Submenu-Handling defekt
### 19.3 Nächste Schritte
1. **CSS-Variants entfernen** (alle `is-drawer-*` Klassen)
2. **Standard DaisyUI-Pattern verwenden** (ohne Custom-Variants)
3. **Logo hinzufügen** (persistent, size-8)
4. **Simplify:** Mobile = Overlay, Desktop = Persistent (keine collapsed State)
5. **Tests schreiben** (Component + Accessibility)
---
## 20. Anhang
### 20.1 Verwendete CSS-Klassen (alphabetisch)
```
avatar, avatar-placeholder, bg-base-100, bg-base-200, bg-neutral,
btn, btn-circle, btn-ghost, btn-square, cursor-pointer, drawer,
drawer-content, drawer-overlay, drawer-side, drawer-toggle, dropdown,
dropdown-content, dropdown-end, dropdown-top, flex, flex-col,
focus:outline-none, focus:ring-2, focus:ring-primary,
focus-within:outline-none, focus-within:ring-2, gap-2, gap-4,
is-drawer-close:*, is-drawer-open:*, items-center, items-start,
mb-2, menu, menu-sm, menu-title, min-h-full, mr-2, mt-3, mt-auto,
p-2, p-4, rounded-box, rounded-full, select, select-sm, shadow,
shadow-sm, size-4, size-5, sr-only, text-lg, text-neutral-content,
text-sm, theme-controller, toggle, tooltip, tooltip-right, w-12,
w-52, w-64, w-full, z-1
```
### 20.2 Verwendete ARIA-Attribute
```
aria-busy, aria-controls, aria-describedby, aria-expanded,
aria-haspopup, aria-hidden, aria-label, aria-labelledby,
aria-live, role="alert", role="button", role="menu",
role="menubar", role="menuitem", role="none", role="status"
```
### 20.3 Relevante Links
- [DaisyUI Drawer Docs](https://daisyui.com/components/drawer/)
- [Tailwind CSS Custom Variants](https://tailwindcss.com/docs/adding-custom-styles#adding-custom-variants)
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [Phoenix LiveView Docs](https://hexdocs.pm/phoenix_live_view/)
---
**Ende des Berichts**

File diff suppressed because it is too large Load diff

View file

@ -1,233 +0,0 @@
# Analyse der fehlschlagenden Tests
## Übersicht
**Gesamtanzahl fehlschlagender Tests:** 5
- **show_test.exs:** 1 Fehler
- **sidebar_test.exs:** 4 Fehler
---
## Kategorisierung
### Kategorie 1: Test-Assertions passen nicht zur Implementierung (4 Tests)
Diese Tests erwarten bestimmte Werte/Attribute, die in der aktuellen Implementierung anders sind oder fehlen.
### Kategorie 2: Datenbank-Isolation Problem (1 Test)
Ein Test schlägt fehl, weil die Datenbank nicht korrekt isoliert ist.
---
## Detaillierte Analyse
### 1. `show_test.exs` - Custom Fields Sichtbarkeit
**Test:** `does not display Custom Fields section when no custom fields exist` (Zeile 112)
**Problem:**
- Der Test erwartet, dass die "Custom Fields" Sektion NICHT angezeigt wird, wenn keine Custom Fields existieren
- Die Sektion wird aber angezeigt, weil in der Datenbank noch Custom Fields von anderen Tests vorhanden sind
**Ursache:**
- Die LiveView lädt alle Custom Fields aus der Datenbank (Zeile 238-242 in `show.ex`)
- Die Test-Datenbank wird nicht zwischen Tests geleert
- Da `async: false` verwendet wird, sollten die Tests sequenziell laufen, aber Custom Fields bleiben in der Datenbank
**Kategorie:** Datenbank-Isolation Problem
---
### 2. `sidebar_test.exs` - Settings Link
**Test:** `T3.1: renders flat menu items with icons and labels` (Zeile 174)
**Problem:**
- Test erwartet `href="#"` für Settings
- Tatsächlicher Wert: `href="/settings"`
**Ursache:**
- Die Implementierung verwendet einen echten Link `~p"/settings"` (Zeile 100 in `sidebar.ex`)
- Der Test erwartet einen Placeholder-Link `href="#"`
**Kategorie:** Test-Assertion passt nicht zur Implementierung
---
### 3. `sidebar_test.exs` - Drawer Overlay CSS-Klasse
**Test:** `drawer overlay is present` (Zeile 747)
**Problem:**
- Test sucht nach exakt `class="drawer-overlay"`
- Tatsächlicher Wert: `class="drawer-overlay lg:hidden focus:outline-none focus:ring-2 focus:ring-primary"`
**Ursache:**
- Der Test verwendet eine exakte String-Suche (`~s(class="drawer-overlay")`)
- Die Implementierung hat mehrere CSS-Klassen
**Kategorie:** Test-Assertion passt nicht zur Implementierung
---
### 4. `sidebar_test.exs` - Toggle Button ARIA-Attribut
**Test:** `T5.2: toggle button has correct ARIA attributes` (Zeile 324)
**Problem:**
- Test erwartet `aria-controls="main-sidebar"` am Toggle-Button
- Das Attribut fehlt in der Implementierung (Zeile 45-65 in `sidebar.ex`)
**Ursache:**
- Das `aria-controls` Attribut wurde nicht in der Implementierung hinzugefügt
- Der Test erwartet es für bessere Accessibility
**Kategorie:** Test-Assertion passt nicht zur Implementierung (Accessibility-Feature fehlt)
---
### 5. `sidebar_test.exs` - Contribution Settings Link
**Test:** `sidebar structure is complete with all sections` (Zeile 501)
**Problem:**
- Test erwartet Link `/contribution_settings`
- Tatsächlicher Link: `/membership_fee_settings`
**Ursache:**
- Der Test hat eine veraltete/inkorrekte Erwartung
- Die Implementierung verwendet `/membership_fee_settings` (Zeile 96 in `sidebar.ex`)
**Kategorie:** Test-Assertion passt nicht zur Implementierung (veralteter Test)
---
## Lösungsvorschläge
### Lösung 1: `show_test.exs` - Custom Fields Sichtbarkeit
**Option A: Test-Datenbank bereinigen (Empfohlen)**
- Im `setup` Block alle Custom Fields löschen, bevor der Test läuft
- Oder: Explizit prüfen, dass keine Custom Fields existieren
**Option B: Test anpassen**
- Den Test so anpassen, dass er explizit alle Custom Fields löscht
- Oder: Die LiveView-Logik ändern, um nur Custom Fields zu laden, die tatsächlich existieren
**Empfehlung:** Option A - Im Test-Setup alle Custom Fields löschen
```elixir
setup do
# Clean up any existing custom fields
Mv.Membership.CustomField
|> Ash.read!()
|> Enum.each(&Ash.destroy!/1)
# Create test member
{:ok, member} = ...
%{member: member}
end
```
---
### Lösung 2: `sidebar_test.exs` - Settings Link
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um `href="/settings"` zu erwarten statt `href="#"`
**Option B: Implementierung ändern**
- Settings-Link zu `href="#"` ändern (nicht empfohlen, da es ein echter Link sein sollte)
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 190 ändern von:
assert html =~ ~s(href="#")
# zu:
assert html =~ ~s(href="/settings")
```
---
### Lösung 3: `sidebar_test.exs` - Drawer Overlay CSS-Klasse
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um nach der Klasse in der Klasse-Liste zu suchen (mit `has_class?` Helper)
**Option B: Regex verwenden**
- Regex verwenden, um die Klasse zu finden
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 752 ändern von:
assert html =~ ~s(class="drawer-overlay")
# zu:
assert has_class?(html, "drawer-overlay")
```
---
### Lösung 4: `sidebar_test.exs` - Toggle Button ARIA-Attribut
**Option A: Implementierung anpassen (Empfohlen)**
- `aria-controls="main-sidebar"` zum Toggle-Button hinzufügen
**Option B: Test anpassen**
- Test entfernen oder als optional markieren (nicht empfohlen für Accessibility)
**Empfehlung:** Option A - Implementierung anpassen
```elixir
# In sidebar.ex Zeile 45-52, aria-controls hinzufügen:
<button
type="button"
id="sidebar-toggle"
class="hidden lg:flex ml-auto btn btn-ghost btn-sm btn-square"
aria-label={gettext("Toggle sidebar")}
aria-controls="main-sidebar"
aria-expanded="true"
onclick="toggleSidebar()"
>
```
---
### Lösung 5: `sidebar_test.exs` - Contribution Settings Link
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um `/membership_fee_settings` statt `/contribution_settings` zu erwarten
**Option B: Link hinzufügen**
- Einen neuen Link `/contribution_settings` hinzufügen (nicht empfohlen, da redundant)
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 519 ändern von:
"/contribution_settings",
# zu:
# Entfernen oder durch "/membership_fee_settings" ersetzen
# (da "/membership_fee_settings" bereits in Zeile 518 vorhanden ist)
```
---
## Zusammenfassung der empfohlenen Änderungen
1. **show_test.exs:** Custom Fields im Setup löschen
2. **sidebar_test.exs (T3.1):** Settings-Link Assertion anpassen
3. **sidebar_test.exs (drawer overlay):** CSS-Klasse-Suche mit Helper-Funktion
4. **sidebar_test.exs (T5.2):** `aria-controls` Attribut zur Implementierung hinzufügen
5. **sidebar_test.exs (edge cases):** Falschen Link aus erwarteter Liste entfernen
---
## Priorisierung
1. **Hoch:** Lösung 1 (show_test.exs) - Datenbank-Isolation ist wichtig
2. **Mittel:** Lösung 4 (ARIA-Attribut) - Accessibility-Verbesserung
3. **Niedrig:** Lösungen 2, 3, 5 - Einfache Test-Anpassungen

File diff suppressed because it is too large Load diff

View file

@ -42,8 +42,6 @@ defmodule Mv.Membership.Member do
alias Mv.Helpers
require Logger
alias Mv.Membership.Helpers.VisibilityConfig
# Module constants
@member_search_limit 10
@ -648,21 +646,18 @@ defmodule Mv.Membership.Member do
"""
@spec show_in_overview?(atom()) :: boolean()
def show_in_overview?(field) when is_atom(field) do
# exit_date defaults to false (hidden) instead of true
default_visibility = if field == :exit_date, do: false, else: true
case Mv.Membership.get_settings() do
{:ok, settings} ->
visibility_config = settings.member_field_visibility || %{}
# Normalize map keys to atoms (JSONB may return string keys)
normalized_config = VisibilityConfig.normalize(visibility_config)
normalized_config = normalize_visibility_config(visibility_config)
# Get value from normalized config, use field-specific default
Map.get(normalized_config, field, default_visibility)
# Get value from normalized config, default to true
Map.get(normalized_config, field, true)
{:error, _} ->
# If settings can't be loaded, use field-specific default
default_visibility
# If settings can't be loaded, default to visible
true
end
end
@ -1032,6 +1027,29 @@ defmodule Mv.Membership.Member do
defp error_type(error) when is_atom(error), do: error
defp error_type(_), do: :unknown
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
@doc """
Performs fuzzy search on members using PostgreSQL trigram similarity.

View file

@ -57,9 +57,6 @@ defmodule Mv.Membership do
# Settings should be created via seed script
define :update_settings, action: :update
define :update_member_field_visibility, action: :update_member_field_visibility
define :update_single_member_field_visibility,
action: :update_single_member_field_visibility
end
end
@ -92,10 +89,7 @@ defmodule Mv.Membership do
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting
|> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: %{"exit_date" => false}
})
|> Ash.Changeset.for_create(:create, %{club_name: default_club_name})
|> Ash.create!(domain: __MODULE__)
|> then(fn settings -> {:ok, settings} end)
@ -189,42 +183,4 @@ defmodule Mv.Membership do
})
|> Ash.update(domain: __MODULE__)
end
@doc """
Atomically updates a single field in the member field visibility configuration.
This action uses PostgreSQL's jsonb_set function to atomically update a single key
in the JSONB map, preventing lost updates in concurrent scenarios. This is the
preferred method for updating individual field visibility settings.
## Parameters
- `settings` - The settings record to update
- `field` - The member field name as a string (e.g., "street", "house_number")
- `show_in_overview` - Boolean value indicating visibility
## Returns
- `{:ok, updated_settings}` - Successfully updated settings
- `{:error, error}` - Validation or update error
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
iex> {:ok, updated} = Mv.Membership.update_single_member_field_visibility(settings, field: "street", show_in_overview: false)
iex> updated.member_field_visibility["street"]
false
"""
def update_single_member_field_visibility(settings,
field: field,
show_in_overview: show_in_overview
) do
settings
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|> Ash.update(domain: __MODULE__)
end
end

View file

@ -91,16 +91,6 @@ defmodule Mv.Membership.Setting do
accept [:member_field_visibility]
end
update :update_single_member_field_visibility do
description "Atomically updates a single field in the member_field_visibility JSONB map"
require_atomic? false
argument :field, :string, allow_nil?: false
argument :show_in_overview, :boolean, allow_nil?: false
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
end
update :update_membership_fee_settings do
description "Updates the membership fee configuration"
require_atomic? false

View file

@ -1,164 +0,0 @@
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
@moduledoc """
Ash change that atomically updates a single field in the member_field_visibility JSONB map.
This change uses PostgreSQL's jsonb_set function to atomically update a single key
in the JSONB map, preventing lost updates in concurrent scenarios.
## Arguments
- `field` - The member field name as a string (e.g., "street", "house_number")
- `show_in_overview` - Boolean value indicating visibility
## Example
settings
|> Ash.Changeset.for_update(:update_single_member_field_visibility,
%{},
arguments: %{field: "street", show_in_overview: false}
)
|> Ash.update(domain: Mv.Membership)
"""
use Ash.Resource.Change
alias Ash.Error.Invalid
alias Ecto.Adapters.SQL
require Logger
def change(changeset, _opts, _context) do
with {:ok, field} <- get_and_validate_field(changeset),
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview) do
add_after_action(changeset, field, show_in_overview)
else
{:error, updated_changeset} -> updated_changeset
end
end
defp get_and_validate_field(changeset) do
case Ash.Changeset.get_argument(changeset, :field) do
nil ->
{:error,
add_error(changeset,
field: :member_field_visibility,
message: "field argument is required"
)}
field ->
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field in valid_fields do
{:ok, field}
else
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "Invalid member field: #{field}"
)}
end
end
end
defp get_and_validate_boolean(changeset, arg_name) do
case Ash.Changeset.get_argument(changeset, arg_name) do
nil ->
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "#{arg_name} argument is required"
)}
value when is_boolean(value) ->
{:ok, value}
_ ->
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "#{arg_name} must be a boolean"
)}
end
end
defp add_error(changeset, opts) do
Ash.Changeset.add_error(changeset, opts)
end
defp add_after_action(changeset, field, show_in_overview) do
# Use after_action to execute atomic SQL update
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
# Use PostgreSQL jsonb_set for atomic update
# jsonb_set(target, path, new_value, create_missing?)
# path is an array: ['field_name']
# new_value must be JSON: to_jsonb(boolean)
sql = """
UPDATE settings
SET member_field_visibility = jsonb_set(
COALESCE(member_field_visibility, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($2::boolean),
true
)
WHERE id = $3
RETURNING member_field_visibility
"""
# Convert UUID string to binary for PostgreSQL
uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
{:ok, %{rows: [[updated_jsonb] | _]}} ->
updated_visibility = normalize_jsonb_result(updated_jsonb)
# Update the settings struct with the new visibility
updated_settings = %{settings | member_field_visibility: updated_visibility}
{:ok, updated_settings}
{:ok, %{rows: []}} ->
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Failed to update visibility"
)}
end
end)
end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
# Convert atom keys to strings if needed
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
# Not a map after decode
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end

View file

@ -1,49 +0,0 @@
defmodule Mv.Helpers.TypeParsers do
@moduledoc """
Helper functions for parsing various input types to common Elixir types.
Provides safe parsing functions for common type conversions, especially useful
when dealing with form data or external APIs.
"""
@doc """
Parses various input types to boolean.
Handles: booleans, strings ("true"/"false"), integers (1/0), and other values (defaults to false).
## Parameters
- `value` - The value to parse (boolean, string, integer, or other)
## Returns
A boolean value
## Examples
iex> parse_boolean(true)
true
iex> parse_boolean("true")
true
iex> parse_boolean("false")
false
iex> parse_boolean(1)
true
iex> parse_boolean(0)
false
iex> parse_boolean(nil)
false
"""
@spec parse_boolean(any()) :: boolean()
def parse_boolean(value) when is_boolean(value), do: value
def parse_boolean("true"), do: true
def parse_boolean("false"), do: false
def parse_boolean(1), do: true
def parse_boolean(0), do: false
def parse_boolean(_), do: false
end

View file

@ -1,55 +0,0 @@
defmodule Mv.Membership.Helpers.VisibilityConfig do
@moduledoc """
Helper functions for normalizing member field visibility configuration.
Handles conversion between string keys (from JSONB) and atom keys (Elixir convention).
JSONB in PostgreSQL converts atom keys to string keys when storing.
This module provides functions to normalize these back to atoms for Elixir usage.
"""
@doc """
Normalizes visibility config map keys from strings to atoms.
JSONB in PostgreSQL converts atom keys to string keys when storing.
This function converts them back to atoms for Elixir usage.
## Parameters
- `config` - A map with either string or atom keys
## Returns
A map with atom keys (where possible)
## Examples
iex> normalize(%{"first_name" => true, "email" => false})
%{first_name: true, email: false}
iex> normalize(%{first_name: true, email: false})
%{first_name: true, email: false}
iex> normalize(%{"invalid_field" => true})
%{}
"""
@spec normalize(map()) :: map()
def normalize(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
def normalize(_), do: %{}
end

View file

@ -17,7 +17,7 @@ defmodule MvWeb do
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt templates)
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do

View file

@ -692,11 +692,10 @@ defmodule MvWeb.CoreComponents do
"""
attr :name, :string, required: true
attr :class, :string, default: "size-4"
attr :rest, :global, include: ~w(aria-hidden)
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} {@rest} />
<span class={[@name, @class]} />
"""
end

View file

@ -9,7 +9,7 @@ defmodule MvWeb.Layouts do
"""
use MvWeb, :html
use Gettext, backend: MvWeb.Gettext
import MvWeb.Layouts.Sidebar
import MvWeb.Layouts.Navbar
embed_templates "layouts/*"
@ -43,66 +43,20 @@ defmodule MvWeb.Layouts do
slot :inner_block, required: true
def app(assigns) do
club_name = get_club_name()
assigns = assign(assigns, :club_name, club_name)
~H"""
<%= if @current_user do %>
<div
id="app-layout"
class="drawer lg:drawer-open"
data-sidebar-expanded="true"
phx-hook="SidebarState"
>
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col relative z-0">
<!-- Mobile Header (only visible on mobile) -->
<header class="lg:hidden sticky top-0 z-10 navbar bg-base-100 shadow-sm">
<label
for="mobile-drawer"
class="btn btn-square btn-ghost"
aria-label={gettext("Open navigation menu")}
>
<.icon name="hero-bars-3" class="size-6" aria-hidden="true" />
</label>
<span class="font-bold">{@club_name}</span>
</header>
<!-- Main Content (shared between mobile and desktop) -->
<main class="px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto space-y-4 max-full">
{render_slot(@inner_block)}
</div>
</main>
</div>
<div class="drawer-side z-40">
<.sidebar current_user={@current_user} club_name={@club_name} mobile={false} />
</div>
</div>
<% else %>
<!-- Not logged in -->
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full">
{render_slot(@inner_block)}
</div>
</main>
<.navbar current_user={@current_user} club_name={@club_name} />
<% end %>
<main class="px-4 py-20 sm:px-6 lg:px-16">
<div class="mx-auto max-full space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
# Helper function to get club name from settings
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
defp get_club_name do
case Mv.Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
end
end
@doc """
Shows the flash group with standard titles and content.
@ -115,7 +69,7 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end">
<div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} />

View file

@ -0,0 +1,152 @@
defmodule MvWeb.Layouts.Navbar do
@moduledoc """
Navbar that is used in the rootlayout shown on every page
"""
use Phoenix.Component
use Gettext, backend: MvWeb.Gettext
use MvWeb, :verified_routes
alias Mv.Membership
import MvWeb.Authorization
attr :current_user, :map,
required: true,
doc: "The current user - navbar is only shown when user is present"
attr :club_name, :string,
default: nil,
doc: "Optional club name - if not provided, will be loaded from database"
def navbar(assigns) do
club_name = assigns[:club_name] || get_club_name()
assigns = assign(assigns, :club_name, club_name)
~H"""
<header class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<a href="/members" class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li>
<details>
<summary>{gettext("Settings")}</summary>
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<li>
<.link navigate="/settings">{gettext("Global Settings")}</.link>
</li>
<%= if can_access_page?(@current_user, ~p"/admin/roles") do %>
<li>
<.link navigate={~p"/admin/roles"}>{gettext("Roles")}</.link>
</li>
<% end %>
</ul>
</details>
</li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
<li>
<details>
<summary>{gettext("Contributions")}</summary>
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<li>
<.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
</li>
<li>
<.link navigate="/membership_fee_settings">
{gettext("Membership Fee Settings")}
</.link>
</li>
</ul>
</details>
</li>
</ul>
</div>
<div class="flex gap-2">
<form method="post" action="/set_locale" class="mr-4">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<label class="sr-only" for="locale-select">{gettext("Select language")}</label>
<select
id="locale-select"
name="locale"
onchange="this.form.submit()"
class="select select-sm"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
<!-- Daisy UI Theme Toggle for dark and light mode-->
<label class="flex cursor-pointer gap-2" aria-label={gettext("Toggle dark mode")}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
</svg>
<input
type="checkbox"
value="dark"
class="toggle theme-controller"
aria-label={gettext("Toggle dark mode")}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</label>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content w-12 rounded-full">
<span>AA</span>
</div>
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
>
<li>
<.link navigate={~p"/users/#{@current_user.id}"}>
{gettext("Profil")}
</.link>
</li>
<li>
<.link navigate={~p"/settings"}>{gettext("Settings")}</.link>
</li>
<li>
<.link href={~p"/sign-out"}>{gettext("Logout")}</.link>
</li>
</ul>
</div>
</div>
</header>
"""
end
# Helper function to get club name from settings
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
defp get_club_name do
case Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
end
end
end

View file

@ -1,317 +0,0 @@
defmodule MvWeb.Layouts.Sidebar do
@moduledoc """
Sidebar navigation component used in the drawer layout
"""
use MvWeb, :html
attr :current_user, :map, default: nil, doc: "The current user"
attr :club_name, :string, required: true, doc: "The name of the club"
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
def sidebar(assigns) do
~H"""
<label
for="mobile-drawer"
aria-label={gettext("Close sidebar")}
class="drawer-overlay lg:hidden focus:outline-none focus:ring-2 focus:ring-primary"
tabindex="-1"
>
</label>
<aside id="main-sidebar" class="sidebar" aria-label={gettext("Main navigation")}>
{sidebar_header(assigns)}
<%= if @current_user do %>
{sidebar_menu(assigns)}
<% end %>
<%= if @current_user do %>
<.sidebar_footer current_user={@current_user} />
<% end %>
</aside>
"""
end
defp sidebar_header(assigns) do
~H"""
<div class="flex items-center gap-3 p-4 border-b border-base-300">
<!-- Logo -->
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<!-- Club Name -->
<span class="menu-label text-lg font-bold truncate">
{@club_name}
</span>
<!-- Toggle Button (Desktop only) -->
<%= unless @mobile do %>
<button
type="button"
id="sidebar-toggle"
class="hidden lg:flex ml-auto btn btn-ghost btn-sm btn-square"
aria-label={gettext("Toggle sidebar")}
aria-controls="main-sidebar"
aria-expanded="true"
onclick="toggleSidebar()"
>
<!-- Expanded Icon (Chevron Left) -->
<.icon
name="hero-chevron-left"
class="size-5 sidebar-expanded-icon"
aria-hidden="true"
/>
<!-- Collapsed Icon (Chevron Right) -->
<.icon
name="hero-chevron-right"
class="size-5 sidebar-collapsed-icon"
aria-hidden="true"
/>
</button>
<% end %>
</div>
"""
end
defp sidebar_menu(assigns) do
~H"""
<ul class="menu flex-1 w-full p-2" role="menubar">
<.menu_item
href={~p"/members"}
icon="hero-users"
label={gettext("Members")}
/>
<.menu_item
href={~p"/users"}
icon="hero-user-circle"
label={gettext("Users")}
/>
<.menu_item
href={~p"/custom_field_values"}
icon="hero-rectangle-group"
label={gettext("Custom Fields")}
/>
<!-- Nested Menu: Contributions -->
<.menu_group
icon="hero-currency-dollar"
label={gettext("Contributions")}
>
<.menu_subitem href="/contribution_types" label={gettext("Contribution Types")} />
<.menu_subitem href="/membership_fee_settings" label={gettext("Settings")} />
</.menu_group>
<.menu_item
href={~p"/settings"}
icon="hero-cog-6-tooth"
label={gettext("Settings")}
/>
</ul>
"""
end
attr :href, :string, required: true, doc: "Navigation path"
attr :icon, :string, required: true, doc: "Heroicon name"
attr :label, :string, required: true, doc: "Menu item label"
defp menu_item(assigns) do
~H"""
<li role="none">
<.link
navigate={@href}
class="flex items-center gap-3 tooltip tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
data-tip={@label}
role="menuitem"
>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="menu-label">{@label}</span>
</.link>
</li>
"""
end
attr :icon, :string, required: true, doc: "Heroicon name for the menu group"
attr :label, :string, required: true, doc: "Menu group label"
slot :inner_block, required: true, doc: "Submenu items"
defp menu_group(assigns) do
~H"""
<li role="none" class="menu-group">
<!-- Expanded Mode: Details/Summary -->
<details class="expanded-menu-group">
<summary
class="flex items-center gap-3 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
role="menuitem"
aria-haspopup="true"
>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="menu-label">{@label}</span>
</summary>
<ul role="menu" class="ml-4">
{render_slot(@inner_block)}
</ul>
</details>
<!-- Collapsed Mode: Dropdown -->
<div class="collapsed-menu-group dropdown dropdown-right">
<button
type="button"
tabindex="0"
class="flex items-center w-full p-2 rounded-lg hover:bg-base-300 tooltip tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
data-tip={@label}
aria-haspopup="menu"
aria-label={@label}
>
<.icon name={@icon} class="size-5" aria-hidden="true" />
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-50 min-w-48 p-2 focus:outline-none"
role="menu"
>
<li class="menu-title">{@label}</li>
{render_slot(@inner_block)}
</ul>
</div>
</li>
"""
end
attr :href, :string, required: true, doc: "Navigation path for submenu item"
attr :label, :string, required: true, doc: "Submenu item label"
defp menu_subitem(assigns) do
~H"""
<li role="none">
<.link
navigate={@href}
role="menuitem"
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
{@label}
</.link>
</li>
"""
end
attr :current_user, :map, default: nil, doc: "The current user"
defp sidebar_footer(assigns) do
~H"""
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
<!-- Language Selector (nur expanded) -->
<form method="post" action={~p"/set_locale"} class="expanded-only">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
<!-- Theme Toggle (immer sichtbar) -->
<.theme_toggle />
<!-- User Menu (nur wenn current_user existiert) -->
<%= if @current_user do %>
<.user_menu current_user={@current_user} />
<% end %>
</div>
"""
end
defp theme_toggle(assigns) do
~H"""
<label
class="flex items-center gap-2 cursor-pointer justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
aria-label={gettext("Toggle dark mode")}
>
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
<input
type="checkbox"
value="dark"
class="toggle toggle-sm theme-controller focus:outline-none"
aria-label={gettext("Toggle dark mode")}
/>
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
</label>
"""
end
attr :current_user, :map, default: nil, doc: "The current user"
defp user_menu(assigns) do
# Defensive check: ensure current_user and email exist
# current_user might be a struct, so we need to handle both maps and structs
# email might be an Ash.CiString, so we use to_string/1 (not String.to_string/1)
email =
case assigns.current_user do
nil -> ""
%{email: email_val} when not is_nil(email_val) -> to_string(email_val)
_ -> ""
end
first_letter =
if email != "" do
String.first(email) |> String.upcase()
else
"?"
end
user_id =
case assigns.current_user do
nil -> nil
%{id: id_val} when not is_nil(id_val) -> id_val
_ -> nil
end
# Store computed values in assigns to avoid HEEx warnings
assigns = assign(assigns, :email, email)
assigns = assign(assigns, :first_letter, first_letter)
assigns = assign(assigns, :user_id, user_id)
~H"""
<div class="dropdown dropdown-top dropdown-end w-full">
<button
type="button"
tabindex="0"
class="user-menu-button flex items-center w-full justify-start gap-3 px-4 h-12 min-h-12 rounded-lg cursor-pointer focus:outline-none"
aria-label={gettext("User menu")}
aria-haspopup="menu"
>
<!-- Avatar: Placeholder with first letter -->
<div class="avatar placeholder shrink-0">
<div class="w-8 h-8 rounded-full bg-neutral text-neutral-content">
<span class="text-sm font-semibold">
{@first_letter}
</span>
</div>
</div>
<span class="menu-label truncate flex-1 text-left">{@email}</span>
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-52 p-2 focus:outline-none absolute z-[100]"
role="menu"
>
<li role="none">
<%= if @user_id do %>
<.link
navigate={~p"/users/#{@user_id}"}
role="menuitem"
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
{gettext("Profile")}
</.link>
<% else %>
<span class="opacity-50">{gettext("Profile")}</span>
<% end %>
</li>
<li role="none">
<.link
href={~p"/sign-out"}
role="menuitem"
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
{gettext("Logout")}
</.link>
</li>
</ul>
</div>
"""
end
end

View file

@ -78,12 +78,6 @@ defmodule MvWeb.AuthController do
end
end
# Catch-all clause for any other error types
defp handle_rauthy_failure(conn, reason) do
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
end
# Handle generic AuthenticationFailed errors
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do

View file

@ -1,59 +0,0 @@
defmodule MvWeb.Helpers.FieldTypeFormatter do
@moduledoc """
Helper functions for formatting field types for display.
Handles both Ash type modules (e.g., `Ash.Type.String`) and simple atoms (e.g., `:string`).
"""
alias MvWeb.Translations.FieldTypes
@doc """
Formats an Ash type for display.
Handles both Ash type modules (e.g., `Ash.Type.String`) and simple atoms (e.g., `:string`).
## Parameters
- `type` - An atom or module representing the field type
## Returns
A human-readable string representation of the type
## Examples
iex> format(:string)
"String"
iex> format(Ash.Type.String)
"String"
iex> format(Ash.Type.Date)
"Date"
"""
@spec format(atom() | module()) :: String.t()
def format(type) when is_atom(type) do
type_string = to_string(type)
if String.contains?(type_string, "Ash.Type.") do
type_string
|> String.split(".")
|> List.last()
|> String.downcase()
|> then(fn type_name ->
try do
type_atom = String.to_existing_atom(type_name)
FieldTypes.label(type_atom)
rescue
ArgumentError -> FieldTypes.label(:string)
end
end)
else
FieldTypes.label(type)
end
end
def format(type) do
to_string(type)
end
end

View file

@ -8,9 +8,9 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
@doc """
Formats a decimal amount as currency string.

View file

@ -18,8 +18,6 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
use MvWeb, :live_component
alias MvWeb.Translations.MemberFields
# ---------------------------------------------------------------------------
# UPDATE
# ---------------------------------------------------------------------------
@ -68,7 +66,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
<.dropdown_menu
id="field-visibility-menu"
icon="hero-adjustments-horizontal"
button_label={gettext("Show/Hide Columns")}
button_label={gettext("Columns")}
items={@all_items}
checkboxes={true}
selected={@selected_fields}
@ -155,12 +153,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
defp field_to_string(field) when is_binary(field), do: field
defp format_field_label(field) when is_atom(field) do
MemberFields.label(field)
MvWeb.Translations.MemberFields.label(field)
end
defp format_field_label(field) when is_binary(field) do
case safe_to_existing_atom(field) do
{:ok, atom} -> MemberFields.label(atom)
{:ok, atom} -> MvWeb.Translations.MemberFields.label(atom)
:error -> fallback_label(field)
end
end

View file

@ -115,7 +115,7 @@ defmodule MvWeb.ContributionTypeLive.Index do
<div class="prose prose-sm max-w-none">
<p>
{gettext(
"Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
"Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)}
</p>
<ul>

View file

@ -26,12 +26,12 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
type="button"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to settings")}
aria-label={gettext("Back to custom field overview")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.button>
<h3 class="card-title">
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
{if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")}
</h3>
</div>
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Data Field")}
{gettext("Save Custom Field")}
</.button>
</div>
</.form>

View file

@ -17,170 +17,158 @@ 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">
<p class="text-sm text-base-content/70">
{gettext("These will appear in addition to other data when adding new members.")}
</p>
<div class="ml-auto">
<.button
class="ml-auto"
variant="primary"
phx-click="new_custom_field"
phx-target={@myself}
>
<.icon name="hero-plus" /> {gettext("New Data Field")}
</.button>
</div>
</div>
<%!-- Show form when creating or editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.CustomFieldLive.FormComponent}
id={@form_id}
custom_field={@editing_custom_field}
on_save={
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="custom_fields"
rows={@streams.custom_fields}
row_click={
fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end
}
>
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
{@field_type_label.(custom_field.value_type)}
</:col>
<:col :let={{_id, custom_field}} label={gettext("Description")}>
{custom_field.description}
</:col>
<:col
:let={{_id, custom_field}}
label={gettext("Required")}
class="max-w-[9.375rem] text-center"
>
<span :if={custom_field.required} class="text-base-content font-semibold">
{gettext("Required")}
</span>
<span :if={!custom_field.required} class="text-base-content/70">
{gettext("Optional")}
</span>
</:col>
<:col
:let={{_id, custom_field}}
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={custom_field.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Edit")}
</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
{gettext("Delete")}
</.link>
</:action>
</.table>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<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.",
@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."
)}
</p>
</div>
</div>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation" phx-target={@myself}>
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="w-full input input-bordered"
/>
</form>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
<div id={@id}>
<.form_section title={gettext("Custom Fields")}>
<div class="flex">
<p class="text-sm text-base-content/70">
{gettext("These will appear in addition to other data when adding new members.")}
</p>
<div class="ml-auto">
<.button
class="ml-auto"
variant="primary"
phx-click="new_custom_field"
phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
<.icon name="hero-plus" /> {gettext("New Custom Field")}
</.button>
</div>
</div>
</dialog>
<%!-- Show form when creating or editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.CustomFieldLive.FormComponent}
id={@form_id}
custom_field={@editing_custom_field}
on_save={
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="custom_fields"
rows={@streams.custom_fields}
row_click={
fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end
}
>
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
{@field_type_label.(custom_field.value_type)}
</:col>
<:col :let={{_id, custom_field}} label={gettext("Description")}>
{custom_field.description}
</:col>
<:col
:let={{_id, custom_field}}
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={custom_field.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Edit")}
</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Delete")}
</.link>
</:action>
</.table>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<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.",
@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."
)}
</p>
</div>
</div>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation" phx-target={@myself}>
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="w-full input input-bordered"
/>
</form>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
</div>
</div>
</dialog>
</.form_section>
</div>
"""
end
@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)
# If show_form is explicitly provided in assigns, reset editing state
socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
@ -191,13 +179,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
{:ok,
socket
|> assign(assigns)
@ -212,11 +193,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true
def handle_event("new_custom_field", _params, socket) do
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :custom_fields})
end
{:noreply,
socket
|> assign(:show_form, true)
@ -228,11 +204,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
def handle_event("edit_custom_field", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :custom_fields})
end
{:noreply,
socket
|> assign(:show_form, true)

View file

@ -26,7 +26,7 @@ defmodule MvWeb.CustomFieldValueLive.Show do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Data field value {@custom_field_value.id}
Custom field value {@custom_field_value.id}
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
<:actions>
@ -62,6 +62,6 @@ defmodule MvWeb.CustomFieldValueLive.Show do
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
end
defp page_title(:show), do: "Show data field value"
defp page_title(:edit), do: "Edit data field value"
defp page_title(:show), do: "Show Custom field value"
defp page_title(:edit), do: "Edit Custom field value"
end

View file

@ -31,7 +31,6 @@ defmodule MvWeb.GlobalSettingsLive do
socket
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign_form()}
end
@ -63,21 +62,11 @@ defmodule MvWeb.GlobalSettingsLive do
</.button>
</.form>
</.form_section>
<%!-- Memberdata Section --%>
<.form_section title={gettext("Memberdata")}>
<.live_component
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
<%!-- Custom Fields Section --%>
<.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
/>
</.form_section>
<%!-- Custom Fields Section --%>
<.live_component
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
/>
</Layouts.app>
"""
end
@ -118,14 +107,12 @@ defmodule MvWeb.GlobalSettingsLive do
)
{:noreply,
socket
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
{:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))}
end
@impl true
@ -134,7 +121,7 @@ defmodule MvWeb.GlobalSettingsLive do
put_flash(
socket,
:error,
gettext("Failed to delete data field: %{error}", error: inspect(error))
gettext("Failed to delete custom field: %{error}", error: inspect(error))
)}
end
@ -143,43 +130,6 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end
@impl true
def handle_info({:editing_section_changed, section}, socket) do
{:noreply, assign(socket, :active_editing_section, section)}
end
@impl true
def handle_info({:member_field_saved, _member_field, action}, socket) do
# Reload settings to get updated member_field_visibility
{:ok, updated_settings} = Membership.get_settings()
# Send update to member fields component to close form
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
show_form: false,
settings: updated_settings
)
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
end
@impl true
def handle_info({:member_field_visibility_updated}, socket) do
# Legacy event - reload settings and update component
{:ok, updated_settings} = Membership.get_settings()
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
settings: updated_settings
)
{:noreply, assign(socket, :settings, updated_settings)}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(

View file

@ -1,338 +0,0 @@
defmodule MvWeb.MemberFieldLive.FormComponent do
@moduledoc """
LiveComponent form for editing member field properties (embedded in settings).
## Features
- Edit member field visibility (show_in_overview)
- Display member field information from Member Resource (read-only)
- Restrict editing for email field (only show_in_overview can be changed)
- Real-time validation
- Updates Settings.member_field_visibility atomically
## Props
- `member_field` - The member field atom to edit (e.g., :first_name, :email)
- `settings` - The current Settings resource
- `on_save` - Callback function to call when form is saved
- `on_cancel` - Callback function to call when form is cancelled
## Note
Member fields are technical fields that cannot be changed (name, value_type, description, required).
Only the visibility (show_in_overview) can be modified.
"""
use MvWeb, :live_component
alias Mv.Helpers.TypeParsers
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields
@required_fields [:first_name, :last_name, :email]
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|> assign(:is_email_field?, assigns.member_field == :email)
|> assign(:field_label, MemberFields.label(assigns.member_field))
~H"""
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
<div class="card-body">
<div class="flex items-center gap-4 mb-4">
<.button
type="button"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to Settings")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.button>
<h3 class="card-title">
{gettext("Edit Field: %{field}", field: @field_label)}
</h3>
</div>
<.form
for={@form}
id={@id <> "-form"}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
<div
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Name")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:name].name}
id={@form[:name].id}
value={@field_label}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<div
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Value type")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:value_type].name}
id={@form[:value_type].id}
value={FieldTypeFormatter.format(@field_attributes.value_type)}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Description")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:description].name}
id={@form[:description].id}
value={@form[:description].value}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:description]}
type="text"
label={gettext("Description")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@form[:required].name} value="false" disabled />
<span class="label flex items-center gap-2">
<input
type="checkbox"
name={@form[:required].name}
id={@form[:required].id}
value="true"
checked={@form[:required].value}
disabled
readonly
class="checkbox checkbox-sm"
/>
<span class="flex items-center gap-2">
{gettext("Required")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</span>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:required]}
type="checkbox"
label={gettext("Required")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<.input
field={@form[:show_in_overview]}
type="checkbox"
label={gettext("Show in overview")}
/>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Field")}
</.button>
</div>
</.form>
</div>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
@impl true
def handle_event("validate", %{"member_field" => member_field_params}, socket) do
# For member fields, we only validate show_in_overview
# Other fields are read-only or derived from the Member Resource
form = socket.assigns.form
updated_params =
member_field_params
|> Map.put(
"show_in_overview",
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
)
|> Map.put("name", form.source["name"])
|> Map.put("value_type", form.source["value_type"])
|> Map.put("description", form.source["description"])
|> Map.put("required", form.source["required"])
updated_form =
form
|> Map.put(:value, updated_params)
|> Map.put(:errors, [])
{:noreply, assign(socket, form: updated_form)}
end
@impl true
def handle_event("save", %{"member_field" => member_field_params}, socket) do
# Only show_in_overview can be changed for member fields
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"])
field_string = Atom.to_string(socket.assigns.member_field)
# Use atomic action to update only this single field
# This prevents lost updates in concurrent scenarios
case Membership.update_single_member_field_visibility(
socket.assigns.settings,
field: field_string,
show_in_overview: show_in_overview
) do
{:ok, _updated_settings} ->
socket.assigns.on_save.(socket.assigns.member_field, "update")
{:noreply, socket}
{:error, error} ->
# Add error to form
form =
socket.assigns.form
|> Map.put(:errors, [
%{field: :show_in_overview, message: format_error(error)}
])
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("cancel", _params, socket) do
socket.assigns.on_cancel.()
{:noreply, socket}
end
# Helper functions
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
field_attributes = get_field_attributes(member_field)
visibility_config = settings.member_field_visibility || %{}
normalized_config = VisibilityConfig.normalize(visibility_config)
show_in_overview = Map.get(normalized_config, member_field, true)
# Create a manual form structure with string keys
# Note: immutable is not included as it's not editable for member fields
form_data = %{
"name" => MemberFields.label(member_field),
"value_type" => FieldTypeFormatter.format(field_attributes.value_type),
"description" => field_attributes.description || "",
"required" => field_attributes.required,
"show_in_overview" => show_in_overview
}
form = to_form(form_data, as: "member_field")
assign(socket, form: form)
end
defp get_field_attributes(field) when is_atom(field) do
# Get attribute info from Member Resource
alias Ash.Resource.Info
case Info.attribute(Mv.Membership.Member, field) do
nil ->
# Fallback for fields not in resource (shouldn't happen with Constants)
%{
value_type: :string,
description: nil,
required: field in @required_fields
}
attribute ->
%{
value_type: attribute.type,
description: nil,
required: not attribute.allow_nil?
}
end
end
defp format_error(%Ash.Error.Invalid{} = error) do
Ash.ErrorKind.message(error)
end
defp format_error(error) do
inspect(error)
end
end

View file

@ -1,219 +0,0 @@
defmodule MvWeb.MemberFieldLive.IndexComponent do
@moduledoc """
LiveComponent for managing member field visibility in overview (embedded in settings).
## Features
- List all member fields from Mv.Constants.member_fields()
- Display show_in_overview status as badge (Yes/No)
- Display required status based on actual attribute definitions (allow_nil? false)
- Edit member field properties (expandable form like custom fields)
- Updates Settings.member_field_visibility
"""
use MvWeb, :live_component
alias Ash.Resource.Info
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|> assign(:required?, &required?/1)
~H"""
<div id={@id}>
<p 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."
)}
</p>
<%!-- Show form when editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.MemberFieldLive.FormComponent}
id={@form_id}
member_field={@editing_member_field}
settings={@settings}
on_save={
fn member_field, action ->
send(self(), {:member_field_saved, member_field, action})
end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="member_fields"
rows={@member_fields}
>
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)}
</:col>
<:col :let={{_field_name, field_data}} label={gettext("Value Type")}>
{format_value_type(field_data.field)}
</:col>
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
{field_data.description || ""}
</:col>
<:col
:let={{_field_name, field_data}}
label={gettext("Required")}
class="max-w-[9.375rem] text-center"
>
<span
:if={@required?.(field_data.field)}
class="text-base-content font-semibold"
>
{gettext("Required")}
</span>
<span :if={!@required?.(field_data.field)} class="text-base-content/70">
{gettext("Optional")}
</span>
</:col>
<:col
:let={{_field_name, field_data}}
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={field_data.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<:action :let={{_field_name, field_data}}>
<.link
phx-click="edit_member_field"
phx-value-field={Atom.to_string(field_data.field)}
phx-target={@myself}
>
{gettext("Edit")}
</.link>
</:action>
</.table>
</div>
"""
end
@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)
# If show_form is explicitly provided in assigns, reset editing state
socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
socket
|> assign(:editing_member_field, nil)
|> assign(:form_id, "member-field-form-new")
else
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
|> assign(assigns)
|> assign_new(:settings, fn -> get_settings() end)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|> assign_new(:editing_member_field, fn -> nil end)}
end
@impl true
def handle_event("edit_member_field", %{"field" => field_string}, socket) do
# Validate that the field is a valid member field before converting to atom
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field_string in valid_fields do
field_atom = String.to_existing_atom(field_string)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :member_fields})
end
{:noreply,
socket
|> assign(:show_form, true)
|> assign(:editing_member_field, field_atom)
|> assign(:form_id, "member-field-form-#{field_string}")}
else
{:noreply, socket}
end
end
# Helper functions
defp get_settings do
case Membership.get_settings() do
{:ok, settings} ->
settings
{:error, _} ->
# Return a minimal struct-like map for fallback
# This is only used for initial rendering, actual settings will be loaded properly
%{member_field_visibility: %{}}
end
end
defp get_member_fields_with_visibility(settings) do
member_fields = Mv.Constants.member_fields()
visibility_config = settings.member_field_visibility || %{}
# Normalize visibility config keys to atoms
normalized_config = VisibilityConfig.normalize(visibility_config)
Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_config, field, true)
attribute = Info.attribute(Mv.Membership.Member, field)
%{
field: field,
show_in_overview: show_in_overview,
value_type: (attribute && attribute.type) || :string,
description: nil
}
end)
|> Enum.map(fn field_data ->
{Atom.to_string(field_data.field), field_data}
end)
end
defp format_value_type(field) when is_atom(field) do
case Info.attribute(Mv.Membership.Member, field) do
nil -> FieldTypeFormatter.format(:string)
attribute -> FieldTypeFormatter.format(attribute.type)
end
end
# Check if a field is required by checking the actual attribute definition
defp required?(field) when is_atom(field) do
case Info.attribute(Mv.Membership.Member, field) do
nil -> false
attribute -> not attribute.allow_nil?
end
end
defp required?(_), do: false
end

View file

@ -34,10 +34,10 @@ defmodule MvWeb.MemberLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")

View file

@ -257,24 +257,6 @@
>
{MvWeb.MemberLive.Index.format_date(member.join_date)}
</:col>
<:col
:let={member}
:if={:exit_date in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_exit_date}
field={:exit_date}
label={gettext("Exit Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{MvWeb.MemberLive.Index.format_date(member.exit_date)}
</:col>
<:col
:let={member}
label={gettext("Membership Fee Status")}

View file

@ -20,8 +20,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
3. Default (all fields visible)
"""
alias Mv.Membership.Helpers.VisibilityConfig
@doc """
Gets all available fields for selection.
@ -179,15 +177,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Gets member field visibility from settings
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
# exit_date defaults to false (hidden), all other fields default to true
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
show_in_overview = Map.get(visibility_config, field, true)
Map.put(acc, field_string, show_in_overview)
end)
end
@ -203,6 +199,27 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
# Normalizes visibility config map keys from strings to atoms
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do

View file

@ -18,9 +18,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
require Ash.Query
alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true

View file

@ -19,10 +19,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
require Ash.Query
alias Mv.Membership
alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
@ -119,7 +119,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")}
aria-label={gettext("Delete membership fee type")}
>
<.icon name="hero-trash" class="size-4" />
</button>

View file

@ -27,7 +27,6 @@ defmodule MvWeb.Translations.MemberFields do
def label(:street), do: gettext("Street")
def label(:house_number), do: gettext("House Number")
def label(:postal_code), do: gettext("Postal Code")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
# Fallback for unknown fields
def label(field) do

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -558,39 +558,10 @@ default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
case Membership.get_settings() do
{:ok, existing_settings} ->
# Settings exist, update if club_name is different from env var
# Also ensure exit_date is set to false by default if not already configured
updates =
%{}
|> then(fn acc ->
if existing_settings.club_name != default_club_name,
do: Map.put(acc, :club_name, default_club_name),
else: acc
end)
|> then(fn acc ->
visibility_config = existing_settings.member_field_visibility || %{}
# Ensure exit_date is set to false if not already configured
if not Map.has_key?(visibility_config, "exit_date") and
not Map.has_key?(visibility_config, :exit_date) do
updated_visibility = Map.put(visibility_config, "exit_date", false)
Map.put(acc, :member_field_visibility, updated_visibility)
else
acc
end
end)
if map_size(updates) > 0 do
{:ok, _updated} = Membership.update_settings(existing_settings, updates)
if existing_settings.club_name != default_club_name do
{:ok, _updated} =
Membership.update_settings(existing_settings, %{club_name: default_club_name})
end
{:ok, nil} ->
# Settings don't exist yet, create with exit_date defaulting to false
{:ok, _settings} =
Membership.Setting
|> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: %{"exit_date" => false}
})
|> Ash.create!()
end
IO.puts("✅ Seeds completed successfully!")

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="12" fill="#4f46e5"/>
<text x="50" y="65" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">M</text>
</svg>

Before

Width:  |  Height:  |  Size: 285 B

View file

@ -1,2 +0,0 @@
Vorname;Nachname;E-Mail;Straße;PLZ;Stadt
Max;Mustermann;max.mustermann@example.com;Hauptstraße;10115;Berlin
1 Vorname Nachname E-Mail Straße PLZ Stadt
2 Max Mustermann max.mustermann@example.com Hauptstraße 10115 Berlin

View file

@ -1,2 +0,0 @@
first_name;last_name;email;street;postal_code;city
John;Doe;john.doe@example.com;Main Street;12345;Berlin
1 first_name last_name email street postal_code city
2 John Doe john.doe@example.com Main Street 12345 Berlin

View file

@ -13,17 +13,14 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
alias Mv.Membership.Member
describe "show_in_overview?/1" do
test "returns true for all member fields by default, except exit_date" do
test "returns true for all member fields by default" do
# When no settings exist or member_field_visibility is not configured
# Test with fields from constants
# Note: exit_date defaults to false (hidden) by design
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
expected_visibility = if field == :exit_date, do: false, else: true
assert Member.show_in_overview?(field) == expected_visibility,
"Field #{field} should be #{if expected_visibility, do: "visible", else: "hidden"} by default"
assert Member.show_in_overview?(field) == true,
"Field #{field} should be visible by default"
end)
end
@ -80,72 +77,4 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
end)
end
end
describe "update_single_member_field_visibility/3" do
test "atomically updates a single field in member_field_visibility" do
{:ok, settings} = Mv.Membership.get_settings()
field_string = "street"
# Update single field
{:ok, updated_settings} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: field_string,
show_in_overview: false
)
# Verify the field was updated
assert updated_settings.member_field_visibility[field_string] == false
# Verify other fields are not affected
other_fields =
Mv.Constants.member_fields()
|> Enum.reject(&(&1 == String.to_existing_atom(field_string)))
Enum.each(other_fields, fn field ->
field_string = Atom.to_string(field)
# Fields not explicitly set should default to true (except exit_date)
expected = if field == :exit_date, do: false, else: true
assert Map.get(updated_settings.member_field_visibility, field_string, expected) ==
expected
end)
end
test "returns error for invalid field name" do
{:ok, settings} = Mv.Membership.get_settings()
assert {:error, %Ash.Error.Invalid{errors: [%{field: :member_field_visibility}]}} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: "invalid_field",
show_in_overview: false
)
end
test "handles concurrent updates atomically" do
{:ok, settings} = Mv.Membership.get_settings()
field1 = "street"
field2 = "house_number"
# Simulate concurrent updates by updating different fields
{:ok, updated1} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: field1,
show_in_overview: false
)
{:ok, updated2} =
Mv.Membership.update_single_member_field_visibility(
updated1,
field: field2,
show_in_overview: true
)
# Both fields should be correctly updated
assert updated2.member_field_visibility[field1] == false
assert updated2.member_field_visibility[field2] == true
end
end
end

View file

@ -0,0 +1,106 @@
defmodule MvWeb.Layouts.NavbarTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "navbar profile section" do
test "renders profile button with correct attributes", %{conn: _conn} do
# Setup: Create a user
user = create_test_user(%{email: "test@example.com"})
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Test dropdown structure
assert html =~ "dropdown-content"
assert html =~ "dropdown-end"
assert html =~ ~s(role="button")
# Test profile link
assert html =~ ~s(href="/users/#{user.id}")
assert html =~ "Profil"
end
@tag :skip
# TODO: Implement user initials in navbar avatar - see issue #170
test "shows user initials in avatar", %{conn: _conn} do
# Setup: Create a user with specific email for testing initials
user = create_test_user(%{email: "test.user@example.com"})
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Initials from test.user@example.com
assert html =~ "<span>TU</span>"
end
@tag :skip
# TODO: Implement user initials in navbar avatar - see issue #170
test "shows different initials for OIDC user", %{conn: _conn} do
# Setup: Create OIDC user
user_info = %{
"sub" => "oidc_123",
"preferred_username" => "oidc.user@example.com"
}
oauth_tokens = %{
"access_token" => "test_token",
"id_token" => "test_id_token"
}
user =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_rauthy, %{
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create!(domain: Mv.Accounts)
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Initials from oidc.user@example.com
assert html =~ "<span>OU</span>"
end
test "includes all required navigation items", %{conn: _conn} do
user = create_test_user(%{email: "test@example.com"})
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Check for all required menu items
assert html =~ "Profil"
assert html =~ "Settings"
assert html =~ "Logout"
# Check for correct logout path
assert html =~ ~s(href="/sign-out")
end
test "Settings link navigates to global settings page", %{conn: conn} do
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_oidc_user(conn, user)
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Check that Settings link exists and points to /settings
assert html =~ "Settings"
assert html =~ ~s(href="/settings") || html =~ ~s(navigate="/settings")
# Verify the link actually works by navigating to it
{:ok, _view, settings_html} = live(conn, ~p"/settings")
assert settings_html =~ "Club Settings"
end
end
end

View file

@ -1,850 +0,0 @@
defmodule MvWeb.Layouts.SidebarTest do
@moduledoc """
Unit tests for the Sidebar component.
Tests cover:
- Basic rendering and structure
- Props handling (current_user, club_name, mobile)
- Menu structure (flat and nested items)
- Footer/Profile section
- Accessibility attributes (ARIA labels, roles)
- CSS classes (DaisyUI conformance)
- Icon rendering
- Conditional visibility
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import MvWeb.Layouts.Sidebar
# =============================================================================
# Helper Functions
# =============================================================================
# Returns assigns for an authenticated user with all required attributes.
defp authenticated_assigns(mobile \\ false) do
%{
current_user: %{id: "user-123", email: "test@example.com"},
club_name: "Test Club",
mobile: mobile
}
end
# Returns assigns for a guest user (not authenticated).
defp guest_assigns(mobile \\ false) do
%{
current_user: nil,
club_name: "Test Club",
mobile: mobile
}
end
# Renders the sidebar component with the given assigns.
defp render_sidebar(assigns) do
render_component(&sidebar/1, assigns)
end
# Checks if the HTML contains a specific CSS class.
defp has_class?(html, class) do
html =~ class
end
# =============================================================================
# Group 1: Basic Rendering (T1.1-T1.3)
# =============================================================================
describe "basic rendering" do
test "T1.1: renders sidebar with main navigation structure" do
html = render_sidebar(authenticated_assigns())
# Check for navigation element with correct ID
assert html =~ ~s(id="main-sidebar")
assert html =~ ~s(aria-label="Main navigation")
# Check for sidebar class
assert has_class?(html, "sidebar")
end
test "T1.2: renders logo correctly" do
html = render_sidebar(authenticated_assigns())
# Check for logo image
assert html =~ ~s(src="/images/mila.svg")
assert html =~ ~s(alt="Mila Logo")
# Check logo has correct size class
assert has_class?(html, "size-8")
end
test "T1.3: renders toggle button with correct attributes (desktop only)" do
html = render_sidebar(authenticated_assigns(false))
# Check for toggle button
assert html =~ ~s(id="sidebar-toggle")
assert html =~ "onclick="
# Check for DaisyUI button classes
assert has_class?(html, "btn")
assert has_class?(html, "btn-ghost")
assert has_class?(html, "btn-sm")
assert has_class?(html, "btn-square")
# Check for both toggle icons (expanded and collapsed)
assert has_class?(html, "sidebar-expanded-icon")
assert has_class?(html, "sidebar-collapsed-icon")
end
test "T1.4: does not render toggle button on mobile" do
html = render_sidebar(authenticated_assigns(true))
# Toggle button should not be rendered on mobile
refute html =~ ~s(id="sidebar-toggle")
end
end
# =============================================================================
# Group 2: Props Handling (T2.1-T2.3)
# =============================================================================
describe "props handling" do
test "T2.1: displays club name when provided" do
assigns = %{
current_user: %{id: "user-1", email: "test@example.com"},
club_name: "My Awesome Club",
mobile: false
}
html = render_sidebar(assigns)
assert html =~ "My Awesome Club"
end
test "T2.2: does not render menu items when current_user is nil" do
html = render_sidebar(guest_assigns())
# Navigation links should not be rendered
refute html =~ ~s(href="/members")
refute html =~ ~s(href="/users")
refute html =~ ~s(href="/settings")
refute html =~ ~s(href="/contribution_types")
# Footer section should not be rendered
refute html =~ "locale-select"
refute html =~ "theme-controller"
end
test "T2.3: renders menu items when current_user is present" do
html = render_sidebar(authenticated_assigns())
# Check for Members link
assert html =~ ~s(href="/members")
# Check for Users link
assert html =~ ~s(href="/users")
# Check for Custom Fields link
assert html =~ ~s(href="/custom_field_values")
# Check for Contributions section
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
# Check for Settings link (placeholder)
assert html =~ ~s(href="/settings")
end
test "T2.4: renders sidebar with main-sidebar ID" do
html = render_sidebar(authenticated_assigns(true))
assert html =~ ~s(id="main-sidebar")
end
test "T2.5: renders sidebar with main-sidebar ID on desktop" do
html = render_sidebar(authenticated_assigns(false))
assert html =~ ~s(id="main-sidebar")
end
end
# =============================================================================
# Group 3: Menu Structure (T3.1-T3.3)
# =============================================================================
describe "menu structure" do
test "T3.1: renders flat menu items with icons and labels" do
html = render_sidebar(authenticated_assigns())
# Check for Members link with icon
assert html =~ ~s(href="/members")
assert html =~ "hero-users"
# Check for Users link with icon
assert html =~ ~s(href="/users")
assert html =~ "hero-user-circle"
# Check for Custom Fields link with icon
assert html =~ ~s(href="/custom_field_values")
assert html =~ "hero-rectangle-group"
# Check for Settings link with icon
assert html =~ ~s(href="/settings")
assert html =~ "hero-cog-6-tooth"
# Check for tooltips (data-tip attribute)
assert html =~ "data-tip="
end
test "T3.2: renders nested menu with details element for expanded state" do
html = render_sidebar(authenticated_assigns())
# Check for Contributions section structure with details
assert html =~ "<details"
assert has_class?(html, "expanded-menu-group")
# Check for contribution links
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
end
test "T3.3: renders nested menu with dropdown for collapsed state" do
html = render_sidebar(authenticated_assigns())
# Check for collapsed dropdown container
assert has_class?(html, "collapsed-menu-group")
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-right")
# Check for dropdown-content
assert has_class?(html, "dropdown-content")
# Check for icon button
assert html =~ "hero-currency-dollar"
assert html =~ ~s(aria-haspopup="menu")
end
end
# =============================================================================
# Group 4: Footer/Profile Section (T4.1-T4.3)
# =============================================================================
describe "footer/profile section" do
test "T4.1: renders footer section when user is authenticated" do
html = render_sidebar(authenticated_assigns())
# Check for footer container with mt-auto
assert has_class?(html, "mt-auto")
# Check for language selector form
assert html =~ ~s(action="/set_locale")
# Check for theme toggle
assert has_class?(html, "theme-controller")
# Check for user menu/avatar
assert has_class?(html, "avatar")
end
test "T4.2: renders language selector with form and options" do
html = render_sidebar(authenticated_assigns())
# Check for form with correct action
assert html =~ ~s(action="/set_locale")
assert html =~ ~s(method="post")
# Check for CSRF token
assert html =~ "_csrf_token"
# Check for select element
assert html =~ ~s(name="locale")
# Check for language options
assert html =~ ~s(value="de")
assert html =~ "Deutsch"
assert html =~ ~s(value="en")
assert html =~ "English"
# Check expanded-only class
assert has_class?(html, "expanded-only")
end
test "T4.3: renders user dropdown with profile and logout links" do
assigns = %{
current_user: %{id: "user-456", email: "test@example.com"},
club_name: "Test Club",
mobile: false
}
html = render_sidebar(assigns)
# Check for dropdown container
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-top")
# Check for avatar button
assert html =~ ~s(aria-haspopup="menu")
# Check for profile link (with user ID)
assert html =~ ~s(href="/users/user-456")
# Check for logout link
assert html =~ ~s(href="/sign-out")
# Check for DaisyUI dropdown classes
assert has_class?(html, "dropdown-content")
end
test "T4.4: renders user avatar with placeholder" do
assigns = %{
current_user: %{id: "user-789", email: "alice@example.com"},
club_name: "Test Club",
mobile: false
}
html = render_sidebar(assigns)
# Should have avatar placeholder classes
assert has_class?(html, "avatar")
assert has_class?(html, "placeholder")
assert has_class?(html, "bg-neutral")
assert has_class?(html, "text-neutral-content")
end
end
# =============================================================================
# Group 5: Accessibility Attributes (T5.1-T5.5)
# =============================================================================
describe "accessibility attributes" do
test "T5.1: navigation has correct ARIA label" do
html = render_sidebar(authenticated_assigns())
assert html =~ ~s(aria-label="Main navigation")
end
test "T5.2: toggle button has correct ARIA attributes" do
html = render_sidebar(authenticated_assigns(false))
# Toggle button
assert html =~ ~s(aria-label="Toggle sidebar")
assert html =~ ~s(aria-controls="main-sidebar")
assert html =~ ~s(aria-expanded="true")
end
test "T5.3: menu has correct roles" do
html = render_sidebar(authenticated_assigns())
# Main menu should have menubar role
assert html =~ ~s(role="menubar")
# List items should have role="none"
assert html =~ ~s(role="none")
# Links should have role="menuitem"
assert html =~ ~s(role="menuitem")
end
test "T5.4: nested menu has correct ARIA attributes" do
html = render_sidebar(authenticated_assigns())
# Details summary should have haspopup
assert html =~ ~s(aria-haspopup="true")
# Dropdown button should have haspopup
assert html =~ ~s(aria-haspopup="menu")
# Nested menus should have role="menu"
assert html =~ ~s(role="menu")
end
test "T5.5: icons are hidden from screen readers" do
html = render_sidebar(authenticated_assigns())
# Icons should have aria-hidden="true"
assert html =~ ~s(aria-hidden="true")
end
end
# =============================================================================
# Group 6: CSS Classes - DaisyUI Conformance (T6.1-T6.4)
# =============================================================================
describe "CSS classes - DaisyUI conformance" do
test "T6.1: uses correct DaisyUI menu classes" do
html = render_sidebar(authenticated_assigns())
# menu class on ul
assert has_class?(html, "menu")
end
test "T6.2: uses correct DaisyUI button classes" do
html = render_sidebar(authenticated_assigns(false))
# Button classes
assert has_class?(html, "btn")
assert has_class?(html, "btn-ghost")
assert has_class?(html, "btn-sm")
assert has_class?(html, "btn-square")
end
test "T6.3: uses correct tooltip classes" do
html = render_sidebar(authenticated_assigns())
# Tooltip classes for menu items
assert has_class?(html, "tooltip")
assert has_class?(html, "tooltip-right")
end
test "T6.4: uses correct dropdown classes" do
html = render_sidebar(authenticated_assigns())
# Dropdown classes
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-right")
assert has_class?(html, "dropdown-top")
assert has_class?(html, "dropdown-content")
assert has_class?(html, "rounded-box")
end
end
# =============================================================================
# Group 7: Icon Rendering (T7.1-T7.2)
# =============================================================================
describe "icon rendering" do
test "T7.1: renders hero icons for menu items" do
html = render_sidebar(authenticated_assigns())
# Check for hero icons
assert html =~ "hero-users"
assert html =~ "hero-user-circle"
assert html =~ "hero-rectangle-group"
assert html =~ "hero-currency-dollar"
assert html =~ "hero-cog-6-tooth"
assert html =~ "hero-chevron-left"
assert html =~ "hero-chevron-right"
# Icons should have aria-hidden
assert html =~ ~s(aria-hidden="true")
end
test "T7.2: renders icons for theme toggle" do
html = render_sidebar(authenticated_assigns())
# Theme toggle icons (sun and moon)
assert html =~ "hero-sun"
assert html =~ "hero-moon"
end
end
# =============================================================================
# Group 8: State-dependent classes (T8.1-T8.3)
# =============================================================================
describe "state-dependent classes" do
test "T8.1: expanded-only elements have correct CSS class" do
html = render_sidebar(authenticated_assigns())
# Language form should be expanded-only
assert has_class?(html, "expanded-only")
end
test "T8.2: menu-label class is used for text that hides when collapsed" do
html = render_sidebar(authenticated_assigns())
# Menu labels that hide in collapsed state
assert has_class?(html, "menu-label")
end
test "T8.3: toggle button has state icons" do
html = render_sidebar(authenticated_assigns(false))
# Expanded icon
assert has_class?(html, "sidebar-expanded-icon")
# Collapsed icon
assert has_class?(html, "sidebar-collapsed-icon")
end
end
# =============================================================================
# Additional Edge Cases and Validation
# =============================================================================
describe "edge cases" do
test "handles user with minimal attributes" do
assigns = %{
current_user: %{id: "minimal-user", email: "min@test.com"},
club_name: "Minimal Club",
mobile: false
}
html = render_sidebar(assigns)
# Should render without error
assert html =~ "Minimal Club"
assert html =~ ~s(href="/users/minimal-user")
end
test "handles empty club name" do
assigns = %{
current_user: %{id: "user-1", email: "test@test.com"},
club_name: "",
mobile: false
}
html = render_sidebar(assigns)
# Should render without error
assert html =~ ~s(id="main-sidebar")
end
test "sidebar structure is complete with all sections" do
html = render_sidebar(authenticated_assigns())
# Header section
assert html =~ "Mila Logo"
# Navigation section
assert html =~ ~s(role="menubar")
# Footer section
assert html =~ "theme-controller"
# All expected links
expected_links = [
"/members",
"/users",
"/custom_field_values",
"/contribution_types",
"/membership_fee_settings",
"/sign-out"
]
for link <- expected_links do
assert html =~ ~s(href="#{link}"), "Missing link: #{link}"
end
end
end
# =============================================================================
# Component Tests - sidebar_header/1
# =============================================================================
describe "sidebar_header/1" do
test "renders logo with correct size" do
html = render_sidebar(authenticated_assigns())
# Logo is size-8 (32px)
assert html =~ ~s(src="/images/mila.svg")
assert html =~ ~s(alt="Mila Logo")
assert has_class?(html, "size-8")
# Logo is always visible (no conditional classes)
assert html =~ ~s(<img)
end
test "renders club name" do
assigns = %{
current_user: %{id: "user-1", email: "test@example.com"},
club_name: "My Test Club",
mobile: false
}
html = render_sidebar(assigns)
# Club name is present
assert html =~ "My Test Club"
assert has_class?(html, "menu-label")
end
test "renders toggle button for desktop" do
html = render_sidebar(authenticated_assigns(false))
# Toggle button has both icons
assert has_class?(html, "sidebar-expanded-icon")
assert has_class?(html, "sidebar-collapsed-icon")
# Toggle button is not mobile (hidden on mobile)
assert html =~ ~s(id="sidebar-toggle")
assert html =~ ~s(aria-label="Toggle sidebar")
assert html =~ ~s(aria-expanded="true")
end
test "does not render toggle button on mobile" do
html = render_sidebar(authenticated_assigns(true))
# Toggle button should not be rendered on mobile
refute html =~ ~s(id="sidebar-toggle")
end
end
# =============================================================================
# Component Tests - menu_item/1
# =============================================================================
describe "menu_item/1" do
test "renders icon and label" do
html = render_sidebar(authenticated_assigns())
# Icon is visible
assert html =~ "hero-users"
assert html =~ ~s(aria-hidden="true")
# Label is present
assert html =~ "Members"
assert has_class?(html, "menu-label")
end
test "has tooltip with correct text" do
html = render_sidebar(authenticated_assigns())
# data-tip attribute set
assert html =~ ~s(data-tip="Members")
assert has_class?(html, "tooltip")
assert has_class?(html, "tooltip-right")
end
test "has correct link" do
html = render_sidebar(authenticated_assigns())
# navigate attribute correct (rendered as href)
assert html =~ ~s(href="/members")
assert html =~ ~s(role="menuitem")
end
end
# =============================================================================
# Component Tests - menu_group/1
# =============================================================================
describe "menu_group/1" do
test "renders expanded menu group" do
html = render_sidebar(authenticated_assigns())
# details/summary present
assert html =~ "<details"
assert html =~ "<summary"
assert has_class?(html, "expanded-menu-group")
end
test "renders collapsed menu group" do
html = render_sidebar(authenticated_assigns())
# dropdown present
assert has_class?(html, "collapsed-menu-group")
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-right")
end
test "renders submenu items" do
html = render_sidebar(authenticated_assigns())
# Inner_block items rendered
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
assert html =~ ~s(role="menu")
end
end
# =============================================================================
# Component Tests - sidebar_footer/1
# =============================================================================
describe "sidebar_footer/1" do
test "renders at bottom of sidebar" do
html = render_sidebar(authenticated_assigns())
# mt-auto present
assert has_class?(html, "mt-auto")
end
test "renders theme toggle" do
html = render_sidebar(authenticated_assigns())
# Toggle is always visible
assert has_class?(html, "theme-controller")
assert html =~ "hero-sun"
assert html =~ "hero-moon"
end
test "renders language selector in expanded only" do
html = render_sidebar(authenticated_assigns())
# expanded-only class
assert has_class?(html, "expanded-only")
assert html =~ ~s(action="/set_locale")
end
test "renders user menu with avatar" do
assigns = %{
current_user: %{id: "user-123", email: "alice@example.com"},
club_name: "Test Club",
mobile: false
}
html = render_sidebar(assigns)
# Avatar present
assert has_class?(html, "avatar")
assert has_class?(html, "placeholder")
# First letter correct (A for alice@example.com)
assert html =~ "A"
end
end
# =============================================================================
# Integration Tests - Sidebar State Management
# =============================================================================
describe "sidebar state management" do
test "sidebar starts expanded by default" do
# The data-sidebar-expanded attribute is set in layouts.ex, not in sidebar.ex
# This test verifies the sidebar structure supports the expanded state
html = render_sidebar(authenticated_assigns())
# Sidebar has classes that support expanded state
assert has_class?(html, "menu-label")
assert has_class?(html, "expanded-only")
end
test "toggle button has correct onclick handler" do
html = render_sidebar(authenticated_assigns(false))
# Toggle button has onclick handler
assert html =~ ~r/onclick="toggleSidebar\(\)"/
end
test "no duplicate IDs in layout" do
html = render_sidebar(authenticated_assigns())
# Check that main-sidebar ID appears only once
id_count = html |> String.split(~r/id="main-sidebar"/) |> length() |> Kernel.-(1)
assert id_count == 1, "main-sidebar ID should appear exactly once"
# Check that sidebar-toggle ID appears only once (if present)
if html =~ ~r/id="sidebar-toggle"/ do
toggle_count = html |> String.split(~r/id="sidebar-toggle"/) |> length() |> Kernel.-(1)
assert toggle_count == 1, "sidebar-toggle ID should appear exactly once"
end
end
end
# =============================================================================
# Integration Tests - Mobile Drawer
# =============================================================================
describe "mobile drawer" do
test "mobile header renders on small screens" do
# Mobile header is in layouts.ex, not sidebar.ex
# This test verifies sidebar works correctly with mobile flag
html = render_sidebar(authenticated_assigns(true))
# Sidebar should render without toggle button on mobile
refute html =~ ~s(id="sidebar-toggle")
end
test "drawer overlay is present" do
html = render_sidebar(authenticated_assigns())
# Drawer overlay label for mobile
assert html =~ ~s(for="mobile-drawer")
assert has_class?(html, "drawer-overlay")
assert html =~ ~s(lg:hidden)
end
end
# =============================================================================
# Accessibility Tests - Extended
# =============================================================================
describe "accessibility - extended" do
test "sidebar has aria-label" do
html = render_sidebar(authenticated_assigns())
assert html =~ ~s(aria-label="Main navigation")
end
test "toggle button has aria-label and aria-expanded" do
html = render_sidebar(authenticated_assigns(false))
# aria-label present
assert html =~ ~s(aria-label="Toggle sidebar")
# aria-expanded="true" or "false"
assert html =~ ~s(aria-expanded="true") || html =~ ~s(aria-expanded="false")
end
test "menu items have role attributes" do
html = render_sidebar(authenticated_assigns())
# role="menubar", "menuitem", "menu"
assert html =~ ~s(role="menubar")
assert html =~ ~s(role="menuitem")
assert html =~ ~s(role="menu")
end
test "icons have aria-hidden" do
html = render_sidebar(authenticated_assigns())
# Decorative icons: aria-hidden="true"
# Count occurrences to ensure multiple icons have it
assert html =~ ~s(aria-hidden="true")
end
test "user menu has aria-haspopup" do
html = render_sidebar(authenticated_assigns())
# aria-haspopup="menu"
assert html =~ ~s(aria-haspopup="menu")
end
end
# =============================================================================
# Regression Tests
# =============================================================================
describe "regression tests" do
test "no duplicate profile links" do
html = render_sidebar(authenticated_assigns())
# Only one Profile link in DOM
profile_count = html |> String.split(~s[href="/users/"]) |> length() |> Kernel.-(1)
assert profile_count <= 1, "Should have at most one profile link"
end
test "nested menu has only one hover effect" do
html = render_sidebar(authenticated_assigns())
# Check that menu-group has proper structure
# Both expanded and collapsed versions should be present
assert has_class?(html, "expanded-menu-group")
assert has_class?(html, "collapsed-menu-group")
# Details element should not have duplicate hover classes
# (CSS handles this, but we verify structure)
assert html =~ "<details"
end
test "tooltips only visible when collapsed" do
html = render_sidebar(authenticated_assigns())
# Tooltip classes are present (CSS handles visibility)
assert has_class?(html, "tooltip")
assert has_class?(html, "tooltip-right")
assert html =~ "data-tip="
end
test "user menu dropdown has correct structure" do
html = render_sidebar(authenticated_assigns())
# Dropdown should have proper classes
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-top")
assert has_class?(html, "dropdown-content")
# Should have both Profile and Logout links
assert html =~ "sign-out"
end
end
end

View file

@ -154,7 +154,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|> render_click()
# Should show success message
assert render(view) =~ "Data field deleted successfully"
assert render(view) =~ "Custom field deleted successfully"
# Custom field should be gone from database
assert {:error, _} = Ash.get(CustomField, custom_field.id)

View file

@ -64,21 +64,5 @@ defmodule MvWeb.GlobalSettingsLiveTest do
assert html =~ "must be present"
end
test "displays Memberdata section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
assert html =~ "Memberdata" or html =~ "Member Data"
end
test "displays flash message after member field visibility update", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate member field visibility update
send(view.pid, {:member_field_visibility_updated})
# Check for flash message
assert render(view) =~ "updated" or render(view) =~ "success"
end
end
end

View file

@ -1,124 +0,0 @@
defmodule MvWeb.MemberFieldLive.IndexComponentTest do
@moduledoc """
Tests for MemberFieldLive.IndexComponent.
Tests cover:
- Rendering all member fields from Mv.Constants.member_fields()
- Displaying show_in_overview status as badge (Yes/No)
- Displaying required status for required fields (first_name, last_name, email)
- Current status is displayed based on settings.member_field_visibility
- Default status is "Yes" (visible) when not configured in settings
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership
setup %{conn: conn} do
user = create_test_user(%{email: "admin@example.com"})
conn = conn_with_oidc_user(conn, user)
{:ok, conn: conn, user: user}
end
describe "rendering" do
test "renders all member fields from Constants", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Check that all member fields are displayed
member_fields = Mv.Constants.member_fields()
for field <- member_fields do
field_name = String.replace(Atom.to_string(field), "_", " ") |> String.capitalize()
# Field name should appear in the table (either as label or in some form)
assert html =~ field_name or html =~ Atom.to_string(field)
end
end
test "displays show_in_overview status as badge", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Should have "Show in overview" column header
assert html =~ "Show in overview" or html =~ "Show in Overview"
# Should have badge elements (Yes/No)
assert html =~ "badge" or html =~ "Yes" or html =~ "No"
end
test "displays required status for required fields", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Required fields: first_name, last_name, email
# Should have "Required" column or indicator
assert html =~ "Required" or html =~ "required"
end
test "shows default status as Yes when not configured", %{conn: conn} do
# Ensure settings have no member_field_visibility configured
{:ok, settings} = Membership.get_settings()
{:ok, _updated} =
Membership.update_settings(settings, %{member_field_visibility: %{}})
{:ok, _view, html} = live(conn, ~p"/settings")
# All fields should show as visible (Yes) by default
# Check for "Yes" badge or similar indicator
assert html =~ "Yes" or html =~ "badge-success"
end
test "shows configured visibility status from settings", %{conn: conn} do
# Configure some fields as hidden
{:ok, settings} = Membership.get_settings()
visibility_config = %{"street" => false, "house_number" => false}
{:ok, _updated} =
Membership.update_member_field_visibility(settings, visibility_config)
{:ok, _view, html} = live(conn, ~p"/settings")
# Street and house_number should show as hidden (No)
# Other fields should show as visible (Yes)
assert html =~ "street" or html =~ "Street"
assert html =~ "house_number" or html =~ "House number"
end
end
describe "required fields" do
test "marks first_name as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# first_name should be marked as required
assert html =~ "first_name" or html =~ "First name"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "marks last_name as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# last_name should be marked as required
assert html =~ "last_name" or html =~ "Last name"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "marks email as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# email should be marked as required
assert html =~ "email" or html =~ "Email"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "does not mark optional fields as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Optional fields should not have required indicator
# Check that street (optional) doesn't have required badge
# This test verifies that only required fields show the indicator
assert html =~ "street" or html =~ "Street"
end
end
end

View file

@ -33,7 +33,7 @@ defmodule MvWeb.ProfileNavigationTest do
end
end
describe "sidebar" do
describe "navbar" do
test "renders profile button with correct attributes", %{conn: conn} do
# Setup: Create and login a user
user = create_test_user(%{email: "test@example.com"})
@ -167,8 +167,8 @@ defmodule MvWeb.ProfileNavigationTest do
test "layout shows user data on user profile page", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
# The sidebar (which requires current_user) should be visible
assert html =~ "sidebar"
# The navbar (which requires current_user) should be visible
assert html =~ "navbar"
# Profile button should be visible
assert html =~ "Profil"
# User ID should be in profile link

View file

@ -7,13 +7,13 @@ defmodule MvWeb.MemberLive.ShowTest do
- Custom Fields section visibility (Issue #282 regression test)
- Custom field values formatting
## Note on async
Tests can run with `async: true` because:
- Each test explicitly manages its own custom fields (creates/deletes as needed)
- The SQL Sandbox provides proper isolation between parallel tests
- Custom field cleanup in tests ensures no interference between tests
## Note on async: false
Tests use `async: false` (not `async: true`) to prevent PostgreSQL deadlocks
when creating members and custom fields concurrently. This is intentional and
documented here to avoid confusion in commit messages.
"""
use MvWeb.ConnCase, async: true
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
use Gettext, backend: MvWeb.Gettext
@ -113,22 +113,11 @@ defmodule MvWeb.MemberLive.ShowTest do
conn: conn,
member: member
} do
# Ensure no custom fields exist for this test
# This ensures test isolation even if previous tests created custom fields
existing_custom_fields = Ash.read!(CustomField)
for cf <- existing_custom_fields do
Ash.destroy!(cf)
end
# Verify no custom fields exist
assert Ash.read!(CustomField) == []
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
# Custom Fields section should NOT be visible
refute html =~ gettext("Additional Data Fields")
refute html =~ gettext("Custom Fields")
end
end