Compare commits
1 commit
f8295ccea1
...
be1cf5468a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1cf5468a |
49 changed files with 1411 additions and 8675 deletions
|
|
@ -166,7 +166,7 @@ environment:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: renovate
|
- name: renovate
|
||||||
image: renovate/renovate:42.79
|
image: renovate/renovate:42.78
|
||||||
environment:
|
environment:
|
||||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||||
RENOVATE_TOKEN:
|
RENOVATE_TOKEN:
|
||||||
|
|
|
||||||
|
|
@ -99,213 +99,4 @@
|
||||||
/* Make LiveView wrapper divs transparent for layout */
|
/* Make LiveView wrapper divs transparent for layout */
|
||||||
[data-phx-session] { display: contents }
|
[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 */
|
/* This file is for your main application CSS */
|
||||||
|
|
|
||||||
204
assets/js/app.js
204
assets/js/app.js
|
|
@ -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, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {_csrf_token: csrfToken},
|
params: {_csrf_token: csrfToken},
|
||||||
|
|
@ -139,170 +102,3 @@ liveSocket.connect()
|
||||||
// >> liveSocket.disableLatencySim()
|
// >> liveSocket.disableLatencySim()
|
||||||
window.liveSocket = liveSocket
|
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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ services:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: mv_dev
|
POSTGRES_DB: mv_dev
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- type: volume
|
||||||
|
source: postgres-data
|
||||||
|
target: /var/lib/postgresql/data
|
||||||
|
volume:
|
||||||
|
nocopy: true
|
||||||
ports:
|
ports:
|
||||||
- "5000:5432"
|
- "5000:5432"
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -45,7 +49,9 @@ services:
|
||||||
- rauthy-dev
|
- rauthy-dev
|
||||||
- local
|
- local
|
||||||
volumes:
|
volumes:
|
||||||
- rauthy-data:/app/data
|
- type: volume
|
||||||
|
source: rauthy-data
|
||||||
|
target: /app/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -40,8 +40,6 @@ defmodule Mv.Membership.Member do
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Mv.Membership.Helpers.VisibilityConfig
|
|
||||||
|
|
||||||
# Module constants
|
# Module constants
|
||||||
@member_search_limit 10
|
@member_search_limit 10
|
||||||
|
|
||||||
|
|
@ -602,21 +600,18 @@ defmodule Mv.Membership.Member do
|
||||||
"""
|
"""
|
||||||
@spec show_in_overview?(atom()) :: boolean()
|
@spec show_in_overview?(atom()) :: boolean()
|
||||||
def show_in_overview?(field) when is_atom(field) do
|
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
|
case Mv.Membership.get_settings() do
|
||||||
{:ok, settings} ->
|
{:ok, settings} ->
|
||||||
visibility_config = settings.member_field_visibility || %{}
|
visibility_config = settings.member_field_visibility || %{}
|
||||||
# Normalize map keys to atoms (JSONB may return string keys)
|
# 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
|
# Get value from normalized config, default to true
|
||||||
Map.get(normalized_config, field, default_visibility)
|
Map.get(normalized_config, field, true)
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
# If settings can't be loaded, use field-specific default
|
# If settings can't be loaded, default to visible
|
||||||
default_visibility
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -961,6 +956,29 @@ defmodule Mv.Membership.Member do
|
||||||
defp error_type(error) when is_atom(error), do: error
|
defp error_type(error) when is_atom(error), do: error
|
||||||
defp error_type(_), do: :unknown
|
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 """
|
@doc """
|
||||||
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,6 @@ defmodule Mv.Membership do
|
||||||
# Settings should be created via seed script
|
# Settings should be created via seed script
|
||||||
define :update_settings, action: :update
|
define :update_settings, action: :update
|
||||||
define :update_member_field_visibility, action: :update_member_field_visibility
|
define :update_member_field_visibility, action: :update_member_field_visibility
|
||||||
|
|
||||||
define :update_single_member_field_visibility,
|
|
||||||
action: :update_single_member_field_visibility
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -92,10 +89,7 @@ defmodule Mv.Membership do
|
||||||
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
||||||
|
|
||||||
Mv.Membership.Setting
|
Mv.Membership.Setting
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{club_name: default_club_name})
|
||||||
club_name: default_club_name,
|
|
||||||
member_field_visibility: %{"exit_date" => false}
|
|
||||||
})
|
|
||||||
|> Ash.create!(domain: __MODULE__)
|
|> Ash.create!(domain: __MODULE__)
|
||||||
|> then(fn settings -> {:ok, settings} end)
|
|> then(fn settings -> {:ok, settings} end)
|
||||||
|
|
||||||
|
|
@ -189,42 +183,4 @@ defmodule Mv.Membership do
|
||||||
})
|
})
|
||||||
|> Ash.update(domain: __MODULE__)
|
|> Ash.update(domain: __MODULE__)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -91,16 +91,6 @@ defmodule Mv.Membership.Setting do
|
||||||
accept [:member_field_visibility]
|
accept [:member_field_visibility]
|
||||||
end
|
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
|
update :update_membership_fee_settings do
|
||||||
description "Updates the membership fee configuration"
|
description "Updates the membership fee configuration"
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -692,11 +692,10 @@ defmodule MvWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
attr :name, :string, required: true
|
attr :name, :string, required: true
|
||||||
attr :class, :string, default: "size-4"
|
attr :class, :string, default: "size-4"
|
||||||
attr :rest, :global, include: ~w(aria-hidden)
|
|
||||||
|
|
||||||
def icon(%{name: "hero-" <> _} = assigns) do
|
def icon(%{name: "hero-" <> _} = assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<span class={[@name, @class]} {@rest} />
|
<span class={[@name, @class]} />
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ defmodule MvWeb.Layouts do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :html
|
use MvWeb, :html
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
import MvWeb.Layouts.Sidebar
|
import MvWeb.Layouts.Navbar
|
||||||
|
|
||||||
embed_templates "layouts/*"
|
embed_templates "layouts/*"
|
||||||
|
|
||||||
|
|
@ -43,66 +43,20 @@ defmodule MvWeb.Layouts do
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def app(assigns) do
|
def app(assigns) do
|
||||||
club_name = get_club_name()
|
|
||||||
assigns = assign(assigns, :club_name, club_name)
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<%= if @current_user do %>
|
<%= if @current_user do %>
|
||||||
<div
|
<.navbar current_user={@current_user} club_name={@club_name} />
|
||||||
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>
|
|
||||||
<% end %>
|
<% 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} />
|
<.flash_group flash={@flash} />
|
||||||
"""
|
"""
|
||||||
end
|
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 """
|
@doc """
|
||||||
Shows the flash group with standard titles and content.
|
Shows the flash group with standard titles and content.
|
||||||
|
|
||||||
|
|
@ -115,7 +69,7 @@ defmodule MvWeb.Layouts do
|
||||||
|
|
||||||
def flash_group(assigns) do
|
def flash_group(assigns) do
|
||||||
~H"""
|
~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={:success} flash={@flash} />
|
||||||
<.flash kind={:warning} flash={@flash} />
|
<.flash kind={:warning} flash={@flash} />
|
||||||
<.flash kind={:info} flash={@flash} />
|
<.flash kind={:info} flash={@flash} />
|
||||||
|
|
|
||||||
152
lib/mv_web/components/layouts/navbar.ex
Normal file
152
lib/mv_web/components/layouts/navbar.ex
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -78,12 +78,6 @@ defmodule MvWeb.AuthController do
|
||||||
end
|
end
|
||||||
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
|
# Handle generic AuthenticationFailed errors
|
||||||
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
|
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
|
||||||
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do
|
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -8,9 +8,9 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Formats a decimal amount as currency string.
|
Formats a decimal amount as currency string.
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
|
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
|
|
||||||
alias MvWeb.Translations.MemberFields
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# UPDATE
|
# UPDATE
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -68,7 +66,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
<.dropdown_menu
|
<.dropdown_menu
|
||||||
id="field-visibility-menu"
|
id="field-visibility-menu"
|
||||||
icon="hero-adjustments-horizontal"
|
icon="hero-adjustments-horizontal"
|
||||||
button_label={gettext("Show/Hide Columns")}
|
button_label={gettext("Columns")}
|
||||||
items={@all_items}
|
items={@all_items}
|
||||||
checkboxes={true}
|
checkboxes={true}
|
||||||
selected={@selected_fields}
|
selected={@selected_fields}
|
||||||
|
|
@ -155,12 +153,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
defp field_to_string(field) when is_binary(field), do: field
|
defp field_to_string(field) when is_binary(field), do: field
|
||||||
|
|
||||||
defp format_field_label(field) when is_atom(field) do
|
defp format_field_label(field) when is_atom(field) do
|
||||||
MemberFields.label(field)
|
MvWeb.Translations.MemberFields.label(field)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_field_label(field) when is_binary(field) do
|
defp format_field_label(field) when is_binary(field) do
|
||||||
case safe_to_existing_atom(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)
|
:error -> fallback_label(field)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ defmodule MvWeb.ContributionTypeLive.Index do
|
||||||
<div class="prose prose-sm max-w-none">
|
<div class="prose prose-sm max-w-none">
|
||||||
<p>
|
<p>
|
||||||
{gettext(
|
{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>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,12 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="cancel"
|
phx-click="cancel"
|
||||||
phx-target={@myself}
|
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" />
|
<.icon name="hero-arrow-left" class="w-4 h-4" />
|
||||||
</.button>
|
</.button>
|
||||||
<h3 class="card-title">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save Data Field")}
|
{gettext("Save Custom Field")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div id={@id} class="mt-8">
|
<div id={@id}>
|
||||||
|
<.form_section title={gettext("Custom Fields")}>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
{gettext("These will appear in addition to other data when adding new members.")}
|
{gettext("These will appear in addition to other data when adding new members.")}
|
||||||
|
|
@ -29,7 +30,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
phx-click="new_custom_field"
|
phx-click="new_custom_field"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
>
|
>
|
||||||
<.icon name="hero-plus" /> {gettext("New Data Field")}
|
<.icon name="hero-plus" /> {gettext("New Custom Field")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,19 +68,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
{custom_field.description}
|
{custom_field.description}
|
||||||
</:col>
|
</: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
|
<:col
|
||||||
:let={{_id, custom_field}}
|
:let={{_id, custom_field}}
|
||||||
label={gettext("Show in overview")}
|
label={gettext("Show in overview")}
|
||||||
|
|
@ -102,7 +90,9 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
</:action>
|
</:action>
|
||||||
|
|
||||||
<:action :let={{_id, custom_field}}>
|
<:action :let={{_id, custom_field}}>
|
||||||
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
|
<.link phx-click={
|
||||||
|
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
|
||||||
|
}>
|
||||||
{gettext("Delete")}
|
{gettext("Delete")}
|
||||||
</.link>
|
</.link>
|
||||||
</:action>
|
</:action>
|
||||||
|
|
@ -111,7 +101,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
<%!-- Delete Confirmation Modal --%>
|
<%!-- Delete Confirmation Modal --%>
|
||||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
|
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
|
||||||
|
|
||||||
<div class="py-4 space-y-4">
|
<div class="py-4 space-y-4">
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
|
|
@ -172,15 +162,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
</.form_section>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
# Track previous show_form state to detect when form is closed
|
|
||||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
|
||||||
|
|
||||||
# If show_form is explicitly provided in assigns, reset editing state
|
# If show_form is explicitly provided in assigns, reset editing state
|
||||||
socket =
|
socket =
|
||||||
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
|
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
|
||||||
|
|
@ -191,13 +179,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
# Detect when form is closed (show_form changes from true to false)
|
|
||||||
new_show_form = Map.get(assigns, :show_form, false)
|
|
||||||
|
|
||||||
if previous_show_form and not new_show_form do
|
|
||||||
send(self(), {:editing_section_changed, nil})
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(assigns)
|
|> assign(assigns)
|
||||||
|
|
@ -212,11 +193,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("new_custom_field", _params, socket) do
|
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,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:show_form, true)
|
|> assign(:show_form, true)
|
||||||
|
|
@ -228,11 +204,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
def handle_event("edit_custom_field", %{"id" => id}, socket) do
|
def handle_event("edit_custom_field", %{"id" => id}, socket) do
|
||||||
custom_field = Ash.get!(Mv.Membership.CustomField, id)
|
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,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:show_form, true)
|
|> assign(:show_form, true)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ defmodule MvWeb.CustomFieldValueLive.Show do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.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>
|
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
|
||||||
|
|
||||||
<:actions>
|
<:actions>
|
||||||
|
|
@ -62,6 +62,6 @@ defmodule MvWeb.CustomFieldValueLive.Show do
|
||||||
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
|
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp page_title(:show), do: "Show data field value"
|
defp page_title(:show), do: "Show Custom field value"
|
||||||
defp page_title(:edit), do: "Edit data field value"
|
defp page_title(:edit), do: "Edit Custom field value"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Settings"))
|
|> assign(:page_title, gettext("Settings"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:active_editing_section, nil)
|
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -63,21 +62,11 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</.form_section>
|
</.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 --%>
|
<%!-- Custom Fields Section --%>
|
||||||
<.live_component
|
<.live_component
|
||||||
:if={@active_editing_section != :member_fields}
|
|
||||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||||
id="custom-fields-component"
|
id="custom-fields-component"
|
||||||
/>
|
/>
|
||||||
</.form_section>
|
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -116,14 +105,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
|
||||||
|> assign(:active_editing_section, nil)
|
|
||||||
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -132,7 +119,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
put_flash(
|
put_flash(
|
||||||
socket,
|
socket,
|
||||||
:error,
|
:error,
|
||||||
gettext("Failed to delete data field: %{error}", error: inspect(error))
|
gettext("Failed to delete custom field: %{error}", error: inspect(error))
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -141,43 +128,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
||||||
end
|
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
|
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||||
form =
|
form =
|
||||||
AshPhoenix.Form.for_update(
|
AshPhoenix.Form.for_update(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -31,10 +31,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias MvWeb.MemberLive.Index.Formatter
|
||||||
alias MvWeb.Helpers.DateFormatter
|
alias MvWeb.Helpers.DateFormatter
|
||||||
alias MvWeb.MemberLive.Index.FieldSelection
|
alias MvWeb.MemberLive.Index.FieldSelection
|
||||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||||
|
|
|
||||||
|
|
@ -257,24 +257,6 @@
|
||||||
>
|
>
|
||||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||||
</:col>
|
</: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
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
label={gettext("Membership Fee Status")}
|
label={gettext("Membership Fee Status")}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
3. Default (all fields visible)
|
3. Default (all fields visible)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Mv.Membership.Helpers.VisibilityConfig
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets all available fields for selection.
|
Gets all available fields for selection.
|
||||||
|
|
||||||
|
|
@ -179,15 +177,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
# Gets member field visibility from settings
|
# Gets member field visibility from settings
|
||||||
defp get_member_field_visibility_from_settings(settings) do
|
defp get_member_field_visibility_from_settings(settings) do
|
||||||
visibility_config =
|
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()
|
member_fields = Mv.Constants.member_fields()
|
||||||
|
|
||||||
Enum.reduce(member_fields, %{}, fn field, acc ->
|
Enum.reduce(member_fields, %{}, fn field, acc ->
|
||||||
field_string = Atom.to_string(field)
|
field_string = Atom.to_string(field)
|
||||||
# exit_date defaults to false (hidden), all other fields default to true
|
show_in_overview = Map.get(visibility_config, field, true)
|
||||||
default_visibility = if field == :exit_date, do: false, else: true
|
|
||||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
|
||||||
Map.put(acc, field_string, show_in_overview)
|
Map.put(acc, field_string, show_in_overview)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -203,6 +199,27 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end)
|
end)
|
||||||
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)
|
# 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
|
defp to_field_identifier(field_string) when is_binary(field_string) do
|
||||||
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
|
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -63,7 +63,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
phx-click="delete_all_cycles"
|
phx-click="delete_all_cycles"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-error btn-outline"
|
class="btn btn-sm btn-error btn-outline"
|
||||||
title={gettext("Delete All Cycles")}
|
title={gettext("Delete all cycles")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
{gettext("Delete All Cycles")}
|
{gettext("Delete All Cycles")}
|
||||||
|
|
@ -168,7 +168,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-value-cycle_id={cycle.id}
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-error btn-outline"
|
class="btn btn-sm btn-error btn-outline"
|
||||||
title={gettext("Delete Cycle")}
|
title={gettext("Delete cycle")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
{gettext("Delete")}
|
{gettext("Delete")}
|
||||||
|
|
@ -329,14 +329,16 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
/>
|
/>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt">
|
<span class="label-text-alt">
|
||||||
{gettext("The cycle will be calculated based on this date and the interval.")}
|
{gettext(
|
||||||
|
"The cycle period will be calculated based on this date and the interval."
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<%= if @create_cycle_date do %>
|
<%= if @create_cycle_date do %>
|
||||||
<div class="form-control w-full mt-4">
|
<div class="form-control w-full mt-4">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{gettext("Cycle")}</span>
|
<span class="label-text">{gettext("Cycle Period")}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-sm text-base-content/70">
|
||||||
{format_create_cycle_period(
|
{format_create_cycle_period(
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.Membership.Member
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.Membership
|
||||||
|
alias Mv.Membership.Member
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -115,7 +115,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
phx-value-id={mft.id}
|
phx-value-id={mft.id}
|
||||||
data-confirm={gettext("Are you sure?")}
|
data-confirm={gettext("Are you sure?")}
|
||||||
class="btn btn-ghost btn-xs text-error"
|
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" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ defmodule MvWeb.Translations.MemberFields do
|
||||||
def label(:street), do: gettext("Street")
|
def label(:street), do: gettext("Street")
|
||||||
def label(:house_number), do: gettext("House Number")
|
def label(:house_number), do: gettext("House Number")
|
||||||
def label(:postal_code), do: gettext("Postal Code")
|
def label(:postal_code), do: gettext("Postal Code")
|
||||||
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
|
||||||
|
|
||||||
# Fallback for unknown fields
|
# Fallback for unknown fields
|
||||||
def label(field) do
|
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
|
|
@ -525,39 +525,10 @@ default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
||||||
case Membership.get_settings() do
|
case Membership.get_settings() do
|
||||||
{:ok, existing_settings} ->
|
{:ok, existing_settings} ->
|
||||||
# Settings exist, update if club_name is different from env var
|
# 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
|
if existing_settings.club_name != default_club_name do
|
||||||
updates =
|
{:ok, _updated} =
|
||||||
%{}
|
Membership.update_settings(existing_settings, %{club_name: default_club_name})
|
||||||
|> 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
|
||||||
end)
|
|
||||||
|
|
||||||
if map_size(updates) > 0 do
|
|
||||||
{:ok, _updated} = Membership.update_settings(existing_settings, updates)
|
|
||||||
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
|
end
|
||||||
|
|
||||||
IO.puts("✅ Seeds completed successfully!")
|
IO.puts("✅ Seeds completed successfully!")
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -13,17 +13,14 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
describe "show_in_overview?/1" do
|
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
|
# When no settings exist or member_field_visibility is not configured
|
||||||
# Test with fields from constants
|
# Test with fields from constants
|
||||||
# Note: exit_date defaults to false (hidden) by design
|
|
||||||
member_fields = Mv.Constants.member_fields()
|
member_fields = Mv.Constants.member_fields()
|
||||||
|
|
||||||
Enum.each(member_fields, fn field ->
|
Enum.each(member_fields, fn field ->
|
||||||
expected_visibility = if field == :exit_date, do: false, else: true
|
assert Member.show_in_overview?(field) == true,
|
||||||
|
"Field #{field} should be visible by default"
|
||||||
assert Member.show_in_overview?(field) == expected_visibility,
|
|
||||||
"Field #{field} should be #{if expected_visibility, do: "visible", else: "hidden"} by default"
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -80,72 +77,4 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
|
||||||
end)
|
end)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
106
test/mv_web/components/layouts/navbar_test.exs
Normal file
106
test/mv_web/components/layouts/navbar_test.exs
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -154,7 +154,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should show success message
|
# 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
|
# Custom field should be gone from database
|
||||||
assert {:error, _} = Ash.get(CustomField, custom_field.id)
|
assert {:error, _} = Ash.get(CustomField, custom_field.id)
|
||||||
|
|
|
||||||
|
|
@ -64,21 +64,5 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
||||||
|
|
||||||
assert html =~ "must be present"
|
assert html =~ "must be present"
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -33,7 +33,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "sidebar" do
|
describe "navbar" do
|
||||||
test "renders profile button with correct attributes", %{conn: conn} do
|
test "renders profile button with correct attributes", %{conn: conn} do
|
||||||
# Setup: Create and login a user
|
# Setup: Create and login a user
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
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
|
test "layout shows user data on user profile page", %{conn: conn, user: user} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
|
||||||
# The sidebar (which requires current_user) should be visible
|
# The navbar (which requires current_user) should be visible
|
||||||
assert html =~ "sidebar"
|
assert html =~ "navbar"
|
||||||
# Profile button should be visible
|
# Profile button should be visible
|
||||||
assert html =~ "Profil"
|
assert html =~ "Profil"
|
||||||
# User ID should be in profile link
|
# User ID should be in profile link
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue